Opus dual-lens review of CT2 (bcfddc97) found 4 SHOULD-FIX + notes; this
applies the campaign lead's rulings.
F1 (the important one): retail's client-side table add is DEDUPED —
gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990 walks
mTitleList and returns without effect when the id is already present,
only inserting on a miss. The server-side SendNotice_AddCharacterTitle
broadcast is unconditional, but RuntimeCharacterTitleState.ApplyUpdateTitle
models the CLIENT receive side, so TitleAdded now fires only on a genuine
new membership. Inverted the pin:
ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision.
F3: removed the send-side titleId==0 rejection from both command
adapters. Retail's own send path (Event_SetDisplayCharacterTitle
@0x006a5720) packs whatever id it is handed, and ACE accepts id 0
(CharacterTitle.Invalid is a defined enum value) — retail's real
protection is the UI ghost-when-current gate (CT3's job), not a
send-side rejection. No register row: this makes acdream MORE
retail-exact.
A2: ResetSession now publishes TableReplaced unconditionally and
DisplayTitleChanged when the display id was non-zero before the clear,
matching the LocalPlayerState.Clear() precedent (publish every category
even when Clear is repeated, so a failed reset can converge on retry).
A3: RuntimeCharacterState.CaptureOwnership reads the new non-allocating
Titles.Count instead of EarnedTitleIds.Count; EarnedTitleIds now carries
an XML warning that every read allocates.
A4/A5: ReplaceTable/ApplyUpdateTitle now mutate under one _gate hold with
change flags computed inside the lock and events raised after release;
every revision bump is gated on an actual state change (a no-op wire
resend produces zero revision edges), matching the change-gated
RuntimeMovementSkillState precedent. TableReplaced itself still fires
unconditionally per retail's own Refresh() dispatch on 0x0029.
A1/A6/A7/A8: CharacterTitleResolverLiveDatTests honors ACDREAM_DAT_DIR
first (CT1 fix-round pattern); documented the EmitResult
primaryObjectId-as-title-id precedent inline; corrected the "third
consumer" comment (CT1 §5 already records gmAttributeUI::PostInit's
icon-DID lookup — CT5 factors the shared GetDIDByEnum helper); added a
titleId -> resolved-string memo to CharacterTitleResolver, the DAT-static
equivalent of retail's lazy-hash cache on the string buffer.
Appended a "CT3 anchors from the CT2 review" list to the plan doc's CT2
ledger entry for CT3 to consume.
Build green. Runtime (102), Core.Net (12), and App (27 + 3 InstalledDat
pins under ACDREAM_RUN_INSTALLED_DAT_TESTS=1) title-scoped tests pass.
Full hermetic solution suite (Lane exclusions per the release gate) is
green: 0 failures across all 15 test projects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
281 lines
16 KiB
Markdown
281 lines
16 KiB
Markdown
# Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment)
|
||
|
||
**Status:** PLANNED 2026-08-24 (owner gate report after the CA5/chat rounds).
|
||
**Execution model:** Fable plans and coordinates; Sonnet implements each
|
||
slice; Opus runs the dual-lens review (retail-faithful + architectural)
|
||
per slice, then a fix round. No pushes to gitea until the owner says so.
|
||
**Register:** this campaign retires AP-109 (inert Titles page) when CT3+CT4
|
||
land; every deviation a slice introduces adds its row in the same commit.
|
||
|
||
## Owner report (2026-08-24, screenshots on file)
|
||
|
||
1. Attribute/skill row **icons misaligned** vs retail.
|
||
2. Retail keeps a **margin between the value column and the border** —
|
||
the gutter reserved for the list scrollbar that appears when the
|
||
window is resized shorter. We author no such margin and never show
|
||
the scrollbar on Attributes.
|
||
3. The character window is **resizable in Y down to an authored
|
||
minimum** in retail; ours is not.
|
||
4. Header identity block: retail shows the name, then
|
||
**"<Gender> <Heritage> <DisplayTitle>"** (e.g. "Female Aluvian War
|
||
Mage"), then **"Non-Player Killer" / "Player Killer" /
|
||
"Player Killer Lite"** in **pure white** — on Attributes AND Skills.
|
||
We show gender+heritage only, no PK line contract, color off.
|
||
5. **Level number color** slightly off vs retail.
|
||
6. **Titles tab is inert** (AP-109): retail lists all earned titles
|
||
(sorted), shows the current display title, and lets the player set
|
||
one ("Set as Display Title"); scrollbar with many titles; the
|
||
header identity line updates live when the display title changes.
|
||
7. **All windows share retail's authored minimum-size behavior** —
|
||
resize clamps to the authored constraints everywhere.
|
||
|
||
## Retail recon (verified 2026-08-24, this session)
|
||
|
||
### Titles page — `gmCharacterTitleUI`
|
||
- `PostInit @0x0049A610` binds: display-title text `0x1000052F`,
|
||
"Set as Display Title" button `0x10000535`, title ListBox
|
||
`0x10000532`. Registers notice handlers for the title-table /
|
||
add-title / set-display-title notices.
|
||
- Rows carry the title id in attribute `0x1000008E`;
|
||
`AddTitleToList @0x0049A840` resolves the display string via
|
||
`CharacterTitleTable::GetCharacterTitleFromID` (DAT title-string
|
||
table — CT2 locates the DID) and inserts SORTED
|
||
(`FindSortedInsertPosition @0x0049A760`). It writes the resolved title
|
||
text into row child `0x10000537` and stamps the row's id via
|
||
`SetAttribute_Enum(row, 0x1000008E, titleId)`, inserting the row via
|
||
`AddItemFromTemplateList(listBox, 0, insertPos)` — CT3 mirrors this
|
||
exact write shape when populating `0x10000532`.
|
||
- **CORRECTED (CT1 fix round 2026-08-24):** `UpdateButtons @0x0049A500`
|
||
— the display button is **GHOSTED (state 0xd) UNLESS a row is
|
||
SELECTED whose title id differs from the current display title; no
|
||
selection → Ghosted.** (Not "ghosts when selected == current" — that
|
||
phrasing had the no-selection case backwards.) Verbatim mechanism:
|
||
a no-match selection falls through to index `0xFFFFFFFF` →
|
||
`GetItem` returns null → `SetState(0xd)`. Selection change
|
||
(msg 4/0x43) re-runs it.
|
||
- Clicking `0x10000535` sends
|
||
`CM_Social::Event_SetDisplayCharacterTitle(titleId)`
|
||
(`ListenToElementMessage @0x0049A6D0`).
|
||
- `gmStatManagementUI::RecvNotice_SetDisplayCharacterTitle @0x004EFD50`
|
||
→ the stat panel refreshes its header when the display title changes.
|
||
|
||
### Wire (ACE cross-checked)
|
||
- Inbound `CharacterTitle` event `0x0029` (already in our
|
||
`GameEventType`): `u32 =1, u32 displayTitleId, u32 count,
|
||
count × u32 titleId` (`GameEventCharacterTitle.cs`).
|
||
- Inbound `UpdateTitle` event `0x002B`: `u32 titleId,
|
||
u32 setAsDisplay` (`GameEventUpdateTitle.cs`).
|
||
- Outbound `TitleSet` GameAction (`GameActionSetTitle.cs`):
|
||
`u32 titleId`. Retail sender: `CM_Social::Event_SetDisplayCharacterTitle`.
|
||
|
||
### Header identity — `gmStatManagementUI::PostInit @0x004EFD90`
|
||
Binds name `0x10000231`, heritage line `0x10000232`, PK line
|
||
`0x10000233`, level `0x1000023B`, total XP `0x10000235`, XP-to-level
|
||
`0x10000238` + meter `0x10000236`, luminance pair `0x100005C5/C6`, list
|
||
box `0x1000023D`. The refresh (vtable slot, near
|
||
`UpdateExperience @0x004F0A70`) composes the heritage line WITH the
|
||
display title; the PK strings are exactly "Player Killer" /
|
||
"Player Killer Lite" / "Non-Player Killer" (IsPK / IsPKLite —
|
||
cross-anchor `CharExamineUI::SetAppraiseInfo @0x004B45F0`). CT5 reads
|
||
the composing function verbatim before writing a line of C#.
|
||
|
||
### CT4 contract (read verbatim 2026-08-24, Fable)
|
||
`gmStatManagementUI::UpdateCharacterInfo @0x004F0770`:
|
||
- **Name line** (`0x10000231`): `ACCWeenieObject::GetObjectName(player,
|
||
NAME_SINGULAR)` through `AllegianceData::GetFullName @0x005B6950`
|
||
(read verbatim): when `AllegianceSystem::GetTitle(rank, heritage,
|
||
gender)` yields an allegiance rank title, the line is
|
||
"<RankTitle><sep @data_794098><Name>"; otherwise the plain name.
|
||
The owner's retail screenshot (plain "Dww") is the rankless case.
|
||
- **Heritage line** (`0x10000232`):
|
||
`AppraisalSystem::InqGenderHeritageDisplay(gender=Int 0x71,
|
||
heritage=Int 0xBC, 0)` ("Female Aluvian"); then, when
|
||
`CharacterTitleTable::GetCharacterTitleFromID(m_titleID)` resolves,
|
||
`AppendText(separator @data_794358)` + `AppendText(titleString)` →
|
||
"Female Aluvian War Mage". Recover the separator literal with the
|
||
PE-read recipe (`claude-memory/reference_pe_byte_decode.md`).
|
||
- **Level** (`0x1000023B`): `InqInt(0x19)` present →
|
||
`Formatted(@data_7a0184)`; absent → literal `@data_7b0f34`.
|
||
- **PK line** (`0x10000233`): `UpdatePKStatus @0x004F00A0` — three-way
|
||
`IsPK` / `IsPKLite` / neither → StringInfo from table enum
|
||
`0x10000001` (= StringTable `0x23000001`, same compute_str_hash
|
||
mechanism the chat labels use) with keys
|
||
`ID_StatManagement_Header_PKStatus_PK` / `_PKL` / `_NPK`.
|
||
- Related key family for the footer/meter (already-shipped surfaces —
|
||
audit only if CT1 finds drift): `ID_StatManagement_Footer_*`,
|
||
`ID_StatManagement_Header_XPToLevelMeterInfinity`.
|
||
|
||
### Already in-tree
|
||
- Tab/page ids wired (`TabTitlesId 0x10000538`, `TitlesPageId
|
||
0x10000539`); pages currently show retail-authored closed visuals.
|
||
- Header labels partially bound (`StatHeaderLine` + `PkStatus` seams
|
||
exist in `CharacterStatController.Bind` — content contract wrong).
|
||
- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist and now
|
||
(CT2, landed) have a parser, a `RuntimeCharacterTitleState` owner, and
|
||
an outbound `TitleSet` builder — see CT2's paragraph below.
|
||
- The character window registers with `DatConstraintSource` — authored
|
||
min/max plumbing exists in `RetailWindowFrame`; Y-resize for this
|
||
window and the list-scrollbar contract do not.
|
||
|
||
## Slices
|
||
|
||
**CT1 — DAT ground truth + pins. REVIEW-CLOSED (2026-08-24): landed `ca4100e7`, Opus dual-lens review (1 doc-level blocker + 5 should-fix, all applied), fix round `e264d839`.**
|
||
Research: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`;
|
||
9 InstalledDat pins in `CharacterPanelLiveDatTests`. Three corrections
|
||
now BINDING on later slices:
|
||
(a) the character root (`0x10000227`, Type-8 TabControl) authors NO
|
||
min/max constraints and `MountCharacter` wires no `DatConstraintSource`
|
||
— CT6 must first find retail's actual minimum mechanism in the decomp
|
||
(likely class behavior, not a DAT property);
|
||
(b) row templates are reachable ONLY via the targeted
|
||
`ImportInfos(dats, layoutId, elementId)` overload (`0x10000248` stat
|
||
row in `0x21000045`; `0x10000536` title row in `0x2100005E`) — the
|
||
plain import's #375 prototype-skip hides them;
|
||
(c) authored row geometry: icon 20x20 at X=0 (code: 16x16 at X=4),
|
||
name X=25 W=150, value X=175 W=100 right-justified, 7px gutter to the
|
||
282px row edge; row Highlight media is `0x06000F93` (code uses
|
||
`0x06001397` — flagged, CT5 verifies).
|
||
Title chain verified end-to-end: titleId → EnumMapper `0x22000041`
|
||
(canonical key, e.g. `ID_CharacterTitle_War_Mage`) → compute_str_hash →
|
||
StringTable `0x2300000E` → text (id 13 = "War Mage").
|
||
Original scope: Live-DAT probe of layout
|
||
`0x2100002E`: attribute/skill row templates (icon x/y vs our hand-built
|
||
rows), the value-column right margin, header element fonts/colors
|
||
(level `0x1000023B` color — item 5), Titles-page elements
|
||
(`0x1000052F/32/35` geometry, row template, scrollbar), window
|
||
min/max constraints. Output: research doc + InstalledDat pins (the
|
||
tooltip/scrollbar-pin pattern). No production changes.
|
||
|
||
**CT2 — Runtime title ownership + wire. LANDED 2026-08-24.** Parsed
|
||
`0x0029 CharacterTitle` (retail's `CharacterTitleTable::UnPack
|
||
@0x005c6e90` — the leading ACE `1u`/retail-Pack-constant field is
|
||
discarded, matching retail's own read) and `0x002B UpdateTitle`
|
||
(`CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0`: title id +
|
||
setAsDisplay). New sibling owner `RuntimeCharacterTitleState`
|
||
(`RuntimeCharacterState.Titles`) holds the earned-title set + display
|
||
title id, clears at generation reset (`CaptureOwnership`/`IsConverged`
|
||
extended with `TitleCount`/`DisplayTitleIsDefault`), and fires
|
||
`TableReplaced`/`TitleAdded`/`DisplayTitleChanged`. Outbound
|
||
`TitleSet (0x002C)` ships behind `IRuntimeCharacterCommands.SetTitle`
|
||
on both hosts (`DirectGameRuntimeCommandAdapter` direct-send,
|
||
`CurrentGameRuntimeCommandAdapter` via the `LiveCommandBus`/
|
||
`LiveSessionCommandRouter` queue) — verified against retail's own
|
||
`CM_Social::Event_SetDisplayCharacterTitle @0x006a5720`, which sends
|
||
the wire message and touches no local field; the display title updates
|
||
only from the server's own echo. No register row: this slice
|
||
introduces no retail deviation. App-layer `CharacterTitleResolver`
|
||
(`src/AcDream.App/UI/Layout/CharacterTitleResolver.cs`) ports
|
||
`GetCharacterTitleFromID`'s EnumMapper(`0x22000041`) → hash →
|
||
StringTable(`0x2300000E`) chain for CT3/CT4 to consume; Runtime stays
|
||
id-only. Conformance tests against ACE's writer shapes
|
||
(`tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs`),
|
||
Runtime owner tests (`RuntimeCharacterTitleStateTests.cs` +
|
||
`RuntimeCharacterStateTests.cs` integration), a wire-send command test
|
||
(`DirectGameRuntimeCommandAdapterTests.cs`), and an InstalledDat pin
|
||
(`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/5/13/14) all pass.
|
||
|
||
**CT2 fix round (Opus dual-lens review, 2026-08-24).** Four SHOULD-FIX
|
||
corrections landed. **F1 (the important one):** the NOTICE broadcast is
|
||
unconditional (retail's server-side `SendNotice_AddCharacterTitle` fires
|
||
regardless of prior membership), but the client-side table ADD is
|
||
DEDUPED — `gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990`
|
||
walks `mTitleList` and returns without effect when the id is already
|
||
present, only inserting + adding the row on a miss.
|
||
`RuntimeCharacterTitleState.ApplyUpdateTitle` (which models the CLIENT
|
||
receive side, not the server send side) now fires `TitleAdded` only on a
|
||
genuine new membership; the inverted pin is
|
||
`ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision`.
|
||
**F3:** the send-side `titleId == 0` rejection is REMOVED from both
|
||
command adapters — retail's own send path
|
||
(`Event_SetDisplayCharacterTitle @0x006a5720`) packs whatever id it is
|
||
handed, and ACE accepts id 0 (`CharacterTitle.Invalid` is a defined enum
|
||
value); retail's actual protection is the UI ghost-when-current gate
|
||
(CT3's job), not a send-side rejection. No register row: removing the
|
||
guard makes acdream MORE retail-exact, not less. The fix round also
|
||
closed four SHOULD-FIX-adjacent items: A2 (`ResetSession` now publishes
|
||
`TableReplaced` unconditionally and `DisplayTitleChanged` when the
|
||
display id was non-zero before the clear, matching the
|
||
`LocalPlayerState.Clear()` precedent), A3 (`RuntimeCharacterState
|
||
.CaptureOwnership` reads the new non-allocating `Titles.Count` instead of
|
||
`EarnedTitleIds.Count`), A4 (the whole mutation in `ReplaceTable`/
|
||
`ApplyUpdateTitle` now happens under one `_gate` hold, with change flags
|
||
computed inside the lock and events raised after release), and A5 (every
|
||
revision bump is now gated on an actual state change — a no-op wire
|
||
resend produces zero revision edges; `TableReplaced` itself still fires
|
||
unconditionally per retail's own `Refresh()` dispatch). A1
|
||
(`CharacterTitleResolverLiveDatTests` now honors `ACDREAM_DAT_DIR`
|
||
first), A6 (documented the `EmitResult` `primaryObjectId`-as-title-id
|
||
precedent inline), A7 (corrected the "third consumer" comment — CT1 §5
|
||
already records `gmAttributeUI::PostInit`'s icon-DID lookup as that third
|
||
consumer; CT5 is where the shared `GetDIDByEnum` helper gets factored),
|
||
and A8 (`CharacterTitleResolver` now memoizes the final resolved string
|
||
per title id, the DAT-static equivalent of retail's lazy-hash cache on
|
||
the string buffer) round out the fix round.
|
||
|
||
**CT3 anchors from the CT2 review** (carried forward for CT3 to consume,
|
||
not yet acted on):
|
||
1. CT3 must refresh the display-title TEXT from `TableReplaced` as well
|
||
as `DisplayTitleChanged` — retail's
|
||
`RecvNotice_UpdateCharacterTitleTable` unconditionally `Refresh()`es
|
||
on every `0x0029` arrival, not only when the display id differs.
|
||
2. ACE sends NO echo when re-setting the already-current title — the
|
||
Set-as-Display button must not wait for a confirmation that never
|
||
arrives; retail prevents the send in the first place via the UI
|
||
ghost-when-current gate.
|
||
3. Retail's fallback display text when a title id doesn't resolve is the
|
||
hardcoded literal `"Unknown"` (`Refresh @0x0049abc0`), not a
|
||
StringTable key — `CharacterTitleResolver.Resolve` returning `null`
|
||
is the correct signal for CT3 to substitute that literal.
|
||
4. The deduped client-side add contract (F1 above) — CT3's title-list
|
||
row rendering must not assume every `TitleAdded` firing corresponds
|
||
to a wire arrival; the reverse still holds (every genuine new row has
|
||
a `TitleAdded` firing).
|
||
|
||
**CT3 — Titles page UI.** Bind the authored page through the standard
|
||
GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero
|
||
bespoke widgets): sorted rows via the ported title-table lookup,
|
||
selection, ghost-when-current logic (state 0xd contract), display-title
|
||
text, Set-as-Display round trip, scrollbar. Retires half of AP-109.
|
||
|
||
**CT4 — Header identity block.** Retail composition: name; "<Gender>
|
||
<Heritage> <DisplayTitle>"; PK status line — authored fonts/colors
|
||
(pure white per probe), live refresh on display-title change and PK
|
||
status, identical on Attributes AND Skills pages. Level color from the
|
||
authored element. Retires the rest of AP-109's UI half.
|
||
|
||
**CT5 — Row alignment + value gutter.** Reconcile our hand-built
|
||
attribute/skill rows with the authored row templates from CT1: icon
|
||
placement, name/value columns, the authored right margin that reserves
|
||
the scrollbar gutter.
|
||
|
||
**CT6 — Resize + scrollbar contract.** CT6 research lead (Fable,
|
||
2026-08-24, follows CT1 correction (a)): `gmPanelUI::ResizeTo
|
||
@0x004BC6E0` is a bare tailcall to `UIElement::ResizeTo` — no clamp
|
||
there, so the minimum lives in the generic Resizebar drag path reading
|
||
element attributes, and the authoring to probe is the PANEL-HOST layout
|
||
`0x2100006E`'s slot elements (Character slot per `RetailPanelCatalog`,
|
||
sibling of social `0x1000018F`) — NOT the character layout root CT1
|
||
probed. First CT6 step: probe the host slots' min/max + resize
|
||
authoring, then read `UIElement_Resizebar::StartMouseResizing
|
||
@0x0046B7E0`'s clamp source verbatim.
|
||
Original scope: Character window Y-resizable to
|
||
the authored minimum; the stat list shows its scrollbar when the
|
||
resized viewport overflows (the full-track/disabled behavior from the
|
||
2026-08-24 scrollbar work applies as-is); authored min/max constraint
|
||
enforcement verified as the STANDARD path for every registered window
|
||
(one shared mechanism in `RetailWindowFrame`/`RetailWindowManager`,
|
||
no per-window special cases).
|
||
|
||
**CT7 — Connected gate.** Test script
|
||
(`docs/research/2026-08-24-campaign-ct-test-script.md`), owner drive:
|
||
titles round trip against ACE (earn/set/display), header lines vs
|
||
retail side-by-side, resize behavior, row alignment screenshots.
|
||
|
||
## Review protocol
|
||
|
||
Per slice: Sonnet implements → Opus dual-lens review (lens 1
|
||
retail-faithfulness vs the cited decomp anchors; lens 2 architecture —
|
||
GUI-class standardization, Runtime ownership boundaries, no
|
||
controller-side state) → fix round → full hermetic suite green.
|
||
Commits to the worktree branch as slices land; **no gitea push until
|
||
the owner directs it**.
|