From 0332466fdc4eb8bf3d1d4db10f86de3f5e44ed41 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 08:19:38 +0200 Subject: [PATCH] docs: Campaign AS plan + AS1 ground-truth synthesis (assess window, player targets) Owner report 2026-08-25: the examination window on an assessed PLAYER is missing retail's identity block (gender/heritage, title, PK), the three per-bodypart armor-level rows, allegiance/faction lines, and the target-configurable extras. Three-lens research (our pipeline, ACE wire, named retail decomp) established: the 0x00C9 parse is already complete (ArmorLevels 0x4000 parsed and dead), everything arrives from ACE, and the core defect is an element mis-mapping in the Character subview vs CharExamineUI @0x004AD3C0 (heritage slot fed a raw string prop, title slot fed AllegianceName, PK slot fed MonarchsName, allegiance slot fed an invented "Assessment incomplete" literal). Zero Character-subview tests existed, which is how it survived the Slice 3 gate. Slices AS2-AS5 (serial, coupled files) + AS6 gate script; ground truth doc carries the binding line-composition tables, gap ledger G1-G10, and rulings R1-R8. Co-Authored-By: Claude Fable 5 --- ...026-08-25-assess-window-parity-campaign.md | 134 ++++++++++ .../2026-08-25-campaign-as-ground-truth.md | 239 ++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 docs/plans/2026-08-25-assess-window-parity-campaign.md create mode 100644 docs/research/2026-08-25-campaign-as-ground-truth.md diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md new file mode 100644 index 00000000..f6ff56ae --- /dev/null +++ b/docs/plans/2026-08-25-assess-window-parity-campaign.md @@ -0,0 +1,134 @@ +# Campaign AS — assess/examination window retail parity (player targets) + +**Status: EXECUTING — AS1 research synthesis complete 2026-08-25; slices final.** + +Owner report (2026-08-25, side-by-side screenshots, acdream vs retail, both +assessing the player "Dww"): acdream's examination window on a PLAYER target +is missing retail's identity block and body/config sections. + +Missing vs retail: + +1. **Identity block** under the title bar: gender + heritage ("Female + Aluvian"), current display title ("War Mage"), PK status + ("Non-Player Killer") — none of it rendered by acdream. +2. **Per-bodypart armor levels**: retail shows three grouped rows — + "Head/Chest/Groin AL: x/y/z", "Bicep/Wrist/Hand AL: x/y/z", + "Thigh/Shin/Foot AL: x/y/z". Absent in acdream. +3. **Target-configurable extras**: retail only shows what the assessed + player configured to show (date of birth, age, number of deaths, chess + rank, fishing skill, damage/crit ratings, …). acdream shows a + "Dmg/CritDmg Rating:" line but not the rest of the family. +4. **Allegiance/faction lines**: allegiance name/patron/faction shown when + the target has them. Absent in acdream. + +Explicit owner rulings: + +- **The animated 3D paperdoll is an INTENTIONAL acdream deviation** (retail + shows a static tinted preview; its colors are buggy in retail). Keep it. + Register row required (added in the first slice that touches the window). +- Retail comparison is the oracle for text composition; all strings come + from DAT StringTables per the decomp — **never hardcoded English + literals**. +- All UI work goes through the standard GUI classes (UiLabel / UiPanel / + UiScrollablePanel / retail chrome) — no bespoke widgets, no quick fixes. + +## Execution model (set by owner) + +- **Fable** plans and coordinates (this doc + slice contracts + synthesis). +- **Sonnet** implements each slice against a pinned contract. +- **Opus** runs the dual-lens review per slice — retail-faithfulness lens + + architecture lens — followed by a fix round; slice is REVIEW-CLOSED only + after the re-review accepts the fixes. +- Commit to the worktree branch (`claude/windmill-seam-and-solid-polys`) as + slices land, full hermetic suite green each time + (`Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure`). +- **Do NOT push to gitea until the owner says so.** +- Campaign stops when all slices are REVIEW-CLOSED and the connected-gate + script is written and waiting on the owner's drive. + +## Ground truth sources + +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` — the retail + examination window class, line composition, StringTable keys, 0x00C9 + client-side parse. +- `references/ACE/` — what our live server actually sends per flag, and the + target-option gating rules. +- `references/Chorizite.ACProtocol/` — field-order cross-check. +- Campaign CT sealed verdicts (`claude-memory/project_character_panel_campaign.md`) + — the heritage/title composition and PK bitfield rules already ported for + the character panel; REUSE, do not re-derive. +- Research synthesis doc (AS1 output): + `docs/research/2026-08-25-campaign-as-ground-truth.md`. + +## Slices (FINAL — re-cut at AS1: the 0x00C9 parse is already complete and +the appraisal profile is session-scoped UI presentation state flowing +through the established router seam, so the skeleton's wire and +runtime-owner slices are unnecessary; all work is App-side composition) + +**Oracle for every slice:** +`docs/research/2026-08-25-campaign-as-ground-truth.md` (AS1 synthesis) — +its §2 line-composition tables, §3 wire truth, §4 gap ledger G1–G10, and +§5 rulings R1–R8 are BINDING on implementers and reviewers. Deviating from +a ruling requires a plan-doc amendment, not an implementer judgment call. + +- **AS1 — ground truth synthesis (DONE, Fable).** Three research lenses + merged; gap ledger G1–G10; rulings R1–R8. +- **AS2 — header identity block (Sonnet).** Fix the element mis-mapping in + `AppraisalUiController.ApplyCreature(character: true)` per ground truth + §2a: `0x10000150` ← composed gender+heritage (reuse + `CharacterIdentityText`, including retail's heritage-id overrides and the + creature fallback when heritage==0); `0x10000151` ← current display title + via `CharacterTitleResolver` (Int 261, fallback String 5 Template); + `0x10000152` ← PK line from the assessed `ClientObject`'s PWD bits + (`PlayerKillerStatusBitfield`, ruling R7); `0x1000053A` ← String 47 + AllegianceName gated on Int 30 ≥ 1, DELETING the invented + "Assessment incomplete" literal. First-ever `AppraisalView.Character` + controller tests (G10). Register: file the animated-paperdoll AD row + (owner-ruled intentional deviation 2026-08-25) in this commit. +- **AS3 — armor-level rows + extras-list plumbing (Sonnet).** Plumb + `Parsed.ArmorLevels` into the extras composer (signature change from + bare `PropertyBundle`); emit the spacer + three grouped AL rows before + the rating rows per ground truth §2b rows 3–7, including the `*%d` + ≥9999 unenchantable rendering, retail's per-row rating gates + (307|313|314 · 308|315|316 · 350|351), spacer discipline per ruling R4, + and the `* = Unenchantable` legend per ruling R3 (unconditional, + gate-verified). Ordering pinned by tests. +- **AS4 — society/allegiance/fellowship + configurable extras (Sonnet).** + Ground truth §2b rows 1–2 and 8–14: the Society row with rank bands and + the local-vs-target faction color rule; the Monarch/Patron/Followers + cascade; Fellowship; Arrived in Dereth; Time in Dereth (locate or port + `ClientUISystem::DeltaTimeToString` — grep for an existing port first); + Chess Rank; Fishing Skill; Deaths ("Has never died" at ≤0); Titles + Earned. Register: narrow AP-110 ("exhaustive character detail regions" + clause retires) in this commit. +- **AS5 — allegiance rank-title table (Sonnet).** Port the 17-function + heritage×gender `AllegianceSystem::GetTitle @0x005B8DD0` table (census + per AP-109's corrected 2026-08-25 text: 11 heritages → 17 functions, + ~170 strings; Gearknight/Tumerok male-only reused, Lugian female-only + reused, Penumbraen aliases Shadowbound, Olthoi excluded by the unsigned + range check) + `AllegianceData::GetFullName @0x005B6950`; wire the + examination title bar (rank from `props.GetInt(0x1E)`, ruling R8) AND + the character panel's name line (closing AP-109's residual). Register: + retire AP-109 in this commit. +- **AS6 — connected gate script (Fable).** User-driven script + `docs/research/2026-08-25-campaign-as-test-script.md`; two-client where + needed (allegiance/fellowship/PK lines, deception-failure rendering); + includes the R3 legend retail-side-by-side check and the owner's + configurable-extras toggle matrix. + +Slices AS2→AS3→AS4 are SERIAL (all touch `AppraisalUiController` / +`CreatureAppraisalRows` — coupled-file rule); AS5 may run after AS4's +review closes. Each slice: Sonnet implements → full hermetic suite green → +commit → Opus dual-lens review (retail-faithfulness + architecture) → fix +round → narrow re-review → REVIEW-CLOSED. + +## Ledger + +| Slice | State | Land / fix commits | Notes | +|---|---|---|---| +| AS1 | **DONE 2026-08-25** | (docs commit) | 3-agent research; ground-truth doc committed | +| AS2 | ready to dispatch | — | | +| AS3 | pending AS2 review-close | — | | +| AS4 | pending AS3 review-close | — | | +| AS5 | pending AS4 review-close | — | | +| AS6 | pending AS5 review-close | — | | diff --git a/docs/research/2026-08-25-campaign-as-ground-truth.md b/docs/research/2026-08-25-campaign-as-ground-truth.md new file mode 100644 index 00000000..32a54d5a --- /dev/null +++ b/docs/research/2026-08-25-campaign-as-ground-truth.md @@ -0,0 +1,239 @@ +# Campaign AS ground truth — assess/examination window, PLAYER targets + +**AS1 synthesis (2026-08-25).** Three-lens research: our pipeline map, ACE +wire truth (`references/ACE`, cross-checked byte-identical against +`references/Chorizite.ACProtocol`), and the named 2013 retail decomp +(`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This document is +the ORACLE for every Campaign AS slice. Addresses are acclient 2013 +v11.4186; property ids verified against ACE enums. + +## 0. The headline findings + +1. **The 0x00C9 parse is already complete.** `AppraiseInfoParser` + (`src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`) parses every + flag retail parses, in retail's exact section order, INCLUDING + `ArmorLevels = 0x4000` (nine u32s). `Parsed.ArmorLevels` is a dead + field — no production reader. **No wire/parse work is needed.** +2. **Everything missing already arrives from ACE** on a successful player + assess (see §3/§4). The whole campaign is client-side composition. +3. **The core defect is an element MIS-MAPPING** in + `AppraisalUiController.ApplyCreature(character: true)` + (`src/AcDream.App/UI/Layout/AppraisalUiController.cs:673-708`): retail's + `CharExamineUI` ctor `@0x004AD3C0` binds `0x10000150` = Heritage, + `0x10000151` = Profession(title), `0x10000152` = PlayerKiller, + `0x1000053A` = AllegianceName — we feed them raw-HeritageGroup-string / + AllegianceName / MonarchsName / the invented literal + `"Assessment incomplete"` (zero retail provenance; the only occurrence + in the repo). +4. **Zero tests exercise `AppraisalView.Character`** — how the mis-mapping + survived the original Slice 3 gate (that gate checked page selection + only). +5. Retail composes these lines from `u"..."` code literals (not + StringTables), same as the item report — matching retail means literals + here, with DAT/EnumMapper lookups exactly where retail does them + (gender/heritage/title chains, attribute names). + +## 1. Retail class map + +`gmExaminationUI` (element-type 0x1000001C, `Register @0x004AB780`; +pinnable `gmFloatyExaminationUI` = 0x1000004C). LayoutDesc `0x2100006B` +(DAT-side; already our mount). Sub-UIs implement `ExamineSubUI` +(`acclient.h:54747`): `Init` / `SetAppraiseInfo(profile, isNewObject)` / +`Show`. + +| Class | Methods that matter | +|---|---| +| `gmExaminationUI` | ctor `0x004AB2B0`; `PostInit 0x004AD6A0`; `RecvNotice_ExamineObject 0x004AB7B0`; dispatcher `SetAppraiseInfo 0x004ADAE0`; `SetTitleText 0x004AD930`; `UseTime 0x004AB530` (0.75 s combat refresh) | +| `BasicCreatureExamineUI` | ctor `0x004ACD30`; `SetAppraiseInfo 0x004B3F70`; `SetLevelValueText 0x004B3E70`; `AddLineToMiscInfo 0x004ABBB0` | +| `CreatureExamineUI` (monsters) | `SetAppraiseInfo 0x004B3FF0` | +| **`CharExamineUI` (players)** | ctor `0x004AD3C0`; **`SetAppraiseInfo 0x004B45F0`** — the full player line composer | + +Sub-UI dispatch (`0x004ADAE0`): creature profile AND (String 5 Template OR +Int 261 CharacterTitleId) → CharExamineUI. Our `SelectView` +(`AppraisalUiController.cs:920-928`) already matches. + +Widget ids: title `0x1000012D`; level value `0x1000014C`; attribute list +`0x10000149`; extra list `0x10000335`; row template label `0x1000012A` / +value `0x1000012B`; header texts `0x10000150/0x10000151/0x10000152/0x1000053A`; +viewport `0x10000148`. + +## 2. Player line composition (CharExamineUI::SetAppraiseInfo @0x004B45F0) + +"colorIdx" = 4th arg of `UIElement_Text::SetTextWithFont @0x0046A500` — +the authored alternate-font-color index (attr 0x1B/0x1D list): 0 default, +**1 = buffed/green, 2 = debuffed/red, 3 = unknown/failed** (semantics +pinned by ACE `CreatureProfile.cs:44-45`). Exact authored RGBA lives in +LayoutDesc 0x2100006B (DAT), not the binary. + +### 2a. Fixed header elements + +| Element | Source | Composition | +|---|---|---| +| Title bar `0x1000012D` | `SetTitleText @0x004AD930`: String 52 override else appropriate object name; stack-count prefix | then the char path OVERWRITES with `AllegianceData::GetFullName @0x005B6950` = `AllegianceSystem::GetTitle(rank=Int 30, heritage=Int 188, gender=Int 113) + " " + name` ("Baroness Aluvia"); plain name when no rank title | +| Heritage `0x10000150` | Int 113 Gender, Int 188 HeritageGroup (Int 2 CreatureType only if heritage==0) | `AppraisalSystem::InqGenderHeritageDisplay @0x005B5AE0` → `" "`. Gender: EnumMapper `0x10000001`; heritage: EnumMapper `0x10000002` with hardcoded overrides `2→"Gharu'ndim"`, `5→"Umbraen"`, `13→"Olthoi"`; creature fallback EnumMapper `0x10000005` (underscore→space) | +| Title `0x10000151` | Int 261 CharacterTitleId, fallback String 5 Template | `CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0`: EnumMapper `0x10000006` → `compute_str_hash` → StringTable behind table-enum `0x10000007`. Current display title only. Same pipeline as `CharacterTitleResolver` (CT campaign). | +| PK `0x10000152` | **NOT from the appraisal payload** — local `cur_weenobj` PWD bits: `IsPK() @0x0058C8B0` → `u"Player Killer"`, `IsPKLite() @0x0058C8A0` → `u"Player Killer Lite"`, else `u"Non-Player Killer"`. Skipped (left cleared) if the weenie is gone. Ours: `ClientObject.PublicWeenieBitfield` + `PlayerKillerStatusBitfield` (bits 0x20 / 0x02000000) — the exact port already exists (`ClientObject.cs:439-451`, per AP-109's CT4 correction). | +| Allegiance `0x1000053A` | String 47 AllegianceName | plain set, element cleared first; only inside the `Int 30 AllegianceRank >= 1` gate | +| Level `0x1000014C` | Int 25 | `SetLevelValueText @0x004B3E70`: >0 → comma-grouped; ≤0/absent → `"???"` | + +### 2b. Extra-info list rows (list `0x10000335`), EXACT ORDER + +Every row = `AddLineToMiscInfo(label, value, colorIdx) @0x004ABBB0`. +Literals: `"%d"` @0x794344/0x7A0184; `"*%d"` @0x7B110C; `u"???"` @0x7B0F34; +`L""` spacer @0x794320. + +| # | Gate | Label | Value | Color | +|---|---|---|---|---| +| 1 | Int 281 Faction1Bits ≠ 0 | `Society:` | society name (+` ~ ` band): bit1→"Celestial Hand"+Int 287, bit2→"Eldrytch Web"+Int 288, bit4→"Radiant Blood"+Int 289, none→`???`. Bands: 1–100 Initiate, 101–300 Adept, 301–600 Knight, 601–1000 Lord, 1001–1500 Master; outside bands → name alone | vs LOCAL player's Faction1Bits (`InqInt 0x119` on own qualities): same → 1 (green), different faction → 2 (red), local none → 0 | +| 2 | Int 30 ≥ 1 | allegiance rows | Str 21 MonarchsTitle absent → `Alleg. Monarch:` + `%d Follower`/`%d Followers` (Int 35, clamp ≥0). Str 21 present, Str 35 PatronsTitle absent → `Monarch:`+Str21. Both present, equal → one row `Monarch/Patron:`. Different → two rows `Monarch:` / `Patron:` | 0 | +| 3 | any of 9 AL > 0 | spacer, then `Head/Chest/Groin`, `Bicep/Wrist/Hand`, `Thigh/Shin/Foot` | per part `"%d"`, or `"*%d"` with (value−9999) when ≥9999 (unenchantable sentinel); value cell = `"AL: %s/%s/%s"`. Nine dwords in order head, chest, groin(=Abdomen), bicep(=UpperArm), wrist(=LowerArm), hand, thigh(=UpperLeg), shin(=LowerLeg), foot | 0 | +| 4 | Int 307\|313\|314 > 0 | spacer, then `Dmg/CritDmg` | `Rating: %d/%d` ← (307 DamageRating, 314 CritDamageRating); 313 gates only | 0 | +| 5 | Int 308\|315\|316 > 0 | [spacer if row 4 absent] `Dmg/CritDmg` | `Resist: %d/%d` ← (308, 316); 315 gates only | 0 | +| 6 | Int 350\|351 > 0 | [spacer if none yet] `DoT/Life:` | `Resist: %d/%d` ← (350, 351) | 0 | +| 7 | any of rows 4–6 shown | trailing spacer | | | +| 8 | Str 10 Fellowship | `Fellowship:` | verbatim | 0 | +| 9 | Str 43 DateOfBirth | `Arrived in Dereth:` | verbatim (server-formatted) | 0 | +| 10 | Int 125 Age | `Time in Dereth:` | `ClientUISystem::DeltaTimeToString(seconds)` | 0 | +| 11 | Int 181 ChessRank | `Chess Rank:` | `%d` | 0 | +| 12 | Int 192 FakeFishingSkill | `Fishing Skill:` | `%d` | 0 | +| 13 | Int 43 NumDeaths | `Deaths:` | ≤0 → `Has never died`, else `%d` | 0 | +| 14 | Int 262 NumCharacterTitles | `Titles Earned:` | `%d` | 0 | +| 15 | unconditional (per decomp — see R3) | `* = Unenchantable` in the LABEL slot, empty value | legend | 0 | + +Then the tail calls `BasicCreatureExamineUI::SetAppraiseInfo @0x004B3F70` +(level + attribute token update). + +Note: retail's ratings format strings are `"%Rating: %d/%d"` / +`"%Resist: %d/%d"` — the leading `%R` is invalid printf that msvcrt renders +as literal `R…`, so the display text is `Rating: x/y` / `Resist: x/y`. Our +existing `CreatureAppraisalRows.BuildExtra` output matches the display. + +### 2c. Attribute/vital rows (list `0x10000149`) + +Created once in ctor order **Str, End, Coord, Quick, Focus, Self** then +**Health (percent shown), Stamina, Mana**; labels via +`SkillSystem::InqAttributeName @0x005C8D90` (StringTable-backed). +`AttributeInfoRegion::Update @0x004F1D90`: `"%d"`, 0 → `"???"`; color: +success==0 → 3, else enchant HI bit → 1 (green) / LO-without-HI → 2 (red) +(`InqAttributeEnchantmentMod @0x005B5EC0`). +`Attribute2ndInfoRegion::Update @0x004F1E80`: `%d/%d`, Health +`%d/%d (%d %%)`; **success==0: Health shows `%d %%` ONLY; Stamina/Mana show +`???`**; same color rule on the MAX-vital bits. + +## 3. ACE wire truth (what the local server sends for a player) + +Serialization: flags dword, Success dword, then sections in retail's parse +order (Int 0x1 → Int64 0x2000 → Bool 0x2 → Float 0x4 → String 0x8 → DID +0x1000 → SpellBook 0x10 → ArmorProfile 0x80 → CreatureProfile 0x100 → +WeaponProfile 0x20 → HookProfile 0x40 → ArmorEnch 0x200 → WeaponEnch 0x800 +→ ResistEnch 0x400 → ArmorLevels 0x4000). Byte-identical vs Chorizite. +Player targets set: Int (always), String (usually), DID (appearance dids), +CreatureProfile (always, even failed), ArmorLevels (Success only). + +**ArmorLevels** (`ACE .../Structure/ArmorLevel.cs:85-96`): nine u32 +Head, Chest, Abdomen, UpperArm, LowerArm, Hand, UpperLeg, LowerLeg, Foot. +Sent when `Success && (Player || !Attackable)`. Values are **BUFFED** +(base + enchant mod per covering Clothing layer, clamped ≥0); if EVERY +covering layer is unenchantable the part gets **+9999** (the `*` sentinel). +Chorizite's "BaseArmor*" naming is misleading — trust ACE. + +**Identity ints:** Gender **113**, HeritageGroup **188**, +PlayerKillerStatus **134** (ACE enum NPK 0x02 / PK 0x04 / PKLite 0x40 — NOT +the PWD bit layout; retail reads the local weenie's PWD bits instead, and so +do we), CharacterTitleId **261**, NumCharacterTitles **262**, Level **25**. +The display strings never ride the wire — client-composed. + +**Option-gated extras** (removed from the tables when the TARGET's option +is off; `AppraiseInfo.cs:352-366`): DateOfBirth str **43** +(AllowOthersToSeeYourDateOfBirth), Age int **125**, ChessRank int **181**, +FakeFishingSkill int **192**, NumDeaths int **43**, NumCharacterTitles int +**262**. Client renders what's present — no client-side option logic. + +**Ratings** (Success only, nonzero only, straight into IntStats): +Damage **307** (+5 heritage-weapon bonus applied server-side), +DamageResist **308**, Crit **313**, CritDamage **314**, CritResist **315**, +CritDamageResist **316**, HealingBoost **323**, NetherResist **331**, +DotResist **350**, LifeResist **351**, GearMaxHealth **379**, PKDamage +**381**, PKDamageResist **382**. Retail renders only +307/314 · 308/316 · 350/351 (313/315 gate; 323 read but never rendered). + +**Allegiance/faction:** AllegianceName str **47**; monarch targets get +AllegianceFollowers int **35**; non-monarch get MonarchsTitle str **21** +(server-composed " ") + PatronsTitle str **35**; +AllegianceRank int **30**; Faction1Bits int **281** (masked to 0x7); +Society ranks ints **287/288/289**; Fellowship str **10**. + +**Failure semantics:** failed assess (target's Deception beats examiner's +AssessPerson, target opted into AttemptToDeceiveOtherPlayers) → Success=0 +but ACE STILL sends full int/string/DID tables; only CreatureProfile +attributes (ShowAttributes flag 0x8), ratings, and ArmorLevels are gated. +Target sees "X tried and failed to assess you!"; retries inside 5 s +auto-fail. Guid-not-found → `Flags=0, Success=0` only. + +## 4. Current acdream state (what exists, what's wrong) + +- Window: `AppraisalUiController` (LayoutDesc 0x2100006B, root 0x100005F2), + mounted as independent top-level floaty; subviews Item/Creature/ + Character/Spell; `UiText` + `UiItemList`/`UiTemplateListSlot` rows from + DAT template `0x10000166`; `CreatureAppraisalLayeredList` two-list + chrome/foreground split over one `UiScrollable`; animated clone in + `UiViewport 0x10000148` (`CreatureAppraisalPresentation`). +- Route: `GameEventWiring.cs:1003` → `LiveSessionEventRouter.OnAppraisal` → + `RetailUiRuntime.HandleAppraisal` → `Apply(Parsed)`. +- Stale-response rejection, 0.75 s visible-combat refresh, busy-cursor + balance: present and pinned. +- Ratings rows exist (`CreatureAppraisalRows.BuildExtra`, + `CreatureAppraisalRows.cs:97-146`) with the retail grouping pinned by + `ExtraRatingsFollowRetailGroupingFormattingAndSeparators`. BuildExtra + takes only a `PropertyBundle` — it cannot see `Parsed.ArmorLevels` + (signature change needed; armor rows precede rating rows). +- Attribute rows: nine ordered rows + `???` failure semantics + enchant + bit styles pinned in `CreatureAppraisalRowsTests`. +- Reusable CT ports: `CharacterIdentityText` (gender/heritage composition, + `CharacterIdentityText.cs:81-117`), `CharacterTitleResolver` (EnumMapper + 0x22000041→hash→StringTable 0x2300000E — the SAME retail pipeline; the + decomp's 0x10000006/0x10000007 are element-scope enum ids resolved to + those DAT ids), `PlayerKillerStatusBitfield`. + +### Gap ledger + +| # | Gap | Fix slice | +|---|---|---| +| G1 | Heritage slot fed raw string prop instead of composed gender+heritage | AS2 | +| G2 | Title slot fed AllegianceName; title never displayed | AS2 | +| G3 | PK slot fed MonarchsName; PK never displayed | AS2 | +| G4 | Armor-level trio absent (parsed, never read) | AS3 | +| G5 | `0x1000053A` shows invented "Assessment incomplete" instead of AllegianceName | AS2 | +| G6 | Society/faction row absent | AS4 | +| G7 | Monarch/Patron/Followers rows absent | AS4 | +| G8 | Configurable extras absent (Fellowship/DOB/Age/Chess/Fishing/Deaths/Titles + legend) | AS3 (legend) + AS4 | +| G9 | Title-bar allegiance rank prefix absent (AP-109's 17-function table) | AS5 | +| G10 | Zero Character-subview test coverage | every slice adds | + +## 5. AS1 rulings on the open questions + +- **R1 (String 52 override):** keep current `BuildTitle` behavior — String + 52 is absent for players; harmless either way. +- **R2 (Int 323 HealingBoost):** retail reads it and never renders it; we + keep discarding it. Do not add a row. +- **R3 (`* = Unenchantable` legend unconditional):** port as the decomp + shows — unconditional at the end of the char extras (it may be a BN + flattening; the CONNECTED GATE explicitly asks the owner to check retail + side-by-side; flip to conditional only on live evidence). +- **R4 (ratings spacer discipline):** the BN output lost the flag + assignments in CharExamineUI; use `CreatureExamineUI::SetAppraiseInfo + @0x004B3FF0`'s clean version of the SAME logic: one spacer before the + first ratings row, one trailing spacer if any rating row was emitted. +- **R5 (colorIdx RGBA):** authored in LayoutDesc 0x2100006B text attrs + (0x1B/0x1D). The row mechanism already supports authored color styles + (enchant bit styles pinned); reuse it, never hardcode RGBA. +- **R6 (literals):** the header/extras labels are code literals in retail — + match them as literals (consistent with the item report), with DAT + lookups only where retail does them (gender/heritage EnumMappers, title + chain, attribute names). +- **R7 (PK source):** from the assessed `ClientObject`'s PWD bits via + `PlayerKillerStatusBitfield`, NOT PropertyInt 134 (AP-109's CT4 lesson); + leave the element cleared when the object is no longer in the table. +- **R8 (rank source for GetFullName):** `props.GetInt(0x1E)` — the + appraisal bundle carries AllegianceRank; never read + `RuntimeAllegianceState` for another player's rank.