From 7d09821fdc7ff3873bcb5973bcb3c5216c22a96d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 11:37:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(chargen):=20Campaign=20CC=20gate=20round=20?= =?UTF-8?q?1=20Batch=20B=20=E2=80=94=20authored=20selection=20states,=20la?= =?UTF-8?q?bel=20state,=20zoom/swatch=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GF-1/GF-8: UiButton now recognizes retail's custom Unselected/Selected radio-pair (0x10000016/0x10000017), bypassing the standard Normal/ Highlight machine that never admitted those state names — .Selected now lights the heritage/template/gender/Face-Clothes rows it was always a no-op for. AP-222/GF-11b: per-state label color/outline (dat 0x1B/0x21) now applies off the REQUESTED retail state id, not the art-gated committed ActiveState — resolves the Appearance spins' current-part highlight (text recolors even though no Highlight art exists on either client) and the Town caption's Normal-to-white swap. GF-11c: UiButton.LabelBox lets a lifted caption with its own authored rect draw there instead of the face-relative offset that's only correct when the label is authored directly on the button (heritage/template family, unchanged). GF-9: wires the real nine companion overlay elements (SetColor's SetVisible mechanism) that swatch clicks were always meant to drive, retiring AP-215 item 1 (the swatch.Selected substitution was a permanent no-op — swatches author no Highlight media at all). GF-10: zoom buttons now set the retail-mirrored mutual-exclusive Highlight/Normal pair on click; InitializePage carries no initial SetState for either button, so both stay at "Normal" until first click. Register: AP-222 retired (mechanism identified and ported), AP-215 narrowed (item 1 retired, item 2 unrelated and unchanged), row count recount corrected 164 (was already one high before this batch). App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Fixture + live- DAT tests only — no graphical client launch. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 5 +- ...-08-16-campaign-cc-gate-round1-findings.md | 143 ++++++++-- src/AcDream.App/UI/IUiDatStateful.cs | 20 ++ .../Layout/CharacterCreationAppearancePage.cs | 71 ++++- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 36 ++- src/AcDream.App/UI/Layout/ElementReader.cs | 62 +++++ src/AcDream.App/UI/UiButton.cs | 128 ++++++++- .../Layout/CharacterCreationLiveDatTests.cs | 253 ++++++++++++++++++ .../CharacterCreationUiControllerTests.cs | 112 +++++++- .../UI/Layout/DatWidgetFactoryTests.cs | 90 +++++++ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 165 ++++++++++++ 11 files changed, 1043 insertions(+), 42 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1b3b3da7..023a0877 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -199,7 +199,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 164 active rows (AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 164 active rows (recount at this same edit: the row count this header carried before Batch B was already one high relative to the physical table — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change; AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -396,14 +396,13 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | -| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions).** Two narrow, DECIDED substitutions where acdream reaches the same functional selection through a different widget mechanism than retail's own: (1) the nine color swatches (`0x1000030f-0x10000317`) use their own `UiButton.Selected` highlight state for "this is the current color" instead of toggling the separate Type-3 companion overlay element (`0x10000318-0x10000320`) retail's `SetColor @ 0x0047DD50` shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — the composited pixel result is UNVERIFIED to match, not asserted identical (same "measured, not assumed" discipline AD-103's own F5 note established for a different swallowed-child case). (2) the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons is out of this round's scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop; `SetStyleSpinLabel`) | Both substitutions reach the SAME underlying selection (the swatch highlight still shows which color index is active; the ordinal still lets a player cycle deterministically and see which slot they're on) through existing widget primitives (`UiButton.Selected`, `UiButton.Label`) rather than adding new rendering infrastructure (a second overlay-visibility channel, or an icon-texture pipeline) this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a different (simpler) selected-swatch visual and text labels where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close the label half of this row. | `gmCGAppearancePage::SetColor @0x0047DD50` (the `m_tColorWheel` overlay toggle); `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | +| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED.** What CLOSED this round: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest, retiring the prior `UiButton.Selected` highlight substitution outright (measured against the installed dat: the swatch buttons author only an unnamed DirectState sprite with no Normal/Highlight media at all, so that substitution was ALWAYS a complete no-op — the retired AP-222's own sibling finding). **Still open (unchanged, out of this round's scope):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons remains out of scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED this round; `SetStyleSpinLabel`, still open) | The ordinal still lets a player cycle deterministically and see which slot they're on through an existing widget primitive (`UiButton.Label`) rather than adding an icon-texture pipeline this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a numbered ordinal where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | | AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1).** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. acdream's swatches show only their authored (static) DAT art regardless of which color they represent or whether the current part even has that many colors — AP-215's `.Selected` substitution covers WHICH swatch is chosen, not what each swatch itself looks like. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — sets `.Selected` only, never touches swatch appearance) | The nine swatches already reach the correct SELECTION semantics through `DatWidgetFactory`'s existing `UiButton` primitive; painting each swatch with a computed color needs either a per-swatch dynamic-color render path (new UI infrastructure this scope doesn't otherwise need) or a fallback to static art, which is what this round shipped. | A side-by-side against retail shows every swatch drawing the SAME authored art regardless of which color it represents, and swatches beyond a part's real color count staying visibly "on" instead of blanking — a real visual gap on a screen the player stares at while picking a color, not a selection-correctness gap. | `gmCGAppearancePage::DoColorSpots @0x0047d850` | | AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3) — the original row misdescribed both the retail mechanism and the acdream gap.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes — the GradCircle is authored, retail-driven *decorative art reflecting the current color*, not an input control. acdream imports the GradCircle through the generic Type-3 `UiDatElement` fallback and never paints it: no `BlitAndColor`-equivalent repaint on color change, and no Eyes-blank equivalent. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`GradCircleId` is resolved by the live-DAT test only; the page never repaints it) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint (a `Blit_Multiply` composite against `m_pGradGraphic`/`m_pGradPlug`, not a click handler) is separate follow-up work with no decomp citation yet for the composite art assets. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color (and never blanking for Eyes) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | | AP-218 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 5).** Retail's `gmCGAppearancePage::Update` sets the Hair/Eyes/Skin spins' text to a heritage-flavored STATIC caption via `UIElement_Text::SetStringInfoWithFont` — normal heritage: `ID_CharGen_HairStyle`/`ID_CharGen_Eyes`/`ID_CharGen_Skin`; Olthoi/OlthoiAcid: `ID_CharGen_OlthoiText_HairButton`/`_EyesButton`/`_SkinButton`; Gearknight: `ID_CharGen_GearText_HairButton`/`_EyesButton`/`_SkinButton`. acdream's `SetStyleSpinLabel` instead overwrites the SAME label slot with a raw 1-based ordinal (or `"-"` when Unset) on all four icon-only spins (Hair/Eyes/Nose/Mouth) — neither the caption text nor its heritage-specific swap survives, and the ordinal itself is already a scope-cut stand-in for retail's icon thumbnail (CC1/AP-215). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`SetStyleSpinLabel`) | The icon-rendering gap (CC1/AP-215) already means the spin can't show retail's icon thumbnail either way this round; reusing the SAME `.Label` slot for a numeric position indicator gives the player SOME feedback about which style is selected without adding a second text element this round's widget catalog doesn't otherwise carry. | A side-by-side against retail shows a numbered ordinal where retail shows static caption text (heritage-flavored) with an icon for the value — a cosmetic/informational gap, not a selection-correctness gap; a Gearknight or Olthoi player sees the SAME generic ordinal a normal-heritage player would, losing the heritage-specific caption entirely. | `gmCGAppearancePage::Update` caption writes @0x0047ebad (`ID_CharGen_HairStyle`), @0x0047ebe3 (`ID_CharGen_Eyes`), @0x0047ec6a (`ID_CharGen_Skin`); @0x0047ed5b/@0x0047ed91/@0x0047ee15 (Olthoi `OlthoiText_*` variants); @0x0047e9ef/@0x0047ea25/@0x0047eaa9 (Gearknight `GearText_*` variants) | | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | -| AP-222 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — discovered while adding the nit's own requested media pin, MEASURED against the installed EoR dat rather than assumed.** F2 item 2's current-part spin highlight (`CharacterCreationAppearancePage.RefreshColorAndShadeControls` calling `spin.TrySetRetailState(UiButtonStateMachine.Highlight)` on the previously-current and newly-current spin, mirroring `gmCGAppearancePage::SetSelection @0x0047e260`'s `SetState(1)`/`SetState(6)` pair) is a COMPLETE NO-OP for all nine spins against the installed dat: `TrySetRetailState` itself always reports success for a `ToggleBehavior` button regardless of media (it just sets `Selected` and lets `UiButton.UpdateVisualState` resolve the actual draw state), but every one of the nine spins' two consumed arrow face segments (`UiButton`'s composite-body mechanism, AD-103's sibling convention) authors ONLY `Normal`/`Normal_rollover`/`Ghosted` state media — no `Highlight`/`Highlight_rollover`/`Highlight_pressed` art exists anywhere on any spin. `UiButton.UpdateVisualState`'s own committed-state gate (`_availableStates.Contains(requested)`, `UiButton.cs:647`) then silently keeps `ActiveState` at `"Normal"` instead of ever reaching `"Highlight"`. The PRE-EXISTING F2-item-2 live-DAT pin (`AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`) only verified the `ToggleBehavior` PROPERTY that gates the state-machine branch, never whether that branch has anything to actually draw — so this shipped, unnoticed, since the fix round that added the highlight call. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s spin loop); `src/AcDream.App/UI/UiButton.cs` (`UpdateVisualState`, `TrySetRetailState`'s `ToggleBehavior` branch) | Not yet resolved which side is wrong: retail's own `SetState(6)` call could ALSO be a visual no-op if retail's spin art likewise lacks Highlight media (this codebase's own `TrySetRetailState` `#382` comment already documents that a committed StateDesc with no media draws nothing in EITHER client) — or retail's current-part indicator might use an entirely different, unported mechanism (an overlay, like AP-215's swatch-selection ring, rather than a state swap on the spin itself). Deciding requires a decomp read of whichever retail function actually renders the spin's per-frame face, out of this residual round's scope (N2 was filed as a media-pin nit, not an investigation). | The F2 "current-part highlight" feature is presentation-dead for every spin today: clicking Hair/Eyes/Nose/Mouth/Skin/Headgear/Shirt/Trousers/Footwear changes the selected part but produces no visible highlight change anywhere on the Appearance page, which a visual gate comparing "does the current spin look selected" against retail would catch immediately, in either direction (parity if retail is equally silent, a real gap if retail is not). | `gmCGAppearancePage::SetSelection @0x0047e260` (`SetState(1)`/`SetState(6)` calls); `UiButton.cs:647` (`UpdateVisualState`'s commit gate); `UiButton.cs:244-303` (`TrySetRetailState`'s `#382` comment on committed-but-medialess StateDesc behavior) | | AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-5).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` still builds ONE flat listbox, rows in ascending skill-id order — that half of the row is UNCHANGED and stays registered. **What CLOSED this round:** the GF-5 fix discovered `RebuildRows` was resolving the WRONG template (`Templates[0]`, retail's 3-child bucket-header row) and requiring its root to be a `UiButton` — the real row template (`Templates[1]`, `0x100002FF`) is a plain container with SEPARATE up/down arrow buttons (`pSkillUpButton 0x10000304`/`pSkillDownButton 0x10000305`), each firing on a PLAIN click (`ListenToElementMessage @0x004814c0`) exactly like retail. The fix wires both real buttons instead of inventing a click-to-advance/double-click-to-retreat single-button substitution — that half of the original divergence is RETIRED, not merely narrowed. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `RefreshRowValues`, `Advance`, `Retreat`) | The four-bucket sorted model remains a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60`; `gmCGSkillsPage::ListenToElementMessage @ 0x004814c0`; `gmCGSkillsPage::DoSkillRecords @ 0x004817e0` | | AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md index 2fb92a5a..73f0bd66 100644 --- a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -1,5 +1,20 @@ # Campaign CC connected gate — round 1 findings (2026-08-16) +**MILESTONE (2026-08-16, post-Batch-A build `1.0.2-cc.g`): the user +completed the FIRST LIVE CHARACTER CREATE from acdream against local ACE — +launcher → character select → Create → six pages → name → Finish → real +character created. USER-CONFIRMED: "Yes i could now create a char." The +create flow's core path is live; the round continues for visual parity +(Batches B-D) and the remaining script checks (rejection dialogs, +log-straight-in confirmation, credit/randomize/exit warnings).** + +**Batch B (selection state media + label state) is CODE-COMPLETE +2026-08-16, pending the user's visual gate.** GF-1, GF-8, GF-9, GF-10, +GF-11b, and GF-11c are fixed — see each entry's own FIXED note below. +Fixture + live-DAT tests only this round (no graphical client launch); +App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Register: +AP-222 RETIRED, AP-215 NARROWED (item 1 retired, item 2 stays open). + User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session, windowed). Screenshots: retail Heritage, acdream Heritage, retail Profession. The user's side-by-side retail reports are AXIOMS @@ -9,12 +24,31 @@ ISSUES.md; this doc is the six-page batch. ## Functional (blocking or behavior-dead) -- **GF-1 Heritage selection dead/unmarked.** Clicking a heritage row does - not light its radio dot (retail: orange lit dot on the selected row — - screenshot 1). Unclear whether the click dispatches at all (the - description text that would confirm is itself broken, GF-2). ALSO: the - open-roll's own rolled heritage shows NO lit dot on entry — every dot - dark in the acdream screenshot. +- **GF-1 Heritage selection dead/unmarked — FIXED (Campaign CC gate round + 1, Batch B).** Root cause: retail authors a custom radio-selection state + pair (`RetailUiStateIds.Unselected`/`Selected`, `0x10000016`/ + `0x10000017`) on the heritage row (property-only state descriptors, no + media) with the actual art on a single stateful CHILD (the dot, + `0x100003C0`, media `0x06006E35`/`0x06006E21`, live-DAT-probe-confirmed). + `UiButton.AddAvailableStates` only recognized the standard Normal/ + Highlight/Ghosted name space, so `_availableStates` never admitted the + custom pair and `.Selected` committed nothing (probe-verified before the + fix: `Selected=true` left `ActiveState=="Unselected"`, while the raw + `TrySetRetailState(0x10000017)` already worked). Fixed by teaching + `UiButton` to detect the authored pair (`HasStateMedia("Unselected") && + HasStateMedia("Selected")`) at construction and bypass the standard + state machine for it — `.Selected` now routes directly to + `RetailUiStateIds.Selected`/`Unselected`, additive and gated on the + pair's presence, so every OTHER button's Normal/Highlight path is + byte-identical. The SAME fix also lights the Profession template icon + (`0x100003D9`), the Appearance Face/Clothes sub-tabs (GF-8, below), and + the gender buttons (whose media lives directly on the button, not a + child — the OTHER shape this fix covers). The open-roll's own + no-lit-dot-on-entry symptom shares this same root: `CharacterCreationHeritagePage.Refresh` + already sets `button.Selected = heritageId == snapshot.HeritageId` for + every row on every refresh (including the first one after open), so the + same `.Selected`-was-a-no-op bug silently ate the initial roll's own dot + too — this fix closes both halves of GF-1 with the same change. - **GF-5 Skills page empty — FIXED (Campaign CC gate round 1, Batch A).** Root cause was `CharacterCreationSkillsPage.RebuildRows` resolving `Templates[0]` (retail's own 3-child bucket-HEADER row, @@ -33,10 +67,25 @@ ISSUES.md; this doc is the six-page batch. fully retired — the flat-list-vs-four-bucket half stays). The credits- caption clobber (`SkillsPage.cs:81-82`, now different line numbers) is UNCHANGED — Batch C's scope. -- **GF-9 Appearance color swatches do nothing observable.** Clicking a - color produces no visible change (model recolor absent). Could be a dead - dispatch or could be working-but-invisible (AP-216 authored-art swatches - + a recolor that fails); investigate, don't guess. +- **GF-9 Appearance color swatches do nothing observable — FIXED (Campaign + CC gate round 1, Batch B).** Root cause confirmed as working-but- + invisible, not a dead dispatch: the `SelectColor`/`SetAppearanceIndex` + click path was already intact end to end (unchanged by this fix). The + swatch buttons themselves author ONLY an unnamed DirectState sprite — + live-DAT-probe-confirmed NO Normal/Highlight media at all — so the + existing `swatch.Selected = ...` highlight assignment in + `RefreshColorAndShadeControls` was a permanent no-op; nothing could ever + have shown a click's effect. Retail's REAL feedback mechanism is nine + separate companion overlay elements (`0x10000318`-`0x10000320`, + `CharacterCreationAppearancePage.SwatchOverlayIds`, live-DAT-confirmed + siblings of the swatches under the color-wheel container `0x100003B9`, + index-paired 1:1 with `SwatchIds`) that retail's `SetColor @0x0047DD50` + shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — + cross-confirmed against `gmCGAppearancePage::InitializePage`'s own + swatch/overlay id-pair table (`@0x004800ff-00480164`). Fixed by wiring + exactly one overlay visible per part, tracking the current part's + selected color index; retires AP-215's swatch-selection substitution + (item 1 — the icon-vs-ordinal item 2 stays open). - **GF-11a Town description text does not change** when switching towns. - **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED (Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat @@ -113,18 +162,62 @@ ISSUES.md; this doc is the six-page batch. per-attribute name labels (Strength…Self), Health/Stamina/Mana labels + values. Sliders and template selection themselves WORK. - **GF-6 Appearance spin captions are numbers,** not part names - ("Hair Style", "Eyes", …). Known rows AP-215/AP-218 — the gate promotes - them to must-port. + ("Hair Style", "Eyes", …). Known rows AP-215 (item 2 — item 1, the + swatch-selection substitution, RETIRED at Batch B/GF-9)/AP-218 — the + gate promotes them to must-port. - **GF-7 Preview backdrop black** on Appearance (and Summary, GF-14); retail's chargen 3D view shows a scenic backdrop. (The Heritage-page preview area shows terrain in BOTH clients — establish from the decomp what actually renders behind the model per page/view.) -- **GF-8 Appearance Face/Clothes sub-tab selection unmarked** (AP-222 - family, promoted by the gate). -- **GF-10 Zoom buttons show identical art** whichever is pushed. -- **GF-11b Town selected marker does not turn white** (button highlights, - but retail's selected-town graphic swaps to white). -- **GF-11c Town names misaligned on the map** vs retail. +- **GF-8 Appearance Face/Clothes sub-tab selection unmarked — FIXED + (Campaign CC gate round 1, Batch B).** Same root and same fix as GF-1: + the Face (`0x100003A9`)/Clothes (`0x100003AA`) sub-tab buttons author + the identical custom Unselected/Selected radio-pair shape (media on a + stateful icon child, `0x100002E9`, live-DAT-probe-confirmed) — not the + AP-222 family as originally suspected (AP-222 turned out to be a + DIFFERENT mechanism, the per-state label color/outline gap fixed + alongside GF-11b below). `UiButton`'s custom-selection-pair bypass + fixes both in one change. +- **GF-10 Zoom buttons show identical art — FIXED (Campaign CC gate round + 1, Batch B).** Pure wiring gap, not a widget mechanism problem — both + zoom buttons already author a standard Normal/Highlight(/rollover) pair + (live-DAT-probe-confirmed). `gmCGAppearancePage::ZoomIn @0x0047CF00` + (`@0x0047d005/0x0047d00f`) ends `ZoomInButton->SetState(6)` (Highlight), + `ZoomOutButton->SetState(1)` (Normal); `ZoomOut @0x0047D050` mirrors. + `CharacterCreationAppearancePage`'s click handlers only ever called + `PreviewControl.ZoomIn()/ZoomOut()`, never touching either button's + state — fixed to set the mutual-exclusive pair on every click. Re- + derived the INITIAL state from `InitializePage @0x0047fdd0-0048032e`: + `m_bZoomedIn = 0` is set at construction, but NO explicit initial + `SetState` call exists for either zoom button anywhere in + `InitializePage` — both start at their DAT-authored "Normal" default + until the first real zoom click; this port does not force an initial + Highlight either. +- **GF-11b Town selected marker does not turn white — FIXED (Campaign CC + gate round 1, Batch B).** The marker PIN art itself already swapped + correctly (the town button's own Normal/Highlight state machine was + never broken — its marker child, `0x1000040C`, authors real Highlight + media). What was missing: retail ALSO recolors the town NAME caption + (a lifted Type-12 child, id collides with the page-level description + panel's own id `0x10000409` in the installed dat — two distinct + elements in two distinct subtrees, harmless for the per-button lift) + from gold (218,167,85) to white (255,255,255) on selection, live-DAT- + measured. `DatWidgetFactory.BuildButton` lifted the caption's font + COLOR once at build time with no per-state override. Same root and fix + as AP-222 (below): per-state label color/outline, applied off the + REQUESTED retail state id. +- **GF-11c Town names misaligned on the map — FIXED (Campaign CC gate + round 1, Batch B).** The per-button caption's own authored rect + (`(0,4,100,37)`, Center-justified, live-DAT-measured) was being + discarded in favor of a Left-aligned offset computed from the marker + FACE's rect (`face.X + face.Width + 4`) — correct for the heritage/ + template/Face-Clothes row family (label authored DIRECTLY on the + button, beside a single-purpose face segment) but wrong here, where a + DISTINCT Type-12 caption child was lifted with its own independent + geometry. Fixed by adding `UiButton.LabelBox`: when a distinct lifted + caption carries its own rect, the label draws within THAT box using + its own authored justify instead of the face-relative offset; every + other button (`LabelBox` null) keeps the EXACT prior draw math. - **GF-12 Missing authored gold frames** around boxes on every page (Skills/Appearance/Town/Summary called out explicitly). - **GF-14 Summary paperdoll backdrop black** (same family as GF-7); @@ -138,9 +231,19 @@ ISSUES.md; this doc is the six-page batch. decide per element from the authored DAT + decomp. 2. Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap feeding GF-2/GF-3/GF-11a/GF-14. -3. Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker, +3. ~~Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker, GF-10 zoom art) — the AP-222 measured mechanism (state media authored - vs applied) across widget kinds. + vs applied) across widget kinds.~~ CLOSED, split into TWO distinct + mechanisms, both fixed at Batch B: (a) GF-1/GF-8 share a genuinely + UNRECOGNIZED custom state-name pair (`UiButton` never admitted + "Unselected"/"Selected" into its available-states set at all); GF-10 + was pure wiring (the standard Normal/Highlight pair was never even + requested). (b) GF-11b turned out NOT to be a state-media gap — the + marker's own media swap already worked; the actual gap was AP-222's + real mechanism, per-state LABEL COLOR/OUTLINE (a property commit + distinct from the art/media commit, and NOT gated by the same art- + availability check `ActiveState` is). See each GF's own FIXED entry + above and the retired AP-222 / narrowed AP-215 register rows. 4. Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws. 5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked chargen screen.~~ CLOSED: focus/typing routing was never broken (live- diff --git a/src/AcDream.App/UI/IUiDatStateful.cs b/src/AcDream.App/UI/IUiDatStateful.cs index 7a86e9b8..da1b0437 100644 --- a/src/AcDream.App/UI/IUiDatStateful.cs +++ b/src/AcDream.App/UI/IUiDatStateful.cs @@ -24,6 +24,22 @@ public static class RetailUiStateIds public const uint LockedUi = 0x10000063u; public const uint UnlockedUi = 0x10000064u; + /// + /// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom + /// radio-selection state pair, live-DAT-probe-confirmed on the Heritage + /// row (0x100003BF), Profession template (0x100003D9), + /// Appearance Face/Clothes sub-tabs (0x100003A9/0x100003AA), + /// and gender buttons (0x100003A7/0x100003A8). Named + /// UiStateInfo.Name strings, not media file ids — the buttons + /// author their state DESCRIPTORS under these two ids, with the actual + /// per-state art living either directly on the button (gender) or on a + /// single stateful face-segment child (heritage/template/sub-tabs). + /// See 's custom-selection-pair + /// bypass in UpdateVisualState. + /// + public const uint Unselected = 0x10000016u; + public const uint Selected = 0x10000017u; + public static string StateName(uint stateId) => stateId switch { @@ -38,6 +54,8 @@ public static class RetailUiStateIds Minimized => "Minimized", LockedUi => "LockedUI", UnlockedUi => "UnlockedUI", + Unselected => "Unselected", + Selected => "Selected", _ => "", }; @@ -56,6 +74,8 @@ public static class RetailUiStateIds "Minimized" => Minimized, "LockedUI" => LockedUi, "UnlockedUI" => UnlockedUi, + "Unselected" => Unselected, + "Selected" => Selected, _ => 0u, }; return stateId != 0; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index 070cd131..92b54b49 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -126,6 +126,31 @@ internal sealed class CharacterCreationAppearancePage : IDisposable 0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u, ]; + /// + /// GF-9 (Campaign CC gate round 1 Batch B): the nine Type-3 companion + /// "selected" overlay elements, one per entry at + /// the SAME index — retail gmCGAppearancePage::InitializePage's + /// own id-pair table (@0x004800ff-00480164, the switch that fills + /// m_tColorWheel[i][0x10]/[0x14]-ish offsets with the + /// swatch/overlay id pair per index) resolves the SAME nine ids this + /// array carries, in the SAME index order. SetColor (case + /// 0x0047DD50, iCurColor assignment) is retail's actual + /// click-feedback mechanism — SetVisible on the overlay at + /// iCurColor's index, NOT a state swap on the swatch itself (the + /// swatch buttons author only an unnamed DirectState sprite; measured + /// against the installed dat, they have NO Normal/Highlight media at + /// all, so was always a complete no-op + /// here — see ). Live-DAT- + /// probe-confirmed siblings of the swatches under the same color-wheel + /// container (0x100003B9), each roughly centered on its paired + /// swatch's own rect. + /// + internal static readonly uint[] SwatchOverlayIds = + [ + 0x10000318u, 0x10000319u, 0x1000031Au, 0x1000031Bu, 0x1000031Cu, + 0x1000031Du, 0x1000031Eu, 0x1000031Fu, 0x10000320u, + ]; + /// Live-DAT-measured arrow geometry, uniform across all nine /// spins (every one is 200px wide): decrement child at local /// x=[80,127), increment child at x=[127,174). Anything outside both @@ -144,6 +169,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable private readonly UiElement? _clothesChoices; private readonly Dictionary _spins = []; private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length]; + private readonly UiElement?[] _swatchOverlays = new UiElement?[SwatchOverlayIds.Length]; private readonly UiScrollbar? _shadeScroll; private readonly UiButton? _rotateClockwise; private readonly UiButton? _rotateCounterClockwise; @@ -209,6 +235,9 @@ internal sealed class CharacterCreationAppearancePage : IDisposable _swatches[i] = swatch; } + for (int i = 0; i < SwatchOverlayIds.Length; i++) + _swatchOverlays[i] = Find(pageRoot, SwatchOverlayIds[i]); + _shadeScroll = Find(pageRoot, ShadeScrollId); if (_shadeScroll is not null) _shadeScroll.ScalarChanged = SetShadeFromScalar; @@ -223,10 +252,33 @@ internal sealed class CharacterCreationAppearancePage : IDisposable _rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise(); _zoomIn = Find(pageRoot, ZoomInId); if (_zoomIn is not null) - _zoomIn.OnClick = () => PreviewControl?.ZoomIn(); + _zoomIn.OnClick = () => + { + PreviewControl?.ZoomIn(); + // GF-10: gmCGAppearancePage::ZoomIn @0x0047CF00 + // (@0x0047d005/0x0047d00f) ends ZoomInButton->SetState(6) + // (Highlight), ZoomOutButton->SetState(1) (Normal) — a + // mutual-exclusive pair. Re-derived from InitializePage + // @0x0047fdd0-0048032e (m_bZoomedIn = 0 at construction, + // @0x004802c3): NO explicit initial SetState call exists + // for either button, so both start at their DAT-authored + // "Normal" default (live-DAT-probe-confirmed) until the + // first real zoom click — this port does not force an + // initial Highlight. + _zoomIn.TrySetRetailState(UiButtonStateMachine.Highlight); + _zoomOut?.TrySetRetailState(UiButtonStateMachine.Normal); + }; _zoomOut = Find(pageRoot, ZoomOutId); if (_zoomOut is not null) - _zoomOut.OnClick = () => PreviewControl?.ZoomOut(); + _zoomOut.OnClick = () => + { + PreviewControl?.ZoomOut(); + // GF-10: gmCGAppearancePage::ZoomOut @0x0047D050 + // (@0x0047d140/0x0047d14a) mirrors ZoomIn — ZoomOutButton + // -> Highlight(6), ZoomInButton -> Normal(1). + _zoomOut.TrySetRetailState(UiButtonStateMachine.Highlight); + _zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal); + }; ApplyChoiceVisibility(); } @@ -608,12 +660,21 @@ internal sealed class CharacterCreationAppearancePage : IDisposable : UiButtonStateMachine.Normal); } + // GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch + // click feedback is the companion overlay's visibility (SetColor + // @0x0047DD50 -> m_tColorWheel[...][0x10][iCurColor*7]->SetVisible), + // not a state swap on the swatch button — measured against the + // installed dat, the nine swatches author only a DirectState sprite + // with no Normal/Highlight media at all, so a prior + // swatch.Selected assignment here was a permanent no-op (see + // SwatchOverlayIds' own doc comment). Exactly one overlay is + // visible: the one at the current part's own selected color index. ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart); uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance); - for (int i = 0; i < _swatches.Length; i++) + for (int i = 0; i < _swatchOverlays.Length; i++) { - if (_swatches[i] is { } swatch) - swatch.Selected = colorSlot is not null && currentColor == (uint)i; + if (_swatchOverlays[i] is { } overlay) + overlay.Visible = colorSlot is not null && currentColor == (uint)i; } ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index eb74843d..bf4df604 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -878,8 +878,31 @@ public static class DatWidgetFactory button.FaceTop = face.Y; button.FaceWidth = face.Width; button.FaceHeight = face.Height; - button.LabelAlign = UiButton.LabelAlignment.Left; - button.LabelOffsetX = face.X + face.Width + 4f; + + if (!ReferenceEquals(labelInfo, info)) + { + // GF-11c (Campaign CC gate round 1 Batch B): a DISTINCT + // Type-12 caption was lifted (e.g. the Town page's per- + // marker name label, 0x10000409 under each town button — + // live-DAT-probe-confirmed authored rect + Center justify, + // independent of the marker face's own geometry) — honor + // ITS OWN authored rect/justify instead of the face- + // relative offset below, which is only correct when the + // label text is authored DIRECTLY on the button itself, + // immediately beside a single-purpose face segment (the + // heritage/template/Face-Clothes sub-tab row family — + // still handled by the else-branch two lines down, since + // ReferenceEquals(labelInfo, info) is true there). + button.LabelBox = (labelInfo.X, labelInfo.Y, labelInfo.Width, labelInfo.Height); + button.LabelAlign = labelInfo.HJustify == HJustify.Left + ? UiButton.LabelAlignment.Left + : UiButton.LabelAlignment.Center; + } + else + { + button.LabelAlign = UiButton.LabelAlignment.Left; + button.LabelOffsetX = face.X + face.Width + 4f; + } } else if (labelInfo.HJustify == HJustify.Left) { @@ -901,6 +924,15 @@ public static class DatWidgetFactory button.LabelOffsetX = labelInfo.X; } + // AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label + // color/outline (dat properties 0x1B/0x21 authored PER STATE on the + // label-bearing element — the Appearance spins' own states, or the + // Town caption child's states) — additive, only non-null when the + // authored dat genuinely carries more than one distinct value. + button.SetPerStateLabelStyle( + ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu), + ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u)); + return button; } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index e1204f9e..606e19e1 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Numerics; using AcDream.App.UI; @@ -673,4 +674,65 @@ public static class ElementReader }) .ToArray(); } + + /// + /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): resolves a color + /// property (0x1B FontColor's Array-tolerant shape, same unwrap as + /// ) for EVERY state itself authors, keyed by retail numeric state id. + /// Returns null unless at least two states resolve to GENUINELY + /// DIFFERENT colors — the overwhelming majority of elements author one + /// color for every state (or none at all), and for those this returns + /// null so the caller keeps its existing single-default-color behavior + /// untouched. Only elements that really do recolor per state (the + /// Appearance spins' Highlight brightening, the Town buttons' Normal- + /// to-white caption swap) get a non-null map. + /// + internal static IReadOnlyDictionary? BuildPerStateColorMap( + ElementInfo info, uint propertyId) + { + Dictionary? map = null; + foreach (uint stateId in info.States.Keys) + { + if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId)) + continue; + + UiPropertyValue? colorValue = value.Kind == UiPropertyKind.Color + ? value + : value.Kind == UiPropertyKind.Array + && value.ArrayValue.Count > 0 + && value.ArrayValue[0].Kind == UiPropertyKind.Color + ? value.ArrayValue[0] + : null; + if (colorValue is null) + continue; + + UiColorValue c = colorValue.ColorValue; + float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f; + (map ??= new Dictionary())[stateId] = + new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha); + } + + return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null; + } + + /// + /// AP-222 counterpart of for a bool + /// property (0x21 Outline) — same "null unless genuinely per-state" + /// gating. + /// + internal static IReadOnlyDictionary? BuildPerStateBoolMap( + ElementInfo info, uint propertyId) + { + Dictionary? map = null; + foreach (uint stateId in info.States.Keys) + { + if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId) + || value.Kind != UiPropertyKind.Bool) + continue; + (map ??= new Dictionary())[stateId] = value.BoolValue; + } + + return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null; + } } diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index b748894b..41146914 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -37,6 +37,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful private readonly FaceSegment[] _faceSegments; private readonly Func _resolve; private readonly HashSet _availableStates = new(); + private readonly bool _hasCustomSelectionPair; + private IReadOnlyDictionary? _stateLabelColors; + private IReadOnlyDictionary? _stateLabelOutlines; private bool _pressed; private bool _pointerOver; private bool _selected; @@ -157,6 +160,25 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots. public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center; + /// + /// GF-11c (Campaign CC gate round 1 Batch B): optional authored label + /// rectangle, LOCAL to this button. When a caption is LIFTED from a + /// DISTINCT Type-12 child that carries its own independent rect (e.g. + /// the Town page's per-marker name label, positioned below/beside its + /// marker rather than immediately right of it), + /// draws the label within THIS box using its own authored geometry + /// instead of the FaceLeft-derived offset / full-button-width centering + /// the ordinary case uses (label authored directly on the button, right + /// beside a single-purpose face segment — the heritage/template/Face- + /// Clothes row family, where the current face-relative math is already + /// correct). Null (default, every pre-existing button) preserves the + /// EXACT prior draw math — still adds + /// to the button's own local origin, and + /// still centers within the whole + /// button width/height. + /// + public (float X, float Y, float Width, float Height)? LabelBox { get; set; } + /// Label horizontal alignment options. public enum LabelAlignment { Center, Left } @@ -330,6 +352,21 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful foreach (FaceSegment segment in _faceSegments) AddAvailableStates(segment.Info); + // Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom + // "Unselected"/"Selected" radio-selection state pair + // (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is + // authored as STATE DESCRIPTORS whose names UiButtonStateMachine's + // Normal/Highlight machine doesn't recognize — the standard + // AddAvailableStates loop above never admits them, so the ordinary + // RequestedState()-driven UpdateVisualState can never select them + // (measured: Selected=true committed nothing against the installed + // dat before this fix). HasStateMedia already checks the same media + // presence (face-segment child OR the button's own StateMedia) used + // everywhere else in this class, so this reuses that exact + // detection rather than adding a new one. + _hasCustomSelectionPair = HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Unselected)) + && HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Selected)); + ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle; RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover; HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick; @@ -402,10 +439,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful if (Label is { Length: > 0 } label && LabelFont is { } lf) { + // GF-11c: LabelBox null (every pre-existing button) reduces boxX/ + // boxY to 0 and boxWidth/boxHeight to the button's own Width/ + // Height — byte-identical to the prior unconditional math. + float boxX = LabelBox?.X ?? 0f; + float boxY = LabelBox?.Y ?? 0f; + float boxWidth = LabelBox?.Width ?? Width; + float boxHeight = LabelBox?.Height ?? Height; float tx = LabelAlign == LabelAlignment.Left - ? LabelOffsetX - : (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default) - float ty = (Height - lf.LineHeight) * 0.5f; + ? boxX + LabelOffsetX + : boxX + (boxWidth - lf.MeasureWidth(label)) * 0.5f; // centered (default) + float ty = boxY + (boxHeight - lf.LineHeight) * 0.5f; ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor); } @@ -638,13 +682,77 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful private void UpdateVisualState() { - uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput( - Disabled: !Enabled, - Selected: _selected, - RolloverEnabled: RolloverEnabled, - Pressed: _pressed, - PointerOver: _pointerOver)); - if (_availableStates.Contains(requested)) + uint requested = ComputeRequestedStateId(); + if (_hasCustomSelectionPair) + { + // gmCGHeritagePage::Update @0x00483219-0x0048372D (and the + // mirrored template/sub-tab/gender call sites): retail sets + // this pair directly by SELECTION, not through the ordinary + // Normal/Highlight/rollover/pressed machine — these buttons + // never author rollover or pressed media for the pair, so + // there is nothing faithful to compute beyond selected-or-not. + ActiveState = RetailUiStateIds.StateName(requested); + } + else if (_availableStates.Contains(requested)) + { ActiveState = UiButtonStateMachine.StateName(requested); + } + + // AP-222: apply the per-state label style off the REQUESTED id, not + // the (possibly art-gated) committed ActiveState — retail's own + // SetState(6) commits the state's PROPERTIES (including text color) + // unconditionally; only the SPRITE draw silently no-ops when a + // state has no media (this class's own #382 comment on + // TrySetRetailState documents the same distinction). The + // Appearance spins' current-part highlight is exactly this case: + // _availableStates never contains Highlight (their arrow face + // segments carry no Highlight art), so ActiveState stays "Normal" + // forever, but the spin's OWN label color must still swap. + ApplyPerStateLabelStyle(requested); + } + + private uint ComputeRequestedStateId() + => _hasCustomSelectionPair + ? (_selected ? RetailUiStateIds.Selected : RetailUiStateIds.Unselected) + : UiButtonStateMachine.RequestedState(new UiButtonVisualInput( + Disabled: !Enabled, + Selected: _selected, + RolloverEnabled: RolloverEnabled, + Pressed: _pressed, + PointerOver: _pointerOver)); + + /// + /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per- + /// RETAIL-STATE label color/outline override, additive over the single + /// default / lifted once at + /// construction. Set by ONLY when + /// the authored dat genuinely carries more than one distinct value + /// across this button's (or its lifted caption child's) own states — + /// e.g. the Appearance spins' Highlight-state gold brightening + /// (dat properties 0x1B/0x21, live-DAT-measured + /// 218,167,85 -> 255,221,131 plus outline off -> on) or the Town + /// buttons' Normal-to-white caption swap (218,167,85 -> 255,255,255). + /// A button with a single authored color (the overwhelming majority) + /// never calls this, so / + /// keep behaving exactly as before — including every existing external + /// post-construction assignment (e.g. ChatWindowController's Send + /// caption, PaperdollController's Slots label), none of which + /// author a second distinct per-state color. + /// + internal void SetPerStateLabelStyle( + IReadOnlyDictionary? colors, + IReadOnlyDictionary? outlines) + { + _stateLabelColors = colors; + _stateLabelOutlines = outlines; + ApplyPerStateLabelStyle(ComputeRequestedStateId()); + } + + private void ApplyPerStateLabelStyle(uint requestedStateId) + { + if (_stateLabelColors is { } colors && colors.TryGetValue(requestedStateId, out Vector4 color)) + LabelColor = color; + if (_stateLabelOutlines is { } outlines && outlines.TryGetValue(requestedStateId, out bool outline)) + Outline = outline; } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 7d1db271..88cb63ff 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1,5 +1,6 @@ using System.IO; using System.Linq; +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Content; @@ -278,6 +279,239 @@ public sealed class CharacterCreationLiveDatTests UiElement.FindDescendant(townRoot, 0x10000409u)); } + /// + /// GF-11b/GF-11c (Campaign CC gate round 1 Batch B). Live-DAT-measured: + /// each town button's marker (0x1000040D's own child + /// 0x1000040C) and its per-button name caption both carry the + /// SAME numeric id 0x10000409 as the page-level description + /// panel found by the sibling test above — a genuine id collision in + /// the installed dat between two DIFFERENT elements in DIFFERENT + /// subtrees (harmless for DatWidgetFactory.BuildButton's lift, + /// which walks the button's OWN ElementInfo.Children list rather + /// than resolving by a global id lookup — but it means this test + /// verifies the BUILT BUTTON's own / + /// /, + /// not a second FindDescendant call, which would ambiguously + /// return the unrelated page-level panel). Pins: the caption's own + /// authored rect (0,4,100,37) survives instead of being overwritten by + /// the marker-face-relative offset (GF-11c), and its color swaps + /// Normal (218,167,85) -> Highlight/white (255,255,255) on selection + /// (GF-11b). + /// + [InstalledDatFact] + public void TownPage_HoltburgButton_CaptionHonorsOwnRectAndRecolorsWhiteOnSelection() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement townRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.TownPageElementId)); + UiButton holtburg = AssertButton(townRoot, 0x1000040Du); + + Assert.Equal("Holtburg", holtburg.Label); + Assert.Equal(UiButton.LabelAlignment.Center, holtburg.LabelAlign); + Assert.Equal((0f, 4f, 100f, 37f), holtburg.LabelBox); + + holtburg.Selected = false; + Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), holtburg.LabelColor); + + holtburg.Selected = true; + Assert.Equal(new Vector4(1f, 1f, 1f, 1f), holtburg.LabelColor); + } + + /// + /// GF-1 (Campaign CC gate round 1 Batch B). Live-DAT-measured: the + /// Heritage row (0x100003BFu, Aluvian) authors retail's custom + /// "Unselected"/"Selected" radio-pair state DESCRIPTORS directly on the + /// row (property-only, no media), with the actual per-state art on its + /// single stateful dot child (0x100003C0). Before this fix, + /// committed nothing here. + /// + [InstalledDatFact] + public void HeritagePage_Row_SelectedTogglesTheAuthoredRadioDotState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement heritageRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.HeritagePageElementId)); + UiButton aluvian = AssertButton(heritageRoot, 0x100003BFu); + + Assert.Equal("Unselected", aluvian.ActiveState); + aluvian.Selected = true; + Assert.Equal("Selected", aluvian.ActiveState); + Assert.Equal(RetailUiStateIds.Selected, aluvian.ActiveRetailStateId); + aluvian.Selected = false; + Assert.Equal("Unselected", aluvian.ActiveState); + } + + /// + /// GF-1 counterpart: the Profession template row (0x100003D9u, + /// Custom/Adventurer) authors the identical custom radio-pair shape on + /// its own icon child (0x100002E9). + /// + [InstalledDatFact] + public void ProfessionPage_TemplateButton_SelectedTogglesTheAuthoredIconState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement professionRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.ProfessionPageElementId)); + UiButton template = AssertButton(professionRoot, 0x100003D9u); + + Assert.Equal("Unselected", template.ActiveState); + template.Selected = true; + Assert.Equal("Selected", template.ActiveState); + template.Selected = false; + Assert.Equal("Unselected", template.ActiveState); + } + + /// + /// GF-8: the Appearance page's Face/Clothes sub-tab buttons + /// (0x100003A9u/0x100003AAu) author the SAME custom + /// radio-pair shape on their own icon child (0x100002E9) — same + /// mechanism as the heritage/template rows, different page. + /// + [InstalledDatFact] + public void AppearancePage_FaceClothesSubTabButtons_SelectedTogglesTheAuthoredState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + foreach (uint buttonId in new[] + { + CharacterCreationAppearancePage.FaceButtonId, + CharacterCreationAppearancePage.ClothesButtonId, + }) + { + UiButton button = AssertButton(appearanceRoot, buttonId); + Assert.Equal("Unselected", button.ActiveState); + button.Selected = true; + Assert.Equal("Selected", button.ActiveState); + button.Selected = false; + Assert.Equal("Unselected", button.ActiveState); + } + } + + /// + /// GF-1 family, gender-button shape: 0x100003A7u/ + /// 0x100003A8u author the custom Unselected/Selected media + /// DIRECTLY on the button's own StateMedia (no separate face-segment + /// child) — live-DAT-measured, distinct from the heritage/template/ + /// sub-tab family above. Exercises 's OTHER + /// custom-selection-pair code path (media on the button itself, so + /// info.StateMedia.Count != 0 and no face child is ever + /// computed). + /// + [InstalledDatFact] + public void AppearancePage_GenderButtons_SelectedTogglesTheAuthoredState() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + foreach (uint buttonId in new[] + { + CharacterCreationAppearancePage.FemaleButtonId, + CharacterCreationAppearancePage.MaleButtonId, + }) + { + UiButton button = AssertButton(appearanceRoot, buttonId); + Assert.Equal("Unselected", button.ActiveState); + button.Selected = true; + Assert.Equal("Selected", button.ActiveState); + button.Selected = false; + Assert.Equal("Unselected", button.ActiveState); + } + } + + /// + /// GF-9 (Campaign CC gate round 1 Batch B). Live-DAT-measured: all nine + /// companion overlay elements (SwatchOverlayIds) resolve as + /// siblings of the swatches under the color-wheel container — retail's + /// ACTUAL click-feedback mechanism (SetColor's + /// m_tColorWheel[...][0x10][iCurColor*7]->SetVisible), not a + /// state swap on the swatch buttons themselves (which author only an + /// unnamed DirectState sprite — no Normal/Highlight media at all). + /// + [InstalledDatFact] + public void AppearancePage_SwatchOverlays_AllNinePresent() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds) + { + UiElement overlay = Assert.IsAssignableFrom( + UiElement.FindDescendant(appearanceRoot, overlayId)); + // Retail's overlay ring starts hidden — SetColor only shows the + // one at the current color index; nothing is selected before + // any color choice runs. + Assert.True(overlay.Visible); + } + } + + /// + /// GF-10 (Campaign CC gate round 1 Batch B). Live-DAT-measured: both + /// zoom buttons author a STANDARD Normal/Highlight(/rollover) pair — + /// unlike the custom radio-pair family above, this is pure wiring + /// ('s click handlers), not + /// a new UiButton mechanism. + /// + [InstalledDatFact] + public void AppearancePage_ZoomButtons_AuthorStandardNormalHighlightPair() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + foreach (uint buttonId in new[] + { + CharacterCreationAppearancePage.ZoomInId, + CharacterCreationAppearancePage.ZoomOutId, + }) + { + UiButton button = AssertButton(appearanceRoot, buttonId); + Assert.Equal("Normal", button.ActiveState); + Assert.True(button.TrySetRetailState(UiButtonStateMachine.Highlight)); + Assert.Equal("Highlight", button.ActiveState); + Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal)); + Assert.Equal("Normal", button.ActiveState); + } + } + /// The exit-warning + all per-heritage/per-town DAT string /// keys this slice cites actually resolve in the installed table. /// @@ -401,6 +635,25 @@ public sealed class CharacterCreationLiveDatTests spin.TrySetRetailState(UiButtonStateMachine.Highlight), $"spin 0x{spinId:X8} must accept a Highlight state request."); Assert.Equal("Normal", spin.ActiveState); + + // AP-222 CORRECTED + RESOLVED (Campaign CC gate round 1 Batch B): + // the art half of the "no-op" stays a genuine no-op (ActiveState + // pinned to "Normal" above, unchanged) — retail's own spin art + // authors no Highlight media either, matching acdream. But the + // current-part highlight is NOT presentation-dead: retail's + // SetState(6) also recolors the spin's caption text (dat + // property 0x1B), live-DAT-measured 218,167,85 (Normal) -> + // 255,221,131 (Highlight), plus outline off -> on (property + // 0x21). DatWidgetFactory.BuildButton wires this per-state style + // unconditionally, so it is already active on `spin` from + // TrySetRetailState(Highlight) above, independent of whether the + // page has assigned a Label string yet. + Assert.Equal(new Vector4(255f / 255f, 221f / 255f, 131f / 255f, 1f), spin.LabelColor); + Assert.True(spin.Outline, $"spin 0x{spinId:X8} must outline its label in the Highlight state."); + + Assert.True(spin.TrySetRetailState(UiButtonStateMachine.Normal)); + Assert.Equal(new Vector4(218f / 255f, 167f / 255f, 85f / 255f, 1f), spin.LabelColor); + Assert.False(spin.Outline, $"spin 0x{spinId:X8} must not outline its label in the Normal state."); } // Every color-wheel-family id resolves through EXISTING diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 462b4622..7d949070 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -785,6 +785,52 @@ public sealed class CharacterCreationUiControllerTests Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount); } + /// + /// GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch click + /// feedback — exactly one companion overlay visible, tracking the + /// current part's own selected color index (SetColor's + /// m_tColorWheel[...][0x10][iCurColor*7]->SetVisible). Drives + /// the snapshot directly (the fake binding only records what a click + /// SENDS, it doesn't feed it back) to exercise + /// RefreshColorAndShadeControls's own overlay loop end to end. + /// + [Fact] + public void AppearanceSwatchOverlays_ExactlyOneVisible_TrackingTheCurrentPartsColorIndex() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + // Part defaults to Hair on construction — no extra click needed. + + UiElement[] overlays = [.. CharacterCreationAppearancePage.SwatchOverlayIds + .Select(environment.Page)]; + + // No color selected yet (Unset) -> every overlay hidden. + Assert.All(overlays, overlay => Assert.False(overlay.Visible)); + + RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with + { + Revision = snapshot.Revision + 1, + Appearance = snapshot.Appearance with { HairColor = 1u }, + }; + environment.Controller.Tick(); + + for (int i = 0; i < overlays.Length; i++) + Assert.Equal(i == 1, overlays[i].Visible); + + snapshot = environment.Runtime.View.Snapshot; + environment.Runtime.View.Snapshot = snapshot with + { + Revision = snapshot.Revision + 1, + Appearance = snapshot.Appearance with { HairColor = 2u }, + }; + environment.Controller.Tick(); + + for (int i = 0; i < overlays.Length; i++) + Assert.Equal(i == 2, overlays[i].Visible); + } + [Fact] public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart() { @@ -848,6 +894,48 @@ public sealed class CharacterCreationUiControllerTests environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!(); } + /// + /// GF-10 (Campaign CC gate round 1 Batch B): ports + /// gmCGAppearancePage::ZoomIn @0x0047CF00 + /// (@0x0047d005/0x0047d00f: ZoomInButton -> Highlight(6), + /// ZoomOutButton -> Normal(1)) and its ZoomOut mirror + /// (@0x0047D050, @0x0047d140/0x0047d14a). Both buttons + /// start at their DAT-authored "Normal" default — re-derived from + /// InitializePage @0x0047fdd0-0048032e: m_bZoomedIn = 0 is + /// set at construction (@0x004802c3) but NO explicit initial + /// SetState call exists for either zoom button anywhere in + /// InitializePage, so this port does not force one either. + /// + [Fact] + public void AppearanceZoomButtons_ClickPath_TogglesMutualExclusiveHighlightPair() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + var preview = new FakeChargenPreviewControl(); + environment.Controller.AppearancePreviewControl = preview; + + UiButton zoomIn = environment.Button(CharacterCreationAppearancePage.ZoomInId); + UiButton zoomOut = environment.Button(CharacterCreationAppearancePage.ZoomOutId); + + Assert.Equal("Normal", zoomIn.ActiveState); + Assert.Equal("Normal", zoomOut.ActiveState); + + zoomIn.OnClick!(); + Assert.Equal("Highlight", zoomIn.ActiveState); + Assert.Equal("Normal", zoomOut.ActiveState); + + zoomOut.OnClick!(); + Assert.Equal("Normal", zoomIn.ActiveState); + Assert.Equal("Highlight", zoomOut.ActiveState); + + // Re-asserting the SAME direction is idempotent (retail's own early- + // return branch when already zoomed in/out — this port doesn't + // track m_bZoomedIn, but the RESULT is identical either way). + zoomOut.OnClick!(); + Assert.Equal("Normal", zoomIn.ActiveState); + Assert.Equal("Highlight", zoomOut.ActiveState); + } + private static void SelectAluvianMale(EnvironmentHarness environment) { environment.Runtime.SelectHeritageDirect(AluvianId); @@ -2151,6 +2239,11 @@ public sealed class CharacterCreationUiControllerTests foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) page.Children.Add(ButtonInfo(swatchId)); + // GF-9: the nine companion overlay elements, live-DAT-measured as + // plain Type-3 siblings of the swatches under the color-wheel + // container. + foreach (uint overlayId in CharacterCreationAppearancePage.SwatchOverlayIds) + page.Children.Add(ContainerInfo(overlayId)); page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId)); page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId)); @@ -2166,12 +2259,27 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId)); page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId)); - page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId)); - page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId)); + // GF-10: unlike the plain ButtonInfo() used above, the zoom buttons + // need REAL Normal/Highlight media so AppearanceZoomButtons_ + // ClickPath_TogglesMutualExclusiveHighlightPair can observe the + // actual mutual-exclusive state swap through TrySetRetailState — + // live-DAT-measured shape (both start "Normal", both author + // Highlight/rollover media). + page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomInId)); + page.Children.Add(ZoomButtonInfo(CharacterCreationAppearancePage.ZoomOutId)); return page; } + private static ElementInfo ZoomButtonInfo(uint id) + { + var info = new ElementInfo { Id = id, Type = 1u, Width = 81f, Height = 38f }; + info.StateMedia["Normal"] = (0x06004D55u, 1); + info.StateMedia["Highlight"] = (0x06004D56u, 1); + info.DefaultStateName = "Normal"; + return info; + } + private static ElementInfo SpinInfo(uint id) { var spin = new ElementInfo diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 72880d99..9c428648 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -366,6 +366,96 @@ public class DatWidgetFactoryTests Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign); } + /// + /// GF-11c (Campaign CC gate round 1 Batch B): the Town page's per-marker + /// caption shape (live-DAT-measured on 0x1000040D/0x10000409) — a + /// single stateful face child (the marker/pin, Normal/Highlight media) + /// PLUS a DISTINCT Type-12 caption child with its own authored rect and + /// Center justify, positioned independently of the marker (e.g. below + /// or above it, not necessarily beside it). Before this fix, the + /// caption's own rect/justify was discarded in favor of a Left-aligned + /// offset computed from the FACE rect — correct only for the heritage/ + /// template row family below, where the label is authored directly on + /// the button itself (see the companion regression test). + /// + [Fact] + public void BuildButton_SingleFaceChild_LiftedCaptionWithOwnRect_HonorsLabelBoxNotFaceOffset() + { + uint stringId = 555u; + var info = new ElementInfo { Type = 1, Width = 106, Height = 80 }; + info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" }; + info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" }; + + var caption = new ElementInfo + { + Type = 12, + X = 0, + Y = 4, + Width = 100, + Height = 37, + HJustify = HJustify.Center, + }; + caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0), + }; + info.Children.Add(caption); + + var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 }; + marker.StateMedia["Normal"] = (0x06004D60u, 1); + marker.StateMedia["Highlight"] = (0x06004D61u, 1); + info.Children.Add(marker); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == stringId ? "Holtburg" : null)); + + Assert.Equal("Holtburg", button.Label); + Assert.Equal(UiButton.LabelAlignment.Center, button.LabelAlign); + Assert.Equal((0f, 4f, 100f, 37f), button.LabelBox); + // The face geometry is still captured for the marker's own draw — + // just no longer used to derive the label's position. + Assert.Equal((36f, 36f, 38f, 38f), (button.FaceLeft, button.FaceTop, button.FaceWidth, button.FaceHeight)); + } + + /// + /// Regression companion: the heritage/template/Face-Clothes row shape — + /// the label string is authored DIRECTLY on the button (no distinct + /// Type-12 child), beside a single stateful face child (the radio dot). + /// This is the case the FACE-relative Left-aligned offset math IS + /// correct for, and it must keep working exactly as before GF-11c. + /// + [Fact] + public void BuildButton_SingleFaceChild_DirectLabel_KeepsFaceRelativeOffset() + { + uint stringId = 777u; + var info = new ElementInfo { Type = 1, Width = 305, Height = 32, HJustify = HJustify.Left }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0), + }; + info.States[RetailUiStateIds.Unselected] = new UiStateInfo { Id = RetailUiStateIds.Unselected, Name = "Unselected" }; + info.States[RetailUiStateIds.Selected] = new UiStateInfo { Id = RetailUiStateIds.Selected, Name = "Selected" }; + + var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 }; + dot.StateMedia["Unselected"] = (0x06006E35u, 1); + dot.StateMedia["Selected"] = (0x06006E21u, 1); + info.Children.Add(dot); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == stringId ? "Aluvian" : null)); + + Assert.Equal("Aluvian", button.Label); + Assert.Null(button.LabelBox); + Assert.Equal(UiButton.LabelAlignment.Left, button.LabelAlign); + Assert.Equal(36f, button.LabelOffsetX); // face.X(0) + face.Width(32) + 4 + } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 8baaaa38..e21c5849 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -249,6 +249,171 @@ public class UiButtonTests Assert.Equal(2, clicks); } + /// + /// GF-1/GF-8 (Campaign CC gate round 1 Batch B): the "gender button" + /// shape — retail's custom Unselected/Selected radio-pair media authored + /// DIRECTLY on the button's own StateMedia (no separate face-segment + /// child), live-DAT-measured on 0x100003A7/0x100003A8 (Female/Male). + /// Before this fix, .Selected committed nothing: the standard + /// AddAvailableStates loop never recognized the "Unselected"/"Selected" + /// names, so _availableStates was empty and UpdateVisualState's + /// RequestedState (which only ever returns Normal/Highlight/Ghosted ids) + /// could never match anyway. + /// + [Fact] + public void CustomSelectionPair_MediaDirectlyOnButton_SelectedTogglesActiveState() + { + var info = ButtonInfo("Unselected", "Selected"); + var b = CreateButton(info); + + Assert.Equal("Unselected", b.ActiveState); + + b.Selected = true; + Assert.Equal("Selected", b.ActiveState); + + b.Selected = false; + Assert.Equal("Unselected", b.ActiveState); + } + + /// + /// The "heritage/template/sub-tab row" shape — the parent authors the + /// Unselected/Selected state DESCRIPTORS (property bag only, no media), + /// and a single stateful child (the radio dot / icon) carries the + /// actual per-state art, matching FindStatefulFaceChildren's + /// name-overlap detection. Live-DAT-measured on the Heritage row + /// (0x100003BF, dot child 0x100003C0) and the Profession template row + /// (0x100003D9, icon child 0x100002E9). + /// + [Fact] + public void CustomSelectionPair_MediaOnFaceChild_SelectedTogglesActiveState() + { + var info = new ElementInfo { Type = 1, Width = 305, Height = 32 }; + info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo + { + Id = UiButtonStateMachine.NormalPressed, + Name = "Normal_pressed", + }; + info.States[RetailUiStateIds.Unselected] = new UiStateInfo + { + Id = RetailUiStateIds.Unselected, + Name = "Unselected", + }; + info.States[RetailUiStateIds.Selected] = new UiStateInfo + { + Id = RetailUiStateIds.Selected, + Name = "Selected", + }; + info.DefaultStateName = "Unselected"; + + var dot = new ElementInfo { Type = 3, Width = 32, Height = 32 }; + dot.StateMedia["Unselected"] = (0x06006E35u, 1); + dot.StateMedia["Selected"] = (0x06006E21u, 1); + info.Children.Add(dot); + + // Face-child discovery (FindStatefulFaceChildren) is DatWidgetFactory's + // job, not UiButton's own constructor — go through the real factory + // path so this fixture matches production exactly (raw CreateButton + // below bypasses that discovery entirely). + var b = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.Equal("Unselected", b.ActiveState); + + b.Selected = true; + Assert.Equal("Selected", b.ActiveState); + Assert.Equal(RetailUiStateIds.Selected, b.ActiveRetailStateId); + + b.Selected = false; + Assert.Equal("Unselected", b.ActiveState); + } + + /// + /// Regression pin: a STANDARD ToggleBehavior button (no Unselected/ + /// Selected states authored at all — the overwhelming majority of + /// buttons, including every pre-existing ToggleBehavior consumer) keeps + /// behaving exactly as before the custom-pair bypass was added. + /// + [Fact] + public void CustomSelectionPair_Absent_StandardToggleBehaviorUnchanged() + { + var info = ButtonInfo("Normal", "Highlight"); + AddBoolProperty(info, 0x0Bu, true); + var b = CreateButton(info); + + b.Selected = true; + Assert.Equal("Highlight", b.ActiveState); + + b.Selected = false; + Assert.Equal("Normal", b.ActiveState); + } + + /// + /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label + /// color/outline reacts to the REQUESTED retail state id even when the + /// standard art-availability gate never lets ActiveState reach it — the + /// Appearance spins' exact shape (their arrow face segments carry no + /// Highlight media at all, so ActiveState is permanently stuck at + /// "Normal", but the label text must still recolor). Live-DAT-measured + /// values: Normal (218,167,85), Highlight (255,221,131), outline + /// off -> on. + /// + [Fact] + public void PerStateLabelStyle_AppliesEvenWhenActiveStateCannotReachIt() + { + var info = ButtonInfo("Normal"); // no Highlight media at all + AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior + var b = CreateButton(info); + b.Label = "Hair Style"; + b.LabelColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f); + + var colors = new Dictionary + { + [UiButtonStateMachine.Normal] = new(218f / 255f, 167f / 255f, 85f / 255f, 1f), + [UiButtonStateMachine.Highlight] = new(255f / 255f, 221f / 255f, 131f / 255f, 1f), + }; + var outlines = new Dictionary + { + [UiButtonStateMachine.Normal] = false, + [UiButtonStateMachine.Highlight] = true, + }; + b.SetPerStateLabelStyle(colors, outlines); + + Assert.Equal(colors[UiButtonStateMachine.Normal], b.LabelColor); + Assert.False(b.Outline); + + b.Selected = true; + + // The art stays "Normal" (no Highlight media exists to commit to) — + // this is the exact AP-222 no-op the standard gate always produced — + // but the label color/outline must still reach the Highlight values. + Assert.Equal("Normal", b.ActiveState); + Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor); + Assert.True(b.Outline); + } + + /// + /// Regression pin: a button with NO per-state color map (null, the + /// overwhelming majority — every existing external post-construction + /// LabelColor assignment such as ChatWindowController's Send caption or + /// PaperdollController's Slots label) never has its LabelColor touched + /// by a state change. + /// + [Fact] + public void PerStateLabelStyle_Absent_ExternalLabelColorAssignmentSurvivesStateChanges() + { + var info = ButtonInfo("Normal", "Highlight"); + AddBoolProperty(info, 0x0Bu, true); + var b = CreateButton(info); + var externalColor = new System.Numerics.Vector4(1f, 0.92f, 0.72f, 1f); + b.LabelColor = externalColor; + + b.Selected = true; + Assert.Equal("Highlight", b.ActiveState); + Assert.Equal(externalColor, b.LabelColor); + + b.Selected = false; + Assert.Equal(externalColor, b.LabelColor); + } + private static UiButton ButtonWithStates(params string[] states) { var info = ButtonInfo(states);