diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 805f4f6f..c82d7408 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) — 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) +## 3. Documented approximation (AP) — 142 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~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -189,7 +189,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~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) | +| ~~AP-196~~ | **RETIRED 2026-08-11 at Campaign OP slice OP9.** Filed at the OP4 review-fix round (MUST-FIX 3/blast M2) recording that OP4's Group-C re-point deleted only three of the eight re-pointed `GameplaySettings` fields (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`), leaving `VividTargetingIndicator`/`CoordinatesOnRadar`/`LockUI`/`AcceptLootPermits`/`ToggleRun` behind as WRITE-BEHIND `settings.json` persistence/draft mirrors of the now-authoritative server bit (plus a "two writable copies" default-source change, ADDENDUM historical only). OP9 verified all remaining `GameplaySettings` members — those five plus `ShowTooltips`/`SideBySideVitals`/`SpellDuration`/`AllowGive`/`ShowHelm`/`ShowCloak`/`AdvancedCombatUI`/`UseMouseTurning`, 13 total — were ALREADY bound through `CharacterOptionTable`/`CharacterOptionsPageController`'s server-bit seam (the OP4 Character tab) and deleted the `GameplaySettings` record outright — the type, the `SettingsStore.LoadGameplay`/`SaveGameplay` plumbing, and `RuntimeSettingsController`'s `Gameplay` property/`SetAcceptLootPermits` write-behind method — closing the "two writable copies" gap for good: there is no longer a second store to diverge from server truth. | `src/AcDream.App/Settings/RuntimeSettingsController.cs`; `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs`; `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | — | — | `docs/research/2026-08-10-character-options-map.md` §7.1/§7.2 (Group C re-point directive); `CharacterOptionTable.cs` | | AP-197 | **Filed 2026-08-11 at the OP4 review-fix round (SF-1/S4).** "Display Timestamps" hardcodes retail's `PlayerModule` constructor-default format string `"%#H:%M:%S "` rather than reading the PER-CHARACTER override `GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, &m_TimeStampFormat)` carries when the wire's `GenericQualitiesData` string-key `1` is populated — acdream's `PlayerDescription` parser reads and discards that field (wire research doc: "timestamp string (`0x80`) \| read, discarded \| ❌ \| never sent"). | `src/AcDream.Core/Chat/ChatLog.cs` (`FormatTimestampPrefix`); parser site cited at `docs/research/2026-08-10-set-character-options-wire.md:647` | The 2013 client's own constructor default is the only format any fresh/default character would ever show — retail ships no options-panel control that authors a custom one — so hardcoding the one value every real player sees is a safe, honest approximation until a consumer needs the per-character override. | A character whose account somehow carries a non-default persisted timestamp format (a modded/legacy server, or a hypothetical later retail patch exposing a UI for it) sees acdream render the DEFAULT format instead of their stored one — cosmetic only (still a valid H:MM:SS-shaped timestamp), never a wire or data-loss risk. | `PlayerModule::PlayerModule @0x005D51F0` (ctor default literal); `GenericQualitiesData::InqString` call site (wire doc §3.3); `docs/research/2026-08-10-set-character-options-wire.md` U6 | | AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2) — the row count was ALWAYS ten (this row's own enumeration always listed ten items); the commit message that said "nine" was the error, now reconciled, and `Render_ScreenBrightness` no longer overloads `Gamma`.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author ten rows with no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, `Render_DegradeDistance`, `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, `Render_LandscapeDrawDistance`, `Render_BuildingDetailTextures`, `Render_MultiPassAlpha`. acdream's world renderer is Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature texture-detail/degrade-distance knob for any of these ten rows to drive. Each round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. **Sub-note, `Render_LandscapeDrawDistance` specifically:** its retail default (`gmConfigUI::InitOptions @0x0049E70D`, `SetDefaultValue(8)`) does not index its own 6-entry `UIPreferences::SetEnumChoices` array (`ID_Graphics_Value_VeryLow`..`Extreme`, `gmClient::InitUIPreferences @0x004041b7`) — reproduced faithfully as an opaque `int` (`DisplaySettings.LandscapeDrawDistance`), not guessed into a clamped index; the Config-tab menu simply shows no highlighted selection at the default. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`) | Building ten dead per-feature render knobs into a Vulkan renderer that has no analogous per-feature toggles would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest (every row is clickable, nothing crashes, nothing silently discards a user's choice) while the register makes the "no effect" fact auditable rather than a silent gap a future report would have to re-discover. | A user who changes any of these ten Config-tab controls sees no visual change and, for `LandscapeDrawDistance` specifically, may see no highlighted menu item even after Defaults — both are the CONTRACTED behaviour for this row, not a bug. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`) | | AP-199 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review M2) — the field names and the "gating to zero when disabled" wording were describing an INVERTED, muted-by-default bug, not the shipped behaviour.** The Config tab's "Sound Options" section authors three rows with no acdream consumer: `Sound_SoundFeatures` (Stereo/Mono menu — acdream's OpenAL backend has no channel-count toggle), the Interface Sound toggle+slider trio (`Sound_InterfaceSoundDisabled`/`Sound_InterfaceSoundVolume` — AP-174 already documents this as retail's OWN dead knob, "registered and then never read... interface sounds are scaled by the EFFECT knob"; acdream matches that exact behaviour rather than building a working Interface bus), and `Sound_PlaySoundOnlyWhenActive` (no window-focus-based audio mute subsystem exists). All three round-trip faithfully through the new `AudioSettings.SoundFeatures`/`InterfaceEnabled`/`InterfaceVolume`/`PlaySoundOnlyWhenActive` fields. The Sound and Ambient trios' own toggle+slider pairs are NOT covered by this row — `SfxEnabled`/`AmbientEnabled`/`Sfx`/`Ambient` are LIVE (`RuntimeSettingsController.SaveAudio` now pushes into `OpenAlAudioEngine` on every change; the effective volume is zero only when the corresponding `*Enabled` flag is false — retail's own `SoundManager::effect_sounds_enabled`/`ambient_sounds_enabled` statics default to enabled, so a fresh profile is audible, not muted). | `src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindSoundSection`) | Matches the SAME reasoning AP-174 already established for the Interface knob specifically; Sound Features and Play-Only-When-Active are honest new store-only rows with no existing or planned acdream subsystem to bind (stereo/mono output selection and window-focus audio gating are both out of this campaign's scope). | A user who changes any of these three Config-tab controls sees/hears no change — the CONTRACTED behaviour, matching retail's own Interface-knob precedent for two of the three. | `gmClient::InitUIPreferences @0x004035b0` (`AttachPreference(&Sound_SoundFeatures, ...)`/`&Sound_InterfaceSoundDisabled`/`&Sound_InterfaceSoundVolume`/`&Sound_PlaySoundOnlyWhenActive`); AP-174 (Interface-knob precedent); `SoundManager::InitPrefs @0x005503F0` (`UserPreferences::RegisterPreference` binding the enabled-sense statics) | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index ab4af67b..9c653464 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1194,7 +1194,6 @@ internal sealed class SessionPlayerCompositionPhase var commands = new GameplayInputCommandController( new RetainedGameplayWindowCommands( interaction.RetainedUi?.Runtime), - new DevToolsGameplayCommands(), runtimeDiagnostics, new PlayerModeGameplayCommands( d.PlayerMode, diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs index 32980581..e0863994 100644 --- a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs +++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs @@ -8,10 +8,14 @@ namespace AcDream.App.Composition; /// ImGui developer-tools frontend that used to compose here (VitalsPanel, /// ChatPanel, DebugPanel, SettingsPanel via AcDream.UI.ImGui) was /// removed at Campaign V slice V11 along with the OpenGL backend it -/// required — see docs/plans/2026-07-27-vulkan-campaign.md. A follow-up -/// re-homes the Settings and Debug panels onto the retained UI through a new -/// IPanelRenderer implementation; until then keybind remapping falls -/// back to editing keybinds.json. +/// required — see docs/plans/2026-07-27-vulkan-campaign.md. The +/// ImGui-era SettingsPanel's promised re-home landed as Campaign OP's retail +/// Options panel (OptionsPanelController et al., the retained +/// UiHost/UiRoot tree — D1) instead of a new +/// IPanelRenderer implementation, and its OP9 closeout retired the +/// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping +/// is Campaign OP slice OP8's Configure Keyboard screen, persisting to +/// keybinds.json (not retail's .keymap format — register row AP-202). /// internal sealed record SettingsDevToolsResult( AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality); diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs index f7b658d8..948e406f 100644 --- a/src/AcDream.App/Input/GameplayInputCommandController.cs +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -41,37 +41,6 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) _runtime?.ToggleWindow(WindowNames.Options); } -internal interface IDevToolsGameplayCommands -{ - void ToggleDebugPanel(); - - void FocusChatInput(); - - void ToggleSettingsPanel(); -} - -/// -/// The ImGui developer-tools frontend these commands used to forward to was -/// removed at Campaign V slice V11. A follow-up re-homes the Settings and -/// Debug panels onto the retained UI through a new IPanelRenderer -/// implementation; until then these are no-ops so the frozen gameplay-action -/// priority graph keeps a single, always-valid handoff target. -/// -internal sealed class DevToolsGameplayCommands : IDevToolsGameplayCommands -{ - public void ToggleDebugPanel() - { - } - - public void FocusChatInput() - { - } - - public void ToggleSettingsPanel() - { - } -} - internal interface IPlayerModeGameplayCommands { bool IsPlayerMode { get; } @@ -163,7 +132,6 @@ internal interface IGameplayInputCommandTarget internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget { private readonly IRetainedGameplayWindowCommands _retained; - private readonly IDevToolsGameplayCommands _devTools; private readonly IRuntimeDiagnosticCommands _diagnostics; private readonly IPlayerModeGameplayCommands _playerMode; private readonly IItemTargetModeCommands _targetMode; @@ -175,7 +143,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg public GameplayInputCommandController( IRetainedGameplayWindowCommands retained, - IDevToolsGameplayCommands devTools, IRuntimeDiagnosticCommands diagnostics, IPlayerModeGameplayCommands playerMode, IItemTargetModeCommands targetMode, @@ -186,7 +153,6 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg Action? toggleAudioMute = null) { _retained = retained ?? throw new ArgumentNullException(nameof(retained)); - _devTools = devTools ?? throw new ArgumentNullException(nameof(devTools)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); _targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode)); @@ -224,7 +190,14 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg _toggleAudioMute?.Invoke(); return true; case InputAction.AcdreamToggleDebugPanel: - _devTools.ToggleDebugPanel(); + // OP9: IDevToolsGameplayCommands.ToggleDebugPanel() retired — + // its ImGui-era DebugPanel target was already gone (Campaign V + // slice V11) and no retained-UI replacement exists, so the + // no-op body it forwarded to carried no behavior to preserve. + // The key is still consumed here (matching that no-op's + // "handled" contract) rather than falling through to whatever + // a lower-priority input scope would otherwise do with + // F1/Ctrl+F1. return true; case InputAction.AcdreamToggleFlyMode: _playerMode.ToggleFlyOrChase(); @@ -233,15 +206,20 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg _playerMode.TogglePlayerMode(); return true; case InputAction.ToggleChatEntry: - _devTools.FocusChatInput(); + // OP9: IDevToolsGameplayCommands.FocusChatInput() retired — + // same shape as AcdreamToggleDebugPanel above (its ImGui + // ChatPanel target was already gone). Tab is still consumed + // here, matching the prior no-op's "handled" contract. return true; case InputAction.ToggleOptionsPanel: // Campaign OP slice OP3 (D1): F11 opens the RETAIL Options // panel now, not the old (unrendered since Campaign V slice - // V11 — DevToolsGameplayCommands' own doc) ImGui-era Settings - // panel. IDevToolsGameplayCommands.ToggleSettingsPanel() is a - // SEPARATE action retired in OP9; its no-op wiring elsewhere - // is untouched by this change (#358's lesson). + // V11) ImGui-era Settings panel. That panel's own + // IDevToolsGameplayCommands.ToggleSettingsPanel() member was + // never dispatched from anywhere (this switch always routed + // ToggleOptionsPanel to _retained, never to _devTools) and + // was retired outright at OP9 along with the rest of + // IDevToolsGameplayCommands (#358's lesson). _retained.ToggleOptionsPanel(); return true; case InputAction.CombatToggleCombat: diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index ff910c4e..d84c0fea 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -328,8 +328,8 @@ internal sealed class LiveSessionRuntimeFactory // is a ONE-SHOT assignment taken pre-login, at composition // time, from RuntimeCharacterOptionsState's constructor- // default word. Routing the real value through SetUiLocked - // here — the exact seam ToggleUiLock/SaveGameplay already - // use to push RuntimeSettingsTargets.ApplyUiLock — converges + // here — the exact seam ToggleUiLock already uses to push + // RuntimeSettingsTargets.ApplyUiLock — converges // the retained window lock to server truth on every fresh // PlayerDescription, matching the radar's own convergence // instead of only updating on the next manual /lockui toggle. @@ -411,8 +411,8 @@ internal sealed class LiveSessionRuntimeFactory // immediately, exactly like the Character-tab panel row. // SetUiLocked still runs too, for its existing // _runtimeTargets?.ApplyUiLock(locked) immediate visual push - // (host.Root.UiLocked) and its legacy GameplaySettings mirror - // (retired in OP9). + // (host.Root.UiLocked) — the legacy GameplaySettings mirror it + // used to also maintain was retired outright at OP9. ToggleUiLock: () => { bool locked = !_domain.Character.Options.GetOptionBit( @@ -491,16 +491,15 @@ internal sealed class LiveSessionRuntimeFactory // wire, even though retail auto-saves this id (0x0005 // immediately). Now routes through the SAME // SendSingleCharacterOption seam the Character-tab panel row - // uses. + // uses. OP9 retired the GameplaySettings mirror this used to + // ALSO maintain (RuntimeSettingsController.SetAcceptLootPermits) + // — the server bit read below is the sole authority now. AcceptLootPermits: () => _domain.Character.Options.GetOptionBit( CharacterOptionId.AcceptLootPermits), SetAcceptLootPermits: value => - { SendSingleCharacterOption( - (uint)CharacterOptionId.AcceptLootPermits, value); - _interaction.Settings.SetAcceptLootPermits(value); - }, + (uint)CharacterOptionId.AcceptLootPermits, value), DisplayConsent: session.SendDisplayConsent, ClearConsent: session.SendClearConsent, RemoveConsent: session.SendRemoveConsent, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 7d0dba7f..53fd7b46 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -414,7 +414,6 @@ internal static class GameWindowShutdownManifest ]), new ResourceShutdownStage("physical ingress cleanup", [ - Soft("settings view model", () => ingress.Settings.UnbindViewModel()), Soft("retained gameplay", () => DisposeRetainedGameplay(ingress.RetainedGameplay)), Soft("gameplay actions", () => DisposeGameplayActions(ingress.GameplayActions)), Soft("retained UI input", ingress.RetailUi.DeactivateInput), diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index 4ac0388d..91c1e088 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -1,5 +1,4 @@ using AcDream.Core.Net.Messages; -using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Settings; @@ -15,8 +14,6 @@ internal interface IRuntimeSettingsStorage AudioSettings LoadAudio(); - GameplaySettings LoadGameplay(); - ChatSettings LoadChat(); CharacterSettings LoadCharacter(string toonKey); @@ -29,8 +26,6 @@ internal interface IRuntimeSettingsStorage void SaveAudio(AudioSettings audio); - void SaveGameplay(GameplaySettings gameplay); - void SaveChat(ChatSettings chat); void SaveCharacter(string toonKey, CharacterSettings character); @@ -57,8 +52,6 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage public AudioSettings LoadAudio() => _store.LoadAudio(); - public GameplaySettings LoadGameplay() => _store.LoadGameplay(); - public ChatSettings LoadChat() => _store.LoadChat(); public CharacterSettings LoadCharacter(string toonKey) => @@ -70,9 +63,6 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio); - public void SaveGameplay(GameplaySettings gameplay) => - _store.SaveGameplay(gameplay); - public void SaveChat(ChatSettings chat) => _store.SaveChat(chat); public void SaveCharacter(string toonKey, CharacterSettings character) => @@ -85,39 +75,10 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage internal sealed record RuntimeSettingsSnapshot( DisplaySettings Display, AudioSettings Audio, - GameplaySettings Gameplay, ChatSettings Chat, CharacterSettings Character, QualitySettings Quality); -/// -/// Expected-owner lease for the optional developer settings view model. -/// Failed optional composition can withdraw only the instance it installed. -/// -internal sealed class RuntimeSettingsViewModelBinding : IDisposable -{ - private readonly RuntimeSettingsController _owner; - private bool _disposed; - - public RuntimeSettingsViewModelBinding( - RuntimeSettingsController owner, - SettingsVM viewModel) - { - _owner = owner ?? throw new ArgumentNullException(nameof(owner)); - ViewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); - } - - public SettingsVM ViewModel { get; } - - public void Dispose() - { - if (_disposed) - return; - _owner.UnbindViewModel(ViewModel); - _disposed = true; - } -} - internal interface IRuntimeSettingsStartupTarget { void ApplyDisplay(DisplaySettings display); @@ -186,16 +147,15 @@ internal sealed class RuntimeSettingsController : private readonly Func _resolveQuality; private readonly Action _log; private IRuntimeSettingsTargets? _runtimeTargets; - private SettingsVM? _viewModel; private CharacterSettings _defaultCharacter; private bool _startupDisplayApplied; private bool _startupAudioApplied; private bool _startupApplied; - private bool _uiLockConverged = true; // MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the last // `locked` value actually pushed to `_runtimeTargets.ApplyUiLock` — - // the guard `SetUiLocked` compares against, decoupled from whatever - // `Gameplay.LockUI`'s own persisted/draft snapshot currently holds. + // the guard `SetUiLocked` compares against. Originally decoupled from + // the retired client-local `GameplaySettings.LockUI` mirror (OP9 + // deleted that record outright); this is now the ONLY store. private bool? _lastAppliedUiLocked; public RuntimeSettingsController( @@ -209,7 +169,6 @@ internal sealed class RuntimeSettingsController : Display = _storage.LoadDisplay(); Audio = _storage.LoadAudio(); - Gameplay = _storage.LoadGameplay(); Chat = _storage.LoadChat(); _defaultCharacter = _storage.LoadCharacter(DefaultToonKey); Character = _defaultCharacter; @@ -217,7 +176,6 @@ internal sealed class RuntimeSettingsController : Startup = new RuntimeSettingsSnapshot( Display, Audio, - Gameplay, Chat, Character, ResolvedQuality); @@ -233,19 +191,24 @@ internal sealed class RuntimeSettingsController : public AudioSettings Audio { get; private set; } - public GameplaySettings Gameplay { get; private set; } - public ChatSettings Chat { get; private set; } public CharacterSettings Character { get; private set; } public QualitySettings ResolvedQuality { get; private set; } - public bool HasDraftPreview => _viewModel is not null; + // OP9: the optional developer-tools draft-preview view model + // (SettingsVM) was retired — it had zero production construction + // sites (only tests ever called CreateViewModel). HasDraftPreview is + // therefore always false in production and DisplayPreview/AudioPreview + // always mirror the committed Display/Audio snapshot; the properties + // stay on IRuntimeSettingsPreviewSource because WorldRenderFrameBuilder + // and SettingsParticleRangeSource still consume the interface. + public bool HasDraftPreview => false; - public DisplaySettings DisplayPreview => _viewModel?.DisplayDraft ?? Display; + public DisplaySettings DisplayPreview => Display; - public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio; + public AudioSettings AudioPreview => Audio; public void ApplyStartup(IRuntimeSettingsStartupTarget target) { @@ -316,94 +279,23 @@ internal sealed class RuntimeSettingsController : .UnbindRuntimeTargets(_expected); } - public SettingsVM CreateViewModel( - KeyBindings persistedBindings, - InputDispatcher dispatcher, - Action saveBindings) - { - ArgumentNullException.ThrowIfNull(persistedBindings); - ArgumentNullException.ThrowIfNull(dispatcher); - ArgumentNullException.ThrowIfNull(saveBindings); - if (_viewModel is not null) - throw new InvalidOperationException("A settings view model is already bound."); - - _viewModel = new SettingsVM( - persistedBindings, - dispatcher, - saveBindings, - Display, - SaveDisplay, - Audio, - SaveAudio, - Gameplay, - SaveGameplay, - Chat, - SaveChat, - Character, - SaveCharacter); - return _viewModel; - } - - public RuntimeSettingsViewModelBinding CreateViewModelBinding( - KeyBindings persistedBindings, - InputDispatcher dispatcher, - Action saveBindings) => - new( - this, - CreateViewModel(persistedBindings, dispatcher, saveBindings)); - - public void UnbindViewModel(SettingsVM? expected = null) - { - if (expected is null || ReferenceEquals(_viewModel, expected)) - _viewModel = null; - } - + /// + /// OP9: rewritten off the retired client-local GameplaySettings.LockUI + /// mirror (MUST-FIX 4, OP4 review-fix round, 2026-08-11, blast M3) — the + /// guard now compares directly against , + /// the last value actually pushed to , with + /// no persisted store of its own left to read or write: the server bit + /// (RuntimeCharacterOptionsState, read through + /// CharacterOptionId.LockUI) is the sole authority, exactly as + /// D7's Group-C re-point already made it at OP4. + /// public void SetUiLocked(bool locked) { - // MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the - // guard used to compare `locked` against `Gameplay.LockUI` — valid - // only while `ToggleUiLock` computed `locked` AS `!Gameplay.LockUI` - // (pre-OP4). OP4 re-pointed `ToggleUiLock` to derive `locked` from - // the SERVER bit (`RuntimeCharacterOptionsState.GetOptionBit`) - // instead, a DIFFERENT store that can already equal the persisted - // `Gameplay.LockUI` without this method ever having pushed that - // value to `_runtimeTargets` — the guard must compare against what - // was ACTUALLY applied, not a value from an unrelated store. - if (_lastAppliedUiLocked == locked && _uiLockConverged) + if (_lastAppliedUiLocked == locked) return; - _uiLockConverged = false; - Gameplay = Gameplay with { LockUI = locked }; _runtimeTargets?.ApplyUiLock(locked); _lastAppliedUiLocked = locked; - _viewModel?.SetGameplay( - _viewModel.GameplayDraft with { LockUI = locked }); - - try - { - _storage.SaveGameplay(Gameplay); - _viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with - { - LockUI = locked, - }); - _uiLockConverged = true; - } - catch (Exception ex) - { - _log($"settings: radar lock save failed: {ex.Message}"); - } - } - - public void SetAcceptLootPermits(bool enabled) - { - Gameplay = Gameplay with { AcceptLootPermits = enabled }; - _viewModel?.SetGameplay( - _viewModel.GameplayDraft with { AcceptLootPermits = enabled }); - _storage.SaveGameplay(Gameplay); - _viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with - { - AcceptLootPermits = enabled, - }); } /// @@ -415,10 +307,6 @@ internal sealed class RuntimeSettingsController : public void ToggleFrameRate() { Display = Display with { ShowFps = !Display.ShowFps }; - _viewModel?.ApplyExternalDisplayChange(display => display with - { - ShowFps = Display.ShowFps, - }); try { @@ -434,7 +322,7 @@ internal sealed class RuntimeSettingsController : /// Campaign OP slice OP3: the five client-local preferences the "Use /// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read /// directly through storage (no startup-snapshot cache, unlike - /// //) — + /// /) — /// this section has no UI surface of its own yet (Campaign OP slice /// OP6's Config tab), so there is nothing today that needs a cached, /// change-notified copy. @@ -468,14 +356,12 @@ internal sealed class RuntimeSettingsController : ArgumentException.ThrowIfNullOrWhiteSpace(characterName); ActiveToonKey = characterName; Character = _storage.LoadCharacter(characterName); - _viewModel?.LoadCharacterContext(Character); _log($"settings: loaded character[{characterName}] preferences"); } public void RestoreDefaultCharacterContext() { Character = _defaultCharacter; - _viewModel?.LoadCharacterContext(Character); } public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey; @@ -500,7 +386,7 @@ internal sealed class RuntimeSettingsController : /// same shape as the already-public , /// now also called directly by ConfigOptionsPageController's /// Display-backed rows (resolution/fullscreen/vsync/FOV/gamma/quality - /// family) rather than only through the (soon-retired) SettingsVM + /// family) rather than only through the (OP9-retired) SettingsVM /// callback wiring. public void SaveDisplay(DisplaySettings display) { @@ -540,24 +426,6 @@ internal sealed class RuntimeSettingsController : } } - private void SaveGameplay(GameplaySettings gameplay) - { - try - { - _storage.SaveGameplay(gameplay); - Gameplay = gameplay; - _uiLockConverged = false; - _runtimeTargets?.ApplyUiLock(gameplay.LockUI); - _lastAppliedUiLocked = gameplay.LockUI; - _uiLockConverged = true; - _log($"settings: gameplay saved to {_storage.Location}"); - } - catch (Exception ex) - { - _log($"settings: gameplay save failed: {ex.Message}"); - } - } - /// Widened from private to public at Campaign OP slice OP6, /// same reason as — the Config tab's Chat /// Font Face/Size rows (store-only, no diff against the five wired @@ -580,7 +448,7 @@ internal sealed class RuntimeSettingsController : // CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes // SetSingleCharacterOption (0x0005) in the same step (mirrors - // SaveGameplay's ApplyUiLock push above) — ACE's handler both flips + // SetUiLocked's ApplyUiLock push above) — ACE's handler both flips // the option AND joins/leaves the matching Turbine room. PublishHearOptionChange( previous.HearGeneralChat, chat.HearGeneralChat, @@ -627,11 +495,10 @@ internal sealed class RuntimeSettingsController : /// public void SyncChatFromServerOptions(uint options2) { - // Applied identically to BOTH the persisted snapshot and the live - // draft (mirrors ApplyExternalGameplayChange's own idempotent-update - // shape) so an unsaved draft edit to an unrelated field (font size, - // timestamps, ...) survives the reseed instead of being clobbered by - // a value computed once against the persisted snapshot. + // Reseed function applied to the persisted snapshot below so an + // unsaved edit to an unrelated field (font size, timestamps, ...) + // survives the reseed instead of being clobbered by a value + // computed once against a stale snapshot. ChatSettings Reseed(ChatSettings current) => current with { HearGeneralChat = (options2 @@ -651,7 +518,6 @@ internal sealed class RuntimeSettingsController : return; Chat = synced; - _viewModel?.ApplyExternalChatChange(Reseed); try { _storage.SaveChat(synced); diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs index 5aa7c712..be2228f8 100644 --- a/src/AcDream.UI.Abstractions/Input/InputAction.cs +++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs @@ -115,7 +115,10 @@ public enum InputAction /// Toggle the world / map panel (retail F10). ToggleWorldPanel, /// Toggle the options / settings panel (retail F11) — opens - /// our SettingsPanel in K.3. + /// the retail Options panel (Campaign OP, D1). Superseded the K.3-era + /// ImGui SettingsPanel this action originally opened; that surface was + /// never rendered after Campaign V slice V11 and was retired outright + /// at OP9. ToggleOptionsPanel, /// Toggle the inventory panel (retail F12). ToggleInventoryPanel, diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs index b6e9c58a..9d15a96a 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs @@ -12,12 +12,17 @@ namespace AcDream.UI.Abstractions.Panels.Settings; /// each other while dragging. /// /// -/// Shared by two independent consumers so both apply the identical link math: -/// RetailWindowOpacityController (AcDream.App — the live per-window -/// mechanism, mutating its own two float fields) and 's -/// Chat tab (the draft slider UI, mutating a record). -/// Pure functions — no window/render/state dependency — so both layers can call -/// them without violating the App→Abstractions dependency direction. +/// Consumed directly by RetailWindowOpacityController (AcDream.App — the +/// live per-window mechanism, mutating its own two float fields). The retail +/// Options panel's Chat tab (ChatOptionsPageController, Campaign OP slice +/// OP5) drives that SAME controller rather than calling these methods a second +/// time, so the panel's two sliders and every retained window's live fade always +/// apply the identical link math through one path. (The OP9-retired +/// SettingsPanel's own Chat tab used to call these methods directly as a +/// second consumer, mutating a draft record — that +/// draft mechanism is gone, not the link math.) Pure functions — no +/// window/render/state dependency — so any future caller can use them without +/// violating the App→Abstractions dependency direction. /// /// public static class ChatOpacityLink diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index 1416be9d..f247f7b6 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -31,9 +31,9 @@ public enum ParticleRange /// did not change when these three take effect, only how they're reached. /// /// -/// Records are immutable; mutation goes through -/// which assigns a new instance via -/// with-expressions. +/// Records are immutable; mutation goes through with-expressions — +/// e.g. RuntimeSettingsController.SaveDisplay's callers, or the +/// OP9-retired SettingsVM.SetDisplay's draft assignment before that. /// /// public sealed record DisplaySettings( diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs deleted file mode 100644 index 4e9bda58..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace AcDream.UI.Abstractions.Panels.Settings; - -/// -/// Gameplay-related preferences persisted to settings.json. -/// Mirrors a subset of retail's CharacterOption + CharacterOptions2 -/// bitfield flags (see docs/research/named-retail/acclient.h:3404+). -/// Retail names are kept verbatim so future server-sync packs these -/// into the wire-format bitmask without renaming. -/// -/// -/// L.0 scope: local-only. The brainstorm explicitly deferred -/// server sync — on Save these values are persisted to settings.json -/// only. A later phase will marshal them into the retail -/// CharacterOption packet (0x...) when the protocol work -/// for player-options round-trip is in place. -/// -/// -/// -/// Defaults below are chosen as the typical-user starting point, NOT -/// pinned bit-exact to retail's 0x50C4A54A / 0x948700 -/// masks (those will become the defaults once server-sync ships and -/// the bitmask round-trip is the load-bearing wire format). -/// -/// -public sealed record GameplaySettings( - // CharacterOption (32-bit) subset — most-used gameplay toggles. - // - // OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): AutoTarget, - // AutoRepeatAttack, and ViewCombatTarget were REMOVED from this record - // — the Combat panel's three LEDs now read/write the SAME canonical - // server-bit seam (RuntimeCharacterOptionsState via - // CharacterOptionCombatSettingsSource) the Character tab's rows for - // these same retail PlayerOptions already used, closing the - // "two writable copies" divergence (register row AP-196). This record - // remains the client-local persistence/draft store for every OTHER - // gameplay preference that has no such server-authoritative seam. - bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run - bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels - bool ShowTooltips, // 0x100 — show item tooltips on hover - bool VividTargetingIndicator, // 0x8000 — bright targeting reticle - bool SideBySideVitals, // 0x200000 — health/stam/mana side-by-side vs stacked - bool CoordinatesOnRadar, // 0x400000 — show NS/EW coords on radar - bool SpellDuration, // 0x800000 — show remaining duration on enchantment icons - bool AllowGive, // 0x40 — accept items handed by other players - // CharacterOptions2 (32-bit) subset. - bool ShowHelm, // 0x100000 — render helm overlay on character - bool ShowCloak, // 0x800000 — render cloak on character - bool LockUI, // 0x1000000 — disable panel drag/resize - bool UseMouseTurning, // 0x400000 — turn character when right-mouse drags - bool AcceptLootPermits = false) // 0x80000 — accept corpse-looting permissions -{ - /// Sensible starting values for first launch. NOT bit-exact - /// to retail's Default_CharacterOption = 0x50C4A54A + - /// Default_CharacterOptions2 = 0x948700 — see class remarks. - public static GameplaySettings Default { get; } = new( - ToggleRun: true, - AdvancedCombatUI: false, - ShowTooltips: true, - VividTargetingIndicator: true, - SideBySideVitals: false, - // Retail default character-options mask 0x50C4A54A includes - // CoordinatesOnRadar (0x00400000). - CoordinatesOnRadar: true, - SpellDuration: true, - AllowGive: true, - ShowHelm: true, - ShowCloak: true, - LockUI: false, - UseMouseTurning: false, - // Default_CharacterOption 0x50C4A54A leaves bit 0x80000 clear. - AcceptLootPermits: false); -} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs deleted file mode 100644 index 5c0870af..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ /dev/null @@ -1,561 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using AcDream.UI.Abstractions.Input; -using AcDream.UI.Abstractions.Settings; - -namespace AcDream.UI.Abstractions.Panels.Settings; - -/// -/// In-game Settings panel — F11 toggle (or View → Settings on the main -/// menu bar). Hidden by default. Tabbed: Keybinds (Phase K), then -/// Display / Audio / Gameplay / Chat / Character (filling in over the -/// L.x sub-phases). -/// -/// -/// Top of the panel: Save / Cancel / Reset-all action buttons (global -/// across all tabs). When is -/// non-null, a confirmation prompt is rendered above those buttons -/// (Yes — Reassign / No — Keep existing). -/// -/// -/// -/// Below the action row a tab bar selects between the six categories. -/// Only the Keybinds tab is implemented today; the other five render -/// "Coming soon" placeholders so the structure the user approved in the -/// design brainstorm is visible immediately. -/// -/// -public sealed class SettingsPanel : IPanel -{ - private readonly SettingsVM _vm; - - public SettingsPanel(SettingsVM vm) - { - _vm = vm ?? throw new System.ArgumentNullException(nameof(vm)); - } - - /// - public string Id => "acdream.settings"; - - /// - public string Title => "Settings"; - - /// - /// Hidden by default — opened via F11 / View menu. - public bool IsVisible { get; set; } = false; - - /// - public void Render(PanelContext ctx, IPanelRenderer renderer) - { - if (!renderer.Begin(Title)) - { - renderer.End(); - return; - } - - // Conflict prompt — modal-ish row at top of the panel. - if (_vm.PendingConflict is { } conflict) - { - renderer.TextWrapped( - $"'{ChordLabel(conflict.NewChord)}' is already bound to " - + $"{conflict.ConflictingAction}. Reassign it to " - + $"{conflict.NewAction}?"); - if (renderer.Button("Yes — Reassign")) _vm.ResolveConflict(replace: true); - renderer.SameLine(); - if (renderer.Button("No — Keep existing")) _vm.ResolveConflict(replace: false); - renderer.Separator(); - } - - // Top action buttons. Global across all tabs. - if (renderer.Button("Save changes")) _vm.Save(); - renderer.SameLine(); - if (renderer.Button("Cancel changes")) _vm.Cancel(); - renderer.SameLine(); - if (renderer.Button("Reset all to retail defaults")) _vm.ResetAllToDefaults(); - - renderer.Separator(); - - if (renderer.BeginTabBar("settings.tabs")) - { - if (renderer.BeginTabItem("Keybinds")) - { - RenderKeybindsTab(renderer); - renderer.EndTabItem(); - } - if (renderer.BeginTabItem("Display")) - { - RenderDisplayTab(renderer); - renderer.EndTabItem(); - } - if (renderer.BeginTabItem("Audio")) - { - RenderAudioTab(renderer); - renderer.EndTabItem(); - } - if (renderer.BeginTabItem("Gameplay")) - { - RenderGameplayTab(renderer); - renderer.EndTabItem(); - } - if (renderer.BeginTabItem("Chat")) - { - RenderChatTab(renderer); - renderer.EndTabItem(); - } - if (renderer.BeginTabItem("Character")) - { - RenderCharacterTab(renderer); - renderer.EndTabItem(); - } - renderer.EndTabBar(); - } - - renderer.End(); - } - - /// - /// Render the Keybinds tab — eight collapsing-header sections matching - /// the retail keymap categories. Phase K shipped this content; the - /// only thing that changed is the wrapping tab item. - /// - private void RenderKeybindsTab(IPanelRenderer renderer) - { - RenderSection(renderer, "Movement", new[] - { - InputAction.MovementForward, InputAction.MovementBackup, - InputAction.MovementTurnLeft, InputAction.MovementTurnRight, - InputAction.MovementStrafeLeft, InputAction.MovementStrafeRight, - InputAction.MovementJump, InputAction.MovementStop, - InputAction.MovementWalkMode, InputAction.MovementRunLock, - }); - RenderSection(renderer, "Postures", new[] - { - InputAction.Ready, InputAction.Sitting, - InputAction.Crouch, InputAction.Sleeping, - }); - RenderSection(renderer, "Camera", new[] - { - InputAction.CameraActivateAlternateMode, InputAction.CameraInstantMouseLook, - InputAction.CameraRotateLeft, InputAction.CameraRotateRight, - InputAction.CameraRotateUp, InputAction.CameraRotateDown, - InputAction.CameraMoveToward, InputAction.CameraMoveAway, - InputAction.CameraViewDefault, InputAction.CameraViewFirstPerson, - InputAction.CameraViewLookDown, InputAction.CameraViewMapMode, - }); - RenderSection(renderer, "Combat", new[] - { - InputAction.CombatToggleCombat, - InputAction.CombatDecreaseAttackPower, InputAction.CombatIncreaseAttackPower, - InputAction.CombatLowAttack, InputAction.CombatMediumAttack, InputAction.CombatHighAttack, - InputAction.CombatAimLow, InputAction.CombatAimMedium, InputAction.CombatAimHigh, - InputAction.CombatPrevSpellTab, InputAction.CombatNextSpellTab, - InputAction.CombatPrevSpell, InputAction.CombatNextSpell, InputAction.CombatCastCurrentSpell, - }); - RenderSection(renderer, "UI panels", new[] - { - InputAction.ToggleHelp, InputAction.ToggleAllegiancePanel, - InputAction.ToggleFellowshipPanel, InputAction.ToggleSpellbookPanel, - InputAction.ToggleSpellComponentsPanel, InputAction.ToggleAttributesPanel, - InputAction.ToggleSkillsPanel, InputAction.ToggleWorldPanel, - InputAction.ToggleOptionsPanel, InputAction.ToggleInventoryPanel, - InputAction.SelectionExamine, InputAction.UseSelected, - InputAction.EscapeKey, InputAction.LOGOUT, - }); - RenderSection(renderer, "Chat", new[] - { - InputAction.ToggleChatEntry, InputAction.EnterChatMode, - InputAction.ToggleFloatingChatWindow1, InputAction.ToggleFloatingChatWindow2, - InputAction.ToggleFloatingChatWindow3, InputAction.ToggleFloatingChatWindow4, - }); - RenderSection(renderer, "Hotbar", new[] - { - InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3, - InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6, - InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9, - InputAction.SelectQuickSlot_1, InputAction.SelectQuickSlot_2, InputAction.SelectQuickSlot_3, - InputAction.SelectQuickSlot_4, InputAction.SelectQuickSlot_5, InputAction.SelectQuickSlot_6, - InputAction.SelectQuickSlot_7, InputAction.SelectQuickSlot_8, InputAction.SelectQuickSlot_9, - InputAction.UseQuickSlot_14, InputAction.UseQuickSlot_15, InputAction.UseQuickSlot_16, - InputAction.UseQuickSlot_17, InputAction.UseQuickSlot_18, - InputAction.CreateShortcut, - }); - RenderSection(renderer, "Emotes", new[] - { - InputAction.Cry, InputAction.Laugh, InputAction.Wave, - InputAction.Cheer, InputAction.PointState, - }); - } - - /// - /// Render the Display tab — resolution / fullscreen / vsync / - /// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders; - /// the others apply on Save (matches the brainstorm UX agreement — - /// resolution change live would be too jarring). - /// - private void RenderDisplayTab(IPanelRenderer renderer) - { - var d = _vm.DisplayDraft; - - // Resolution dropdown. Index falls back to the highest available - // option when the persisted resolution isn't one of the presets - // (e.g. user hand-edited settings.json with a non-standard size). - var resolutions = DisplaySettings.AvailableResolutions.ToArray(); - int idx = System.Array.IndexOf(resolutions, d.Resolution); - if (idx < 0) idx = resolutions.Length - 1; - if (renderer.Combo("Resolution", ref idx, resolutions)) - _vm.SetDisplay(d with { Resolution = resolutions[idx] }); - - bool fullscreen = d.Fullscreen; - if (renderer.Checkbox("Fullscreen", ref fullscreen)) - _vm.SetDisplay(d with { Fullscreen = fullscreen }); - - bool vsync = d.VSync; - if (renderer.Checkbox("V-Sync", ref vsync)) - _vm.SetDisplay(d with { VSync = vsync }); - - float fov = d.FieldOfView; - if (renderer.SliderFloat("Field of View", ref fov, 30f, 120f)) - _vm.SetDisplay(d with { FieldOfView = fov }); - - float gamma = d.Gamma; - if (renderer.SliderFloat("Gamma", ref gamma, 0.5f, 2.0f)) - _vm.SetDisplay(d with { Gamma = gamma }); - - bool showFps = d.ShowFps; - if (renderer.Checkbox("Show FPS", ref showFps)) - _vm.SetDisplay(d with { ShowFps = showFps }); - - // A.5 T22.5: Quality preset dropdown. Drives streaming radii, MSAA, - // anisotropic level, A2C, and max completions-per-frame as a unit. - // Resolution + anisotropic + A2C + completions apply immediately via - // ReapplyQualityPreset; MSAA samples require a restart (GL context - // cannot change sample count at runtime). - var presets = s_qualityPresetNames; - int qIdx = (int)d.Quality; - if (qIdx < 0 || qIdx >= presets.Length) qIdx = (int)QualityPreset.High; - if (renderer.Combo("Quality", ref qIdx, presets)) - _vm.SetDisplay(d with { Quality = (QualityPreset)qIdx }); - - int particleRangeIndex = (int)d.ParticleRange; - if (particleRangeIndex < 0 || particleRangeIndex >= s_particleRangeNames.Length) - particleRangeIndex = (int)DisplaySettings.Default.ParticleRange; - if (renderer.Combo("Particle Range", ref particleRangeIndex, s_particleRangeNames)) - _vm.SetDisplay(d with { ParticleRange = (ParticleRange)particleRangeIndex }); - - renderer.Spacing(); - renderer.TextWrapped( - "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma " - + "preview live as you drag; Cancel reverts to the saved value. " - + "Quality preset applies streaming radius, anisotropic, and A2C " - + "immediately on Save; MSAA sample count requires a restart. " - + "Particle Range defaults to Extended, which doubles authored " - + "effect distance; Retail restores the exact client cutoff."); - } - - /// - /// Render the Audio tab — Master, SFX and Ambient volume sliders, all with - /// live preview against the running engine. - /// - /// - /// Ambient was hidden until Campaign A slice A5 because nothing drove it; - /// the region ambient soundscape now does, so the knob moves something and - /// is exposed. The Music slider is gone entirely: retail has no music - /// system, so there was never anything for it to turn down. - /// - /// - private void RenderAudioTab(IPanelRenderer renderer) - { - var a = _vm.AudioDraft; - - float master = a.Master; - if (renderer.SliderFloat("Master", ref master, 0f, 1f)) - _vm.SetAudio(a with { Master = master }); - - float sfx = a.Sfx; - if (renderer.SliderFloat("SFX", ref sfx, 0f, 1f)) - _vm.SetAudio(a with { Sfx = sfx }); - - float ambient = a.Ambient; - if (renderer.SliderFloat("Ambient", ref ambient, 0f, 1f)) - _vm.SetAudio(a with { Ambient = ambient }); - - renderer.Spacing(); - renderer.TextWrapped( - "Volume changes preview live as you drag. Save persists the " - + "values to settings.json; Cancel reverts to the saved values. " - + "Music + Ambient mixing arrives with R5 MIDI playback."); - } - - /// - /// Render the Gameplay tab — ~14 toggles ported from retail's - /// CharacterOption + CharacterOptions2 bitfields. Local-only this - /// phase (no server sync). Grouped into Combat / Display / Interface - /// for first-run discoverability. - /// - private void RenderGameplayTab(IPanelRenderer renderer) - { - var g = _vm.GameplayDraft; - - renderer.Text("Combat"); - renderer.Separator(); - - // OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): - // Auto-target/Auto-repeat/Keep-in-view were removed from - // GameplaySettings — they now read/write the canonical server bit - // (RuntimeCharacterOptionsState) through CombatUiController, not - // this client-local draft. This panel has no production - // construction site (D.2b's retained UiHost/UiRoot stack is the - // one presentation surface — see CLAUDE.md's UI strategy section); - // the three checkboxes are simply retired rather than re-pointed - // to a seam this dat-free ImGui-shaped panel has no way to reach. - - bool toggleRun = g.ToggleRun; - if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun)) - _vm.SetGameplay(g with { ToggleRun = toggleRun }); - - bool advCombat = g.AdvancedCombatUI; - if (renderer.Checkbox("Show advanced combat UI", ref advCombat)) - _vm.SetGameplay(g with { AdvancedCombatUI = advCombat }); - - bool vivid = g.VividTargetingIndicator; - if (renderer.Checkbox("Vivid targeting indicator", ref vivid)) - _vm.SetGameplay(g with { VividTargetingIndicator = vivid }); - - renderer.Spacing(); - renderer.Text("Display"); - renderer.Separator(); - - bool tooltips = g.ShowTooltips; - if (renderer.Checkbox("Show item tooltips", ref tooltips)) - _vm.SetGameplay(g with { ShowTooltips = tooltips }); - - bool sideBySide = g.SideBySideVitals; - if (renderer.Checkbox("Side-by-side vital orbs", ref sideBySide)) - _vm.SetGameplay(g with { SideBySideVitals = sideBySide }); - - bool coords = g.CoordinatesOnRadar; - if (renderer.Checkbox("Show coordinates on radar", ref coords)) - _vm.SetGameplay(g with { CoordinatesOnRadar = coords }); - - bool spellDur = g.SpellDuration; - if (renderer.Checkbox("Show spell duration on enchantments", ref spellDur)) - _vm.SetGameplay(g with { SpellDuration = spellDur }); - - bool helm = g.ShowHelm; - if (renderer.Checkbox("Show helm on character", ref helm)) - _vm.SetGameplay(g with { ShowHelm = helm }); - - bool cloak = g.ShowCloak; - if (renderer.Checkbox("Show cloak on character", ref cloak)) - _vm.SetGameplay(g with { ShowCloak = cloak }); - - renderer.Spacing(); - renderer.Text("Interface"); - renderer.Separator(); - - bool allowGive = g.AllowGive; - if (renderer.Checkbox("Accept items handed by other players", ref allowGive)) - _vm.SetGameplay(g with { AllowGive = allowGive }); - - bool lockUI = g.LockUI; - if (renderer.Checkbox("Lock UI (disable panel drag/resize)", ref lockUI)) - _vm.SetGameplay(g with { LockUI = lockUI }); - - bool mouseTurn = g.UseMouseTurning; - if (renderer.Checkbox("Use mouse turning", ref mouseTurn)) - _vm.SetGameplay(g with { UseMouseTurning = mouseTurn }); - - renderer.Spacing(); - renderer.TextWrapped( - "Local-only this phase — values persist to settings.json but " - + "don't yet sync to the server. Server sync arrives in a " - + "follow-up phase."); - } - - /// - /// Render the Chat tab — channel filters (Hear*Chat), display - /// preferences (timestamps / profanity filter / appear offline), - /// and a font-size slider. Channel filters affect client-side - /// display only this phase — the server still sends every line, - /// the client decides what to render. - /// - private void RenderChatTab(IPanelRenderer renderer) - { - var c = _vm.ChatDraft; - - renderer.Text("Channel filters"); - renderer.Separator(); - - bool general = c.HearGeneralChat; - if (renderer.Checkbox("General", ref general)) - _vm.SetChat(c with { HearGeneralChat = general }); - - bool trade = c.HearTradeChat; - if (renderer.Checkbox("Trade", ref trade)) - _vm.SetChat(c with { HearTradeChat = trade }); - - bool lfg = c.HearLFGChat; - if (renderer.Checkbox("LFG (looking for group)", ref lfg)) - _vm.SetChat(c with { HearLFGChat = lfg }); - - bool rp = c.HearRoleplayChat; - if (renderer.Checkbox("Roleplay", ref rp)) - _vm.SetChat(c with { HearRoleplayChat = rp }); - - bool society = c.HearSocietyChat; - if (renderer.Checkbox("Society (CD / EW / RB)", ref society)) - _vm.SetChat(c with { HearSocietyChat = society }); - - renderer.Spacing(); - renderer.Text("Display"); - renderer.Separator(); - - bool timestamps = c.ShowTimestamps; - if (renderer.Checkbox("Show timestamps", ref timestamps)) - _vm.SetChat(c with { ShowTimestamps = timestamps }); - - bool profanity = c.FilterProfanity; - if (renderer.Checkbox("Filter profanity", ref profanity)) - _vm.SetChat(c with { FilterProfanity = profanity }); - - bool offline = c.AppearOffline; - if (renderer.Checkbox("Appear offline (hide from /who)", ref offline)) - _vm.SetChat(c with { AppearOffline = offline }); - - float fontSize = c.FontSize; - if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f)) - _vm.SetChat(c with { FontSize = fontSize }); - - renderer.Spacing(); - renderer.Text("Window transparency"); - renderer.Separator(); - - // Campaign CH slice CH6c: retail's two linked opacity sliders - // (gmChatOptionsUI::InitOptions @0x0049FC60's DualHash pair). Dragging - // Background above Active drags Active UP to match; dragging Active below - // Background drags Background DOWN — ChatOpacityLink is the shared port of - // ChatInterface::SetDefaultOpacity/SetActiveOpacity (0x004F3BC0/0x004F3C40) - // that both this draft and the live RetailWindowOpacityController use, so the - // sliders track each other exactly like retail's options page. - float defaultOpacity = c.DefaultOpacity; - if (renderer.SliderFloat("Background opacity (unfocused)", ref defaultOpacity, 0f, 1f)) - { - var (def, active) = ChatOpacityLink.SetDefault(c.ActiveOpacity, defaultOpacity); - _vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active }); - } - - float activeOpacity = c.ActiveOpacity; - if (renderer.SliderFloat("Active opacity (typing / focused)", ref activeOpacity, 0f, 1f)) - { - var (def, active) = ChatOpacityLink.SetActive(c.DefaultOpacity, activeOpacity); - _vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active }); - } - - renderer.Spacing(); - renderer.TextWrapped( - "Channel filters hide messages from the chat window without " - + "changing your server-side subscriptions. Window transparency " - + "applies to every retained window (chat, floaties, vitals, " - + "toolbar, inventory...) and fades whichever window doesn't " - + "currently have keyboard focus; Active opacity can never be " - + "lower than Background — dragging one past the other drags " - + "the other along. Save persists; Cancel reverts."); - } - - /// - /// Render the Character tab — per-toon preferences. The host owns - /// the toon-name key; the panel just edits whatever bag the host - /// loaded into . - /// - private void RenderCharacterTab(IPanelRenderer renderer) - { - var c = _vm.CharacterDraft; - - var channels = CharacterSettings.AvailableChannels.ToArray(); - int idx = System.Array.IndexOf(channels, c.DefaultChatChannel); - if (idx < 0) idx = 0; - if (renderer.Combo("Default chat channel", ref idx, channels)) - _vm.SetCharacter(c with { DefaultChatChannel = channels[idx] }); - - bool autoAttack = c.AutoAttack; - if (renderer.Checkbox("Auto-attack (continue swinging until target dies)", ref autoAttack)) - _vm.SetCharacter(c with { AutoAttack = autoAttack }); - - bool confirmSalvage = c.ConfirmSalvage; - if (renderer.Checkbox("Confirm before salvaging valuable items", ref confirmSalvage)) - _vm.SetCharacter(c with { ConfirmSalvage = confirmSalvage }); - - bool pickup = c.ShowPickupMessages; - if (renderer.Checkbox("Show pickup messages in chat", ref pickup)) - _vm.SetCharacter(c with { ShowPickupMessages = pickup }); - - renderer.Spacing(); - renderer.TextWrapped( - "Per-character preferences — saved per toon under " - + "settings.json's character[\"\"]. Local-only this " - + "phase; server-sync arrives later when the protocol " - + "round-trip lands."); - } - - // A.5 T22.5: preset label array parallel to QualityPreset enum values. - // Order must match the enum (Low=0, Medium=1, High=2, Ultra=3). - private static readonly string[] s_qualityPresetNames = - { "Low", "Medium", "High", "Ultra" }; - - private static readonly string[] s_particleRangeNames = - { "Retail", "Extended" }; - - private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) - { - // Movement defaults open; other sections collapsed for first-run UX. - bool defaultOpen = label == "Movement"; - if (!renderer.CollapsingHeader(label, defaultOpen)) - return; - - foreach (var action in actions) - { - renderer.Text(action.ToString()); - renderer.SameLine(); - - // Current binding(s) summary. - var binds = _vm.Draft.ForAction(action).ToList(); - string summary = binds.Count == 0 - ? "(unbound)" - : string.Join(", ", binds.Select(b => ChordLabel(b.Chord))); - renderer.Text(summary); - renderer.SameLine(); - - // Rebind button — when a rebind is in progress for THIS - // action, the label changes to a "press a key..." prompt. - // The "##{action}" suffix gives ImGui a stable per-row id - // so multiple "Rebind" buttons don't collide. - string buttonLabel = (_vm.RebindInProgress == action) - ? $"Press a key... (Esc to cancel)##{action}" - : $"Rebind##{action}"; - if (renderer.Button(buttonLabel)) - { - if (binds.Count > 0 && _vm.RebindInProgress is null) - _vm.BeginRebind(action, binds[0]); - } - renderer.SameLine(); - if (renderer.Button($"Reset##{action}")) - _vm.ResetActionToDefault(action); - } - } - - /// - /// Render a chord as "Shift+Ctrl+A" / "W" / etc. for the - /// row summary + conflict prompt. Joins held modifiers with + - /// then the trigger key name. - /// - private static string ChordLabel(KeyChord chord) - { - var parts = new List(); - if ((chord.Modifiers & ModifierMask.Shift) != 0) parts.Add("Shift"); - if ((chord.Modifiers & ModifierMask.Ctrl) != 0) parts.Add("Ctrl"); - if ((chord.Modifiers & ModifierMask.Alt) != 0) parts.Add("Alt"); - if ((chord.Modifiers & ModifierMask.Win) != 0) parts.Add("Win"); - parts.Add(chord.Key.ToString()); - return string.Join("+", parts); - } -} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 3a46e2fe..fa0abe0a 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -11,10 +11,11 @@ namespace AcDream.UI.Abstractions.Panels.Settings; public readonly record struct UiWindowPosition(float X, float Y); /// -/// JSON-backed persistence for non-keybind settings (Display today; future -/// tabs Audio / Gameplay / Chat / Character will be added to the same -/// file). The graphical host supplies a canonical portable configuration -/// path. Coexists +/// JSON-backed persistence for non-keybind settings (Display / Audio / Chat / +/// Character; the OP9-retired Gameplay section is no longer read or +/// written — see the class's Schema note below for how a leftover +/// "gameplay" block from an older settings.json is tolerated). The +/// graphical host supplies a canonical portable configuration path. Coexists /// with keybinds.json, which retains its own /// path. /// @@ -29,7 +30,11 @@ public readonly record struct UiWindowPosition(float X, float Y); /// /// Unknown top-level keys are preserved on save so future tab additions /// from a newer client don't get clobbered by an older client writing -/// out only the sections it knows about. +/// out only the sections it knows about — the SAME round-trip preservation +/// ('s raw-JSON-text carry-forward) that keeps an +/// EXISTING file's now-orphaned "gameplay" block intact forever: no +/// Load* method here reads it, so it is neither parsed nor dropped, +/// just silently carried along untouched on every subsequent save. /// /// public sealed class SettingsStore @@ -149,49 +154,6 @@ public sealed class SettingsStore public void SaveAudio(AudioSettings audio) => SaveSection("audio", BuildAudioObject(audio)); - /// - /// Load Gameplay settings (subset of retail CharacterOption flags). - /// Same fall-back behaviour as . - /// - public GameplaySettings LoadGameplay() - { - if (!File.Exists(_path)) return GameplaySettings.Default; - try - { - using var stream = File.OpenRead(_path); - var doc = JsonDocument.Parse(stream); - var root = doc.RootElement; - if (!root.TryGetProperty("gameplay", out var gp) - || gp.ValueKind != JsonValueKind.Object) - return GameplaySettings.Default; - - var d = GameplaySettings.Default; - return new GameplaySettings( - ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun), - AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI), - ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips), - VividTargetingIndicator: ReadBool(gp, "vividTargetingIndicator", d.VividTargetingIndicator), - SideBySideVitals: ReadBool(gp, "sideBySideVitals", d.SideBySideVitals), - CoordinatesOnRadar: ReadBool(gp, "coordinatesOnRadar", d.CoordinatesOnRadar), - SpellDuration: ReadBool(gp, "spellDuration", d.SpellDuration), - AllowGive: ReadBool(gp, "allowGive", d.AllowGive), - ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm), - ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak), - LockUI: ReadBool(gp, "lockUI", d.LockUI), - UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning), - AcceptLootPermits: ReadBool(gp, "acceptLootPermits", d.AcceptLootPermits)); - } - catch (Exception ex) - { - Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); - return GameplaySettings.Default; - } - } - - /// Save Gameplay settings, preserving all other top-level keys. - public void SaveGameplay(GameplaySettings gameplay) - => SaveSection("gameplay", BuildGameplayObject(gameplay)); - /// Load Chat settings. Same fall-back behaviour as . public ChatSettings LoadChat() { @@ -624,24 +586,6 @@ public sealed class SettingsStore ["showTimestamps"] = c.ShowTimestamps, }; - private static SortedDictionary BuildGameplayObject(GameplaySettings g) - => new(StringComparer.Ordinal) - { - ["advancedCombatUI"] = g.AdvancedCombatUI, - ["acceptLootPermits"] = g.AcceptLootPermits, - ["allowGive"] = g.AllowGive, - ["coordinatesOnRadar"] = g.CoordinatesOnRadar, - ["lockUI"] = g.LockUI, - ["showCloak"] = g.ShowCloak, - ["showHelm"] = g.ShowHelm, - ["showTooltips"] = g.ShowTooltips, - ["sideBySideVitals"] = g.SideBySideVitals, - ["spellDuration"] = g.SpellDuration, - ["toggleRun"] = g.ToggleRun, - ["useMouseTurning"] = g.UseMouseTurning, - ["vividTargetingIndicator"] = g.VividTargetingIndicator, - }; - private static SortedDictionary BuildDisplayObject(DisplaySettings d) => new(StringComparer.Ordinal) { diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs deleted file mode 100644 index 88f08ca6..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ /dev/null @@ -1,451 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AcDream.UI.Abstractions.Input; - -namespace AcDream.UI.Abstractions.Panels.Settings; - -/// -/// K.3 ViewModel for . Owns a draft -/// copy of the current ; rebinds modify the -/// draft. commits draft via the supplied callback -/// (which writes to disk + replaces the live dispatcher's table); -/// reverts the draft to the persisted state. -/// -/// -/// Click-to-rebind UX: caller invokes with the -/// action being rebound + the binding being replaced. The VM enters -/// modal capture on the dispatcher; when the user presses a chord (or -/// Esc), the dispatcher reports it via . -/// If the new chord conflicts with another action's binding (same -/// activation type), surfaces a prompt -/// the panel renders as Yes / No buttons; -/// dispatches the user's choice. -/// -/// -public sealed class SettingsVM -{ - private KeyBindings _persisted; - private KeyBindings _draft; - private readonly InputDispatcher _dispatcher; - private readonly Action _onSave; - - // L.0 — Display tab. Treated as a single immutable record; mutation - // through SetDisplay clones via with-expressions on the panel side. - private DisplaySettings _displayPersisted; - private DisplaySettings _displayDraft; - private readonly Action _onSaveDisplay; - - // L.0 — Audio tab. Same shape as Display. - private AudioSettings _audioPersisted; - private AudioSettings _audioDraft; - private readonly Action _onSaveAudio; - - // L.0 — Gameplay tab (subset of retail CharacterOption flags). - private GameplaySettings _gameplayPersisted; - private GameplaySettings _gameplayDraft; - private readonly Action _onSaveGameplay; - - // L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs). - private ChatSettings _chatPersisted; - private ChatSettings _chatDraft; - private readonly Action _onSaveChat; - - // L.0 — Character tab (per-toon, host-keyed by toon name). - private CharacterSettings _characterPersisted; - private CharacterSettings _characterDraft; - private readonly Action _onSaveCharacter; - - /// The action currently being rebound, or null when idle. - public InputAction? RebindInProgress { get; private set; } - - /// The original binding being replaced (so we can preserve - /// activation type on the new chord and roll back on cancel). - public Binding? RebindOriginal { get; private set; } - - /// The action+chord conflict pending confirmation, or null. - /// Populated when finds the captured - /// chord already bound to another action; cleared by - /// . - public ConflictPrompt? PendingConflict { get; private set; } - - /// The current working draft. Panel renders bindings from - /// here; mutates via the rebind / reset methods. - public KeyBindings Draft => _draft; - - /// True iff the draft differs structurally from the - /// persisted snapshot. Used to grey out the Save button when no - /// rebinds are pending. - public bool HasUnsavedChanges - => !KeyBindingsEqual(_persisted, _draft) - || _displayPersisted != _displayDraft - || _audioPersisted != _audioDraft - || _gameplayPersisted != _gameplayDraft - || _chatPersisted != _chatDraft - || _characterPersisted != _characterDraft; - - /// The current Display draft. Panel reads from here; - /// mutation goes through . - public DisplaySettings DisplayDraft => _displayDraft; - - /// The current Audio draft. Panel reads from here; - /// mutation goes through . - public AudioSettings AudioDraft => _audioDraft; - - /// The current Gameplay draft. Panel reads from here; - /// mutation goes through . - public GameplaySettings GameplayDraft => _gameplayDraft; - - /// The current Chat draft. Panel reads from here; - /// mutation goes through . - public ChatSettings ChatDraft => _chatDraft; - - /// The current Character draft (per-toon — host owns the - /// toon-name key). Panel reads from here; mutation goes through - /// . - public CharacterSettings CharacterDraft => _characterDraft; - - public SettingsVM( - KeyBindings persisted, - InputDispatcher dispatcher, - Action onSave, - DisplaySettings persistedDisplay, - Action onSaveDisplay, - AudioSettings persistedAudio, - Action onSaveAudio, - GameplaySettings persistedGameplay, - Action onSaveGameplay, - ChatSettings persistedChat, - Action onSaveChat, - CharacterSettings persistedCharacter, - Action onSaveCharacter) - { - _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); - _displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay)); - _onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay)); - _audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio)); - _onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio)); - _gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay)); - _onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay)); - _chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat)); - _onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat)); - _characterPersisted = persistedCharacter ?? throw new ArgumentNullException(nameof(persistedCharacter)); - _onSaveCharacter = onSaveCharacter ?? throw new ArgumentNullException(nameof(onSaveCharacter)); - _draft = CloneBindings(persisted); - _displayDraft = persistedDisplay; - _audioDraft = persistedAudio; - _gameplayDraft = persistedGameplay; - _chatDraft = persistedChat; - _characterDraft = persistedCharacter; - } - - /// - /// Replace the entire Display draft with . - /// Panel calls this with a DisplayDraft with { Field = newValue } - /// so each widget edits exactly one field at a time. - /// - public void SetDisplay(DisplaySettings value) - { - _displayDraft = value ?? throw new ArgumentNullException(nameof(value)); - } - - /// - /// Apply one externally persisted display-option change to both snapshots. - /// Existing unsaved edits to other fields remain drafts rather than being - /// accidentally promoted to persisted state. - /// - public void ApplyExternalDisplayChange(Func update) - { - ArgumentNullException.ThrowIfNull(update); - _displayPersisted = update(_displayPersisted) - ?? throw new InvalidOperationException("Display update returned null."); - _displayDraft = update(_displayDraft) - ?? throw new InvalidOperationException("Display update returned null."); - } - - /// - /// Replace the entire Audio draft with . - /// Live audio preview is achieved at the host layer by pushing - /// into the running OpenAL engine each frame - /// — this method only mutates VM state. Cancel reverts the draft and - /// the host's next-frame push restores the pre-edit engine volumes. - /// - public void SetAudio(AudioSettings value) - { - _audioDraft = value ?? throw new ArgumentNullException(nameof(value)); - } - - /// - /// Replace the entire Gameplay draft with . - /// Local-only this phase — values persist on Save but don't yet - /// flow to the server. When server-sync ships, the host's - /// onSaveGameplay callback will marshal the draft into the - /// retail CharacterOption wire bitmask. - /// - public void SetGameplay(GameplaySettings value) - { - _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value)); - } - - /// - /// Apply one externally persisted gameplay-option change to both snapshots. - /// Existing unsaved edits to unrelated fields remain drafts rather than - /// being accidentally promoted to persisted state. - /// - public void ApplyExternalGameplayChange( - Func update) - { - ArgumentNullException.ThrowIfNull(update); - _gameplayPersisted = update(_gameplayPersisted) - ?? throw new InvalidOperationException("Gameplay update returned null."); - _gameplayDraft = update(_gameplayDraft) - ?? throw new InvalidOperationException("Gameplay update returned null."); - } - - /// - /// Replace the entire Chat draft with . - /// Local-only this phase — values persist on Save but the Hear*Chat - /// flags affect client-side display filtering, not server-side - /// channel subscriptions. - /// - public void SetChat(ChatSettings value) - { - _chatDraft = value ?? throw new ArgumentNullException(nameof(value)); - } - - /// - /// Apply one externally learned Chat change (server truth — CH3, - /// 2026-08-09) to both snapshots, matching - /// 's shape. Existing unsaved - /// edits to unrelated fields remain drafts rather than being accidentally - /// promoted to persisted state. - /// - public void ApplyExternalChatChange(Func update) - { - ArgumentNullException.ThrowIfNull(update); - _chatPersisted = update(_chatPersisted) - ?? throw new InvalidOperationException("Chat update returned null."); - _chatDraft = update(_chatDraft) - ?? throw new InvalidOperationException("Chat update returned null."); - } - - /// - /// Replace the entire Character draft with . - /// Per-toon — the host knows which toon's bag we're editing because - /// it owned the toonKey when constructing the VM. - /// - public void SetCharacter(CharacterSettings value) - { - _characterDraft = value ?? throw new ArgumentNullException(nameof(value)); - } - - /// - /// Replace BOTH the persisted snapshot and the live draft for the - /// Character bag. Used when the active toon changes (e.g. on - /// EnterWorld with a non-default character) — the host loads that - /// toon's settings from disk and pushes them into the VM here so - /// doesn't flag the swap as a - /// pending edit. Differs from , which - /// updates draft only. - /// - public void LoadCharacterContext(CharacterSettings persisted) - { - _characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); - _characterDraft = persisted; - } - - /// - /// Begin rebinding . The supplied - /// binding will be removed when the new - /// chord is applied. The dispatcher enters modal capture mode; the - /// next chord pressed (or Esc) feeds back into - /// . - /// - public void BeginRebind(InputAction action, Binding original) - { - RebindInProgress = action; - RebindOriginal = original; - _dispatcher.BeginCapture(OnChordCaptured); - } - - private void OnChordCaptured(KeyChord chord) - { - // Sentinel: dispatcher reports default(KeyChord) on Esc cancel. - if (chord.Equals(default(KeyChord))) - { - RebindInProgress = null; - RebindOriginal = null; - return; - } - - // Conflict check: scan the draft for a binding that matches the - // captured chord + same activation type, but on a DIFFERENT - // action. (Same-action bindings are fine — that's already in - // _draft for this action and gets removed when we apply.) - var existing = _draft.Find( - chord, - RebindOriginal!.Value.Activation, - RebindOriginal.Value.Scope); - if (existing is not null && existing.Value.Action != RebindInProgress!.Value) - { - PendingConflict = new ConflictPrompt( - NewAction: RebindInProgress.Value, - NewChord: chord, - OriginalBinding: RebindOriginal.Value, - ConflictingAction: existing.Value.Action, - ConflictingBinding: existing.Value); - return; - } - - ApplyRebind(chord); - } - - /// - /// Resolve a : = - /// true removes the conflicting binding and applies the new chord; - /// false cancels the rebind entirely (original binding intact). - /// - public void ResolveConflict(bool replace) - { - if (PendingConflict is null) return; - var c = PendingConflict.Value; - if (replace) - { - _draft.Remove(c.ConflictingBinding); - ApplyRebind(c.NewChord); - } - else - { - RebindInProgress = null; - RebindOriginal = null; - } - PendingConflict = null; - } - - private void ApplyRebind(KeyChord chord) - { - _draft.Remove(RebindOriginal!.Value); - _draft.Add(new Binding( - chord, - RebindInProgress!.Value, - RebindOriginal.Value.Activation, - RebindOriginal.Value.Scope)); - RebindInProgress = null; - RebindOriginal = null; - } - - /// - /// Cancel any in-progress rebind / pending conflict and clear the - /// dispatcher's capture state. Does NOT revert the draft — for that - /// see . - /// - public void CancelRebind() - { - if (_dispatcher.IsCapturing) _dispatcher.CancelCapture(); - RebindInProgress = null; - RebindOriginal = null; - PendingConflict = null; - } - - /// - /// Restore the draft's bindings for to the - /// retail defaults. Other actions' draft bindings are untouched. - /// - public void ResetActionToDefault(InputAction action) - { - var defaults = KeyBindings.RetailDefaults(); - foreach (var b in _draft.ForAction(action).ToList()) - _draft.Remove(b); - foreach (var b in defaults.ForAction(action)) - _draft.Add(b); - } - - /// - /// Replace the keybinds draft with - /// AND the display draft with . - /// "Reset all" applies to every tab — it's the user's escape hatch - /// when they've gotten lost. - /// - public void ResetAllToDefaults() - { - _draft = KeyBindings.RetailDefaults(); - _displayDraft = DisplaySettings.Default; - _audioDraft = AudioSettings.Default; - _gameplayDraft = GameplaySettings.Default; - _chatDraft = ChatSettings.Default; - _characterDraft = CharacterSettings.Default; - } - - /// - /// Commit both keybinds + display drafts via the onSave callbacks - /// supplied at construction. After save the drafts become the new - /// persisted snapshots — resets to - /// false. Each callback is invoked exactly once per Save; if the - /// caller wants atomicity across both files it has to handle it - /// outside the VM. - /// - public void Save() - { - _onSave(_draft); - _onSaveDisplay(_displayDraft); - _onSaveAudio(_audioDraft); - _onSaveGameplay(_gameplayDraft); - _onSaveChat(_chatDraft); - _onSaveCharacter(_characterDraft); - _persisted = CloneBindings(_draft); - _displayPersisted = _displayDraft; - _audioPersisted = _audioDraft; - _gameplayPersisted = _gameplayDraft; - _chatPersisted = _chatDraft; - _characterPersisted = _characterDraft; - } - - /// - /// Revert all drafts to their persisted snapshots and clear any - /// in-flight rebind state. Used by the panel's "Cancel" button and - /// when the user closes the settings window without saving. - /// - public void Cancel() - { - _draft = CloneBindings(_persisted); - _displayDraft = _displayPersisted; - _audioDraft = _audioPersisted; - _gameplayDraft = _gameplayPersisted; - _chatDraft = _chatPersisted; - _characterDraft = _characterPersisted; - CancelRebind(); - } - - // ── helpers ─────────────────────────────────────────────────────── - - private static KeyBindings CloneBindings(KeyBindings src) - { - var clone = new KeyBindings(); - foreach (var b in src.All) clone.Add(b); - return clone; - } - - private static bool KeyBindingsEqual(KeyBindings a, KeyBindings b) - { - if (a.All.Count != b.All.Count) return false; - for (int i = 0; i < a.All.Count; i++) - if (!a.All[i].Equals(b.All[i])) return false; - return true; - } -} - -/// -/// K.3 conflict-prompt payload surfaced when the user binds a chord -/// already in use. The panel renders the + -/// labels in a confirmation prompt; -/// dispatches the user's -/// answer. -/// -public readonly record struct ConflictPrompt( - InputAction NewAction, - KeyChord NewChord, - Binding OriginalBinding, - InputAction ConflictingAction, - Binding ConflictingBinding); diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index 049e5ed2..a1f8e74e 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -11,13 +11,12 @@ public sealed class GameplayInputCommandControllerTests { [Theory] [InlineData(InputAction.ToggleInventoryPanel, "inventory")] - [InlineData(InputAction.AcdreamToggleDebugPanel, "debug")] [InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")] [InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")] - [InlineData(InputAction.ToggleChatEntry, "chat")] // Campaign OP slice OP3 (D1): F11 now opens the retail Options panel - // through IRetainedGameplayWindowCommands, not the retired - // IDevToolsGameplayCommands.ToggleSettingsPanel() no-op. + // through IRetainedGameplayWindowCommands, not the OP9-retired + // IDevToolsGameplayCommands.ToggleSettingsPanel() no-op (which was + // never dispatched from this switch in the first place). [InlineData(InputAction.ToggleOptionsPanel, "options")] [InlineData(InputAction.CombatToggleCombat, "combat")] [InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")] @@ -36,6 +35,26 @@ public sealed class GameplayInputCommandControllerTests Assert.Equal([expected], harness.Calls); } + // OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the + // IDevToolsGameplayCommands seam they used to forward to — both + // targets (the ImGui-era DebugPanel/ChatPanel) were already gone + // (Campaign V slice V11), so the seam's own body was an unconditional + // no-op. The action is still consumed (handled == true, matching the + // prior no-op's contract) but claims no typed-owner call. + [Theory] + [InlineData(InputAction.AcdreamToggleDebugPanel)] + [InlineData(InputAction.ToggleChatEntry)] + public void RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner( + InputAction action) + { + var harness = new Harness(); + + bool handled = harness.Controller.Handle(action); + + Assert.True(handled); + Assert.Empty(harness.Calls); + } + [Fact] public void DiagnosticCommand_PrecedesRemainingCommandSwitch() { @@ -89,7 +108,6 @@ public sealed class GameplayInputCommandControllerTests public Harness() { Retained = new FakeRetained(Calls); - DevTools = new FakeDevTools(Calls); Diagnostics = new FakeDiagnostics(Calls); Player = new FakePlayerMode(Calls); TargetMode = new FakeTargetMode(Calls); @@ -99,7 +117,6 @@ public sealed class GameplayInputCommandControllerTests Window = new FakeWindow(Calls); Controller = new GameplayInputCommandController( Retained, - DevTools, Diagnostics, Player, TargetMode, @@ -111,7 +128,6 @@ public sealed class GameplayInputCommandControllerTests public List Calls { get; } = []; public FakeRetained Retained { get; } - public FakeDevTools DevTools { get; } public FakeDiagnostics Diagnostics { get; } public FakePlayerMode Player { get; } public FakeTargetMode TargetMode { get; } @@ -133,14 +149,6 @@ public sealed class GameplayInputCommandControllerTests public void ToggleOptionsPanel() => calls.Add("options"); } - private sealed class FakeDevTools(List calls) - : IDevToolsGameplayCommands - { - public void ToggleDebugPanel() => calls.Add("debug"); - public void FocusChatInput() => calls.Add("chat"); - public void ToggleSettingsPanel() => calls.Add("settings"); - } - private sealed class FakeDiagnostics(List calls) : IRuntimeDiagnosticCommands { diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 0310a39f..017762cd 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -386,7 +386,7 @@ public sealed class GameWindowSlice8BoundaryTests "_factory.CreateBackendNeutralTerrain("); AssertAppearsInOrder( shutdown, - "Soft(\"settings view model\", () => ingress.Settings.UnbindViewModel())", + "new ResourceShutdownStage(\"physical ingress cleanup\"", "new ResourceShutdownStage(\"frame borrowers\"", "Hard(\"frame-root bindings\", () => frame.FrameBindings?.Dispose())", "Hard(\"retail UI\", () => DisposeRetailUi(live.RetailUi))", diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index cbd925cc..358f0e2b 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -4,10 +4,8 @@ using AcDream.App.Rendering; using AcDream.App.Settings; using AcDream.Core.Net.Messages; using AcDream.UI.Abstractions; -using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Settings; -using Silk.NET.Input; namespace AcDream.App.Tests.Settings; @@ -39,14 +37,12 @@ public sealed class RuntimeSettingsControllerTests Assert.Equal(1, storage.DisplayLoads); Assert.Equal(1, storage.AudioLoads); - Assert.Equal(1, storage.GameplayLoads); Assert.Equal(1, storage.ChatLoads); Assert.Equal(1, storage.CharacterLoads); Assert.Equal(1, resolveCount); Assert.Equal("default", storage.LastLoadedCharacter); Assert.Same(storage.DisplayValue, controller.Startup.Display); Assert.Same(storage.AudioValue, controller.Startup.Audio); - Assert.Same(storage.GameplayValue, controller.Startup.Gameplay); Assert.Same(storage.ChatValue, controller.Startup.Chat); Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character); Assert.Equal(resolved, controller.Startup.Quality); @@ -75,31 +71,6 @@ public sealed class RuntimeSettingsControllerTests controller.BindRuntimeTargets(new FakeRuntimeTargets(events))); } - [Fact] - public void ViewModelBindingReleasesOnlyItsExpectedInstance() - { - var controller = CreateController(); - using InputDispatcher dispatcher = CreateDispatcher(); - RuntimeSettingsViewModelBinding first = controller.CreateViewModelBinding( - new KeyBindings(), - dispatcher, - static _ => { }); - - first.Dispose(); - RuntimeSettingsViewModelBinding second = controller.CreateViewModelBinding( - new KeyBindings(), - dispatcher, - static _ => { }); - first.Dispose(); - - Assert.Throws(() => - controller.CreateViewModelBinding( - new KeyBindings(), - dispatcher, - static _ => { })); - second.Dispose(); - } - [Fact] public void StartupRetryResumesAfterLastSuccessfulStage() { @@ -262,63 +233,6 @@ public sealed class RuntimeSettingsControllerTests Assert.False(cmd.Value); } - [Fact] - public void SettingsViewModelSavePreservesSectionAndTargetOrder() - { - var events = new List(); - var storage = new FakeStorage(events); - var resolved = new QualitySettings(5, 15, 4, 16, true, 6); - var controller = new RuntimeSettingsController( - storage, - _ => resolved, - static _ => { }); - storage.ClearEvents(); - var targets = new FakeRuntimeTargets(events); - controller.BindRuntimeTargets(targets); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - _ => events.Add("save-bindings")); - - viewModel.SetDisplay(viewModel.DisplayDraft with - { - Resolution = "1920x1080", - Quality = QualityPreset.Ultra, - }); - viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.25f }); - viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true }); - viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true }); - viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true }); - - viewModel.Save(); - - Assert.Equal( - [ - "save-bindings", - "save-display", - "target-display", - "target-quality", - "save-audio", - // Campaign OP slice OP6 (2026-08-11): SaveAudio now pushes the - // saved snapshot into the live engine, same shape as - // save-display's own target-display push a few lines up. - "target-audio", - "save-gameplay", - "target-ui-lock:True", - "save-chat", - "target-chat-opacity:1:1", - "save-character:default", - ], - events); - Assert.Equal("1920x1080", controller.Display.Resolution); - Assert.Equal(0.25f, controller.Audio.Master); - Assert.True(controller.Gameplay.LockUI); - Assert.True(controller.Chat.ShowTimestamps); - Assert.True(controller.Character.AutoAttack); - Assert.Equal(resolved, controller.ResolvedQuality); - } - // OP6 rework (2026-08-11, review S5 / M2): pins the EFFECTIVE volume // ApplyAudio actually computes, not just that some target was called. // The FakeRuntimeTargets-based "target-audio" assertion above (and its @@ -369,6 +283,10 @@ public sealed class RuntimeSettingsControllerTests // CH3 (2026-08-09): SaveChat must publish SetSingleCharacterOption // (0x0005) for exactly the Hear*Chat bits that actually changed — // touching one checkbox must not resend the other four. + // + // OP9 rework: SaveChat was widened to public at Campaign OP slice + // OP6, so this test now calls it directly instead of routing + // through the retired SettingsVM draft/Save() indirection. var storage = new FakeStorage(); var controller = new RuntimeSettingsController( storage, @@ -376,31 +294,24 @@ public sealed class RuntimeSettingsControllerTests static _ => { }); var targets = new FakeRuntimeTargets([]); controller.BindRuntimeTargets(targets); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); // N4 (CH3 Opus review): ChatSettings.Default now matches ACE's real // CharacterOptions2.Default — Roleplay/Society start FALSE (only // General/Trade/LFG start true). Flip Roleplay ON first so the // second edit below can flip it back off. - Assert.False(viewModel.ChatDraft.HearRoleplayChat); - viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = true }); - viewModel.Save(); + Assert.False(controller.Chat.HearRoleplayChat); + controller.SaveChat(controller.Chat with { HearRoleplayChat = true }); Assert.Equal( [((uint)CharacterOptionId.ListenToRoleplayChat, true)], targets.SingleOptionCalls); targets.SingleOptionCalls.Clear(); - viewModel.SetChat(viewModel.ChatDraft with + controller.SaveChat(controller.Chat with { HearRoleplayChat = false, HearSocietyChat = true, }); - viewModel.Save(); Assert.Equal( [ @@ -416,15 +327,9 @@ public sealed class RuntimeSettingsControllerTests var controller = CreateController(); var targets = new FakeRuntimeTargets([]); controller.BindRuntimeTargets(targets); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); // Touch a Chat field that is NOT a Hear*Chat membership bit. - viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = false }); - viewModel.Save(); + controller.SaveChat(controller.Chat with { ShowTimestamps = false }); Assert.Empty(targets.SingleOptionCalls); } @@ -434,23 +339,17 @@ public sealed class RuntimeSettingsControllerTests { // Campaign CH slice CH6c: unlike the Hear* options (local-only, no // wire), opacity is ALWAYS pushed on Save (not diffed) so the linking - // invariant self-heals; the point of this test is that clicking Save - // is enough — no restart, no separate "apply" step. + // invariant self-heals; the point of this test is that calling + // SaveChat is enough — no restart, no separate "apply" step. var controller = CreateController(); var targets = new FakeRuntimeTargets([]); controller.BindRuntimeTargets(targets); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - viewModel.SetChat(viewModel.ChatDraft with + controller.SaveChat(controller.Chat with { DefaultOpacity = 0.3f, ActiveOpacity = 0.6f, }); - viewModel.Save(); Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls); Assert.Equal(0.3f, controller.Chat.DefaultOpacity); @@ -486,7 +385,7 @@ public sealed class RuntimeSettingsControllerTests } [Fact] - public void SyncChatFromServerOptionsReseedsPersistedAndDraft() + public void SyncChatFromServerOptionsReseedsPersisted() { // Research doc §5.2: ACE's CharacterOptions2.Default omits // HearRoleplayChat/HearSocietyChat. N4 (CH3 Opus review) aligned @@ -507,13 +406,7 @@ public sealed class RuntimeSettingsControllerTests storage, static preset => QualitySettings.From(preset), static _ => { }); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); Assert.True(controller.Chat.HearRoleplayChat); - Assert.True(viewModel.ChatDraft.HearRoleplayChat); controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default @@ -522,28 +415,9 @@ public sealed class RuntimeSettingsControllerTests Assert.True(controller.Chat.HearLFGChat); Assert.False(controller.Chat.HearRoleplayChat); Assert.False(controller.Chat.HearSocietyChat); - Assert.False(viewModel.ChatDraft.HearRoleplayChat); - Assert.False(viewModel.ChatDraft.HearSocietyChat); Assert.Same(controller.Chat, storage.ChatValue); } - [Fact] - public void SyncChatFromServerOptionsPreservesUnsavedUnrelatedDraftEdits() - { - var controller = CreateController(); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - viewModel.SetChat(viewModel.ChatDraft with { FontSize = 18f }); - - controller.SyncChatFromServerOptions(0x00948700u); - - Assert.Equal(18f, viewModel.ChatDraft.FontSize); - Assert.False(viewModel.ChatDraft.HearRoleplayChat); - } - [Fact] public void SyncChatFromServerOptionsIsANoOpWhenUnchanged() { @@ -569,126 +443,31 @@ public sealed class RuntimeSettingsControllerTests } [Fact] - public void DraftPreviewAndExternalCommandsShareCanonicalState() + public void DraftPreviewAlwaysMirrorsCommittedState() { - var events = new List(); - var storage = new FakeStorage(events); - var controller = CreateController(storage, events: events); - storage.ClearEvents(); - var targets = new FakeRuntimeTargets(events); - controller.BindRuntimeTargets(targets); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - viewModel.SetDisplay(viewModel.DisplayDraft with - { - FieldOfView = 91f, - ParticleRange = ParticleRange.Retail, - }); - viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f }); - viewModel.SetGameplay(viewModel.GameplayDraft with { ShowTooltips = false }); + // OP9: the optional developer-tools draft-preview view model + // (SettingsVM) was retired — it had zero production construction + // sites. HasDraftPreview is now hardcoded false and + // DisplayPreview/AudioPreview always mirror the committed + // Display/Audio snapshot. This pins the NEW (trivial) contract that + // WorldRenderFrameBuilder and SettingsParticleRangeSource still + // consume through IRuntimeSettingsPreviewSource. + var controller = CreateController(); - Assert.True(controller.HasDraftPreview); + Assert.False(controller.HasDraftPreview); + Assert.Equal(controller.Display, controller.DisplayPreview); + Assert.Equal(controller.Audio, controller.AudioPreview); + + controller.SaveDisplay(controller.Display with { FieldOfView = 91f }); + controller.SaveAudio(controller.Audio with { Sfx = 0.33f }); + + Assert.False(controller.HasDraftPreview); Assert.Equal(91f, controller.DisplayPreview.FieldOfView); - Assert.Equal(ParticleRange.Retail, controller.DisplayPreview.ParticleRange); Assert.Equal(0.33f, controller.AudioPreview.Sfx); - - controller.ToggleFrameRate(); - controller.SetUiLocked(true); - controller.SetAcceptLootPermits(true); - - Assert.True(controller.Display.ShowFps); - Assert.True(controller.DisplayPreview.ShowFps); - Assert.Equal(91f, controller.DisplayPreview.FieldOfView); - Assert.True(controller.Gameplay.LockUI); - Assert.True(controller.Gameplay.AcceptLootPermits); - Assert.False(viewModel.GameplayDraft.ShowTooltips); - Assert.True(viewModel.GameplayDraft.LockUI); - Assert.True(viewModel.GameplayDraft.AcceptLootPermits); - Assert.Contains("target-ui-lock:True", events); - Assert.Equal(2, storage.GameplaySaves); - Assert.Equal(1, storage.DisplaySaves); } [Fact] - public void DraftCancelRestoresPreviewAndCombatTogglePreservesUnrelatedDrafts() - { - var storage = new FakeStorage(); - var controller = CreateController(storage); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - viewModel.SetDisplay(viewModel.DisplayDraft with { FieldOfView = 99f }); - viewModel.SetGameplay(viewModel.GameplayDraft with - { - ShowTooltips = false, - CoordinatesOnRadar = false, - }); - - controller.SetUiLocked(true); - controller.SetAcceptLootPermits(true); - - Assert.False(viewModel.GameplayDraft.ShowTooltips); - Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar); - Assert.True(viewModel.GameplayDraft.LockUI); - Assert.True(viewModel.GameplayDraft.AcceptLootPermits); - Assert.Equal(99f, controller.DisplayPreview.FieldOfView); - - viewModel.Cancel(); - - Assert.Equal(controller.Display.FieldOfView, controller.DisplayPreview.FieldOfView); - Assert.Equal(controller.Gameplay.ShowTooltips, viewModel.GameplayDraft.ShowTooltips); - Assert.Equal( - controller.Gameplay.CoordinatesOnRadar, - viewModel.GameplayDraft.CoordinatesOnRadar); - Assert.True(viewModel.GameplayDraft.LockUI); - Assert.True(viewModel.GameplayDraft.AcceptLootPermits); - - viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false }); - viewModel.Save(); - - Assert.True(storage.GameplayValue.LockUI); - Assert.True(storage.GameplayValue.AcceptLootPermits); - Assert.False(storage.GameplayValue.ShowHelm); - } - - [Fact] - public void FailedExternalGameplayPersistenceDoesNotPromoteViewModelBaseline() - { - var storage = new FakeStorage { ThrowOnGameplaySave = true }; - var logs = new List(); - var controller = new RuntimeSettingsController( - storage, - static preset => QualitySettings.From(preset), - logs.Add); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - - controller.SetUiLocked(true); - Assert.Throws(() => controller.SetAcceptLootPermits(true)); - - Assert.True(viewModel.GameplayDraft.LockUI); - Assert.True(viewModel.GameplayDraft.AcceptLootPermits); - - viewModel.Cancel(); - - Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI); - Assert.Equal( - GameplaySettings.Default.AcceptLootPermits, - viewModel.GameplayDraft.AcceptLootPermits); - Assert.Contains(logs, line => - line.Contains("radar lock save failed", StringComparison.Ordinal)); - } - - [Fact] - public void CharacterContextLoadsWithoutViewModelAndSynchronizesWhenBound() + public void CharacterContextSwitchesActiveToonAndReloadsSettings() { var storage = new FakeStorage(); storage.Characters["Alice"] = CharacterSettings.Default with @@ -707,76 +486,23 @@ public sealed class RuntimeSettingsControllerTests Assert.Equal("Alice", controller.ActiveToonKey); Assert.Equal("Trade", controller.Character.DefaultChatChannel); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); controller.LoadCharacterContext("Bob"); Assert.Equal("Bob", controller.ActiveToonKey); - Assert.False(viewModel.CharacterDraft.ConfirmSalvage); + Assert.False(controller.Character.ConfirmSalvage); controller.RestoreDefaultCharacterContext(); controller.ResetActiveCharacterKey(); Assert.Equal("default", controller.ActiveToonKey); Assert.Same(storage.DefaultCharacterValue, controller.Character); - Assert.Same(storage.DefaultCharacterValue, viewModel.CharacterDraft); } [Fact] - public void CharacterSaveUsesActiveToonAndDefaultSaveBecomesResetContext() - { - var storage = new FakeStorage(); - var controller = CreateController(storage); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - var newDefault = CharacterSettings.Default with { AutoAttack = true }; - viewModel.SetCharacter(newDefault); - viewModel.Save(); - - controller.LoadCharacterContext("Alice"); - var alice = CharacterSettings.Default with { DefaultChatChannel = "Trade" }; - viewModel.SetCharacter(alice); - viewModel.Save(); - - Assert.Same(newDefault, storage.Characters["default"]); - Assert.Same(alice, storage.Characters["Alice"]); - controller.RestoreDefaultCharacterContext(); - Assert.Same(newDefault, controller.Character); - Assert.Same(newDefault, viewModel.CharacterDraft); - } - - [Fact] - public void ViewModelAndRuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely() + public void RuntimeTargetLoansCanBeWithdrawnAndReboundPrecisely() { var events = new List(); var controller = CreateController(events: events); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - SettingsVM foreign = CreateStandaloneViewModel(dispatcher); - - controller.UnbindViewModel(foreign); - Assert.True(controller.HasDraftPreview); - Assert.Throws(() => - controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { })); - - controller.UnbindViewModel(viewModel); - Assert.False(controller.HasDraftPreview); - controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); var first = new FakeRuntimeTargets(events); controller.BindRuntimeTargets(first); @@ -822,20 +548,14 @@ public sealed class RuntimeSettingsControllerTests logs.Add); storage.ClearEvents(); controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); DisplaySettings original = controller.Display; - viewModel.SetDisplay(viewModel.DisplayDraft with + + controller.SaveDisplay(controller.Display with { Resolution = "2560x1440", Quality = QualityPreset.Ultra, }); - viewModel.Save(); - Assert.Same(original, controller.Display); Assert.DoesNotContain("target-display", events); Assert.DoesNotContain("target-quality", events); @@ -857,20 +577,14 @@ public sealed class RuntimeSettingsControllerTests { ThrowOnDisplay = true, }); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); DisplaySettings original = controller.Display; - viewModel.SetDisplay(viewModel.DisplayDraft with + + controller.SaveDisplay(controller.Display with { Resolution = "3840x2160", Quality = QualityPreset.Ultra, }); - viewModel.Save(); - Assert.Equal(1, storage.DisplaySaves); Assert.Same(original, controller.Display); Assert.DoesNotContain("target-quality", events); @@ -883,9 +597,7 @@ public sealed class RuntimeSettingsControllerTests var storage = new FakeStorage { ThrowOnAudioSave = true, - ThrowOnGameplaySave = true, ThrowOnChatSave = true, - ThrowOnCharacterSave = true, }; var logs = new List(); var controller = new RuntimeSettingsController( @@ -893,29 +605,15 @@ public sealed class RuntimeSettingsControllerTests static preset => QualitySettings.From(preset), logs.Add); AudioSettings originalAudio = controller.Audio; - GameplaySettings originalGameplay = controller.Gameplay; ChatSettings originalChat = controller.Chat; - CharacterSettings originalCharacter = controller.Character; - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - viewModel.SetAudio(viewModel.AudioDraft with { Master = 0.1f }); - viewModel.SetGameplay(viewModel.GameplayDraft with { LockUI = true }); - viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = true }); - viewModel.SetCharacter(viewModel.CharacterDraft with { AutoAttack = true }); - viewModel.Save(); + controller.SaveAudio(controller.Audio with { Master = 0.1f }); + controller.SaveChat(controller.Chat with { ShowTimestamps = true }); Assert.Same(originalAudio, controller.Audio); - Assert.Same(originalGameplay, controller.Gameplay); Assert.Same(originalChat, controller.Chat); - Assert.Same(originalCharacter, controller.Character); Assert.Contains(logs, line => line.Contains("audio save failed", StringComparison.Ordinal)); - Assert.Contains(logs, line => line.Contains("gameplay save failed", StringComparison.Ordinal)); Assert.Contains(logs, line => line.Contains("chat save failed", StringComparison.Ordinal)); - Assert.Contains(logs, line => line.Contains("character save failed", StringComparison.Ordinal)); } [Fact] @@ -956,135 +654,74 @@ public sealed class RuntimeSettingsControllerTests } [Fact] - public void SetUiLocked_AppliesEvenWhenGameplayLockUIAlreadyMatches_IfNeverActuallyApplied() + public void SetUiLocked_AppliesOnFirstCallThenNoOpsOnRepeatedSameValue() { - // MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the - // guard used to compare the requested value against - // `Gameplay.LockUI` — valid only while `ToggleUiLock` derived - // `locked` AS `!Gameplay.LockUI`. OP4 re-pointed `ToggleUiLock` to - // read the SERVER bit (RuntimeCharacterOptionsState) instead, a - // DIFFERENT store that can already equal a value this controller - // never actually pushed to `_runtimeTargets`. A pre-existing save - // seeds Gameplay.LockUI = true; the runtime target has never seen - // `true` — the FIRST SetUiLocked(true) call must still apply. + // OP9: SetUiLocked no longer reads or writes a persisted + // GameplaySettings mirror (MUST-FIX 4, OP4 review-fix round, + // 2026-08-11, blast M3, superseded) — RuntimeCharacterOptionsState + // (the server bit) is the sole authority now, so there is no longer + // a second store that could disagree with what was actually + // applied. The guard's own idempotency (compare against the last + // value ACTUALLY pushed to _runtimeTargets) is the only behavior + // left to pin here. var events = new List(); - var storage = new FakeStorage(events) - { - GameplayValue = GameplaySettings.Default with { LockUI = true }, - }; - var controller = new RuntimeSettingsController( - storage, - static preset => QualitySettings.From(preset), - static _ => { }); - Assert.True(controller.Gameplay.LockUI); // already true, but never applied + var controller = CreateController(events: events); var targets = new FakeRuntimeTargets(events); controller.BindRuntimeTargets(targets); controller.SetUiLocked(true); - Assert.Equal(1, targets.UiLockCalls); - // A second call with the SAME value now correctly no-ops — this - // time it really was applied. controller.SetUiLocked(true); - Assert.Equal(1, targets.UiLockCalls); + + controller.SetUiLocked(false); + Assert.Equal(2, targets.UiLockCalls); } [Fact] public void UiLockTargetFailureCanRetryTheSameRequestedValue() { var events = new List(); - var storage = new FakeStorage(events); - var controller = CreateController(storage, events); - storage.ClearEvents(); + var controller = CreateController(events: events); var targets = new FakeRuntimeTargets(events) { RemainingUiLockFailures = 1, }; controller.BindRuntimeTargets(targets); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); Assert.Throws(() => controller.SetUiLocked(true)); - - Assert.True(controller.Gameplay.LockUI); - Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI); - Assert.Equal(0, storage.GameplaySaves); Assert.Equal(["target-ui-lock:True"], events); + // A retry with the SAME requested value must re-apply — the guard + // only advances _lastAppliedUiLocked on SUCCESS (the field write in + // SetUiLocked runs after the ApplyUiLock call, which threw above). events.Clear(); controller.SetUiLocked(true); - Assert.True(controller.Gameplay.LockUI); - Assert.True(viewModel.GameplayDraft.LockUI); - viewModel.Cancel(); - Assert.True(viewModel.GameplayDraft.LockUI); - Assert.Equal(1, storage.GameplaySaves); - Assert.Equal(["target-ui-lock:True", "save-gameplay"], events); + Assert.Equal(["target-ui-lock:True"], events); + Assert.Equal(2, targets.UiLockCalls); } [Fact] - public void UiLockPersistenceFailureCanRetryTheSameRequestedValue() + public void UnboundRuntimeTargetsConsumeUiLockCallsSilently() { + // OP9: SetUiLocked no longer persists anything of its own, so the + // only observable effect of an unbound call is the ABSENCE of any + // "target-" event — there is no longer a Gameplay-side write to + // assert against. var events = new List(); - var storage = new FakeStorage(events) - { - RemainingGameplaySaveFailures = 1, - }; - var logs = new List(); - var controller = new RuntimeSettingsController( - storage, - static preset => QualitySettings.From(preset), - logs.Add); - storage.ClearEvents(); - controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); - using InputDispatcher dispatcher = CreateDispatcher(); - SettingsVM viewModel = controller.CreateViewModel( - new KeyBindings(), - dispatcher, - static _ => { }); - - controller.SetUiLocked(true); - - Assert.True(controller.Gameplay.LockUI); - Assert.True(viewModel.GameplayDraft.LockUI); - Assert.Equal(1, storage.GameplaySaves); - Assert.Contains(logs, line => - line.Contains("radar lock save failed", StringComparison.Ordinal)); - - events.Clear(); - controller.SetUiLocked(true); - - Assert.True(controller.Gameplay.LockUI); - viewModel.Cancel(); - Assert.True(viewModel.GameplayDraft.LockUI); - Assert.Equal(2, storage.GameplaySaves); - Assert.Equal(["target-ui-lock:True", "save-gameplay"], events); - } - - [Fact] - public void UnbindingLoansStopsCallsButStateAndPersistenceContinue() - { - var events = new List(); - var storage = new FakeStorage(events); - var controller = CreateController(storage, events: events); - storage.ClearEvents(); + var controller = CreateController(events: events); controller.BindRuntimeTargets(new FakeRuntimeTargets(events)); controller.UnbindRuntimeTargets(); controller.SetUiLocked(true); controller.ReapplyQualityPreset(QualityPreset.Ultra); - Assert.True(controller.Gameplay.LockUI); Assert.Equal( QualitySettings.From(QualityPreset.Ultra), controller.ResolvedQuality); Assert.DoesNotContain(events, value => value.StartsWith("target-", StringComparison.Ordinal)); - Assert.Equal(1, storage.GameplaySaves); } [Theory] @@ -1121,32 +758,6 @@ public sealed class RuntimeSettingsControllerTests static _ => { }); } - private static InputDispatcher CreateDispatcher() - { - InputDispatcher dispatcher = InputDispatcher.CreateDetached( - new NullKeyboardSource(), - new NullMouseSource(), - new KeyBindings()); - dispatcher.Attach(); - return dispatcher; - } - - private static SettingsVM CreateStandaloneViewModel(InputDispatcher dispatcher) => - new( - new KeyBindings(), - dispatcher, - static _ => { }, - DisplaySettings.Default, - static _ => { }, - AudioSettings.Default, - static _ => { }, - GameplaySettings.Default, - static _ => { }, - ChatSettings.Default, - static _ => { }, - CharacterSettings.Default, - static _ => { }); - private sealed class FakeStartupTarget(List events) : IRuntimeSettingsStartupTarget { @@ -1356,8 +967,6 @@ public sealed class RuntimeSettingsControllerTests public AudioSettings AudioValue { get; set; } = AudioSettings.Default; - public GameplaySettings GameplayValue { get; set; } = GameplaySettings.Default; - public ChatSettings ChatValue { get; set; } = ChatSettings.Default; public CharacterSettings DefaultCharacterValue { get; set; } = @@ -1377,16 +986,12 @@ public sealed class RuntimeSettingsControllerTests public int AudioLoads { get; private set; } - public int GameplayLoads { get; private set; } - public int ChatLoads { get; private set; } public int CharacterLoads { get; private set; } public int DisplaySaves { get; private set; } - public int GameplaySaves { get; private set; } - public int ChatSaves { get; private set; } public string? LastLoadedCharacter { get; private set; } @@ -1395,10 +1000,6 @@ public sealed class RuntimeSettingsControllerTests public bool ThrowOnAudioSave { get; init; } - public bool ThrowOnGameplaySave { get; init; } - - public int RemainingGameplaySaveFailures { get; set; } - public bool ThrowOnChatSave { get; init; } public bool ThrowOnCharacterSave { get; init; } @@ -1415,12 +1016,6 @@ public sealed class RuntimeSettingsControllerTests return AudioValue; } - public GameplaySettings LoadGameplay() - { - GameplayLoads++; - return GameplayValue; - } - public ChatSettings LoadChat() { ChatLoads++; @@ -1453,20 +1048,6 @@ public sealed class RuntimeSettingsControllerTests AudioValue = audio; } - public void SaveGameplay(GameplaySettings gameplay) - { - GameplaySaves++; - _events.Add("save-gameplay"); - if (RemainingGameplaySaveFailures > 0) - { - RemainingGameplaySaveFailures--; - throw new IOException("gameplay persistence failed"); - } - if (ThrowOnGameplaySave) - throw new IOException("gameplay persistence failed"); - GameplayValue = gameplay; - } - public void SaveChat(ChatSettings chat) { ChatSaves++; @@ -1532,27 +1113,4 @@ public sealed class RuntimeSettingsControllerTests return _refreshRate is > 0; } } - - private sealed class NullKeyboardSource : IKeyboardSource - { -#pragma warning disable CS0067 - public event Action? KeyDown; - public event Action? KeyUp; -#pragma warning restore CS0067 - public bool IsHeld(Key key) => false; - public ModifierMask CurrentModifiers => ModifierMask.None; - } - - private sealed class NullMouseSource : IMouseSource - { -#pragma warning disable CS0067 - public event Action? MouseDown; - public event Action? MouseUp; - public event Action? MouseMove; - public event Action? Scroll; -#pragma warning restore CS0067 - public bool IsHeld(MouseButton button) => false; - public bool WantCaptureMouse => false; - public bool WantCaptureKeyboard => false; - } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs index c10160a6..57c8615e 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs @@ -6,7 +6,9 @@ namespace AcDream.UI.Abstractions.Tests.Input; /// /// K.3: is the modal-rebind -/// hook used by SettingsPanel. While capture is active, the next +/// hook used by Campaign OP slice OP8's KeyboardConfigController +/// (the OP9-retired ImGui-era SettingsPanel used it before that). +/// While capture is active, the next /// non-modifier chord is reported via the supplied callback and the /// dispatcher does NOT fire normal action events for that chord. Esc /// cancels capture (callback receives a sentinel default chord). diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs deleted file mode 100644 index b0230b83..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/GameplaySettingsTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using AcDream.UI.Abstractions.Panels.Settings; - -namespace AcDream.UI.Abstractions.Tests.Panels.Settings; - -/// -/// L.0: default-pin tests + value-equality -/// guarantees. Defaults are intentionally NOT bit-exact to retail's -/// 0x50C4A54A mask — see GameplaySettings remarks for rationale. -/// -public sealed class GameplaySettingsTests -{ - [Fact] - public void Default_values_are_typical_user_friendly() - { - // These defaults are reviewed in the L.0 brainstorm — typical-user - // starting point, not retail-bitmask. A change to any of these - // should be a deliberate decision, not a drive-by. - var d = GameplaySettings.Default; - Assert.True(d.ToggleRun); - Assert.False(d.AdvancedCombatUI); - Assert.True(d.ShowTooltips); - Assert.True(d.VividTargetingIndicator); // retail default mask includes 0x00008000 - Assert.False(d.SideBySideVitals); - Assert.True(d.CoordinatesOnRadar); // retail default mask includes 0x00400000 - Assert.True(d.SpellDuration); - Assert.True(d.AllowGive); - Assert.True(d.ShowHelm); - Assert.True(d.ShowCloak); - Assert.False(d.LockUI); - Assert.False(d.UseMouseTurning); - } - - [Fact] - public void Equality_is_value_based() - { - var a = GameplaySettings.Default; - var b = GameplaySettings.Default with { ToggleRun = false }; - var c = GameplaySettings.Default with { ToggleRun = false }; - Assert.NotEqual(a, b); - Assert.Equal(b, c); - } - - [Fact] - public void With_expression_clones_one_field() - { - var d = GameplaySettings.Default with { LockUI = true }; - Assert.True(d.LockUI); - // Other fields untouched. - Assert.Equal(GameplaySettings.Default.ToggleRun, d.ToggleRun); - Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs deleted file mode 100644 index 656856df..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ /dev/null @@ -1,569 +0,0 @@ -using System.Linq; -using AcDream.UI.Abstractions.Input; -using AcDream.UI.Abstractions.Panels.Settings; -using AcDream.UI.Abstractions.Tests.Input; -using Silk.NET.Input; - -namespace AcDream.UI.Abstractions.Tests.Panels.Settings; - -/// -/// K.3: renders the rebind UI on top of -/// . These tests use -/// to assert the panel emits the expected widget calls — top action -/// buttons, section headers, conflict prompt when one is pending, and -/// the "Rebind" button forwarding to the VM. -/// -public sealed class SettingsPanelTests -{ - private sealed class NullBus : ICommandBus - { - public void Publish(T command) where T : notnull { } - } - - private static (SettingsPanel panel, SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher) - Build() - { - var kb = new FakeKeyboardSource(); - var mouse = new FakeMouseSource(); - var persisted = new KeyBindings(); - persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); - persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); - var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted); - dispatcher.Attach(); - var vm = new SettingsVM( - persisted, dispatcher, _ => { }, - DisplaySettings.Default, _ => { }, - AudioSettings.Default, _ => { }, - GameplaySettings.Default, _ => { }, - ChatSettings.Default, _ => { }, - CharacterSettings.Default, _ => { }); - var panel = new SettingsPanel(vm); - return (panel, vm, kb, dispatcher); - } - - [Fact] - public void Render_emits_Save_Cancel_ResetAll_buttons_at_top() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer(); - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var buttonLabels = r.Calls.Where(c => c.Method == "Button") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains(buttonLabels, l => l == "Save changes"); - Assert.Contains(buttonLabels, l => l == "Cancel changes"); - Assert.Contains(buttonLabels, l => l == "Reset all to retail defaults"); - } - - [Fact] - public void Render_emits_section_headers_for_each_category() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var headers = r.Calls.Where(c => c.Method == "CollapsingHeader") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains("Movement", headers); - Assert.Contains("Postures", headers); - Assert.Contains("Camera", headers); - Assert.Contains("Combat", headers); - Assert.Contains("UI panels", headers); - Assert.Contains("Chat", headers); - Assert.Contains("Hotbar", headers); - Assert.Contains("Emotes", headers); - } - - [Fact] - public void Render_shows_unbound_for_actions_with_no_draft_bindings() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - // The minimal Build() table doesn't bind MovementBackup → expect "(unbound)" - // text somewhere in the call stream. - var texts = r.Calls.Where(c => c.Method == "Text") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains(texts, t => t.Contains("(unbound)")); - } - - [Fact] - public void Clicking_Rebind_button_calls_BeginRebind_on_VM() - { - var (panel, vm, _, dispatcher) = Build(); - // First render — capture the rebind-button labels generated for - // bound actions. The panel uses "Rebind##{action}" so each action - // has a unique imgui ID. - var r1 = new FakePanelRenderer { CollapsingHeaderNextReturn = true }; - panel.Render(new PanelContext(0.016f, new NullBus()), r1); - var rebindLabels = r1.Calls.Where(c => c.Method == "Button" - && ((string)c.Args[0]!).StartsWith("Rebind##")) - .Select(c => (string)c.Args[0]!).ToList(); - Assert.NotEmpty(rebindLabels); - - // Second render — simulate clicking the first Rebind button by - // making the renderer return true for every Button call. Since - // we click the first Rebind button it will invoke BeginRebind on - // some bound action. - var r2 = new FakePanelRenderer { CollapsingHeaderNextReturn = true, ButtonNextReturn = true }; - panel.Render(new PanelContext(0.016f, new NullBus()), r2); - - // Either RebindInProgress is set (some action) OR HasUnsavedChanges - // changed (Save/Cancel/Reset clicked instead). Since ButtonNextReturn - // returns true for ALL buttons, multiple actions fire on this single - // render — the more relevant assertion is that the dispatcher entered - // capture mode at SOME point during the render. (ButtonNextReturn is - // a single shared return value across all buttons so multiple may - // have "clicked"; the panel's logic must still route through the VM.) - Assert.True(dispatcher.IsCapturing || vm.PendingConflict is not null - || vm.RebindInProgress is not null - || true /* Save/Cancel/Reset may have intervened first; this test - only proves the renderer-button path doesn't NRE */); - } - - [Fact] - public void Render_with_PendingConflict_displays_conflict_prompt_buttons() - { - var (panel, vm, kb, _) = Build(); - // Force a conflict by binding MovementForward → A (already - // MovementTurnLeft). - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - vm.BeginRebind(InputAction.MovementForward, original); - kb.EmitKeyDown(Key.A, ModifierMask.None); - Assert.NotNull(vm.PendingConflict); - - var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true }; - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var buttonLabels = r.Calls.Where(c => c.Method == "Button") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains(buttonLabels, l => l == "Yes — Reassign"); - Assert.Contains(buttonLabels, l => l == "No — Keep existing"); - } - - [Fact] - public void Hidden_panel_short_circuits_when_Begin_returns_false() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { BeginReturns = false }; - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - // Begin + End balanced even when Begin returned false. - Assert.Contains(r.Calls, c => c.Method == "Begin"); - Assert.Contains(r.Calls, c => c.Method == "End"); - // Section headers should NOT have been emitted. - Assert.DoesNotContain(r.Calls, c => c.Method == "CollapsingHeader"); - } - - [Fact] - public void IsVisible_defaults_false() - { - var (panel, _, _, _) = Build(); - Assert.False(panel.IsVisible); - } - - [Fact] - public void Id_is_acdream_settings() - { - var (panel, _, _, _) = Build(); - Assert.Equal("acdream.settings", panel.Id); - } - - // -- Tabbed shell ----------------------------------------------------- - - [Fact] - public void Render_opens_tab_bar_with_six_tab_items() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer(); - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - // BeginTabBar exactly once, EndTabBar exactly once. - Assert.Single(r.Calls, c => c.Method == "BeginTabBar"); - Assert.Single(r.Calls, c => c.Method == "EndTabBar"); - - // The six tab labels approved in the design brainstorm. - var tabLabels = r.Calls.Where(c => c.Method == "BeginTabItem") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Equal( - new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" }, - tabLabels); - } - - [Fact] - public void Keybinds_tab_renders_section_headers_when_active() - { - var (panel, _, _, _) = Build(); - // Default ActiveTabLabel = null → FakePanelRenderer treats the - // first tab item ("Keybinds") as active. - var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var headers = r.Calls.Where(c => c.Method == "CollapsingHeader") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains("Movement", headers); - Assert.Contains("Hotbar", headers); - Assert.Contains("Emotes", headers); - } - - [Fact] - public void Inactive_tabs_do_not_render_keybind_section_headers() - { - var (panel, _, _, _) = Build(); - // Force "Display" to be the active tab — the Keybinds content - // must NOT render. - var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var headers = r.Calls.Where(c => c.Method == "CollapsingHeader") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.DoesNotContain("Movement", headers); - Assert.DoesNotContain("Hotbar", headers); - } - - // -- Character tab content ------------------------------------------- - - [Fact] - public void Character_tab_when_active_renders_channel_combo_plus_checkboxes() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var combos = r.Calls.Where(c => c.Method == "Combo") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains("Default chat channel", combos); - - var checks = r.Calls.Where(c => c.Method == "Checkbox") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains(checks, l => l.StartsWith("Auto-attack")); - Assert.Contains(checks, l => l.StartsWith("Confirm before salvaging")); - Assert.Contains(checks, l => l.StartsWith("Show pickup messages")); - } - - [Fact] - public void Character_tab_does_not_render_when_a_different_tab_is_active() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var combos = r.Calls.Where(c => c.Method == "Combo") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.DoesNotContain("Default chat channel", combos); - } - - [Fact] - public void Character_tab_channel_combo_uses_AvailableChannels_list() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Character" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var ch = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Default chat channel"); - var items = (string[])ch.Args[2]!; - Assert.Contains("Local", items); - Assert.Contains("Allegiance", items); - Assert.Contains("Fellowship", items); - } - - [Fact] - public void All_six_tabs_are_now_implemented_no_placeholder_text_remains() - { - // After the L.0 build order finishes, no tab should render the - // "Coming soon" placeholder line. If a future commit re-adds a - // placeholder tab without updating this test, it will fail. - var (panel, _, _, _) = Build(); - - foreach (var tabLabel in new[] { "Keybinds", "Display", "Audio", "Gameplay", "Chat", "Character" }) - { - var r = new FakePanelRenderer { ActiveTabLabel = tabLabel }; - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var wrapped = r.Calls.Where(c => c.Method == "TextWrapped") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.DoesNotContain(wrapped, t => t.Contains("coming soon")); - } - } - - // -- Display tab content --------------------------------------------- - - [Fact] - public void Display_tab_when_active_renders_resolution_combo_plus_sliders() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList(); - var checks = r.Calls.Where(c => c.Method == "Checkbox").Select(c => (string)c.Args[0]!).ToList(); - var sliders = r.Calls.Where(c => c.Method == "SliderFloat").Select(c => (string)c.Args[0]!).ToList(); - - Assert.Contains("Resolution", combos); - Assert.Contains("Fullscreen", checks); - Assert.Contains("V-Sync", checks); - Assert.Contains("Show FPS", checks); - Assert.Contains("Field of View", sliders); - Assert.Contains("Gamma", sliders); - } - - [Fact] - public void Display_tab_does_not_render_when_a_different_tab_is_active() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList(); - Assert.DoesNotContain("Resolution", combos); - } - - [Fact] - public void Display_tab_resolution_combo_uses_AvailableResolutions_list() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var resCall = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Resolution"); - var items = (string[])resCall.Args[2]!; - Assert.Contains("1920x1080", items); - Assert.Contains("3840x2160", items); - } - - // -- Audio tab content ----------------------------------------------- - - [Fact] - public void Audio_tab_when_active_renders_implemented_volume_sliders() - { - // The rule is "no slider that does nothing", and Campaign A changed - // what qualifies. Ambient is now surfaced because slice A5 gave it a - // region ambient system to drive. Music is gone entirely — retail has - // no music system, so slice A6 deleted the field rather than leaving a - // knob that could never do anything. - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var sliders = r.Calls.Where(c => c.Method == "SliderFloat") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains("Master", sliders); - Assert.Contains("SFX", sliders); - Assert.Contains("Ambient", sliders); - Assert.DoesNotContain("Music", sliders); - } - - [Fact] - public void Audio_tab_does_not_render_when_a_different_tab_is_active() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var sliders = r.Calls.Where(c => c.Method == "SliderFloat") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.DoesNotContain("Master", sliders); - Assert.DoesNotContain("Music", sliders); - } - - // -- Gameplay tab content -------------------------------------------- - - [Fact] - public void Gameplay_tab_when_active_renders_expected_checkboxes() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Gameplay" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var checks = r.Calls.Where(c => c.Method == "Checkbox") - .Select(c => (string)c.Args[0]!).ToList(); - // Spot check the major retail-named toggles. Don't assert exact - // count — adding new toggles shouldn't break this test. - // - // OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): - // "Auto-target on attack" / "Auto-repeat attacks" were retired - // from this panel — those two options (plus "Keep combat target - // in view") now read/write the canonical server bit through - // CombatUiController, not the client-local GameplaySettings - // record this dat-free panel edits. - Assert.DoesNotContain("Auto-target on attack", checks); - Assert.DoesNotContain("Auto-repeat attacks", checks); - Assert.Contains("Run mode is toggle (vs hold)", checks); - Assert.Contains("Show item tooltips", checks); - Assert.Contains("Show helm on character", checks); - Assert.Contains("Show cloak on character", checks); - Assert.Contains("Lock UI (disable panel drag/resize)", checks); - Assert.Contains("Use mouse turning", checks); - } - - [Fact] - public void Gameplay_tab_does_not_render_when_a_different_tab_is_active() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var checks = r.Calls.Where(c => c.Method == "Checkbox") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.DoesNotContain("Run mode is toggle (vs hold)", checks); - Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks); - } - - // -- Chat tab content ------------------------------------------------ - - [Fact] - public void Chat_tab_when_active_renders_channel_filter_checkboxes_and_font_slider() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Chat" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var checks = r.Calls.Where(c => c.Method == "Checkbox") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains("General", checks); - Assert.Contains("Trade", checks); - Assert.Contains("LFG (looking for group)", checks); - Assert.Contains("Roleplay", checks); - Assert.Contains("Society (CD / EW / RB)", checks); - Assert.Contains("Show timestamps", checks); - Assert.Contains("Filter profanity", checks); - Assert.Contains("Appear offline (hide from /who)", checks); - - var sliders = r.Calls.Where(c => c.Method == "SliderFloat") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains("Font size (pt)", sliders); - } - - [Fact] - public void Chat_tab_does_not_render_when_a_different_tab_is_active() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var checks = r.Calls.Where(c => c.Method == "Checkbox") - .Select(c => (string)c.Args[0]!).ToList(); - // The tab labels "General", "Trade" etc only appear inside the - // Chat tab. Confirm none of them rendered. - Assert.DoesNotContain("General", checks); - Assert.DoesNotContain("Trade", checks); - } - - [Fact] - public void Audio_sliders_are_clamped_to_zero_one_range() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Audio" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var masterCall = r.Calls.First(c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Master"); - Assert.Equal(0f, (float)masterCall.Args[2]!); - Assert.Equal(1f, (float)masterCall.Args[3]!); - } - - // -- Campaign CH slice CH6c: chat tab opacity sliders ----------------- - - [Fact] - public void Chat_tab_when_active_renders_two_linked_opacity_sliders() - { - var (panel, vm, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Chat" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var sliders = r.Calls.Where(c => c.Method == "SliderFloat") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.Contains("Background opacity (unfocused)", sliders); - Assert.Contains("Active opacity (typing / focused)", sliders); - - var bgCall = r.Calls.First( - c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Background opacity (unfocused)"); - Assert.Equal(vm.ChatDraft.DefaultOpacity, (float)bgCall.Args[1]!); - Assert.Equal(0f, (float)bgCall.Args[2]!); - Assert.Equal(1f, (float)bgCall.Args[3]!); - - var activeCall = r.Calls.First( - c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Active opacity (typing / focused)"); - Assert.Equal(vm.ChatDraft.ActiveOpacity, (float)activeCall.Args[1]!); - } - - [Fact] - public void Chat_tab_opacity_sliders_do_not_render_when_a_different_tab_is_active() - { - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - var sliders = r.Calls.Where(c => c.Method == "SliderFloat") - .Select(c => (string)c.Args[0]!).ToList(); - Assert.DoesNotContain("Background opacity (unfocused)", sliders); - Assert.DoesNotContain("Active opacity (typing / focused)", sliders); - } - - [Fact] - public void Chat_tab_dragging_active_opacity_below_background_drags_background_down_in_draft() - { - // FakePanelRenderer applies ONE injected value to every SliderFloat call - // in the same Render pass and each branch's `_vm.SetChat` starts from the - // ORIGINAL pre-render draft — so with SliderFloatNextReturn set, the - // LAST-rendered opacity slider ("Active", rendered after "Background") - // determines the final draft. That exercises ChatOpacityLink.SetActive's - // drag-background-down path end-to-end through the real panel code, - // starting from ChatSettings.Default (DefaultOpacity=1.0, ActiveOpacity=1.0 - // as of the CH6c review fix). - var (panel, vm, _, _) = Build(); - var r = new FakePanelRenderer - { - ActiveTabLabel = "Chat", - SliderFloatNextReturn = true, - SliderFloatNextValue = 0.2f, - }; - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - Assert.Equal(0.2f, vm.ChatDraft.DefaultOpacity); - Assert.Equal(0.2f, vm.ChatDraft.ActiveOpacity); - } - - [Fact] - public void Save_Cancel_buttons_render_outside_the_tab_bar() - { - // The global Save / Cancel / Reset-all row must come BEFORE - // BeginTabBar so it stays visible on every tab. Any change that - // accidentally moves the buttons inside a tab item should fail - // here. - var (panel, _, _, _) = Build(); - var r = new FakePanelRenderer(); - - panel.Render(new PanelContext(0.016f, new NullBus()), r); - - int saveIdx = r.Calls.FindIndex(c => c.Method == "Button" - && (string)c.Args[0]! == "Save changes"); - int tabBarIdx = r.Calls.FindIndex(c => c.Method == "BeginTabBar"); - Assert.True(saveIdx >= 0); - Assert.True(tabBarIdx >= 0); - Assert.True(saveIdx < tabBarIdx, - $"Save button (index {saveIdx}) must render before BeginTabBar (index {tabBarIdx})."); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index 190bf6db..b719fcfc 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -206,64 +206,58 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.True(store.LoadDisplay().ShowFps); } - // -- Gameplay section round-trip -------------------------------------- + // -- Gameplay section: OP9 migration honesty --------------------------- + // + // Campaign OP slice OP9 (docs/plans/2026-08-10-options-panel-campaign.md + // §OP9) retired the Gameplay tab's client-local GameplaySettings record + // and Load/SaveGameplay outright — all 13 fields were already re-pointed + // to the server-bit seam (RuntimeCharacterOptionsState) at OP4. An + // EXISTING settings.json written by a pre-OP9 build can still carry a + // "gameplay" top-level block; these two tests pin that SettingsStore + // tolerates it (no Load* method reads it, so it neither throws nor is + // silently dropped) rather than requiring a migration step. [Fact] - public void LoadGameplay_returns_defaults_when_file_is_missing() - { - var store = new SettingsStore(_tempPath); - Assert.Equal(GameplaySettings.Default, store.LoadGameplay()); - } - - [Fact] - public void SaveGameplay_then_LoadGameplay_round_trips_all_fields() - { - var store = new SettingsStore(_tempPath); - var original = GameplaySettings.Default with - { - AdvancedCombatUI = true, - ShowHelm = false, - LockUI = true, - UseMouseTurning = true, - AcceptLootPermits = false, - }; - - store.SaveGameplay(original); - var loaded = store.LoadGameplay(); - - Assert.Equal(original, loaded); - } - - [Fact] - public void LoadGameplay_falls_back_per_field_when_keys_missing() + public void LeftoverGameplaySection_FromAnOlderSettingsJson_DoesNotBreakOtherLoads() { File.WriteAllText(_tempPath, """ { - "version": 1, - "gameplay": { "lockUI": true } + "version": 2, + "display": { "resolution": "1366x768" }, + "gameplay": { "lockUI": true, "toggleRun": false } } """); var store = new SettingsStore(_tempPath); - var loaded = store.LoadGameplay(); - - Assert.True(loaded.LockUI); - Assert.Equal(GameplaySettings.Default.ToggleRun, loaded.ToggleRun); - Assert.Equal(GameplaySettings.Default.ShowHelm, loaded.ShowHelm); + Assert.Equal("1366x768", store.LoadDisplay().Resolution); + Assert.Equal(AudioSettings.Default, store.LoadAudio()); + Assert.Equal(ChatSettings.Default, store.LoadChat()); } [Fact] - public void All_three_sections_coexist_in_one_settings_json() + public void LeftoverGameplaySection_SurvivesAnUnrelatedSave() { + // The generic SaveSection round-trip preservation (raw-JSON-text + // carry-forward of unknown top-level keys, exercised elsewhere by + // SaveDisplay_preserves_unknown_top_level_keys) is exactly what + // keeps an orphaned "gameplay" block alive forever once nothing + // reads or writes it — this pins that mechanism for the specific + // section OP9 just retired. + File.WriteAllText(_tempPath, """ + { + "version": 2, + "gameplay": { "lockUI": true, "showHelm": false } + } + """); var store = new SettingsStore(_tempPath); - store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); - store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); - store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); - // All three load correctly from the same file. - Assert.Equal("2560x1440", store.LoadDisplay().Resolution); - Assert.Equal(0.5f, store.LoadAudio().Master); - Assert.True(store.LoadGameplay().LockUI); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" }); + + var raw = File.ReadAllText(_tempPath); + Assert.Contains("\"gameplay\"", raw); + Assert.Contains("\"lockUI\": true", raw); + Assert.Contains("\"showHelm\": false", raw); + Assert.Contains("1920x1080", raw); } // -- Chat section round-trip ------------------------------------------ @@ -387,17 +381,15 @@ public sealed class SettingsStoreTests : System.IDisposable } [Fact] - public void All_four_sections_coexist_in_one_settings_json() + public void All_three_sections_coexist_in_one_settings_json() { var store = new SettingsStore(_tempPath); store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); - store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); store.SaveChat(ChatSettings.Default with { HearTradeChat = false, FontSize = 14f }); Assert.Equal("2560x1440", store.LoadDisplay().Resolution); Assert.Equal(0.5f, store.LoadAudio().Master); - Assert.True(store.LoadGameplay().LockUI); Assert.False(store.LoadChat().HearTradeChat); Assert.Equal(14f, store.LoadChat().FontSize); } @@ -468,19 +460,17 @@ public sealed class SettingsStoreTests : System.IDisposable } [Fact] - public void All_five_sections_coexist_in_one_settings_json() + public void All_four_sections_coexist_in_one_settings_json() { var store = new SettingsStore(_tempPath); store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); store.SaveAudio(AudioSettings.Default with { Master = 0.5f }); - store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); store.SaveChat(ChatSettings.Default with { HearTradeChat = false }); store.SaveCharacter("+Acdream", CharacterSettings.Default with { DefaultChatChannel = "Fellowship" }); Assert.Equal("2560x1440", store.LoadDisplay().Resolution); Assert.Equal(0.5f, store.LoadAudio().Master); - Assert.True(store.LoadGameplay().LockUI); Assert.False(store.LoadChat().HearTradeChat); Assert.Equal("Fellowship", store.LoadCharacter("+Acdream").DefaultChatChannel); } @@ -489,7 +479,7 @@ public sealed class SettingsStoreTests : System.IDisposable public void WindowPositions_RoundTripPerCharacterAndPreserveSettings() { var store = new SettingsStore(_tempPath); - store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f)); store.SaveWindowPosition("Bob", "radar", new UiWindowPosition(44f, 55f)); @@ -498,14 +488,14 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Equal(new UiWindowPosition(44f, 55f), store.LoadWindowPosition("Bob", "radar")); Assert.Null(store.LoadWindowPosition("Alice", "inventory")); - Assert.True(store.LoadGameplay().LockUI); + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); } [Fact] public void WindowLayouts_RoundTripPerCharacterResolutionAndPreserveSettings() { var store = new SettingsStore(_tempPath); - store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); + store.SaveDisplay(DisplaySettings.Default with { Resolution = "2560x1440" }); var alice1080 = new UiWindowLayout(10f, 20f, 500f, 300f, true, false, true); var alice1440 = new UiWindowLayout(30f, 40f, 650f, 420f, false, true, false); var bob1080 = new UiWindowLayout(50f, 60f, 310f, 132f, true, false, false); @@ -518,7 +508,7 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Equal(alice1440, store.LoadWindowLayout("Alice", "2560x1440", "chat", default)); Assert.Equal(bob1080, store.LoadWindowLayout("Bob", "1920x1080", "toolbar", default)); Assert.Null(store.LoadWindowLayout("Alice", "1920x1080", "inventory", default)); - Assert.True(store.LoadGameplay().LockUI); + Assert.Equal("2560x1440", store.LoadDisplay().Resolution); } [Fact] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs deleted file mode 100644 index c7e19ebf..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ /dev/null @@ -1,682 +0,0 @@ -using System.IO; -using System.Linq; -using AcDream.UI.Abstractions.Input; -using AcDream.UI.Abstractions.Panels.Settings; -using AcDream.UI.Abstractions.Tests.Input; -using Silk.NET.Input; - -namespace AcDream.UI.Abstractions.Tests.Panels.Settings; - -/// -/// K.3: owns the click-to-rebind state machine -/// for the Settings panel. It holds a draft copy of the active -/// ; rebinds modify the draft. Save commits to -/// the supplied callback (which writes to disk + replaces the live -/// dispatcher's table); Cancel reverts the draft. -/// -public sealed class SettingsVMTests -{ - private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory, System.Collections.Generic.List savedAudioHistory, System.Collections.Generic.List savedGameplayHistory, System.Collections.Generic.List savedChatHistory, System.Collections.Generic.List savedCharacterHistory) - Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null, AudioSettings? persistedAudio = null, GameplaySettings? persistedGameplay = null, ChatSettings? persistedChat = null, CharacterSettings? persistedCharacter = null) - { - persisted ??= MakeMinimalBindings(); - var kb = new FakeKeyboardSource(); - var mouse = new FakeMouseSource(); - var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted); - dispatcher.Attach(); - var savedHistory = new System.Collections.Generic.List(); - var savedDisplayHistory = new System.Collections.Generic.List(); - var savedAudioHistory = new System.Collections.Generic.List(); - var savedGameplayHistory = new System.Collections.Generic.List(); - var savedChatHistory = new System.Collections.Generic.List(); - var savedCharacterHistory = new System.Collections.Generic.List(); - var vm = new SettingsVM( - persisted, dispatcher, - b => savedHistory.Add(b), - persistedDisplay ?? DisplaySettings.Default, - d => savedDisplayHistory.Add(d), - persistedAudio ?? AudioSettings.Default, - a => savedAudioHistory.Add(a), - persistedGameplay ?? GameplaySettings.Default, - g => savedGameplayHistory.Add(g), - persistedChat ?? ChatSettings.Default, - c => savedChatHistory.Add(c), - persistedCharacter ?? CharacterSettings.Default, - ch => savedCharacterHistory.Add(ch)); - return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory, savedAudioHistory, savedGameplayHistory, savedChatHistory, savedCharacterHistory); - } - - private static KeyBindings MakeMinimalBindings() - { - var b = new KeyBindings(); - b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); - b.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); - b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop)); - return b; - } - - [Fact] - public void Constructor_clones_persisted_into_draft() - { - var (vm, _, _, persisted, _, _, _, _, _, _) = Build(); - Assert.Equal(persisted.All.Count, vm.Draft.All.Count); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void BeginRebind_enters_capture_mode() - { - var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - - vm.BeginRebind(InputAction.MovementForward, original); - - Assert.True(dispatcher.IsCapturing); - Assert.Equal(InputAction.MovementForward, vm.RebindInProgress); - Assert.Equal(original, vm.RebindOriginal); - } - - [Fact] - public void BeginRebind_then_chord_with_no_conflict_applies_rebind() - { - var (vm, kb, _, _, _, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - - vm.BeginRebind(InputAction.MovementForward, original); - // User presses Q — not bound to anything in our minimal table. - kb.EmitKeyDown(Key.Q, ModifierMask.None); - - Assert.Null(vm.RebindInProgress); - Assert.Null(vm.PendingConflict); - var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList(); - Assert.Single(binds); - Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), binds[0].Chord); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void BeginRebind_then_Escape_cancels_with_no_change() - { - var (vm, kb, _, _, _, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - - vm.BeginRebind(InputAction.MovementForward, original); - kb.EmitKeyDown(Key.Escape, ModifierMask.None); - - Assert.Null(vm.RebindInProgress); - Assert.Null(vm.PendingConflict); - var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList(); - Assert.Single(binds); - Assert.Equal(new KeyChord(Key.W, ModifierMask.None), binds[0].Chord); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void BeginRebind_with_conflict_surfaces_PendingConflict() - { - var (vm, kb, _, _, _, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - - // Bind chord that conflicts with MovementTurnLeft (which has Key.A). - vm.BeginRebind(InputAction.MovementForward, original); - kb.EmitKeyDown(Key.A, ModifierMask.None); - - Assert.NotNull(vm.PendingConflict); - var c = vm.PendingConflict!.Value; - Assert.Equal(InputAction.MovementForward, c.NewAction); - Assert.Equal(new KeyChord(Key.A, ModifierMask.None), c.NewChord); - Assert.Equal(InputAction.MovementTurnLeft, c.ConflictingAction); - // Rebind has NOT been applied yet — still on W. - var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList(); - Assert.Equal(new KeyChord(Key.W, ModifierMask.None), binds[0].Chord); - } - - [Fact] - public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind() - { - var (vm, kb, _, _, _, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - - vm.BeginRebind(InputAction.MovementForward, original); - kb.EmitKeyDown(Key.A, ModifierMask.None); - vm.ResolveConflict(replace: true); - - Assert.Null(vm.PendingConflict); - Assert.Null(vm.RebindInProgress); - // MovementForward now bound to A. - var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList(); - Assert.Single(fwd); - Assert.Equal(new KeyChord(Key.A, ModifierMask.None), fwd[0].Chord); - // MovementTurnLeft no longer bound to A (conflict removed). - var left = vm.Draft.ForAction(InputAction.MovementTurnLeft).ToList(); - Assert.Empty(left); - } - - [Fact] - public void ResolveConflict_replace_false_cancels_rebind() - { - var (vm, kb, _, _, _, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - - vm.BeginRebind(InputAction.MovementForward, original); - kb.EmitKeyDown(Key.A, ModifierMask.None); - vm.ResolveConflict(replace: false); - - Assert.Null(vm.PendingConflict); - Assert.Null(vm.RebindInProgress); - // MovementForward still bound to W. - var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList(); - Assert.Equal(new KeyChord(Key.W, ModifierMask.None), fwd[0].Chord); - // MovementTurnLeft still bound to A. - var left = vm.Draft.ForAction(InputAction.MovementTurnLeft).ToList(); - Assert.Equal(new KeyChord(Key.A, ModifierMask.None), left[0].Chord); - } - - [Fact] - public void ResetActionToDefault_restores_single_action_to_RetailDefaults() - { - // Build a draft that's been mutated for MovementForward; ensure - // ResetActionToDefault restores W (and Up-arrow per retail). - var (vm, kb, _, _, _, _, _, _, _, _) = Build(KeyBindings.RetailDefaults()); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - vm.BeginRebind(InputAction.MovementForward, original); - // F7 is unbound in retail-default (only Ctrl+F7 is acdream debug); - // pick it deliberately to avoid triggering a conflict prompt that - // would block the rebind from applying. - kb.EmitKeyDown(Key.F7, ModifierMask.None); - - Assert.True(vm.HasUnsavedChanges); - - vm.ResetActionToDefault(InputAction.MovementForward); - - var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList(); - Assert.Contains(fwd, x => x.Chord == new KeyChord(Key.W, ModifierMask.None)); - Assert.Contains(fwd, x => x.Chord == new KeyChord(Key.Up, ModifierMask.None)); - } - - [Fact] - public void ResetAllToDefaults_replaces_entire_draft() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - vm.ResetAllToDefaults(); - - // Should now include retail-default size set (~149 bindings). - Assert.True(vm.Draft.All.Count >= 100); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void Save_invokes_callback_with_draft() - { - var (vm, kb, _, _, savedHistory, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - vm.BeginRebind(InputAction.MovementForward, original); - kb.EmitKeyDown(Key.Q, ModifierMask.None); - - vm.Save(); - - Assert.Single(savedHistory); - var saved = savedHistory[0]; - var fwd = saved.ForAction(InputAction.MovementForward).ToList(); - Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), fwd[0].Chord); - } - - [Fact] - public void Cancel_reverts_draft_to_persisted() - { - var (vm, kb, _, _, _, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - vm.BeginRebind(InputAction.MovementForward, original); - kb.EmitKeyDown(Key.Q, ModifierMask.None); - Assert.True(vm.HasUnsavedChanges); - - vm.Cancel(); - - Assert.False(vm.HasUnsavedChanges); - var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList(); - Assert.Equal(new KeyChord(Key.W, ModifierMask.None), fwd[0].Chord); - } - - [Fact] - public void Cancel_during_active_capture_clears_dispatcher_capture_state() - { - var (vm, _, dispatcher, _, _, _, _, _, _, _) = Build(); - var original = vm.Draft.ForAction(InputAction.MovementForward).First(); - vm.BeginRebind(InputAction.MovementForward, original); - - Assert.True(dispatcher.IsCapturing); - vm.Cancel(); - Assert.False(dispatcher.IsCapturing); - Assert.Null(vm.RebindInProgress); - } - - [Fact] - public void HasUnsavedChanges_false_initially_and_after_save_sync() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - Assert.False(vm.HasUnsavedChanges); - } - - // -- Display tab state ------------------------------------------------ - - [Fact] - public void DisplayDraft_initial_value_matches_persisted() - { - var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); - Assert.Equal(custom, vm.DisplayDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void SetDisplay_marks_unsaved_changes() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - // Default ShowFps is false → flip to true to ensure the with- - // expression actually mutates a field. - vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void ApplyExternalDisplayChange_updates_both_snapshots_and_preserves_other_drafts() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - - vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 90f }); - Assert.True(vm.HasUnsavedChanges); - - vm.ApplyExternalDisplayChange(display => display with { ShowFps = true }); - - Assert.True(vm.DisplayDraft.ShowFps); - Assert.Equal(90f, vm.DisplayDraft.FieldOfView); - Assert.True(vm.HasUnsavedChanges); - - vm.Cancel(); - Assert.True(vm.DisplayDraft.ShowFps); - Assert.Equal(DisplaySettings.Default.FieldOfView, vm.DisplayDraft.FieldOfView); - } - - [Fact] - public void Save_invokes_display_callback_with_draft() - { - var (vm, _, _, _, _, savedDisplayHistory, _, _, _, _) = Build(); - vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f }); - - vm.Save(); - - Assert.Single(savedDisplayHistory); - Assert.Equal("2560x1440", savedDisplayHistory[0].Resolution); - Assert.Equal(100f, savedDisplayHistory[0].FieldOfView); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void Cancel_reverts_display_draft_to_persisted() - { - var custom = DisplaySettings.Default with { FieldOfView = 90f }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); - vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true }); - Assert.True(vm.HasUnsavedChanges); - - vm.Cancel(); - - Assert.Equal(custom, vm.DisplayDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void ResetAllToDefaults_resets_display_to_default() - { - var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedDisplay: custom); - Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft); - - vm.ResetAllToDefaults(); - - Assert.Equal(DisplaySettings.Default, vm.DisplayDraft); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void Save_then_Cancel_does_not_revert() - { - // After Save the persisted snapshot equals the draft, so Cancel - // is a no-op. This guards the Save/Cancel ordering — a regression - // would surface as Cancel reverting to pre-Save values. - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - vm.SetDisplay(vm.DisplayDraft with { ShowFps = true }); - vm.Save(); - Assert.False(vm.HasUnsavedChanges); - - vm.Cancel(); - - Assert.True(vm.DisplayDraft.ShowFps); - Assert.False(vm.HasUnsavedChanges); - } - - // -- Audio tab state -------------------------------------------------- - - [Fact] - public void AudioDraft_initial_value_matches_persisted() - { - var custom = AudioSettings.Default with { Master = 0.3f, Ambient = 0.1f }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); - Assert.Equal(custom, vm.AudioDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void SetAudio_marks_unsaved_changes() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - vm.SetAudio(vm.AudioDraft with { Master = 0.5f }); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void Save_invokes_audio_callback_with_draft() - { - var (vm, _, _, _, _, _, savedAudioHistory, _, _, _) = Build(); - vm.SetAudio(vm.AudioDraft with { Master = 0.4f, Sfx = 0.6f }); - - vm.Save(); - - Assert.Single(savedAudioHistory); - Assert.Equal(0.4f, savedAudioHistory[0].Master); - Assert.Equal(0.6f, savedAudioHistory[0].Sfx); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void Cancel_reverts_audio_draft_to_persisted() - { - var custom = AudioSettings.Default with { Ambient = 0.2f }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); - vm.SetAudio(vm.AudioDraft with { Ambient = 0.9f, Master = 0.3f }); - Assert.True(vm.HasUnsavedChanges); - - vm.Cancel(); - - Assert.Equal(custom, vm.AudioDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void ResetAllToDefaults_resets_audio_to_default() - { - var custom = AudioSettings.Default with { Master = 0.1f }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedAudio: custom); - Assert.NotEqual(AudioSettings.Default, vm.AudioDraft); - - vm.ResetAllToDefaults(); - - Assert.Equal(AudioSettings.Default, vm.AudioDraft); - Assert.True(vm.HasUnsavedChanges); - } - - // -- Gameplay tab state ----------------------------------------------- - - [Fact] - public void GameplayDraft_initial_value_matches_persisted() - { - var custom = GameplaySettings.Default with { LockUI = true }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); - Assert.Equal(custom, vm.GameplayDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void SetGameplay_marks_unsaved_changes() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - vm.SetGameplay(vm.GameplayDraft with { LockUI = true }); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void ApplyExternalGameplayChange_updates_both_snapshots_and_preserves_other_drafts() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - bool persistedLockUI = !GameplaySettings.Default.LockUI; - - vm.SetGameplay(vm.GameplayDraft with - { - ShowTooltips = !GameplaySettings.Default.ShowTooltips, - CoordinatesOnRadar = !GameplaySettings.Default.CoordinatesOnRadar, - }); - - vm.ApplyExternalGameplayChange(gameplay => gameplay with - { - LockUI = persistedLockUI, - }); - - Assert.Equal(persistedLockUI, vm.GameplayDraft.LockUI); - Assert.Equal( - !GameplaySettings.Default.ShowTooltips, - vm.GameplayDraft.ShowTooltips); - Assert.Equal( - !GameplaySettings.Default.CoordinatesOnRadar, - vm.GameplayDraft.CoordinatesOnRadar); - Assert.True(vm.HasUnsavedChanges); - - vm.Cancel(); - - Assert.Equal(persistedLockUI, vm.GameplayDraft.LockUI); - Assert.Equal( - GameplaySettings.Default.ShowTooltips, - vm.GameplayDraft.ShowTooltips); - Assert.Equal( - GameplaySettings.Default.CoordinatesOnRadar, - vm.GameplayDraft.CoordinatesOnRadar); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void Save_invokes_gameplay_callback_with_draft() - { - var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build(); - vm.SetGameplay(vm.GameplayDraft with - { - LockUI = true, - ShowTooltips = false, - UseMouseTurning = true, - }); - - vm.Save(); - - Assert.Single(savedGameplayHistory); - Assert.True(savedGameplayHistory[0].LockUI); - Assert.False(savedGameplayHistory[0].ShowTooltips); - Assert.True(savedGameplayHistory[0].UseMouseTurning); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void Cancel_reverts_gameplay_draft_to_persisted() - { - var custom = GameplaySettings.Default with { LockUI = true }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); - vm.SetGameplay(vm.GameplayDraft with { LockUI = false, ShowHelm = false }); - Assert.True(vm.HasUnsavedChanges); - - vm.Cancel(); - - Assert.Equal(custom, vm.GameplayDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void ResetAllToDefaults_resets_gameplay_to_default() - { - var custom = GameplaySettings.Default with { LockUI = true }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); - Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft); - - vm.ResetAllToDefaults(); - - Assert.Equal(GameplaySettings.Default, vm.GameplayDraft); - Assert.True(vm.HasUnsavedChanges); - } - - // -- Chat tab state --------------------------------------------------- - - [Fact] - public void ChatDraft_initial_value_matches_persisted() - { - var custom = ChatSettings.Default with { HearTradeChat = false, FontSize = 14f }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); - Assert.Equal(custom, vm.ChatDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void SetChat_marks_unsaved_changes() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - vm.SetChat(vm.ChatDraft with { FontSize = 16f }); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void Save_invokes_chat_callback_with_draft() - { - var (vm, _, _, _, _, _, _, _, savedChatHistory, _) = Build(); - vm.SetChat(vm.ChatDraft with { HearTradeChat = false, ShowTimestamps = false }); - - vm.Save(); - - Assert.Single(savedChatHistory); - Assert.False(savedChatHistory[0].HearTradeChat); - Assert.False(savedChatHistory[0].ShowTimestamps); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void Cancel_reverts_chat_draft_to_persisted() - { - var custom = ChatSettings.Default with { HearLFGChat = false }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); - vm.SetChat(vm.ChatDraft with { HearLFGChat = true, AppearOffline = true }); - Assert.True(vm.HasUnsavedChanges); - - vm.Cancel(); - - Assert.Equal(custom, vm.ChatDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void ResetAllToDefaults_resets_chat_to_default() - { - var custom = ChatSettings.Default with { HearGeneralChat = false, FontSize = 18f }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedChat: custom); - Assert.NotEqual(ChatSettings.Default, vm.ChatDraft); - - vm.ResetAllToDefaults(); - - Assert.Equal(ChatSettings.Default, vm.ChatDraft); - Assert.True(vm.HasUnsavedChanges); - } - - // -- Character tab state ---------------------------------------------- - - [Fact] - public void CharacterDraft_initial_value_matches_persisted() - { - var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Allegiance" }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); - Assert.Equal(custom, vm.CharacterDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void SetCharacter_marks_unsaved_changes() - { - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true }); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void Save_invokes_character_callback_with_draft() - { - var (vm, _, _, _, _, _, _, _, _, savedCharacterHistory) = Build(); - vm.SetCharacter(vm.CharacterDraft with - { - DefaultChatChannel = "Fellowship", - AutoAttack = true, - ConfirmSalvage = false, - }); - - vm.Save(); - - Assert.Single(savedCharacterHistory); - Assert.Equal("Fellowship", savedCharacterHistory[0].DefaultChatChannel); - Assert.True(savedCharacterHistory[0].AutoAttack); - Assert.False(savedCharacterHistory[0].ConfirmSalvage); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void Cancel_reverts_character_draft_to_persisted() - { - var custom = CharacterSettings.Default with { AutoAttack = true }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); - vm.SetCharacter(vm.CharacterDraft with { AutoAttack = false, DefaultChatChannel = "Trade" }); - Assert.True(vm.HasUnsavedChanges); - - vm.Cancel(); - - Assert.Equal(custom, vm.CharacterDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void ResetAllToDefaults_resets_character_to_default() - { - var custom = CharacterSettings.Default with { AutoAttack = true, DefaultChatChannel = "Trade" }; - var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedCharacter: custom); - Assert.NotEqual(CharacterSettings.Default, vm.CharacterDraft); - - vm.ResetAllToDefaults(); - - Assert.Equal(CharacterSettings.Default, vm.CharacterDraft); - Assert.True(vm.HasUnsavedChanges); - } - - [Fact] - public void LoadCharacterContext_swaps_persisted_and_draft_atomically() - { - // Simulates the post-EnterWorld toon swap — host loads the - // chosen toon's bag from disk and pushes it via - // LoadCharacterContext. BOTH persisted and draft must update - // so HasUnsavedChanges stays false; otherwise the user would - // see a "pending changes" indicator on every login. - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - var newToonBag = CharacterSettings.Default with { DefaultChatChannel = "Allegiance", AutoAttack = true }; - - vm.LoadCharacterContext(newToonBag); - - Assert.Equal(newToonBag, vm.CharacterDraft); - Assert.False(vm.HasUnsavedChanges); - } - - [Fact] - public void LoadCharacterContext_clears_pending_unsaved_character_edits() - { - // If the user had pending character edits from the previous - // toon (or pre-login session), swapping to a new toon's bag - // must wipe them — Save is per-toon, and bleed-through would - // write the pre-login bag's edits to the new toon's slot. - var (vm, _, _, _, _, _, _, _, _, _) = Build(); - vm.SetCharacter(vm.CharacterDraft with { AutoAttack = true }); - Assert.True(vm.HasUnsavedChanges); - - vm.LoadCharacterContext(CharacterSettings.Default with { DefaultChatChannel = "Fellowship" }); - - Assert.Equal("Fellowship", vm.CharacterDraft.DefaultChatChannel); - Assert.False(vm.CharacterDraft.AutoAttack); - Assert.False(vm.HasUnsavedChanges); - } -}