Ports the 17-function heritage×gender AllegianceSystem::GetTitle
@0x005B8DD0 dispatch and AllegianceData::GetFullName @0x005B6950 as
AllegianceRankTitleTable (src/AcDream.App/UI/Layout/), then wires both
retail call sites: the examination window's title bar
(AppraisalUiController.BuildCharacterTitleBarName, from ApplyCreature's
character branch) and the character panel's name line
(CharacterSheetProvider.BuildSheet).
Census confirmed directly against the decomp (matches AP-109's
2026-08-25 correction exactly): 11 heritages -> 17 functions. Gearknight
and Tumerok author only a MALE Get*Title function, reused for both
gender dispatch branches; Lugian authors only a FEMALE one, reused for
both. Heritage id 0xA (Penumbraen) aliases to the Shadowbound (5)
functions on both branches. Olthoi/OlthoiAcid (12/13) are excluded by
GetTitle's own unsigned range check (heritage-1) <= 0xa. Every one of
the 17 functions shares an identical unsigned rank bounds test
(rank-1) > 9 -> no title (valid range 1..10; there is no "clamp to
rank-10 title" behavior for an out-of-range rank). All 170 title
strings transcribed verbatim from the decomp, including several
PE-byte-recovered data-literal indirections in the Sho/Gearknight/
Tumerok tables ("Kou", "Ou", "Dux", "Ona", "Rea", "Tah") that match
published AC lore exactly.
GetFullName: title = GetTitle(rank, heritage, gender); when GetTitle
resolves nothing, the output is the plain name; when it resolves, the
output is "title" + a single ASCII space (PE-byte-recovered at
data_794098) + name.
Two call sites independently re-verified against the decomp for the
rank/heritage/gender property ids: CharExamineUI::SetAppraiseInfo's
local AllegianceData struct (proven by its ctor/dtor pair) never shows
an explicit field write for _rank/_hg/_gender in the decompile — a
Binary Ninja struct-flattening artifact, not a missing read — while
gmStatManagementUI::UpdateCharacterInfo shows the same three
CBaseQualities::InqInt(0x71/0xbc/0x1e) calls as plain, unambiguous
locals, confirming Gender=0x71/HeritageGroup=0xBC/AllegianceRank=0x1E
as the three inputs at both sites (ruling R8: read live off the
appraisal/qualities bundle, never RuntimeAllegianceState).
Register: AP-109 is NARROWED, not retired. Its stated risk (a ranked
character's Name line showing plain-name-only) is closed, but the same
CT4 narrowing also flagged FormatXp's non-byte-identical
GetNumberFormatA approximation as "this row's own residual sliver if
one is ever found" — that caveat is untouched by this slice and is now
the row's only surviving open item.
Tests: AllegianceRankTitleTableTests (per-function golden values,
gender-reuse rules, Penumbraen alias, Olthoi exclusion, rank/heritage/
gender bounds, GetFullName composition); AppraisalUiControllerTests
(title-bar prefix, plain-name fallback, monster-path regression pin);
CharacterSheetProviderTests (name-line prefix + plain-name fallback).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the last three retail extras-list row families from CharExamineUI::
SetAppraiseInfo @0x004B45F0 into CreatureAppraisalRows.BuildExtra, closing
the character-path extras list end to end (AS2 header + AS3 armor-level/
ratings/legend + AS4 here). All rows are CHARACTER-path only; the monster
path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) is unaffected and
pinned by a controller-level regression test.
Society row (gap G6, @0x004b49a1-@0x004b4c24): gated on PropertyInt 281
(Faction1Bits) being PRESENT — a literal reading of InqInt's found/not-
found return, not the ground-truth doc's informal "!= 0" value test.
Bit-priority if/else-if chain (Celestial Hand 0x1 -> Eldrytch Web 0x2 ->
"???" when Radiant Blood's 0x4 bit is ALSO clear -> else Radiant Blood)
comes straight off the decompiled branches. Rank-band suffix boundaries
(1-100 Initiate / 101-300 Adept / 301-600 Knight / 601-1000 Lord /
1001-1500 Master, no suffix outside every band) read directly off the
decomp's inclusive range checks @0x004b4ab9-@0x004b4b92 and match the
ground-truth table exactly. Color rule: green when the LOCAL player
shares the target's selected bit (checked first, so extra local bits
don't override a match), red when local has a different bit but not the
target's, normal when local has no society bits; the unrecognized "???"
branch never gets a color (retail's ebx_3 stays at its zero initializer).
The local player's own Faction1Bits comes from a new pure `localFactionBits`
parameter on BuildExtra — the composer never reads state directly.
Monarch/Patron/Followers cascade (gap G7, @0x004b4d97-@0x004b4f54): gated
by the caller on AllegianceRank (Int 30) >= 1, the SAME InqInt read AS2's
header AllegianceName binding already consumes. Four arms in retail's
exact order: MonarchsTitle (Str 21) absent -> "Alleg. Monarch:" + clamped
"%d Follower"/"%d Followers" (Int 35, singular only at exactly 1); present
+ PatronsTitle (Str 35) absent -> "Monarch:" only; both present and
ordinally equal -> one "Monarch/Patron:" row; both present and different
-> "Monarch:" then "Patron:".
Configurable extras (gap G8, @0x004b58be-@0x004b5c4d): Fellowship
(Str 10), Arrived in Dereth (Str 43 DateOfBirth), Time in Dereth (Int 125
Age via the ALREADY-PORTED RetailDurationText.Format — the same
ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls at
@0x004b59e0, reused rather than re-ported), Chess Rank (Int 181), Fishing
Skill (Int 192), Deaths (Int 43 NumDeaths, <= 0 -> "Has never died" with
the SAME "Deaths:" label, verified in the decomp), Titles Earned
(Int 262). Each row gates independently on its own property being
PRESENT (server already strips these per the target's visibility options
per ground truth §3) — no client-side option or success gating added. No
spacers separate these seven rows, matching the decomp exactly.
Seam: AppraisalUiController takes a new `Func<int> _localFactionBits`
dependency (per-call, never captured once — the secure-trade deferred-Func
lesson), invoked only on the character path. AppraisalRuntimeBindings
gained `LocalFactionBits`, wired in InteractionRetainedUiComposition from
`d.Character.LocalPlayer.Properties.GetInt(281)` — the SAME LocalPlayerState
instance CharacterSheetProvider already reads from, no new state path.
AS3 NIT 11: CharacterResponse_CombatRefreshRetainsArmorLevelRows now
applies a SECOND response with different armor-level values (proving the
refresh re-renders from fresh data) then a THIRD with armorLevels: null
(proving it clears). AS3 NIT 12: BuildExtra's XML doc now documents the
full authored row order with the @0x004b5d7d legend anchor. The test
Parsed() helper now ORs IdentifyResponseFlags.ArmorLevels into Flags
whenever armorLevels is supplied (realism sub-nit), applying uniformly to
every existing armor-level test in the file.
Ground-truth doc imprecisions found while verifying against the decomp
directly: (1) the Society gate is presence-of-property, not value != 0 —
InqInt's return is a found/not-found bool, the summary's "!= 0" phrasing
describes the common case but not the literal branch condition; (2) the
Society color rule's bit-priority (same-bit match checked before the
other-bits check) wasn't spelled out in the summary table, only "same/
different/none" — confirmed exact by reading all three branches
(@0x004b49fd/@0x004b4a49/@0x004b4a8b). No other disagreements found.
Register: docs/architecture/retail-divergence-register.md row AP-110
retires the "exhaustive character detail regions" clause from its
still-lacks list with a dated 2026-08-25 narrowing note (AS2+AS3+AS4
together closed the character-path extras list); the row's other
residuals (item-object preview, effective shield projection, cooldown-
remaining, augmentation-cost StringInfo, creature FontInfo-list
selection) are untouched.
Tests: 51 new rows-level tests in CreatureAppraisalRowsTests.cs (society
gate/bit-priority/band-boundaries/color-vs-local-faction, the full
allegiance cascade incl. follower singular/plural/clamp, each
configurable extra present/absent, "Has never died", monster-path
regression, one complete ordering-pin snapshot) plus 3 new controller-
level tests in AppraisalUiControllerTests.cs through the real LayoutDesc/
template binding (localFactionBits seam wired end to end, its default
fallback, and the strengthened monster-path regression). Hermetic
AcDream.App.Tests: 6253 passed. Full-solution hermetic run: 15,410
passed across all projects, 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus's dual-lens review of the AS2 header-identity-block commit
(f8a22589) approved the retail port as exact and ordered six
mechanical fixes:
- F13: correct the plan doc's "static tinted preview" claim about
retail's examine-window paperdoll — the decomp shows retail's clone
IS animated (BasicCreatureExamineUI::Init @0x004AB9C0 ->
CPhysicsObj::makeObject -> MorphToExistingObject +
play_script_internal, CreatureMode::Render driving update_position
every frame), just decoupled from the live target. AD-114 already
records the correct deviation; only the plan doc's rationale text
was wrong.
- F14: add a regression test pinning the monster path (character:
false, success:false, no String 5 / Int 0x105 marker) so element
0x1000053A stays cleared — the exact case that used to emit the
invented "Assessment incomplete" literal AS2 deleted. This is the
test coverage the AS2 commit message over-claimed already existed.
- F15: dedup the PK/PKLite PWD bit constants that were copy-pasted
across three files into named PublicWeenieFlags.PlayerKiller
(0x20) / PlayerKillerLite (0x02000000) values, routing all three
consumers (AppraisalUiController, CharacterSheetProvider,
LocalPlayerTeleportController) through them. Pure mechanical
substitution; the write side (PlayerKillerStatusBitfield.Apply) is
untouched.
- F16: file register row AD-115 for a real, previously-unrecorded
deviation the AS2 commit introduced: BuildCharacterTitleDisplay
clears element 0x10000151 when neither source resolves, where
retail (CharExamineUI::Show, BasicCreatureExamineUI::Init) never
clears it and would show the previous target's stale title
instead. Marked intentional — ours is deliberately better.
- F17: narrow BuildPlayerKillerDisplay's doc comment — retail's
`cur_weenobj != 0` guard @0x004b482f skips only the PK line, not
the whole response; the whole-response drop on a missing local
object is separate, pre-existing, and out of scope here.
- F18: ledger row for AS2 now reads "review fix round" with the land
commit and a note that the fix SHA is recorded at review-close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail's CharExamineUI::SetAppraiseInfo (@0x004B45F0, player-target
examine subview) binds four fixed header lines that acdream was feeding
from the wrong appraisal properties:
- 0x10000150 (Heritage) got raw string prop 4 verbatim. Retail composes
"<Gender> <Heritage>" via InqGenderHeritageDisplay (@0x005B5AE0) from
Int 113 Gender + Int 188 HeritageGroup, falling back to the assessed
object's creature-type name only when HeritageGroup == 0. Reuses the
Campaign CT gender/heritage tables in CharacterIdentityText (new
GenderHeritageDisplay helper) and the controller's existing
CreatureDisplayNameResolver for the creature-type fallback arm.
- 0x10000151 (Profession/title) got AllegianceName (string 47) — the
title line was never shown at all. Now resolves the CURRENT display
title from Int 261 CharacterTitleId through CharacterTitleResolver
(CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0), falling
back to String 5 Template verbatim when the id is absent or
unresolvable. RetailUiRuntime.MountAppraisal now wires the SAME
CharacterTitleResolver instance the D.2b Character panel already
owns (_bindings.Character.TitleResolver), resolved per call under
DatLock — never captured once at mount time, per the secure-trade
deferred-Func lesson.
- 0x10000152 (PlayerKiller) got MonarchsName (string 11) — never shown.
Ruling R7: retail reads the LOCAL weenie's PWD bits
(ACCWeenieObject::IsPK/IsPKLite @0x0058C8B0/@0x0058C8A0), never the
appraisal payload. Now reads the assessed ClientObject's
PublicWeenieBitfield directly (bit 0x20 -> "Player Killer", bit
0x02000000 -> "Player Killer Lite", else "Non-Player Killer").
Apply()'s existing bail-out when the object has left the local table
already matches retail's "weenie is gone, leave the line cleared".
- 0x1000053A (AllegianceName) invented a literal "Assessment
incomplete" on failed assess — zero retail provenance, deleted
outright. Retail clears the element first (ClearCreatureText already
does this every ApplyCreature call) then sets String 47
AllegianceName only inside the Int 30 AllegianceRank >= 1 gate.
Scoped to the character branch since retail's CreatureExamineUI
(monsters) never binds this element at all.
None of the four lines are success-gated — ACE sends the int/string
tables even on a failed assess, matching retail's own composition.
First-ever AppraisalView.Character controller test coverage (gap G10):
header composition, title/allegiance fallback and gating, all three PK
variants plus the missing-object clear case, failed-assess rendering
with a repo grep confirming the invented literal is gone, and a
regression pin proving the monster (character: false) path is
untouched.
Register: files AD-114 for the examination preview's animated clone
(mirrors the assessed target's live current pose via
CreatureAppraisalFramePresenter) versus retail's independently
animated private CreatureMode clone (BasicCreatureExamineUI::Init
@0x004AB9C0) — owner-ruled intentional deviation, 2026-08-25
("we animate it, and I like it").
Full hermetic suite green: 15,469 tests passed, 0 failed, 0 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Fixes the CT7 gate finding: on the Titles tab, the authored divider
0x10000530 escapes the Character window above its top edge at the
CT6-correct 372px mounted default (computed Y ~ -178, matching the
owner's screenshot). Retail clips child rendering to the intersected
ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the
element's screen Box2D plus a SmartArray<Box2D> of inherited clip
rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82),
and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect
only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw
walk rendered children unclipped by default, so any authored element
relying on clipping -- this divider, and the chat input row at small
window sizes (the owner's earlier "text input sticks out on resize"
report) -- became a visible artifact.
Mechanism (element-level, reusing the existing clip-rect-stack
infrastructure in UiRenderContext.PushClip/PopClip):
- UiElement.ClipsChildren now defaults to TRUE for every element
(was an opt-in used only by UiScrollablePanel/UiItemList). Each
element's children draw AND hit-test clipped to the intersection
of its own rect with the inherited ancestor clip; an element
positioned outside its parent's box silently disappears, matching
retail's non-empty-intersection gate. HitTest's existing early
bounds check already implemented this shape for ClipsChildren=true
elements -- flipping the default aligns hit-testing with the new
draw-clip default in one property, per the plan's own point 4.
- UiElement.ExpandsClipForPopup (default false) is the one opt-out:
retail spawns a menu popup as a SEPARATE top-level region
(UIElement_Menu::MakePopup), clipped only by the screen; acdream
draws UiMenu's popup inline from the owning button in a second
traversal (OnDrawOverlay, pre-existing -- its own doc comment
already says "regardless of this element's position in the tree").
DrawOverlays now resets the accumulated clip to unbounded
(UiRenderContext.PushClipUnbounded, sharing the existing clip
stack) for exactly the OnDrawOverlay call of an opted-in element.
UiMenu overrides ExpandsClipForPopup=>true, paired with
ClipsChildren=>false so its own out-of-bounds OnHitTest union (the
popup occupies ly<0 or ly>=Height depending on open direction)
stays reachable through the same early-bounds gate that now
defaults on for every other element.
Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow
OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the
ONLY OnDrawOverlay override client-wide, so it is the only element
needing ExpandsClipForPopup. RetailTooltipPresenter's popup and
UiRoot's drag ghost both already escape structurally -- the tooltip
mounts as an ordinary UiRoot CHILD (sibling of every window, clipped
only by the canvas), and the drag ghost is drawn directly by UiRoot
outside the tree entirely -- neither needed a code change, both are
covered by new tests proving the invariant. UiResizeGrip and
UiNineSlicePanel's frame/bevel draw entirely within their own
[0,Width]x[0,Height] (grip flush at the window's own edges; the
window's own Width/Height already represents the OUTER frame
including its 5px bevel, so its ClipsChildren push already covers
the frame's own content children correctly -- no negative insets
found). UiScrollbar draws entirely within its own bounds (confirmed
by reading OnDraw).
Hit-testing: aligned with the new default via the single
ClipsChildren flip (see above); UiMenu's own opt-out override keeps
its popup hit-test union working, verified by the full UiMenuTests
suite staying green.
Divergence register: AD-113 filed for the ExpandsClipForPopup
adaptation (inline popup drawing vs retail's separate top-level
region).
Fixed two pre-existing test-harness gaps the new default surfaced
(both real bugs in the harnesses, not workarounds around the fix):
- ChatLayoutConformanceTests' bottom-right-grip grow test read a
STALE (pre-shrink) grip screen position because it drove two resize
gestures back-to-back with no intervening Draw pass -- the only
place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame
draws every tick, so production never hits this; the test now
inserts a real DrawSelfAndChildren pass between the two gestures,
matching a real frame boundary.
- VendorUiControllerTests' hand-built Items/Buying/Selling page
containers were left at their bare 0x0 UiElement default (the
harness never runs a real DAT-driven layout pass) -- harmless
before ancestor clipping existed, but now hides every child of an
unsized page. Sized them to the window's own content root, matching
production's shape (a tab page fills the window body).
Tests (all confirmed as genuine regression pins by temporarily
reverting the relevant default/override and observing the exact
predicted failure, then reverting back):
- CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe
CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate
repro against the real character_2100002E.json fixture through
RetailWindowFrame.Mount at the CT6 372px default -- the divider
renders nothing (computed Y ~ -173, matching the owner's ~-178);
growing the window to 600px renders it at its authored spot.
- ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad
RendersOutsideTheWindowRect: no input-row quad escapes the chat
window rect at three small sizes (300x100 sanity control,
120x40/80x30 genuine pre-fix overflow -- verified failing without
the fix at Y=38/55 past the window edge).
- UiAncestorClipTests (new file): the core mechanism against plain
synthetic elements (culled-outside / clipped-at-the-edge / hit-test
parity), UiMenu's popup escaping a tiny owning window (and staying
clipped while closed), and the tooltip's structural immunity
(mounts as a UiRoot sibling, unaffected by a tiny ancestor window).
Verification: full solution build green; hermetic suite green
(--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&
Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&
Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure",
14,000+ tests across every project); InstalledDat lane green
(ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure,
205+34+3+172 tests). CharacterTitlesControllerTests' existing suite
and the full UiMenuTests/UiScrollbarTests suites are unaffected.
src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing
uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by
this change and deliberately left out of this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aligns the hand-built attribute/skill rows in CharacterStatController with
the authored shared row template 0x10000248 (LayoutDesc 0x21000045,
InfoRegion::InfoRegion @0x004F1450 template index 0 — the same template
gmAttributeUI and gmSkillUI both instantiate):
- Row geometry replaced with AUTHORED PIXEL VALUES instead of derived
fractions: icon flush left 20x20 (was 16x16 at X=4, vertically
centered), name column X=25 W=150 fixed (was RowPadX+IconSize+IconGap
offset with a width*0.60 fraction), value column X=175 W=100
right-justified (its right edge sits 7px short of the row's 282px
right edge — the authored gutter the owner reported). Row width itself
now clamps to the authored 282px template width (RowContentWidth)
rather than the ListBox's raw 300px container width. Attribute-row
height fixed at 20px (was 22px, no dat basis); SkillRowHeight folded
into the same RowHeight constant since both row kinds share H=20.
- RowHighlightSprite corrected from 0x06001397 to 0x06000F93 — CT1's
ground-truth research sealed the verdict that gmAttributeUI::
UpdateSelection @0x0049DEE0 (SetState(6) -> InfoRegion::SetState
@0x004F0EE0) swaps the row's Highlight-state media (0x06000F93), a
full-row background swap. 0x06001397 belongs to a different mechanism
entirely (the spellbook row's UIElement_UIItem::SetSelectedState
overlay child) and SpellbookRowStyle.cs is untouched.
- UiClickablePanel.UseSelectionBars/SelectionBarHeight retired outright
(UiPanel.cs): they existed only to emulate 0x06001397's dark-bars art;
the correct retail rendering is the full-panel sprite stretch the base
UiPanel.OnDraw already performs, so the override is dead code once the
correct sprite is used. No consumer existed outside
CharacterStatController.
- Per-attribute/per-vital icon DIDs now resolve through the live
DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve, AP-235's
unification seam) when a resolver is supplied — RetailUiRuntime.
MountCharacter wires one under the shared DatLock — falling back to
the hardcoded AttrRows/VitalRows column otherwise (tests, no dat).
gmAttributeUI::PostInit @0x0049DB70 read verbatim: attributes resolve
via category 0x10000002 (statId order 1,2,4,3,5,6, matching AttrRows'
authored display order exactly); vitals via category 0x10000003.
Live-DAT-verified: every hardcoded fallback value already matched the
resolved DID byte-exact (new InstalledDat pin
AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain).
- RetailAppraisalNameResolver.ResolveHeritage's independent
re-implementation of the 2/5/13 heritage overrides deleted; it now
delegates straight to CharacterIdentityText.HeritageGroupDisplayName
(which already bakes in the same overrides) — one owner, byte-identical
behavior. AP-235's register row updated to reflect the single-owner fix
(the underlying hardcoded-vs-live-DAT mechanism divergence itself
stays open — out of CT5's scope).
Hand-built-vs-template ruling: rows stay HAND-BUILT rather than
converting to UiTemplateListBox instantiation. The hand-built path hits
every authored number byte-exact (proven by the CT1 InstalledDat pin
AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns),
while conversion would touch ~15 call sites (raise-button affordability,
footer State A/B, per-row tooltip, section bucketing, live-refresh,
selection-highlight) for a geometry-only slice — smaller-risk path per
the task's own judgment-call guidance.
Tests: CharacterStatControllerTests' sprite/UseSelectionBars assertions
corrected to the authored geometry; new InstalledDat pin for the icon-DID
chain. Full hermetic solution suite green (App/Core/Runtime/Headless/
Launcher/Content/etc., 0 failures) and the full InstalledDat lane green
(203 App.Tests pins, TowerAscentReplayTests' known Status=KnownFailure
case excluded per the acceptance filter).
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>
Titles tab (AP-109's known-inert gap) now switches to a real page and
CharacterTitlesController binds it entirely through UiTemplateListBox/
UiScrollbar/UiButton — zero bespoke widgets, matching every other
social/options row-list page in this codebase.
Retail anchors: gmCharacterTitleUI::PostInit @0x0049A610; AddTitleToList
@0x0049A840 + FindSortedInsertPosition @0x0049A760 (rows sorted by
resolved display text — this port rebuilds the full sorted set on every
change rather than a positional splice, since UiTemplateListBox has no
insert-at-index primitive and no other consumer needs one either);
InfoRegion::SetState(selected?6:1) (row Highlight/DirectState swap, the
same mechanism CT1's SEALED VERDICT confirmed for the stat rows);
UpdateButtons @0x0049A500 CORRECTED direction (Ghosted unless a row is
selected whose id differs from the current display title — no selection
IS the Ghosted case); Refresh @0x0049abc0 (display-title text, including
the hardcoded "Unknown" fallback, refreshed on both TableReplaced and
DisplayTitleChanged per CT2's review anchor 1); Event_SetDisplayCharacterTitle
@0x006a5720 (wire-only TitleSet 0x002C send, no local mutation).
CharacterStatController.Bind now three-way switches Attributes/Skills/
Titles — Titles is a genuinely separate, non-duplicated page container
(CT1 ground truth §3), unlike Attributes/Skills which share one mounted
page and only rebind content.
The two page captions (0x1000052E/0x10000531) are left untouched:
LayoutImporter.BuildText already resolves every element's authored
StringInfo caption at import time, so no controller-side string lookup
was added.
New IGameRuntimeCommands.SetTitle seam on DeferredGameRuntimeStateCommands
(InteractionUiRuntimeSources.cs) mirrors the existing Advance() shape.
CharacterRuntimeBindings gains Titles/TitleResolver/SendSetTitle;
CharacterTitleResolver (CT2) is constructed once at composition time and
its .Resolve method group is passed to the controller as a delegate
(not the concrete DAT-backed type) so the controller stays hermetically
testable without a live IDatReaderWriter.
Tests (tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs):
binding-seam coverage against the REAL committed character_2100002E.json
fixture (verified this session to already carry the Titles page subtree,
including the ListBox's own authored TemplateList=[(0x2100005E,
0x10000536)] entry — RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds
proves the controller reads that authored pair, not a hardcoded one); a
hand-authored ElementInfo standing in only for the row template itself
(a separate LayoutDesc with no committed fixture yet — CT1 was a live-DAT
probe only); sorted-row order, Unknown fallback, row selection/highlight,
the ghost truth table (no selection / selected==display / selected!=
display), click-sends-exactly-one-SetTitle-and-mutates-nothing,
click-while-ghosted-sends-nothing, TableReplaced rebuild (including
selection survival when the id is still earned), TitleAdded single-row
growth, DisplayTitleChanged text+ghost refresh, and Dispose
unsubscription. CharacterStatControllerTests updated for the Titles tab
no longer being ClickThrough, plus a new tab-switch visibility test.
Register: amends AP-109 (docs/architecture/retail-divergence-register.md)
to record the Titles-page half as LIVE; the header identity block and
luminance fields remain open for CT4.
Suites: full solution 15,405 tests / 0 skips (App 6,130) green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two corrections from the owner's retail-render oracle at the CA5 re-check,
both against readings the TS-85 register row had recorded as settled:
Compose (skill tooltips): retail is formula + newline + description —
GetTooltip @0x004f1fe0's operator+ has the InqSkillFormula output as the
LEFT operand; the old '"\n" + formula, no separator' reading had the
operand order backwards and produced a leading blank line with the formula
and description glued on one line. A formula-less skill (Salvaging) shows
the bare description, matching the failed-InqSkillFormula branch.
Sizing (ALL tooltips, per the owner's direction): retail sizes a tooltip
in TWO passes (StartTooltip @0x0045DE90) — measure-wrap at the max width,
resize the root through the authored ResizeTo clamps, then
RecalculateGlyphList RE-WRAPS the text at its final clamped width and a
second resize grows the root's HEIGHT for the extra lines. The branch the
register called 'a structural no-op' IS that second pass; without it a
description longer than the clamped popup stayed one clipped line, where
retail shows three. ApplyTooltipText now ports the full chain, so every
tooltip surface (items, options rows, character panel, world hover, map)
wraps and grows exactly as retail.
Pinned by BuildTooltip_FormulaFirstThenNewlineThenDescription,
BuildTooltip_FormulaLessSkillShowsBareDescription, and
LongTooltip_RewrapsAtTheClampedPopupWidth_AndGrowsHeightForTheExtraLines.
TS-85 carries both dated corrections. Owner visual re-check owed: skill
tooltip shows formula on line one, description below, long descriptions
wrapping to three-plus lines inside the parchment. Full hermetic suite
15,332 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects the owner found at the CA5 drive, one shared theme: the data
was right and the presentation seam was dead.
#430 (tooltips): the TS-85 Batch-B port set runtime TooltipText on the
runtime-built attribute/vital/skill rows but never gave them a popup
locator, and RetailTooltipPresenter.OnTooltipShow refuses any widget with
AuthoredTooltipRootElementId == 0 — the tooltip could never mount, on any
row, ever. (The register's 'live-verified on the Character tab' was the
OPTIONS panel's Character tab — authored elements with authored locators;
a different surface.) Rows now carry the shared popup skin
0x10000395/0x21000041 — live-DAT probed as the ONLY locator pair the
character layout references, and the same inference UiItemSlot already
ships for runtime-built widgets. TS-85's row carries the dated correction.
#440 (train row stuck): training a skill debited credits on screen but
left the row in the untrained section until the NEXT click — because the
sheet-changed subscription only refreshed the captured sheet, and row
STRUCTURE rebuilt exclusively in click handlers (the raise 'completed'
callback runs after SEND, before the server answers; the owner's second
click was simply the first rebuild after the record landed, and ACE's
rejection of that second train — 'Failed to train', no credit change —
matches the owner's report exactly). The same gap kept CA4's
awaiting-ghost from visually releasing. CharacterStatController.Bind now
returns the data-changed refresh and MountCharacter invokes it on every
authoritative sheet change, mirroring retail's quality-change broadcast
(InfoRegion::OnQualityChanged @ 0x004F0EB0).
Pinned by DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick
and Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText. Owner
visual re-check owed next session (hover-dwell a row; train a skill and
watch it move immediately). Full hermetic suite 15,329 passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail sends a raise and WAITS: one request in flight, the raise
controls ghost, and displayed state changes only when the authoritative
quality-change record lands (gmStatManagementUI @ 0x004F03F0 family,
pinned in docs/research/2026-07-10-retail-panel-behavior-pseudocode.md
§5, whose own conclusion names ApplyLocalRaise as the thing to remove).
The optimistic layer predates the inbound parsers — it existed so the
panel showed anything at all — and with CA2 delivering server truth it
became strictly harmful: against ACE, a wrong TrainSkill cost fails
SILENTLY, so the optimistic promote-and-debit could show a trained
skill the server refused with nothing to ever correct it.
Deleted: CharacterSheetProvider.ApplyLocalRaise + both spend helpers,
and LocalPlayerState's six optimistic mutators (ApplyAttributeRaise,
ApplyVitalRaise, ApplySkillRaise, ApplySkillTraining, DebitIntProperty,
DebitInt64Property) with their tests. Added: the one-in-flight latch in
HandleRaiseRequest, CharacterSheet.AwaitingRaise ghosting all raise
controls, and gate release on every authoritative quality signal
(attribute/character/player-property events unconditionally; vital
events only release-and-refresh while a raise is in flight, so regen
ticks stay out of the sheet-rebuild path). Panel unmount resets the
gate — retail's awaiting flag lives on the panel instance.
AP-73 NARROWS rather than retires: retail's release on a rejection that
produces NO quality change is statically unverifiable, and ACE sends
chat-only (Raise*) or nothing (RaiseSkill/TrainSkill) on failure; until
the CA5 live check, a silently-rejected request leaves the controls
ghosted until panel reopen — recorded with its observable symptom.
Also verified for CA4: the train button sends the DAT-exact TrainedCost
(ACE's silent exact-match rule), and there is correctly NO panel
specialize send — retail/ACE specialize only via the SkillAlterationDevice
item-use + confirmation round-trip, whose client seams
(SendConfirmationResponse 0x0275, the 0x028B WeenieErrorWithString chat
routing) already exist. Provider tests now pin the retail contract:
send-without-mutation, one-in-flight, release-on-record, release-on-
unmount, and the regen-tick rebuild guard. Full hermetic suite 15,327
passed / 0 failed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-directed enhancement ('I want the night sky to look very good'):
retail's star layer is one small texture stretched over a 10-poly dome
cap, so stars smear regardless of source-image quality. With the
Atmospheric render pack active, sky.frag now renders the star layer
(GfxObj 0x010015EF, identical in all 20 Dereth day groups) as a fully
procedural sky computed from the view direction: hash-derived stars on
a cube-face grid in three density tiers plus sparse diffraction-spiked
standouts, sized in SCREEN pixels via derivatives so they stay crisp at
any resolution and FOV, over the user-approved 0.4-1.3% cool mottle
(gen_starfield2.py seed 11, approved 2026-08-23). The draw is forced
additive; the day/night fade rides the star layer's existing retail
lighting product so the schedule matches the authored keyframes. Pack
inactive = retail look byte-untouched.
EnhancedNightSkyRuleTests pins the uParamA gate, the exact star-layer
id, the forced-additive draw, and the pack-runtime wiring; sky shader
SPIR-V recompiled and re-pinned. Hermetic App suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The aurora was never missing data — it was a missing mechanism plus a
misread. New decompile evidence closes the April-2026 contradiction:
retail plays the sky carriers' PES through the Setup's own DefaultScript
(GameSky::MakeObject @0x00506EE0 -> CPhysicsObj::makeObject @0x00513970
sets state|=0x80000; animate_static_object @0x00513DF0 ticks
ScriptManager + ParticleManager). The pes_id column stays dead — that
half of the April finding stands; the ids are byte-equal mirrors.
- SkyPesFrameController is now the production owner (ACDREAM_ENABLE_SKY_PES
deleted): script ids resolve from the Setup DefaultScript
(SkyObjectData.DefaultScriptId; the pes_id column is a one-time-logged
cross-check), slots persist by (index, gfx id, properties) per
CreateDeletePhysicsObjects @0x005073C0 — a day-group swap keeping the
carrier no longer restarts its emitters — and stale slots stop before
replacements claim the slot-derived owner id.
- RetailParticleFacing ports calc_draw_frame @0x0050DFA0: degrade mode 2
faces the viewer roll-free (set_vector_heading) instead of the camera
plane; modes 3/4/5 spin the authored frame around one local axis
(rotate_around_axis_to_vector) — Dereth authors 54 mode-5 emitters that
previously got no facing at all; 1,583 mode-2 emitters get the exact
law; authored/mode-1 paths are unchanged.
- The 2026-08-23 'whole-sky tint' was the Rainy-group lightning/thunder
PES playing at the debug anchor inside their 0.03-0.19 window, not the
aurora: the aurora is nine faint viewer-facing glows pulsing on
6.7/15/55-minute rebirth cycles, in every day group, all day.
Research: docs/research/2026-08-23-sky-default-script-port.md.
Register: AD-112 filed (camera-anchored synthetic owners vs sky-cell
physics objects). ISSUES #2 corrected (the playback ban is lifted by the
new evidence); #28 fix landed pending the connected night gate.
Tests: RetailParticleFacingTests (16), SkyPesFrameControllerTests (6);
hermetic suites App 6,076/0, Core 4,905/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Holtburg windmill axle (GfxObj 0x010010CE, 8 polygons, all
Stippling.NoPos + SurfaceType.Base1Solid) extracted to a 0-vertex mesh.
NoPos ("NO_POS_UVS", acclient.h:7380-7388) means "this side has no
texture coordinates" — true of every solid-colour polygon, since
nothing samples them — not "there is no positive face". Extraction read
it as the latter and dropped the polygon entirely, client-wide, for
every untextured polygon on every object.
Retail's D3DPolyRender::DrawMesh (@0x0059d4a0, named-retail decomp
~line 426048) draws an untextured subset on an ordinary object exactly
like a textured one; the only retail cases that skip an untextured
subset are a building shell (RenderDeviceD3D::DrawBuilding @0x0059f2a0
sets ObjBuildingOrBuildingPart=1) or an EnvCell interior
(RenderDeviceD3D::DrawEnvCell @0x0059f170, arg4=1). The #119
investigation's "retail's skipNoTexture never draws them either"
conclusion was itself wrong as a general rule.
- MeshExtractor.PrepareGfxObjMeshData / GfxObjMesh.Build: emit the
positive side whenever PosSurface is a valid index, regardless of
NoPos; the existing UV-index-0 fallback already produces zero
texcoords for a NoPos polygon with no UVs on the wire.
- RetailUntexturedSurfacePolicy.IsUntextured(SurfaceType): the one
place that answers "is this surface textured"
((type & (Base1Image|Base1ClipMap)) == 0), replacing the old
`isSolid = NoPos || Base1Solid` (which also mis-classified a NEG-side
batch by the POS-side's NoPos flag).
- RetailUntexturedSubsetPolicy.Draws(isBuildingShell, isUntextured):
the shared draw-time gate wired into WbDrawDispatcher.ClassifyBatches,
.PackedOracle.ClassifyPackedBatches, and
.DirectionalShadows.AddDirectionalShadowBatches — one predicate so the
three walks cannot drift (Campaign VM VM6 lesson).
- CellMesh.cs / MeshExtractor.PrepareCellStructMeshData deliberately
KEEP their NoPos-gated skip for cell-wall geometry — retail's
DrawEnvCell really does skip untextured subsets there; register row
AP-234 documents the NoPos-vs-Surface.Type approximation.
- PakFormat.CurrentBakeToolVersion 4->5 (LauncherInstallRecordStore in
lockstep): a pak baked by an older tool is missing every untextured
face. No bake was run as part of this commit.
Also fixed: WorldBuilder's own upstream ObjectMeshManager.cs has the
identical NoPos bug (ObjectMeshManager.cs:959,984) — our port had
faithfully carried it over, and our own conformance test
(Build_NoPosFlag_OnlyEmitsNegSide) asserted the bug as correct WB
conformance. Renamed/reworded to Build_NoPosFlag_EmitsBothPosAndNegSide
with a citation for why retail decomp overrides WB here.
Issue119UpNullGfxObjDumpTests re-run against the installed DAT:
#119's own two objects (0x010002B4 9/9 polys, 0x010008A8 1/1 poly) now
gate DRAWS on every polygon instead of extracting to nothing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Holtburg windmill (Setup 0x020003E5, cycle 0x0300061B) is a 60-frame
quarter turn that loops by 4-blade symmetry: frame 59 and frame 0 are the
same picture but 88.5 deg apart. BuildBlendedFrame wrapped the cyclic
node's next-frame index to frame 0, so the seam slerped 88.5 deg backwards
inside one 33 ms interval - the blades visibly flapped back every two
seconds.
Retail never blends animation frames at all: CPartArray::UpdateParts
(0x005190F0) applies get_part_frame(floor(frame_number)), holding every
authored frame for its interval and hard-cutting at the wrap. The
render-side blend now holds the boundary frame at BOTH ends of a node's
window - the same rule the #61 link-tail fix already applied to one-shot
nodes - so every seam is retail's cut while interior frames stay smooth
(the owner's choice over dropping the blend, 2026-08-23). Register row
AP-233 records the blend as the deviation it has been since the R1-P5
cutover.
Test: Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0 (fails
on the previous code at ~45 deg, passes held at 90 deg). Core 4,696/0,
App 6,068/0 hermetic (Release).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opus dual-lens review of the three VM6 commits (0930c35d, 39e8408c,
6cc5e183) found two blockers and two should-fix issues; all landed here
along with the review's nits and documentation corrections.
Blockers:
- A1: the procedural-scenery classifier tested bit 31 alone instead of
the full top nibble (0xF000_0000 == 0x8000_0000), so it also matched
LandblockStaticEntityIdAllocator's 0xC... namespace (fences/gates/
building shells with a cutout subset), the 0xDA11_D0xx paperdoll id,
and the 0xFFFF_FF01 portal-tunnel id as procedural scenery — all
three would have swayed. ProceduralSceneryIdAllocator.IsInNamespace
now does the exact top-nibble test; FoliageWindClassification
delegates to it.
- A2: GroupKey (the receiver's instance-batching key) did not carry
FoliageFlags while the caster's dedup key already did, so a scenery
instance and a non-scenery instance sharing a mesh subset coalesced
into one receiver InstanceGroup whose flags were last-writer-wins —
disagreeing with the correctly-keyed caster. GroupKey now carries
FoliageFlags, computed before key construction and set exactly once
at group creation; the imperative re-stamp is gone, and CachedBatch's
now-redundant FoliageFlags field is removed.
Should-fix:
- A3: the world receiver pass bound UniformAtmosphericFrame only by
accident (leftover from the caster pass, which runs first each
frame, since Vulkan binding state isn't reset between passes).
DirectionalShadowFrameBinding now carries the caster's exact
AtmosphericFrameBufferBinding and BindDirectionalShadowReceiver binds
it explicitly.
- A4: a Setup-composed tree's opaque trunk part never got the trunk
flag because HasCutoutSubset is cached per GfxObj part, not per
entity. FoliageWindClassification.ComputeEntityHasCutoutSubset now
ORs HasCutoutSubset across an entity's resolved sibling parts once
per entity, threaded into ClassifyBatches/AddDirectionalShadowBatches
via a new optional override parameter.
Nits: A5 hashes the per-vertex flutter seed relative to the instance
origin instead of absolute world XY (fp32 sin() precision loss at far
landblock corners), mirrored in both foliage_wind.glsl and
FoliageWindModel; A7 documents the max(maxHeight, 0.5) divide-guard as
a deliberate pseudocode divergence; A8 switches FoliageWindExclusions'
construction to ToFrozenSet() and softens the "never stale" doc
comment to "no slower than one frame behind."
Tests added: top-nibble classification (0xFFFFFFFFu now correctly
false), GroupKey inequality across entity-driven scenery/landblock-
static classification, a caster-batch test proving the same pairing
never coalesces, ComputeEntityHasCutoutSubset unit + end-to-end
two-part-Setup tests, the caster→receiver AtmosphericFrame binding
carry-through, flutter-hash translation invariance relative to
instance origin, and a Storm-wind mid-height displacement floor
guarding against a "no motion" regression.
Docs: plan VM6 body corrected to the five-row WeatherKind table, "bits
1 and 2", "all four" caster shaders, and top-nibble wording throughout;
the owner gate checklist's Rain/Storm step; the stale v1-only shader-
interface compatibility entry; semantic-bindings-v1.md's v2 members
folded into the main 192-byte block; the IA-25 register row's top-
nibble wording; AtmosphericFrameInputs.cs's ABI size reference.
foliage_wind.glsl's A5 change recompiled exactly the five shaders that
include it (mesh_atmospheric.vert, the four directional_shadow_world_*
casters) plus the manifest; no other .spv changed.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,041/0 failed (no environment-specific failures this run).
RenderPackValidator 30/30. Full hermetic-filtered solution: 15,269/0
failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FoliageWindByDayGroup / FoliageWindDayGroupPoint(int ActiveDayGroup, ...)
becomes FoliageWindByWeather / FoliageWindWeatherPoint(string WeatherKind,
...) in AtmospherePolicyDeclaration (Plugin.Abstractions is BCL-only, so
the key is the exact member name of AcDream.Core.World.WeatherKind rather
than the enum itself). The raw activeDayGroup index carries no weather
meaning by itself; WeatherState.cs already classifies each day group's
authored DAT name into one of five real weather kinds, and that fact was
already threaded through AtmosphericFrameInputs.Weather / uAtmosphereWeather.x
— this reuses it instead of guessing an index-to-category mapping.
Built-in table (BuiltInAtmosphericRenderPack.AtmospherePolicy()): Clear
0.25/0.15, Overcast 0.60/0.35, Rain 0.85/0.60, Snow 0.35/0.20, Storm
1.00/0.75 — all five WeatherKind members declared, the invented "Cloudy"
row dropped. RenderPackAtmospherePolicyEvaluation.FoliageWind now takes a
WeatherKind and matches by weather.ToString() (ordinal) against each
declared point's name; a kind absent from the table falls back to the
declared Clear row, then to (0,0) if Clear itself is undeclared. The
delta-seconds EMA interpolation (EaseTowardTarget) is unchanged.
AtmosphericPostProcessGraph.ResolveFoliageWind and its two callers
(RenderPostProcess via inputs.Weather; RenderDirectionalShadows via
foundation.Atmosphere.Kind) now pass WeatherKind instead of the day-group
int.
RenderPackValidation.ValidateAtmosphere (runs for every pack declaring an
AtmospherePolicy, not gated to Tier2/shadow packs) now rejects an unknown
or non-exact-case weather-kind name and a repeated kind, mirroring the
existing ActiveDayGroupMultiplier duplicate-key check.
Tests: RenderPackAtmospherePolicyEvaluationTests rewritten for the
kind-keyed API (all five kinds resolve to their declared row, an unlisted
kind falls back to Clear, ordinal exact-case matching, null-table
handling); RenderPackSpirvValidatorTests gains four descriptor-validation
cases (unknown name, wrong case, duplicate kind, the five-kind table
accepted); AtmosphericPostProcessGraphTests' three foliage-wind cases now
select WeatherKind.Storm via `with` instead of an assumed day-group index.
Spot-check (per the coordinator's ask, not changed here): yes —
ActiveDayGroupMultiplier / EvaluateDayGroupPolicy (pre-existing, Campaign
AR/VM3-era — BuiltInAtmosphericRenderPack.AtmospherePolicy()'s three rows
`new ActiveDayGroupMultiplier(0, 1.0), (1, 0.35), (2, 0.20)`) key the
sun-ray/shadow/volumetric day-group strength multiplier by the same raw
activeDayGroup index with an undocumented assumed meaning (0=brightest ...
2=dimmest), the identical class of issue this commit fixes for foliage
wind. Left unchanged per instruction; flagging for the coordinator to file.
Full solution Debug and Release builds green. App hermetic filter
6024/6026 — the same 2 pre-existing failures as VM6a/VM6b. Both were
re-run in isolation per the verification ask: both still fail alone (not
a load-flake in this environment) — confirmed via git stash earlier this
session that both already fail on the unmodified pre-VM6 baseline, so
they are pre-existing and unrelated to this change. Core.Tests hermetic
4697/4697. RenderPackValidator.Tests 30/30. No shader/spv changes in this
commit (pure C#/docs fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Procedural-scenery foliage (trees/bushes — entity ids in the
ProceduralSceneryIdAllocator's 0x8XXYYIII namespace) sways with weather in
mesh_atmospheric.vert and all four directional_shadow_world_* caster vertex
shaders, both calling the identical new foliage_wind.glsl include so the
shadow moves with the leaf by construction.
Classification (FoliageWindClassification, AcDream.App.Rendering.Wb): two
new BatchData.flags bits, computed once per (entity, subset) from four
inputs — entity id (bit 31 for procedural scenery), the pack's declared
FoliageExclusions membership, the subset's TranslucencyKind, and
ObjectRenderData.HasCutoutSubset (computed once per mesh at build time, not
per frame). Bit 1 marks an alpha-cutout leaf subset; bit 2 marks an opaque
trunk subset (only when its own mesh also owns a cutout subset, so rocks
stay still). WbDrawDispatcher.ClassifyBatches (world receiver) and
AddDirectionalShadowBatches (caster) call this with the same four inputs, so
casters and receivers classify identically without needing to share state.
Retail's mesh_modern/terrain_modern/mesh_detail pipelines never read these
bits, so pack-off output is unaffected.
Motion model (foliage_wind.glsl, mirrored bit-for-bit in the new
FoliageWindModel for hermetic CPU tests): height-squared-scaled slow lean
for every foliage subset, plus branch swing and per-vertex-hash-decorrelated
flutter for cutout subsets only. AtmosphericPostProcessGraph.ResolveFoliageWind
resolves the wind block once per frame.Serial — advanced by whichever of
RenderDirectionalShadows (which runs first) or RenderPostProcess is called
first that frame, with the second reading the already-advanced state, which
is what keeps the caster and receiver reading byte-identical clock/strength
values. The per-day-group mean/gust target (AtmospherePolicyDeclaration.
FoliageWindByDayGroup, keyed by the same day-group index convention
ActiveDayGroupMultipliers already established: Clear/Cloudy/Overcast/Rainy)
eases toward its target over WeatherSystem.TransitionSeconds (10s) so a
weather change never snaps; wind-enabled off or indoor instead gates the
OUTPUT to an exact zero (not an asymptotic approach) so a settings toggle or
cell transition is immediate. The wind clock is a Stopwatch started at graph
construction (monotonic, session-relative magnitude for GPU sin() accuracy),
overridable by the same ACDREAM_SKY_PHASE_SECONDS pin SkyRenderer already
uses, for deterministic offline gates.
New settings: wind-enabled, wind-strength, wind-direction-degrees (225°
default — no authored retail wind direction exists to read),
wind-lean-metres, wind-branch-metres, wind-flutter-metres (0 on Low),
wind-canopy-height-metres.
Register row IA-25 files this as an intentional, strictly opt-in divergence:
retail applies no per-vertex wind displacement to any geometry. Known,
accepted limitation: classification is per mesh-subset (one BatchData.flags
word per indirect-draw batch), not per entity instance, so the rare case of
one mesh subset being reachable from both a procedural-scenery and a
non-scenery placement would classify all of that subset's instances alike.
Tests: FoliageWindClassificationTests (the full classification matrix),
FoliageWindModelTests (identity on non-foliage/calm-wind/base-vertex,
canopy-top displacement bound, z-never-increases, trunk has no flutter
term), RenderPackAtmospherePolicyEvaluationTests (exact day-group lookup,
no interpolation across day-group ids, easing convergence without overshoot
or discontinuity), AtmosphericShaderAbiTests (each of the five shaders calls
acdreamFoliageDisplace exactly once; mesh_modern/terrain/mesh_detail call it
never), and four AtmosphericPostProcessGraphTests additions (indoor/disabled
exact-zero gating, settings-to-UBO wiring, same-frame-Serial idempotency —
the last proxies the caster/receiver agreement invariant without needing
this hermetic harness's WbDrawDispatcher/TerrainModernRenderer dependency
chain to exercise RenderDirectionalShadows directly).
App hermetic filter: 6015/6017 (the same 2 pre-existing failures as VM6a,
confirmed unrelated). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests:
30/30. Full solution Debug and Release builds green. Shader recompile
touched exactly the 5 edited files' .spv (plus manifest); the retail oracle
set and every other pack shader are byte-identical.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opus narrow re-review of ae651312: APPROVE. Closes its three residuals:
- AP-232 filed: retail's single-pass stage-1 OUTPUT alpha
(MODULATE(TEXTURE, CURRENT) @0x0059c549) is the blend weight for a
translucent subset; acdream's two-draw model is exact for opaque
subsets (fog identity pinned) and a bounded weight difference on
translucent ones. Distinct from AP-34 (queue order). Owed since
05970306.
- TerrainAtlas.DetailSamplerDescription names the production sampler
(WRAP/LINEAR x3 per ACRender::SetDetailSurfaceInternal @0x006b6280);
the test now asserts that constant's properties instead of a
test-local copy.
- Plan VM1 section: fragment now described as fogged; VM1 marked CLOSED
with the Holtburg measurement (+2.17/+0.57/+0.16 vs predicted
+2.2/+0.66/+0.16) and the detail-on cost (+0.3-0.5 ms CPU at Arwic).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VM2's live cdb read against the PDB-paired retail client (GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) proved
m_caps.bCanDoSinglePassDetailing = 1 and trysinglepass = 1 on real hardware,
so D3DPolyRender::RenderMeshSubset (0x0059ca10) never falls back to the
two-pass framebuffer blend the earlier #226 port reproduced. Every loaded
CGfxObj sets use_built_mesh = 1 (CGfxObj::InitLoad 0x005346b0), so buildings
and EnvCells always take the single-pass texture-stage combine set up in
D3DPolyRender::SetSurface (0x0059c4d0):
result = lerp(base * diffuse, detail.rgb, detail.a * diffuse.a)
RenderMeshSubset lights opaque built-mesh subsets with
tmpmaterial.Diffuse.a = 1, so on the live Dereth category texture
0x06006D58 (mean rgb 0.165, mean alpha 0.132) the combine works out to
~0.868 * base + 0.022 — a mild darkening, the opposite sign of the fallback
DstColor blend's brightening.
Also removes the invented 10 m / 50 m distance fade. Retail's
ACRender::get_alpha_for_z (0x006b6230) is only evaluated in
D3DPolyRender::DrawPolyInternal (0x0059d7c0, the immediate-polygon path)
and only when the static noFadeDetail (0x00820e38, initialised to 1) is 0 —
unreachable for built meshes. Attenuation is the sampler's linear mip chain
converging to the texture mean, not a scripted ramp.
Changes:
- mesh_detail.vert/.frag: drop vDetailFade and its distance term; add
vDetailOpacity mirroring mesh_modern.vert's InstanceAlphaBuf (binding 7)
read, and output detail.rgb with alpha = detail.a * vDetailOpacity under
the corrected pipeline blend.
- VulkanViewportMapping.BlendFactorsOf / GpuEnums.GpuBlendMode.RetailDetail:
SrcAlpha + OneMinusSrcAlpha instead of DstColor + OneMinusSrcAlpha.
- RetailDetailTextureContract: replaced the distance-fade constants and
FramebufferFactor with Expected(base, detail, opacity) and IsNeutral,
matching the lerp; contract tests cover zero-alpha/zero-opacity no-ops,
the measured darkening on the live category texture, and full-alpha
replacement.
- Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.
- Docs: #226's pseudocode note, the docs/ISSUES.md #226 entry, and the
retired TS-52 divergence-register row corrected from the two-pass
DESTCOLOR description to the single-pass path and the darkening
expectation, each citing the VM2 cdb note.
Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests and AcDream.Core.Tests (Release, hermetic
lanes) both green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported symptom: Abandon, New, Record, Start, First and Last all unclickable.
That Abandon was in the list is what identified it — Abandon is deliberately
unwired, so if it behaved the same as the others the cause could not be wiring.
UiButton read authored property 0x0D as "starts disabled" (Enabled = !0x0D).
It was the one property read in that file with no citation, and it was wrong.
Every button on the Journal panel authors 0x0D, so every one built disabled:
visible, because drawing never consults Enabled, and unclickable, because
UiElement.HitTest skips disabled elements. Exactly the reported shape.
The evidence is a sweep of every installed layout (LayoutDump gained --ghosted
for it): 85 elements author 0x0D and ALL 85 author it TRUE — not one False
anywhere in the client — and no panel ever clears it, the only four
SetAttribute_Bool(.., 0xd, ..) sites in the binary being chargen appearance,
the keymap option and the barber. A flag that is only ever true, never cleared,
and sits on New, Record, Start, Delete and Reset cannot mean "dead button";
under the old reading 85 elements were permanently dead in a shipping game.
It is not a pure ghosted LOOK either, which is why this ignores it rather than
moving it to appearance: the same 85 mix live buttons with inert column headers
("Contract", "Status", "Title", "Timer", "Label", "#"), and one appearance
cannot be right for both. Registered as QJ-2 with the measurement, so the open
question is recorded rather than quietly decided.
The test that asserted the old behaviour carried no citation either — it
encoded the same assumption. It now asserts the evidenced behaviour, with a
companion test proving the state machine's own Ghosted transition still
suppresses a click: that mechanism is separate and did not change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Journal panel now has all three tabs working: contracts from the server,
and a per-character notebook with its searchable index.
Two ported details that a reimplementation would get wrong in a way nobody
notices until they lose work:
Every navigation button commits the current page FIRST. Retail's
ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all
five of them, which is why paging away never eats what you just typed. And the
file is written when the notes page is HIDDEN, not only at exit — a crash then
costs at most the page in front of you.
The search is CASE-SENSITIVE across label, title and notes: retail compares
with wcsstr and lowercases neither side. Making it insensitive would be
friendlier and would be a divergence, so it is ported as-is with a test naming
the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0,
@0x00493158) rather than the 500 ms the item-interaction path uses, and firing
it clears the tracker so a third click does not re-open.
Two unlabelled buttons on the notes page turned out to be prev/next: retail
switches on (idElement - 0x10000565), which names them without a caption. The
running-timer readout is authored at the same x as the three day/hour/minute
boxes, so the strip is one or the other — that overlap is the data form of
ShowEditableTimer versus ShowRunningTimer, not a layout bug.
DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is
ClientUISystem's, not gmContractsUI's — the journal timer and the contract
repeat countdown both call it, and it only lived under Quests because that was
its first caller. A bridge class to reach it across features would have been
the wrong answer to the same observation.
The journal file lives in the client's data directory rather than beside the
executable, for the same reason the chat log does. Register QJ-1.
Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CT-B4 was filed as "the plain-text session chat log, path and rotation
UNKNOWN, needs a live check." Both unknowns dissolve once you read the
handler: there is no automatic session log. Retail's @log is a COMMAND.
DoSetOutput @0x0057E4F0 takes a filename, StartCopyOutputToFile @0x0057C8A0
does the fopen(name, "a+"), and running it again with no argument closes it.
Nothing rotates because it appends forever, and nothing has a fixed path
because the player names the file.
The path question that DOES exist — where a bare name lands — was answered
all along by retail's own help text, which CH4 extracted verbatim into our
help table a fortnight ago and nobody read: "a log file named Aclog.txt in
your Asheron's Call directory." A blocked question sat on top of a committed
answer.
We cannot use the install directory: the launcher replaces it atomically on
update, so a log written there is wiped by the next update or blocks it. The
client's own log directory is the equivalent that survives. Rooted paths are
honoured verbatim, as retail's fopen would. Register CT-5.
The verb was registered in the help table but NOT in the command catalog, so
/log printed help and did nothing — and the CH4 conformance registry recorded
it as a "server passthrough" precisely because that shape is indistinguishable
from an unimplemented client command. It never went on the wire at all. Both
are corrected, with the totals moved in the same commit rather than left to
drift.
Moving it into the catalog also moves which help table answers for it, so
retail's real text moved to the catalog-verb table in the same change. Without
that, /help log would have silently started printing acdream's own invented
one-line summary — caught by the coverage test, and now pinned by a test that
names the text.
All five replies are byte-decoded from the PDB-paired binary rather than read
off Binary Ninja's previews, which truncate at ~33 characters and would have
lost the second half of every one of them (including the two spaces retail
puts after "Copying chat to %s.").
The writer attaches on OPEN, not at startup — retail's help is explicit that
only what appears after the command is copied — and detaches from the
transcript it actually attached to, so a session teardown cannot leave a live
handler writing into a file the player believes is closed. What gets written
is the composed display line with the shared timestamp, because retail's
fprintf sits inside AddTextToScroll: downstream of composition, upstream of
glyph layout. Logging the raw entry text would have produced a file of bare
fragments with no speakers.
acdream's logs carry no inline tag markup where retail's do, since tags live
beside the text as spans here rather than inside it. Registered as CT-6 rather
than reconstructed purely to write it to a file.
Register: CT-5, CT-6.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blink is not code. It is data, and we were throwing it away.
A retail UI state's media is a small program: images interleaved with timed
pauses, branches, and a terminal hand-off to another state. Our importer kept
the FIRST image per state and dropped the rest, so nothing authored could ever
animate — the indicator was correct in every other respect and simply sat
still.
Measured from the installed dats (LayoutDump --media 0x1000048C), the chat
unseen-text indicator's Normal state authors thirteen steps: two frames
alternating every half second, three times, then `State 13` — Ghosted, whose
authored 0x3B is Invisible.
So retail's indicator is a three-second attention FLASH that hides itself, not
a badge that stays lit until you scroll to the bottom. Nobody would guess that
from the code, because there is no blink code anywhere; the behaviour lives
entirely in the authored sequence. Our shipped version stayed lit, which is
the one thing the data says it must not do.
Sampling is a pure function of (steps, elapsed) rather than a playback object
holding a cursor, so an element only has to remember WHEN its state began and
the whole thing is testable without a clock, a GPU or a frame loop. One shared
UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has
no tick of its own.
The controller change is the other half: it starts the flash on the rising
edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero
and it would never blink at all — which is the failure mode the second new
test exists to catch, and which no "is it visible?" assertion would notice.
When the sequence reaches its terminal step the controller follows it down
instead of re-lighting it.
Two guesses are refused rather than made, and both are registered: a Pause's
max duration (every sequence measured sets min == max, and what the range MEANS
is not in the decomp) and a sub-1 branch probability (falls through, the
direction where a malformed sequence stops rather than animates forever).
A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin
inside a frame.
Kept `Other` steps in the list rather than filtering them, so a jump's authored
index still lands on the entry it names.
Register: CT-3, CT-4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"I do not want any censoring." Recording it as a KNOWING divergence (register
row CT-2) rather than leaving it as an implicit gap, because retail does filter
and someone reading the code later would otherwise read our silence as an
oversight.
The option itself stays: FilterLanguage still stores and ships its bit to the
server exactly as retail does, so anything the SERVER gates on it behaves
normally. What acdream does not do is substitute words in the transcript.
The attempt before the decision is kept in the plan, since it establishes two
things that would otherwise be rediscovered:
- The taboo table's dat id is not readable from the decomp —
TabooTableAdaptor::CheckCensorsW @0x00682A30 reaches it through
DBObj::GetByEnum with the arguments elided by Binary Ninja. Measured the
portal master enum map (0x25000000) instead: 22 categories, with category 3
(0x0E010001 / 0x0E010002) and single-entry categories 8 and 11 as the
plausible candidates.
- Chorizite.DatReaderWriter declares a TabooTable DBObj type but does not
decode it: only DBObjType and HeaderFlags. The format would need decoding
here first.
Also sharpened CT-B4's status to research-blocked — the chat log file's path
and rotation are not in the decomp either.
Group B therefore ships B1 and B2 and is complete as scoped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Campaign CT slice B1.
CORRECTION TO THE PLAN: this slice was written as "the transcript grows for the
life of the session — a slow leak". That was wrong, and the plan said it
because I read the retail-side finding and inferred our side without checking.
ChatLog has always been bounded (ConcurrentQueue, maxEntries default 500, with
a dequeue loop in Append). There was no leak.
The real gap is the UNIT. Retail bounds the rendered transcript by CHARACTERS —
0x2710, beheaded toward 0x1D4C at a newline boundary — while we bounded the
model by messages. Two different things: a window of 500 messages is far more
scrollback than 10,000 characters, and the message cap is a safety limit on the
log rather than a display rule.
So the budget is applied where retail applies it: on the rendered window, not
the model. ChatLog's entry cap stays as the model-level bound.
Two deliberate simplifications, both registered as CT-1 rather than left
implicit:
- ONE threshold, not retail's two. The hysteresis exists to stop retail
re-trimming an accumulating buffer on every append; we rebuild the visible
list each time, so there is nothing to damp, and a second threshold would
only make the oldest visible line jump around as messages arrive.
- Whole-line cutting rather than a newline search near an offset — our unit
already IS the line, which is what retail's newline preference is for.
Filtered-out lines deliberately do not consume budget: a line this window
filters out is not in retail's buffer at all, so counting it would mean turning
a filter OFF silently shortened the visible history.
Solution builds clean; full hermetic gate green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retires AD-74 (Exit to Character Selection 'behaves as Exit Game') and
files AD-110 (the composed handoff edge) — register rows in this commit.
Retail derivation (named decomp):
- gmGamePlayUI::UseTime @0x004EA3A0: confirmed Yes drains into
CPlayerSystem::LogOffCharacter(0) when grounded (transient_state &
CONTACT); the grounded three-way branch now also covers the
indicator-bar end-session control (it was Options-only).
- CPlayerSystem::LogOffCharacter @0x00563520: SaveToServer FIRST (the
existing pre-logoff flush hook), then RequestLogOff @0x00562DD0:
'Logging off...' chat (type 0), 0xF653 via Proto_UI::LogOffCharacter
@0x00546A20, logOffRequestTime = now + 3.0 (+20.0 when
IsPlayerKiller @0x0058C910 — PWD bits 0x20|0x2000000), and
CommandInterpreter::HandleLogOff @0x006B3330 -> Disable.
- The log-off ANIMATION is server-driven: ACE broadcasts
MotionCommand.LogOut (0x1000011E, Player.cs:596 SendMotionAsCommands)
and it plays on the local player through the existing inbound
unpack_movement funnel during the 3 s hold — retail plays nothing
locally; Disable() is the whole client-side effect.
- gmSmartBoxUI::UseTime @0x004D6E64: hold elapsed ->
BeginTeleportAnimation(TAS_WORLD_FADE_OUT) @0x004D6E83 (enter cue
@0x004D638E, unconditional) -> TunnelFadeIn -> Tunnel. The tunnel
plays the SAME forward 40 fps animation; nothing renders backwards,
and NO exit cue ever fires on logout (the char-select swap preempts
the TunnelContinue/FadeOut tail).
- Inbound 0xF653 echo (dispatch case 3 @0x0055C963) ->
ExecuteLogOff @0x0055D780: world teardown with the LOGON CONNECTION
KEPT (ExitWorldDisconnect @0x00541E00 removes every connection
except logonRecID_ — one connection against ACE) and
Proto_UI::SetEventCounter(0) @0x00541E79; the fresh CharacterList in
the same batch re-shows character management (gmGamePlayUI::Update
@0x004E9CD0 -> QueueUIMode(0x1000000a)). ACE mirrors it:
SendFinalLogOffMessages (Session.cs:249) sends 0xF653 + CharacterList
+ ServerName >=6 s after the request and leaves the session
AuthConnected — a second EnterWorld needs no re-handshake.
Implementation:
- RuntimeWorldTransitState: the canonical logout lifecycle
(Requested/PresentationActive/Confirmed, retail 3 s/+20 s holds,
cancel/reset/ownership convergence).
- WorldSession: RequestCharacterLogOff (non-blocking 0xF653),
IsCharacterLogOffConfirmed, ReturnToCharacterSelect (InWorld ->
InCharacterSelect + game-action sequence reset; transport untouched).
- LiveSessionController: BeginCharacterLogOff (flush-first request) and
CompleteCharacterLogOff — the return-to-selection transaction
(ReconnectCore minus the transport swap: retire the world
generation's routes, host reset, state flip, fresh generation
re-bind, roster re-applied from the pushed CharacterList; failures
degrade to the full StopCore teardown).
- RuntimeLocalPlayerMovementState.DisableCommandInterpreter +
DispatcherMovementInputSource gate: retail's Disable() — held keys
produce no movement while the server LogOut motion plays; cleared by
the generation reset.
- LocalPlayerTeleportController: the logout pump as the third arm of
the one wormhole machine (request/hold/wormhole/confirmed handoff;
teleport starts refused during logout; the handoff runs the session
transaction whose world reset retires the tunnel as the fresh
selection state re-shows the character screen).
- UI: both end-session surfaces share the retail three-way grounded
gate and now run the REAL flow; Options' Exit Game keeps the app
exit (window close -> the existing graceful-shutdown logoff).
Tests: +5 transit lifecycle, +4 session transaction, +7 logout pump.
Runtime 1756/0 (baseline 1747), App live-DAT 5523/3 (baseline 5512/3
+ 11 this round), Core.Net 1004/0, full solution green (0 failures).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
USER-DIRECTED deviation from retail (register row AD-109, same commit):
retail presents the empty pre-player gameplay screen — black behind the
retained UI — from the Enter click (CPlayerSystem::LogOnCharacter
@0x0055F890 -> CM_Login::SendNotice_BeginEnterWorld @0x006AD810, UI mode
0x10000008) until CreatePlayer raises SmartBox::teleport_in_progress
@0x00451C20 and gmSmartBoxUI::UseTime @0x004D6EAB begins TAS_TUNNEL. The
user prefers the tunnel to cover that whole wait.
- ILocalPlayerTeleportNetworkSink.ArmLoginTunnel: begins the login
wormhole presentation at the Enter click, consuming the sequencer's
begin-edge events SYNCHRONOUSLY (the Enter command blocks the update
thread for the whole ServerReady round trip, so a deferred first tick
would leave exactly the black window this deviation removes). The
enter cue plays at the click: retail's own rule is cue-at-animation-
begin (Sound_UI_EnterPortal @0x004D638E, unconditional inside
BeginTeleportAnimation), and the animation begin moved to the click.
- Armed pre-reveal pump: tunnel animates across the round trip
(worldReady pinned false, sequencer holds in Tunnel); the hold clock
accumulates from the click.
- Adoption: the Runtime login reveal ADOPTS the running presentation
(no re-Begin, no second cue); rejected EnterWorld (lifecycle back to
AwaitingSelection) disarms and retires the tunnel.
- Wired at the ONE host edge every entry route shares:
ILiveSessionLifecycleHost.ApplySelectedCharacter (direct connect,
roster Enter, enter-after-create) via
LiveSessionSelectionBindings.ArmLoginTunnel (default no-op keeps
headless and every existing construction site unchanged).
- ILocalPlayerLoginLifecycleSource: typed seam (not a stored delegate —
the frame-phase owner delegate-field guard) projecting the Runtime
character-selection lifecycle for the disarm edge.
- Frame contract update: [login-frames] over a login is tunnel -> world
from the click — no void, and no black between click and world.
Tests: 4 new armed-tunnel tests (arm/adopt/disarm/frame-shape); App
suite live-DAT 5516 passed / 3 skipped (baseline 5512/3 + 4 new).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found during the systemic escape-normalization round (967b9c57): retail's
ItemExamineUI::AddItemInfo @ 0x004AC050 appends wire strings straight to
UIElement_Text::AppendTextWithFont with no unescape — the escape decode
belongs exclusively to StringInfo resolution, which that round ported to
DatStringResolver/RetailStringEscapes. ItemAppraisalTextLayout.Shape's
literal-"\n"-to-line-break replace on server fragment text is therefore a
deviation, pre-existing inside the user-accepted assessment surface, now
filed instead of living only in a code comment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The login tunnel now covers from the first world-facing frame (the
sky-void backdrop can never present pre-tunnel) and holds through an
atomic tunnel-to-world swap at reveal completion — the void is
structurally unreachable on both edges, pinned by frame-sequence tests
across WorldSceneRenderer/WorldRevealCoordinator/LocalPlayerTeleport-
Controller/RuntimeWorldTransitState. Vitals detail icons draw at their
authored centered offsets in both stacked and side-by-side layouts.
Implemented and live-probed by the fix agent; finalized by the lead
after the agent parked post-verification (gates re-run green:
App 5512/3, Runtime 1747/0).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Retail runs the SAME TAS_TUNNEL wormhole at initial login as at an F751
teleport, with no F751 involved: SmartBox::teleport_in_progress
@0x00451C20 returns 1 the moment the login player exists with
position_update_complete == 0, gmSmartBoxUI::UseTime @0x004D6EAB
edge-detects it into BeginTeleportAnimation(TAS_TUNNEL) @0x004D6EC9
(playing Sound_UI_EnterPortal @0x004D638E), SmartBox::UseTime
@0x00455483 ends the hold once destination cells stop blocking,
Sound_UI_ExitPortal plays at the viewport swap @0x004D7405, and
LoginComplete goes out at the WorldFadeIn end @0x004D745D ->
CPlayerSystem::SendLoginCompleteNotification @0x00562E90 (ACE's own
GameActionLoginComplete comment names this contract: 'called when the
client player exits portal space. It includes initial login'). acdream
skipped all of it at login — every entry route (direct auto-select,
character-select Enter, enter-after-create) dropped onto the sky-only
'waiting for login' backdrop until the world reveal completed.
The fix engages the EXISTING F751 presentation machinery on Runtime's
login reveal — no duplicated presentation code, no timers:
- LocalPlayerTeleportController gains a login arm keyed off the
Runtime-owned login reveal generation (RuntimeWorldTransitState
.BeginLoginReveal, begun on the first accepted local-player position
on every entry route). It drives the same TeleportAnimSequencer/
PortalTunnelPresentation lifecycle and the same enter/exit cues; the
Place edge is a no-op at login (the first-entry conductor already
committed the canonical placement — retail's analogue only flips
position_update_complete), and FireLoginComplete now performs
EnterWorld + the single LoginComplete send + reveal Complete, exactly
like the F751 pump. worldReady is latched on BOTH canonical first
placement (OnLocalPlayerFirstEntryCompleted, the repointed
GraphicalSessionEventRoute completion callback that used to send
LoginComplete immediately) AND destination reveal readiness.
ActiveDestinationCell now also reports the login destination so the
render frame's reveal-preparation arm keeps running after portal-space
entry flips ChaseModeEverEntered.
- PlayerModeController.TryEnterPortalSpaceForLogin performs the
player-mode presentation attach (the same BuildControllerAndCamera the
post-reveal auto-entry used to run) before flipping into portal space
— at login no player-mode entry has happened yet. TryEnterPortalSpace
itself now refuses (retryable) on a constructed-but-unpublished
Runtime controller via the documented CanExecuteLiveMovement skip
predicate instead of faulting — the first connected run crashed on
exactly that pre-publication State write.
- HouseQuery stays at first-entry completion (retail: tail-called from
CPlayerSystem::InitializePlayer @0x00563570, an object-arrival edge,
not a tunnel edge).
- An F751 arriving mid-login-tunnel withdraws the login claim and hands
the presentation to the portal pump, which owns the single
LoginComplete — matching retail's one teleportInProgress flag.
TS-28 narrowed: the graphical host now runs the full login wormhole;
the residual is headless-only (no presentation; placement-edge send).
Live gates (testaccount2/+Horan vs local ACE, Release): the
character-select Enter route and the --session-config direct auto-select
route both play the wormhole with Sound_UI_EnterPortal at animation
begin, hold with retail's 'In Portal Space - Please Wait...' notice
until readiness, fade out with the view-plane warp, send LoginComplete
at the WorldFadeIn end, and materialize in Holtburg; ACE-confirmed
graceful logout. Tests: App 5493/3 skips (baseline 5490 + 3 new login
tests), Runtime 1744/0, full solution green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bookkeeping for the vitals retail-modes round (306a1670 + db8fa328): the
side-by-side vitals row joins IA-15's production LayoutDesc import list.
No new divergence class — the window shell, layout persistence, and
whole-surface drag regions the two vitals windows ride are already
registered under IA-12/IA-15/AP-98.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User finding 3 (retail screenshot): hovering a town on the Map tab turns
its marker GREEN and shows the name on a special-font tooltip — clearly
not our generic 0x10000395 popup skin, and we had no hover highlight at
all.
Re-derivation (live-DAT probe + raw ElementDesc dump + surface
byte-decode; MapNoteLiveDatTests pins all of it):
- m_pMap (0x100001EC)'s P0x47/P0x48 = 0x100001F0 @ 0x21000026 are the
note CONSTRUCTION template (AddMapNote @0x004a1bb0's
CreateChildElement args) — that part we had right.
- The TEMPLATE's own DirectState authors the note's tooltip popup
locator P0x47=0x10000398/P0x48=0x21000041 — the FOURTH popup skin,
whose incorporated text child 0x10000396 fonts 0x40000015 where the
other three skins font 0x40000002 (the user's "special font") — plus
P0x50=0.0 (zero per-element tooltip delay: town tooltips fire the
instant the dwell arms; UiRoot already honors it), P0x4B TooltipOn,
and P0x13 RolloverEnabled. Batch C's "the template authors no locator
of its own" claim was WRONG, and BuildTownMarkers' hardcoded
shared-skin override was clobbering the authored values — removed.
- The hover highlight: the template's Normal/Normal_rollover states are
PassToChildren descriptors driving the swallowed highlight child
0x100001F1 (base 0x100002B7@0x21000042 — a four-piece frame all
drawing 0x06004CC9, byte-decoded PURE GREEN A=FF R=00 G=FF B=00) via
per-state P0x3B (Invisible): hidden at rest, green on rollover.
Port:
- UiButton.CascadeStateToChildren — retail UIElement::SetState
@0x00464E70's PassToChildren cascade, keyed off the REQUESTED state id
(properties commit unconditionally; only the sprite draw is art-gated,
the existing #382/AP-222 distinction).
- UiDatElement.TrySetRetailState honors per-state P0x3B for NAMED states
(OnSetAttribute @0x00462d80 case 8: SetVisible(value==0)). The
unnamed-DirectState case is explicitly excluded — honoring it would
un-gate ISSUES #408 (1,083 authored-invisible elements) through
BuildWidget's post-children state reapply; measured breaking the
spell-favorite drag tests before the scoping (note added to #408).
- MapPageController.BuildTownMarkers rebuilds the button-swallowed
highlight child per marker through the AD-108 IconBuilder seam
(Bindings.TemplateInfoResolver, backed by
RowTemplateResolver.ResolveInfo — same cache) and arms it with the
initial Normal cascade.
Register TS-85's Batch C paragraph corrected; RetailTooltipPresenter's
F10 shared-skin remark updated (MapPageController no longer a consumer).
Tests: 3 installed-DAT pins (locator/delay/rollover; per-state P0x3B +
green frame; the four-skin font sweep), UiButton cascade + UiDatElement
P0x3B units, MapHousePanel marker no-clobber + hover-highlight fixture.
App suite 5487 passed / 3 skips (5490 total, +11 over baseline);
Runtime 1744/1744.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User finding 1 (side-by-side vs retail): our world-object tooltips popped
the instant the found object changed; retail's "lag". The night round's
derivation from RecvNotice_SmartBoxObjectFound @0x004E5AD0 misread the
notice as edge-MOUNTING: its immediate StartTooltipAtMouse @0x004E5DFB is
inside `if (s_pInstance->m_dragElement != 0)` (@0x004E5D8E) — and
m_dragElement is a real, distinct PDB field in acclient.h's
UIElementManager (separate from the m_pTooltipElement family), so the
immediate mount is DRAG-AND-DROP ONLY. The ordinary hover path merely
STAGES the name (SetTooltip @0x004E5D74 + the |=0x20 TooltipOn bit) and
the display rides the SAME UIElementManager::CheckTooltip @0x0045B6E0
mouse-idle dwell as UI tooltips: 250 ms (m_tooltipDelay @0x0045f75d)
since m_lastMouseMoveTime (stamped on EVERY move, MouseMoveHandler
@0x0045e736). Found swaps under an IDLE mouse replace the popup the same
frame (SetTooltip's own text-change teardown @0x004617FF -> ResetTooltip
@0x0045C360 tail-calling CheckTooltip); the 10 s duration expiry
(@0x0045b78a) requires a fresh mouse move before re-arming
(SwitchMouseOver(null) @0x0045b7b2 clears m_pElementLastEntered).
Port: UiRoot gains the unconditional last-mouse-move stamp
(m_lastMouseMoveTime 1:1 — the existing _hoverStartedMs stamps are
deliberately conditional) exposed as MouseIdleMs/NowMs;
RetailTooltipPresenter.UpdateWorldHoverTooltip now stages text at the
notice edge (ShowTooltips gate + name resolve read there, @0x004E5D21/
@0x004E5D3B, empty-name SetTooltip skip @0x004E5D48 included) and mounts
via the CheckTooltip dwell block (no-capture gate @0x0045b715,
m_tooltipEnable via MouseHover @0x0046254C — which the drag-immediate
branch faithfully bypasses). Session reset also forgets the staged text.
Tests: the world-hover fixture section rewritten to the corrected model —
found edge stages but never mounts before the dwell; a continuously
moving mouse never mounts until it rests; idle found-swap replaces
same-frame without stacking; duration auto-hide needs a move + fresh
dwell to remount; drag-in-progress mounts immediately. 38/38 pass.
Register TS-85 and ISSUES item 2 corrected honestly: the "edge-fired
(no dwell)" conclusion is superseded by the user's retail evidence and
the m_dragElement branch read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two mechanisms, both live-verified (register row AD-108 updated to match):
1. RESOLUTION. The player/house icons (0x100001ED/0x100001EE) are authored
as nested dat children of m_pMap (0x100001EC), itself a Type-1 button
whose UiButton.ConsumesDatChildren swallows them at build. The old
ResolveSwallowedIcon re-imported them standalone via
ImportInfos(hostLayout, iconId) — which returns null on the live DAT:
FindDesc walks the raw top-level Elements table (one entry for
0x2100006E) and never reaches them. Their ElementInfos only materialize
inside the full panel-slot resolve (ImportInfos(0x2100006E, 0x1000018C))
that MountMapHousePanel already imports — the pageInfo Bind already
receives. The fix finds each icon's info under m_pMap's own resolved
info subtree and BUILDS it through the new Bindings.IconBuilder seam
(production: LayoutImporter.Build under the DAT lock — the build half
of RowTemplateResolver's shape). An icon the normal walk DID build is
preferred (FindDescendant first), so a future ConsumesDatChildren
policy change cannot double-build.
2. POSITION. Found by this fix's own F1 live verification: the resolved
ring rendered pinned to m_pMap's top-left. PlaceMarker owns marker
position outright (retail's gmMapUI::Update re-places every tick;
retail's UpdateForParentSizeChange runs only on real parent resize),
but acdream re-runs ApplyAnchor per frame and the icon's compatibility
anchor had captured the authored (0,0) rect while the panel was still
hidden, re-asserting it over PlaceMarker's writes every frame.
PrepareIcon now sets Anchors=None (clearing any imported LayoutPolicy),
the established runtime-positioned-element convention.
Live numeric gate (session character +Acdream, cell 0xF07E003F):
independent computation (gid_to_lcoord -> display (90.8E, 0.5S) ->
byte-decoded PlaceMarkerOnMap formula, 17x16 icon, marker area
(6,8)-(247,258)) predicts local pixel (226,125); the connected client's
UI-tree dump shows the icon at screen (1166,195) under m_pMap (940,70) =
local (226,125) — exact match in both panel-open dumps. Coordinate text
"0.5S,90.8E", Holtburg town-marker tooltip (real-mouse hover), and the
House tab's "You may buy another house immediately." sentence all
confirmed on screen; ACE-confirmed graceful logout.
New pin: MapHousePanelLiveDatMountTests ([InstalledDatFact]) reproduces
the production mount recipe against the installed DATs — the test that
would have caught this at Batch C: pins the cold-import null, the
panel-slot resolution of both icons with non-degenerate extents, AND
that PlaceMarker's writes survive the per-frame ApplyAnchor pass.
Gates: Release build green; App suite (live-DAT mode) 5479/3 skips
(baseline 5478 + the new pin); Runtime 1744/0; full solution green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live verification against the connected client (part of the F1-F15
gate) found the Map tab's player/house icons never mount:
"[D.2b] Map tab: icon 0x100001ED did not resolve" / "...0x100001EE did
not resolve". AD-108 (filed earlier this session for F9) had described
the standalone re-import mechanism as working; it does not.
A throwaway diagnostic (not committed) confirmed the root cause:
LayoutImporter.ImportInfos(dats, hostLayoutId, elementId)'s FindDesc
walks the LayoutDesc's raw top-level Elements table (one entry) and
recurses through ElementDesc.Children with no tab-page/state resolution
— calling it directly with these icon ids returns null. Resolving the
panel's own slot first (what MountMapHousePanel actually does) and
searching THAT tree finds m_pMap with both icon children present, so
the icons are real, just unreachable via a cold standalone import.
This is pre-existing (predates this session, confirmed via git log)
and unrelated to any F1-F15 fix — it means F1's byte-decoded
PlaceMarkerOnMap formula could not be visually confirmed against the
running client this round; it remains verified only at the unit-test/
golden-pixel level. Filed a follow-up task for the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F9: filed register row AD-108 for MapPageController.ResolveSwallowedIcon
— the standalone re-import of the Map tab's player/house icons, which
m_pMap's own Type-1 UiButton authoring swallows as dat children
(UiButton.ConsumesDatChildren). This adaptation was implemented but
never had a register row.
F10: extracted the popup-locator pair (0x10000395/0x21000041),
previously duplicated as three separately-cited private constants
across UiItemSlot.cs, RetailTooltipPresenter.cs, and
MapPageController.cs, into ONE public pair on RetailTooltipPresenter
(SharedPopupSkinRootElementId/SharedPopupSkinLayoutDid) with a single
canonical citation. The other two sites now reference it instead of
carrying their own copy.
F12: fixed TS-85's SetTooltip-site arithmetic. The register (and a
mirrored ISSUES.md log entry) claimed "15 known sites, all accounted
for" — recounting the row's own enumerated list finds 17 distinct
sites (the tally had dropped gmPaperDollUI::UpdateItemSlotTooltip
@0x004A52EF and undercounted by one more), of which 16 are ported and
one — UIElement_Text::RecalculateTruncation @0x00466F80, the headline
highest-volume site sub-mechanism (1) itself named as deliberately
deferred — was never actually closed. The "all 15 accounted for"
close was wrong twice over: wrong count, and a site the row's own text
already scoped as open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gmHouseUI::DisplayPurchaseTimeText @0x004a3110's not-yet-expired
branch was wrongly marked "unrecoverable from this decomp dump" — a
direct capstone disassembly resolves all three concatenated pieces:
prefix "You may buy another landscape house at " @0x7ab790 (pushed
@0x004a3265), the strftime "%c" format literal @0x7ab7ec (pushed
@0x004a321d) applied to localtime(timestamp + 0x278d00) — the expiry
moment, 30 days after the purchase timestamp — and suffix ". This
restriction does not apply to apartments." @0x7ab7b8 (pushed
@0x004a3235).
Ported in RuntimeHouseState.Recompute, substituting .NET's
culture-default DateTime.ToString() for the CRT's strftime("%c", ...)
(different formatting engine, same "process locale, full date+time"
intent) — filed as register row IA-23 (an approximation, not a gap).
TimeProvider.LocalTimeZone (overridable, defaulting to
TimeZoneInfo.Local in production) keeps the conversion deterministically
testable while matching retail's own localtime() call.
Updated RuntimeHouseStateTests: the not-expired case now asserts the
composed prefix/suffix structure and the exact expiry instant (pinned
via a UTC-fixed test TimeProvider), replacing the old "renders nothing"
assertion. Un-claimed "unrecoverable" in ISSUES #413 item 2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F3: TS-85 had claimed the plain-spell branch's three SetTooltip format
strings were "genuine gmNoticeHandler vtable SLOTS" and unrecoverable
from the decomp dump. That was itself the artifact — Binary Ninja's
pseudo-C rendering of PStringBase::sprintf's second argument as
"&gmSpellcastingUI::`vftable'.RecvNotice_XXX" was a spurious symbol
match, not the true operand. A direct capstone disassembly of the raw
bytes at gmSpellcastingUI::UpdateCastButtonTooltip @0x004c6a30's four
call sites (0x4c6e48/0x4c6ea4/0x4c6f18/0x4c6f5d) resolves the actual
pushed literals: "CAST %hs" @0x7b63a4 (untargeted/self-cast, and
targeted+compatible with " on %s" @0x7b6464 appended), "You must
select an appropriate target for %hs" @0x7b6348 (incompatible target),
"You must select a target for %hs" @0x7b63b8 (no target). %hs is the
spell's own name throughout.
Added RuntimeSpellCastState.EvaluateCastGate (SpellCastGate: NoTarget-
Needed/TargetCompatible/TargetIncompatible/NoTargetSelected/Unknown),
refactoring IsTargetReady to use it, and wired
SpellcastingUiController.ComputeSpellCastState to the four-state
tooltip text, replacing the bare-spell-name fallback.
F4: the endowment branch's "USE the %s" (and both select-target
strings) vararg is NOT the bare item name — retail composes
"%s (%hs)" @0x7b64d8 (item name, spell name) once at @0x004c6bb6-ef
and reuses it for all three format strings, byte-confirmed by all
three sprintf call sites (0x4c6c7f/0x4c6ca4/0x4c6d46) reading the
identical stack slot. Added ComposeEndowmentName and wired it in place
of the bare item name.
F7: added test coverage for the two genuinely NEW disabled states
(needs-target, needs-appropriate-target) neither branch had any
coverage for before, plus the enabled untargeted/targeted-compatible
states and both endowment-branch composed-name cases.
Corrected the register's TS-85 row (the "cannot be recovered" claim
and the endowment operand claim) with the byte-decoded findings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CM_House::Event_QueryHouse @0x006aaa00 (opcode 0x21e) tail-calls
unconditionally from the end of CPlayerSystem::InitializePlayer
@0x00563570 — the same once-per-session function
AttemptSendLoginCompleteNotification lives in (both guarded by the
player_initialized flag), right after that notification. Retail never
sends it from gmHouseUI::PostInit or gmMapUI::PostInit on House-tab
activation.
Moved WorldSession.SendHouseQuery() to the direct (non-portal)
first-entry completion edges — the same places acdream already sends
the analogous "initial session bootstrap" LoginComplete:
- graphical: LiveSessionRuntimeFactory's RuntimeFirstEntryDriveController
localPlayerCompleted callback
- headless: HeadlessSessionHost's equivalent callback
- headless content-less direct host: RuntimeLiveEntitySessionController.OnSpawned
Portal-space re-entries (LocalPlayerTeleportController's F751 path,
RuntimeLiveEntitySessionController.TryAdvancePortalCompletion) do NOT
resend it, matching retail's single-shot guard.
Removed the invented House-tab-open -> SendHouseQuery trigger
(InteractionRetainedUiComposition's HouseShown binding) and retired
register row AD-107, which had documented that adaptation.
Updated RuntimeLiveEntitySessionControllerTests' exact game-action
assertions for the content-less path, which now also captures the
HouseQuery send alongside LoginComplete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>