diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 37147479..3a21307c 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) — 137 active rows (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) — 140 active rows (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 — the Config tab's nine Graphics/Rendering-Quality-family rows plus Screen Brightness are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; AP-196 filed 2026-08-11 at the OP4 review-fix round (MUST-FIX 3 / blast M2) — the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the GameplaySettings store retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -187,6 +187,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-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.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author ten rows with no acdream renderer consumer: `Render_ScreenBrightness` (reuses the existing inert `DisplaySettings.Gamma` field — no gamma-correction pass exists), `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 nine dead per-feature render knobs (plus reusing one already-dead field) 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.** 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`/`InterfaceDisabled`/`InterfaceVolume`/`PlaySoundOnlyWhenActive` fields. The Sound and Ambient trios' own toggle+slider pairs are NOT covered by this row — `SfxDisabled`/`AmbientDisabled`/`Sfx`/`Ambient` are LIVE (`RuntimeSettingsController.SaveAudio` now pushes into `OpenAlAudioEngine` on every change, gating the effective volume to zero when disabled). | `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) | +| AP-200 | **Filed 2026-08-11 at Campaign OP slice OP6.** The Config tab's "UI Options" section authors `UI_ChatFontFace`/`UI_ChatFontSize` menu rows (retail Windows TrueType face name / a Tiny-Small-Medium-Large-XLarge size-tier enum). These are DELIBERATELY separate NEW fields (`ChatSettings.ChatFontFace`/`ChatFontSizeIndex`) rather than reusing the existing LIVE `ChatSettings.FontSize` (a 10..20pt float acdream's chat panel already renders with) — there is no verified index-to-point mapping from retail's five-tier enum to that float range, and acdream's text rendering has no arbitrary system-font-face swap capability (DAT-baked/bitmap fonts only, not OS TrueType files). Store-only round-trip; `FontSize` is untouched by these two rows. | `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindUiSection`) | Inventing a size-index-to-point mapping without retail evidence would risk silently overwriting `FontSize`'s own already-live, user-visible behaviour with a guessed value; keeping the two concepts separate is the honest choice until a byte-verified mapping (or a font-face-swap capability) exists. | A user who changes either Config-tab font control sees no chat-panel rendering change; the SEPARATE, pre-existing font-size control (wherever acdream currently exposes `ChatSettings.FontSize`) remains the only live one. | `gmClient::InitUIPreferences @0x0040387b`/`@0x00403a1a` (`AttachPreference(&UI_ChatFontFace, ...)`/`&UI_ChatFontSize`, `SetEnumChoices` choice arrays "Arial"/"Tiny".."XLarge") | | AP-172 | **Filed 2026-08-08 (#354 fix — spell-bar drag reorder).** Retail removes a lifted favorite from `PlayerModule` (+ UI list + wire) the instant a drag starts and the remaining shortcuts visibly slide left to close the gap for the rest of the gesture (`RecvNotice_ItemListBeginDrag` → `RemoveSpellFromMenu`, live). acdream's controller performs the same PlayerModule/wire removal at drag-begin but DEFERS the whole favorite-list's visual rebuild until the drag concludes (drop or off-bar release) — the lifted cell's icon stays visible in its old slot and siblings do not slide until release, instead of reflowing continuously through the gesture. `DropFavorite` compensates by porting retail's own `AddFavorite`-side index adjustment (decrement the target index by one when the lifted item's original index was before it) against the now-intentionally-stale sibling numbering, so the FINAL landed position is byte-identical to retail's in every case exercised (`DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire`). **NARROWED + CORRECTED 2026-08-08 (drop-ring change).** Correction: this row originally claimed empty-tail-slot drops were "already-live-count-relative and are untouched" — false. The #354 `-1` adjustment sat inside `DropFavorite`, which the empty-cell path also calls, so its live-count-clamped (post-lift-numbered) index was double-corrected: lifting a non-last favorite onto the empty tail landed it second-to-last instead of last. `FavoriteDropIndex` is now THE one landing computation and applies retail's rule exactly — the `-1` is gated on the lifted spell's pre-lift-numbered removal site (retail's `RemoveSpellFromMenu`-return-gated decrement @0x004C7157), which for a live-numbered empty-tail target is retail's `RemoveSpellFromMenu == -1` no-adjustment case (test `SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd` fails against the double-correcting code). Narrowing: the mid-drag presentation now includes retail's authored drag-over Accept ring — `SpellCastSubMenu::OnItemListDragOver` @0x004C5990 setting the per-cell authored DragAccept child (element 0x1000045A, `UIElement_UIItem::PostInit` @0x004E1870) to `ItemSlot_DragOver_Accept` (UIStateId 0x10000040 → authored art 0x060011F9) on the hovered cell while a spell drag is live, cleared on leave/drop (`UiCatalogSlot.DragOverAcceptance` → `UiItemSlot.DrawDragAcceptOverlay`), with the ring and the drop sharing `FavoriteDropIndex` so the ring cannot promise a different landing. | `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`BeginFavoriteDrag`, `EndFavoriteDrag`, `DropFavorite`, `Tick` — the `_favoriteDragActive` gate) | `UiRoot`'s subtree-removal safety net (`ClearSubtreeOwnership`, `UiRoot.cs:240-247`) cancels any in-flight drag whose source widget is destroyed, and `Rebuild()` tears down and recreates every cell in the list (`UiItemList.Flush` → `RemoveChild` per cell) rather than incrementally diffing. Left unguarded, the press-time removal's `SpellbookChanged` event would let the very next per-frame `Tick()` (production drives this unconditionally via `RetailUiRuntime.Tick`) destroy the cell driving the gesture and silently cancel the reorder before the user could complete the drop — this was the reported bug. Deferring the rebuild for the gesture's duration is the minimal fix that does not touch the shared `UiRoot` drag machinery every other panel (toolbar/inventory/vendor/paperdoll) also depends on. | A future rewrite that makes `Rebuild()` an incremental per-cell diff (add/remove/reflow one cell) instead of flush-and-recreate-all would make this deferral unnecessary and should retire this row along with it — until then, a player watching their OWN spell bar mid-drag sees the vacated slot's icon linger and siblings snap into place only on release, rather than reflowing live as retail does — and one ring consequence of that frozen bar: when dragging rightward past the source, the Accept ring's SCREEN slot sits one cell right of where the icon finally lands (retail's live-reflowed bar makes them coincide); the ring is on the correct CELL in both — the spell lands immediately before that cell's spell, retail's exact insert-before semantic. No effect on final position, the wire pair sent, or any other panel; cross-window spellbook→favorite drops are live-count-relative and untouched (the empty-tail claim this sentence used to carry was corrected 2026-08-08 — see the Divergence column). | `gmSpellcastingUI::RecvNotice_ItemListBeginDrag` @0x004C7360 (`SpellCastSubMenu::RemoveSpellFromMenu`, immediate live-list removal at lift); `SpellCastSubMenu::AddFavorite` @0x004C7060 (`RemoveSpellFromMenu`'s return value gating the `-1`-if-lifted-before-target `m_numSpells` adjustment before `ItemList_InsertSpellShortcut`); `PlayerModule::AddSpellFavorite` @0x005D43E0 (`InsertPos`); `PlayerModule::RemoveSpellFavorite` @0x005D4910 | | AP-160 | **Filed 2026-08-07, Slice 5.3 (vendor browse lifecycle). CORRECTED AND EXTENDED 2026-08-07 at the Slice 5.3 review corrections (fixes 4/5).** **Correction (fix 4):** this row's own Retail-oracle citation originally grouped `WorldObject_Use.cs:50,57` under the SAME citation as `Vendor.CheckClose`/`GetCylinderDistance`, which read as if the `wo.UseRadius ?? 0.6f` fallback lived inside the close watcher. It does not: `WorldObject_Use.cs:50,57` is `WorldObject.IsWithinUseRadiusOf`, the APPROACH check ("how close you need to be to open the shop") — a wholly different method from `Vendor.CheckClose`, which reads `UseRadius` directly with no fallback of its own (`UseRadius` is `float?`; a nullable comparison against a null right operand is always `false`, so `CheckClose` never closes at all on an unauthored radius). `EnforceRange`'s own code comment carried the same mis-attribution and, worse, actually APPLIED that mis-borrowed 0.6f as its fallback; it now passes the raw authored `UseRadius` with no fallback of any kind (0 when absent/unauthored, matching retail's own memset-zero `PublicWeenieDesc::_useRadius` default — a plain `float` field, `acclient.h:37181`, no sentinel). Retail's own behavior for a radius-0 handler is exactly this: close on the very first nonzero-distance check. **Extension (fix 5):** the watcher reads the SERVER-ECHOED ACCEPTED position snapshot (`RuntimeEntityRecord.Snapshot.Position`), sampled once per advanced frame at the post-network-command-phase, not retail's continuous live-pose push (retail's own client simulates and renders every entity's pose every frame; `CPlayerSystem`'s range handler reads that live pose, never a periodically-echoed one). Between accepted-position updates the watcher's distance measurement is therefore up to one update-interval stale. The one BLIND WINDOW this staleness could open into a wrong in/out-of-range verdict — an in-session portal/teleport, where the player's and vendor's position snapshots can briefly sit in DIFFERENT landblock coordinate frames mid-transit — is closed unconditionally by this same review's fix 1b (`RuntimeWorldTransitState.HasPendingTeleportStart`/`IsTeleportActive` short-circuit the whole distance computation before it runs, closing the session instead of measuring across the transit), so the staleness itself never reaches that particular failure mode; it remains recorded here as a standing precision gap for the window fix 1b does NOT cover (ordinary out-of-transit movement between the same-generation position updates a slow network tick can leave briefly stale). **Original text:** The client-local vendor-panel distance watcher closes on PLAIN 3D center-to-center distance instead of retail/ACE's CYLINDER-GAP distance (both objects' own collision radius and height subtracted from the center distance before comparing to `UseRadius`). Retail: `gmVendorUI::OpenVendor` registers `CPlayerSystem::RegisterObjectRangeHandler` keyed to the vendor's own `PublicWeenieDesc._useRadius`; ACE's server-side belt-and-suspenders `Vendor.CheckClose` closes on `GetCylinderDistance(lastPlayer) > UseRadius`, i.e. `Position::cylinder_distance`/`Physics.Common.Position.CylinderDistance` with each side's real `GetRadius()`/`GetHeight()`. **NARROWED 2026-08-08 (vendor-verify gate): the watcher now measures retail's cylinder-gap via the ResolveObjectTableHost radii — the plain-center shortcut was self-closing sessions inside the walk-to-use acceptance band (opened at 4.29 m center vs authored radius 3, closed same frame). Residuals: heights pass 0, unresolvable hosts degrade to center distance (close-early only).** | `src/AcDream.Runtime/Gameplay/RuntimeVendorRangeQuery.cs` (`EnforceRange`) | `AcDream.Runtime` does not resolve a live per-entity collision radius/height for an arbitrary NPC outside the App-layer's Setup-cylinder resolver (`WorldSelectionQuery`'s `_setupCylinder`, App-only — out of Runtime's reach per the Core-structure rules, and `PhysicsBody`/`RuntimeEntityRecord` carry no radius/height field). Plain center distance is a well-defined, non-degenerate substitute (using `ObjectRangeMath.ObjectsInRange`'s existing `useRadii: false` branch rather than inventing a new metric) for a CLIENT-LOCAL UI convenience that never touches the wire or any authoritative state — closing the panel is not gated by, nor gates, anything server-visible. Reading the accepted-position snapshot rather than a continuously-integrated live pose is the same "Runtime has no live render-side pose, only the last accepted wire snapshot" constraint every other Runtime-side distance query in this codebase already accepts. | The panel can close up to (player radius + vendor radius) sooner than exact retail — typically well under a meter for a two-legged NPC — so a player standing exactly at the boundary of a large-radius vendor's `UseRadius` may see the panel close slightly earlier than retail would. No effect on any transaction, wire message, or authoritative state (Slice 6's buy/sell owns those). Retiring the cylinder-gap half requires a Runtime-owned per-entity collision radius/height source, which does not exist today; retiring the staleness half requires a continuously-updated live-pose source Runtime does not keep either. | `CPlayerSystem::RegisterObjectRangeHandler` pc:203677/0x004C4C34; `gmVendorUI::OnObjectRangeExit` pc:199486/0x004C02F0; ACE `Vendor.CheckClose`/`GetCylinderDistance` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367`) — a SEPARATE method, `WorldObject.IsWithinUseRadiusOf` (`WorldObject_Use.cs:44-52`), owns the unrelated `?? 0.6f` approach-check fallback; `acclient.h:37181` (`float _useRadius`, plain memset-zero field, no sentinel); `docs/research/2026-08-08-slice5-vendor-browse-research.md` §A.3/§B.1/§B.2 | | AP-141 | **Filed 2026-08-04, C4 route 5 (projectile authoritative placement); NARROWED 2026-08-04 at the round-2 delta review (B1/B2) — the far-branch clause was factually wrong for the adopted-body case and is corrected below.** Three related projectile-only shapes, all pinned by design (D-P4) rather than ported: (a) the near-`Interpolate` disposition is a NO-OP for a live missile, where retail would lazily build interpolation machinery (`InterpolateTo` @0x005163AF) for it; (b) the post-operation `ConstrainTo` @0x00454272 (`MakePositionManager` @0x00510523 then `PositionManager::ConstrainTo`) is never ARMED for a projectile — retail's single arming site has no kind test, so retail WOULD build a `PositionManager` on demand and arm a missile's leash on any nonzero `MoveOrTeleport` return; acdream never arms it on any disposition, including the adopted-body case (whose PRE-EXISTING leash the teleport/far branches now un-arm or clear queue state for, but never RE-anchor, per retail's post-operation `ConstrainTo`); (c) a null-classified or `Rejected*` accepted Position for a missile is swallowed (write nothing) rather than caught up through any remote-shaped policy. | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`ApplyAcceptedProjectilePosition`) | acdream deliberately does not construct an `EntityPhysicsHost`/`PositionManager`/`InterpolationManager` chain for a ballistic body — the route-5b split the C4 route 5 contract rejected. The context that makes this safe rather than merely convenient: ACE never sends `UpdatePosition` for a missile (`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:333-334`, `SendUpdatePosition()` commented out inside the `PhysicsState.Missile` branch at `:265`) — every half of this row is deterministic-test-gated only, never exercised against a real server. **The far branch's `StopInterpolating` skip is retail-faithful ONLY for a BARE missile** (no `RemoteMotion` — retail's own `position_manager != 0` guard @0x005163C9 skips it for a never-interpolated object, so acdream's skip is faithful by consequence there). For the ADOPTED-BODY case (`TryBind`'s shared-body branch: an ordinary remote whose Missile bit was set by a later State packet, still carrying its `RemoteMotion`), retail's guard IS satisfied and retail WOULD clear the queue — acdream now ports this (`route.StopInterpolating && record.RemoteMotion is RemoteMotion adopted → adopted.Interp.Clear()`), matching the teleport branch's equivalent `StopInterpolating` action inside `teleport_hook`. What remains divergent for the adopted case is the post-operation `ConstrainTo` re-anchor @0x00454272 — retail re-anchors an existing leash at the just-updated position on every nonzero return; acdream never arms/re-anchors it on any projectile disposition (clause (b)). | A future change that DOES give projectiles a `PositionManager` (or a headless/no-window remote-motion consumer that expects one) must re-decide this row rather than silently building the machinery ad hoc; until then, a live missile never shows an ARMED constraint leash and never catches up via the near/UnroutedCatchUp policy — both unreachable in play. An adopted-body missile's INHERITED leash (armed before it became a missile) is un-armed by the teleport hook, has its queue cleared by both teleport and far, but is never re-anchored at the new position by either — its brake accumulator (`ConstraintPosOffset`) is not reset to zero at each accepted Position the way retail's @0x00454272 re-anchor does. **Correction, round 3 (2026-08-04): the round-2 wording here — that a stale leash "would drag the body toward a stale anchor" — was wrong and is retracted.** `ConstraintManager.ConstraintPos` is write-only in both retail and the port (never read by `AdjustOffset`), and `ConstraintManager::adjust_offset` @0x00556180 only tapers or zeroes an already-composed per-tick offset while `InContact` — a leash brakes motion the interp/sticky chain already produced; it has no mechanism to move anything toward the anchor. The real residual is confined to one tick of un-reset brake accumulator, contact-gated, and it cannot move an airborne far-snapped missile at all (the clamp branch does not run while airborne). | `CPhysicsObj::MoveOrTeleport` 0x00516330 (`InterpolateTo` @0x005163AF, `IsMovingTo` @0x0050EB10 returning 0 without a `MovementManager`; far branch `StopInterpolating` @0x005163C9-@0x005163CB); `SmartBox::HandleReceivedPosition` 0x00453FD0 (`ConstrainTo` arming site @0x00454272); `CPhysicsObj::ConstrainTo` 0x00510520 (`MakePositionManager` @0x00510523); `ConstraintManager::adjust_offset` 0x00556180 (brake-only taper, write-only anchor); `WorldObject_Tick.cs:333-334`/`:265` (ACE never-sends evidence) | @@ -412,7 +415,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-68 | **Filed 2026-08-09 (Campaign CH slice CH4); corrected 2026-08-09 at the CH4 REJECT-review, Blocker 1.** `@allegiance`/`@all` and `@house`/`@hou` are real retail management-command dispatchers with 12 and 15 subcommands respectively (registry doc §2.5/§2.5b). acdream ports only the subset with simple parameterless/single-field wire shapes (allegiance `info`/`hometown`/`ho`; house `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`). For `@house`, every other subcommand (open, close, storage, remove, boot, boot_all, remove_all, guest, available, hooks, on, off) still falls through to ACE server-passthrough (which replies "Unknown command") — unchanged from the original filing. **The original filing was WRONG for `@allegiance`/`@all`: retail's own `DoAllegiance` never reaches DoChannelCommand/server-passthrough for an unrecognized subcommand** — it prints "Please see @help Allegiance for more information on how to use this command." locally (`label_57da4b`, 0x0057DA4B) and stays entirely client-side. **Corrected again 2026-08-09 at the CH4 re-review, SHOULD-FIX 3.** Retail does NOT refuse boot/ban/officer/title/motd/name/lock/house/chat/broadcast — `DoAllegiance`'s dispatcher table EXECUTES each one locally through its own handler (e.g. `DoAllegianceBoot @ 0x0057D646` is the dispatcher's call site into `ClientCommunicationSystem::DoAllegianceBoot`; `DoAllegianceBan`/`DoAllegianceOfficer`/`DoAllegianceOfficerTitle`/`DoMotd`/`DoAllegianceName`/`DoAllegianceLock`/`DoAllegianceHouse` are its siblings in the same table). acdream has none of those nine handlers ported (tracked by issue #360) and instead shows the SAME unrecognized-subcommand refusal ("Please see @help Allegiance...", `label_57da4b`, 0x0057DA4B) for every one of them, pending the #360 port. What matches retail here is the OWNERSHIP RULE — the verb never reaches `DoChannelCommand`/server-passthrough for `@allegiance`/`@all` regardless of subcommand — NOT the subcommand's actual behavior, which retail executes and acdream does not yet. This still closes the real bug the original filing named (the unmatched subcommand text broadcast to the Allegiance chat channel, 0x02000000). The standalone `@motd` verb (reached directly, not via `@allegiance motd`) remains a separate, still-open gap. `RetailClientCommandCatalog.TryMatchHouse`/`TryMatchAllegiance` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`) | Retail would execute these locally (with its own usage/confirmation/refusal text). House's unported subcommands still reach ACE, which does not implement them as chat commands either — no functional loss on a real server, but a user typing e.g. `@house open` gets ACE's generic "Unknown command" instead of retail's real behavior. Allegiance's unported subcommands correctly stay local (never reach ACE) but show a generic refusal instead of retail's real per-subcommand execution — a user typing e.g. `@allegiance boot Name` gets "Please see @help Allegiance..." instead of retail's real boot confirmation/effect, until #360 ports the nine `DoAllegiance*`/`DoMotd`/`DoAllegianceHouse` handlers. | `ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0`; `DoHouse @ 0x00580860`; ACE `GameActionType` opcodes for each subcommand (all exist server-side) | | TS-69 | **Filed 2026-08-09 (Campaign CH slice CH4).** `@day`, `@log`, and `@render` are registered retail verbs acdream recognizes only in the `/help ` lookup table, not as executable client commands. `@day` needs a sky/time-of-day override hook the renderer doesn't expose; `@log` needs a safely-lifecycled chat-to-file writer (deferred to avoid an unaudited file-handle leak across reconnects); `@render` has no acdream equivalent to retail's `SmartBox::HandleRenderOption` render-option surface. All three fall through to server passthrough. `RetailCommandHelpTable` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs`) | A user typing `@day`/`@log`/`@render` gets ACE's "Unknown command" instead of retail's local toggle/file-copy/render-option behavior — cosmetic/QoL only, no gameplay impact | `ClientCommunicationSystem::DoDay @ 0x005706F0`; `DoSetOutput @ 0x0057E4F0`; `DoRenderOption @ 0x0057E120` | | TS-67 | **Ambient contributions are computed in-plane.** Retail's `CLandBlock::add_ambient_sounds` @ `0x530310` positions each contributing land cell at its own SW terrain VERTEX, including that vertex's height, and `Ambient::CalcWeight` deliberately includes Z in its distance (where `CalcDir` deliberately excludes it — the two differ on purpose). acdream's gatherer supplies Z = 0 for the offset, so a cell's weight ignores the height difference between the listener and the terrain under that cell. | `src/AcDream.Core/Audio/AmbientSoundGatherer.cs` (`ContributeLandblock`) | Sampling the height needs the landblock's height table threaded into the walk alongside the terrain words; the walk already runs only on a 24 m crossing so the cost is not the obstacle, the extra plumbing at slice end was. The error is bounded by terrain relief inside 120 m and affects the crossfade weight only, never the direction. | On steep ground an ambient reads slightly louder than retail, because the true 3-D distance is longer than the planar one. | `CLandBlock::add_ambient_sounds @ 0x530310`; `Ambient::CalcWeight @ 0x550DD0` | -| TS-74 | **Filed 2026-08-11 at Campaign OP slice OP3; What/Where extended 2026-08-11 at the OP3 review-fix round (mechanism review S5).** acdream has no persistent "turn to face camera" mouse-turning MODE — `MouseLookState` only implements retail's MMB-hold `CameraInstantMouseLook`. The Options panel's "Use Mouse Turning Settings" button still sends the `PlayerOption.UseMouseTurning` bit (`SetSingleCharacterOption 0x0005`) and persists the five client-local `CameraTurningSettings` preferences exactly as retail does — but flipping the bit ON has NO observable effect on acdream's camera today, because the mode it is supposed to enable was never built. **All five persisted preferences are STORE-ONLY with no consumer, not just the camera mode itself:** `Camera_Stiffness`, `Camera_AdjustmentSpeed`, `Camera_AlignToSlope`, `Input_MouseLookSensitivity`, and `Input_InvertMouseLookYAxis` (research doc `2026-08-10-options-panel-structure.md` §4; OP6 is their contracted home) land in `settings.json`'s `cameraTurning` section and are read back only by the macro itself — acdream's ACTUALLY-live mouse sensitivity lives entirely separately, in `CameraPointerInputController`'s `_chase`/`_flySensitivity`/`_orbitSensitivity` fields (F8/F9-adjustable), so the macro's chat lines quote a `Default`-seeded "from" value (e.g. `0.550000`) that describes no live client state on a fresh profile. | `src/AcDream.UI.Abstractions/Input/MouseLookState.cs` (the only mouse-look mode present); `src/AcDream.App/UI/Layout/MouseTurningSettingsMacro.cs` (sends the bit regardless); `src/AcDream.UI.Abstractions/Panels/Settings/CameraTurningSettings.cs` (the five store-only keys); `src/AcDream.App/Input/CameraPointerInputController.cs` (the SEPARATE, actually-live sensitivity fields) | Building the persistent mouse-turning camera mode is a camera/physics-scope feature, out of the Options-panel campaign's scope; the STORE-and-SEND half is honest and complete (matches every other stored-but-unconsumed option class in this register), so the bit round-trips correctly for any future consumer or a retail client reading the same character. | A user who clicks "Use Mouse Turning Settings" expecting the camera to start turning with mouse movement sees no camera change — only the (unwired) preferences persisting and the wire bit flipping. Beyond that: a user who separately tunes acdream's live F8/F9 mouse sensitivity, then clicks this button, sees a chat line quoting an UNRELATED stored value, not their live sensitivity — two stores for one concept, user-visible once OP6 surfaces `cameraTurning` in the Config tab UI. | `PlayerModule::UseMouseTurning @0x005D3380`; `CharacterOptions2.UseMouseTurning 0x00400000`; `claude-memory/project_camera_visibility_coupling.md` | +| TS-74 | **Filed 2026-08-11 at Campaign OP slice OP3; What/Where extended 2026-08-11 at the OP3 review-fix round (mechanism review S5).** acdream has no persistent "turn to face camera" mouse-turning MODE — `MouseLookState` only implements retail's MMB-hold `CameraInstantMouseLook`. The Options panel's "Use Mouse Turning Settings" button still sends the `PlayerOption.UseMouseTurning` bit (`SetSingleCharacterOption 0x0005`) and persists the five client-local `CameraTurningSettings` preferences exactly as retail does — but flipping the bit ON has NO observable effect on acdream's camera today, because the mode it is supposed to enable was never built. **All five persisted preferences are STORE-ONLY with no consumer, not just the camera mode itself:** `Camera_Stiffness`, `Camera_AdjustmentSpeed`, `Camera_AlignToSlope`, `Input_MouseLookSensitivity`, and `Input_InvertMouseLookYAxis` (research doc `2026-08-10-options-panel-structure.md` §4) land in `settings.json`'s `cameraTurning` section and are read back only by the macro itself — acdream's ACTUALLY-live mouse sensitivity lives entirely separately, in `CameraPointerInputController`'s `_chase`/`_flySensitivity`/`_orbitSensitivity` fields (F8/F9-adjustable), so the macro's chat lines quote a `Default`-seeded "from" value (e.g. `0.550000`) that describes no live client state on a fresh profile. **LANDED 2026-08-11 at Campaign OP slice OP6**: the Config tab now surfaces all five as its own Camera/Input rows (`ConfigOptionsPageController.BindCameraSection`/`BindInputSection`), plus a SIXTH, previously-unmodeled field — `CameraTurningSettings.UseMouseTurning` (`Input_UseMouseTurning`, the Config tab's OWN client-local checkbox, distinct from the server-synced `PlayerOption.UseMouseTurning` bit this row already describes) — with the SAME store-only disposition; the "two stores for one concept" symptom below is now directly observable rather than latent. | `src/AcDream.UI.Abstractions/Input/MouseLookState.cs` (the only mouse-look mode present); `src/AcDream.App/UI/Layout/MouseTurningSettingsMacro.cs` (sends the bit regardless); `src/AcDream.UI.Abstractions/Panels/Settings/CameraTurningSettings.cs` (the six store-only keys); `src/AcDream.App/Input/CameraPointerInputController.cs` (the SEPARATE, actually-live sensitivity fields); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (the Config tab's own rows, OP6) | Building the persistent mouse-turning camera mode is a camera/physics-scope feature, out of the Options-panel campaign's scope; the STORE-and-SEND half is honest and complete (matches every other stored-but-unconsumed option class in this register), so the bit round-trips correctly for any future consumer or a retail client reading the same character. | A user who clicks "Use Mouse Turning Settings" expecting the camera to start turning with mouse movement sees no camera change — only the (unwired) preferences persisting and the wire bit flipping. Beyond that: a user who separately tunes acdream's live F8/F9 mouse sensitivity, then clicks this button, sees a chat line quoting an UNRELATED stored value, not their live sensitivity — two stores for one concept, now user-visible in the Config tab UI (OP6). | `PlayerModule::UseMouseTurning @0x005D3380`; `CharacterOptions2.UseMouseTurning 0x00400000`; `claude-memory/project_camera_visibility_coupling.md` | --- diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md index 0ed0ff4b..e03f1a86 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -1,6 +1,6 @@ # Campaign OP connected-gate test script -**Status:** OP3, OP4, OP5, and OP7 sections. Later slices (OP6, OP8) append +**Status:** OP3, OP4, OP5, OP6, and OP7 sections. Later slices (OP8) append their own sections here as they land; the campaign's OP9 closeout gate is this document complete plus every slice code-complete. @@ -509,6 +509,157 @@ chat windows already read when deciding which lines to show. --- +## OP6 — the Config tab + +The Config tab (`0x21000029`) has six sections: **Sound Options** (5 rows — +a menu, three toggle+slider volume trios, one toggle), **Camera Options** (4 +rows), **Graphics Options** (7 rows), **Rendering Quality Options** (6 +rows), **Input Options** (3 rows), **UI Options** (2 rows) — 27 authored +rows total, byte-verified directly from `gmConfigUI::InitOptions +@0x0049E400` and `gmClient::InitUIPreferences @0x004035b0` (the retail +registration table that supplies every row's label/tooltip/range/enum- +choice — a stronger source than this campaign's own research docs, which +had left the slider-caption pairing explicitly UNVERIFIED; OP6 resolved it: +**retail ships zero range captions on this tab** — do not expect +"Dark/Bright"-style low/high labels on any slider). + +### Opening the tab and reading the rows + +1. **Open the Options panel (F11) and click the Config tab.** Six section + headers appear top-to-bottom: **Sound Options**, **Camera Options**, + **Graphics Options**, **Rendering Quality Options**, **Input Options**, + **UI Options**. Every row has a label (a menu dropdown, a checkbox, or a + slider — three of the Sound rows are a combined checkbox+slider in one + row). If ANY row shows no label at all, that is a DAT string-resolution + miss worth reporting (the code deliberately renders nothing rather than + invented English when a string fails to resolve). +2. **Scroll the list** via the scrollbar. All 27 rows are reachable; the + list does not clip or overlap the Apply/Reset/Defaults buttons at the + bottom. + +### Live rows — audio + +3. **Drag the "Sound Effects" slider (first Sound Options trio) while a + sound effect is audibly looping or repeating** (e.g. stand near an + ambient sound source, or trigger a combat/UI sound repeatedly). The + volume should change LIVE, continuously, as you drag — not just on + release. +4. **Check the "Disable Sound Effects" checkbox** (the SAME row's toggle + half). Sound effects should go SILENT immediately, regardless of where + the slider is set. Uncheck it — sound effects resume at the slider's + current level. +5. **Repeat steps 3-4 for the "Ambient Sound" trio** (the second row) using + an ambient loop (wind, water, torches) as your audible test — same live + drag + mute-on-check behaviour. +6. **Note the retail-faithful surprise:** on a FRESH character (never + touched these settings), BOTH "Disable Sound Effects" and "Disable + Ambient Sound" checkboxes are CHECKED by default — i.e., sound starts + MUTED out of the box. This is retail's own byte-verified 2013 EoR + default (`gmConfigUI::InitOptions`'s `SetDefaultValue(1, ...)` on both + trios), reproduced faithfully — **not a bug**, however counterintuitive. + Uncheck both to hear anything. +7. **Drag the third slider / check its toggle ("Disable Interface Sound", + third trio).** Expect NO audible change either way — this is retail's + OWN dead knob (register row AP-174/AP-199): retail registers this + preference and then never reads it; UI/interface sounds are always + scaled by the Sound Effects slider instead. Confirm the row is + clickable/draggable and does not crash; do not expect it to do anything. + +### Live rows — display + +8. **Open the Resolution menu (Graphics Options, first row) and pick a + different resolution.** The window should resize IMMEDIATELY, live, no + restart needed. +9. **Toggle "Full Screen".** The window should switch between windowed and + fullscreen IMMEDIATELY, live. +10. **Toggle "Sync To Refresh" (VSync) and drag the "Field of View" + slider.** Both persist to `settings.json`, but — matching this + project's pre-existing (not new) behaviour for these two fields — + neither re-applies until the NEXT LAUNCH. Do not expect an immediate + visual change; do a full relaunch afterward (step 15) to confirm the + new value took effect at startup. + +### Store-only rows — no observable effect is the CONTRACTED behaviour + +11. **Sound Features menu (Stereo/Mono), Play Sound Only When Active + toggle.** No consumer exists (register row AP-199). Confirm they're + clickable and persist (step 14); expect no audible/behavioural change. +12. **Screen Brightness slider, Automatic Degrades toggle, Graphics + Performance slider, Degrade Distance slider, the four Rendering + Quality menus (Landscape/Environment Texture Detail, Texture + Filtering, Landscape Draw Distance), Building Detail Textures, + Multi-Pass Alpha.** No consumer exists — acdream's Vulkan renderer is + driven by one aggregate quality preset, not these per-feature knobs + (register row AP-198). Confirm every one is clickable/draggable and + persists; expect zero visual change from any of them. **Landscape + Draw Distance specifically may show NO highlighted item** even right + after Defaults — that is retail's own decompiled data being + ambiguous (AP-198's own sub-note), not a rendering bug. +13. **Mouse Look Sensitivity slider, Invert Mouselook Y Axis toggle, Use + Mouse Turning toggle (Input Options), Camera Stiffness/Adjustment + Speed sliders, Align To Slope toggle (Camera Options), Chat Font + Face/Size menus (UI Options).** No consumer exists for any of these + six Camera/Input rows (register row TS-74 — the SAME "no persistent + mouse-turning camera mode" gap the Gameplay tab's macro already + exposed) or the two Chat font rows (register row AP-200 — distinct + fields from the chat panel's own separately-live font-size control, + wherever that is currently exposed). Confirm all eight are + clickable/draggable and persist; expect no visual/audible change. + +### Apply/Reset/Defaults + +14. **Toggle a mix of row types (a checkbox, a slider, a menu selection), + then click Reset.** Every row you touched reverts to its + last-shown/Applied value — live audio/display rows revert their + ACTUAL effect too (sound un-mutes or re-mutes, resolution changes + back), not just the checkbox art. +15. **Click Defaults.** Every row jumps LIVE to retail's byte-verified + default (Sound/Ambient trios back to muted+100% volume, Resolution to + 800×600, Full Screen on, Camera Stiffness to 0.45, etc.) — Apply/Reset + light up afterward if anything actually changed (Defaults never + commits by itself). +16. **Switch to another tab (e.g. Gameplay) without clicking Apply after + an edit.** Uncommitted edits silently revert — switching tabs is a + Reset, not a save. + +### Persistence — local-only, survives relaunch + +17. **Set a distinctive combination** (e.g. unmute Sound Effects at 40% + volume, pick a non-default Resolution, drag Field of View partway) — + every row here writes to `settings.json` on change, not just on + Apply. +18. **Fully relaunch the client** (close the window, start a new + process — a relog is not required, this is all local-only state). + Reopen the Options panel's Config tab — every row (live AND + store-only) should read back at your set values. The Sync To + Refresh/Field Of View rows deferred to next-launch (step 10) should + now ALSO be visibly applied (window vsync behaviour / camera FOV). + +### What to report + +- Any section/row/label missing, or a slider showing a low/high range + caption (retail ships none on this tab — see this section's own intro). +- A live row (Sound/Ambient volume+mute, Resolution, Full Screen) that + does not change live, or a store-only row that unexpectedly DOES change + something (a sign its "no consumer" premise is stale). +- Reset/Defaults not behaving as described in 14-15. +- Any row that fails to persist across a full relaunch (step 18), + including the two next-launch-only rows (Sync To Refresh, Field of + View) not taking effect at the NEXT startup. +- Any crash, freeze, or scroll glitch anywhere in the 27-row list. + +### Explicitly NOT in scope for this gate + +- Any audible/visual change from the store-only rows listed in steps + 11-13 — no acdream consumer exists for any of them (register rows + AP-198, AP-199, AP-200, TS-74). +- The camera actually turning from "Use Mouse Turning" (Config tab OR the + Gameplay tab's macro) — no persistent mouse-turning camera mode exists + (TS-74, already covered by OP3's gate). +- Configure Keyboard — OP8. + +--- + ## OP7 — headless `characterOptions` Unlike OP3-OP6, this is not a graphical-client gate: no window is launched. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 4e7fc03a..1f6088f8 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -857,7 +857,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory SaveCameraTurning: d.Settings.SaveCameraTurning, // Campaign OP slice OP4 (2026-08-11): the Character-tab // panel's row-seed source. - CurrentCharacterOption: id => d.Character.Options.GetOptionBit(id)), + CurrentCharacterOption: id => d.Character.Options.GetOptionBit(id), + // Campaign OP slice OP6 (2026-08-11): the Config tab's + // Display/Audio-backed rows — RuntimeSettingsController + // is the sole writer of both sections (unlike Chat; see + // RetailUiRuntime.MountOptionsPanel's own note). + LoadDisplay: () => d.Settings.Display, + SaveDisplay: d.Settings.SaveDisplay, + LoadAudio: () => d.Settings.Audio, + SaveAudio: d.Settings.SaveAudio), StackSplitQuantity: d.StackSplitQuantity, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 25c532f2..ab4af67b 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -341,7 +341,10 @@ internal sealed class SessionPlayerCompositionPhase // CH6c: null when no retained UI exists (e.g. a no-window host) — the // Chat tab's opacity sliders then apply through NullRuntimeChatOpacityTarget. chatOpacity: interaction.RetainedUi?.Runtime.WindowOpacity, - log: d.Log); + log: d.Log, + // OP6: null on a no-audio/headless host — ApplyAudio then + // silently no-ops, same shape as chatOpacity above. + audio: content.Audio?.Engine); bindings.Adopt( "runtime settings targets", d.Settings.BindRuntimeTargetsOwned(settingsTargets)); diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index 9dee6f13..4ac0388d 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -129,6 +129,17 @@ internal interface IRuntimeSettingsTargets { void ApplyDisplayWindowState(DisplaySettings display); + /// + /// Campaign OP slice OP6 (2026-08-11): pushes the CURRENT audio + /// snapshot into the live OpenAlAudioEngine — the SAME + /// RuntimeSettingsStartupTargets.ApplyAudio static helper the + /// startup path already used, now also reachable on every + /// SaveAudio. Before this, an Audio-tab change never took + /// effect until the next process launch; the mechanism already + /// existed, it was just never invoked outside startup. + /// + void ApplyAudio(AudioSettings audio); + void ApplyQuality(QualitySettings quality); void ApplyUiLock(bool locked); @@ -485,7 +496,13 @@ internal sealed class RuntimeSettingsController : _runtimeTargets?.ApplyQuality(resolved); } - private void SaveDisplay(DisplaySettings display) + /// Widened from private to public at Campaign OP slice OP6 — + /// 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 + /// callback wiring. + public void SaveDisplay(DisplaySettings display) { try { @@ -501,12 +518,20 @@ internal sealed class RuntimeSettingsController : } } - private void SaveAudio(AudioSettings audio) + /// Widened from private to public at Campaign OP slice OP6, + /// same reason as . Also now pushes the saved + /// snapshot into the live engine via + /// — previously this + /// method only persisted; Audio-tab changes took effect on the NEXT + /// launch only. OP6's Sound-trio sliders/toggles are the first live + /// consumer. + public void SaveAudio(AudioSettings audio) { try { _storage.SaveAudio(audio); Audio = audio; + _runtimeTargets?.ApplyAudio(audio); _log($"settings: audio saved to {_storage.Location}"); } catch (Exception ex) @@ -533,7 +558,12 @@ internal sealed class RuntimeSettingsController : } } - private void SaveChat(ChatSettings chat) + /// 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 + /// Hear*Chat fields) reuse this exact seam rather than a parallel + /// save path. + public void SaveChat(ChatSettings chat) { ChatSettings previous = Chat; try diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index d79a3529..47828705 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -136,8 +136,16 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar if (engine is not { IsAvailable: true }) return; engine.MasterVolume = audio.Master; - engine.SfxVolume = audio.Sfx; - engine.AmbientVolume = audio.Ambient; + // Campaign OP slice OP6: the Config tab's toggle halves of the + // Sound/Ambient volume trios (Sound_SoundDisabled/ + // Sound_AmbientSoundDisabled) gate the SAME slider value — retail's + // UIOption_CheckboxSlider is one row over two preferences, and a + // checked "Disable Sound Effects" LED mutes the category regardless + // of what the slider itself is set to. No engine-level "disabled" + // concept is needed: the effective volume sent IS zero when + // disabled, exactly as if the user dragged the slider to zero. + engine.SfxVolume = audio.SfxDisabled ? 0f : audio.Sfx; + engine.AmbientVolume = audio.AmbientDisabled ? 0f : audio.Ambient; } } @@ -240,6 +248,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets private readonly IRuntimeChatOpacityTarget _chatOpacity; private readonly ICommandBus _commands; private readonly Action _log; + private readonly OpenAlAudioEngine? _audio; public RuntimeSettingsTargets( IRuntimeDisplayWindowTarget displayWindow, @@ -250,7 +259,12 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets UiRoot? uiRoot, ICommandBus commands, RetailWindowOpacityController? chatOpacity = null, - Action? log = null) + Action? log = null, + // Campaign OP slice OP6: the live engine reference — see + // ApplyAudio's doc. Optional/trailing so every pre-existing + // construction site keeps compiling unchanged (matches + // chatOpacity/log's own optional-trailing shape). + OpenAlAudioEngine? audio = null) : this( displayWindow, new RuntimeQualityApplicationTarget( @@ -265,7 +279,8 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets log, chatOpacity is null ? NullRuntimeChatOpacityTarget.Instance - : new RuntimeChatOpacityTarget(chatOpacity)) + : new RuntimeChatOpacityTarget(chatOpacity), + audio) { } @@ -275,7 +290,8 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets IRuntimeUiLockTarget uiLock, ICommandBus commands, Action? log = null, - IRuntimeChatOpacityTarget? chatOpacity = null) + IRuntimeChatOpacityTarget? chatOpacity = null, + OpenAlAudioEngine? audio = null) { _displayWindow = displayWindow ?? throw new ArgumentNullException(nameof(displayWindow)); @@ -284,11 +300,22 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets _chatOpacity = chatOpacity ?? NullRuntimeChatOpacityTarget.Instance; _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _log = log ?? Console.WriteLine; + _audio = audio; } public void ApplyDisplayWindowState(DisplaySettings display) => _displayWindow.Apply(display); + /// Campaign OP slice OP6: reuses the SAME static helper the + /// startup path () + /// already runs — one mixer-apply implementation, two call sites (once + /// at process start, now also on every RuntimeSettingsController. + /// SaveAudio). engine (a headless/no-audio + /// host) is a silent no-op, matching every other optional target in + /// this class. + public void ApplyAudio(AudioSettings audio) => + RuntimeSettingsStartupTargets.ApplyAudio(_audio, audio); + public void ApplyQuality(QualitySettings quality) { _quality.SetAlphaToCoverage(quality.AlphaToCoverage); diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs new file mode 100644 index 00000000..e2fb4901 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -0,0 +1,987 @@ +using System; +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.UI.Layout; + +/// +/// Campaign OP slice OP6 (2026-08-11): binds the Config tab (LayoutDesc +/// 0x21000029, root 0x100001FF, ListBox 0x10000200) — +/// retail's gmConfigUI::InitOptions @0x0049E400 — through OP2's +/// template-list mechanism and OP3's per-page +/// model, exactly like / +/// . +/// +/// +/// The row table below is transcribed directly from the decompiled +/// registration, not from the research docs' own row table. Two +/// grep-named-first sources supersede +/// docs/research/2026-08-10-options-panel-structure.md §4/§9's +/// summary (which itself flagged the slider-caption pairing as UNVERIFIED — +/// its own "U4"): +/// +/// gmConfigUI::InitOptions @0x0049E400 — the +/// authored row ORDER, widget shape per row (AddMenuOption/ +/// AddToggleOption/AddSliderOption/ +/// AddToggleWithSliderOption), and every SetDefaultValue +/// literal. +/// gmClient::InitUIPreferences @0x004035b0 — the +/// COMPLETE UIPreferences::AttachPreference registration table: +/// every row's exact label string key (ID_<Key>, hashed +/// against string table 0x23000003, table-enum 0x10000003 +/// matching AddHeader's own convention), its tooltip key +/// (<label>_Help — verified at every non-truncated call site, +/// applied uniformly), every slider's real-unit +/// UIPreferences::SetPreferenceRange (NOT the widget's [0,1] scalar +/// space — / convert +/// at the build site), and every menu's UIPreferences::SetEnumChoices +/// string array. +/// +/// +/// +/// +/// U4 resolved: retail ships ZERO slider range captions on this tab. +/// Every one of the seven PlayerOptionPage::SetSliderLabel calls this +/// tab makes passes literal (0, 0) for the low/high string ids +/// (0x0049E4E0/0x0049E51D/0x0049E556/0x0049E614/ +/// 0x0049E65D/0x0049E68E — six calls; the seventh slider, +/// Mouse Look Sensitivity, gets NO SetSliderLabel call at all). +/// SetSliderLabel's own body (0x004F2B80) has no IsValid +/// guard before writing — unlike AddHeader — so StringId=0 +/// resolves to blank text either way. The ID_Graphics_Value_* globals +/// (Dark/Bright, Speed/Detail, ...) the structure doc's §4 found ARE real +/// strings elsewhere in the binary, but THIS tab's InitOptions never +/// wires them to any row — they are dead literals for this build. This +/// controller therefore never touches the range-caption text children +/// (0x1000021E/0x1000021F) at all — omitting them is the +/// faithful port, not a shortcut. +/// +/// +/// +/// The row-template array (8 entries, Config's OWN ListBox property +/// 0x64, cross-checked against the committed +/// options_config_21000029.json/options_2100002B.json +/// fixtures): idx0 header (Type 12 text, 0x10000216); idx1 +/// separator (Type 3, 0x10000217); idx2 toggle row (Type 3, +/// 0x10000218, one UIOption_Checkbox child 0x10000219); +/// idx3 simple slider row (Type 3, 0x1000021A, name text +/// 0x1000021B + slider 0x1000021C — used ONLY for Mouse Look +/// Sensitivity, retail's own arg3=0 row); idx4 menu row (Type 3, +/// 0x10000222, name text 0x10000223 + menu +/// 0x10000224); idx5 toggle+slider trio (Type +/// 0x10000036 = , +/// 0x10000220, checkbox 0x10000219 + slider +/// 0x1000021C, no separate name text — the checkbox's OWN label +/// carries the row); idx6 range-captioned slider row (Type 3, +/// 0x1000021D, name text 0x1000021B + slider +/// 0x1000021C + UNUSED range-caption texts 0x1000021E/ +/// 0x1000021F — the six arg3=1 sliders); idx7 a SECOND +/// toggle+slider trio template with range-caption children +/// (0x10000221) that retail's own InitOptions never +/// invokes — authored but dead, matching Chat's own unused-index pattern. +/// +/// +/// +/// Consumer disposition (OP6 contract §4): LIVE — the Sound/Ambient +/// volume-trio sliders (AudioSettings.Sfx/Ambient, already +/// live via ApplyAudio) and their toggle halves +/// (/, +/// gating the SAME engine write); Resolution/Full Screen +/// (DisplaySettings.Resolution/Fullscreen, immediately live — +/// resizes the window on Save). NEXT-LAUNCH (existing precedent, not a new +/// gap — neither the pre-existing dev-tools Settings panel nor this +/// controller re-applies these outside ApplyStartup): Sync To +/// Refresh, Field of View. STORE-ONLY (register rows, cited per group below): +/// Sound Features menu, Interface Sound trio (AP-174 — retail's own +/// registered-and-never-read knob), Play Sound Only When Active; Screen +/// Brightness (reuses the existing inert DisplaySettings.Gamma — no +/// gamma-correction pass exists); Automatic Degrades/Graphics +/// Performance/Degrade Distance/the four texture-detail-family +/// menus/Building Detail Textures/Multi-Pass Alpha (the renderer is Vulkan + +/// one aggregate QualityPreset, no per-feature knobs); Camera +/// Stiffness/Adjustment Speed/Align To Slope/Mouse Look +/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no +/// persistent mouse-turning camera mode exists for ANY of these six to +/// drive, already registered before this slice); Chat Font Face/Size +/// (distinct NEW fields from the existing live ChatSettings.FontSize +/// — no verified index-to-point/face mapping). +/// +/// +public static class ConfigOptionsPageController +{ + /// Config page root — gmConfigUI — the STANDALONE + /// 0x21000029 layout's own root id. Only present as its own + /// distinct node when 0x21000029 is imported directly (e.g. + /// FixtureLoader.LoadOptionsConfig()); when mounted through the + /// tab host (0x2100006E/0x2100002B, what + /// actually operates on), ElementReader.Merge's "derived id wins" + /// rule means the PAGE SLOT keeps its own id + /// () instead — use that one for any + /// host-tree lookup (same split 's + /// own RootElementId doc explains). + public const uint RootElementId = 0x100001FFu; + + /// The Config page's SLOT element within the tab host + /// (OptionsPanelController's own private ConfigPageId) — + /// the id that actually survives base-merge in the host-mounted tree + /// operates on. Used to SCOPE the scrollbar lookup + /// below (the shared-id hazard with the Chat tab). + private const uint PageSlotElementId = 0x10000213u; + + /// The row ListBox (dat Type 5) — m_pOptionBox. + public const uint ListBoxElementId = 0x10000200u; + + /// The ListBox's linked scrollbar — SHARED with the Chat tab + /// (research doc §10.1: both tabs author the same scrollbar element + /// id). Scoped from 's page slot, exactly + /// like 's own scrollbar lookup — + /// the shared-id hazard this campaign's binding pattern exists for. + public const uint ScrollbarElementId = 0x10000201u; + + private const int HeaderTemplateIndex = 0; + private const int SeparatorTemplateIndex = 1; + private const int ToggleTemplateIndex = 2; + private const int SimpleSliderTemplateIndex = 3; + private const int MenuTemplateIndex = 4; + private const int TrioTemplateIndex = 5; + private const int RangedSliderTemplateIndex = 6; + + private const uint StringTableId = 0x23000003u; + + /// Slider row templates' (idx3/idx6) name-label text child. + private const uint SliderLabelElementId = 0x1000021Bu; + + /// Slider leaf shared by every slider-bearing template + /// (idx3/idx5/idx6) — retail's own single 0x1000021C convention, + /// same id CharacterOptions/ChatOptions controllers already cite. + private const uint SliderElementId = 0x1000021Cu; + + /// Menu row template's (idx4) name-label text child. + private const uint MenuLabelElementId = 0x10000223u; + + /// Menu row template's (idx4) leaf. + private const uint MenuElementId = 0x10000224u; + + /// Toggle checkbox leaf shared by the plain toggle row (idx2) + /// and the trio row's toggle half (idx5) — retail's own single + /// 0x10000219 convention. + private const uint ToggleCheckboxElementId = 0x10000219u; + + /// The live read/write seam every row on this page writes/reads + /// through — four settings groups, each read once per row-build and + /// mutated read-modify-write per change (the SAME per-change persistence + /// shape OP3's mouse-turning macro and OP4's Character rows use). + public sealed record Bindings( + Func LoadDisplay, + Action SaveDisplay, + Func LoadAudio, + Action SaveAudio, + Func LoadCameraTurning, + Action SaveCameraTurning, + Func LoadChat, + Action SaveChat); + + /// + /// Builds the six authored sections (Sound/Camera/Graphics/Rendering + /// Quality/Input/UI Options) into 's Config + /// ListBox, links its scrollbar, seeds every row's current/default + /// state, and registers each row into . + /// + public static bool Bind( + ImportedLayout layout, + OptionPage page, + Func templateResolver, + Func resolveString, + Bindings bindings) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(page); + ArgumentNullException.ThrowIfNull(templateResolver); + ArgumentNullException.ThrowIfNull(resolveString); + ArgumentNullException.ThrowIfNull(bindings); + + if (layout.FindElement(ListBoxElementId) is not UiTemplateListBox listBox) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: ListBox 0x{ListBoxElementId:X8} " + + "not found (or not a UiTemplateListBox) in the built Options panel tree — " + + "the Config tab will have no rows."); + return false; + } + + listBox.TemplateResolver = templateResolver; + + // ScrollbarElementId (0x10000201) is SHARED with the Chat tab + // (0x1000050D's own scrollbar lookup cites the same hazard) — + // ImportedLayout.FindElement is a flat id->widget dictionary + // (last-build-wins on a collision), so a plain lookup here could + // silently wire THIS scroll model onto the Chat tab's own + // scrollbar instance. Scope the search to the Config page's own + // subtree instead. Scoped from PageSlotElementId, NOT RootElementId + // — see that field's own doc for why the standalone layout's root + // id does not survive base-merge here. + UiElement? configPageSlot = layout.FindElement(PageSlotElementId); + UiElement? scrollbarElement = configPageSlot is null + ? null + : UiElement.FindDescendant(configPageSlot, ScrollbarElementId); + if (scrollbarElement is UiScrollbar scrollbar) + scrollbar.Model = listBox.Scroll; + else + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: scrollbar 0x{ScrollbarElementId:X8} " + + $"not found under Config page slot 0x{PageSlotElementId:X8} — the Config " + + "tab's row list will not scroll."); + + DisplaySettings display = bindings.LoadDisplay(); + AudioSettings audio = bindings.LoadAudio(); + CameraTurningSettings cameraTurning = bindings.LoadCameraTurning(); + ChatSettings chat = bindings.LoadChat(); + + BindSoundSection(listBox, page, resolveString, bindings, ref audio); + BuildSeparatorRow(listBox); + BindCameraSection(listBox, page, resolveString, bindings, ref cameraTurning); + BuildSeparatorRow(listBox); + BindGraphicsSection(listBox, page, resolveString, bindings, ref display); + BuildSeparatorRow(listBox); + BindRenderingQualitySection(listBox, page, resolveString, bindings, ref display); + BuildSeparatorRow(listBox); + BindInputSection(listBox, page, resolveString, bindings, ref cameraTurning); + BuildSeparatorRow(listBox); + BindUiSection(listBox, page, resolveString, bindings, ref chat); + + return true; + } + + // ── Section 1: Sound Options ──────────────────────────────────────── + + private static void BindSoundSection( + UiTemplateListBox listBox, + OptionPage page, + Func resolveString, + Bindings bindings, + ref AudioSettings audio) + { + BuildHeaderRow(listBox, "ID_Sound_SoundSection", resolveString); + + BuildMenuRow( + listBox, "ID_Sound_SoundFeatures", + new[] { "ID_Sound_Stereo", "ID_Sound_Mono" }, + page, resolveString, + read: () => bindings.LoadAudio().SoundFeatures, + apply: value => + { + AudioSettings updated = bindings.LoadAudio() with { SoundFeatures = value }; + bindings.SaveAudio(updated); + }, + defaultValue: 0); + + BuildTrioRow( + listBox, "ID_Sound_DisableSound", toggleDefault: true, + sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f, + page, resolveString, + toggleRead: () => bindings.LoadAudio().SfxDisabled, + toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { SfxDisabled = value }), + sliderRead: () => bindings.LoadAudio().Sfx, + sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Sfx = value })); + + BuildTrioRow( + listBox, "ID_Sound_DisableAmbientSound", toggleDefault: true, + sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f, + page, resolveString, + toggleRead: () => bindings.LoadAudio().AmbientDisabled, + toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { AmbientDisabled = value }), + sliderRead: () => bindings.LoadAudio().Ambient, + sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { Ambient = value })); + + // Interface Sound: retail's own dead knob (AP-174 — "interface + // sounds are scaled by the EFFECT knob"; registered and never + // read). Store-only, same shape as every other row here — this + // trio simply has no live consumer to gate. + BuildTrioRow( + listBox, "ID_Sound_DisableInterfaceSound", toggleDefault: true, + sliderMin: 0f, sliderMax: 1f, sliderDefault: 1.0f, + page, resolveString, + toggleRead: () => bindings.LoadAudio().InterfaceDisabled, + toggleApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceDisabled = value }), + sliderRead: () => bindings.LoadAudio().InterfaceVolume, + sliderApply: value => bindings.SaveAudio(bindings.LoadAudio() with { InterfaceVolume = value })); + + BuildToggleRow( + listBox, "ID_Sound_NoFocusNoSound", defaultValue: true, page, resolveString, + read: () => bindings.LoadAudio().PlaySoundOnlyWhenActive, + apply: value => bindings.SaveAudio(bindings.LoadAudio() with { PlaySoundOnlyWhenActive = value })); + + audio = bindings.LoadAudio(); + } + + // ── Section 2: Camera Options ─────────────────────────────────────── + + private static void BindCameraSection( + UiTemplateListBox listBox, + OptionPage page, + Func resolveString, + Bindings bindings, + ref CameraTurningSettings cameraTurning) + { + BuildHeaderRow(listBox, "ID_Camera_CameraSection", resolveString); + + // Camera Stiffness / Adjustment Speed / Align To Slope: TS-74 — + // store-only, no persistent mouse-turning camera mode exists. + BuildSliderRow( + listBox, RangedSliderTemplateIndex, "ID_Camera_Stiffness", + min: 0.285714298f, max: 1f, defaultValue: 0.45f, page, resolveString, + read: () => bindings.LoadCameraTurning().Stiffness, + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { Stiffness = value })); + + BuildSliderRow( + listBox, RangedSliderTemplateIndex, "ID_Camera_AdjustmentSpeed", + min: 5f, max: 80f, defaultValue: 40.0f, page, resolveString, + read: () => bindings.LoadCameraTurning().AdjustmentSpeed, + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AdjustmentSpeed = value })); + + // Field of View: NEXT-LAUNCH via DisplaySettings.FieldOfView + the + // existing RuntimeSettingsController.ApplyStartup path — matches + // the pre-existing (dev-tools Settings panel era) behaviour, not a + // new gap this slice introduces. + BuildSliderRow( + listBox, RangedSliderTemplateIndex, "ID_Graphics_FieldOfView", + min: 10f, max: 160f, defaultValue: 90.0f, page, resolveString, + read: () => bindings.LoadDisplay().FieldOfView, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { FieldOfView = value })); + + BuildToggleRow( + listBox, "ID_Camera_AlignToSlope", defaultValue: true, page, resolveString, + read: () => bindings.LoadCameraTurning().AlignToSlope, + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { AlignToSlope = value })); + + cameraTurning = bindings.LoadCameraTurning(); + } + + // ── Section 3: Graphics Options ───────────────────────────────────── + + private static void BindGraphicsSection( + UiTemplateListBox listBox, + OptionPage page, + Func resolveString, + Bindings bindings, + ref DisplaySettings display) + { + BuildHeaderRow(listBox, "ID_Graphics_GraphicsSection", resolveString); + + // Resolution: LIVE — DisplaySettings.Resolution already resizes + // the window immediately on Save (ApplyDisplayWindowState). The + // menu's payload is the resolution STRING itself (not an index — + // retail's Display_Resolution is the ONE row on this tab built + // via arg3=0/SetUserPreference, a genuinely different code path + // from every other menu here; this controller resolves its label + // directly rather than through UIPreferences::InqPreference's + // generic path, matching that divergence honestly). SetConfirmChange + // ships when a resolution-change confirmation flow exists — not + // this slice (plan §4 OP6). Retail's literal default ("800x600", + // preserved below as the value Defaults restores) is NOT one of + // DisplaySettings.AvailableResolutions' modern presets — the same + // "opaque default the menu may not highlight" shape as + // LandscapeDrawDistance (register row AP-198's own sub-note), NOT + // a functional gap: TryParseResolution accepts any "WxH" string, + // so clicking Defaults still resizes the window correctly to + // 800x600, it just may not show a highlighted dropdown row. A + // FRESH profile's own current value (DisplaySettings.Default = + // "1280x720") IS in the preset list and highlights normally. + BuildStringMenuRow( + listBox, "ID_Rendering_DisplayResolution", + DisplaySettings.AvailableResolutions, page, resolveString, + read: () => bindings.LoadDisplay().Resolution, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Resolution = value }), + defaultValue: "800x600"); + + BuildToggleRow( + listBox, "ID_Rendering_FullScreen", defaultValue: true, page, resolveString, + read: () => bindings.LoadDisplay().Fullscreen, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Fullscreen = value })); + + // Sync To Refresh: NEXT-LAUNCH (same DisplaySettings.VSync + // pre-existing precedent as FieldOfView above). + BuildToggleRow( + listBox, "ID_Rendering_SyncToDisplayRefresh", defaultValue: false, page, resolveString, + read: () => bindings.LoadDisplay().VSync, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { VSync = value })); + + // Screen Brightness: reuses the existing DisplaySettings.Gamma + // field (no gamma-correction render pass exists — inert, store-only). + BuildSliderRow( + listBox, RangedSliderTemplateIndex, "ID_Graphics_ScreenBrightness", + min: -1f, max: 1f, defaultValue: 0f, page, resolveString, + read: () => bindings.LoadDisplay().Gamma, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { Gamma = value })); + + BuildToggleRow( + listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString, + read: () => bindings.LoadDisplay().AutomaticDegrades, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value })); + + BuildSliderRow( + listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias", + min: -1f, max: 1f, defaultValue: 0f, page, resolveString, + read: () => bindings.LoadDisplay().GraphicsPerformance, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value })); + + BuildSliderRow( + listBox, RangedSliderTemplateIndex, "ID_Graphics_DegradeDistance", + min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString, + read: () => bindings.LoadDisplay().DegradeDistance, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value })); + + display = bindings.LoadDisplay(); + } + + // ── Section 4: Rendering Quality Options ──────────────────────────── + + private static readonly string[] TextureDetailChoices = + { + "ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium", + "ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh", + }; + + private static readonly string[] TextureFilteringChoices = + { + "ID_Graphics_TextureFiltering_Bilinear", "ID_Graphics_TextureFiltering_Trilinear", + "ID_Graphics_TextureFiltering_Sharp", "ID_Graphics_TextureFiltering_Anisotropic", + }; + + private static readonly string[] LandscapeDrawDistanceChoices = + { + "ID_Graphics_Value_VeryLow", "ID_Graphics_Value_Low", "ID_Graphics_Value_Medium", + "ID_Graphics_Value_High", "ID_Graphics_Value_VeryHigh", "ID_Graphics_Value_Extreme", + }; + + private static void BindRenderingQualitySection( + UiTemplateListBox listBox, + OptionPage page, + Func resolveString, + Bindings bindings, + ref DisplaySettings display) + { + BuildHeaderRow(listBox, "ID_Graphics_TextureSection", resolveString); + + // The whole section is store-only: the world renderer is + // Vulkan + one aggregate QualityPreset, not per-feature knobs + // (register row, OP6). + BuildMenuRow( + listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString, + read: () => bindings.LoadDisplay().LandscapeTextureDetail, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeTextureDetail = value }), + defaultValue: 2); + + BuildMenuRow( + listBox, "ID_Graphics_EnvironmentTextureDetail", TextureDetailChoices, page, resolveString, + read: () => bindings.LoadDisplay().EnvironmentTextureDetail, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { EnvironmentTextureDetail = value }), + defaultValue: 1); + + BuildMenuRow( + listBox, "ID_Graphics_TextureFiltering", TextureFilteringChoices, page, resolveString, + read: () => bindings.LoadDisplay().TextureFiltering, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { TextureFiltering = value }), + defaultValue: 1); + + // UNRESOLVED (see class doc / register row): retail's own + // SetDefaultValue(8) does not index this 6-entry choice array. + // Reproduced as an opaque int; the menu simply shows no + // highlighted item at the default (no crash, no invented mapping). + BuildMenuRow( + listBox, "ID_Graphics_LandscapeDrawDistance", LandscapeDrawDistanceChoices, page, resolveString, + read: () => bindings.LoadDisplay().LandscapeDrawDistance, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { LandscapeDrawDistance = value }), + defaultValue: 8); + + BuildToggleRow( + listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString, + read: () => bindings.LoadDisplay().BuildingDetailTextures, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value })); + + BuildToggleRow( + listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString, + read: () => bindings.LoadDisplay().MultiPassAlpha, + apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { MultiPassAlpha = value })); + + display = bindings.LoadDisplay(); + } + + // ── Section 5: Input Options ──────────────────────────────────────── + + private static void BindInputSection( + UiTemplateListBox listBox, + OptionPage page, + Func resolveString, + Bindings bindings, + ref CameraTurningSettings cameraTurning) + { + BuildHeaderRow(listBox, "ID_Input_InputSection", resolveString); + + // Retail's own arg3=0 row (AddSliderOption(this, + // &Input_MouseLookSensitivity, 0)) — template idx3, the ONLY row + // that uses it (structural cross-check: idx3 appears exactly once + // in Config's authored template array). TS-74 — store-only. + BuildSliderRow( + listBox, SimpleSliderTemplateIndex, "ID_Input_MouseLookSensitivity", + min: 0.00999999978f, max: 1f, defaultValue: 0.55f, page, resolveString, + read: () => bindings.LoadCameraTurning().MouseLookSensitivity, + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { MouseLookSensitivity = value })); + + BuildToggleRow( + listBox, "ID_Input_InvertMouseLookYAxis", defaultValue: false, page, resolveString, + read: () => bindings.LoadCameraTurning().InvertMouseLookYAxis, + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { InvertMouseLookYAxis = value })); + + // Input_UseMouseTurning: the Config tab's OWN client-local + // UIPreference — DISTINCT from the Gameplay tab macro's + // server-synced PlayerOption.UseMouseTurning bit (see + // CameraTurningSettings.UseMouseTurning's own doc). TS-74. + BuildToggleRow( + listBox, "ID_Input_UseMouseTurning", defaultValue: false, page, resolveString, + read: () => bindings.LoadCameraTurning().UseMouseTurning, + apply: value => bindings.SaveCameraTurning(bindings.LoadCameraTurning() with { UseMouseTurning = value })); + + cameraTurning = bindings.LoadCameraTurning(); + } + + // ── Section 6: UI Options ─────────────────────────────────────────── + + private static readonly string[] ChatFontFaceChoices = + { + "ID_UI_Value_Arial", + }; + + private static readonly string[] ChatFontSizeChoices = + { + "ID_UI_Value_Tiny", "ID_UI_Value_Small", "ID_UI_Value_Medium", + "ID_UI_Value_Large", "ID_UI_Value_XLarge", + }; + + private static void BindUiSection( + UiTemplateListBox listBox, + OptionPage page, + Func resolveString, + Bindings bindings, + ref ChatSettings chat) + { + BuildHeaderRow(listBox, "ID_UI_UISection", resolveString); + + // Chat Font Face/Size: store-only, distinct from the existing live + // ChatSettings.FontSize (see that field's own doc for why). The + // face-choice array is authored with only ONE literal decoded so + // far ("Arial") — the remaining installed system fonts are a + // per-machine list retail enumerates at runtime + // (UIPreferences::AttachPreference's own SmartArray grow loop has + // no fixed upper bound in the decompile); the menu still shows + // every choice this table names rather than guessing a longer list. + BuildMenuRow( + listBox, "ID_UI_ChatFontFace", ChatFontFaceChoices, page, resolveString, + read: () => bindings.LoadChat().ChatFontFace, + apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontFace = value }), + defaultValue: 2); + + BuildMenuRow( + listBox, "ID_UI_ChatFontSize", ChatFontSizeChoices, page, resolveString, + read: () => bindings.LoadChat().ChatFontSizeIndex, + apply: value => bindings.SaveChat(bindings.LoadChat() with { ChatFontSizeIndex = value }), + defaultValue: 1); + + chat = bindings.LoadChat(); + } + + // ── Row builders (shared shapes) ──────────────────────────────────── + + private static void BuildHeaderRow( + UiTemplateListBox listBox, string headerKey, Func resolveString) + { + if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: header template did not build as " + + $"UiText for '{headerKey}'."); + return; + } + + string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(headerKey)); + if (label is null) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: header string '{headerKey}' did not " + + "resolve from the DAT string table — the row renders with no text rather " + + "than an invented label."); + return; + } + + header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) }; + } + + private static void BuildSeparatorRow(UiTemplateListBox listBox) + { + if (listBox.AddItemFromTemplateList(SeparatorTemplateIndex) is null) + Console.WriteLine("[D.2b] ConfigOptionsPageController: separator template did not build."); + } + + private static void BuildToggleRow( + UiTemplateListBox listBox, + string labelKey, + bool defaultValue, + OptionPage page, + Func resolveString, + Func read, + Action apply) + { + UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex); + if (row is null) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: toggle template did not build for " + + $"'{labelKey}'."); + return; + } + + UiButton? checkbox = FindCheckbox(row); + if (checkbox is null) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: no checkbox child found in the " + + $"toggle row for '{labelKey}'."); + return; + } + + ApplyLabelAndTooltip(checkbox, labelKey, resolveString); + + bool initial = read(); + checkbox.Selected = initial; + + var row_ = new BoolOptionRow( + initial, + defaultValue, + apply: value => + { + checkbox.Selected = value; + apply(value); + }, + read: read, + refresh: value => checkbox.Selected = value); + page.Register(row_); + + checkbox.OnClick = () => row_.SetCurrentValue(checkbox.Selected); + } + + /// Builds a slider row from EITHER + /// (idx3, Mouse Look Sensitivity only) or + /// (idx6, the other six) — structurally identical leaves for this + /// controller's purposes (name text + slider; idx6's extra range-caption + /// children are deliberately left untouched — see class doc's U4 note). + /// Converts between the row's REAL-unit current/default (what + /// / traffic in — the + /// settings-store unit) and the widget's normalized [0,1] scalar space. + private static void BuildSliderRow( + UiTemplateListBox listBox, + int templateIndex, + string labelKey, + float min, + float max, + float defaultValue, + OptionPage page, + Func resolveString, + Func read, + Action apply) + { + UiElement? row = listBox.AddItemFromTemplateList(templateIndex); + if (row is null) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: slider template did not build for " + + $"'{labelKey}'."); + return; + } + + if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label) + SetLabelText(label, labelKey, resolveString); + + if (UiElement.FindDescendant(row, SliderElementId) is not UiScrollbar slider) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: no slider leaf found in the row for " + + $"'{labelKey}'."); + return; + } + + float initial = read(); + slider.SetScalarPosition(ToNormalized(initial, min, max)); + + var row_ = new FloatOptionRow( + initial, + defaultValue, + apply: value => + { + slider.SetScalarPosition(ToNormalized(value, min, max)); + apply(value); + }, + read: read, + refresh: value => slider.SetScalarPosition(ToNormalized(value, min, max))); + page.Register(row_); + + slider.ScalarChanged = normalized => row_.SetCurrentValue(FromNormalized(normalized, min, max)); + } + + /// Builds the toggle+slider trio (idx5) — the checkbox half + /// carries the ONLY row label (retail's own row shape has no separate + /// name text; see class doc). Both halves are [0,1]-ranged in this + /// campaign (every trio here is a volume knob), so no unit conversion + /// is needed for the slider half. + private static void BuildTrioRow( + UiTemplateListBox listBox, + string toggleLabelKey, + bool toggleDefault, + float sliderMin, + float sliderMax, + float sliderDefault, + OptionPage page, + Func resolveString, + Func toggleRead, + Action toggleApply, + Func sliderRead, + Action sliderApply) + { + if (listBox.AddItemFromTemplateList(TrioTemplateIndex) is not UiOptionToggleSlider trio) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: trio template did not build as " + + $"UiOptionToggleSlider for '{toggleLabelKey}'."); + return; + } + + UiButton? checkbox = trio.Toggle; + UiScrollbar? slider = trio.Slider; + if (checkbox is null || slider is null) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: trio row for '{toggleLabelKey}' is " + + $"missing its toggle or slider child (toggle={checkbox is not null}, " + + $"slider={slider is not null})."); + return; + } + + ApplyLabelAndTooltip(checkbox, toggleLabelKey, resolveString); + + bool toggleInitial = toggleRead(); + checkbox.Selected = toggleInitial; + var toggleRow = new BoolOptionRow( + toggleInitial, + toggleDefault, + apply: value => + { + checkbox.Selected = value; + toggleApply(value); + }, + read: toggleRead, + refresh: value => checkbox.Selected = value); + page.Register(toggleRow); + checkbox.OnClick = () => toggleRow.SetCurrentValue(checkbox.Selected); + + float sliderInitial = sliderRead(); + slider.SetScalarPosition(ToNormalized(sliderInitial, sliderMin, sliderMax)); + var sliderRow = new FloatOptionRow( + sliderInitial, + sliderDefault, + apply: value => + { + slider.SetScalarPosition(ToNormalized(value, sliderMin, sliderMax)); + sliderApply(value); + }, + read: sliderRead, + refresh: value => slider.SetScalarPosition(ToNormalized(value, sliderMin, sliderMax))); + page.Register(sliderRow); + slider.ScalarChanged = normalized => + sliderRow.SetCurrentValue(FromNormalized(normalized, sliderMin, sliderMax)); + } + + /// Builds a menu row (idx4) over an payload — + /// every Config-tab menu except Resolution (see + /// ). + private static void BuildMenuRow( + UiTemplateListBox listBox, + string labelKey, + string[] choiceKeys, + OptionPage page, + Func resolveString, + Func read, + Action apply, + int defaultValue) + { + UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); + if (row is null) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: menu template did not build for " + + $"'{labelKey}'."); + return; + } + + if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label) + SetLabelText(label, labelKey, resolveString); + + if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: no UiMenu leaf found in the row for " + + $"'{labelKey}'."); + return; + } + + string[] choiceLabels = new string[choiceKeys.Length]; + var items = new UiMenu.MenuItem[choiceKeys.Length]; + for (int i = 0; i < choiceKeys.Length; i++) + { + string? choiceLabel = resolveString(StringTableId, DatStringResolver.ComputeHash(choiceKeys[i])); + choiceLabels[i] = choiceLabel ?? string.Empty; + if (choiceLabel is null) + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: menu choice '{choiceKeys[i]}' " + + $"(for '{labelKey}') did not resolve — item renders with no caption " + + "rather than invented English."); + items[i] = new UiMenu.MenuItem(choiceLabels[i], i); + } + menu.Items = items; + + int initial = read(); + menu.Selected = initial; + menu.ButtonLabelProvider = () => + { + int current = menu.Selected is int selected ? selected : initial; + return current >= 0 && current < choiceLabels.Length ? choiceLabels[current] : string.Empty; + }; + + var row_ = new IntOptionRow( + initial, + defaultValue, + apply: value => + { + menu.Selected = value; + apply(value); + }, + read: read, + refresh: value => menu.Selected = value); + page.Register(row_); + + menu.OnSelect = payload => + { + if (payload is int value) + row_.SetCurrentValue(value); + }; + } + + /// Resolution's own menu — the one Config-tab row built via + /// retail's arg3=0/SetUserPreference path rather than the + /// generic UIPreferences::InqPreference label lookup every other + /// menu here uses; its payload is the resolution string itself. + private static void BuildStringMenuRow( + UiTemplateListBox listBox, + string labelKey, + IReadOnlyList choices, + OptionPage page, + Func resolveString, + Func read, + Action apply, + string defaultValue) + { + UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex); + if (row is null) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: menu template did not build for " + + $"'{labelKey}'."); + return; + } + + if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label) + SetLabelText(label, labelKey, resolveString); + + if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: no UiMenu leaf found in the row for " + + $"'{labelKey}'."); + return; + } + + var items = new UiMenu.MenuItem[choices.Count]; + for (int i = 0; i < choices.Count; i++) + items[i] = new UiMenu.MenuItem(choices[i], choices[i]); + menu.Items = items; + + string initial = read(); + menu.Selected = initial; + menu.ButtonLabelProvider = () => menu.Selected as string ?? initial; + + var stringRow = new StringOptionRow( + initial, + defaultValue, + apply: value => + { + menu.Selected = value; + apply(value); + }, + read: read, + refresh: value => menu.Selected = value); + page.Register(stringRow); + + menu.OnSelect = payload => + { + if (payload is string value) + stringRow.SetCurrentValue(value); + }; + } + + private static void ApplyLabelAndTooltip( + UiButton checkbox, string labelKey, Func resolveString) + { + string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey)); + if (label is not null) + checkbox.Label = label; + else + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — " + + "row renders with no caption rather than invented English."); + + string? tooltip = resolveString( + StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help")); + if (tooltip is not null) + checkbox.TooltipText = tooltip; + } + + private static void SetLabelText(UiText label, string labelKey, Func resolveString) + { + string? text = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey)); + if (text is null) + { + Console.WriteLine( + $"[D.2b] ConfigOptionsPageController: label '{labelKey}' did not resolve — " + + "row renders with no caption rather than invented English."); + return; + } + label.LinesProvider = () => new[] { new UiText.Line(text, label.DefaultColor) }; + } + + private static UiButton? FindCheckbox(UiElement root) + { + if (root is UiButton direct) return direct; + foreach (UiElement child in root.Children) + if (child is UiButton button) + return button; + return UiElement.FindDescendant(root, ToggleCheckboxElementId) as UiButton; + } + + /// Real-unit value → the widget's normalized [0,1] scalar + /// space ('s own clamp). + private static float ToNormalized(float real, float min, float max) + => max > min ? (real - min) / (max - min) : 0f; + + /// Inverse of . + private static float FromNormalized(float normalized, float min, float max) + => min + normalized * (max - min); +} diff --git a/src/AcDream.App/UI/Layout/OptionPageModel.cs b/src/AcDream.App/UI/Layout/OptionPageModel.cs index ad05004e..7d13e705 100644 --- a/src/AcDream.App/UI/Layout/OptionPageModel.cs +++ b/src/AcDream.App/UI/Layout/OptionPageModel.cs @@ -279,6 +279,159 @@ public sealed class FloatOptionRow : IOptionRow } } +/// +/// Campaign OP slice OP6: a UIOption_Menu leaf's current/saved/default +/// triple over an — the Config tab's menu rows (Sound +/// Features, Resolution, the four texture-detail-family selectors, Chat Font +/// Face/Size). Same shape as / +/// (research doc §3.3's leaf semantics apply identically — every +/// UIOption subclass shares the same base current/saved/default verbs). +/// is what picking a popup row runs — applies +/// live immediately, does not touch . +/// +public sealed class IntOptionRow : IOptionRow +{ + private readonly Action? _apply; + private readonly Func? _read; + private readonly Action? _refresh; + private Action? _notifyPageOptionChanged; + private int _current; + private int _saved; + private int _default; + + public IntOptionRow( + int initial, + int defaultValue, + Action? apply = null, + Func? read = null, + Action? refresh = null) + { + _current = initial; + _saved = initial; + _default = defaultValue; + _apply = apply; + _read = read; + _refresh = refresh; + } + + /// The live value — the menu's currently-selected payload. + public int Current => _current; + + /// The committed baseline Reset reverts to. + public int Saved => _saved; + + /// The value Defaults restores. + public int DefaultValue => _default; + + public bool Changed => _saved != _current; + + public void SetDefaultValue(int value) => _default = value; + + public void SetCurrentValue(int value) + { + _current = value; + _apply?.Invoke(value); + _notifyPageOptionChanged?.Invoke(); + } + + public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify; + + public void SaveCurrentValue() + { + if (_read is not null) + { + _current = _read(); + _refresh?.Invoke(_current); + } + _saved = _current; + } + + public void RestoreSavedValue() + { + _current = _saved; + _apply?.Invoke(_current); + } + + public void RestoreDefaultValue() + { + _current = _default; + _apply?.Invoke(_current); + } +} + +/// +/// Campaign OP slice OP6: a UIOption_Menu leaf's current/saved/default +/// triple over a payload — used ONLY for the Config +/// tab's Resolution row, whose retail UIOption_Menu::SetUserPreference +/// path (built via AddMenuOption(..., arg3=0), the tab's own +/// documented outlier) carries the resolution string itself rather than an +/// enum-choice index. Same current/saved/default shape as +/// /. +/// +public sealed class StringOptionRow : IOptionRow +{ + private readonly Action? _apply; + private readonly Func? _read; + private readonly Action? _refresh; + private Action? _notifyPageOptionChanged; + private string _current; + private string _saved; + private string _default; + + public StringOptionRow( + string initial, + string defaultValue, + Action? apply = null, + Func? read = null, + Action? refresh = null) + { + _current = initial; + _saved = initial; + _default = defaultValue; + _apply = apply; + _read = read; + _refresh = refresh; + } + + public string Current => _current; + public string Saved => _saved; + public string DefaultValue => _default; + public bool Changed => _saved != _current; + + public void SetDefaultValue(string value) => _default = value; + + public void SetCurrentValue(string value) + { + _current = value; + _apply?.Invoke(value); + _notifyPageOptionChanged?.Invoke(); + } + + public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify; + + public void SaveCurrentValue() + { + if (_read is not null) + { + _current = _read(); + _refresh?.Invoke(_current); + } + _saved = _current; + } + + public void RestoreSavedValue() + { + _current = _saved; + _apply?.Invoke(_current); + } + + public void RestoreDefaultValue() + { + _current = _default; + _apply?.Invoke(_current); + } +} + /// /// Campaign OP slice OP5: one of the Chat tab's five per-window text-filter /// UIOption_CheckboxBitfield64 blocks — the current/saved/default triple over diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index daec1da5..805a4bb9 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -202,7 +202,18 @@ public sealed record OptionsRuntimeBindings( // Campaign OP slice OP4 (2026-08-11): reads a live character-option // bit by its linear id (RuntimeCharacterOptionsState.GetOptionBit) — // the Character-tab panel's row-seed source. - Func CurrentCharacterOption); + Func CurrentCharacterOption, + // Campaign OP slice OP6 (2026-08-11): the Config tab's Display/Audio- + // backed rows. RuntimeSettingsController is the SOLE writer of these + // two sections (unlike Chat, which OP5 already routes through a raw + // SettingsStore side channel — Config's Chat Font Face/Size rows reuse + // THAT same store directly at the composition site instead of these + // two delegates, to avoid a stale-cache clobber of CH6's filter/opacity + // writes; see RetailUiRuntime.MountOptionsPanel). + Func LoadDisplay, + Action SaveDisplay, + Func LoadAudio, + Action SaveAudio); public sealed record InventoryRuntimeBindings( ClientObjectTable Objects, @@ -2131,6 +2142,50 @@ public sealed class RetailUiRuntime : IDisposable Console.WriteLine("[UI] options panel: Chat tab rows did not bind."); } + // Campaign OP slice OP6 (2026-08-11): the Config tab's 6 headers + + // 27 rows. Same "runs before ActivateTabs, own dat-lock scope" + // shape as the Character/Chat blocks above. + lock (_bindings.Assets.DatLock) + { + var strings = new DatStringResolver(_bindings.Assets.Dats); + + bool configBound = Layout.ConfigOptionsPageController.Bind( + layout, + controller.ConfigPage, + templateResolver: (templateLayoutId, templateElementId) => + { + ElementInfo? info = LayoutImporter.ImportInfos( + _bindings.Assets.Dats, templateLayoutId, templateElementId); + return info is null + ? null + : LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont, + strings.Resolve).Root; + }, + resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId), + new Layout.ConfigOptionsPageController.Bindings( + LoadDisplay: _bindings.Options.LoadDisplay, + SaveDisplay: _bindings.Options.SaveDisplay, + LoadAudio: _bindings.Options.LoadAudio, + SaveAudio: _bindings.Options.SaveAudio, + LoadCameraTurning: _bindings.Options.LoadCameraTurning, + SaveCameraTurning: _bindings.Options.SaveCameraTurning, + // Chat Font Face/Size route through the SAME raw + // SettingsStore side channel SaveChatWindowFilters/ + // SaveChatOpacity already use — RuntimeSettingsController's + // cached ChatSettings is NOT refreshed by those direct + // writes, so reading/writing through it here would + // silently clobber CH6's filter/opacity edits with a + // stale snapshot the next time either surface saves. + LoadChat: () => _bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default, + SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat))); + if (!configBound) + Console.WriteLine("[UI] options panel: Config tab rows did not bind."); + } + controller.ActivateTabs(); RetailWindowHandle handle = RetailWindowFrame.Mount( diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs index 1821efa1..364b7fdc 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs @@ -18,16 +18,56 @@ namespace AcDream.UI.Abstractions.Panels.Settings; /// who never opens the Audio tab gets identical behaviour to the /// previous env-var-only world. /// +/// +/// +/// Campaign OP slice OP6 (2026-08-11): the six trailing fields are the +/// remaining Config-tab "Sound Options" rows, byte-verified from +/// gmClient::InitUIPreferences @0x004035b0 +/// (UIPreferences::AttachPreference calls). / +/// gate the EXISTING live / +/// knobs (RuntimeSettingsStartupTargets.ApplyAudio +/// sends 0 to the engine when disabled, else the slider value — retail's +/// own Sound_SoundDisabled/Sound_AmbientSoundDisabled toggle +/// halves of the SAME UIOption_CheckboxSlider row). The other four +/// fields are honest store-only round-trips (register row, OP6): retail +/// registers Sound_InterfaceSoundVolume/Sound_InterfaceSoundDisabled +/// and then never reads them either — AP-174 already documents "interface +/// sounds are scaled by the EFFECT knob" — so acdream matches retail's own +/// dead-knob behaviour rather than building a working one; +/// (Stereo/Mono) and have no acdream +/// mixer-channel-count or window-focus-mute consumer. +/// /// public sealed record AudioSettings( float Master, float Sfx, - float Ambient) + float Ambient, + // OP6: Sound Options menu row — Sound_SoundFeatures (Stereo=0/Mono=1). + // Store-only: acdream's OpenAL backend has no channel-count toggle. + int SoundFeatures = 0, + // OP6: the toggle halves of the Sound/Ambient volume trios + // (Sound_SoundDisabled / Sound_AmbientSoundDisabled). LIVE — gate the + // existing Sfx/Ambient knobs (RuntimeSettingsStartupTargets.ApplyAudio). + bool SfxDisabled = true, + bool AmbientDisabled = true, + // OP6: the Interface Sound trio — retail's own dead knob (AP-174). + // Store-only. + bool InterfaceDisabled = true, + float InterfaceVolume = 1.0f, + // OP6: Sound_PlaySoundOnlyWhenActive — store-only, no window-focus + // mute subsystem exists. + bool PlaySoundOnlyWhenActive = true) { /// /// Values used on first launch. Retail's own defaults are 1.0 for every /// sound preference (SoundManager::InitPrefs @ 0x005503F0), - /// so ambient starts at unity rather than the invented 0.8. + /// so ambient starts at unity rather than the invented 0.8. The six OP6 + /// trailing fields default to retail's OWN byte-verified + /// gmClient::InitUIPreferences literals: SetDefaultValue(1, + /// 0x3f800000) on every trio (toggle=checked/"Disabled"=true, + /// slider=1.0) — retail's 2013 EoR build genuinely ships every Sound + /// category checkbox CHECKED (disabled) by default; ported faithfully, + /// not "fixed". /// public static AudioSettings Default { get; } = new( Master: 1.0f, diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/CameraTurningSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/CameraTurningSettings.cs index 5f1a6751..c761cfc9 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/CameraTurningSettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/CameraTurningSettings.cs @@ -21,14 +21,26 @@ public sealed record CameraTurningSettings( float AdjustmentSpeed, float MouseLookSensitivity, bool AlignToSlope, - bool InvertMouseLookYAxis) + bool InvertMouseLookYAxis, + // Campaign OP slice OP6: Input_UseMouseTurning — the Config tab's OWN + // client-local UIPreference checkbox (gmClient::InitUIPreferences, + // AttachPreference(&Input_UseMouseTurning, 4, ...)), DISTINCT from the + // server-synced PlayerOption.UseMouseTurning bit the Gameplay tab's + // "Use Mouse Turning Settings" macro sends via SetSingleCharacterOption + // (0x0005) — that bit is NOT modeled here (MouseTurningSettingsMacro's + // own sixth target). Store-only, same TS-74 disposition as the five + // fields above: acdream has no persistent mouse-turning camera mode for + // EITHER preference to drive. + bool UseMouseTurning = false) { /// /// Retail's ORDINARY Config-tab defaults (NOT the mouse-turning macro's /// targets — gmConfigUI::InitOptions @0x0049E400, research doc §4): /// Stiffness 0.45 (0x3EE66666), AdjustmentSpeed 40.0 /// (0x42200000), MouseLookSensitivity 0.55 (0x3F0CCCCD), - /// AlignToSlope on, InvertMouseLookYAxis off. + /// AlignToSlope on, InvertMouseLookYAxis off, UseMouseTurning off + /// (gmConfigUI::InitOptions @0x0049E7AA: + /// AddToggleOption(this, &Input_UseMouseTurning)->SetDefaultValue(0)). /// public static CameraTurningSettings Default { get; } = new( Stiffness: 0.45f, diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs index 3a27f6b4..16227719 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs @@ -91,7 +91,19 @@ public sealed record ChatSettings( // retail 0.5-while-idle fade by default, but the Settings → Chat // transparency slider remains fully user-settable (AP-190). float DefaultOpacity = 1.0f, - float ActiveOpacity = 1.0f) + float ActiveOpacity = 1.0f, + // Campaign OP slice OP6: the Config tab's "UI Options" section — + // UI_ChatFontFace (retail Windows TrueType face name, enum choices + // starting "Arial") and UI_ChatFontSize (index into + // Tiny/Small/Medium/Large/XLarge, gmClient::InitUIPreferences + // @0x0040387b/@0x00403a1a). Deliberately NOT the same field as + // FontSize above: FontSize is acdream's own live 10..20pt render + // knob with no verified index-to-point mapping to retail's five-tier + // enum, and acdream's text rendering has no arbitrary system-font-face + // swap (DAT-baked/bitmap fonts only) — both new fields are honest + // store-only round-trips (register row, OP6). + int ChatFontFace = 2, + int ChatFontSizeIndex = 1) { /// /// N4 (CH3 Opus review): matches ACE's ACTUAL diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index 9a747f0d..310419a9 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -16,9 +16,19 @@ public enum ParticleRange /// /// Display-related preferences persisted to settings.json. -/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces -/// the various ACDREAM_* environment variables for resolution + -/// windowed mode with an in-game UI. +/// Originally documented as "no retail equivalent for FOV / vsync etc" — +/// Campaign OP slice OP6's Config-tab research corrected that: retail's +/// gmClient::InitUIPreferences @0x004035b0 DOES register +/// Render_FieldOfView (ID_Graphics_FieldOfView, range +/// [10,160]), Display_SyncToRefresh, Display_Resolution, and +/// Render_ScreenBrightness (range [-1,1], mapped onto +/// below) as genuine UserPreferences.ini rows — +/// they simply had no acdream UI surface until OP6's Config tab. Resolution/ +/// Fullscreen are LIVE on save (RuntimeSettingsTargets. +/// ApplyDisplayWindowState resizes the window immediately); VSync/FOV/ +/// Gamma apply at the next launch only (RuntimeSettingsController. +/// ApplyStartup), matching this record's pre-existing behaviour — OP6 +/// did not change when these three take effect, only how they're reached. /// /// /// Records are immutable; mutation goes through @@ -34,7 +44,26 @@ public sealed record DisplaySettings( float Gamma, bool ShowFps, QualityPreset Quality, - ParticleRange ParticleRange) + ParticleRange ParticleRange, + // Campaign OP slice OP6: the Config tab's "Graphics Options" + + // "Rendering Quality Options" rows with no acdream renderer consumer — + // the world renderer is Vulkan + one aggregate QualityPreset, not + // per-feature knobs (register row, OP6). Persisted faithfully; every + // default below is retail's own byte-verified + // gmClient::InitUIPreferences / gmConfigUI::InitOptions literal. + bool AutomaticDegrades = false, + float GraphicsPerformance = 0f, + float DegradeDistance = 50f, + int LandscapeTextureDetail = 2, + int EnvironmentTextureDetail = 1, + int TextureFiltering = 1, + // UNRESOLVED (OP6, cite in register row): retail's own + // SetDefaultValue(8) does not index its 6-entry SetEnumChoices array + // (VeryLow..Extreme) — reproduced faithfully as an opaque int, not + // guessed into a clamped index. + int LandscapeDrawDistance = 8, + bool BuildingDetailTextures = true, + bool MultiPassAlpha = false) { /// Values used on first launch / when settings.json is absent. /// Geometry defaults preserve the pre-L.0 runtime state: Resolution diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index e725c223..33ba1933 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -70,7 +70,16 @@ public sealed class SettingsStore ShowFps: ReadBool (disp, "showFps", d.ShowFps), Quality: ReadQuality (disp, "quality", d.Quality), ParticleRange: ReadParticleRange( - disp, "particleRange", d.ParticleRange)); + disp, "particleRange", d.ParticleRange), + AutomaticDegrades: ReadBool (disp, "automaticDegrades", d.AutomaticDegrades), + GraphicsPerformance: ReadFloat(disp, "graphicsPerformance", d.GraphicsPerformance), + DegradeDistance: ReadFloat(disp, "degradeDistance", d.DegradeDistance), + LandscapeTextureDetail: ReadInt (disp, "landscapeTextureDetail", d.LandscapeTextureDetail), + EnvironmentTextureDetail:ReadInt (disp, "environmentTextureDetail",d.EnvironmentTextureDetail), + TextureFiltering: ReadInt (disp, "textureFiltering", d.TextureFiltering), + LandscapeDrawDistance: ReadInt (disp, "landscapeDrawDistance", d.LandscapeDrawDistance), + BuildingDetailTextures: ReadBool (disp, "buildingDetailTextures", d.BuildingDetailTextures), + MultiPassAlpha: ReadBool (disp, "multiPassAlpha", d.MultiPassAlpha)); } catch (Exception ex) { @@ -109,7 +118,13 @@ public sealed class SettingsStore return new AudioSettings( Master: ReadFloat(audio, "master", d.Master), Sfx: ReadFloat(audio, "sfx", d.Sfx), - Ambient: ReadFloat(audio, "ambient", d.Ambient)); + Ambient: ReadFloat(audio, "ambient", d.Ambient), + SoundFeatures: ReadInt (audio, "soundFeatures", d.SoundFeatures), + SfxDisabled: ReadBool (audio, "sfxDisabled", d.SfxDisabled), + AmbientDisabled: ReadBool (audio, "ambientDisabled", d.AmbientDisabled), + InterfaceDisabled: ReadBool (audio, "interfaceDisabled", d.InterfaceDisabled), + InterfaceVolume: ReadFloat(audio, "interfaceVolume", d.InterfaceVolume), + PlaySoundOnlyWhenActive: ReadBool (audio, "playSoundOnlyWhenActive", d.PlaySoundOnlyWhenActive)); } catch (Exception ex) { @@ -199,7 +214,9 @@ public sealed class SettingsStore ChatWindow4Filter: ReadULong(chat, "chatWindow4Filter", d.ChatWindow4Filter), ChatWindowMainFilter: ReadULong(chat, "chatWindowMainFilter", d.ChatWindowMainFilter), DefaultOpacity: ReadFloat(chat, "defaultOpacity", d.DefaultOpacity), - ActiveOpacity: ReadFloat(chat, "activeOpacity", d.ActiveOpacity)); + ActiveOpacity: ReadFloat(chat, "activeOpacity", d.ActiveOpacity), + ChatFontFace: ReadInt(chat, "chatFontFace", d.ChatFontFace), + ChatFontSizeIndex: ReadInt(chat, "chatFontSizeIndex", d.ChatFontSizeIndex)); } catch (Exception ex) { @@ -235,7 +252,8 @@ public sealed class SettingsStore AdjustmentSpeed: ReadFloat(ct, "adjustmentSpeed", d.AdjustmentSpeed), MouseLookSensitivity: ReadFloat(ct, "mouseLookSensitivity", d.MouseLookSensitivity), AlignToSlope: ReadBool (ct, "alignToSlope", d.AlignToSlope), - InvertMouseLookYAxis: ReadBool (ct, "invertMouseLookYAxis", d.InvertMouseLookYAxis)); + InvertMouseLookYAxis: ReadBool (ct, "invertMouseLookYAxis", d.InvertMouseLookYAxis), + UseMouseTurning: ReadBool (ct, "useMouseTurning", d.UseMouseTurning)); } catch (Exception ex) { @@ -580,6 +598,8 @@ public sealed class SettingsStore { ["activeOpacity"] = c.ActiveOpacity, ["appearOffline"] = c.AppearOffline, + ["chatFontFace"] = c.ChatFontFace, + ["chatFontSizeIndex"] = c.ChatFontSizeIndex, ["chatWindow1Filter"] = c.ChatWindow1Filter, ["chatWindow2Filter"] = c.ChatWindow2Filter, ["chatWindow3Filter"] = c.ChatWindow3Filter, @@ -617,13 +637,22 @@ public sealed class SettingsStore private static SortedDictionary BuildDisplayObject(DisplaySettings d) => new(StringComparer.Ordinal) { + ["automaticDegrades"] = d.AutomaticDegrades, + ["buildingDetailTextures"] = d.BuildingDetailTextures, + ["degradeDistance"] = d.DegradeDistance, + ["environmentTextureDetail"] = d.EnvironmentTextureDetail, ["fieldOfView"] = d.FieldOfView, ["fullscreen"] = d.Fullscreen, ["gamma"] = d.Gamma, + ["graphicsPerformance"] = d.GraphicsPerformance, + ["landscapeDrawDistance"] = d.LandscapeDrawDistance, + ["landscapeTextureDetail"] = d.LandscapeTextureDetail, + ["multiPassAlpha"] = d.MultiPassAlpha, ["particleRange"] = d.ParticleRange.ToString(), ["quality"] = d.Quality.ToString(), ["resolution"] = d.Resolution, ["showFps"] = d.ShowFps, + ["textureFiltering"] = d.TextureFiltering, ["vsync"] = d.VSync, }; @@ -635,14 +664,21 @@ public sealed class SettingsStore ["invertMouseLookYAxis"] = c.InvertMouseLookYAxis, ["mouseLookSensitivity"] = c.MouseLookSensitivity, ["stiffness"] = c.Stiffness, + ["useMouseTurning"] = c.UseMouseTurning, }; private static SortedDictionary BuildAudioObject(AudioSettings a) => new(StringComparer.Ordinal) { - ["ambient"] = a.Ambient, - ["master"] = a.Master, - ["sfx"] = a.Sfx, + ["ambient"] = a.Ambient, + ["ambientDisabled"] = a.AmbientDisabled, + ["interfaceDisabled"] = a.InterfaceDisabled, + ["interfaceVolume"] = a.InterfaceVolume, + ["master"] = a.Master, + ["playSoundOnlyWhenActive"] = a.PlaySoundOnlyWhenActive, + ["sfx"] = a.Sfx, + ["sfxDisabled"] = a.SfxDisabled, + ["soundFeatures"] = a.SoundFeatures, }; /// @@ -710,6 +746,10 @@ public sealed class SettingsStore => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number ? el.GetSingle() : fallback; + private static int ReadInt(JsonElement obj, string name, int fallback) + => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number + ? el.GetInt32() : fallback; + private static ulong ReadULong(JsonElement obj, string name, ulong fallback) { if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index 5f404375..1e06b52c 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -300,6 +300,10 @@ public sealed class RuntimeSettingsControllerTests "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", @@ -1153,6 +1157,14 @@ public sealed class RuntimeSettingsControllerTests throw new InvalidOperationException("quality target failed"); } + public List AudioCalls { get; } = []; + + public void ApplyAudio(AudioSettings audio) + { + AudioCalls.Add(audio); + events.Add("target-audio"); + } + public void ApplyUiLock(bool locked) { UiLockCalls++; diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs new file mode 100644 index 00000000..fec6c80b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -0,0 +1,596 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign OP slice OP6 (2026-08-11) conformance + behavior tests for +/// — the same hermetic pattern +/// CharacterOptionsPageControllerTests/ChatOptionsPageControllerTests +/// established: pure-data conformance against the byte-verified decomp +/// tables (gmConfigUI::InitOptions + gmClient::InitUIPreferences), +/// then behavioral tests against the committed +/// options_panel_2100006E_1000018D.json + options_2100002B.json +/// fixtures — no live DAT access. Also covers the shared scrollbar-id hazard +/// (Config and Chat both author element 0x10000201) and a settings- +/// store round-trip per record OP6 touched. +/// +public sealed class ConfigOptionsPageControllerTests +{ + // ── Pure data conformance ──────────────────────────────────────────────── + + [Fact] + public void SixSectionHeaders_AreDistinct() + { + string[] headers = + [ + "ID_Sound_SoundSection", + "ID_Camera_CameraSection", + "ID_Graphics_GraphicsSection", + "ID_Graphics_TextureSection", + "ID_Input_InputSection", + "ID_UI_UISection", + ]; + Assert.Equal(6, headers.Distinct().Count()); + } + + [Fact] + public void AudioSettings_Default_MatchesRetailByteVerifiedTrioDefaults() + { + // gmClient::InitUIPreferences: every Sound trio's checkbox defaults + // to CHECKED ("Disabled"=true) — SetDefaultValue(1, 0x3f800000) in + // gmConfigUI::InitOptions, ported faithfully (see + // AudioSettings.Default's own doc for why this looks backwards but + // isn't a "fix"). + AudioSettings d = AudioSettings.Default; + Assert.Equal(0, d.SoundFeatures); + Assert.True(d.SfxDisabled); + Assert.True(d.AmbientDisabled); + Assert.True(d.InterfaceDisabled); + Assert.Equal(1.0f, d.InterfaceVolume); + Assert.True(d.PlaySoundOnlyWhenActive); + Assert.Equal(1.0f, d.Sfx); + Assert.Equal(1.0f, d.Ambient); + } + + [Fact] + public void CameraTurningSettings_Default_MatchesRetailByteVerifiedConfigDefaults() + { + CameraTurningSettings d = CameraTurningSettings.Default; + Assert.Equal(0.45f, d.Stiffness); + Assert.Equal(40.0f, d.AdjustmentSpeed); + Assert.Equal(0.55f, d.MouseLookSensitivity); + Assert.True(d.AlignToSlope); + Assert.False(d.InvertMouseLookYAxis); + Assert.False(d.UseMouseTurning); + } + + [Fact] + public void DisplaySettings_Default_MatchesRetailByteVerifiedConfigDefaults() + { + DisplaySettings d = DisplaySettings.Default; + Assert.False(d.AutomaticDegrades); + Assert.Equal(0f, d.GraphicsPerformance); + Assert.Equal(50f, d.DegradeDistance); + Assert.Equal(2, d.LandscapeTextureDetail); + Assert.Equal(1, d.EnvironmentTextureDetail); + Assert.Equal(1, d.TextureFiltering); + Assert.Equal(8, d.LandscapeDrawDistance); + Assert.True(d.BuildingDetailTextures); + Assert.False(d.MultiPassAlpha); + } + + [Fact] + public void ChatSettings_Default_MatchesRetailByteVerifiedConfigDefaults() + { + ChatSettings d = ChatSettings.Default; + Assert.Equal(2, d.ChatFontFace); + Assert.Equal(1, d.ChatFontSizeIndex); + } + + // ── Settings-store round trips (SettingsStore, no live DAT) ───────────── + + [Fact] + public void SettingsStore_AudioRoundTrip_PreservesTheSixOP6Fields() + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"acdream-op6-audio-{Guid.NewGuid():N}.json"); + try + { + var store = new SettingsStore(path); + var written = AudioSettings.Default with + { + SoundFeatures = 1, + SfxDisabled = false, + AmbientDisabled = false, + InterfaceDisabled = false, + InterfaceVolume = 0.4f, + PlaySoundOnlyWhenActive = false, + }; + store.SaveAudio(written); + AudioSettings read = store.LoadAudio(); + Assert.Equal(written, read); + } + finally + { + System.IO.File.Delete(path); + } + } + + [Fact] + public void SettingsStore_DisplayRoundTrip_PreservesTheNineOP6Fields() + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"acdream-op6-display-{Guid.NewGuid():N}.json"); + try + { + var store = new SettingsStore(path); + var written = DisplaySettings.Default with + { + AutomaticDegrades = true, + GraphicsPerformance = 0.5f, + DegradeDistance = 75f, + LandscapeTextureDetail = 4, + EnvironmentTextureDetail = 3, + TextureFiltering = 2, + LandscapeDrawDistance = 3, + BuildingDetailTextures = false, + MultiPassAlpha = true, + }; + store.SaveDisplay(written); + DisplaySettings read = store.LoadDisplay(); + Assert.Equal(written, read); + } + finally + { + System.IO.File.Delete(path); + } + } + + [Fact] + public void SettingsStore_CameraTurningRoundTrip_PreservesUseMouseTurning() + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"acdream-op6-camera-{Guid.NewGuid():N}.json"); + try + { + var store = new SettingsStore(path); + var written = CameraTurningSettings.Default with { UseMouseTurning = true }; + store.SaveCameraTurning(written); + CameraTurningSettings read = store.LoadCameraTurning(); + Assert.Equal(written, read); + } + finally + { + System.IO.File.Delete(path); + } + } + + [Fact] + public void SettingsStore_ChatRoundTrip_PreservesFontFaceAndSize() + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"acdream-op6-chat-{Guid.NewGuid():N}.json"); + try + { + var store = new SettingsStore(path); + var written = ChatSettings.Default with { ChatFontFace = 0, ChatFontSizeIndex = 3 }; + store.SaveChat(written); + ChatSettings read = store.LoadChat(); + Assert.Equal(written.ChatFontFace, read.ChatFontFace); + Assert.Equal(written.ChatFontSizeIndex, read.ChatFontSizeIndex); + } + finally + { + System.IO.File.Delete(path); + } + } + + // ── Behavioral: built against the committed fixtures ───────────────────── + + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + private static ElementInfo? Find(ElementInfo n, uint id) + { + if (n.Id == id) return n; + foreach (ElementInfo c in n.Children) + { + ElementInfo? f = Find(c, id); + if (f is not null) return f; + } + return null; + } + + /// The SAME "resolve a row template from the standalone + /// 0x2100002B fixture" resolver every other Options-panel page-controller + /// test file uses — Config's own template array points at 0x2100002B too + /// (the tab-host layout), not the standalone 0x21000029 fixture. + private static Func MakeTemplateResolver() + { + ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos(); + return (layoutId, elementId) => + { + if (layoutId != 0x2100002Bu) return null; + ElementInfo? templateInfo = Find(panelRoot, elementId); + return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root; + }; + } + + private sealed class FakeBindings + { + public DisplaySettings Display = DisplaySettings.Default; + public AudioSettings Audio = AudioSettings.Default; + public CameraTurningSettings CameraTurning = CameraTurningSettings.Default; + public ChatSettings Chat = ChatSettings.Default; + + public List DisplaySaves { get; } = new(); + public List AudioSaves { get; } = new(); + public List CameraTurningSaves { get; } = new(); + public List ChatSaves { get; } = new(); + + public ConfigOptionsPageController.Bindings ToBindings() => new( + LoadDisplay: () => Display, + SaveDisplay: value => { Display = value; DisplaySaves.Add(value); }, + LoadAudio: () => Audio, + SaveAudio: value => { Audio = value; AudioSaves.Add(value); }, + LoadCameraTurning: () => CameraTurning, + SaveCameraTurning: value => { CameraTurning = value; CameraTurningSaves.Add(value); }, + LoadChat: () => Chat, + SaveChat: value => { Chat = value; ChatSaves.Add(value); }); + } + + private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal( + Func? resolveString = null) + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + + var fakeBindings = new FakeBindings(); + bool bound = ConfigOptionsPageController.Bind( + layout, + controller.ConfigPage, + MakeTemplateResolver(), + resolveString ?? ((_, _) => null), + fakeBindings.ToBindings()); + + return (controller, fakeBindings, bound); + } + + [Fact] + public void Bind_Succeeds_AndRegistersExactly30Rows() + { + // 27 visible rows; the 3 toggle+slider trios each register TWO + // IOptionRow instances (toggle half + slider half) — 27 + 3 = 30, + // matching the doc's own "27 Add* calls... three toggle+slider rows + // are one widget each" accounting. + (OptionsPanelController controller, _, bool bound) = BindReal(); + + Assert.True(bound); + Assert.Equal(30, controller.ConfigPage.Rows.Count); + } + + [Fact] + public void Bind_RowTypeSequence_MatchesAuthoredSectionOrder() + { + (OptionsPanelController controller, _, bool bound) = BindReal(); + Assert.True(bound); + + Type[] expected = + [ + typeof(IntOptionRow), // Sound Features menu + typeof(BoolOptionRow), typeof(FloatOptionRow), // Sound trio + typeof(BoolOptionRow), typeof(FloatOptionRow), // Ambient trio + typeof(BoolOptionRow), typeof(FloatOptionRow), // Interface trio + typeof(BoolOptionRow), // Play sound only when active + + typeof(FloatOptionRow), // Camera Stiffness + typeof(FloatOptionRow), // Camera Adjustment Speed + typeof(FloatOptionRow), // Field of View + typeof(BoolOptionRow), // Align To Slope + + typeof(StringOptionRow), // Resolution + typeof(BoolOptionRow), // Full Screen + typeof(BoolOptionRow), // Sync To Refresh + typeof(FloatOptionRow), // Screen Brightness + typeof(BoolOptionRow), // Automatic Degrades + typeof(FloatOptionRow), // Graphics Performance + typeof(FloatOptionRow), // Degrade Distance + + typeof(IntOptionRow), // Landscape Texture Detail + typeof(IntOptionRow), // Environment Texture Detail + typeof(IntOptionRow), // Texture Filtering + typeof(IntOptionRow), // Landscape Draw Distance + typeof(BoolOptionRow), // Building Detail Textures + typeof(BoolOptionRow), // Multi-Pass Alpha + + typeof(FloatOptionRow), // Mouse Look Sensitivity + typeof(BoolOptionRow), // Invert Mouselook Y Axis + typeof(BoolOptionRow), // Use Mouse Turning + + typeof(IntOptionRow), // Chat Font Face + typeof(IntOptionRow), // Chat Font Size + ]; + + Type[] actual = controller.ConfigPage.Rows.Select(r => r.GetType()).ToArray(); + Assert.Equal(expected, actual); + } + + [Fact] + public void ToggleRow_SfxDisabled_WritesThroughAudioBindings() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = (BoolOptionRow)controller.ConfigPage.Rows[1]; // Sound trio toggle + + row.SetCurrentValue(!AudioSettings.Default.SfxDisabled); + + Assert.Single(bindings.AudioSaves); + Assert.Equal(!AudioSettings.Default.SfxDisabled, bindings.AudioSaves[0].SfxDisabled); + // The slider half's own field must be untouched by the toggle write. + Assert.Equal(AudioSettings.Default.Sfx, bindings.AudioSaves[0].Sfx); + } + + [Fact] + public void SliderRow_SoundVolume_ConvertsScalarToRealUnitRange() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = (FloatOptionRow)controller.ConfigPage.Rows[2]; // Sound trio slider, [0,1] + + row.SetCurrentValue(0.25f); + + Assert.Equal(0.25f, bindings.AudioSaves[^1].Sfx); + } + + [Fact] + public void SliderRow_CameraAdjustmentSpeed_ConvertsRealUnitOutOf0To1Range() + { + // Camera_AdjustmentSpeed's retail range is [5,80] — the widget only + // ever sees a normalized [0,1] scalar; SetCurrentValue below drives + // the ROW directly in real units (what CameraTurningSettings stores), + // so this pins the row-level write, not the widget conversion (a + // separate concern already covered by ToNormalized/FromNormalized's + // own math, exercised implicitly by every slider apply/refresh path). + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = (FloatOptionRow)controller.ConfigPage.Rows[9]; // AdjustmentSpeed + + row.SetCurrentValue(62.5f); + + Assert.Equal(62.5f, bindings.CameraTurningSaves[^1].AdjustmentSpeed); + } + + [Fact] + public void MenuRow_SoundFeatures_WritesThroughAudioBindings() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = (IntOptionRow)controller.ConfigPage.Rows[0]; // Sound Features + + row.SetCurrentValue(1); + + Assert.Equal(1, bindings.AudioSaves[^1].SoundFeatures); + } + + [Fact] + public void MenuRow_Resolution_IsStringBacked_AndWritesThroughDisplayBindings() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = (StringOptionRow)controller.ConfigPage.Rows[12]; // Resolution + + row.SetCurrentValue("1920x1080"); + + Assert.Equal("1920x1080", bindings.DisplaySaves[^1].Resolution); + } + + [Fact] + public void ToggleRow_UseMouseTurning_WritesTheConfigTabOwnPreference_NotTheWireBit() + { + // CameraTurningSettings.UseMouseTurning is the Config tab's OWN + // client-local checkbox — distinct from the Gameplay-tab macro's + // server-synced PlayerOption.UseMouseTurning bit. This pins that + // this row writes ONLY the store, never a wire command (there is no + // wire seam wired into ConfigOptionsPageController.Bindings at all). + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = (BoolOptionRow)controller.ConfigPage.Rows[27]; // Use Mouse Turning + + row.SetCurrentValue(true); + + Assert.True(bindings.CameraTurningSaves[^1].UseMouseTurning); + } + + [Fact] + public void MenuRow_ChatFontSize_WritesThroughChatBindings_WithoutTouchingHearFlags() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = (IntOptionRow)controller.ConfigPage.Rows[29]; // Chat Font Size + + row.SetCurrentValue(3); + + Assert.Equal(3, bindings.ChatSaves[^1].ChatFontSizeIndex); + Assert.Equal(ChatSettings.Default.HearGeneralChat, bindings.ChatSaves[^1].HearGeneralChat); + } + + [Fact] + public void Apply_CommitsBaseline_ForAMixOfRowTypes() + { + (OptionsPanelController controller, _, _) = BindReal(); + var toggle = (BoolOptionRow)controller.ConfigPage.Rows[1]; + var slider = (FloatOptionRow)controller.ConfigPage.Rows[2]; + var menu = (IntOptionRow)controller.ConfigPage.Rows[0]; + + toggle.SetCurrentValue(!toggle.Current); + slider.SetCurrentValue(0.1f); + menu.SetCurrentValue(1); + Assert.True(controller.ConfigPage.Changed); + + controller.ConfigPage.Apply(); + + Assert.False(controller.ConfigPage.Changed); + Assert.Equal(toggle.Current, toggle.Saved); + Assert.Equal(slider.Current, slider.Saved); + Assert.Equal(menu.Current, menu.Saved); + } + + [Fact] + public void Defaults_RestoresRetailDefaultValue_ForEveryRow_WithoutCommitting() + { + (OptionsPanelController controller, _, _) = BindReal(); + foreach (IOptionRow r in controller.ConfigPage.Rows) + { + switch (r) + { + case BoolOptionRow b: b.SetCurrentValue(!b.DefaultValue); break; + case FloatOptionRow f: f.SetCurrentValue(f.DefaultValue + 1000f); break; + case IntOptionRow i: i.SetCurrentValue(i.DefaultValue + 1); break; + case StringOptionRow s: s.SetCurrentValue(s.DefaultValue + "-x"); break; + } + } + + controller.ConfigPage.Defaults(); + + foreach (IOptionRow r in controller.ConfigPage.Rows) + { + switch (r) + { + case BoolOptionRow b: Assert.Equal(b.DefaultValue, b.Current); break; + case FloatOptionRow f: Assert.Equal(f.DefaultValue, f.Current); break; + case IntOptionRow i: Assert.Equal(i.DefaultValue, i.Current); break; + case StringOptionRow s: Assert.Equal(s.DefaultValue, s.Current); break; + } + } + } + + // The tab host's own private per-page SLOT ids (OptionsPanelController's + // ConfigPageId/ChatPageId) — the ids that actually survive base-merge in + // the host-mounted tree (each page's own standalone-layout root id does + // NOT survive; see ConfigOptionsPageController.PageSlotElementId's own + // doc). Both controllers keep their own copy private; these test-local + // literals mirror them for scoped lookups exactly the way the + // controllers themselves scope their scrollbar linkage. + private const uint ConfigPageSlotId = 0x10000213u; + private const uint ChatPageSlotId = 0x1000050Cu; + + [Fact] + public void ScrollbarLinkage_ModelPointsAtTheConfigListBoxScroll() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var fakeBindings = new FakeBindings(); + bool bound = ConfigOptionsPageController.Bind( + layout, controller.ConfigPage, MakeTemplateResolver(), (_, _) => null, + fakeBindings.ToBindings()); + Assert.True(bound); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var listBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + var scrollbar = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ScrollbarElementId)); + + Assert.Same(listBox.Scroll, scrollbar.Model); + } + + [Fact] + public void SharedScrollbarId_ChatAndConfigBoundTogether_EachOwnsItsOwnScrollbar() + { + // The shared-id hazard this campaign's binding pattern exists for: + // Chat's ListBox (0x1000050D) and Config's ListBox (0x10000200) both + // author scrollbar element id 0x10000201. Binding BOTH pages against + // the SAME host layout must not let the second Bind() call clobber + // the first page's scrollbar linkage (or vice versa). + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + + var chatBindings = new ChatOptionsPageControllerFakeBindings(); + bool chatBound = ChatOptionsPageController.Bind( + layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null, + chatBindings.ToBindings()); + var configBindings = new FakeBindings(); + bool configBound = ConfigOptionsPageController.Bind( + layout, controller.ConfigPage, MakeTemplateResolver(), (_, _) => null, + configBindings.ToBindings()); + + Assert.True(chatBound); + Assert.True(configBound); + + var chatSlot = UiElement.FindDescendant(controller.TabPanel, ChatPageSlotId)!; + var chatListBox = Assert.IsType( + UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ListBoxElementId)); + var chatScrollbar = Assert.IsType( + UiElement.FindDescendant(chatSlot, ChatOptionsPageController.ScrollbarElementId)); + + var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; + var configListBox = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ListBoxElementId)); + var configScrollbar = Assert.IsType( + UiElement.FindDescendant(configSlot, ConfigOptionsPageController.ScrollbarElementId)); + + Assert.Same(chatListBox.Scroll, chatScrollbar.Model); + Assert.Same(configListBox.Scroll, configScrollbar.Model); + Assert.NotSame(chatScrollbar, configScrollbar); + Assert.NotSame(chatListBox.Scroll, configListBox.Scroll); + } + + private sealed class ChatOptionsPageControllerFakeBindings + { + public float DefaultOpacity = 0.5f; + public float ActiveOpacity = 1.0f; + + public ChatOptionsPageController.Bindings ToBindings() => new( + CurrentDefaultOpacity: () => DefaultOpacity, + CurrentActiveOpacity: () => ActiveOpacity, + SetDefaultOpacity: value => DefaultOpacity = value, + SetActiveOpacity: value => ActiveOpacity = value, + DefaultOpacityDatDefault: 0.5f, + ActiveOpacityDatDefault: 1.0f, + CurrentFilter: _ => 0xFBFFFFFFul, + SetFilter: (_, _) => { }); + } + + [Fact] + public void Bind_MissingListBox_ReturnsFalse_AndDoesNotThrow() + { + var emptyRoot = new ElementInfo { Id = 0, Type = 3 }; + ImportedLayout emptyLayout = LayoutImporter.Build(emptyRoot, NoTex, null); + var page = new OptionPage(); + var fakeBindings = new FakeBindings(); + + bool bound = ConfigOptionsPageController.Bind( + emptyLayout, page, MakeTemplateResolver(), (_, _) => null, + fakeBindings.ToBindings()); + + Assert.False(bound); + Assert.Empty(page.Rows); + } + + [Fact] + public void LabelResolutionFailure_LeavesLabelsNull_NeverInventsEnglish_AndStillRegistersAllRows() + { + (OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => null); + + Assert.True(bound); + Assert.Equal(30, controller.ConfigPage.Rows.Count); + } +}