From 2627ec7e304d41557329a40972f875524caa2161 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 5 Sep 2026 09:14:25 +0200 Subject: [PATCH] tui: bind bundled agent avatars to named characters --- CHANGELOG.md | 133 +++++++- codex-rs/tui/src/avatars/assets.rs | 308 ++++++++++++++---- codex-rs/tui/src/avatars/binding.rs | 34 ++ .../tui/src/pets/ansi_half_block_tests.rs | 52 +++ ..._tests__renders_bundled_agent_avatars.snap | 73 +++++ 5 files changed, 543 insertions(+), 57 deletions(-) create mode 100644 codex-rs/tui/src/pets/snapshots/codex_tui__pets__ansi_half_block__tests__renders_bundled_agent_avatars.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb564c560..18800ccb62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,132 @@ -The changelog can be found on the [releases page](https://github.com/openai/codex/releases). +# Changelog + +Clanker Code is a fork of [OpenAI Codex](https://github.com/openai/codex). This +file documents what Clanker Code adds **on top of** upstream Codex. + +For upstream Codex changes, see the +[Codex releases page](https://github.com/openai/codex/releases). + +The current release and its exact upstream provenance are recorded in +`codex-rs/config/src/clanker_version.rs`: + +``` +0.1.0+codex.0.143.0-alpha.10.355.g5c19155cbd93 +└─ Clanker └─ upstream Codex release the fork is rebased on +``` + +--- + +## Unreleased + +### Added + +- **Clanker build and install Makefile** — `make` targets for building and + installing the Clanker Code binary locally. + +--- + +## Clanker features on top of Codex + +Everything below is Clanker-specific and has no upstream Codex equivalent. + +### Characters + +A first-class notion of an agent *character* — a named identity with an avatar, +declared aliases, and a validated manifest. + +- **`codex-rs/character/` crate** — character manifest schema + (`CHARACTER_SCHEMA_VERSION = 1`), avatar pack validation, canonical-id and + alias resolution, and a local character registry with global collision + detection. +- **`clanker character` CLI** (`codex-rs/cli/src/character_cmd.rs`): + - `validate` — validate one manifest, or the complete local registry with + `--all` (including cross-manifest name collisions). + - `resolve` — resolve a canonical id or declared alias against the local + registry, with `--materialize` to install a built-in character first. + - Both support emitting a stable machine-readable character contract. +- **Canonical avatar continuity** (#30) — an avatar stays bound to its character + across sessions rather than being re-picked per run. +- **Continuity acceptance contract** (#28) — `scripts/character_continuity_acceptance.py` + plus `scripts/character_continuity/contract.json` and fixtures, enforcing + character/avatar continuity as an executable contract rather than a convention. + +### Avatars & pets (TUI) + +An animated avatar system rendered directly in the terminal UI. + +- **`codex-rs/tui/src/pets/`** — avatar runtime: spritesheet model, animation + frames, the `/pets` picker, live preview, and an ambient/idle driver. +- **Multiple render backends** — ANSI half-block, Sixel, and terminal image + protocols, selected per terminal capability. +- **Talking signal** (`talking_signal.rs`) — drives mouth-flap animation from + an external signal file, so the avatar animates while the agent speaks. +- **`codex-rs/tui/src/avatars/`** — asset loading, character↔avatar binding, and + the avatar runtime that ties a resolved character to a rendered pet. +- **Avatar pack format** — 24×24 frames, 22-frame horizontal spritesheet, + binary alpha, and nine semantic animation states (`idle`, `running`, + `waiting`, `review`, `failed`, `planning`, `tired-idle`, `tired-running`, + `talking`), declared in an `avatar.json` manifest. +- **Bundled character avatars** — `clanker`, `chloe`, `c3ph0`, `hai`, + `clautist`, `jasonelle`, and `centurion` resolve as named characters with a + canonical avatar binding. The underlying packs are also installed into the + pet catalog, including `chloe-r2-09` and its Locked In variant. +- **Reproducible assets** — generator scripts document and reproduce the + Clautist and Centurion avatar sources; the Clautist generator keeps exact + compatibility capture separate from normalized preview art. +- **Custom avatars** — loaded at runtime from `$CODEX_HOME/avatars//avatar.json`; + the bundled default is auto-installed on first use. + +### Character-scoped memories + +Memories are partitioned by character and project instead of being global. + +- **Character-scoped continuity** (#29) — `codex-rs/memories/write/src/scope.rs` + and `codex-rs/ext/memories/src/character_context.rs` resolve a memory scope + from the current thread, character, project, and parent thread. +- **Schema migrations** — `0002_character_memory_scope.sql` adds a + `thread_memory_scopes` table and `clanker_id` / `project_key` / `visibility` + columns; `0003_scoped_phase2.sql` completes the scoped rollout. +- **Visibility model** — memories carry an explicit visibility, with + `anonymous_legacy` preserving pre-migration rows. +- **App-server protocol** — adds `MemoryResetParams` / `MemoryResetScope` so + memory can be reset at a chosen scope. + +### Collaboration modes + +Five Clanker-specific collaboration modes +(`codex-rs/collaboration-mode-templates/templates/`), each replacing any +previously active mode: + +| Mode | Template | +|---|---| +| **LARP** (default) | `larp.md` | +| **Based** | `based.md` | +| **Locked In** | `locked_in.md` | +| **Ultrachill** | `ultrachill.md` | +| **Cringe** | `cringe.md` | + +### Branding + +- **Clanker Code product identity** — `PRODUCT_NAME` and `VERSION` in + `codex-rs/config/src/clanker_version.rs`, surfaced across CLI and TUI. +- **`clanker` binary entrypoint** — `codex-rs/cli/src/clanker.rs`. +- **Version provenance** — the rebase pipeline stamps `VERSION` from the + upstream commit's nearest `rust-v*` tag, tag distance, and 12-char revision, + so every Clanker build states exactly which Codex it derives from. + +### Build & tooling + +- **Reduced Cargo target disk usage** (#31) — `scripts/cargo_target_size.py`. +- **Stabilized dependency refresh integration** (#46). +- **Fork sync workflow** — `.github/workflows/fork-sync.yml`. +- **`clanker-avatar-animations` skill** — `.claude/skills/`, for authoring + animated avatar state sets against the avatar pack contract. + +--- + +## Licensing + +Clanker Code is distributed under the Apache License 2.0, inherited from +upstream Codex. See [`LICENSE`](./LICENSE) and [`NOTICE`](./NOTICE) for +attribution, including code derived from +[Ratatui](https://github.com/ratatui/ratatui) (MIT). diff --git a/codex-rs/tui/src/avatars/assets.rs b/codex-rs/tui/src/avatars/assets.rs index 74113a7a72..838cd04ba5 100644 --- a/codex-rs/tui/src/avatars/assets.rs +++ b/codex-rs/tui/src/avatars/assets.rs @@ -15,6 +15,16 @@ const CHLOE_SHEET: &[u8] = include_bytes!("../../assets/chloe-r2-09/sheet.png"); const CHLOE_LOCKED_IN_MANIFEST: &str = include_str!("../../assets/chloe-r2-09-locked-in/avatar.json"); const CHLOE_LOCKED_IN_SHEET: &[u8] = include_bytes!("../../assets/chloe-r2-09-locked-in/sheet.png"); +const C3PH0_MANIFEST: &str = include_str!("../../assets/c3ph0/avatar.json"); +const C3PH0_SHEET: &[u8] = include_bytes!("../../assets/c3ph0/sheet.png"); +const HAI_MANIFEST: &str = include_str!("../../assets/hai/avatar.json"); +const HAI_SHEET: &[u8] = include_bytes!("../../assets/hai/sheet.png"); +const CLAUTIST_MANIFEST: &str = include_str!("../../assets/clautist/avatar.json"); +const CLAUTIST_SHEET: &[u8] = include_bytes!("../../assets/clautist/sheet.png"); +const JASONELLE_MANIFEST: &str = include_str!("../../assets/jasonelle/avatar.json"); +const JASONELLE_SHEET: &[u8] = include_bytes!("../../assets/jasonelle/sheet.png"); +const CENTURION_MANIFEST: &str = include_str!("../../assets/centurion/avatar.json"); +const CENTURION_SHEET: &[u8] = include_bytes!("../../assets/centurion/sheet.png"); const CLANKER_CHARACTER_MANIFEST: &str = r#"{ "schemaVersion": 1, "id": "clanker", @@ -35,13 +45,58 @@ const CHLOE_CHARACTER_MANIFEST: &str = r#"{ "avatarPlacement": "far_right", "voiceProfile": "cleo" }"#; +const C3PH0_CHARACTER_MANIFEST: &str = r#"{ + "schemaVersion": 1, + "id": "c3ph0", + "displayName": "C-3PH0", + "aliases": ["c-3ph0"], + "avatar": "avatar/default/avatar.json", + "avatarPlacement": "far_right" +}"#; +const HAI_CHARACTER_MANIFEST: &str = r#"{ + "schemaVersion": 1, + "id": "hai", + "displayName": "HAI", + "avatar": "avatar/default/avatar.json", + "avatarPlacement": "far_right" +}"#; +const CLAUTIST_CHARACTER_MANIFEST: &str = r#"{ + "schemaVersion": 1, + "id": "clautist", + "displayName": "Clautist", + "avatar": "avatar/default/avatar.json", + "avatarPlacement": "far_right" +}"#; +const JASONELLE_CHARACTER_MANIFEST: &str = r#"{ + "schemaVersion": 1, + "id": "jasonelle", + "displayName": "Jasonelle", + "avatar": "avatar/default/avatar.json", + "avatarPlacement": "far_right" +}"#; +const CENTURION_CHARACTER_MANIFEST: &str = r#"{ + "schemaVersion": 1, + "id": "centurion", + "displayName": "Centurion", + "avatar": "avatar/default/avatar.json", + "avatarPlacement": "far_right" +}"#; +#[derive(Clone, Copy)] struct BundledAvatar { id: &'static str, manifest: &'static str, sheet: &'static [u8], } +struct BundledCharacter { + id: &'static str, + aliases: &'static [&'static str], + manifest: &'static str, + avatars: &'static [BundledAvatar], + install_eagerly: bool, +} + const BUNDLED_AVATARS: &[BundledAvatar] = &[ BundledAvatar { id: "clanker", @@ -58,6 +113,118 @@ const BUNDLED_AVATARS: &[BundledAvatar] = &[ manifest: CHLOE_LOCKED_IN_MANIFEST, sheet: CHLOE_LOCKED_IN_SHEET, }, + BundledAvatar { + id: "c3ph0", + manifest: C3PH0_MANIFEST, + sheet: C3PH0_SHEET, + }, + BundledAvatar { + id: "hai", + manifest: HAI_MANIFEST, + sheet: HAI_SHEET, + }, + BundledAvatar { + id: "clautist", + manifest: CLAUTIST_MANIFEST, + sheet: CLAUTIST_SHEET, + }, + BundledAvatar { + id: "jasonelle", + manifest: JASONELLE_MANIFEST, + sheet: JASONELLE_SHEET, + }, + BundledAvatar { + id: "centurion", + manifest: CENTURION_MANIFEST, + sheet: CENTURION_SHEET, + }, +]; + +const BUNDLED_CHARACTERS: &[BundledCharacter] = &[ + BundledCharacter { + id: "clanker", + aliases: &["rusty"], + manifest: CLANKER_CHARACTER_MANIFEST, + avatars: &[BundledAvatar { + id: "default", + manifest: CLANKER_MANIFEST, + sheet: CLANKER_SHEET, + }], + install_eagerly: true, + }, + BundledCharacter { + id: "chloe", + aliases: &["cleo"], + manifest: CHLOE_CHARACTER_MANIFEST, + avatars: &[ + BundledAvatar { + id: "default", + manifest: CHLOE_MANIFEST, + sheet: CHLOE_SHEET, + }, + BundledAvatar { + id: "locked-in", + manifest: CHLOE_LOCKED_IN_MANIFEST, + sheet: CHLOE_LOCKED_IN_SHEET, + }, + ], + install_eagerly: true, + }, + BundledCharacter { + id: "c3ph0", + aliases: &["c-3ph0"], + manifest: C3PH0_CHARACTER_MANIFEST, + avatars: &[BundledAvatar { + id: "default", + manifest: C3PH0_MANIFEST, + sheet: C3PH0_SHEET, + }], + install_eagerly: false, + }, + BundledCharacter { + id: "hai", + aliases: &[], + manifest: HAI_CHARACTER_MANIFEST, + avatars: &[BundledAvatar { + id: "default", + manifest: HAI_MANIFEST, + sheet: HAI_SHEET, + }], + install_eagerly: false, + }, + BundledCharacter { + id: "clautist", + aliases: &[], + manifest: CLAUTIST_CHARACTER_MANIFEST, + avatars: &[BundledAvatar { + id: "default", + manifest: CLAUTIST_MANIFEST, + sheet: CLAUTIST_SHEET, + }], + install_eagerly: false, + }, + BundledCharacter { + id: "jasonelle", + aliases: &[], + manifest: JASONELLE_CHARACTER_MANIFEST, + avatars: &[BundledAvatar { + id: "default", + manifest: JASONELLE_MANIFEST, + sheet: JASONELLE_SHEET, + }], + install_eagerly: false, + }, + BundledCharacter { + id: "centurion", + aliases: &[], + manifest: CENTURION_CHARACTER_MANIFEST, + avatars: &[BundledAvatar { + id: "default", + manifest: CENTURION_MANIFEST, + sheet: CENTURION_SHEET, + }], + install_eagerly: false, + }, ]; /// Materialize built-in packs without overwriting an existing user-owned id. @@ -77,75 +244,45 @@ pub(crate) fn ensure_bundled_avatars(codex_home: &Path) -> Result<()> { .with_context(|| format!("write {}", manifest.display()))?; validate_installed_avatar(&avatar_dir)?; } - ensure_bundled_characters(codex_home) -} - -pub(super) fn ensure_bundled_characters(codex_home: &Path) -> Result<()> { - ensure_bundled_character( - codex_home, - "clanker", - CLANKER_CHARACTER_MANIFEST, - &[("default", CLANKER_MANIFEST, CLANKER_SHEET)], - )?; - ensure_bundled_character( - codex_home, - "chloe", - CHLOE_CHARACTER_MANIFEST, - &[ - ("default", CHLOE_MANIFEST, CHLOE_SHEET), - ("locked-in", CHLOE_LOCKED_IN_MANIFEST, CHLOE_LOCKED_IN_SHEET), - ], - )?; + for character in BUNDLED_CHARACTERS + .iter() + .filter(|character| character.install_eagerly) + { + ensure_bundled_character(codex_home, character)?; + } Ok(()) } pub fn ensure_bundled_character_for_name(codex_home: &Path, requested_name: &str) -> Result<()> { - if requested_name.eq_ignore_ascii_case("clanker") - || requested_name.eq_ignore_ascii_case("rusty") - { - return ensure_bundled_character( - codex_home, - "clanker", - CLANKER_CHARACTER_MANIFEST, - &[("default", CLANKER_MANIFEST, CLANKER_SHEET)], - ); - } - if requested_name.eq_ignore_ascii_case("chloe") || requested_name.eq_ignore_ascii_case("cleo") { - return ensure_bundled_character( - codex_home, - "chloe", - CHLOE_CHARACTER_MANIFEST, - &[ - ("default", CHLOE_MANIFEST, CHLOE_SHEET), - ("locked-in", CHLOE_LOCKED_IN_MANIFEST, CHLOE_LOCKED_IN_SHEET), - ], - ); + if let Some(character) = BUNDLED_CHARACTERS.iter().find(|character| { + requested_name.eq_ignore_ascii_case(character.id) + || character + .aliases + .iter() + .any(|alias| requested_name.eq_ignore_ascii_case(alias)) + }) { + ensure_bundled_character(codex_home, character)?; } Ok(()) } -fn ensure_bundled_character( - codex_home: &Path, - id: &str, - character_manifest: &str, - avatars: &[(&str, &str, &[u8])], -) -> Result<()> { - let character_dir = codex_home.join("characters").join(id); +fn ensure_bundled_character(codex_home: &Path, character: &BundledCharacter) -> Result<()> { + let character_dir = codex_home.join("characters").join(character.id); let manifest_path = character_dir.join("character.json"); if character_dir.exists() { return Ok(()); } - for (avatar_id, avatar_manifest, sheet) in avatars { - let avatar_dir = character_dir.join("avatar").join(avatar_id); + for avatar in character.avatars { + let avatar_dir = character_dir.join("avatar").join(avatar.id); fs::create_dir_all(&avatar_dir) .with_context(|| format!("create {}", avatar_dir.display()))?; - fs::write(avatar_dir.join("sheet.png"), sheet) + fs::write(avatar_dir.join("sheet.png"), avatar.sheet) .with_context(|| format!("write bundled avatar in {}", avatar_dir.display()))?; - fs::write(avatar_dir.join("avatar.json"), avatar_manifest) + fs::write(avatar_dir.join("avatar.json"), avatar.manifest) .with_context(|| format!("write bundled avatar in {}", avatar_dir.display()))?; validate_installed_avatar(&avatar_dir)?; } - fs::write(&manifest_path, character_manifest) + fs::write(&manifest_path, character.manifest) .with_context(|| format!("write {}", manifest_path.display())) } @@ -205,17 +342,76 @@ mod tests { .join("avatars/chloe-r2-09-locked-in/avatar.json") .is_file() ); - assert!(dir.path().join("avatars/clanker/avatar.json").is_file()); + let installed_avatar_ids = BUNDLED_AVATARS + .iter() + .filter(|avatar| { + dir.path() + .join("avatars") + .join(avatar.id) + .join("avatar.json") + .is_file() + }) + .map(|avatar| avatar.id) + .collect::>(); + assert_eq!( + installed_avatar_ids, + vec![ + "clanker", + "chloe-r2-09", + "chloe-r2-09-locked-in", + "c3ph0", + "hai", + "clautist", + "jasonelle", + "centurion", + ] + ); assert!( dir.path() .join("characters/chloe/avatar/locked-in/avatar.json") .is_file() ); - assert!( - dir.path() - .join("characters/clanker/character.json") - .is_file() + let installed_character_ids = BUNDLED_CHARACTERS + .iter() + .filter(|character| { + dir.path() + .join("characters") + .join(character.id) + .join("character.json") + .is_file() + }) + .map(|character| character.id) + .collect::>(); + assert_eq!(installed_character_ids, vec!["clanker", "chloe"]); + } + + #[test] + fn eager_install_preserves_custom_aliases_for_lazy_bundled_characters() { + let dir = tempfile::tempdir().unwrap(); + let character_dir = dir.path().join("characters/orion"); + write_valid_avatar(&character_dir.join("avatar/default"), "sheet.png"); + fs::write( + character_dir.join("character.json"), + r#"{ + "schemaVersion":1, + "id":"orion", + "displayName":"Orion", + "aliases":["hai"], + "avatar":"avatar/default/avatar.json" + }"#, + ) + .unwrap(); + + ensure_bundled_avatars(dir.path()).unwrap(); + + let resolved = codex_character::CharacterCatalog::load(dir.path()) + .resolve("hai") + .unwrap(); + assert_eq!( + (resolved.manifest.id.as_str(), resolved.match_kind), + ("orion", codex_character::MatchKind::ExplicitAlias) ); + assert!(!dir.path().join("characters/hai").exists()); } #[test] diff --git a/codex-rs/tui/src/avatars/binding.rs b/codex-rs/tui/src/avatars/binding.rs index 66382f3733..cef2e0fcc6 100644 --- a/codex-rs/tui/src/avatars/binding.rs +++ b/codex-rs/tui/src/avatars/binding.rs @@ -130,6 +130,40 @@ mod tests { ); } + #[test] + fn every_bundled_character_name_resolves_to_its_own_avatar_binding() { + let home = tempfile::tempdir().unwrap(); + + let resolved = [ + ("rusty", "clanker"), + ("cleo", "chloe"), + ("c-3ph0", "c3ph0"), + ("hai", "hai"), + ("clautist", "clautist"), + ("jasonelle", "jasonelle"), + ("centurion", "centurion"), + ] + .into_iter() + .map(|(requested_name, expected_id)| { + let binding = resolve_named_avatar_binding(home.path(), requested_name).unwrap(); + (binding.character_id().to_string(), expected_id) + }) + .collect::>(); + + assert_eq!( + resolved, + vec![ + ("clanker".to_string(), "clanker"), + ("chloe".to_string(), "chloe"), + ("c3ph0".to_string(), "c3ph0"), + ("hai".to_string(), "hai"), + ("clautist".to_string(), "clautist"), + ("jasonelle".to_string(), "jasonelle"), + ("centurion".to_string(), "centurion"), + ] + ); + } + #[test] fn named_binding_ignores_unrelated_invalid_character_packages() { let home = tempfile::tempdir().unwrap(); diff --git a/codex-rs/tui/src/pets/ansi_half_block_tests.rs b/codex-rs/tui/src/pets/ansi_half_block_tests.rs index e60c1aeb9f..ac07b841c4 100644 --- a/codex-rs/tui/src/pets/ansi_half_block_tests.rs +++ b/codex-rs/tui/src/pets/ansi_half_block_tests.rs @@ -41,6 +41,58 @@ fn renders_24px_avatar_as_twelve_truecolor_half_block_rows() { assert_eq!(buffer[(23, 0)].fg, Color::Rgb(0, 0, 255)); } +fn render_bundled_avatar(sheet: &[u8]) -> String { + let sheet = image::load_from_memory(sheet).unwrap().into_rgba8(); + let image = image::imageops::crop_imm(&sheet, 0, 0, 24, 24).to_image(); + let frame = AnsiHalfBlockFrame::from_image(image).unwrap(); + let area = Rect::new(0, 0, AVATAR_WIDTH, AVATAR_HEIGHT); + let mut buffer = Buffer::empty(area); + + frame.render(area, &mut buffer); + + (0..AVATAR_HEIGHT) + .map(|row| { + (0..AVATAR_WIDTH) + .map(|column| buffer[(column, row)].symbol()) + .collect::() + .replace(' ', "·") + }) + .collect::>() + .join("\n") +} + +#[test] +fn renders_bundled_agent_avatars() { + let rendered = [ + ( + "C-3PH0", + include_bytes!("../../assets/c3ph0/sheet.png").as_slice(), + ), + ( + "HAI", + include_bytes!("../../assets/hai/sheet.png").as_slice(), + ), + ( + "Clautist", + include_bytes!("../../assets/clautist/sheet.png").as_slice(), + ), + ( + "Jasonelle", + include_bytes!("../../assets/jasonelle/sheet.png").as_slice(), + ), + ( + "Centurion", + include_bytes!("../../assets/centurion/sheet.png").as_slice(), + ), + ] + .into_iter() + .map(|(name, sheet)| format!("{name}\n{}", render_bundled_avatar(sheet))) + .collect::>() + .join("\n\n"); + + assert_snapshot!(rendered); +} + #[test] fn transparent_pixels_keep_the_default_background() { let mut image = RgbaImage::from_pixel(24, 24, Rgba([0, 0, 0, 0])); diff --git a/codex-rs/tui/src/pets/snapshots/codex_tui__pets__ansi_half_block__tests__renders_bundled_agent_avatars.snap b/codex-rs/tui/src/pets/snapshots/codex_tui__pets__ansi_half_block__tests__renders_bundled_agent_avatars.snap new file mode 100644 index 0000000000..e60133c387 --- /dev/null +++ b/codex-rs/tui/src/pets/snapshots/codex_tui__pets__ansi_half_block__tests__renders_bundled_agent_avatars.snap @@ -0,0 +1,73 @@ +--- +source: tui/src/pets/ansi_half_block_tests.rs +expression: rendered +--- +C-3PH0 +·▀▀▄·▄▀▀▀▀▀▀▀▀▀▀▄▄··▄▀▀· +··▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀·· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀···· +··▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀·▄·▄ +··▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀·▀▀▀ +·▀▀▀▀▀▀·▀▀▀▀▀▀▀▀▄▀▀····· +·▀▀▀▀▀▀▀▄▄·····▄▀▀······ +··▀▀▀▀▀▀▀▀▀▄▄▀▀▀▀▀······ +··▀▀·▀▀▀▀▀▀▀▀▀▀▀▀▀▀····· +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀···· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· + +HAI +························ +······▄▄▄▄▄▄▄▄▄▄▄▄······ +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀·▀·· +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +·····▀▀▀▀▀▀▀▀▀▀▀▀▀▀··▀·· +······▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +·······▄·▀▀▀▀▀▀·▄······· +······▄▀▀▀▄▀▀▀▀▀▀▄······ +·····▄▀▀▀▀▀▀▀▀▀▀▀▀▄····· +····▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄···· + +Clautist +························ +························ +························ +····▀···▀▄▄▄▄▄▄▄▀···▀··· +····▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄··· +··▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄· +··▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀·· +·····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀···· +························ +························ +························ + +Jasonelle +···▄▄··············▄▄··· +··▄▀▀▀▀··▄▄▄▄▄▄··▄▀▀▀▄·· +··▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀·· +··▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀·· +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀···· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀···· +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀···· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀···· + +Centurion +········▄▀▀▄▄▄▄▄▄······· +······▀▀▀▀▀▀▀▀▀▀▀▀▄····▀ +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··▄▀· +····▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄···▀ +····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀·▄▀· +·····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀· +·····▀▀▀▀▀▀▀▀▀▀▀▀▀▀····· +···▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄··· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +···▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀··· +·····▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀····