Applies all 11 items from the Opus dual-lens review of 989f6652 (0
blockers, 7 SHOULD-FIX, 4 NOTE):
- S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right
after PushAlpha and wraps OnDraw + the children walk +
OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere
@0x0069FA30 shape, which clips an element's OWN DrawSelf too, not
just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph
blits to its own clipped surface rect; UIRegion::DrawSelf
@0x0069F1A0 blits per clip rect). Deleted the two now-redundant
ad-hoc self-clips this supersedes: UiText.DrawText and
UiField.DrawMultiLine both pushed their own (0,0,Width,Height) —
exactly what the new ambient clip already provides one level up.
Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox,
an authored INNER sub-rect that can be smaller than and offset from
the button's own full rect — a genuine narrower viewport, not a
redundant duplicate.
- S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override
— correct under the old opt-in-false default, inverted under the
new default-true (an unconfigured list would stop clipping instead
of clipping like everything else).
- S4: pinned the escaped-popup input path end to end. New
UiAncestorClipTests test mounts a menu inside a short window on a
real UiRoot, opens it, and proves a click in the escaped popup
region reaches the menu through UiRoot.PopupHit (a plain top-down
walk is proven to reject the same point first). UiRoot.WantsMouse
now also checks PopupHit — it previously only checked Captured/
HitTestTopDown, so a game action could fire underneath an open
dropdown's escaped region. OnMouseDown/OnScroll already routed
through PopupHit first (#374); unchanged.
- S5: strengthened the Titles-divider regression test's positive
half. The old assertion only checked SOME quad's Y fell in a band —
vacuously true given other same-band content. Now asserts the
divider's exact rect (X and Y), then diffs against the same rect
with the divider hidden (Visible=false) to prove the quad was
actually attributable to it.
- S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/
Options mounted through their real production Bind entry points
with a non-zero sprite resolver, drawn via RecordingGpuDevice,
asserting a per-window vertex floor (~40-45% of this session's
observed baseline: Character 588, Chat 162, Vendor 54, Options 240)
plus one key sprite id read LIVE off the bound controller/element
(never hardcoded). Character's key sprite (RetailChromeSprites.
TopEdge) specifically exercises OnDrawAfterChildren, the exact path
S2's caution note flagged. Inventory/Paperdoll/social/map-house
skipped — no single fixture-driven top-level Bind entry point.
- S6: added the CT-GF1 subsection to the campaign plan's ledger
(989f6652 + this fix round; CT7 re-gate still owed).
- S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect
(0,0,ScreenSize), not null — retail's own popup region is
SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region
bounded by the screen), not truly unbounded. AD-113 amended.
- N1: UiRoot overrides ClipsChildren => false — the root's own region
IS the screen (the viewport already scissors it), so this is a
safety net against a momentarily zero-sized root silently blanking
the whole UI tree under the new ancestor-clip default.
- N2: added the empty-clip subtree cull (retail's var_24 gate
@0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly
separate traversal untouched by this change. New test proves a menu
inside a fully-clipped (zero-width) window still draws its open
popup via the overlay pass while the main pass draws nothing.
- N3: CT7 script §5 now names the collapsed-toolbar check and the
four highest-overflow windows (combat/vitals bar, Options
bottom-button row, map/house page, floaty chat) as explicit
eyeball items for the re-gate.
- N4: verification below covers both the working tree and the clean
committed tree.
Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate
@0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip);
UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen-
clipped popup region).
Verification (both runs green, --filter "Lane!=InstalledDat&
Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&
Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&
Status!=KnownFailure"): full Release solution build green; working
tree 14,900+ tests across every project (one LandblockPresentation
PipelineTests flake reproduced ONLY under full-solution parallel
load, passes standalone and on rerun — unrelated to this change,
streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT
_TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core
tests). Clean committed tree (git stash push -u the uncommitted
owner probe + docs files, rerun, stash pop) reported in the session
summary.
src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively
(git add -p) so only this commit's own two hunks (ClipsChildren
override, WantsMouse) landed; the probe hunk is untouched and stays
uncommitted, same as before this fix round.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:
BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, clamping MinWidth to 320 while the
window's actual mounted outer width stayed 310 — silently below its own
minimum until RetailWindowManager.ResizeTo forcibly widened it despite
ResizeX=false. Fixed with a new
RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame opt-out
(chrome inset = 0 for constraint resolution only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.
S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.
S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.
S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.
N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.
N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.
CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.
Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CT6 (Campaign CT slice 6): the resize clamp source is the SHARED
gmPanelUI host (0x100005FE in LayoutDesc 0x2100006E), not 0x2100002E's
own root and not the Character/Skills slot 0x1000018E either — live
probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no
horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that
the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are
direct children of the host, not the content parent. Decomp chain:
UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls
UIElement::StartResizing(this->GetParent(), ...), stashing drag state on
that parent; UIElement::MouseResizeElement @0x00461130 then reads
GetAttribute_Int(this, 0x3C..0x3F) off that same element every
mouse-move.
RetailUiRuntime.MountCharacter now imports the host element and passes
it as RetailWindowFrame.Options.DatConstraintSource, matching the
existing MountSideVitals pattern.
CharacterStatController.RebuildActiveList now wraps BOTH the Attributes
and Skills tabs' rows in the same UiScrollablePanel viewport (previously
only Skills got one; Attributes rows had no clipping/scrolling and the
shared scrollbar was force-hidden — owner report item 2). The shared
scrollbar is now always bound + visible; UiScrollbar's own
IsPresentationVisible/IsModelDisabled already draw the correct
full-track "disabled" thumb when content fits. This surfaced and fixed
a real #372/#412-class anchor-baseline bug: the viewport's
Left|Top|Bottom anchor was capturing its baseline margins lazily on its
own first ApplyAnchor call, which happens AFTER the ListBox has already
grown from its raw DAT height to its mounted height, permanently
capping the viewport short on every later resize. Fixed with an eager
CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox
.Viewport's own lazy getter.
CharacterTitlesController.Bind gained the same defensive
Anchors = Left|Top|Bottom fallback for the Titles ListBox that
CharacterStatController already had (a no-op on the real DAT — both the
Titles page and its ListBox already carry a real authored LayoutPolicy
that stretches correctly).
Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight,
set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read
identically by interactive drag, RetailWindowManager.ResizeTo, and
RetailWindowLayoutPersistence's restore clamp. No gaps found; no
register row (every number is a live-probed authored DAT value or a
structural correctness fix, nothing inferred).
Tests: CharacterStatControllerTests
.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar,
CharacterTitlesControllerTests
.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips,
RetailWindowFrameTests
.NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds
(shared-mechanism regression pin), CharacterPanelLiveDatTests
.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat
pin). Existing attribute-row tests updated from list.Children to
Descendants(list) for the new nested-viewport shape (the pattern skill
rows already needed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of ed652ed8 found 2 blockers + 5 should-fix. All applied.
BLOCKERS:
- Bind the luminance pair (0x100005C5/0x100005C6): caption "Luminance:"
(UTF-16, PE-byte-decoded from the gmStatManagementUI vftable-adjacent
data at @0x007c3dd4) and value "<available> / <maximum>" (narrow
"%s / %s" @0x007c3dcc) — both literals independently re-derived from the
raw acclient.exe bytes and confirmed byte-exact against the review's
claim. Numbers format through a new shared FormatXp helper
(.ToString("N0", InvariantCulture) — retail's ExperienceSystem::XPToString
equivalent), also now used by Total XP / XP-to-next-level (previously an
un-invariant bare "N0"). Hide path switched from Visible=false to
retail's own UIElement_Text::ClearAllText mechanism
(@0x004f0e31/@0x004f0e3c — empty LinesProvider, leave layout); each
LinesProvider re-reads data() on every draw, so no separate refresh call
is needed.
- CharacterIdentityText.StripLeadingArticle deleted: retail AppendText's
the resolved title VERBATIM (@0x004f0990); 26 real ACE CharacterTitle
entries begin with "The" and were being mangled. The dead
CharacterSheet.Race fallback is deleted alongside it — retail's
InqGenderHeritageDisplay creature-type argument is a hardcoded literal 0
(@0x004f08db), no producer exists.
SHOULD-FIX:
- PK line re-sourced: classifies off the live ClientObject.PublicWeenieBitfield
PWD bits (0x20 IsPK / 0x02000000 IsPKLite — ACCWeenieObject::IsPK/IsPKLite
@0x0058c8b0/@0x0058c8a0) instead of a bitwise test against raw
PropertyInt 134, which carries ACE's own PlayerKillerStatus enum bit
layout, not the PWD layout. PropertyInt 134 already drives the correct
bits via the existing PlayerKillerStatusBitfield.Apply; this is a
re-source, not new wiring. Deleted the 0x4|0x8 combined-flag test case,
which asserted a non-retail answer.
- Register AP-109 row: restores CT3's Titles-page narrowing paragraph
(CT4's edit had compressed it to a bare pointer phrase), corrects the
rank-prefix source to PropertyInt 0x1E (AllegianceRank) read live off
the qualities bundle — not RuntimeAllegianceState, which is a different
UI's (SocialAllegiancePageController) own documented substitute —
corrects the title-table size from an estimated 22 functions/~200
strings to the actual 17 functions/~170 strings (AllegianceSystem::GetTitle's
dispatch switch read directly), and downgrades the evidence claim.
Filed AP-235 for the gender/heritage hardcoded-table-vs-live-EnumMapper
mechanism divergence, pointing at the ALREADY-EXISTING
RetailDataIdResolver.Resolve helper as CT5's unification seam.
- CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors
extended with the luminance pair's own occurrence-count + font/color
pins, matching every other header id's pattern.
Also landed: an InstalledDat pin
(GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain) proving
CharacterIdentityText.GenderDisplayName/HeritageGroupDisplayName match the
live retail EnumMapper chain (master map category 1 ->
ClientEnumToID[0x10000001]/[0x10000002] -> EnumMapper DIDs
0x2200000A/0x2200000B) byte-exact, including the two entries the review
flagged as unverified guesses (10 "Penumbraen", 12 "Olthoi" — both
correct). CharacterSheetProvider.BuildSheet's level read switched from a
GetInt+ContainsKey double lookup to one TryGetValue. Plan ledger's
test-provenance sentence corrected (Bind_HeaderElements_... predates CT4,
extended to cover PkStatusId).
Tests: CharacterStatControllerTests (verbatim title incl. "The Noob",
luminance content/gate, luminance text binding, extended
Bind_HeaderElements_... covering PkStatusId), CharacterSheetProviderTests
(PK status driven through ClientObjectTable.UpdateIntProperty instead of
a raw property write), CharacterPanelLiveDatTests (luminance pin, gender/
heritage EnumMapper pin). Full hermetic solution suite green under Release
(0 failures, 15 projects); InstalledDat pins green (197/197, excluding one
confirmed pre-existing unrelated failure — TowerAscentReplayTests, verified
to fail identically with these changes stashed out).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retires the rest of AP-109's UI half: the character panel's Name/Heritage/
PkStatus/Level header identity block is now live and DAT-faithful on both
Attributes and Skills pages (verified: CharacterStatController.Bind already
scopes Label/LabelAuthoredColor to the ONE physically-visible page container,
so both tabs share the same bound widgets).
- Name/Heritage/PkStatus/Level switch from hand-picked Body/Gold runtime
colors to the widget's own authored DefaultColor (LabelAuthoredColor) —
CT1's live-DAT pin (HeaderElements_AuthorExpectedFontsAndColors) confirmed
all four already carry the correct FontColor (white/white/white/pale-gold
with Outline); the former "runtime color, dat carries none" comment was
false.
- PkStatus resolves through StringTable 0x23000001 by key
(ID_StatManagement_Header_PKStatus_PK/_PKL/_NPK) with a bitwise
IsPK/IsPKLite test (gmStatManagementUI::UpdatePKStatus @0x004F00A0) instead
of the prior exact-equality switch, which silently dropped combined-flag
PlayerKillerStatus values. Live-DAT-verified strings: "Player Killer" /
"Player Killer Lite" / "Non-Player Killer" (new InstalledDat pin
PkStatusKeys_ResolveExpectedAuthoredStrings).
- Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal "???"
when absent (CharacterSheet.Level is now int?).
- Heritage line appends CT2/CT3's resolved RuntimeCharacterTitleState
display title through CharacterTitleResolver, refreshing live on both
TableReplaced (0x0029) and DisplayTitleChanged (0x002B) —
CharacterSheetProvider's ChangeBinding now subscribes to both.
- Name-line ruling: ships the PLAIN-NAME case only. Retail's allegiance
rank-title prefix (AllegianceData::GetFullName @0x005B6950 ->
AllegianceSystem::GetTitle @0x005B8DD0) needs a ~200-string, 22-function
heritage x gender table (verbatim decomp literals, e.g.
GetAluvianMaleTitle @0x005B7BC0's Yeoman/Baronet/.../High King) judged out
of reasonable size for this slice. RuntimeAllegianceState already carries
the local player's own rank; only the string table is missing. Registered,
not silently omitted.
- Luminance pair (0x100005C5/0x100005C6): CharacterSheet.AvailableLuminance/
MaximumLuminance (PropertyInt64 6/7) already flow generically through both
the PlayerDescription snapshot and the live 0x02CF private-update parsers
(no wiring gap). The retail show/hide gate (Level >= 200 &&
MaximumLuminance != 0, UpdateExperience @0x004F0A70) is wired and toggles
Visible on both elements every sheet refresh; the exact caption/value text
could not be recovered this slice (retail's SetText source resolves
through a Binary-Ninja-mislabeled data pointer, not a StringTable key — a
DAT string-table sweep found no match), so content stays unbound rather
than guessed.
- AP-109 narrowed accordingly (register row amended in the same commit).
Tests: CharacterStatControllerTests (heritage composition + live title
update, name stays plain, level int/"???" with authored — not constant —
color across 3 cases, PK line shows resolved text in authored color across
3 statuses, luminance visibility across 5 level/luminance combinations) and
CharacterSheetProviderTests (PK key-by-status resolution including a
combined-flag case, no-resolver leaves PkStatus null, Level null-vs-present,
title resolution + live refresh on both title events + unsubscribe-on-
dispose, luminance Int64 read-through). Full hermetic solution suite green
under Release (0 failures across all 14 test projects); InstalledDat pins
green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of CT3 (03e073b7) found 1 BLOCKER + 2 SHOULD-FIX + notes.
BLOCKER: CharacterTitlesController never ported Refresh @0x0049abc0's own
SetSelectedItem(nullptr, 1) (@0x0049ac5a) — retail clears the current
title selection UNCONDITIONALLY on every Refresh() call, regardless of
whether the previously-selected id is still earned. OnTableReplaced
(0x0029) and OnDisplayTitleChanged (the display half of 0x002B) are
retail's two Refresh() call sites, so both now clear _selectedTitleId
before rebuilding/re-highlighting. OnTitleAdded (0x002B's add half) is a
DIFFERENT retail method — RecvNotice_AddCharacterTitle @0x0049a990 splices
one row without ever touching m_pSelectedItem — so it deliberately still
preserves selection. Net effect: after the user sets a display title and
ACE echoes 0x002B, the previously-highlighted row now goes dark and the
Set-as-Display button re-ghosts, matching retail; earning a new title
while a row is selected still leaves that selection alone.
SHOULD-FIX: ported AddTitleToList @0x0049A840's early-outs
(@0x0049a873/@0x0049a914) — an id of 0, or an id CharacterTitleResolver
fails to resolve, now produces NO row at all. The "Unknown" fallback
literal belongs only to the display-title text (Refresh @0x0049abc0's
other half), never a row — this was previously ported backwards.
SHOULD-FIX: rows and the display text now use their UiText's own authored
DefaultColor instead of a hardcoded Vector4.One, and each LinesProvider
now returns a cached UiText.Line[] built once per text change instead of
allocating a fresh array literal every draw call (pattern:
CharacterCreationSkillsPage.cs:829).
Notes (all ruled in): corrected two CharacterStatController comments that
falsely claimed the Titles page authors its own copies of the raise
buttons (verified against the fixture — it does not; the hide loop that
comment guarded is a defensive no-op given Visible's draw/click cascade,
kept only for the contentPage-not-found fallback); switched the row sort
from List.Sort to a stable OrderBy/ThenBy (ties broken by title id) so
equal-text rows keep retail's insert-after-equals order; wrapped the
title-resolver delegate in RetailUiRuntime.MountCharacter with the same
DatLock the row-template resolver already takes (DatCollection is
documented not thread-safe); set the list box's authored 24px row height
so wheel/line scroll lands row-aligned; kept the bind-time display-text
refresh with a comment explaining why the pre-notice "Unknown" frame is
unreachable in live play (ACE always sends 0x0029 before this panel can
open).
Tests: inverted TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted
into TableReplaced_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned (cites
@0x0049ac5a), added its DisplayTitleChanged twin, and added
TitleAdded_PreservesSelection (the case most at risk from the blocker fix).
Inverted Rows_UnresolvedTitle_ShowsRetailUnknownLiteral into
Rows_UnresolvedTitle_ProducesNoRow (cites @0x0049a873/@0x0049a914) and added
Rows_TitleIdZero_ProducesNoRow for the other early-out. Extended
ClickingSetDisplay_..._AndMutatesNothingLocally to assert the row set and
selection are untouched by the click. Added
Fixture_PageCaptions_ResolveToNonEmptyText, which rebuilds the committed
character_2100002E.json fixture with a stub string resolver to pin this
class's own claim that the two page captions (0x1000052E/0x10000531) carry
a resolvable authored StringInfo.
Verified pre-existing/unrelated: the full hermetic suite run surfaced 2
failures in AcDream.App.Tests (LiveEntityNetworkBranchRoutingTests IL-shape
assertion, GameWindowRenderLeafCompositionTests IL-shape assertion) that
also fail with these five files stashed back to their pre-fix-round state —
confirmed unrelated to this change.
Build green. CharacterTitlesControllerTests: 24/24 (was 21, +3 net after
one invert-and-split and two new facts). Full hermetic solution suite
(Lane!=InstalledDat/PreparedPackage/Live/Manual/Timing/Windows/Linux/
SystemFont, Purpose!=Diagnostic, Status!=KnownFailure): only the two
pre-existing IL-shape failures above; every other project green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Campaign CT slice CT2: the client now learns the character's earned
titles and current display title from the server, owns that state in
Runtime, and can send a display-title change. No UI (CT3/CT4).
Wire (Core.Net):
- GameEvents.ParseCharacterTitleTable (0x0029 CharacterTitle): retail
CharacterTitleTable::UnPack @0x005c6e90 skips a leading u32 into no
field — its own Pack @0x005c6e40 always writes the literal 1 there,
matching ACE's unconditional Writer.Write(1u) — then reads
displayTitleId, then a count-prefixed PList<uint> of earned ids.
- GameEvents.ParseUpdateTitle (0x002B UpdateTitle): titleId +
setAsDisplay, per CM_Social::DispatchUI_AddOrSetCharacterTitle
@0x006a54c0 -> Handle_Social__AddOrSetCharacterTitle @0x00564260,
which ALWAYS adds (SendNotice_AddCharacterTitle, unconditional) and
additionally sets display only when setAsDisplay != 0
(SendNotice_SetDisplayCharacterTitle, gated).
- SocialActions.BuildTitleSet / WorldSession.SendSetTitle: outbound
TitleSet (0x002C), u32 titleId, matching ACE's GameActionSetTitle.
- GameEventWiring gains onCharacterTitleTable/onUpdateTitle delegate
holes (Core.Net cannot reference AcDream.Runtime directly).
Runtime:
- New RuntimeCharacterTitleState (RuntimeCharacterState.Titles): earned
title id set + display title id, TableReplaced/TitleAdded/
DisplayTitleChanged events matching retail's unconditional-add /
gated-display-set contract, clears at generation reset.
RuntimeCharacterOwnershipSnapshot/CaptureOwnership/IsConverged and
RuntimeCharacterSnapshot extended (trailing optional fields, no
existing call site broken).
- IRuntimeCharacterCommands.SetTitle: generation-gated, sends
TitleSet only — NO optimistic local mutation. 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 (the CA-campaign
lesson: never re-add an optimistic write). Implemented on both hosts
(DirectGameRuntimeCommandAdapter direct-send;
CurrentGameRuntimeCommandAdapter via LiveCommandBus /
LiveSessionCommandRouter's new SetTitleRuntimeCmd).
- LiveSessionEventRouter wires the two inbound events unconditionally
(RuntimeCharacterState.Titles is a required child, not an optional
sibling like Fellowship/Allegiance).
App (non-UI plumbing + resolver):
- CharacterTitleResolver (src/AcDream.App/UI/Layout/): ports
CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0 — titleId ->
EnumMapper(0x22000041) canonical key -> compute_str_hash ->
StringTable(0x2300000E) localized text. Runtime stays id-only; CT3/
CT4 consume this for display. DIDs hardcoded per the RetailKeyNames
precedent (CT1 verified them end-to-end).
Register: no new row. Retail's send path is non-optimistic and so is
ours — no deviation to record for this slice.
Tests: wire conformance (byte-exact + truncation) in
CharacterTitleEventsTests.cs + SocialActionsTests.cs; Runtime owner
unit tests in RuntimeCharacterTitleStateTests.cs plus integration in
RuntimeCharacterStateTests.cs; a no-local-mutation command test in
DirectGameRuntimeCommandAdapterTests.cs; an InstalledDat pin
(CharacterTitleResolverLiveDatTests.cs, ids 0/1/2/3/5/13/14, run green
with ACDREAM_RUN_INSTALLED_DAT_TESTS=1). Full solution build green;
hermetic filtered suite green (15,380 passed / 0 failed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applies the Opus dual-lens review corrections to CT1's DAT ground-truth
research (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md):
- Window constraints (BLOCKER): replaced the "likely a hardcoded
ResizeTo/SetMinSize" guess with the verified mechanism —
UIElement::ResizeTo clamps only via element attributes 0x3C-0x3F,
nothing writes them at runtime, and retail resizes the SHARED
gmPanelUI host (LayoutDesc 0x2100006E, slot 0x1000018E) rather than
0x2100002E's own content root. Flags the unresolved 300x600-vs-300x362
size tension for CT3/CT6 and marks the host elements NOT PROBED by
CT1.
- RowHighlightSprite upgraded from a flagged hedge to a SEALED VERDICT:
the stat row's selected-state media is 0x06000F93
(gmAttributeUI::UpdateSelection -> InfoRegion::SetState on template
0x10000248), not 0x06001397 (which is legitimately the spellbook
row's separate selected-overlay mechanism). Falsifies the matching
comment in CharacterStatController.cs and dated-corrects the older
2026-06-26 doc at the spot that originated the wrong sprite id.
- Replaced the "18px gutter + 7px = 25px" derived story with the bare
authored rectangles (the numbers don't compose cleanly: 300-281=19,
and the 282px row overlaps the 281px scrollbar band by 1px) — CT5
must implement the authored numbers directly, never a derived
listWidth-18 formula.
- Plan doc: corrected the UpdateButtons ghost rule (no selection ->
Ghosted, not "ghosts when selected == current") and added the
AddTitleToList row-write contract for CT3.
- Pins: CharacterPanelLiveDatTests now honors ACDREAM_DAT_DIR first
(matching InstalledDatFactAttribute and its sibling live-DAT test
classes), hoists five vacuous bare-foreach assertions to counted
.ToList() pins, and adds the stat ListBox + scrollbar rect pins that
CT5/CT6 depend on.
- Doc hygiene: marked several probe-session observations (header
geometry "identical" claim, 0x06004CC2 characterization, the
master-map/category-map dump) as unpinned inference vs. committed
fact, corrected the 0x1000052D "throwaway container" mislabel, and
stated the header table's parent-relative coordinate frame.
- Recorded the CT5 gold this round found: InfoRegion::InfoRegion's
icon-DID lookup (a third GetDIDByEnum consumer, category
0x10000002) and gmSkillUI::RebuildSkillList's section-header order
confirmation, plus the RowHeight=22-vs-authored-20 divergence for
attribute rows.
Verified: ACDREAM_RUN_INSTALLED_DAT_TESTS=1 CharacterPanelLiveDatTests
filter 9/9 green; hermetic App suite filter (CI's Lane exclusion list)
6111/6111 green. No production code changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Name via AllegianceData::GetFullName, heritage line composed with the
display title, level fallback literal, PK line from StringTable
0x23000001 ID_StatManagement_Header_PKStatus_* keys (the chat-label
mechanism).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>