From b1968ce980d8cdc8fa5e36d6da5b7cf9a12cbc90 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 11 Aug 2026 09:53:10 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20OP8=20rework=20=E2=80=94=20activatio?= =?UTF-8?q?n/scope=20preservation,=20camera-row=20de-alias,=20conflict-con?= =?UTF-8?q?firm=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the three MUST-FIX findings from the 2026-08-11 combined dual-lens review of commit b4edee97 (docs/research/2026-08-11-op8-review.md). M1 — SetForAction destroyed ActivationType/InputScope on every write, collapsing walk-mode's Hold, the three combat-scoped bindings, and CameraInstantMouseLook's mouse chord the instant a row (including Defaults, which touches all ~140 mapped rows at once) wrote back. Widened the Bindings seam to carry the full Binding (chord + activation + scope), not a bare chord: KeyboardConfigController captures each row's live Activation/Scope ONCE at build time (every multi-chord action in KeyBindings.RetailDefaults() shares one pair across all its bindings) and reapplies it on every write — rebind, Cancel/Revert, and Defaults (which restores DAT-sourced KEYS only, never touches the pair). New tests pin this across both Defaults and Cancel for a Hold+MeleeCombat-scoped action. M2 — InputMap 0x5 (CameraControls) and 0x6 (CameraAlternateControls) aliased one InputAction each: both rows read/wrote the same live target, so they showed identical stale chords, a rebind of one silently wiped the other, and a row could conflict with its own twin. Building real per-scheme dual-binding storage (or new InputAction members plus the camera-dispatch code to consume them) is a feature, not a one-line fix. Chose the third option: only ctx 0x5 — the scheme RetailDefaults() actually has live support for — maps to InputAction; ctx 0x6 falls through to the existing unmapped/store-only path (AP-203), fully rendered, bindable, and persisted, honestly carrying no live effect. This also retired 10 stale allowlist entries in the DAT-vs- RetailDefaults() round-trip test: with the alias gone, ctx 0x5 alone matches RetailDefaults() exactly for all twelve Camera actions. M3 — the auto-reassign-on-conflict path was wired silent in production (NotifyReassigned: _ => "") though the contract asked for a prompt and retail confirms before overwriting (OpenOverwriteBindingDialog). Wired a real confirm dialog through RetailDialogFactory.MakeConfirmation — the same seam GameplayConfirmationController already uses — read lazily since DialogFactory mounts after MountKeyboardConfig in Initialize()'s order. Only reassigns on accept; decline leaves every row untouched. AP-204 (which recorded the narrowing) is RETIRED; the still-true OK/Cancel left-click-vs-right-click-release note moves to a code comment (zero observable difference, doesn't warrant a register row). Reverted the gate script's step 9 from documenting the silent shape back to the real confirm-prompt behavior. SHOULD-FIX addressed as one-liners in files already touched: - S1: non-user-bindable conflicts are now checked BEFORE any row conflict (retail's own order), and ALL conflicting rows are collected (N-way), not just the first match. - S3: Save wraps the file-write pair in the same try/catch RuntimeKeyBindingTarget.Apply already uses for keybinds.json. - S4: assigning "Mapping 3" on a row with no existing bindings now lands on display index 2, not index 0 — ReplaceSlotValue trims only TRAILING empty slots instead of stripping every default(KeyChord). Right-click on an already-empty slot is now a no-op instead of shifting later bindings. - S6: UiButton.OnRightClick returns false (unhandled, bubbles to parent) when no handler is set, disabled or not — matching the pre-existing behavior the class doc already claimed. Left for a future pass (not one-liners): S2 (ActionMap.ConflictingMaps is still unread — the conflict scan treats all 306 rows as one flat universe instead of respecting the DAT's own legitimately-shared-key table) and S5 (the ~330 DAT layout imports still run eagerly at mount instead of lazily on first open). 19 KeyboardConfigControllerTests (was 12): +2 activation/scope preservation (Defaults, Cancel), +1 camera de-alias, +2 confirm-dialog accept/decline, +1 non-bindable-takes-priority-over-row-conflict, +1 sparse-row third-slot placement. Full solution suite 13,154 passed / 4 skipped / 0 failed (this round's baseline 13,147/4/0, zero regressions). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 4 +- .../2026-08-11-campaign-op-test-script.md | 23 +- .../UI/Layout/KeyboardConfigController.cs | 224 +++++++++----- src/AcDream.App/UI/RetailUiRuntime.cs | 51 +++- src/AcDream.App/UI/UiButton.cs | 14 +- .../Input/RetailActionIdentityTable.cs | 55 ++-- .../Layout/KeyboardConfigControllerTests.cs | 274 +++++++++++++++++- .../RetailActionIdentityRoundTripTests.cs | 39 +-- 8 files changed, 521 insertions(+), 163 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 067b1182..88bb6e61 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -175,7 +175,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 144 active rows (AP-204/AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) — 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, and assorted UI/Combat/Camera odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet, and AP-204 records the conflict-resolution UX narrowing (silent auto-reassign instead of retail's modal confirm-before-reassign dialog) plus the OK/Cancel buttons' left-click porting of retail's right-click-release gesture; AP-201 filed 2026-08-11 at the OP5 review-fix round (S2) — `UiScrollablePanel`'s row viewport culls whole rows instead of clipping them (no scissor stack), which OP5's 240-260px filter blocks make user-visible for the first time as a whole-block vanish-then-reappear at certain scroll offsets; see the row below for the full analysis and issue #371; 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 filed 2026-08-11 at the OP4 review-fix round (MUST-FIX 3 / blast M2) — the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the GameplaySettings store retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); 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) — 143 active rows (~~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-201 filed 2026-08-11 at the OP5 review-fix round (S2) — `UiScrollablePanel`'s row viewport culls whole rows instead of clipping them (no scissor stack), which OP5's 240-260px filter blocks make user-visible for the first time as a whole-block vanish-then-reappear at certain scroll offsets; see the row below for the full analysis and issue #371; 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 filed 2026-08-11 at the OP4 review-fix round (MUST-FIX 3 / blast M2) — the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the GameplaySettings store retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); 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 @@ -186,7 +186,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-201 | **Filed 2026-08-11 at the OP5 review-fix round (S2), predates OP5 but was made user-visible by it.** `UiTemplateListBox`'s internal row viewport (`UiScrollablePanel.LayoutScrollableChildren`) culls a child WHOLE — `child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f` — rather than clipping the visible portion of a row that straddles the viewport edge, because the UI renderer has no scissor stack. Retail's own `UIElement_ListBox`/scroll-region rendering clips partially-visible rows at the pixel boundary, same as any native scroll view. Every row in this viewport was 8-36px until Campaign OP slice OP5 added five self-sized filter blocks (12x20=240px / 13x20=260px, AP-195) to the Chat tab's ~560px viewport; a 240-260px block straddling the viewport edge at a given scroll offset now disappears ENTIRELY (a visible "pop") instead of clipping, where the pre-OP5 8-36px rows made the same all-or-nothing cull read as ordinary row-granular scrolling. | `src/AcDream.App/UI/UiScrollablePanel.cs:69` (the cull predicate); consumed by `src/AcDream.App/UI/UiTemplateListBox.cs` (`Viewport`) — the Character/Chat/Config Options-panel tabs and any other controller-built row list sharing this viewport | A scissor stack does not exist anywhere in the retained-UI renderer yet (class's own doc comment, `UiScrollablePanel.cs:8-12`, predates this row); whole-row culling is a correct, cheap stand-in for every list whose rows are small relative to the viewport, which was true for every consumer before OP5. | A tall block (any future row taller than roughly the viewport's own height, not just OP5's filter blocks) can vanish completely for a range of scroll offsets instead of showing a partial view — the OP5 gate script's own step 2 documents the exact symptom so it is not mistaken for a self-sizing regression (`docs/research/2026-08-11-campaign-op-test-script.md`). Scrolling further always restores the block whole; no data or state is lost, only the presentation pops. | No scissor-stack retail oracle needed — this is a stand-in for ordinary native clip-rect rendering every GUI toolkit (including retail's own) provides; issue #371 tracks adding a real per-row clip rect to `UiScrollablePanel` | | AP-202 | **Filed 2026-08-11 at Campaign OP slice OP8 (D4).** Configure Keyboard persists every rebind to `keybinds.json` only. Retail's own storage is a `\Asheron's Call\.keymap` text file (`CInputManager_WIN32::SaveKeyMap @0x00686C20`, `PFileParser`), with Load-File/Save-As buttons for NAMED keymap profiles and a `keymap` key in `UserPreferences.ini` selecting which one loads at startup (research doc §5.7). D4 chose the existing, tested `keybinds.json` schema over building a second `PFileParser`-compatible text codec + named-profile management; this row's the Load File/Save As buttons on the Configure Keyboard screen (`0x10000027`/`0x10000029`) are wired but INERT. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`WireScreenButtons`'s Load/Save-As no-op); `src/AcDream.UI.Abstractions/Input/KeyBindings.cs` (`SaveToFile`/`LoadOrDefault`) | `keybinds.json` already round-trips every retail action this screen can bind (identity table + the DAT-defaults conformance test), so the ONLY capability lost is exchanging `.keymap` files with a real retail client or another acdream install by named profile — a real feature gap, not a correctness gap. | A user who expects to export/import a named `.keymap` profile (e.g. to share a control layout with a retail-client friend) cannot; every rebind still works and persists locally. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.7-§5.8; `CInputManager_WIN32::SaveKeyMap @0x00686C20`; `gmKeyboardUI::SaveKeymap @0x004DCF90` | | AP-203 | **Filed 2026-08-11 at Campaign OP slice OP8.** Of the DAT ActionMap's 306 user-bindable rows, `RetailActionIdentityTable` (`src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs`) resolves roughly half to a live acdream `InputAction`; the rest render, bind, conflict-check, and persist (via `RetailUnmappedKeyBindings`, a sibling `*-unmapped.json` file) exactly like any other row, but have no live gameplay consumer to dispatch through. The two largest classes: 82 of 87 Emote rows (only Cry/Laugh/Cheer/Wave/PointState dispatch an animation today — acdream has no general emote-animation player), and all 48 CharacterSettings hotkey rows (ctx `0x10000008` — these are hotkeys for the SAME `PlayerOption`/`CharacterOptions` preference bits OP1's `CharacterOptionTable` and OP4's Character-tab checkboxes already model; wiring "press this key, flip that same server-synced bit" is a real feature, a hotkey-to-option-toggle dispatcher, that does not exist anywhere in acdream yet). Smaller residuals: Spell Slot 10-12, Quickslot 10-13 (both hit a PRE-EXISTING `InputAction` enum gap this slice did not introduce), and roughly twenty UI-panel-toggle rows for panels acdream has no analog for (Vitae/Link Status/House/Map/Character Info/the two Magic panels/...). | `src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs` (class doc has the full accounting); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`CurrentForUnmapped`/`SetForUnmapped`) | Guessing a mapping for an ambiguous row risks silently misrouting a rebind to the wrong gameplay action (worse than an honest "not wired yet" — the identity table's own class doc states this directly); every mapping that WAS added was cross-verified two ways (label match + DAT-default-vs-`KeyBindings.RetailDefaults()` byte match, see `RetailActionIdentityRoundTripTests`). | A user rebinds e.g. an emote or a CharacterSettings hotkey on the Configure Keyboard screen and the binding persists but has no observable in-game effect — matches retail's OWN screen shape (the row exists, is bindable) while honestly lacking retail's gameplay behavior behind it. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.1-§5.3; live-DAT probe 2026-08-11 (306-row/six-ActionClass accounting, `RetailActionMapReaderTests`) | -| AP-204 | **Filed 2026-08-11 at Campaign OP slice OP8.** Two narrowings in the Configure Keyboard screen's own interaction shape versus research doc §5.4-§5.5: (1) a chord conflict against another row on this screen auto-reassigns (erases the losing row's slot, applies the new one) and stays silent, where retail shows a modal `OpenOverwriteBindingDialog` BEFORE reassigning (may list several conflicts at once) — this port has no confirm-dialog integration built for this slice, and no retail string exists for a POST-reassign notice (retail's own flow never shows one either, so staying silent after the fact is not itself a text-invention risk); the distinct non-user-bindable refusal (`ID_KeyMapCantOverwriteReadOnlyKeymap_Label`, table `0x23000004`, byte-verified "Could not overwrite ") IS ported faithfully. (2) retail's OK/Cancel fire on right-click RELEASE (`idMessage 0x19`, `dwParam1 == 7`); this port wires them as ordinary left-click, matching every OTHER Campaign OP button's input shape rather than retail's asymmetric authored gesture for just these two. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`FindConflict`/`BeginSlotCapture`; `WireScreenButtons`'s OK/Cancel `OnClick`) | The confirm-dialog integration is a real subsystem this slice did not build (`RetailDialogFactory`'s catalog is wired but no keyboard-specific confirm flow exists); auto-reassign-then-silent is the same NET OUTCOME a user sees after clicking "yes" on retail's dialog, just without the "are you sure" step. The left-click gesture change is authored-input-only — no user-visible affordance differs (retail's right-click-release on THIS pair of buttons has no visual cue distinguishing it from left-click either). | A user who rebinds a key already claimed by another row on this screen gets no confirmation prompt and no notice — the reassignment simply happens; if this is ever found jarring in the connected gate, the fix is a real confirm-dialog integration, not a text change. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.4 (`UIOption_ActionKeyMap::KeyHitHandler @0x00489570`, `OpenOverwriteBindingDialog @0x00488BF0`, `OpenCantOverwriteBindingDialog @0x00489300`) and §5.5 (OK/Cancel `idMessage 0x19` gesture) | +| ~~AP-204~~ | **RETIRED 2026-08-11 at the OP8 rework (M3, combined review).** Originally filed for two narrowings: (1) silent auto-reassign on a cross-row conflict instead of retail's modal `OpenOverwriteBindingDialog`, and (2) OK/Cancel wired as left-click instead of retail's right-click-release gesture. (1) is FIXED — `KeyboardConfigController.BeginSlotCapture` now opens a real confirm dialog through `RetailDialogFactory.MakeConfirmation` (the SAME seam `GameplayConfirmationController` uses) BEFORE reassigning, listing every conflicting row (N-way), and only applies on accept; decline leaves every row untouched. (2) is NOT fixed and does not warrant its own row: it is authored-input-only with zero observable difference to a user (retail's own right-click-release on just this pair of buttons carries no distinguishing visual cue either, and every other Campaign OP button already uses left-click) — noted as a code comment at the OK/Cancel wiring site instead of a register row, matching this register's convention of reserving rows for divergences that could produce an observable symptom. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`FindConflicts`/`BeginSlotCapture`; `WireScreenButtons`'s OK/Cancel `OnClick` comment); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountKeyboardConfig`'s `ConfirmOverwrite` wiring) | — | — | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.4 (`UIOption_ActionKeyMap::KeyHitHandler @0x00489570`, `OpenOverwriteBindingDialog @0x00488BF0`, `OpenCantOverwriteBindingDialog @0x00489300`) and §5.5 (OK/Cancel `idMessage 0x19` gesture) | | AP-194 | `CharacterOptionTable`'s `ClientDefault` column (what the Character tab's Defaults button restores) disagrees with the raw constructor default word for three ids: `ConfirmVolatileRareUse` (`0x2D`), `ShowHelm` (`0x2F`), and `ShowCloak` (`0x32`) are all ON in retail's constructor default `CharacterOptions2 = 0x00948700` (`PlayerModule::PlayerModule @0x005D51F0`, byte-verified literal write) but report default-OFF via `PlayerModule::GetDefaultOptionValue @0x005D2A30`, whose own per-option table stops at id `0x2A` and returns `false` for everything past it. This is retail's OWN behavior, reproduced deliberately — the Defaults button does not reproduce a fresh `PlayerModule`. **CONFIRMED 2026-08-11 at Campaign OP slice OP4**: `CharacterOptionsPageController` seeds every `BoolOptionRow`'s default directly from this column (`EveryRow_DefaultValue_MatchesCharacterOptionTableClientDefault`, `tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs`); the directive below was followed, not re-litigated. OP4 also independently traced retail's OWN mechanism for the Character tab specifically — `UIOption_Checkbox::SetPlayerOption @0x00486e80` (pseudo-C line 147375) sets `m_default` directly from `GetDefaultOptionValue`, confirming this column (not the separate `DBPropertyCollection`/`InqDefaultGameplayOptionProperty` mechanism that governs the Chat/Config tabs' `m_propName`-bound rows) is the correct and ONLY source for this tab. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`ClientDefault` column; see the type's XML doc); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` | Byte-verified at both addresses (wire research §2.5 for the constructor literals, §8.2 for `GetDefaultOptionValue`'s own table and bounds check) — this is not a guess, it is retail's documented quirk. "Fixing" it to match the constructor default would make acdream's Defaults button MORE correct than retail's own, which is the opposite of this project's goal. | A future OP-campaign slice (OP4, the Character tab's Defaults button) must consult THIS column, not the constructor default word, or a future reader may "fix" this back and silently diverge from retail. | `PlayerModule::GetDefaultOptionValue @0x005D2A30`; `UIOption_Checkbox::SetPlayerOption @0x00486e80` (N-4 anchor-column correction, OP4 review-fix round 2026-08-11 — was mislabeled `PlayerModule::SetPlayerOption`, same address, wrong class); `PlayerModule::PlayerModule @0x005D51F0`; `docs/research/2026-08-10-set-character-options-wire.md` §8.2 | | AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 | | AP-196 | **Filed 2026-08-11 at the OP4 review-fix round (MUST-FIX 3 / blast M2).** OP4's Group-C re-point (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`/`VividTargetingIndicator`/`CoordinatesOnRadar`/`LockUI`/`AcceptLootPermits`/`ToggleRun`) moved these options' EFFECTIVE default source from the client-local `GameplaySettings.Default` record (or a persisted `settings.json`) to the retail constructor word, read live through `RuntimeCharacterOptionsState`/`CharacterOptionTable.ClientDefault` — a fresh install's observable default for `ViewCombatTarget` FLIPS from `true` (the old `GameplaySettings.Default`) to `false` (retail's real `CharacterOptions1` default — bit `0x80` is clear in `0x50C4A54A`). This fix round additionally DELETED the three now-fully-orphaned `GameplaySettings` fields (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`) and their `RuntimeSettingsController` mirror properties/`SetCombatGameplay` method outright — the Combat panel's own three LEDs (`CombatUiController`) now read/write the SAME `RuntimeCharacterOptionsState` seam the Character tab's rows use, closing the "two writable copies" divergence the blast review found. The other five re-pointed options remain present in `GameplaySettings` as WRITE-BEHIND persistence/draft mirrors (`settings.json` still records the last-known value for restart continuity and Settings-panel draft consistency) — but the AUTHORITATIVE read for gameplay behavior is always the live server bit. | `src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs`; `src/AcDream.App/Settings/RuntimeSettingsController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs`; `src/AcDream.App/Combat/LiveCombatAttackOperations.cs` (`CharacterOptionCombatSettingsSource`) | Server-authoritative reads matching CH3's established precedent are strictly more correct than a client-local snapshot that can silently diverge from the character's real server state; the three deleted fields had zero remaining production consumers once the Combat panel's LEDs were re-pointed, so deleting rather than deprecating avoids a permanently-dead second store. | A fresh install (or a character whose `settings.json` predates this change) shows `ViewCombatTarget` OFF by default where it previously showed ON — a deliberate correctness fix, not a regression, if a future report cites "combat camera doesn't track by default." Any future code that still expects `GameplaySettings` to carry `AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget` fails to compile — a forcing function, not a silent gap. | `CharacterOptions1.Default = 0x50C4A54A` (`PlayerModule::PlayerModule @0x005D51F0`); `CharacterOptionTable.cs`'s `ClientDefault` column; `docs/research/2026-08-11-op4-review-blast.md` §S3. ADDENDUM (2026-08-11, re-review R4): the HEADLESS host's effective defaults changed in the same re-point — `HeadlessGameplayOperations` previously hardcoded `AutoRepeatAttack = false` / `AutoTarget = true` and now reads the live bits, so a bot that declares neither option observes `AutoRepeatAttack` flip `false → true` (the retail constructor word has it ON); a bot that needs the old behavior declares `"AutoRepeatAttack": false` in its `characterOptions` block (OP7) | diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md index b06763b9..984316e0 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -910,13 +910,16 @@ line calling it INERT no longer applies. 8. **Still on Movement, click "Move Backward"'s first key button** (currently "X"), then press **W** — the SAME key you just confirmed is bound to "Move Forward." -9. **Expect**: "Move Backward" takes W, and "Move Forward" silently - loses its W slot (down to just "U" from step 5) — the SAME outcome - you'd get after confirming retail's own "reassign?" dialog, except - this port applies it immediately without asking first (AP-204, - register row — a deliberate scope narrowing for this slice; retail - shows a confirm dialog before reassigning). No error, no crash, both - rows' key-button labels update to reflect the swap. +9. **Expect**: a confirmation dialog opens (retail's own + `OpenOverwriteBindingDialog`, ported through the same + `RetailDialogFactory` confirm mechanism the game's other Yes/No + prompts already use) naming "Move Forward" as the row that currently + holds the key and asking whether to reassign it to "Move Backward." + **Click Yes**: "Move Backward" takes W, and "Move Forward" loses its + W slot (down to just "U" from step 5); both rows' key-button labels + update to reflect the swap. Repeat steps 8-9 once more but **click + No** this time: neither row should change at all — the capture is + simply abandoned, exactly like Escape. ### Non-bindable refusal @@ -989,9 +992,9 @@ line calling it INERT no longer applies. explicitly guards against — a regression here would mean one page's rows leaked into another, or the six reused element ids resolved to the wrong page's instance). -- Whether the silent auto-reassign (step 9) or the refusal message's - odd phrasing (step 10) feels wrong enough in practice to warrant - building the real confirm-dialog integration AP-204 defers. +- Whether the confirm dialog's wording (step 9) or the refusal + message's odd phrasing (step 10, the literal DAT string) reads + awkwardly enough in practice to warrant a follow-up polish pass. - Any row whose Emote/CharacterSettings binding visibly DOES something in-game despite AP-203 saying it shouldn't (would mean acdream grew a consumer for it since this table was written, and the identity table diff --git a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs index eed27428..c4ec8299 100644 --- a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs +++ b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs @@ -81,21 +81,44 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// Conflicts (research doc §5.4). Retail's conflict model is N-way and -/// cross-input-map, with a DISTINCT refusal for a chord already bound to a -/// non-user-bindable action. This port scans every OTHER row on this screen -/// (the full user-bindable universe, since every DAT-sourced row is inherently -/// user-bindable — already filtered out the -/// non-bindable ones) PLUS the live table for chords -/// bound to an acdream-only action with no -/// row at all (Ctrl+M mute, the debug F-keys, ...) — those are this port's -/// "non-user-bindable" analogue (there is no retail row to reassign them from) and -/// refuse via exactly like retail's -/// distinct OpenCantOverwriteBindingDialog. A genuine cross-row conflict -/// (register row — narrowed from retail's modal confirm-before-reassign) auto- -/// reassigns (erases the losing row's slot, applies the new one) and reports the -/// outcome via rather than blocking on a -/// confirm dialog this slice does not build. +/// Activation/Scope preservation (M1, 2026-08-11 review). A mapped row's +/// read returns the FULL live +/// list, not bare chords — a single acdream +/// consistently carries one / +/// pair across every one of its bindings (verified +/// against every multi-chord action in KeyBindings.RetailDefaults(): +/// walk-mode's Hold, the three melee/missile/magic combat scopes, ...), so this +/// row captures that pair ONCE at build time (from the first live binding, or +/// / if the action +/// starts wholly unbound) and reapplies it to every chord this row ever writes — +/// on a live rebind, on Cancel/Revert (RestoreSavedValue), and on Defaults +/// (RestoreDefaultValue, which restores DAT-sourced KEYS only; Activation/ +/// Scope are retail-side properties of the ACTION, not of which physical key +/// triggers it, so Defaults must never touch them). Before this fix, +/// SetForAction reconstructed every with the +/// constructor's bare defaults (/ +/// ), so a single click of Defaults collapsed the +/// Hold/scope of every one of the ~140 mapped actions in one shot — walk-mode +/// stopped unlatching, melee/missile/magic combat holds stopped repeating, and +/// scope precedence broke for every chord shared across those three scopes by +/// design (Insert/Delete/End/PageUp/PageDown). +/// +/// +/// +/// Conflicts (research doc §5.4, reworked per M3/S1, 2026-08-11 review). +/// Retail's conflict model is N-way and cross-input-map, with a DISTINCT refusal +/// for a chord already bound to a non-user-bindable action, checked BEFORE any +/// user-bindable conflict is even considered (retail refuses outright the instant +/// ANY conflicting target is non-user-bindable). This port's non-user-bindable +/// analogue is a chord already bound to an acdream-only action with no +/// row at all (Ctrl+M mute, the debug +/// F-keys, ...) — refused via +/// exactly like retail's distinct OpenCantOverwriteBindingDialog, with no +/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine +/// cross-row conflict collects EVERY conflicting row (not just the first) and +/// opens a real confirm dialog through — +/// retail's OpenOverwriteBindingDialog(&conflicts) — BEFORE reassigning; +/// only on accept are the losing rows' slots erased and the new chord applied. /// /// public sealed class KeyboardConfigController @@ -152,10 +175,12 @@ public sealed class KeyboardConfigController /// The live read/write/capture seam this screen writes bindings /// through — mirrors every other Campaign OP page controller's /// Bindings shape (a plain delegate record, no DAT/InputDispatcher - /// dependency baked into the controller itself). + /// dependency baked into the controller itself). / + /// carry the FULL (chord + + /// activation + scope), not a bare chord — M1's fix (see class doc). public sealed record Bindings( - Func> CurrentForAction, - Action> SetForAction, + Func> CurrentForAction, + Action> SetForAction, Func<(uint InputMapId, uint ActionId), IReadOnlyList> CurrentForUnmapped, Action<(uint InputMapId, uint ActionId), IReadOnlyList> SetForUnmapped, Action> BeginCapture, @@ -163,7 +188,11 @@ public sealed class KeyboardConfigController Action Toggle, Action DisplaySystemMessage, string NonBindableRefusalText, - Func NotifyReassigned); + // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm + // BEFORE reassigning a chord already bound to another row on this screen. + // message is pre-composed (real row labels, no invented retail text); + // the callback receives the user's Yes(true)/No(false) choice. + Action> ConfirmOverwrite); public OptionPage Page { get; } = new(); public IReadOnlyList Rows => _rows; @@ -325,17 +354,35 @@ public sealed class KeyboardConfigController bool mapped = RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action); InputAction? mappedAction = mapped ? action : null; - IReadOnlyList initial = mapped + // M1: capture this row's live Activation/Scope ONCE, from the first + // existing binding for the action (every multi-chord action in + // KeyBindings.RetailDefaults() shares one Activation/Scope pair across + // all its bindings — see class doc). Falls back to the Binding record's + // own defaults (Press/Game) only when the action starts wholly unbound. + IReadOnlyList liveBindings = mapped ? bindings.CurrentForAction(action) + : Array.Empty(); + (ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0 + ? (liveBindings[0].Activation, liveBindings[0].Scope) + : (ActivationType.Press, InputScope.Game); + + IReadOnlyList initial = mapped + ? liveBindings.Select(b => b.Chord).ToArray() : bindings.CurrentForUnmapped((row.InputMapId, row.ActionId)); IReadOnlyList defaults = DatDefaultsToChords(row.DefaultBindings); var model = new ActionKeyMapOptionRow(initial, defaults, apply: value => { + // Interior/padding default(KeyChord) entries (S4 — sparse-slot + // display, see ReplaceSlotValue) are never real bindings; filter + // them out at the write boundary, not at storage time. + IReadOnlyList real = value.Where(c => c != default).ToArray(); if (mapped) - bindings.SetForAction(action, value); + bindings.SetForAction( + action, + real.Select(c => new Binding(c, action, template.Activation, template.Scope)).ToArray()); else - bindings.SetForUnmapped((row.InputMapId, row.ActionId), value); + bindings.SetForUnmapped((row.InputMapId, row.ActionId), real); }); Page.Register(model); @@ -369,7 +416,10 @@ public sealed class KeyboardConfigController { IReadOnlyList current = view.Model.Current; for (int i = 0; i < view.KeyButtons.Count; i++) - view.KeyButtons[i].Label = i < current.Count ? DescribeChord(current[i]) : null; + { + bool bound = i < current.Count && current[i] != default; + view.KeyButtons[i].Label = bound ? DescribeChord(current[i]) : null; + } } private static string DescribeChord(KeyChord chord) @@ -384,83 +434,114 @@ public sealed class KeyboardConfigController { if (captured is not { } chord) return; // Escape — retail cancels silently. - switch (FindConflict(chord, exclude: view)) + (ConflictOutcome outcome, List conflictRows) = FindConflicts(chord, exclude: view); + switch (outcome) { - case ConflictKind.None: - break; - case ConflictKind.Row: - // A real cross-row conflict — auto-reassign (register row: retail - // confirms first via OpenOverwriteBindingDialog; this port narrows - // to reassign-then-notify rather than a blocking modal). - RowView conflictRow = _lastConflictRow!; - ReplaceSlotValue(conflictRow, RemoveChord(conflictRow.Model.Current, chord)); - RefreshRowButtons(conflictRow); - bindings.DisplaySystemMessage(bindings.NotifyReassigned(conflictRow.Label ?? "?")); - break; - case ConflictKind.NonBindable: - // Bound to an acdream-only action with no DAT row at all (Ctrl+M - // mute, the debug F-keys, ...) — this port's analogue of retail's - // distinct "can't overwrite" refusal (OpenCantOverwriteBindingDialog). + case ConflictOutcome.NonBindable: + // S1 / retail order: checked BEFORE any row conflict is even + // considered — retail refuses outright the instant ANY + // conflicting target is non-user-bindable. This port's + // analogue: a chord already bound to an acdream-only action + // with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) — + // OpenCantOverwriteBindingDialog's ported refusal, no dialog. bindings.DisplaySystemMessage(bindings.NonBindableRefusalText); return; - } - List updated = new(view.Model.Current); - while (updated.Count <= slot) updated.Add(default); - updated[slot] = chord; - ReplaceSlotValue(view, updated); - RefreshRowButtons(view); + case ConflictOutcome.Rows: + // M3: retail's OpenOverwriteBindingDialog — confirm BEFORE + // reassigning (N-way: every conflicting row is named, not just + // the first). Only on accept do the losing rows lose the slot. + string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?")); + string message = + $"'{DescribeChord(chord)}' is already bound to {names}. " + + $"Reassign it to '{view.Label}'?"; + bindings.ConfirmOverwrite(message, accepted => + { + if (!accepted) return; + foreach (RowView conflictRow in conflictRows) + { + ReplaceSlotValue(conflictRow, RemoveChord(conflictRow.Model.Current, chord)); + RefreshRowButtons(conflictRow); + } + ApplySlot(view, slot, chord); + }); + return; + + case ConflictOutcome.None: + ApplySlot(view, slot, chord); + return; + } }); } + private static void ApplySlot(RowView view, int slot, KeyChord chord) + { + List updated = new(view.Model.Current); + while (updated.Count <= slot) updated.Add(default); + updated[slot] = chord; + ReplaceSlotValue(view, updated); + RefreshRowButtons(view); + } + private void EraseSlot(RowView view, int slot) { if (slot >= view.Model.Current.Count) return; + if (view.Model.Current[slot] == default) return; // nothing bound in this display slot var updated = new List(view.Model.Current); updated.RemoveAt(slot); ReplaceSlotValue(view, updated); RefreshRowButtons(view); } - private static void ReplaceSlotValue(RowView view, IReadOnlyList value) => - view.Model.SetCurrentValue(value.Where(c => c != default).ToArray()); + private static void ReplaceSlotValue(RowView view, IReadOnlyList value) + { + // S4 (2026-08-11 review): only trim TRAILING empty slots. Retail's + // SetBinding(qc, slot) writes the SPECIFIC slot the user clicked — a row + // with no bindings whose "Mapping 3" button is set must keep the chord at + // display index 2, not collapse it onto index 0. Interior default(KeyChord) + // entries only ever come from ApplySlot's own padding, so trimming just the + // tail keeps RefreshRowButtons' positional read correct without inventing a + // nullable-chord storage type. + int lastReal = -1; + for (int i = 0; i < value.Count; i++) + if (value[i] != default) lastReal = i; + view.Model.SetCurrentValue(lastReal < 0 ? Array.Empty() : value.Take(lastReal + 1).ToArray()); + } private static IReadOnlyList RemoveChord(IReadOnlyList from, KeyChord chord) => from.Where(c => c != chord).ToArray(); - private enum ConflictKind { None, Row, NonBindable } - - // Set by FindConflict just before returning ConflictKind.Row — avoids a - // second lookup pass at the call site. Single-threaded (UI thread only). - private RowView? _lastConflictRow; + private enum ConflictOutcome { None, NonBindable, Rows } /// /// Retail's N-way, cross-input-map conflict scan (research doc §5.4: /// ICIDM::FindConflictingInputMaps/FindConflictingControls), - /// scoped to this screen's own universe: every OTHER row's current chord set + /// scoped to this screen's own universe: the non-user-bindable check runs + /// FIRST (S1 — retail's own order), then EVERY OTHER row's current chord set /// (covers BOTH mapped and unmapped rows — a chord already claimed by an - /// unmapped row is just as real a conflict as one claimed by a mapped one), - /// then the live table for an acdream-only action - /// this screen has no row for at all. + /// unmapped row is just as real a conflict as one claimed by a mapped one) is + /// collected in full, not just the first match. /// - private ConflictKind FindConflict(KeyChord chord, RowView exclude) + private (ConflictOutcome Outcome, List Rows) FindConflicts(KeyChord chord, RowView exclude) { - _lastConflictRow = null; + if (_bindings is not null) + { + foreach (InputAction candidate in Enum.GetValues()) + { + if (RetailActionIdentityTable.Map.Values.Contains(candidate)) continue; + if (_bindings.CurrentForAction(candidate).Any(b => b.Chord == chord)) + return (ConflictOutcome.NonBindable, new List()); + } + } + + var rows = new List(); foreach (RowView other in _rows) { if (ReferenceEquals(other, exclude)) continue; - if (!other.Model.Current.Contains(chord)) continue; - _lastConflictRow = other; - return ConflictKind.Row; + if (other.Model.Current.Contains(chord)) + rows.Add(other); } - if (_bindings is null) return ConflictKind.None; - foreach (InputAction candidate in Enum.GetValues()) - { - if (RetailActionIdentityTable.Map.Values.Contains(candidate)) continue; - if (_bindings.CurrentForAction(candidate).Contains(chord)) - return ConflictKind.NonBindable; - } - return ConflictKind.None; + return rows.Count > 0 ? (ConflictOutcome.Rows, rows) : (ConflictOutcome.None, rows); } private static void WireScreenButtons( @@ -493,8 +574,9 @@ public sealed class KeyboardConfigController // OK — right-click release in retail (idMessage 0x19); ported as a plain // left-click here, matching every other Campaign OP button (the asymmetry - // is authored-input-only, not a behavior a user would notice — register - // row if reviewed otherwise). + // is authored-input-only — no user-visible affordance differs, since + // retail's own right-click-release on just this pair of buttons carries + // no distinguishing visual cue either). if (layout.FindElement(OkButtonId) is UiButton okButton) okButton.OnClick = () => { diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 2dca4547..a8476af4 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2331,13 +2331,17 @@ public sealed class RetailUiRuntime : IDisposable }, resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId), new Layout.KeyboardConfigController.Bindings( - CurrentForAction: action => dispatcher.Bindings.ForAction(action) - .Select(b => b.Chord).ToArray(), - SetForAction: (action, chords) => + // M1 (2026-08-11 review): read/write the FULL live Binding + // (chord + activation + scope), not a bare chord — SetForAction + // used to reconstruct every Binding with the constructor's bare + // defaults (Press/Game), collapsing walk-mode's Hold and every + // combat-scoped binding's scope the instant a row wrote back. + CurrentForAction: action => dispatcher.Bindings.ForAction(action).ToArray(), + SetForAction: (action, newBindings) => { KeyBindings updated = CloneWithout(dispatcher.Bindings, action); - foreach (KeyChord chord in chords) - updated.Add(new Binding(chord, action)); + foreach (Binding b in newBindings) + updated.Add(b); dispatcher.SetBindings(updated); }, CurrentForUnmapped: key => unmapped.Get(key.InputMapId, key.ActionId), @@ -2346,8 +2350,20 @@ public sealed class RetailUiRuntime : IDisposable chord => onResult(chord == default ? null : chord)), Save: () => { - dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); - unmapped.SaveToFile(unmappedPath); + // S3 (2026-08-11 review): match the existing keybinds.json + // writer's own discipline (RuntimeKeyBindingTarget.Apply) — + // an IO failure is reported, not thrown out of UiButton.OnClick + // into the input/render loop, and does not roll back the + // already-accepted live binding. + try + { + dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); + unmapped.SaveToFile(unmappedPath); + } + catch (Exception failure) + { + Console.WriteLine($"keyboard config: save failed: {failure.Message}"); + } }, Toggle: () => ToggleWindow(WindowNames.KeyboardConfig), DisplaySystemMessage: text => @@ -2355,13 +2371,20 @@ public sealed class RetailUiRuntime : IDisposable if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text); }, NonBindableRefusalText: refusalText ?? string.Empty, - // No retail string exists for "binding reassigned" — retail's - // own flow only shows the confirm-before-reassign dialog - // (research doc §5.4's OpenOverwriteBindingDialog), never a - // post-reassign notice. This port's auto-reassign (register - // row) stays silent rather than inventing English for a - // message retail never had. - NotifyReassigned: _ => string.Empty)); + // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — + // confirm through the SAME RetailDialogFactory/MakeConfirmation + // seam GameplayConfirmationController already uses, before + // reassigning. DialogFactory is mounted AFTER MountKeyboardConfig + // in Initialize()'s order, so this reads the property lazily + // (Initialize() has always finished by the time a user can + // actually open this screen and trigger a capture). + ConfirmOverwrite: (message, onResult) => + { + if (DialogFactory is null) { onResult(false); return; } + DialogFactory.MakeConfirmation( + message, + data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult))); + })); if (controller is null) { diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 346bad01..b0b77a2d 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -528,9 +528,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful OnClickAt?.Invoke(e.Data1, e.Data2); return OnClick is not null || OnClickAt is not null; case UiEventType.RightClick: + // S6 (2026-08-11 review): unlike Click (whose swallow-when- + // disabled is pre-existing, harmless-by-construction behavior + // every button already had), RightClick was UNHANDLED before + // this class grew OnRightClick — it fell through to `default: + // return false` and bubbled to the parent. Preserve that for + // every button with no handler, disabled or not, so this + // addition is genuinely a no-op for every pre-existing button + // (matching this property's own doc comment) and only changes + // behavior for the ones that opt in. + if (OnRightClick is null) return false; if (!Enabled) return true; - OnRightClick?.Invoke(); - return OnRightClick is not null; + OnRightClick.Invoke(); + return true; case UiEventType.DragEnter: _itemDragAcceptance = e.Payload is ItemDragPayload payload ? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None diff --git a/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs b/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs index accd612e..1e3b6223 100644 --- a/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs +++ b/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs @@ -49,9 +49,11 @@ namespace AcDream.UI.Abstractions.Input; /// 's UseQuickSlot_* family jumps from 9 straight to /// 14, a pre-existing enum gap this slice did not introduce and does not fix); every /// CharacterSettings row (ctx 0x10000008, all 48); 82 of 87 Emote rows (ctx -/// 0x10000006); and roughly half of the UI-class rows (ctx -/// 0x10000007/0x10000009 — panels acdream has no toggle for, e.g. Vitae, -/// Link Status, House, Map, Character Info, the positive/negative Magic panels). +/// 0x10000006); all 10 CameraAlternateControls rows (ctx 0x6 — the M2 +/// de-alias carve-out, see the mapping table's own comment); and roughly half of the +/// UI-class rows (ctx 0x10000007/0x10000009 — panels acdream has no +/// toggle for, e.g. Vitae, Link Status, House, Map, Character Info, the +/// positive/negative Magic panels). /// /// public static class RetailActionIdentityTable @@ -86,24 +88,35 @@ public static class RetailActionIdentityTable M(0x4, 0x10000096, InputAction.Sitting); M(0x4, 0x10000097, InputAction.Sleeping); - // ── CameraControls (ctx 0x5) + CameraAlternateControls (ctx 0x6) — - // 12 distinct actions, both contexts map to the SAME InputAction - // (alternate/numpad chords for the same camera verb). 22/22. ── - foreach (uint ctx in new uint[] { 0x5, 0x6 }) - { - M(ctx, 0x33, InputAction.CameraMoveToward); - M(ctx, 0x34, InputAction.CameraMoveAway); - M(ctx, 0x35, InputAction.CameraRotateLeft); - M(ctx, 0x36, InputAction.CameraRotateRight); - M(ctx, 0x37, InputAction.CameraRotateUp); - M(ctx, 0x38, InputAction.CameraRotateDown); - M(ctx, 0x39, InputAction.CameraViewDefault); - M(ctx, 0x3A, InputAction.CameraViewFirstPerson); - M(ctx, 0x3B, InputAction.CameraViewLookDown); - M(ctx, 0x3C, InputAction.CameraViewMapMode); - } - // 0x3D/0x3E ("Toggle Mouselook"/"Toggle Alternate Camera Mode") only - // author defaults under ctx 0x5 (dev=1 mouse chord + F2/Numpad-Divide). + // ── CameraControls (ctx 0x5) — 12/12. ────────────────────────── + // M2 REWORK (2026-08-11 review): CameraControls (ctx 0x5, the + // Numpad-default scheme RetailDefaults() actually carries) and + // CameraAlternateControls (ctx 0x6, the arrow-key alternate scheme + // RetailDefaults() never had — see + // RetailActionIdentityRoundTripTests' now-retired camera allowlist + // entries) were both previously mapped to the SAME InputAction. + // KeyBindings/Binding has no "which scheme" tag, and SetForAction is + // whole-action replacement, so the two rows aliased one live target: + // both showed identical (stale) chords, rebinding one silently wiped + // the other, and a row could conflict with its own twin. Building + // real per-scheme dual-binding storage (or ten new InputAction + // members plus the camera-dispatch code to consume them) is a real + // feature, not a one-line fix, and out of scope for this rework. Only + // ctx 0x5 — the scheme that already has a live, verified + // RetailDefaults() presence — maps here; ctx 0x6 falls through to the + // generic unmapped/store-only path below (AP-203), fully renderable, + // bindable and persisted, honestly carrying no live effect, exactly + // like every other unmapped row. + M(0x5, 0x33, InputAction.CameraMoveToward); + M(0x5, 0x34, InputAction.CameraMoveAway); + M(0x5, 0x35, InputAction.CameraRotateLeft); + M(0x5, 0x36, InputAction.CameraRotateRight); + M(0x5, 0x37, InputAction.CameraRotateUp); + M(0x5, 0x38, InputAction.CameraRotateDown); + M(0x5, 0x39, InputAction.CameraViewDefault); + M(0x5, 0x3A, InputAction.CameraViewFirstPerson); + M(0x5, 0x3B, InputAction.CameraViewLookDown); + M(0x5, 0x3C, InputAction.CameraViewMapMode); M(0x5, 0x3D, InputAction.CameraInstantMouseLook); M(0x5, 0x3E, InputAction.CameraActivateAlternateMode); diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs index bb2527cf..680fe8ff 100644 --- a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs @@ -16,6 +16,13 @@ namespace AcDream.App.Tests.UI.Layout; /// so the behavioral assertions stay focused. Live-DAT row-count/label conformance /// lives in AcDream.Core.Tests.Input.RetailActionMapReaderTests and /// RetailActionIdentityRoundTripTests. +/// +/// +/// Reworked at the 2026-08-11 combined review (M1/M2/M3/S1/S4): the seam now +/// carries (chord + activation + scope), the M2 fix means +/// only ONE camera InputMap context maps live, and conflicts open a real confirm +/// dialog instead of auto-reassigning silently. +/// /// public sealed class KeyboardConfigControllerTests { @@ -51,14 +58,15 @@ public sealed class KeyboardConfigControllerTests private sealed class FakeBindings { - public Dictionary> Mapped { get; } = new(); + public Dictionary> Mapped { get; } = new(); public Dictionary<(uint, uint), List> Unmapped { get; } = new(); - public List<(InputAction Action, IReadOnlyList Value)> MappedSets { get; } = new(); + public List<(InputAction Action, IReadOnlyList Value)> MappedSets { get; } = new(); public List<((uint, uint) Row, IReadOnlyList Value)> UnmappedSets { get; } = new(); public List Messages { get; } = new(); public int SaveCalls { get; private set; } public int ToggleCalls { get; private set; } public Action? PendingCapture { get; private set; } + public (string Message, Action OnResult)? PendingConfirm { get; private set; } public void Capture(KeyChord? chord) { @@ -67,8 +75,15 @@ public sealed class KeyboardConfigControllerTests cb?.Invoke(chord); } + public void RespondToConfirm(bool accept) + { + var pending = PendingConfirm ?? throw new InvalidOperationException("no pending confirm"); + PendingConfirm = null; + pending.OnResult(accept); + } + public KeyboardConfigController.Bindings ToBindings() => new( - CurrentForAction: a => Mapped.TryGetValue(a, out var v) ? v : Array.Empty(), + CurrentForAction: a => Mapped.TryGetValue(a, out var v) ? v : Array.Empty(), SetForAction: (a, v) => { Mapped[a] = v.ToList(); @@ -85,7 +100,7 @@ public sealed class KeyboardConfigControllerTests Toggle: () => ToggleCalls++, DisplaySystemMessage: msg => Messages.Add(msg), NonBindableRefusalText: "cannot overwrite", - NotifyReassigned: label => $"reassigned from {label}"); + ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult)); } private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None); @@ -143,7 +158,11 @@ public sealed class KeyboardConfigControllerTests }); var fake = new FakeBindings(); - fake.Mapped[InputAction.MovementForward] = new List { ChordW, ChordUp }; + fake.Mapped[InputAction.MovementForward] = new List + { + new(ChordW, InputAction.MovementForward), + new(ChordUp, InputAction.MovementForward), + }; fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List { ChordA }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); @@ -174,7 +193,14 @@ public sealed class KeyboardConfigControllerTests fake.Capture(ChordW); Assert.Contains(ChordW, row.Model.Current); - Assert.Contains((InputAction.MovementForward, (IReadOnlyList)row.Model.Current), fake.MappedSets); + (InputAction Action, IReadOnlyList Value) written = Assert.Single(fake.MappedSets); + Assert.Equal(InputAction.MovementForward, written.Action); + Binding onlyBinding = Assert.Single(written.Value); + Assert.Equal(ChordW, onlyBinding.Chord); + // No live binding existed at build time — falls back to the Binding + // record's own defaults (Press/Game), same as before M1. + Assert.Equal(ActivationType.Press, onlyBinding.Activation); + Assert.Equal(InputScope.Game, onlyBinding.Scope); Assert.Equal("W", row.KeyButtons[0].Label); } @@ -183,7 +209,7 @@ public sealed class KeyboardConfigControllerTests { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var fake = new FakeBindings(); - fake.Mapped[InputAction.MovementForward] = new List { ChordW }; + fake.Mapped[InputAction.MovementForward] = new List { new(ChordW, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; @@ -201,7 +227,11 @@ public sealed class KeyboardConfigControllerTests { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var fake = new FakeBindings(); - fake.Mapped[InputAction.MovementForward] = new List { ChordW, ChordUp }; + fake.Mapped[InputAction.MovementForward] = new List + { + new(ChordW, InputAction.MovementForward), + new(ChordUp, InputAction.MovementForward), + }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; @@ -216,7 +246,55 @@ public sealed class KeyboardConfigControllerTests } [Fact] - public void Capture_ConflictWithAnotherRow_AutoReassignsAndNotifies() + public void KeyButtonRightClick_OnAlreadyEmptySlot_IsANoOp() + { + var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); + var fake = new FakeBindings(); + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView row = controller.Rows.Single(); + Assert.Empty(row.Model.Current); + + row.KeyButtons[0].OnRightClick!.Invoke(); + + Assert.Empty(row.Model.Current); + Assert.Empty(fake.MappedSets); + } + + /// S4 (2026-08-11 review): clicking "Mapping 3" (slot index 2) on a + /// row with NO existing bindings must land the captured chord on display + /// index 2, not collapse it onto index 0. + [Fact] + public void KeyButtonClick_OnSparseRow_ThirdSlotLandsOnThirdButton() + { + var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); + var fake = new FakeBindings(); + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView row = controller.Rows.Single(); + Assert.Equal(3, row.KeyButtons.Count); + Assert.Empty(row.Model.Current); + + row.KeyButtons[2].OnClick!.Invoke(); // "Mapping 3" + fake.Capture(ChordW); + + Assert.Null(row.KeyButtons[0].Label); + Assert.Null(row.KeyButtons[1].Label); + Assert.Equal("W", row.KeyButtons[2].Label); + + // The write to the live seam only ever carries the REAL chord — no + // default(KeyChord) padding leaks into the persisted Binding list. + (InputAction Action, IReadOnlyList Value) written = Assert.Single(fake.MappedSets); + Binding onlyBinding = Assert.Single(written.Value); + Assert.Equal(ChordW, onlyBinding.Chord); + } + + [Fact] + public void Capture_ConflictWithAnotherRow_OpensConfirmDialog_AcceptReassigns() { var snapshot = new RetailActionMapSnapshot(new[] { @@ -224,7 +302,7 @@ public sealed class KeyboardConfigControllerTests Row(0x4, 0x2A, RetailActionClass.Movement), // MovementBackup — will hold ChordA }); var fake = new FakeBindings(); - fake.Mapped[InputAction.MovementBackup] = new List { ChordA }; + fake.Mapped[InputAction.MovementBackup] = new List { new(ChordA, InputAction.MovementBackup) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; @@ -235,19 +313,53 @@ public sealed class KeyboardConfigControllerTests forward.KeyButtons[0].OnClick!.Invoke(); fake.Capture(ChordA); // steal MovementBackup's chord + // M3: nothing is applied yet — a confirm dialog is pending. + Assert.NotNull(fake.PendingConfirm); + Assert.DoesNotContain(ChordA, forward.Model.Current); + Assert.Contains(ChordA, backup.Model.Current); + Assert.Empty(fake.Messages); + + fake.RespondToConfirm(true); + Assert.Contains(ChordA, forward.Model.Current); Assert.DoesNotContain(ChordA, backup.Model.Current); - Assert.Contains(fake.Messages, m => m.Contains("reassigned")); } [Fact] - public void Capture_ConflictWithNonBindableAcdreamAction_RefusesAndLeavesBoth() + public void Capture_ConflictWithAnotherRow_DeclineLeavesBothRowsUnchanged() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + Row(0x4, 0x2A, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = new List { new(ChordW, InputAction.MovementForward) }; + fake.Mapped[InputAction.MovementBackup] = new List { new(ChordA, InputAction.MovementBackup) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); + KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au); + + forward.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordA); + fake.RespondToConfirm(false); + + Assert.Equal(new[] { ChordW }, forward.Model.Current); // untouched + Assert.Equal(new[] { ChordA }, backup.Model.Current); // untouched + } + + [Fact] + public void Capture_ConflictWithNonBindableAcdreamAction_RefusesWithoutADialog() { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var fake = new FakeBindings(); // AcdreamToggleAudioMute has no RetailActionIdentityTable row at all. var muteChord = new KeyChord(Silk.NET.Input.Key.M, ModifierMask.Ctrl); - fake.Mapped[InputAction.AcdreamToggleAudioMute] = new List { muteChord }; + fake.Mapped[InputAction.AcdreamToggleAudioMute] = + new List { new(muteChord, InputAction.AcdreamToggleAudioMute) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; @@ -256,9 +368,39 @@ public sealed class KeyboardConfigControllerTests forward.KeyButtons[0].OnClick!.Invoke(); fake.Capture(muteChord); + // S1: refused outright, no confirm dialog offered. + Assert.Null(fake.PendingConfirm); Assert.DoesNotContain(muteChord, forward.Model.Current); Assert.Contains("cannot overwrite", fake.Messages); - Assert.Equal(new[] { muteChord }, fake.Mapped[InputAction.AcdreamToggleAudioMute]); + Assert.Equal(muteChord, Assert.Single(fake.Mapped[InputAction.AcdreamToggleAudioMute]).Chord); + } + + /// S1: retail checks the non-user-bindable target BEFORE any + /// user-bindable row conflict, even when both exist for the same chord. + [Fact] + public void Capture_ConflictWithBothARowAndANonBindableAction_NonBindableWins() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + Row(0x4, 0x2A, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + var sharedChord = new KeyChord(Silk.NET.Input.Key.M, ModifierMask.Ctrl); + fake.Mapped[InputAction.MovementBackup] = new List { new(sharedChord, InputAction.MovementBackup) }; + fake.Mapped[InputAction.AcdreamToggleAudioMute] = + new List { new(sharedChord, InputAction.AcdreamToggleAudioMute) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); + forward.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(sharedChord); + + Assert.Null(fake.PendingConfirm); + Assert.Contains("cannot overwrite", fake.Messages); + Assert.DoesNotContain(sharedChord, forward.Model.Current); } [Fact] @@ -288,7 +430,7 @@ public sealed class KeyboardConfigControllerTests { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var fake = new FakeBindings(); - fake.Mapped[InputAction.MovementForward] = new List { ChordW }; + fake.Mapped[InputAction.MovementForward] = new List { new(ChordW, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; @@ -314,7 +456,7 @@ public sealed class KeyboardConfigControllerTests new[] { new RetailKeyChord(0x11, 0, 0, 3) }), // DIK_W }); var fake = new FakeBindings(); - fake.Mapped[InputAction.MovementForward] = new List { ChordUp }; + fake.Mapped[InputAction.MovementForward] = new List { new(ChordUp, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; @@ -329,6 +471,106 @@ public sealed class KeyboardConfigControllerTests Assert.True(row.Model.Changed); // live but uncommitted, matching retail } + /// M1 (2026-08-11 review): Defaults must restore the DAT-sourced + /// KEY only — the row's live Activation/Scope (Hold + MeleeCombat here, + /// captured from the action's live binding at build time) must survive the + /// click unchanged, across the FULL 306-row shape this test represents with + /// one Hold+scoped action. + [Fact] + public void DefaultsButton_PreservesActivationAndScope_ForAHoldScopedAction() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x10000003, 0x1000005D, RetailActionClass.Combat, defaults: + new[] { new RetailKeyChord(0xD3, 0, 0, 3) }), // CombatLowAttack, DIK_DELETE + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.CombatLowAttack] = new List + { + new(ChordUp, InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat), + }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!; + defaultsButton.OnClick!.Invoke(); + + (InputAction Action, IReadOnlyList Value) written = Assert.Single(fake.MappedSets); + Assert.Equal(InputAction.CombatLowAttack, written.Action); + Binding result = Assert.Single(written.Value); + Assert.Equal(Silk.NET.Input.Key.Delete, result.Chord.Key); // the DAT default key + Assert.Equal(ActivationType.Hold, result.Activation); // preserved, not reset to Press + Assert.Equal(InputScope.MeleeCombat, result.Scope); // preserved, not reset to Game + } + + /// M1: Cancel/Revert (RestoreSavedValue) must ALSO preserve + /// Activation/Scope, not just Defaults. + [Fact] + public void CancelButton_PreservesActivationAndScope() + { + var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x32, RetailActionClass.Movement) }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementWalkMode] = new List + { + new(ChordW, InputAction.MovementWalkMode, ActivationType.Hold, InputScope.Game), + }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView row = controller.Rows.Single(); + row.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordUp); // uncommitted edit + + UiButton cancel = (UiButton)layout.FindElement(0x1000002Du)!; + cancel.OnClick!.Invoke(); + + // MappedSets also carries the capture's own write (ChordUp) before the + // revert — take the LAST write, which is Cancel's RestoreSavedValue. + (InputAction Action, IReadOnlyList Value) written = fake.MappedSets[^1]; + Binding result = Assert.Single(written.Value); + Assert.Equal(ChordW, result.Chord); // reverted to saved + Assert.Equal(ActivationType.Hold, result.Activation); + } + + /// M2 (2026-08-11 review): InputMap 0x6 (CameraAlternateControls) no + /// longer aliases InputMap 0x5's (CameraControls) InputAction — each row is + /// independent, so rebinding one never clobbers the other, and a row cannot + /// conflict with its own former twin. + [Fact] + public void CameraContext5And6_AreIndependentRows_NotAliased() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x5, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0x4B, 0, 0, 3) }), + Row(0x6, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0xCB, 0, 0, 3) }), + }); + var fake = new FakeBindings(); + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + + KeyboardConfigController.RowView ctx5 = controller.Rows.Single(r => r.InputMapId == 0x5u); + KeyboardConfigController.RowView ctx6 = controller.Rows.Single(r => r.InputMapId == 0x6u); + + Assert.Equal(InputAction.CameraRotateLeft, ctx5.MappedAction); + Assert.Null(ctx6.MappedAction); // unmapped — no live dual-binding infrastructure (M2) + + // Rebinding ctx5's row must not touch ctx6's storage, and vice versa. + ctx5.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordW); + Assert.Contains(ChordW, ctx5.Model.Current); + Assert.Empty(ctx6.Model.Current); // ctx6 never seeded/written by ctx5's edit + Assert.Empty(fake.Unmapped); // ctx6 untouched — only ctx5's mapped write happened + + ctx6.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordA); + Assert.Contains(ChordA, ctx6.Model.Current); + Assert.Contains(ChordW, ctx5.Model.Current); // ctx5 unaffected by ctx6's edit + Assert.True(fake.Unmapped.ContainsKey((0x6u, 0x35u))); + } + [Fact] public void Bind_MissingWindowRoot_ReturnsNull() { diff --git a/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs b/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs index b86b68c2..480c6e15 100644 --- a/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs +++ b/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs @@ -18,11 +18,18 @@ namespace AcDream.Core.Tests.Input; /// unavailable (CI), matching every other live-DAT conformance test in this project. /// /// -/// Three real, byte-verified disagreements survive after the mechanism fixes -/// (2026-08-11 investigation — none are bugs in this slice's table; all three are -/// PRE-EXISTING gaps/design choices this -/// slice does not touch, listed in -/// with citations): +/// Two real, byte-verified disagreements survive after the mechanism fixes +/// (2026-08-11 investigation, updated at the M2 rework — none are bugs in this +/// slice's table; both are PRE-EXISTING +/// gaps/design choices this slice does not touch, listed in +/// with citations). A THIRD +/// disagreement — ten CameraAlternateControls (InputMap 0x6) actions — was RETIRED +/// at the M2 rework: no longer maps InputMap +/// 0x6 to any at all (the aliasing that produced two +/// independent rows fighting over one live target — M2, 2026-08-11 review), so this +/// test never sees a ctx-0x6 row and the ctx-0x5-only union now matches +/// RetailDefaults() exactly for all twelve Camera actions with no allowlist +/// entry needed: /// /// /// MovementWalkMode. The DAT's raw QualifiedControl.Modifier @@ -33,18 +40,6 @@ namespace AcDream.Core.Tests.Input; /// CurrentModifiers=Shift alongside a Shift key-DOWN event, so the chord must /// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT /// artifact this slice's reader faithfully reproduces. -/// Ten CameraAlternateControls (InputMap 0x6) actions. Retail -/// ships TWO camera-control schemes with DIFFERENT default keys: InputMap 0x5's -/// (Numpad: Keypad4/6/8/2 for rotate, KeypadSubtract/Add for zoom, ...) and InputMap -/// 0x6's (Arrow keys: Left/Right/Up/Down for rotate, ...). Both InputMaps' actions -/// share the SAME bucket and this slice -/// correctly maps BOTH to the same (research doc §5.3: a -/// user can rebind either scheme's row independently). RetailDefaults() — a -/// PRE-EXISTING, OP8-independent file — only carries the Numpad (0x5) scheme; it does -/// not carry the arrow-key (0x6) alternates as SECOND bindings for the same action. -/// This is a genuine RetailDefaults() completeness gap, reported here rather -/// than silently patched into a foundational, heavily-tested file outside this -/// slice's scope (register row filed). /// Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9 /// counterpart). The DAT's own default /// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" — @@ -67,16 +62,6 @@ public sealed class RetailActionIdentityRoundTripTests private static readonly HashSet KnownRetailDefaultsDisagreements = new() { InputAction.MovementWalkMode, - InputAction.CameraMoveToward, - InputAction.CameraMoveAway, - InputAction.CameraRotateLeft, - InputAction.CameraRotateRight, - InputAction.CameraRotateUp, - InputAction.CameraRotateDown, - InputAction.CameraViewDefault, - InputAction.CameraViewFirstPerson, - InputAction.CameraViewLookDown, - InputAction.CameraViewMapMode, InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3,