From 86c0a7e0ee8a5aa70a4cffe918717a23390c52da Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 10 Aug 2026 23:31:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(runtime,net):=20Campaign=20OP=20slice=20OP?= =?UTF-8?q?1=20=E2=80=94=20full=20character-option=20table,=20dirty=20mode?= =?UTF-8?q?l,=20real=200x01A1=20blob=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retail Options panel (Campaign OP) needs a Runtime-owned option map covering all 53 PlayerOption ids and the real batched SetCharacterOptions (0x01A1) blob before any UI can be built on top of it. Today's surface only modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side- channels-vs-ace.md). - CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34) -> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults- button table). Reconstructing CharacterOptions1/2 defaults from the ClientDefault column independently reproduces 0x50C4A54A / 0x00008700, cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs) widened from 6 to all 53 ids to match. - RuntimeCharacterOptionsState: SetOptionBit now resolves through the full table (was a 6-case switch). New TrySetOption is the ONE shared local- write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly: write the bit locally first, then either send 0x0005 immediately (auto- save ids) or MarkDirty for the batched blob, no-op on an unchanged value (retail's own early-return) or an unmodeled id. New dirty model (IsDirty/ FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected TimeProvider so it's fully unit-testable without a live clock. - Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current) now route through TrySetOption instead of duplicating the write; this fixes the headless local-write gap the OP1 research flagged (the direct adapter previously sent the wire message without writing the bit first, same class of bug CH4 fixed for the graphical host). Both also reject an id outside the table instead of silently accepting it. LiveSessionRuntime Factory's SendSingleCharacterOption closure now delegates to the same seam instead of duplicating write-then-send inline. - New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in both adapters, including a new SaveCharacterOptionsRuntimeCmd on the graphical router. - SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions: the real PlayerModule::Pack body per the wire research's field-by-field layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired comps are non-empty, favorite spells always 8 lists, never sets 0x100 or 0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook filters (via new CharacterOptionsBlobSource) instead of zeroing them. Conformance: a hand-computed golden byte vector (not generated by the builder under test — the CH3 builder died of tests that pinned a wrong shape and looked green) plus a round-trip through PlayerDescriptionParser. Contract deviation: the 480 s auto-save timer and the flush-before-logout trigger are implemented as fully-tested pure state-machine logic (TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame loop or graceful-shutdown sequence in this slice — only the explicit SaveOptions verb is production-wired. Wiring the timer touches App's UpdateFrameOrchestrator graph and Headless's tick loop (outside this slice's Runtime/wire-layer scope); wiring logout risks the already-fragile graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's own escape valve ("target: not deferred" with a register row if deferred). Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced, unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's table disagrees with the constructor default for ConfirmVolatileRareUse/ ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed). Tests: table completeness x53, auto-save/client-default split pinned id-by-id against the byte-verified tables, unknown/reserved-id rejection (0x35/0x36 landmines), local-write-then-send on both adapters + the router, the dirty/flush state machine, SaveOptions, and the wire golden vector + PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4 skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests, zero regressions). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 7 +- .../Net/LiveSessionCommandRouter.cs | 8 + .../Net/LiveSessionRuntimeFactory.cs | 51 ++-- .../CurrentGameRuntimeCommandAdapter.cs | 27 +++ .../Messages/SocialActions.cs | 220 +++++++++++++++++- src/AcDream.Core.Net/WorldSession.cs | 27 +++ src/AcDream.Runtime/GameRuntime.cs | 3 +- src/AcDream.Runtime/GameRuntimeCommands.cs | 9 + .../Gameplay/CharacterOptionTable.cs | 169 ++++++++++++++ .../Gameplay/CharacterOptionsBlobSource.cs | 49 ++++ .../Gameplay/RuntimeCharacterState.cs | 189 ++++++++++++--- .../DirectGameRuntimeCommandAdapter.cs | 40 +++- .../InteractionUiRuntimeSourcesTests.cs | 4 + .../Net/LiveSessionCommandRouterTests.cs | 22 +- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 38 +++ .../Messages/SocialActionsTests.cs | 164 +++++++++++++ .../Gameplay/CharacterOptionTableTests.cs | 188 +++++++++++++++ .../Gameplay/RuntimeCharacterStateTests.cs | 161 +++++++++++++ .../DirectGameRuntimeCommandAdapterTests.cs | 150 ++++++++++++ 19 files changed, 1464 insertions(+), 62 deletions(-) create mode 100644 src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs create mode 100644 src/AcDream.Runtime/Gameplay/CharacterOptionsBlobSource.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/CharacterOptionTableTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8978d977..48933725 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -170,7 +170,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 133 active rows (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) — 135 active rows (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 @@ -178,6 +178,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-194 | `CharacterOptionTable`'s `ClientDefault` column (what the Character tab's Defaults button restores) disagrees with the raw constructor default word for three ids: `ConfirmVolatileRareUse` (`0x2D`), `ShowHelm` (`0x2F`), and `ShowCloak` (`0x32`) are all ON in retail's constructor default `CharacterOptions2 = 0x00948700` (`PlayerModule::PlayerModule @0x005D51F0`, byte-verified literal write) but report default-OFF via `PlayerModule::GetDefaultOptionValue @0x005D2A30`, whose own per-option table stops at id `0x2A` and returns `false` for everything past it. This is retail's OWN behavior, reproduced deliberately — the Defaults button does not reproduce a fresh `PlayerModule`. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`ClientDefault` column; see the type's XML doc) | Byte-verified at both addresses (wire research §2.5 for the constructor literals, §8.2 for `GetDefaultOptionValue`'s own table and bounds check) — this is not a guess, it is retail's documented quirk. "Fixing" it to match the constructor default would make acdream's Defaults button MORE correct than retail's own, which is the opposite of this project's goal. | A future OP-campaign slice (OP4, the Character tab's Defaults button) must consult THIS column, not the constructor default word, or a future reader may "fix" this back and silently diverge from retail. | `PlayerModule::GetDefaultOptionValue @0x005D2A30`; `PlayerModule::PlayerModule @0x005D51F0`; `docs/research/2026-08-10-set-character-options-wire.md` §8.2 | +| AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 | | AP-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) | @@ -344,10 +346,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-191 | **Filed 2026-08-10 (Campaign CH round 4, user-gate items 1+2 — retail two-plane glyph outline + authored SpewBox/chat text style, `docs/research/2026-08-10-retail-ui-text-style.md`).** The chat transcript's authored BASE STYLE (`0x10000372` in layout `0x2100003F`) carries a `0x1C`/`0x1D` pair alongside its `0x1A`/`0x1B` — `0x1D` (`TagFontColor[]`) is confirmed authored `ARGB(255,0,178,0)` (green), and `0x1C` is UNVERIFIED but most likely `TagFontDID` by symmetry with `0x1D` (both are pull-based, no `OnSetAttribute` case, unlike `0x1A`/`0x1B`/`0x21`/`0x22` which this round's commit DOES import). Retail's `AppendTextWithFont` selects a font/colour PAIR per appended run via `SetFontDIDNum`/`SetFontColorNum`, so a message's `[General]`-style channel tag can render in a distinct colour/font from the rest of the line — a capability `UiText.Line` does not have (one `Color` per whole line, no sub-line run concept). Landing this needs a per-run tag boundary threaded from `ChatTranscriptRenderer.BuildLines` through `UiText`'s line model into `UiRenderContext.DrawStringDat`, deliberately out of this round's scope (Fix 5 only changed the DEFAULT/uncolored-run seed, not the run model). `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`BuildLines`); `src/AcDream.App/UI/UiText.cs` (`Line`) | The default-fill fix (this same commit) is the higher-value, lower-risk half of retail's text-style gap for the transcript; a per-run tag concept is a larger structural change (touches the line model every transcript consumer reads) better landed as its own reviewed slice than folded into a text-style bugfix commit | Retail's `[General]`/channel-name tag prefix on a chat line renders the SAME colour as the rest of the line in acdream instead of green, and any authored tag-specific font goes unused — cosmetic only, the message text itself is unaffected | `UIElement_Text::AppendTextWithFont @0x00469de0`; `UIElement_Text::SetFontColorHelper @0x00466ac0`; `docs/research/2026-08-10-retail-ui-text-style.md` §2.3/§2.6 | | AP-192 | **Filed 2026-08-10 (Campaign CH round-5 polish, review item S2 — non-UiText outline paths).** Authored glyph outline `0x21`/outline color `0x22` now reach every text-bearing retained widget (`UiText`, `UiButton`, `UiDatElement`, `UiField`, `UiMeter`, `UiMenu`, `UiCatalogSlot` — the last two settable-only, having no authored build path), seeded ONCE from the element's effective-default state via `ElementReader.ApplyCanonicalLegacyProjection`'s `TryGetEffectiveProperty` (DirectState-then-effective-default rule). Retail instead re-resolves text properties on every UI STATE CHANGE — a button entering state `0x3` whose StateDesc authors `0x21=true` gains the outline for the duration of that state. The authored data hits this today: the dialog panel's two buttons (`0x2100003C` elements `0x17`/`0x19`), the character panel button `0x10000535`, and the combat panel button `0x100000B2` each author `0x21=true` in state `0x3` ONLY (DefaultStateId=1 → no outline at effective-default; `0x100000B2` also authors DirectState `0x21=true`, which the canonical rule DOES honor). The same seed-once shape already governs `UiText` (its `ApplyDatState` re-resolves `0x1B` FontColor per state but not `0x21`/`0x22`). `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (BuildButton/BuildCheckbox/BuildMeter/BuildText + the editable-field branch); `src/AcDream.App/UI/UiText.cs` (`ApplyDatState`) | Seed-once from the canonical effective state is strictly closer to retail than the pre-round-5 any-state first-wins scan (which lit those state-`0x3` outlines PERMANENTLY); the widening this row rides in on makes every ALWAYS-outlined authored element (DirectState/default-state authors) render retail-correct, and per-state re-resolution needs a property-application pass on the existing `TrySetRetailState` path — a reviewed slice of its own, not a polish-commit fold-in | A button that retail outlines only in a specific UI state (the four state-`0x3` authors above — state 3 is a hover/highlight-class state) never shows that transient outline in acdream; conversely nothing over-renders, since the effective-default resolution correctly yields outline-off for those elements | `UIElement_Text::SetOutline @0x0046a81c` (`m_bitField & 0x10`); `UIElement_Text::DrawSelf @0x00467aa0` (two-pass outline+fill); LayoutDesc fixtures `dialogs_2100003C.json` (`0x17`/`0x19`), `character_2100002E.json` (`0x10000535`), `combat_21000073.json` (`0x100000B2`) | -## 4. Temporary stopgap (TS) — 39 active rows (TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 40 active rows (TS-71 filed 2026-08-10 at Campaign OP slice OP1 — only the explicit SaveOptions flush trigger is production-wired; the 480 s timer and logout flush exist as tested pure state-machine logic but are not yet called from either host's live loop (see the row below); TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| TS-71 | Only ONE of retail's three `SetCharacterOptions (0x01A1)` flush triggers is production-wired: the explicit `SaveOptions` Runtime command (`IRuntimeCharacterCommands.SaveOptions`, both host adapters). The 480 s auto-save timer (`CPlayerModule::UseTime`) and the flush-before-logout trigger (`CPlayerSystem::LogOffCharacter` calling `SaveToServer`) exist as fully-tested, pure Runtime state-machine logic (`RuntimeCharacterOptionsState.TryFlushIfAutoSaveDue` / `TryFlush`) but are not yet called from either host's live per-frame tick loop or graceful-shutdown sequence — a batched (non-auto-save) option change today reaches the server only via an explicit `SaveOptions` call (e.g. the future Options panel's Apply button), a subsequent option change that IS auto-save (which never touches the blob), or a fresh `PlayerDescription` overwriting the local draft on reconnect. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TryFlushIfAutoSaveDue`, `TryFlush`) | Wiring the timer requires a new per-frame phase touching both `AcDream.App`'s `UpdateFrameOrchestrator` graph and `AcDream.Headless`'s `HeadlessSessionHost.Tick` — outside Campaign OP slice OP1's Runtime/wire-layer scope. Wiring logout requires a live `WorldSession` reference at the moment either adapter's `Stop` command runs, ahead of `IRuntimeSessionCommands.Stop`'s teardown; deferred rather than risking the already-fragile graceful-shutdown sequence CLAUDE.md flags (ACE session-cleanup timing sensitivity) for an untested addition in this slice. | Until a follow-up slice wires these, a player who toggles ONLY batched (non-auto-save) Character-tab options and then disconnects without pressing Apply/calling `SaveOptions` loses those toggles — ACE never receives the blob. Auto-save ids (the 21-id table) are unaffected; they always send immediately regardless. | `CPlayerModule::UseTime @0x0059A710`; `CPlayerSystem::LogOffCharacter @0x00563520`; `docs/research/2026-08-10-set-character-options-wire.md` §3.3 | | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | | ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 6c88b88c..8edd4d0a 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -45,6 +45,11 @@ internal sealed record LiveSessionCommandBindings( RuntimeCommunicationState Communication, RuntimeCharacterState CharacterState, Action SendSingleCharacterOption, + // Campaign OP slice OP1 (2026-08-10): the real SetCharacterOptions + // (0x01A1) blob-flush verb — CH3's TODO, now resurrected per wire + // research §2.3-§2.7. No-ops when the batched module is clean, matching + // retail's CPlayerModule::SaveToServer(force: 0). + Action SaveCharacterOptions, Action? Log = null); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); @@ -69,6 +74,7 @@ internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost); internal readonly record struct SetSingleCharacterOptionRuntimeCmd( uint OptionId, bool Value); +internal readonly record struct SaveCharacterOptionsRuntimeCmd; internal readonly record struct AddFriendRuntimeCmd(string Name); internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); internal readonly record struct ClearFriendsRuntimeCmd; @@ -169,6 +175,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting bindings.SendSingleCharacterOption( command.OptionId, command.Value))); + commands.Register( + _ => SendIfActive(bindings.SaveCharacterOptions)); commands.Register( command => SendIfActive(() => bindings.AddFriend(command.Name))); commands.Register( diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index f809f258..cdb9cf8b 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -327,27 +327,47 @@ internal sealed class LiveSessionRuntimeFactory private LiveSessionCommandBindings CreateCommandBindings( WorldSession session) { - // CH4 re-review SHOULD-FIX 2 (2026-08-09): single local-write - // chokepoint for retail's SetSingleCharacterOption (0x0005) local - // option-bit write, reached by BOTH entrances that can flip a - // character option — @join/@leave + // CH4 re-review SHOULD-FIX 2 (2026-08-09), widened by Campaign OP + // slice OP1 (2026-08-10): single write-then-send/dirty chokepoint + // for retail's PlayerModule::OnChanged policy, reached by BOTH + // entrances that can flip a character option — @join/@leave // (ClientCommandController.Bindings.SetSingleCharacterOption below) // and the Settings Chat toggles (LiveSessionCommandBindings. // SendSingleCharacterOption at the bottom of this method, routed // through RuntimeSettingsController.PublishHearOptionChange -> // RuntimeSettingsTargets.SetSingleCharacterOption -> // SetSingleCharacterOptionRuntimeCmd -> LiveSessionCommandRouter). - // Retail's PlayerModule::SetHear*Chat family writes the bit into the - // local options copy FIRST, then notifies the server, for both - // entrances alike — routing only @join/@leave through the local - // write (the prior CH4 fix) left a Settings-route toggle stale in - // TurbineChatMembershipGate (which reads _domain.Character.Options) - // until the next PlayerDescription happened to arrive. - void SendSingleCharacterOption(uint optionId, bool value) - { - _domain.Character.Options.SetOptionBit(optionId, value); - session.SendSetSingleCharacterOption(optionId, value); - } + // OP1 moved the actual write-then-send/dirty POLICY into + // RuntimeCharacterOptionsState.TrySetOption (the shared Runtime seam + // src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs's + // SetSingleOption now also calls) so graphical and headless hosts — + // and every entrance within this host — share exactly one ordering: + // write the bit locally FIRST, then either send 0x0005 immediately + // (retail's auto-save ids) or mark the batched module dirty. + void SendSingleCharacterOption(uint optionId, bool value) => + _domain.Character.Options.TrySetOption( + optionId, + value, + sendAutoSave: () => + session.SendSetSingleCharacterOption(optionId, value)); + + // OP1: the explicit SaveOptions verb — retail's + // CPlayerModule::SaveToServer(force: 0). No-ops when the batched + // module is clean. + void SaveCharacterOptionsIfDirty() => + _domain.Character.Options.TryFlush(() => + { + CharacterOptionsBlobEcho echo = CharacterOptionsBlobSource.Capture( + _domain.Character, + _domain.Inventory.Shortcuts); + session.SendSetCharacterOptions( + echo.Options1, + echo.Options2, + echo.Shortcuts, + echo.FavoriteSpells, + echo.DesiredComponents, + echo.SpellbookFilters); + }); return new( ClientCommands: new ClientCommandController.Bindings( @@ -520,6 +540,7 @@ internal sealed class LiveSessionRuntimeFactory Communication: _domain.Communication, CharacterState: _domain.Character, SendSingleCharacterOption: SendSingleCharacterOption, + SaveCharacterOptions: SaveCharacterOptionsIfDirty, Log: _log); } diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index e68fd275..74ccf66b 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -673,6 +673,20 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); + // OP1 (Campaign OP, 2026-08-10): reject an id outside + // CharacterOptionTable at THIS seam too — the router's + // SetSingleCharacterOptionRuntimeCmd handler ultimately funnels into + // RuntimeCharacterOptionsState.TrySetOption (the SAME shared write- + // then-send/dirty policy the direct host uses), which would also + // reject it, but LiveCommandBus.Publish has no return value for that + // rejection to travel back on. + if (!CharacterOptionTable.TryGet(optionId, out _)) + { + return EmitResult( + RuntimeCommandDomain.Character, + operation: 4, + RuntimeCommandStatus.Rejected); + } _commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value)); return EmitResult( RuntimeCommandDomain.Character, @@ -680,6 +694,19 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus.Accepted); } + public RuntimeCommandResult SaveOptions( + RuntimeGenerationToken expectedGeneration) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + _commands.Publish(new SaveCharacterOptionsRuntimeCmd()); + return EmitResult( + RuntimeCommandDomain.Character, + operation: 5, + RuntimeCommandStatus.Accepted); + } + public RuntimeCommandResult Execute( RuntimeGenerationToken expectedGeneration, in RuntimeFriendCommand command) diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index da72f74d..9d52e30f 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -1,6 +1,7 @@ using System; using System.Buffers.Binary; using System.Text; +using AcDream.Core.Items; namespace AcDream.Core.Net.Messages; @@ -50,6 +51,36 @@ public static class SocialActions // SendSetSingleCharacterOption), so only it is implemented. public const uint SetSingleCharacterOptionOpcode = 0x0005u; // u32 optionId, u32 value (0/1) + // OP1 (Campaign OP, 2026-08-10): the real batched-option blob, resurrected + // per docs/research/2026-08-10-set-character-options-wire.md §2.3-§2.7 — + // NOT the malformed 16-byte CH3 builder this opcode used to name (deleted + // 2026-08-09, post-mortem in that doc §6). Body IS + // `PlayerModule::Pack @0x005D45C0`. + public const uint SetCharacterOptionsOpcode = 0x01A1u; + + /// + /// PlayerModulePackHeader bits retail's 2013 client ALWAYS sets + /// (PlayerModule::SetPackHeader @0x005D44A0, BYTE-VERIFIED — wire + /// research §2.2): SpellLists8 (0x400), SpellbookFilters + /// (0x020), 2ndCharacterOptions/Options2 (0x040). The other + /// unconditional bits from the same disassembly are OR'd in below when + /// their section is non-empty; SquelchList (0x02), + /// MultiSpellList (0x04), ExtendedMultiSpellLists (0x10), + /// and TimeStampFormat (0x80) are NEVER set by the 2013 client and + /// never appear here; GenericQualitiesData (0x100) is never set by + /// acdream (wire research §2.4d U2 — float sub-table shape disputed + /// between retail and ACE, unreachable if we never set it); + /// GameplayOptions (0x200) is omitted while acdream packs nothing + /// into m_colGameplayOptions (safe per §2.5 — the receiver leaves + /// its collection untouched when the flag is absent). + /// + private const uint PlayerModulePackHeaderBase = + 0x400u // PM_Packed_8_SpellLists + | 0x020u // PM_Packed_SpellbookFilters + | 0x040u; // PM_Packed_2ndCharacterOptions + private const uint PlayerModulePackHeaderShortcuts = 0x001u; // PM_Packed_ShortCutManager + private const uint PlayerModulePackHeaderDesiredComps = 0x008u; // PM_Packed_DesiredComps + /// Query a target's health — server replies with UpdateHealth (0x01C0). public static byte[] BuildQueryHealth(uint seq, uint targetGuid) { @@ -159,6 +190,128 @@ public static class SocialActions return body; } + /// + /// Flush the batched-option module: SetCharacterOptions (0x01A1). + /// Body IS PlayerModule::Pack @0x005D45C0 — wire research §2.3 + /// field-by-field, exactly. The header is always + /// (0x460) OR'd with the + /// per-section bits below when that section is non-empty; ACE stores + /// / and discards + /// the four "TODO" sections (shortcuts, spell lists, desired comps, + /// spellbook filters) into their own dedicated GameActions, but retail + /// still packs them, so this builder echoes the caller's last-parsed + /// values instead of zeroing them (§5.3) — never invent zeros for + /// state this session actually has. MUST + /// have exactly 8 entries, matching retail's unconditional + /// favorite_spells_[8] — an empty tab is a lone u32 0. + /// Never sets header bit 0x100 (GenericQualitiesData, U2 — + /// unresolved float sub-table shape) or 0x200 (GameplayOptions, + /// unpacked by acdream today — CH6f). Every field is a 4-byte-aligned + /// u32/record, so the trailing pad (§2.7) is always zero bytes in + /// practice, but the computation is still performed for exact fidelity + /// with PlayerModule::Pack's own unconditional pad step. + /// + public static byte[] BuildSetCharacterOptions( + uint seq, + uint options1, + uint options2, + IReadOnlyList shortcuts, + IReadOnlyList> favoriteSpells, + IReadOnlyDictionary desiredComponents, + uint spellbookFilters) + { + ArgumentNullException.ThrowIfNull(shortcuts); + ArgumentNullException.ThrowIfNull(favoriteSpells); + ArgumentNullException.ThrowIfNull(desiredComponents); + if (favoriteSpells.Count != 8) + { + throw new ArgumentException( + "Retail PlayerModule::Pack always emits exactly 8 favorite-spell lists (acclient.h:36507 favorite_spells_[8]).", + nameof(favoriteSpells)); + } + + uint header = PlayerModulePackHeaderBase; + if (shortcuts.Count > 0) header |= PlayerModulePackHeaderShortcuts; + if (desiredComponents.Count > 0) header |= PlayerModulePackHeaderDesiredComps; + + int payloadSize = + 4 // header + + 4 // options1 + + (shortcuts.Count > 0 ? 4 + 12 * shortcuts.Count : 0) + + FavoriteSpellsPackSize(favoriteSpells) + + (desiredComponents.Count > 0 ? 4 + 8 * desiredComponents.Count : 0) + + 4 // spellbookFilters + + 4; // options2 + int pad = (4 - (payloadSize & 3)) & 3; + + byte[] body = new byte[12 + payloadSize + pad]; + int p = 0; + WriteU32(body, ref p, GameActionEnvelope); + WriteU32(body, ref p, seq); + WriteU32(body, ref p, SetCharacterOptionsOpcode); + WriteU32(body, ref p, header); + WriteU32(body, ref p, options1); + + if (shortcuts.Count > 0) + { + WriteU32(body, ref p, (uint)shortcuts.Count); + foreach (ShortcutEntry entry in shortcuts) + { + WriteI32(body, ref p, entry.Index); + WriteU32(body, ref p, entry.ObjectId); + WriteU32(body, ref p, entry.SpellId); + } + } + + for (int tab = 0; tab < 8; tab++) + { + IReadOnlyList list = favoriteSpells[tab]; + int count = list?.Count ?? 0; + WriteU32(body, ref p, (uint)count); + for (int i = 0; i < count; i++) + WriteU32(body, ref p, list![i]); + } + + if (desiredComponents.Count > 0) + { + // PackableHashTable::Pack @0x005692B0: sizeInfo = (tableSize + // << 16) | count. ACE (and acdream's own inbound parser) only + // reads the low 16 bits; the advisory high half is left zero. + WriteU32(body, ref p, (uint)desiredComponents.Count); + foreach (KeyValuePair kvp in desiredComponents) + { + WriteU32(body, ref p, kvp.Key); + WriteU32(body, ref p, kvp.Value); + } + } + + WriteU32(body, ref p, spellbookFilters); + WriteU32(body, ref p, options2); + // Tail pad bytes are already zero from `new byte[]`; nothing to write. + return body; + } + + private static int FavoriteSpellsPackSize( + IReadOnlyList> favoriteSpells) + { + int size = 0; + for (int tab = 0; tab < 8; tab++) + size += 4 + 4 * (favoriteSpells[tab]?.Count ?? 0); + return size; + } + + private static void WriteU32(byte[] dest, ref int pos, uint value) + { + BinaryPrimitives.WriteUInt32LittleEndian(dest.AsSpan(pos), value); + pos += 4; + } + + private static void WriteI32(byte[] dest, ref int pos, int value) + { + BinaryPrimitives.WriteInt32LittleEndian(dest.AsSpan(pos), value); + pos += 4; + } + // ── Helpers ────────────────────────────────────────────────────────────── private static byte[] SingleGuid(uint seq, uint sub, uint guid) @@ -189,19 +342,76 @@ public static class SocialActions } /// -/// ACE CharacterOption ids (a LINEAR enum, distinct from the +/// The linear PlayerOption id space (a LINEAR enum, distinct from the /// CharacterOptions1/CharacterOptions2 BITFIELDS) — the first -/// u32 of a SetSingleCharacterOption (0x0005) payload. Only -/// the six ListenTo*Chat ids Campaign CH slice CH3 (2026-08-09) needs -/// are modeled here; ACE Source/ACE.Entity/Enum/CharacterOption.cs -/// has the complete list. +/// u32 of a SetSingleCharacterOption (0x0005) payload, and the +/// key into . +/// Campaign OP slice OP1 (2026-08-10) widened this from the 6 +/// ListenTo*Chat ids Campaign CH slice CH3 needed to the complete +/// 0x00..0x34 set, verbatim from named-retail/acclient.h:4162 +/// (enum PlayerOption) — every member below is +/// <Name>_PlayerOption there with its _PlayerOption +/// suffix dropped, EXCEPT the six pre-existing ListenTo*Chat members +/// (retail names them Hear*Chat_PlayerOption; kept as-is rather than +/// renamed, since every existing caller — TurbineChatMembershipGate, +/// its tests, the CH3/CH4 chat wiring — already spells them this way). +/// HearPkDeathMessages (0x34) is ACE-sourced, not present in +/// the 2013 PDB (the id was TotalNumberOfPlayerOptions_PlayerOption +/// there) — register row, wire research §8.1. /// public enum CharacterOptionId : uint { + AutoRepeatAttack = 0x00, + IgnoreAllegianceRequests = 0x01, + IgnoreFellowshipRequests = 0x02, + IgnoreTradeRequests = 0x03, + DisableMostWeatherEffects = 0x04, + PersistentAtDay = 0x05, + AllowGive = 0x06, + ViewCombatTarget = 0x07, + ShowTooltips = 0x08, + UseDeception = 0x09, + ToggleRun = 0x0A, + StayInChatMode = 0x0B, + AdvancedCombatUI = 0x0C, + AutoTarget = 0x0D, + VividTargetingIndicator = 0x0E, + FellowshipShareXP = 0x0F, + AcceptLootPermits = 0x10, + FellowshipShareLoot = 0x11, + FellowshipAutoAcceptRequests = 0x12, + SideBySideVitals = 0x13, + CoordinatesOnRadar = 0x14, + SpellDuration = 0x15, + DisableHouseRestrictionEffects = 0x16, + DragItemOnPlayerOpensSecureTrade = 0x17, + DisplayAllegianceLogonNotifications = 0x18, + UseChargeAttack = 0x19, + UseCraftSuccessDialog = 0x1A, ListenToAllegianceChat = 0x1B, + DisplayDateOfBirth = 0x1C, + DisplayAge = 0x1D, + DisplayChessRank = 0x1E, + DisplayFishingSkill = 0x1F, + DisplayNumberDeaths = 0x20, + DisplayTimeStamps = 0x21, + SalvageMultiple = 0x22, ListenToGeneralChat = 0x23, ListenToTradeChat = 0x24, ListenToLFGChat = 0x25, ListenToRoleplayChat = 0x26, + AppearOffline = 0x27, + DisplayNumberCharacterTitles = 0x28, + MainPackPreferred = 0x29, + LeadMissileTargets = 0x2A, + UseFastMissiles = 0x2B, + FilterLanguage = 0x2C, + ConfirmVolatileRareUse = 0x2D, ListenToSocietyChat = 0x2E, + ShowHelm = 0x2F, + DisableDistanceFog = 0x30, + UseMouseTurning = 0x31, + ShowCloak = 0x32, + LockUI = 0x33, + HearPkDeathMessages = 0x34, } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index d17675d2..79a387fb 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2205,6 +2205,33 @@ public sealed class WorldSession : IDisposable SendGameAction(SocialActions.BuildSetSingleCharacterOption(seq, optionId, value)); } + /// + /// Send retail SetCharacterOptions (0x01A1) — the batched-option + /// module flush (Campaign OP slice OP1, 2026-08-10). Callers own the + /// dirty check (RuntimeCharacterOptionsState.TryFlush / + /// TryFlushIfAutoSaveDue); this method always sends when called, + /// matching retail's CPlayerModule::SaveToServer once its own + /// m_bDirty gate has already passed. + /// + public void SendSetCharacterOptions( + uint options1, + uint options2, + IReadOnlyList shortcuts, + IReadOnlyList> favoriteSpells, + IReadOnlyDictionary desiredComponents, + uint spellbookFilters) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildSetCharacterOptions( + seq, + options1, + options2, + shortcuts, + favoriteSpells, + desiredComponents, + spellbookFilters)); + } + public void SendAddFriend(string name) { uint seq = NextGameActionSequence(); diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index fef9ffbe..c9321334 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -196,7 +196,8 @@ public sealed class GameRuntime context, faultInjection); - context.Character = new RuntimeCharacterState(); + context.Character = new RuntimeCharacterState( + timeProvider: dependencies.TimeProvider); construction.Own(context.Character); Fault( GameRuntimeConstructionPoint.CharacterCreated, diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index acd5a5b4..877e1e43 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -250,6 +250,15 @@ public interface IRuntimeCharacterCommands RuntimeGenerationToken expectedGeneration, uint optionId, bool value); + + /// + /// Retail CPlayerModule::SaveToServer(force: 0) — the batched- + /// option blob-flush verb (Campaign OP slice OP1, 2026-08-10). Flushes + /// SetCharacterOptions (0x01A1) iff the module is dirty; a clean + /// module sends nothing, matching retail exactly (both its own + /// production call sites — Apply, logout — pass force = 0). + /// + RuntimeCommandResult SaveOptions(RuntimeGenerationToken expectedGeneration); } public enum RuntimeFriendCommandKind diff --git a/src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs b/src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs new file mode 100644 index 00000000..bdd21963 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs @@ -0,0 +1,169 @@ +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Gameplay; + +/// +/// One row of : which word the id lives in +/// ( selects CharacterOptions1 vs +/// CharacterOptions2), its bit , whether it sends +/// SetSingleCharacterOption (0x0005) immediately +/// () or only dirties the batched +/// SetCharacterOptions (0x01A1) module, and the value retail's own +/// Character-tab Defaults button would restore (). +/// +public readonly record struct CharacterOptionTableEntry( + CharacterOptionId Id, + bool IsOptions1, + uint Mask, + bool IsAutoSave, + bool ClientDefault); + +/// +/// The ONE typed table for every retail character option: linear +/// PlayerOption id (0x00..0x34) to +/// (CharacterOptions1|CharacterOptions2 word, bit mask, +/// auto-save wire policy, client Defaults-button value). Campaign OP slice +/// OP1 (2026-08-10) — replaces the 6-id partial coverage +/// RuntimeCharacterOptionsState.SetOptionBit used to hand-roll. +/// +/// +/// Sources, all byte-verified against the PDB-paired 2013 EoR binary +/// (docs/research/2026-08-10-character-options-map.md + +/// docs/research/2026-08-10-set-character-options-wire.md): +/// +/// +/// Word/Mask — verbatim +/// named-retail/acclient.h:3404-3436 (enum CharacterOption, +/// the Options1 bitfield — despite the name, this is NOT the same enum as +/// ) and acclient.h:3451-3481 +/// (enum CharacterOptions2), cross-referenced by name against +/// acclient.h:4162-4218 (enum PlayerOption, the id space +/// itself). Reconstructing CharacterOptions1.Default from every +/// ClientDefault row below whose word is Options1 yields exactly +/// 0x50C4A54A; Options2 yields 0x00008700 — both independently +/// confirmed against the retail constructor's own literal writes +/// (character-options-map.md §1.4, wire doc §2.5). +/// IsAutoSave — +/// CPlayerModule::IsAutoSaveOption @0x0059A600's 0x34-byte jump table +/// at VA 0x0059A62C (wire doc §3.2): 21 of 53 ids send 0x0005 +/// immediately; the rest only mark PlayerModule dirty for the batched +/// blob. +/// ClientDefault — +/// PlayerModule::GetDefaultOptionValue @0x005D2A30's 0x2B-byte table +/// at VA 0x005D2A5C (wire doc §8.2): only covers ids 0x00..0x2A +/// (16 default-ON); every id above 0x2A (0x2B..0x34) falls off +/// the end of that table and defaults to false here even though THREE +/// of them — ConfirmVolatileRareUse, ShowHelm, +/// ShowCloak — are actually ON in the raw constructor default word +/// 0x00948700. This is retail's OWN behavior (the Defaults button +/// does not reproduce a fresh PlayerModule), reproduced here +/// deliberately — see the matching row in +/// docs/architecture/retail-divergence-register.md. Do not "fix" it to +/// match the constructor default. +/// +/// +/// +/// (0x34) +/// does not exist in the 2013 build (PlayerOption there terminates at +/// TotalNumberOfPlayerOptions_PlayerOption = 0x34). Its +/// Options2 mask 0x02000000 is ACE-sourced +/// (ListenToPKDeathMessages) and UNVERIFIABLE against our binary — +/// register row. Its auto-save classification is likewise an open unknown +/// (wire doc §8.1 U4: the 2013 IsAutoSaveOption bounds check would +/// reject any id > 0x33 by construction, which is evidence about +/// the 2013 build, not the final client that actually shipped this option). +/// Modeled here as batched (not auto-save) — the conservative reading: it +/// never sends anything acdream cannot otherwise justify, and ACE's +/// 0x0005 handler's default: branch just stores the bit either +/// way, so nothing server-observable depends on the choice. +/// +/// +public static class CharacterOptionTable +{ + private static readonly Dictionary Entries = Build(); + + public static bool TryGet(uint optionId, out CharacterOptionTableEntry entry) => + Entries.TryGetValue(optionId, out entry); + + public static bool TryGet(CharacterOptionId optionId, out CharacterOptionTableEntry entry) => + TryGet((uint)optionId, out entry); + + /// Every modeled id, id-ascending. Used by conformance tests + /// that must walk the complete 0x00..0x34 set. + public static IReadOnlyList All { get; } = + [.. Entries.Values.OrderBy(static e => (uint)e.Id)]; + + private static Dictionary Build() + { + var table = new Dictionary(53); + + void Add( + CharacterOptionId id, + bool isOptions1, + uint mask, + bool autoSave, + bool clientDefault) => + table.Add( + (uint)id, + new CharacterOptionTableEntry(id, isOptions1, mask, autoSave, clientDefault)); + + // acclient.h:4162-4218 order (== PlayerOption id-ascending). + Add(CharacterOptionId.AutoRepeatAttack, true, 0x00000002u, true, true); + Add(CharacterOptionId.IgnoreAllegianceRequests, true, 0x00000004u, true, false); + Add(CharacterOptionId.IgnoreFellowshipRequests, true, 0x00000008u, true, true); + Add(CharacterOptionId.IgnoreTradeRequests, true, 0x00020000u, false, false); + Add(CharacterOptionId.DisableMostWeatherEffects, true, 0x00010000u, false, false); + Add(CharacterOptionId.PersistentAtDay, false, 0x00000001u, false, false); + Add(CharacterOptionId.AllowGive, true, 0x00000040u, false, true); + Add(CharacterOptionId.ViewCombatTarget, true, 0x00000080u, false, false); + Add(CharacterOptionId.ShowTooltips, true, 0x00000100u, false, true); + Add(CharacterOptionId.UseDeception, true, 0x00000200u, false, false); + Add(CharacterOptionId.ToggleRun, true, 0x00000400u, false, true); + Add(CharacterOptionId.StayInChatMode, true, 0x00000800u, false, false); + Add(CharacterOptionId.AdvancedCombatUI, true, 0x00001000u, false, false); + Add(CharacterOptionId.AutoTarget, true, 0x00002000u, false, true); + Add(CharacterOptionId.VividTargetingIndicator, true, 0x00008000u, false, true); + Add(CharacterOptionId.FellowshipShareXP, true, 0x00040000u, true, true); + Add(CharacterOptionId.AcceptLootPermits, true, 0x00080000u, true, false); + Add(CharacterOptionId.FellowshipShareLoot, true, 0x00100000u, true, false); + Add(CharacterOptionId.FellowshipAutoAcceptRequests, true, 0x20000000u, true, false); + Add(CharacterOptionId.SideBySideVitals, true, 0x00200000u, false, false); + Add(CharacterOptionId.CoordinatesOnRadar, true, 0x00400000u, false, true); + Add(CharacterOptionId.SpellDuration, true, 0x00800000u, false, true); + Add(CharacterOptionId.DisableHouseRestrictionEffects, true, 0x02000000u, false, false); + Add(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, true, 0x04000000u, false, false); + Add(CharacterOptionId.DisplayAllegianceLogonNotifications, true, 0x08000000u, false, false); + Add(CharacterOptionId.UseChargeAttack, true, 0x10000000u, true, true); + Add(CharacterOptionId.UseCraftSuccessDialog, true, 0x80000000u, false, false); + Add(CharacterOptionId.ListenToAllegianceChat, true, 0x40000000u, true, true); + Add(CharacterOptionId.DisplayDateOfBirth, false, 0x00000002u, false, false); + Add(CharacterOptionId.DisplayAge, false, 0x00000020u, false, false); + Add(CharacterOptionId.DisplayChessRank, false, 0x00000004u, false, false); + Add(CharacterOptionId.DisplayFishingSkill, false, 0x00000008u, false, false); + Add(CharacterOptionId.DisplayNumberDeaths, false, 0x00000010u, false, false); + Add(CharacterOptionId.DisplayTimeStamps, false, 0x00000040u, false, false); + Add(CharacterOptionId.SalvageMultiple, false, 0x00000080u, false, false); + Add(CharacterOptionId.ListenToGeneralChat, false, 0x00000100u, true, true); + Add(CharacterOptionId.ListenToTradeChat, false, 0x00000200u, true, true); + Add(CharacterOptionId.ListenToLFGChat, false, 0x00000400u, true, true); + Add(CharacterOptionId.ListenToRoleplayChat, false, 0x00000800u, true, false); + Add(CharacterOptionId.AppearOffline, false, 0x00001000u, true, false); + Add(CharacterOptionId.DisplayNumberCharacterTitles, false, 0x00002000u, false, false); + Add(CharacterOptionId.MainPackPreferred, false, 0x00004000u, false, false); + Add(CharacterOptionId.LeadMissileTargets, false, 0x00008000u, true, true); + Add(CharacterOptionId.UseFastMissiles, false, 0x00010000u, true, false); + Add(CharacterOptionId.FilterLanguage, false, 0x00020000u, false, false); + Add(CharacterOptionId.ConfirmVolatileRareUse, false, 0x00040000u, false, false); + Add(CharacterOptionId.ListenToSocietyChat, false, 0x00080000u, true, false); + Add(CharacterOptionId.ShowHelm, false, 0x00100000u, true, false); + Add(CharacterOptionId.DisableDistanceFog, false, 0x00200000u, false, false); + Add(CharacterOptionId.UseMouseTurning, false, 0x00400000u, true, false); + Add(CharacterOptionId.ShowCloak, false, 0x00800000u, true, false); + Add(CharacterOptionId.LockUI, false, 0x01000000u, true, false); + // D3 / register row: id and mask are ACE-sourced (ListenToPKDeathMessages), + // unverifiable against the 2013 binary. See the type doc above. + Add(CharacterOptionId.HearPkDeathMessages, false, 0x02000000u, false, false); + + return table; + } +} diff --git a/src/AcDream.Runtime/Gameplay/CharacterOptionsBlobSource.cs b/src/AcDream.Runtime/Gameplay/CharacterOptionsBlobSource.cs new file mode 100644 index 00000000..e9bdefef --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/CharacterOptionsBlobSource.cs @@ -0,0 +1,49 @@ +using AcDream.Core.Items; + +namespace AcDream.Runtime.Gameplay; + +/// +/// The exact non-boolean fields PlayerModule::Pack ALSO writes into +/// the SetCharacterOptions (0x01A1) blob alongside the two option +/// bitfields — shortcuts, the 8 favorite-spell lists, desired components, +/// and the spellbook filter word. Wire research §5.3: ACE discards these +/// four sections into its own dedicated GameActions, but retail still packs +/// them, so a faithful builder echoes Runtime's already-parsed +/// last-PlayerDescription state instead of zeroing them. +/// +public readonly record struct CharacterOptionsBlobEcho( + uint Options1, + uint Options2, + IReadOnlyList Shortcuts, + IReadOnlyList> FavoriteSpells, + IReadOnlyDictionary DesiredComponents, + uint SpellbookFilters); + +/// +/// Captures a from Runtime's live +/// state. The SAME capture is used by every host that can flush the batched +/// module (both IRuntimeCharacterCommands.SaveOptions adapters) so +/// there is exactly one place that assembles the echo fields. +/// +public static class CharacterOptionsBlobSource +{ + public static CharacterOptionsBlobEcho Capture( + RuntimeCharacterState character, + ShortcutStore shortcuts) + { + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(shortcuts); + + var favorites = new IReadOnlyList[8]; + for (int tab = 0; tab < 8; tab++) + favorites[tab] = character.Spellbook.GetFavorites(tab); + + return new CharacterOptionsBlobEcho( + character.Options.Options1, + character.Options.Options2, + shortcuts.Items, + favorites, + character.Spellbook.DesiredComponents, + character.Spellbook.SpellbookFilters); + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 95e25610..895c0991 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -86,11 +86,13 @@ public sealed class RuntimeCharacterState : IDisposable private int _jumpSkillBase = -1; private PlayerSkillMath.AugmentationBonuses _movementSkillAugmentations; - public RuntimeCharacterState(SpellTable? spellTable = null) + public RuntimeCharacterState( + SpellTable? spellTable = null, + TimeProvider? timeProvider = null) { Spellbook = new Spellbook(spellTable); LocalPlayer = new LocalPlayerState(Spellbook); - Options = new RuntimeCharacterOptionsState(); + Options = new RuntimeCharacterOptionsState(timeProvider); MovementSkills = new RuntimeMovementSkillState(); View = new CharacterView(this); Spellbook.StateChanged += OnSpellbookChanged; @@ -620,7 +622,9 @@ public readonly record struct RuntimeCharacterOptionsSnapshot( } /// -/// Canonical session-owned copy of retail's two character-option bitfields. +/// Canonical session-owned copy of retail's two character-option bitfields, +/// plus the batched-module dirty model +/// (CPlayerModule::m_bDirty/m_timeFirstDirtied). /// PlayerModule::PlayerModule @ 0x005D51F0 installs the defaults. /// Runtime reset restores the equivalent fresh-player-module state because /// one Runtime owner survives across graphical and no-window sessions. @@ -631,9 +635,27 @@ public sealed class RuntimeCharacterOptionsState (uint)PlayerDescriptionParser.CharacterOptions1.Default; public const uint DefaultOptions2 = 0x00948700u; + /// + /// CPlayerModule::UseTime @0x0059A710, BYTE-VERIFIED literal + /// 480.0 (wire research §3.3): the batched module flushes 480 + /// seconds after it FIRST went dirty, not after the last change. A + /// property (not a field) so this type keeps zero static mutable state + /// — see GameRuntimeContractTests.J4GameplayOwnersHaveNoStaticMutableSessionState. + /// + public static TimeSpan AutoSaveDelay => TimeSpan.FromSeconds(480); + + private readonly TimeProvider _timeProvider; + private readonly object _dirtyGate = new(); private uint _options1 = DefaultOptions1; private uint _options2 = DefaultOptions2; private long _revision; + private bool _isDirty; + private DateTimeOffset _firstDirtiedAt; + + public RuntimeCharacterOptionsState(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } public uint Options1 => Volatile.Read(ref _options1); public uint Options2 => Volatile.Read(ref _options2); @@ -641,6 +663,21 @@ public sealed class RuntimeCharacterOptionsState public RuntimeCharacterOptionsSnapshot Snapshot => new(_options1, _options2, Revision); + /// Retail's m_bDirty — an unflushed batched-option + /// change is waiting on Apply / logout / the 480 s timer. + public bool IsDirty + { + get { lock (_dirtyGate) return _isDirty; } + } + + /// Retail's m_timeFirstDirtied — the instant the module + /// FIRST went dirty since its last flush, or null when + /// clean. + public DateTimeOffset? FirstDirtiedAt + { + get { lock (_dirtyGate) return _isDirty ? _firstDirtiedAt : null; } + } + public bool DragItemOnPlayerOpensSecureTrade => Snapshot.DragItemOnPlayerOpensSecureTrade; @@ -651,63 +688,143 @@ public sealed class RuntimeCharacterOptionsState Interlocked.Increment(ref _revision); } + /// + /// THE shared local-write-then-send/dirty seam every entrance that can + /// flip a character option funnels through — @join/@leave, the Settings + /// Chat toggles, the Options panel, a headless bot, both + /// IRuntimeCharacterCommands.SetSingleOption host adapters. + /// Mirrors CPlayerModule::OnChanged(PlayerOption) @0x0059A8E0 + /// exactly: write the bit into this LOCAL copy FIRST (so a same-session + /// consumer like is correct + /// before any round trip), THEN either invoke + /// immediately (retail's + /// IsAutoSaveOption branch — Event_PlayerOptionChangedEvent, + /// the 0x0005 send) or for the batched + /// 0x01A1 flush (the else branch). Matches retail's own + /// unchanged-value early return (wire research §3.1 — "an unchanged + /// option produces no notice, no side effect, no message at all") by + /// no-op'ing when already holds. Returns + /// false for an id outside + /// (retail's own IsAutoSaveOption/id-cast bounds check would + /// reject it too) — callers turn that into a + /// , never a silent send. + /// + public bool TrySetOption(uint characterOptionId, bool value, Action sendAutoSave) + { + ArgumentNullException.ThrowIfNull(sendAutoSave); + if (!CharacterOptionTable.TryGet(characterOptionId, out CharacterOptionTableEntry entry)) + return false; + + uint word = entry.IsOptions1 ? Options1 : Options2; + if (((word & entry.Mask) != 0u) == value) + return true; + + SetOptionBit(characterOptionId, value); + + if (entry.IsAutoSave) + sendAutoSave(); + else + MarkDirty(); + + return true; + } + /// /// Set ONE character-option bit locally, by its linear /// CharacterOptionId (the same id carried on the wire by - /// SetSingleCharacterOption (0x0005)). Retail's - /// PlayerModule::SetHearGeneralChat @0x005D35C0 (and its five - /// SetHear*Chat siblings) write the bit into this LOCAL copy - /// FIRST, before the client ever notifies the server. CH4 - /// REJECT-review SHOULD-FIX 4 (2026-08-09): acdream's @join/ - /// @leave previously pushed only the wire message and left this - /// state untouched, so - /// kept refusing a room the player had just joined until the next - /// PlayerDescription happened to arrive. Only the six - /// ListenTo*Chat ids CharacterOptionId models are - /// recognized here; any other id is a silent no-op — this state only - /// tracks what the Turbine-chat membership gate needs, not a complete - /// PlayerModule mirror. + /// SetSingleCharacterOption (0x0005)), resolved through the + /// complete (Campaign OP slice OP1, + /// 2026-08-10 — widened from the 6 ListenTo*Chat ids Campaign CH + /// slice CH3 modeled). Retail's PlayerModule::SetHearGeneralChat + /// @0x005D35C0 (and every sibling Set<Option> accessor) + /// writes the bit into this LOCAL copy FIRST, before the client ever + /// notifies the server — is the seam that + /// preserves that ordering end-to-end; call this directly only when you + /// specifically want the bit write WITHOUT the send/dirty policy (e.g. + /// reseeding local state that a fresh PlayerDescription already + /// authoritatively carries). An id outside the table is a silent no-op. /// public void SetOptionBit(uint characterOptionId, bool value) { - (bool isOptions1, uint mask) = characterOptionId switch - { - (uint)CharacterOptionId.ListenToAllegianceChat => - (true, (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat), - (uint)CharacterOptionId.ListenToGeneralChat => - (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat), - (uint)CharacterOptionId.ListenToTradeChat => - (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat), - (uint)CharacterOptionId.ListenToLFGChat => - (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat), - (uint)CharacterOptionId.ListenToRoleplayChat => - (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat), - (uint)CharacterOptionId.ListenToSocietyChat => - (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat), - _ => (false, 0u), - }; - if (mask == 0u) + if (!CharacterOptionTable.TryGet(characterOptionId, out CharacterOptionTableEntry entry)) return; - if (isOptions1) + if (entry.IsOptions1) { - uint updated = value ? (Options1 | mask) : (Options1 & ~mask); + uint updated = value ? (Options1 | entry.Mask) : (Options1 & ~entry.Mask); Volatile.Write(ref _options1, updated); } else { - uint updated = value ? (Options2 | mask) : (Options2 & ~mask); + uint updated = value ? (Options2 | entry.Mask) : (Options2 & ~entry.Mask); Volatile.Write(ref _options2, updated); } Interlocked.Increment(ref _revision); } + /// + /// Retail's CPlayerModule::OnChanged else-branch: if + /// (!m_bDirty) { m_bDirty = 1; m_timeFirstDirtied = Timer::cur_time; } + /// — only the FIRST dirtying change since the last flush stamps the + /// timer; later batched changes before the next flush do not push it + /// out. + /// + public void MarkDirty() + { + lock (_dirtyGate) + { + if (_isDirty) return; + _isDirty = true; + _firstDirtiedAt = _timeProvider.GetUtcNow(); + } + } + + /// + /// Retail's CPlayerModule::SaveToServer(force: 0) @0x0059A660 — + /// both production call sites (Apply, logout) pass force = 0, so + /// a clean module sends nothing. The explicit SaveOptions + /// Runtime command flushes through here. + /// + public bool TryFlush(Action flush) + { + ArgumentNullException.ThrowIfNull(flush); + lock (_dirtyGate) + { + if (!_isDirty) return false; + flush(); + _isDirty = false; + return true; + } + } + + /// + /// Retail's CPlayerModule::UseTime @0x0059A710: flush iff dirty + /// AND at least (480 s, BYTE-VERIFIED) has + /// elapsed since . A no-op host may call + /// this once per tick; it is cheap and inert unless the timer is + /// actually due. + /// + public bool TryFlushIfAutoSaveDue(Action flush) + { + ArgumentNullException.ThrowIfNull(flush); + lock (_dirtyGate) + { + if (!_isDirty) return false; + if (_timeProvider.GetUtcNow() - _firstDirtiedAt < AutoSaveDelay) return false; + flush(); + _isDirty = false; + return true; + } + } + public void ResetSession() { Volatile.Write(ref _options1, DefaultOptions1); Volatile.Write(ref _options2, DefaultOptions2); Interlocked.Increment(ref _revision); + lock (_dirtyGate) + _isDirty = false; } } diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index 55ec8b8c..fd9c0662 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -658,11 +658,47 @@ public sealed class DirectGameRuntimeCommandAdapter Validate(expectedGeneration, out WorldSession? session); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - session!.SendSetSingleCharacterOption(optionId, value); + // OP1 (Campaign OP, 2026-08-10): route through the SAME shared + // write-then-send seam the graphical host's LiveSessionRuntimeFactory + // closure uses, fixing the headless local-write gap lane B §4.4 / + // lane C §7.4 found (this path used to send the wire message WITHOUT + // writing the bit locally first). + bool accepted = _runtime.CharacterOwner.Options.TrySetOption( + optionId, + value, + sendAutoSave: () => + session!.SendSetSingleCharacterOption(optionId, value)); return EmitResult( RuntimeCommandDomain.Character, operation: 4, - RuntimeCommandStatus.Accepted); + accepted ? RuntimeCommandStatus.Accepted : RuntimeCommandStatus.Rejected); + } + + public RuntimeCommandResult SaveOptions( + RuntimeGenerationToken expectedGeneration) + { + RuntimeCommandStatus gate = + Validate(expectedGeneration, out WorldSession? session); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + bool flushed = _runtime.CharacterOwner.Options.TryFlush(() => + { + CharacterOptionsBlobEcho echo = CharacterOptionsBlobSource.Capture( + _runtime.CharacterOwner, + _runtime.InventoryOwner.Shortcuts); + session!.SendSetCharacterOptions( + echo.Options1, + echo.Options2, + echo.Shortcuts, + echo.FavoriteSpells, + echo.DesiredComponents, + echo.SpellbookFilters); + }); + return EmitResult( + RuntimeCommandDomain.Character, + operation: 5, + RuntimeCommandStatus.Accepted, + primaryObjectId: flushed ? 1u : 0u); } public RuntimeCommandResult Execute( diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index 7483d0ae..af117b27 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -307,6 +307,10 @@ public sealed class InteractionUiRuntimeSourcesTests bool value) => Accepted(expectedGeneration); + public RuntimeCommandResult SaveOptions( + RuntimeGenerationToken expectedGeneration) => + Accepted(expectedGeneration); + private RuntimeCommandResult Accepted( RuntimeGenerationToken generation, uint objectId = 0u) diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index cb0556cd..f66b7596 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -438,6 +438,24 @@ public sealed class LiveSessionCommandRouterTests Assert.Equal([(0x26u, true)], options); } + // ── OP1 (Campaign OP, 2026-08-10): SaveCharacterOptionsRuntimeCmd ──── + + [Fact] + public void SaveCharacterOptionsCommand_RoutesToBindingOnlyWhileActive() + { + int flushes = 0; + LiveSessionCommandRouter router = NewRouter( + saveCharacterOptions: () => flushes++); + + router.Publish(new SaveCharacterOptionsRuntimeCmd()); + router.Activate(); + router.Publish(new SaveCharacterOptionsRuntimeCmd()); + router.Dispose(); + router.Publish(new SaveCharacterOptionsRuntimeCmd()); + + Assert.Equal(1, flushes); + } + // ── CH4 re-review SHOULD-FIX 2 (2026-08-09) ───────────────────────── // The Settings Chat toggles reach this same SetSingleCharacterOptionRuntimeCmd // route (RuntimeSettingsController.PublishHearOptionChange -> @@ -559,7 +577,8 @@ public sealed class LiveSessionCommandRouterTests ClientCommandController.Bindings? clientBindings = null, RuntimeCommunicationState? communication = null, RuntimeCharacterState? characterState = null, - Action? sendSingleCharacterOption = null) => new( + Action? sendSingleCharacterOption = null, + Action? saveCharacterOptions = null) => new( new LiveSessionCommandBindings( clientBindings ?? NewClientBindings(), chat ?? new ChatLog(), @@ -591,6 +610,7 @@ public sealed class LiveSessionCommandRouterTests Communication: communication ?? new RuntimeCommunicationState(), CharacterState: characterState ?? new RuntimeCharacterState(), SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }), + SaveCharacterOptions: saveCharacterOptions ?? (() => { }), Log: log)); [MethodImpl(MethodImplOptions.NoInlining)] diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 357d3552..237e24e4 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -523,6 +523,44 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(published, harness.Commands.Published.Count); } + // ── OP1 (Campaign OP, 2026-08-10) ──────────────────────────────────── + + [Fact] + public void SetSingleOption_UnknownId_RejectsWithoutPublishing() + { + using var harness = new Harness(); + _ = harness.Runtime.Session.Start(harness.Runtime.Generation); + RuntimeGenerationToken generation = harness.Runtime.Generation; + IGameRuntimeCommands commands = harness.Runtime; + int published = harness.Commands.Published.Count; + + // 0x36 == CharacterOptions2Default — the whole-default-mask + // landmine (wire research §5.4.3), never a real option. + RuntimeCommandResult result = commands.Character.SetSingleOption( + generation, + 0x36u, + true); + + Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); + Assert.Equal(published, harness.Commands.Published.Count); + } + + [Fact] + public void SaveOptions_PublishesSaveCharacterOptionsCommand() + { + using var harness = new Harness(); + _ = harness.Runtime.Session.Start(harness.Runtime.Generation); + RuntimeGenerationToken generation = harness.Runtime.Generation; + IGameRuntimeCommands commands = harness.Runtime; + + RuntimeCommandResult result = commands.Character.SaveOptions(generation); + + Assert.True(result.Accepted); + Assert.Contains( + harness.Commands.Published, + static command => command is SaveCharacterOptionsRuntimeCmd); + } + [Fact] public void GraphicalAndNoWindowJ4CommandsProduceIdenticalCanonicalState() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs index ab0fc87c..ce1ab493 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs @@ -1,6 +1,8 @@ using System; using System.Buffers.Binary; +using System.Collections.Generic; using System.Text; +using AcDream.Core.Items; using AcDream.Core.Net.Messages; using Xunit; @@ -130,4 +132,166 @@ public sealed class SocialActionsTests Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); } + + // ── OP1 (Campaign OP, 2026-08-10): BuildSetCharacterOptions (0x01A1) ── + // docs/research/2026-08-10-set-character-options-wire.md §2.3-§2.7. The + // golden vector below is HAND-COMPUTED, field by field, from that + // layout — not generated by calling the builder under test. The CH3 + // builder (deleted 2026-08-09) died of ten green tests pinning a wrong + // shape; a golden vector this way is the only test that can catch the + // SAME class of mistake (wire doc §6.2). + + [Fact] + public void BuildSetCharacterOptions_GoldenByteVector_MatchesHandComputedLayout() + { + ShortcutEntry[] shortcuts = [new ShortcutEntry(0, 0x80000001u, 0u)]; + IReadOnlyList[] favorites = + [ + new uint[] { 1234u }, // tab 0 + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + ]; + var desiredComponents = new Dictionary { [0x68000001u] = 12u }; + + byte[] body = SocialActions.BuildSetCharacterOptions( + seq: 5u, + options1: 0x50C4A54Au, + options2: 0x00948700u, + shortcuts: shortcuts, + favoriteSpells: favorites, + desiredComponents: desiredComponents, + spellbookFilters: 0x3FFFu); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1 + 0x05, 0x00, 0x00, 0x00, // seq 5 + 0xA1, 0x01, 0x00, 0x00, // opcode 0x01A1 + 0x69, 0x04, 0x00, 0x00, // header 0x469 (base 0x460 | shortcuts 0x001 | desiredComps 0x008) + 0x4A, 0xA5, 0xC4, 0x50, // options1 0x50C4A54A + 0x01, 0x00, 0x00, 0x00, // shortcuts count = 1 + 0x00, 0x00, 0x00, 0x00, // index 0 + 0x01, 0x00, 0x00, 0x80, // objectId 0x80000001 + 0x00, 0x00, 0x00, 0x00, // spellId 0 + 0x01, 0x00, 0x00, 0x00, // tab0 count = 1 + 0xD2, 0x04, 0x00, 0x00, // spellId 1234 (0x4D2) + 0x00, 0x00, 0x00, 0x00, // tab1 count = 0 + 0x00, 0x00, 0x00, 0x00, // tab2 count = 0 + 0x00, 0x00, 0x00, 0x00, // tab3 count = 0 + 0x00, 0x00, 0x00, 0x00, // tab4 count = 0 + 0x00, 0x00, 0x00, 0x00, // tab5 count = 0 + 0x00, 0x00, 0x00, 0x00, // tab6 count = 0 + 0x00, 0x00, 0x00, 0x00, // tab7 count = 0 + 0x01, 0x00, 0x00, 0x00, // desiredComps sizeInfo = 1 + 0x01, 0x00, 0x00, 0x68, // key 0x68000001 + 0x0C, 0x00, 0x00, 0x00, // value 12 + 0xFF, 0x3F, 0x00, 0x00, // spellbookFilters 0x3FFF + 0x00, 0x87, 0x94, 0x00, // options2 0x00948700 + ]; + + Assert.Equal(expected, body); + Assert.Equal(92, body.Length); + } + + [Fact] + public void BuildSetCharacterOptions_OmitsOptionalHeaderBitsWhenSectionsEmpty() + { + IReadOnlyList[] favorites = + [ + Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), + ]; + + byte[] body = SocialActions.BuildSetCharacterOptions( + seq: 1u, + options1: 0u, + options2: 0u, + shortcuts: Array.Empty(), + favoriteSpells: favorites, + desiredComponents: new Dictionary(), + spellbookFilters: 0u); + + // Base header only: PM_Packed_8_SpellLists | SpellbookFilters | 2ndCharacterOptions. + Assert.Equal(0x460u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + // envelope+seq+opcode(12) + header+options1(8) + 8 empty lists(32) + // + spellbookFilters+options2(8). + Assert.Equal(12 + 8 + 32 + 8, body.Length); + } + + [Fact] + public void BuildSetCharacterOptions_RequiresExactlyEightFavoriteSpellLists() + { + IReadOnlyList[] tooFew = [Array.Empty(), Array.Empty()]; + + Assert.Throws(() => SocialActions.BuildSetCharacterOptions( + seq: 1u, + options1: 0u, + options2: 0u, + shortcuts: Array.Empty(), + favoriteSpells: tooFew, + desiredComponents: new Dictionary(), + spellbookFilters: 0u)); + } + + [Fact] + public void BuildSetCharacterOptions_RoundTripsThroughPlayerDescriptionParser() + { + ShortcutEntry[] shortcuts = + [ + new ShortcutEntry(3, 0x70000010u, 0u), + new ShortcutEntry(4, 0x70000011u, 5u), + ]; + IReadOnlyList[] favorites = new IReadOnlyList[8]; + favorites[0] = new uint[] { 111u, 222u }; + for (int tab = 1; tab < 8; tab++) + favorites[tab] = Array.Empty(); + var desiredComponents = new Dictionary + { + [0x68000002u] = 3u, + [0x68000003u] = 7u, + }; + + byte[] body = SocialActions.BuildSetCharacterOptions( + seq: 9u, + options1: 0x12345678u, + options2: 0x0000ABCDu, + shortcuts: shortcuts, + favoriteSpells: favorites, + desiredComponents: desiredComponents, + spellbookFilters: 0x1234u); + + // Strip the 12-byte envelope/seq/opcode — PlayerModule::Pack's own + // payload starts at `header`, which is exactly where + // PlayerDescriptionParser's trailer starts reading too (wire + // research §2.6: ACE's PlayerDescription trailer reader and + // PlayerModule::Pack agree field-for-field). Prefix a minimal empty + // PlayerDescription header (propertyFlags=0, weenieType=0, + // vectorFlags=0, hasHealth=0) so the parser walks straight into it. + byte[] packPayload = body[12..]; + byte[] syntheticPlayerDescription = new byte[16 + packPayload.Length]; + packPayload.CopyTo(syntheticPlayerDescription, 16); + + PlayerDescriptionParser.Parsed? parsed = + PlayerDescriptionParser.TryParse(syntheticPlayerDescription); + + Assert.NotNull(parsed); + Assert.False(parsed!.Value.TrailerTruncated); + Assert.Equal(0x12345678u, parsed.Value.Options1); + Assert.Equal(0x0000ABCDu, parsed.Value.Options2); + Assert.Equal(0x1234u, parsed.Value.SpellbookFilters); + Assert.Equal(shortcuts, parsed.Value.Shortcuts); + Assert.Equal(8, parsed.Value.HotbarSpells.Count); + Assert.Equal(new uint[] { 111u, 222u }, parsed.Value.HotbarSpells[0]); + for (int tab = 1; tab < 8; tab++) + Assert.Empty(parsed.Value.HotbarSpells[tab]); + Assert.Equal(2, parsed.Value.DesiredComps.Count); + Assert.Contains((0x68000002u, 3u), parsed.Value.DesiredComps); + Assert.Contains((0x68000003u, 7u), parsed.Value.DesiredComps); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/CharacterOptionTableTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/CharacterOptionTableTests.cs new file mode 100644 index 00000000..7254e1db --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/CharacterOptionTableTests.cs @@ -0,0 +1,188 @@ +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign OP slice OP1 (2026-08-10) conformance for +/// — table completeness across the +/// complete 0x00..0x34 id space, the auto-save split pinned +/// id-by-id against +/// docs/research/2026-08-10-set-character-options-wire.md §3.2's +/// byte-verified table, the client-Defaults split against §8.2's, and +/// unknown-id rejection (ACE throws on an unmodeled id — one must never +/// reach the wire, wire doc §5.4.2). +/// +public sealed class CharacterOptionTableTests +{ + // wire research §3.2 — CPlayerModule::IsAutoSaveOption @0x0059A600, + // byte-verified 0x34-byte table at VA 0x0059A62C. 21 ids. + private static readonly CharacterOptionId[] AutoSaveIds = + [ + CharacterOptionId.AutoRepeatAttack, + CharacterOptionId.IgnoreAllegianceRequests, + CharacterOptionId.IgnoreFellowshipRequests, + CharacterOptionId.FellowshipShareXP, + CharacterOptionId.AcceptLootPermits, + CharacterOptionId.FellowshipShareLoot, + CharacterOptionId.FellowshipAutoAcceptRequests, + CharacterOptionId.UseChargeAttack, + CharacterOptionId.ListenToAllegianceChat, + CharacterOptionId.ListenToGeneralChat, + CharacterOptionId.ListenToTradeChat, + CharacterOptionId.ListenToLFGChat, + CharacterOptionId.ListenToRoleplayChat, + CharacterOptionId.AppearOffline, + CharacterOptionId.LeadMissileTargets, + CharacterOptionId.UseFastMissiles, + CharacterOptionId.ListenToSocietyChat, + CharacterOptionId.ShowHelm, + CharacterOptionId.UseMouseTurning, + CharacterOptionId.ShowCloak, + CharacterOptionId.LockUI, + ]; + + // wire research §8.2 — PlayerModule::GetDefaultOptionValue @0x005D2A30, + // byte-verified 0x2B-byte table at VA 0x005D2A5C. 16 default-ON ids + // (every id past 0x2A falls off the end of that table and defaults to + // false, even though 3 of them are ON in the raw constructor word — + // see the divergence register row, D3/OP1). + private static readonly CharacterOptionId[] ClientDefaultOnIds = + [ + CharacterOptionId.AutoRepeatAttack, + CharacterOptionId.IgnoreFellowshipRequests, + CharacterOptionId.AllowGive, + CharacterOptionId.ShowTooltips, + CharacterOptionId.ToggleRun, + CharacterOptionId.AutoTarget, + CharacterOptionId.VividTargetingIndicator, + CharacterOptionId.FellowshipShareXP, + CharacterOptionId.CoordinatesOnRadar, + CharacterOptionId.SpellDuration, + CharacterOptionId.UseChargeAttack, + CharacterOptionId.ListenToAllegianceChat, + CharacterOptionId.ListenToGeneralChat, + CharacterOptionId.ListenToTradeChat, + CharacterOptionId.ListenToLFGChat, + CharacterOptionId.LeadMissileTargets, + ]; + + [Fact] + public void All_HasExactly53Entries_Ids0x00Through0x34Contiguous() + { + CharacterOptionTableEntry[] all = [.. CharacterOptionTable.All]; + + Assert.Equal(53, all.Length); + for (uint id = 0x00; id <= 0x34; id++) + { + Assert.True( + CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry), + $"id 0x{id:X2} missing from CharacterOptionTable"); + Assert.Equal(id, (uint)entry.Id); + } + } + + [Theory] + [MemberData(nameof(AllModeledIds))] + public void IsAutoSave_MatchesByteVerifiedSplit(CharacterOptionId id) + { + bool expected = Array.IndexOf(AutoSaveIds, id) >= 0; + + Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry)); + Assert.Equal(expected, entry.IsAutoSave); + } + + [Theory] + [MemberData(nameof(AllModeledIds))] + public void ClientDefault_MatchesByteVerifiedSplit(CharacterOptionId id) + { + bool expected = Array.IndexOf(ClientDefaultOnIds, id) >= 0; + + Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry)); + Assert.Equal(expected, entry.ClientDefault); + } + + [Fact] + public void AutoSaveIds_CountIs21() + { + Assert.Equal(21, AutoSaveIds.Length); + Assert.Equal(21, CharacterOptionTable.All.Count(static e => e.IsAutoSave)); + } + + [Fact] + public void ClientDefaultOnIds_CountIs16() + { + Assert.Equal(16, ClientDefaultOnIds.Length); + Assert.Equal(16, CharacterOptionTable.All.Count(static e => e.ClientDefault)); + } + + [Fact] + public void ReconstructedClientDefaultWords_MatchIndependentlyConfirmedConstants() + { + // wire research §1.4/§2.5: OR'ing every ClientDefault=true row's mask + // into its own word reconstructs EXACTLY CharacterOptions1.Default + // (0x50C4A54A, also the retail constructor literal) for Options1, + // and 0x00008700 for Options2 — the client Defaults-button value, + // deliberately NOT the same as the raw constructor default + // (0x00948700, RuntimeCharacterOptionsState.DefaultOptions2) because + // GetDefaultOptionValue's table stops at id 0x2A. + uint options1 = 0u; + uint options2 = 0u; + foreach (CharacterOptionTableEntry entry in CharacterOptionTable.All) + { + if (!entry.ClientDefault) continue; + if (entry.IsOptions1) options1 |= entry.Mask; + else options2 |= entry.Mask; + } + + Assert.Equal(0x50C4A54Au, options1); + Assert.Equal(0x00008700u, options2); + } + + [Theory] + [InlineData(0x35u)] // CharacterOptions1Default — the WHOLE default mask, not a real option + [InlineData(0x36u)] // CharacterOptions2Default — same landmine, other word + [InlineData(0xFFFFu)] + [InlineData(0xFFFFFFFFu)] + public void TryGet_RejectsUnknownAndReservedIds(uint optionId) + { + Assert.False(CharacterOptionTable.TryGet(optionId, out _)); + } + + [Fact] + public void SpotCheck_WordAndMaskAgainstVerbatimAcclientEnums() + { + // acclient.h:3404-3436 `enum CharacterOption` / :3451-3481 + // `enum CharacterOptions2`. + Assert.True(CharacterOptionTable.TryGet( + CharacterOptionId.AutoRepeatAttack, out CharacterOptionTableEntry autoRepeat)); + Assert.True(autoRepeat.IsOptions1); + Assert.Equal(0x00000002u, autoRepeat.Mask); + + // PersistentAtDay (id 0x05) lives in Options2 despite its low id — + // acclient.h:3454 `PersistentAtDay_CharacterOptions2 = 0x1`. + Assert.True(CharacterOptionTable.TryGet( + CharacterOptionId.PersistentAtDay, out CharacterOptionTableEntry persistentAtDay)); + Assert.False(persistentAtDay.IsOptions1); + Assert.Equal(0x00000001u, persistentAtDay.Mask); + + Assert.True(CharacterOptionTable.TryGet( + CharacterOptionId.ListenToAllegianceChat, out CharacterOptionTableEntry allegiance)); + Assert.True(allegiance.IsOptions1); + Assert.Equal(0x40000000u, allegiance.Mask); + + // HearPkDeathMessages (0x34) — ACE-sourced, unverifiable against the + // 2013 binary; register row. + Assert.True(CharacterOptionTable.TryGet( + CharacterOptionId.HearPkDeathMessages, out CharacterOptionTableEntry pkDeath)); + Assert.False(pkDeath.IsOptions1); + Assert.Equal(0x02000000u, pkDeath.Mask); + Assert.False(pkDeath.IsAutoSave); + } + + public static IEnumerable AllModeledIds() + { + for (uint id = 0x00; id <= 0x34; id++) + yield return [(CharacterOptionId)id]; + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index 9a05cc0f..8d33593d 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -286,6 +286,167 @@ public sealed class RuntimeCharacterStateTests Assert.Equal(beforeRevision, options.Revision); } + // ── OP1 (Campaign OP, 2026-08-10): TrySetOption — the shared + // local-write-then-send/dirty seam, + the dirty/flush state machine ──── + + [Fact] + public void TrySetOption_AutoSaveId_WritesLocallyThenSendsImmediately_NeverDirties() + { + var options = new RuntimeCharacterOptionsState(); + options.Replace(options.Options1, 0u); // every Options2 Hear*Chat bit off + var sent = new List<(uint OptionId, bool Value)>(); + + bool accepted = options.TrySetOption( + (uint)CharacterOptionId.ListenToGeneralChat, + true, + sendAutoSave: () => sent.Add( + ((uint)CharacterOptionId.ListenToGeneralChat, true))); + + Assert.True(accepted); + Assert.Equal( + (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat, + options.Options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat); + Assert.Equal([((uint)CharacterOptionId.ListenToGeneralChat, true)], sent); + Assert.False(options.IsDirty); + Assert.Null(options.FirstDirtiedAt); + } + + [Fact] + public void TrySetOption_BatchedId_WritesLocallyAndMarksDirty_NeverSends() + { + var options = new RuntimeCharacterOptionsState(); + var sent = new List<(uint OptionId, bool Value)>(); + + // AutoTarget (0x0D) is default-ON per CharacterOptionTable — flip it + // off to exercise a real transition. + bool accepted = options.TrySetOption( + (uint)CharacterOptionId.AutoTarget, + false, + sendAutoSave: () => sent.Add(((uint)CharacterOptionId.AutoTarget, false))); + + Assert.True(accepted); + // AutoTarget_CharacterOption = 0x2000 (acclient.h:3417). + Assert.Equal(0u, options.Options1 & 0x00002000u); + Assert.Empty(sent); + Assert.True(options.IsDirty); + Assert.NotNull(options.FirstDirtiedAt); + } + + [Fact] + public void TrySetOption_UnchangedValue_IsANoOp_MatchingRetailEarlyReturn() + { + var options = new RuntimeCharacterOptionsState(); + var sent = new List<(uint, bool)>(); + // AutoTarget defaults ON — re-asserting ON must be a no-op (retail: + // an unchanged option produces no notice, no side effect, no send). + bool accepted = options.TrySetOption( + (uint)CharacterOptionId.AutoTarget, + true, + sendAutoSave: () => sent.Add(((uint)CharacterOptionId.AutoTarget, true))); + + Assert.True(accepted); + Assert.Empty(sent); + Assert.False(options.IsDirty); + } + + [Fact] + public void TrySetOption_UnknownId_ReturnsFalse_NeverInvokesCallback() + { + var options = new RuntimeCharacterOptionsState(); + bool invoked = false; + + bool accepted = options.TrySetOption(0x35u, true, () => invoked = true); + + Assert.False(accepted); + Assert.False(invoked); + Assert.False(options.IsDirty); + } + + [Fact] + public void MarkDirty_OnlySecondCallDoesNotPushOutFirstDirtiedAt() + { + var clock = new ManualTimeProvider(); + var options = new RuntimeCharacterOptionsState(clock); + + options.TrySetOption( + (uint)CharacterOptionId.AutoTarget, false, () => { }); + DateTimeOffset? firstStamp = options.FirstDirtiedAt; + Assert.NotNull(firstStamp); + + clock.Advance(TimeSpan.FromSeconds(10)); + options.TrySetOption( + (uint)CharacterOptionId.ShowTooltips, false, () => { }); + + Assert.Equal(firstStamp, options.FirstDirtiedAt); + } + + [Fact] + public void TryFlush_NoOpWhenClean_FlushesAndClearsWhenDirty() + { + var options = new RuntimeCharacterOptionsState(); + int cleanFlushes = 0; + Assert.False(options.TryFlush(() => cleanFlushes++)); + Assert.Equal(0, cleanFlushes); + + options.TrySetOption( + (uint)CharacterOptionId.AutoTarget, false, () => { }); + Assert.True(options.IsDirty); + + int dirtyFlushes = 0; + Assert.True(options.TryFlush(() => dirtyFlushes++)); + Assert.Equal(1, dirtyFlushes); + Assert.False(options.IsDirty); + Assert.Null(options.FirstDirtiedAt); + + // A second flush on a now-clean module is a no-op — retail's + // SaveToServer(force: 0) sends nothing for a clean module. + Assert.False(options.TryFlush(() => dirtyFlushes++)); + Assert.Equal(1, dirtyFlushes); + } + + [Fact] + public void TryFlushIfAutoSaveDue_DoesNotFireBeforeThreshold_FiresAtThreshold() + { + var clock = new ManualTimeProvider(); + var options = new RuntimeCharacterOptionsState(clock); + options.TrySetOption( + (uint)CharacterOptionId.AutoTarget, false, () => { }); + + int flushes = 0; + clock.Advance(RuntimeCharacterOptionsState.AutoSaveDelay - TimeSpan.FromSeconds(1)); + Assert.False(options.TryFlushIfAutoSaveDue(() => flushes++)); + Assert.True(options.IsDirty); + + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(options.TryFlushIfAutoSaveDue(() => flushes++)); + Assert.Equal(1, flushes); + Assert.False(options.IsDirty); + } + + [Fact] + public void ResetSession_ClearsDirtyState() + { + var options = new RuntimeCharacterOptionsState(); + options.TrySetOption( + (uint)CharacterOptionId.AutoTarget, false, () => { }); + Assert.True(options.IsDirty); + + options.ResetSession(); + + Assert.False(options.IsDirty); + Assert.Null(options.FirstDirtiedAt); + } + + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset _now = new(2026, 8, 10, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan elapsed) => _now += elapsed; + } + // ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ─── // ── run/jump skill (pseudocode doc §9) ───────────────────────────── diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index df191d75..256f1efd 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -274,6 +274,156 @@ public sealed class DirectGameRuntimeCommandAdapterTests Assert.False(runtime.Session.IsInWorld); } + // ── OP1 (Campaign OP, 2026-08-10): the headless local-write-then-send + // seam, SaveOptions, and unknown-id rejection ─────────────────────── + + [Fact] + public void SetSingleOption_AutoSaveId_WritesLocalBitBeforeTheWireSendFires() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + uint? options2AtSendTime = null; + operations.Sessions[^1].GameActionCapture = _ => + options2AtSendTime ??= runtime.CharacterOwner.Options.Options2; + + // Options2 default (0x00948700) has HearGeneralChat (0x100) ON; + // toggling it OFF exercises the local-write-then-send ordering this + // slice fixed on the headless path (lane B §4.4 / lane C §7.4). + RuntimeCommandResult result = adapter.Character.SetSingleOption( + runtime.Generation, + (uint)CharacterOptionId.ListenToGeneralChat, + false); + + Assert.True(result.Accepted); + Assert.NotNull(options2AtSendTime); + Assert.Equal(0u, options2AtSendTime!.Value & 0x00000100u); + Assert.Equal( + 0u, + runtime.CharacterOwner.Options.Options2 & 0x00000100u); + runtime.Dispose(); + } + + [Fact] + public void SetSingleOption_BatchedId_MarksDirtyWithoutSendingAnything() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + // AutoTarget (0x0D) is batched, default ON — flip it off. + RuntimeCommandResult result = adapter.Character.SetSingleOption( + runtime.Generation, + (uint)CharacterOptionId.AutoTarget, + false); + + Assert.True(result.Accepted); + Assert.Empty(gameActions); + Assert.True(runtime.CharacterOwner.Options.IsDirty); + runtime.Dispose(); + } + + [Fact] + public void SetSingleOption_UnknownId_RejectsWithoutSendingAnything() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + // 0x35 == CharacterOptions1Default — the whole-default-mask landmine + // (wire research §5.4.3), never a real option. + RuntimeCommandResult result = adapter.Character.SetSingleOption( + runtime.Generation, + 0x35u, + true); + + Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); + Assert.Empty(gameActions); + runtime.Dispose(); + } + + [Fact] + public void SaveOptions_FlushesTheDirtyBlobThenNoOpsWhenClean() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + adapter.Character.SetSingleOption( + runtime.Generation, (uint)CharacterOptionId.AutoTarget, false); + Assert.True(runtime.CharacterOwner.Options.IsDirty); + Assert.Empty(gameActions); + + RuntimeCommandResult saved = adapter.Character.SaveOptions(runtime.Generation); + + Assert.True(saved.Accepted); + Assert.False(runtime.CharacterOwner.Options.IsDirty); + byte[] blob = Assert.Single(gameActions); + Assert.Equal( + SocialActions.SetCharacterOptionsOpcode, + System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian( + blob.AsSpan(8))); + + // A clean module's second SaveOptions sends nothing more. + RuntimeCommandResult savedAgain = + adapter.Character.SaveOptions(runtime.Generation); + Assert.True(savedAgain.Accepted); + Assert.Single(gameActions); + runtime.Dispose(); + } + + private static (GameRuntime Runtime, DirectGameRuntimeCommandAdapter Adapter, FixtureSessionOperations Operations) + CreateStartedHarness() + { + var operations = new FixtureSessionOperations(); + var gameplay = new FixtureGameplayOperations(); + var runtime = new GameRuntime(new GameRuntimeDependencies( + gameplay, + gameplay, + gameplay, + gameplay, + SessionOperations: operations)); + gameplay.Bind(runtime); + var resetHost = new FixtureResetHost(); + DirectGameRuntimeCommandAdapter? adapter = null; + LiveSessionConnectOptions options = new( + true, + "127.0.0.1", + 9000, + "account", + "password"); + var live = new LiveSessionHost( + runtime.Session, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + _ => new FixtureEventRoute(), + session => adapter!.CreateRoute(session)), + generation => runtime.ResetGeneration( + generation, + resetHost), + new LiveSessionSelectionBindings( + id => runtime.PlayerIdentity.ServerGuid = id, + _ => { }, + runtime.CommunicationOwner.Chat.SetLocalPlayerGuid, + _ => { }, + _ => { }, + runtime.ActionOwner.Combat.Clear), + new LiveSessionEnteredWorldBindings( + _ => { }, + () => { }, + () => { }, + _ => { }, + () => { }), + (_, _, _) => { }, + () => { }), + options); + adapter = new DirectGameRuntimeCommandAdapter(runtime, live); + _ = adapter.Session.Start(runtime.Generation); + return (runtime, adapter, operations); + } + private sealed class FixtureSessionOperations : ILiveSessionOperations { public List Sessions { get; } = [];