diff --git a/CLAUDE.md b/CLAUDE.md index ea825031..c4e2124e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,9 @@ parity (`docs/plans/2026-08-09-chat-parity-campaign.md`) is CODE-COMPLETE channels, command registry) landed and dual-Opus-reviewed, pending the connected user gate (test script: `docs/research/2026-08-09-campaign-ch-test-script.md`); carried tail -#358–#363. +#359–#361 (#358 and #362 closed pre-existing; #363 closed 2026-08-10 — +the interface-text seam, see `docs/plans/2026-08-09-chat-parity-campaign.md`'s +ledger). **Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every placement route now runs through the canonical residence + continuation- diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 59f826eb..134a3913 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -96,30 +96,53 @@ slice CH4). ## #363 — Chat refusal/usage call sites are typed ClientLocal 0x00 where retail types several 0x1A -**Status:** OPEN — filed 2026-08-09, CH4 REJECT-review, SHOULD-FIX 9. -`ChatVM.ShowSystemMessage`'s single-typed sink (`LogTextType 0x00`, -informational) is correct for most of `ClientCommandController`'s output, -but Campaign CH slice CH4 added roughly 10 new refusal/usage call sites -that retail types `0x1A` (bright red / ClientLocal), not `0x00`: -`DoStupidChannelHack` (the "You must specify the text you wish to say!" -family, registered channel verbs only), `DoChannelList`/`DoChannelOn`/ -`DoChannelOff` ("Please specify the channel name."), `DoAllegiance` (the -"Please see @help Allegiance..." refusal this session's Blocker 1 fix -added), `DoHouseAvailableList`, and `DoReply` ("Someone must @tell you -first!"). Three CH4 sites are ALREADY correct because retail itself types -them informational `0x00`: `DoSpeaker`, `DoEndurance`, `DoTitle`. -Separately, retail's own bad-args fallback -(`ClientCommunicationSystem::DoCommand @0x0057E46D`) answers a registered -handler that returns 0 with `HandleFailureEvent(0x26)`, not a local -"Usage: " line — `ChatCommandRouter.Submit` shows a synthesized -`"Usage: {clientCommand.Usage}"` string instead whenever a catalog -command's `InvalidArgumentsText` is null. Register row: AP-183. -Deliberately NOT fixed this session — re-plumbing every call site to a -typed sink (and porting `HandleFailureEvent(0x26)`'s real text) is larger -than a REJECT-review fix batch; CH5-or-later. +**Status:** CLOSED 2026-08-10. `ChatVM` gained a typed interface-text seam +(`OnInterfaceText` init hook + `ShowInterfaceText(text)`) that the App-layer +composition (`InteractionRetainedUiComposition.CreateRetainedUi`) wires to +`RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)` — +the same SpewBox chokepoint every other interface-text producer uses. +UI.Abstractions still never references Runtime directly (Code Structure +Rules); the hook is the seam. Unwired callers (headless, the automation +probe runner, plain test fixtures) fall back to the ordinary chat log +tagged `ClientLocal`, so no text is ever silently dropped. + +Every site AP-183 named now routes through the seam: `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` shape with NO message at all), `DoChannelList`/`DoChannelOn`/ +`DoChannelOff` ("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 — verified at `acclient_2013_pseudo_c.txt:381481`/`1029383`, +replacing the acdream-synthesized "Usage: /hslist " fallback), +and `DoReply` ("Someone must @tell you first!", newly wired for the +"message but no last teller" branch — bare `/r` with no message at all is +a separate retail branch, deliberately out of scope, not named by AP-183). +`DoSpeaker`/`DoEndurance`/`DoTitle` are untouched — already correct at +`0x00` (their text is produced by `ClientCommandController`, not +`ChatCommandRouter`). + +The generic bad-args fallback is also fixed: `ChatCommandRouter.Submit`'s +catalog dispatch now falls to `WeenieErrorMessages.Resolve(0x026u, null)` +("That is not a valid command.", the exact port of retail's +`HandleFailureEvent(0x26)`) instead of synthesizing `"Usage: {Usage}"` — +verified 5 decompiled handlers (`DoDie`, `DoChannelList`/`On`/`Off`, +`DoAllegiance`, `DoHouseAvailableList`) are ALL `0x1A`, confirming the +uniform routing decision. This also closes #367 (the "Unknown command" +DoHelp fallback and the degenerate-prefix "Unknown command: {verb}." +refusal both now use the same seam) and retires register row AP-186 — +see that row's retirement note. + +Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs` +(seam wired / null-fallback), `ChatInputParserTests.cs` +(`IsBareRegisteredChannelVerb`/`IsReplyMissingLastTeller` pure-predicate +coverage), `ChatCommandRouterTests.cs` (per-site routing pinned both ways +for every reclassified/newly-wired site, plus a Turbine-only-channel +negative case and a 0x00-site-stays-in-chat sanity check). **Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH, -CH4 REJECT-review). +CH4 REJECT-review; closed in the goal-window follow-up). ## #365 — Headless host cannot move at head: session quarantines on the first advance tick; world never hydrates @@ -343,46 +366,19 @@ click — not decoded by CH6a. ## #367 — ChatCommandRouter's local-presentation fallbacks type-0x1A text still lands in the chat scroll, never the SpewBox -**Status:** OPEN — filed 2026-08-10, Campaign CH user-gate round 3, while -tracing `ClientCommunicationSystem::DoHelp @0x0057f9e0`'s complete print -sequence for findings (b)/(c). Retail's DoHelp fallback for an unresolved -`/help ` is `AddTextToScroll(u"Unknown command", 0x1A, 1, 0)` — type -`0x1A` (`ClientLocal`) is HARDCODED to the SpewBox, never the chat window -(`docs/research/2026-08-09-chat-retail-interface-text.md` §2.1/§2.2, the -same routing rule Campaign CH user-gate round 2 item 2 already ported for -the portal-space notice). `RuntimeCommunicationState.AddText` (Runtime -layer) implements this rule correctly — `type == ClientLocal` routes to -`SpewBox` only. But `ChatCommandRouter`/`ChatVM` live in -`AcDream.UI.Abstractions`, a layer beneath Runtime that must stay -presentation/Runtime-independent (Code Structure Rules), so they have no -path to the SpewBox at all — every local-presentation fallback -(`RetailCommandHelpTable.UnknownCommand` now; also the pre-existing -"Unknown command: {verb}." command-shaped-input refusal in -`ChatCommandRouter.Submit`'s main body) still writes through -`ChatVM.ShowSystemMessage`, which only ever reaches `ChatLog`. Not a -regression this round — the fallback text was already wrong AND -already routed to the chat window before this round's fix; this round -corrected the TEXT ("Unknown command", byte-exact) and traced the -routing divergence clearly enough to file it. Register row AP-186. - -**Fix shape:** either (a) give `ChatVM` (or a sibling in UI.Abstractions) -an optional `Action? OnClientLocalText` hook the App-layer host -wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)` -the same way `ChatWindowController`/other retained-UI controllers already -receive delegates from the composition layer, or (b) accept the -divergence permanently as an acdream simplification (all local-presentation -refusals show in the chat window instead of splitting across two surfaces) -and retire AP-186 as an accepted Intentional Architecture row instead. Needs -a product decision, not just an implementation — small either way once -decided. - -**Where:** `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; -`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`; -`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs` (`AddText`, the -correctly-implemented Runtime-layer oracle this should eventually reach). +**Status:** CLOSED 2026-08-10, closed as a side effect of #363's +interface-text seam (fix shape (a) from this issue's own filing). +`ChatVM.OnInterfaceText` is exactly the hook this issue asked for; both +named fallbacks (`RetailCommandHelpTable.UnknownCommand` in +`ChatCommandRouter.EmitVerbHelp`, and the degenerate-prefix "Unknown +command: {verb}." refusal in `ChatCommandRouter.Submit`'s main body) now +call `vm.ShowInterfaceText(...)` instead of `vm.ShowSystemMessage(...)`, +reaching the SpewBox through `RuntimeCommunicationState.AddText` via the +App-layer composition wiring. See #363's closure note for the full +mechanism and test list. Register row AP-186 retired in the same commit. **Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH, -user gate round 3). +user gate round 3; closed in the goal-window follow-up). ## #364 — Three `/help` group topics still partial: HelpStupidChannelHack unresolved diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 394ec1c7..4b0e07c6 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) — 135 active rows (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 one shared 0.5/1.0 default rather than gmMainChatUI's per-class 1.0/1.0 override, 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 filed 2026-08-10 at Campaign CH user-gate round 3, while tracing DoHelp's print sequence — ChatCommandRouter's type-0x1A local-presentation fallbacks (now including the byte-exact "Unknown command" text) still render via the chat scroll instead of the SpewBox, an architectural layering gap (UI.Abstractions cannot see Runtime's `RuntimeCommunicationState.AddText`), tracked as 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 filed 2026-08-09 at Campaign CH user-gate round 2, item 3 — three of the seven retail `/help` group-topic listings (channels/chatting/commands) remain PARTIAL, not fully verbatim: their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which constructs its output from three BN-mislabeled data fragments (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc. are NOT real vtable dispatch) concatenated around a live `ChannelSystem::GetChannelName` lookup, not decodable with confidence from a static string sweep; each partial group keeps its own verbatim summary line and an explicit UNVERIFIED note instead of the fully-fabricated meta-message the user caught on `/help death` (that group, plus status/text/allegiances, are now COMPLETE verbatim listings); tracked as ISSUES.md #364. 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 filed 2026-08-09 at the CH4 REJECT-review, item 9 — roughly 10 chat refusal/usage call sites this campaign added route through `ChatVM.ShowSystemMessage`'s single `ClientLocal 0x00` sink where retail types several of them `0x1A`: `DoStupidChannelHack`, `DoChannelList`/`On`/`Off`, `DoAllegiance`, `DoHouseAvailableList`, `DoReply`; three sites (`DoSpeaker`/`DoEndurance`/`DoTitle`) are already correct at `0x00`, matching retail. Retail's own bad-args fallback (`DoCommand @0x0057E46D`) also answers with `HandleFailureEvent(0x26)`, not a local "Usage:" line, which acdream's `ChatCommandRouter.Submit` synthesizes instead. Deliberately NOT re-plumbed this session — filed as issue #363, marked for CH5-or-later; 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) — 133 active rows (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 one shared 0.5/1.0 default rather than gmMainChatUI's per-class 1.0/1.0 override, 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 filed 2026-08-09 at Campaign CH user-gate round 2, item 3 — three of the seven retail `/help` group-topic listings (channels/chatting/commands) remain PARTIAL, not fully verbatim: their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which constructs its output from three BN-mislabeled data fragments (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc. are NOT real vtable dispatch) concatenated around a live `ChannelSystem::GetChannelName` lookup, not decodable with confidence from a static string sweep; each partial group keeps its own verbatim summary line and an explicit UNVERIFIED note instead of the fully-fabricated meta-message the user caught on `/help death` (that group, plus status/text/allegiances, are now COMPLETE verbatim listings); tracked as ISSUES.md #364. 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 @@ -335,10 +335,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers committed the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm named the destination — measured 2026-08-04 at round 3. **AMENDED 2026-08-05 at the C5b architecture review: the route-2 half of that measurement is now STALE and the two callers no longer agree.** C5b made the merge withhold the wire cell (AD-60), and route 2 submits from `TryExecuteAcceptedLocalPosition` BEFORE the `OnPosition` prologue rebucket (W2) it returns ahead of — so on a route-2 FIRST submit `PlacementTouchesPrefix`'s `CurrentCellId` arm now names the SOURCE landblock the local player is leaving, not the destination. The graphical REMOTE half is unchanged: its prologue rebucket still runs ahead of the far-snap submit. The consequence is confined to which prefix the quiescence pre-flight matches, which this row's own part (2) already established cannot be the correctness mechanism (`SubmitPreparedPlacementCore`'s restorable parks are); it widens rather than narrows the set of prefixes a local force can be parked against. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and the surviving non-Position rebucket writer (the projection materializer — C4 route 4b-3 deleted the second shipped writer, `RemoteTeleportController`'s rollback, and C4 route 7 D4 demoted the third, the equipped-child renderer, to a presentation-only move that no longer touches `record.FullCellId`) can rebucket it to a third landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). **C4 route 4b-3 adds a second producer of the visible-without-collision shape in item (1)'s storing list**: the teleport arm inherits the identical store-and-stay-visible residual for the same reasons — a remote that teleports into a non-published landblock and stands still is visible but not collidable until a later packet commits. No new machinery; the retirement path is the same #309. A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | | AP-139 | **Filed 2026-08-04 (Bug B).** The remote tick clears its InterpolationManager queue on the LANDING edge — retail’s own `set_on_walkable(1)` transition, the same edge HitGround fires from. Retail has no such clear on a ground or contact edge: its only queue teardown outside a completed walk is `PositionManager::StopInterpolating` from `CPhysicsObj::teleport_hook` @0x00514EFD and the `InterpolationManager::UseTime` @0x00555f20 stall/autonomy blips. The clear is carried over unchanged in intent from the deleted hand-rolled landing block (#184, 2026-07-07), which hung it on a hand-rolled `Airborne && IsOnGround && Velocity.Z <= 0` test that also fired on a steep (non-walkable) contact; Bug B re-derived the edge without changing the behaviour it was written for | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (the SetPositionInternal commit block); the packet-side twin lives in `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the player-remote landing snap) | A contact-free arc never enqueues — route 4a's airborne no-op writes nothing at all — so anything still queued when the body lands is a pre-arc waypoint, and the first catch-up after touchdown would otherwise walk the body backward toward it | A remote that regains contact while a legitimately fresh waypoint is queued loses one correction and re-acquires it on the next accepted Position (~5-10 Hz). A body that repeatedly loses and regains contact (a bounce chain down a rough face) clears the queue once per bounce. Retire when the arc itself feeds the queue, at which point the pre-arc waypoints are no longer stale | `CPhysicsObj::teleport_hook @ 0x00514ED0` (`StopInterpolating` @0x00514EFD); `InterpolationManager::UseTime @ 0x00555f20`; `CPhysicsObj::SetPositionInternal @ 0x00515330` | | AP-181 | **Filed 2026-08-09 (Campaign CH slice CH3, side-channel gate); corrected 2026-08-09 at the CH3 Opus review (S6) — the original text named only the spam throttle and wrongly credited `RouteLegacyChannel` with porting gates it has no code for.** Retail's `SendTurbineChat @0x0057db10` runs TWO local pre-send refusals acdream has no port for, in this order: `IsMessageSafe(text)` first (a silent drop — no wire send, no local text at all), then, only if that passes, the per-account spam throttle `IsMessageSpam()` (→ "You must wait %ds before communicating again!"). acdream's `TurbineChatMembershipGate`/`RouteTurbineChat` port the Turbine-unavailable and Hear-option gates that run BEFORE both checks in retail's own function (§4.2) and stop there — neither `IsMessageSafe` nor `IsMessageSpam` exists anywhere in acdream. `RouteLegacyChannel` is the unrelated legacy 0x0147 `ChatChannel` pipeline and has no equivalent of either check in retail OR acdream — it was never the site these two gates belonged to. | `src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs`; `src/AcDream.App/Net/LiveSessionCommandRouter.cs` (`RouteTurbineChat`) | The user's target server (local ACE) leaves `chat_requires_account_15days`/`chat_requires_player_level` etc. at their disabled defaults (research doc §3.6) and has no observed rate-limit or unsafe-content complaint; porting a client-side throttle/safety check with no server-side counterpart to validate against risks inventing a threshold retail didn't use. | A future connected gate against a server that DOES rate-limit chat, or a deliberately unsafe test string, would see every send attempted rather than refused after the first — cosmetic only, since ACE's own server-side handling (if any) still governs what actually reaches other players. | `ClientCommunicationSystem::SendTurbineChat @0x0057db10` (`IsMessageSafe`/`IsMessageSpam` branches); research doc `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §4.2 | -| AP-183 | **Filed 2026-08-09 at the CH4 REJECT-review, item 9.** Roughly 10 chat refusal/usage call sites Campaign CH slice CH4 added route through `ChatVM.ShowSystemMessage`'s single `LogTextType 0x00` (ClientLocal-informational) sink; retail types several of them `0x1A` (bright red / genuine refusal) instead: `DoStupidChannelHack` (the "You must specify the text you wish to say!" family, registered channel verbs), `DoChannelList`/`DoChannelOn`/`DoChannelOff` ("Please specify the channel name."), `DoAllegiance` (the "Please see @help Allegiance..." refusal this session's Blocker 1 added), `DoHouseAvailableList`, and `DoReply` ("Someone must @tell you first!"). Three CH4 sites are already correct at `0x00` because retail itself types them informational: `DoSpeaker`, `DoEndurance`, `DoTitle`. Separately, retail's own bad-args fallback (`ClientCommunicationSystem::DoCommand @0x0057E46D`) answers a registered handler that returns 0 with `HandleFailureEvent(0x26)`, not a local "Usage: " line — `ChatCommandRouter.Submit` synthesizes a `"Usage: {clientCommand.Usage}"` string instead whenever a catalog command's `InvalidArgumentsText` is null. Filed as issue #363; deliberately NOT re-plumbed this session (re-typing every call site is larger than a REJECT-review fix batch). `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` (`ShowSystemMessage`); `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` (`Submit`'s `Usage:` fallback) | Every one of these sites shows correctly-worded text in the correctly-shaped chat log entry, just at the wrong color/destination classification — a real but low-severity divergence from retail's exact on-screen presentation | A user comparing acdream's chat window side-by-side with retail for one of these specific refusals sees the wrong color (informational white/default instead of bright red) or, for the generic "Usage:" fallback, different WORDING than retail's `HandleFailureEvent(0x26)` text entirely | `ClientCommunicationSystem::DoStupidChannelHack @ 0x0057B144`; `DoChannelList @ 0x0057A9B0`; `DoChannelOn @ 0x0057AA80`; `DoChannelOff @ 0x0057AB50`; `DoAllegiance @ 0x0057D5A0`; `DoHouseAvailableList @ 0x00570510`; `DoReply @ 0x00577910`; `DoCommand @ 0x0057E46D` (`HandleFailureEvent(0x26)`) | | AP-182 | **Filed 2026-08-09 (Campaign CH slice CH4); corrected 2026-08-09 at the CH4 REJECT-review (nit 11).** `@title ` is wired to a pure no-op — `LiveSessionRuntimeFactory`'s `SetChatTitle` binding is `_ => { }`; the requested title is neither stored nor consumed anywhere (the original filing's "stores the value locally" claim was false). This matches retail's own silent success (no confirmation text was recovered at the `DoTitle` success site, so a no-visible-effect accept is exactly as faithful as a stored-but-unread value would be). Also omitted: `DoTitle`'s three local failure messages — no title given, "You must provide a new title for the window."; length over 99 characters, "Window title length cannot exceed 100 characters."; and wrong source window (`m_idCurrentCommandSource` 1 or 8), "This command must be issued from a popup chat window." — acdream's catalog validator (`ClientCommandId.SetChatTitle`, `AnyArguments`) accepts any argument shape and never raises any of the three. `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`SetChatTitle`) | Retail's chat window presumably re-renders its title bar text; acdream's chat window has no title bar at all under the current retained-UI import, so there is nothing to visually diverge from yet | Once a titled chat-window chrome is built, `@title` needs to be re-wired to it — today it is a pure no-op, and the three failure messages above are silently absent | `ClientCommunicationSystem::DoTitle @ 0x0057A640` | | AP-185 | **Filed 2026-08-10 (Campaign CH slice CH6a — retail chat-window layout + 8-grip resize).** The main chat window's 8 cosmetic `_Locked` border-art twins (`0x10000693`-`0x1000069A`) are retail's `PlayerModule::LockUI`-driven alternate skin — `gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0` swaps them in for the 8 live Resizebar/Dragbar grips (`0x1000069B`-`0x100006A2`) when the UI is locked, and swaps them back out when unlocked. `ChatWindowController.Bind` always hides the twins and always shows the live set — i.e. it renders only retail's UNLOCKED skin, regardless of `UiRoot.UiLocked`. `src/AcDream.App/UI/Layout/ChatWindowController.cs` (`LockedTwinIds`) | `UiRoot.UiLocked` already gates the underlying move/resize INTERACTION generically and correctly in both states (locked ⇒ no move, no resize, regardless of which border art is drawn); the two art sets occupy identical rects, so always showing the interactive-grip skin is a cosmetic simplification, not a functional one, and the default matches `UiLocked`'s own `false` default | A user who locks the UI (`PlayerModule::LockUI`) sees the interactive-grip chat-window border art unchanged instead of retail's inert locked variant — cosmetic only; the window still correctly refuses to move or resize while locked | `gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0`; `PlayerModule::LockUI`; `docs/research/2026-08-09-chat-retail-window-shell.md` §1.6 | -| AP-186 | **Filed 2026-08-10 at Campaign CH user-gate round 3, while tracing `ClientCommunicationSystem::DoHelp @0x0057f9e0`'s complete print sequence for findings (b)/(c).** Retail's DoHelp fallback for an unresolved `/help ` is `AddTextToScroll(u"Unknown command", 0x1A, 1, 0)` — type `0x1A` (`ClientLocal`) is HARDCODED to the SpewBox, never the chat window (same routing rule round 2 item 2 already ported for the portal-space notice; `docs/research/2026-08-09-chat-retail-interface-text.md` §2.1/§2.2). `RuntimeCommunicationState.AddText` (Runtime layer) implements this rule correctly. But `ChatCommandRouter`/`ChatVM` live in `AcDream.UI.Abstractions`, a layer beneath Runtime that must stay presentation/Runtime-independent (Code Structure Rules) — they have no path to the SpewBox, so every local-presentation fallback typed `ClientLocal` (now `RetailCommandHelpTable.UnknownCommand`; also the pre-existing "Unknown command: {verb}." command-shaped-input refusal in `ChatCommandRouter.Submit`'s main body) still writes through `ChatVM.ShowSystemMessage`, which only ever reaches `ChatLog`. Not a regression this round — the fallback already showed in the chat window before this round's fix (with fabricated text); this round corrected the TEXT and traced the routing divergence clearly enough to register it. Filed as ISSUES.md #367. `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` | This is an architectural layering gap (UI.Abstractions cannot see Runtime), not a data-availability gap — `RuntimeCommunicationState.AddText` already implements the correct routing rule one layer up, so fixing it is a plumbing exercise (an optional delegate hook), not new research | A user typing an unresolvable `/help ` sees "Unknown command" in the chat scroll instead of the top-of-screen SpewBox flash retail shows — text content is now byte-exact, only the SURFACE differs | `ClientSystem::AddTextToScroll @0x00563C50`; `docs/research/2026-08-09-chat-retail-interface-text.md` §2.1/§2.2; `src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs` (`AddText`, the correctly-implemented oracle) | | AP-187 | **Filed 2026-08-10 (Campaign CH slice CH6b — floating chat windows).** The four floating chat windows' text-type filters (`AcDream.Core.Chat.ChatWindowState`, retail's `0x1000007F` per-window option) persist only in local `settings.json` (`ChatSettings.ChatWindow1Filter`..`ChatWindow4Filter`, `SettingsStore.LoadChat`/`SaveChat`). Retail's authoritative store for this same data is the per-window option array (`0x1000008C`) packed inside the character-scoped `GameplayOptions` blob, which ACE stores and echoes as opaque bytes without parsing (window-shell research doc §4.1/§4.4); acdream has no reader or writer for that blob (CH3 already deleted one malformed attempt at the outbound `SetCharacterOptions 0x01A1` builder — `SocialActions.cs`). Geometry and open/visible state for these same four windows do NOT need a row of their own: they persist through the pre-existing generic `RetailWindowLayoutPersistence` path (X/Y/W/H/visible/collapsed/maximized per window name), which is retail's OWN local-file mechanism too (`gmGamePlayUI::SaveScreenLayout`/`LoadScreenLayout`, window-shell research doc §4.3) — only the filter mask lacks any such local-file precedent in retail and is acdream's own addition to make the feature usable before CH6f lands. `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs` (`LoadChat`/`SaveChat`/`BuildChatObject`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountFloatingChatWindows`, `SaveChatWindowFilters`) | CH6a/CH6b's own port-shape recommendation (window-shell research doc §6.1) explicitly chose local persistence first and deferred the `0x1000008B`/`0x1000008C` wire to a dedicated CH6f slice, citing CH3's deleted malformed builder as the reason not to rush it | A character's floating-window filter customization does not travel to a different acdream install, and would not round-trip through a retail client sharing the same character (retail would see acdream's local-only values as unset, falling back to its own `PostInit` defaults) — cosmetic/preference-only, no gameplay effect | `PlayerModule::GetChatOptionStructure @0x005D5300`; `PlayerModule::InqChatWindowOption/SetChatWindowOption @0x005D5540/:70`; `docs/research/2026-08-09-chat-retail-window-shell.md` §4.1/§4.4/§6.1; `docs/plans/2026-08-09-chat-parity-campaign.md` (CH6f row) | | AP-188 | **Filed 2026-08-10 (Campaign CH slice CH6b — floating chat windows).** A floating chat window's chat entry always sends on the `Say` channel (`FloatingChatWindowController.Bind`'s `OnSubmit` hardcodes `ChatChannelKind.Say`). The floaty LayoutDesc (`0x2100005B`) authors no talk-focus menu (window-shell research doc §2.2 — only the main window's layout has one, element `0x10000014`), so there is no visible channel picker on a floaty window either way, matching retail's authored UI exactly. What is UNVERIFIED is whether retail's actual SEND path for a floaty window's typed message reads a per-window channel or the single globally-current talk-focus channel/target the main window's menu (or the last-selected/last-speakable-target state `gmMainChatUI::UseTime @0x004CDB20` tracks) last set — if the latter, a real retail floaty window would send on whatever channel the player most recently picked from the MAIN window, not always `Say`. Confirming this requires tracing `gmCCommunicationSystem`'s send-command path from a floaty `ChatInterface` instance, not yet done. Filed as ISSUES.md #369. `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` (`Bind`, the `OnSubmit` wiring) | Building genuine cross-window shared-channel state (reading `ChatWindowController`'s private `_activeChannel` from four independent sibling controllers, or promoting it to a shared owner) is a real design decision outside this slice's explicit scope (task items 1-6 do not ask for cross-window channel sharing); `Say` is retail's own default channel and the safest fixed value absent confirmation | If retail's actual mechanism is "send on the currently-selected global channel," a user who selects e.g. Fellowship from the main window's talk-focus menu and then types into a floaty window would see it sent as Fellowship in retail but as Say in acdream — no data loss (the message still sends), only channel-selection mismatch | `gmMainChatUI::InitTalkFocusMenu @0x004CDC50`; `gmMainChatUI::UseTime @0x004CDB20`; `docs/research/2026-08-09-chat-retail-window-shell.md` §2.2 | | AP-189 | **Filed 2026-08-10 at the CH6a/b REJECT-review rework (SHOULD-FIX 5, `docs/research/2026-08-10-ch6ab-review-findings.md`).** Retail keeps a PER-`ChatInterface` `m_chatLog`, truncated at 10,000 lines (`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4711` → `TruncateChatLog`) — each of the five windows (main + 4 floaty) owns its OWN 10,000-line backlog, and a closed window keeps accumulating into its own log because `gmFloatyMainChatUI::SetVisible @0x004CE9B0` never unregisters the handler. acdream instead shares ONE canonical `ChatLog` capped at 500 entries (`RuntimeCommunicationState`'s ctor, `maximumChatEntries: 500`) with a 200-line display tail every window filters from (`InteractionRetainedUiComposition.cs:564`'s `displayLimit: 200` feeding `ChatVM.RecentLinesDetailed`; `ChatWindowState.ShouldDisplay` does the per-window filtering). The accumulate-while-closed and independent-per-window-scroll BEHAVIORS both fall out correctly from this shared-log shape, but the EFFECTIVE per-window scrollback DEPTH differs from retail's: a window whose filter accepts only a rare message type (e.g. a Fellowship-only floaty) sees only the fellowship lines that happen to still be inside the shared log's last 200-of-500 lines, not up to 10,000 like retail's own per-window log. `src/AcDream.Core/Chat/ChatLog.cs` (`_maxEntries`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:564` (`displayLimit: 200`); `src/AcDream.App/UI/Layout/ChatWindowController.cs`/`FloatingChatWindowController.cs` (`GetTranscriptLines`) | A single shared canonical log matches acdream's Slice-J "one canonical transcript, many filtered presentations" pattern and keeps memory bounded regardless of how many windows are open; 500 shared entries covers many minutes of typical mixed-channel play, and both retail-observable BEHAVIORS this row could have broken (closed-window accumulation, independent per-window scroll position) are reproduced correctly — only the numeric DEPTH ceiling differs | In a busy mixed-channel session (heavy General/Trade traffic), a rarely-used channel (Fellowship, a Turbine room) can scroll out of the shared 500-entry window long before a floaty window filtered to just that channel would have neared retail's 10,000-line depth — a user who opens that floaty window after a long session sees a much shorter backlog than retail would show for the same play session | `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640`/`TruncateChatLog @0x004F4711`; `gmFloatyMainChatUI::SetVisible @0x004CE9B0`; `docs/research/2026-08-09-chat-retail-window-shell.md` §1.2 | diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index e4dca9d0..d9ffdfa6 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -288,6 +288,7 @@ implementer per slice against a pinned contract (per | User gate round 3 | `98de4f5a` | Debug (all projects): 12,329 passed / 4 skipped / 1 failed (pre-existing #351 Debug-only flake — reproduces identically on the pristine pre-round-3 commit, not a regression); Release (every project reachable while a live `AcDream.App.exe` client — PID 15064, must not be killed per project policy — holds its own Release binaries locked, blocking `AcDream.App`/`AcDream.App.Tests`/`AcDream.Core.Tests` specifically): `AcDream.UI.Abstractions.Tests` (the layer this round's `/help` fix lives in) 867/867, plus `Core.Net.Tests` 823/823, `Runtime.Tests` 1,323/1,323, `Content.Tests` 130/130, `Headless.Tests` 89/89, `Bake.Tests` 15/15, `Cli.Tests` 4/4 — all 0 failed | — | findings (a)-(c) fixed this commit — SpewBox flush-top + retail dat font, `/help`/`/help death` exact retail print sequence (see "User gate — round 3" below) | | CH6b floating windows 1–4 | `22020ef2`, reworked `1aa77099` | 12,420 passed / 4 skipped / 0 failed | REJECT (docs/research/2026-08-10-ch6ab-review-findings.md) → reworked `1aa77099` — SHOULD-FIXES 2/3/4/5 + NITs 1-5 applied | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation, plus the new 0x2100005B fixture's resolved-type assumptions | | CH6c opacity | `a819687c` | 12,459 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed this commit — BLOCKER (out-of-box `DefaultOpacity` 0.5→1.0), AP-190 reworded + two new decomp-verified clauses (retail's per-tick ease, retail's entry-field-specific focus predicate), NITs (`UiElement.cs` stale comment, `WindowUnregistered` detach, post-Dispose `Set*` guards, `DrawString`/outline-pass alpha tests) | pending — needs the next connected round for visual confirmation (window fade on focus change, Settings slider live-apply) | +| Goal-window #363/#367 interface-text seam | `e822888a` | 12,542 passed / 4 skipped / 0 failed | implementer-only, no subagent review this session (hard constraint) | pending — needs the next connected round to confirm the SpewBox now flashes for the reclassified refusals (see "Goal-window follow-up" below) | ### CH4 closeout (2026-08-09) @@ -855,3 +856,87 @@ entirely against the FIX list, not the research. Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline 12,392/4/0 at `22020ef2`; net +28 tests, all new coverage from this rework, zero regressions). + +## Goal-window follow-up — #363/#367 interface-text seam (2026-08-10) + +Closed issues #363 and #367 (register rows AP-183 and AP-186, both +RETIRED) under the goal-window's hard constraints: no subagents, no +client launches, one commit. + +`ChatVM` (`AcDream.UI.Abstractions`) gained an `OnInterfaceText` +(`Action?`) init property and a `ShowInterfaceText(text)` method — +the seam #367's own filing proposed as fix shape (a). The App-layer +composition (`InteractionRetainedUiComposition.CreateRetainedUi`) wires it +to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, +the same SpewBox chokepoint every other interface-text producer (item +examine, magic feedback, the portal wait cue) already uses. Unwired hosts +(headless has no `ChatVM` at all; plain test fixtures) fall back to the +ordinary chat log tagged `ClientLocal`, so no text is ever silently +dropped — only the SURFACE degrades, never the message. + +Every site AP-183 named now routes through the seam at its correct retail +type, cross-checked against the named-retail decomp (5 handlers spot- +verified: `DoDie`, `DoChannelList`/`On`/`Off`, `DoAllegiance`, +`DoHouseAvailableList` — all `0x1A`): + +- `DoStupidChannelHack` ("You must specify the text you wish to say!") — + **newly wired**, not merely reclassified: the six legacy channel verbs + (Fellowship/Allegiance/AllegianceBroadcast/Vassals/Patron/CoVassals) + previously fell through `ChatInputParser.Parse`'s pure `return null` + shape with NO message shown at all. New `ChatInputParser + .IsBareRegisteredChannelVerb` predicate (pure, no side effects) plus a + `ChatCommandRouter.Submit` check ahead of `Parse`. +- `DoChannelList`/`DoChannelOn`/`DoChannelOff` ("Please specify the + channel name.") — reclassified (was already wired via + `InvalidArgumentsText`, just typed `0x00`). +- `DoAllegiance` ("Please see @help Allegiance...") — reclassified, same + shape. +- `DoHouseAvailableList` — reclassified AND corrected: retail's own + bad-house-type text is "Please see @help hslist for more information on + how to use this command" (`acclient_2013_pseudo_c.txt:381481`/`1029383`, + `AddTextToScroll(..., 0x1a, ...)`), not the acdream-synthesized "Usage: + /hslist " line the catalog fell back to. +- `DoReply` ("Someone must @tell you first!") — **newly wired** for the + message-but-no-last-teller branch only (`gmCCommunicationSystem + ::GetLastTeller() == 0`). New `ChatInputParser.IsReplyMissingLastTeller` + predicate. Bare `/r` with no message at all is retail's OWN separate + copy of the "you must specify text" string (a different call site) and + is deliberately still unported — not named by AP-183, out of scope. +- `DoSpeaker`/`DoEndurance`/`DoTitle` — untouched, confirmed still correct + at `0x00` (their text is produced by `ClientCommandController`, not + `ChatCommandRouter`). + +The generic bad-args fallback is also fixed: `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 +`"Usage: {Usage}"` — the row already carried the `0x026` template from +CH2's `HandleFailureEvent` port, so no new retail text needed extracting. + +This closes #367 too: `ChatCommandRouter`'s two other local-presentation +fallbacks (`RetailCommandHelpTable.UnknownCommand` in `EmitVerbHelp`, and +the degenerate-prefix "Unknown command: {verb}." refusal in `Submit`'s +main body) now call `ShowInterfaceText` instead of `ShowSystemMessage`. +The test-script doc (`docs/research/2026-08-09-campaign-ch-test-script.md`) +is updated to drop the "known, tracked gap" notes for both — the next +connected round should observe both fallbacks flash on the SpewBox instead +of landing in the chat window. + +Tests: per-site routing pinned both ways (seam wired → reaches the +SpewBox capture; seam unwired → falls back to chat, tagged `ClientLocal`) +for every reclassified/newly-wired site in +`tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`, +plus a Turbine-only-channel negative case and a 0x00-site-stays-in-chat +sanity check; pure-predicate coverage for `IsBareRegisteredChannelVerb`/ +`IsReplyMissingLastTeller` in `ChatInputParserTests.cs`; the seam itself +in `ChatVMRetellAndProvidersTests.cs`. + +Full Release suite: 12,542 passed / 4 skipped / 0 failed (baseline 12,466/4/0 +at `ff2784ea`; net +76 tests, all new coverage from this follow-up, zero +regressions). No subagent review this session (hard constraint); no +connected user gate (hard constraint — no client launches). The next +connected round should confirm: bare `/g`/`/a`/etc. now flash the SpewBox +red instead of doing nothing; `/hslist badtype` and a bad-args catalog +command (e.g. `/ls now`) now flash red SpewBox text instead of showing a +green "Usage:" line in chat; `/help nonsenseverb` and a bare `/`/`@` now +flash on the SpewBox instead of appearing in the chat scroll. diff --git a/docs/research/2026-08-09-campaign-ch-test-script.md b/docs/research/2026-08-09-campaign-ch-test-script.md index efe9c4b7..4a4f9151 100644 --- a/docs/research/2026-08-09-campaign-ch-test-script.md +++ b/docs/research/2026-08-09-campaign-ch-test-script.md @@ -73,10 +73,33 @@ Side-by-side vs retail if possible: - `/help death` → the SAME "Note:" line first, then a SECOND line starting "For more information, type @help ." immediately followed by the 8-line corpse/death command listing — not just the 8 lines alone. -- `/help somenonsenseverb` → "Unknown command" (no "Note:" line before it; - retail's fallback skips the wrapper). This still shows in the CHAT - window rather than the SpewBox — a known, tracked gap (#367/AP-186), not - a new bug to report. +- `/help somenonsenseverb` → **goal-window fix (2026-08-10, #367 closed):** + "Unknown command" now flashes on the SpewBox (top of viewport), NOT the + chat window — the SAME surface as section 1's "jump in the air" refusal. + Chat gets no line at all. + +## 6. Goal-window fix — SpewBox refusal typing (2026-08-10, #363/#367) + +- `/g` (or `/f`/`/a`/`/m`/`/p`/`/v`/`/c` etc.) with NO message → **"You + must specify the text you wish to say!"** flashes on the SpewBox. + Previously this silently did nothing. +- `/r hi there` (or `/reply`) with no prior incoming Tell → **"Someone + must @tell you first!"** flashes on the SpewBox. Previously silent. +- A bad-args catalog command, e.g. `/ls now` or `/marketplace foo` → **"That + is not a valid command."** flashes on the SpewBox. Previously showed a + green "Usage: /lifestone" line in CHAT — both the color/surface AND the + wording changed. +- `/hslist badtype` → **"Please see @help hslist for more information on + how to use this command"** flashes on the SpewBox. Previously showed a + green "Usage: /hslist " line in CHAT. +- `/clist`/`/on`/`/off` with no channel name, and `@allegiance boot Bob` + from section 4 above — SAME text as before, but now flash on the + SpewBox instead of showing in chat. +- A bare `/` or `@x` (no letter verb) and `/help somenonsenseverb` (section + 5) both now flash "Unknown command[: x]" on the SpewBox too. +- Sanity: `/endurance`, `@version`, `@loc` and other informational command + output must STILL show in chat (green/default), not the SpewBox — only + genuine refusals moved. ## Known-open, do not report as new @@ -84,9 +107,6 @@ Side-by-side vs retail if possible: - SpewBox line lifetime is still a placeholder (AP-177) pending a retail measurement session; position/font are now best-available approximations (AP-178), not confirmed retail pixel values. -- `/help ` fallback ("Unknown command") shows in the chat window, - not the SpewBox — retail types it for the SpewBox exclusively, but - `ChatCommandRouter` has no path there yet (#367/AP-186). - Allegiance management subcommands print the help refusal instead of executing (#360); `@day`/`@log`/`@render` deferred (#361); four request - commands send but responses aren't rendered yet (#362). + commands send but responses aren't rendered yet (#362, already closed). diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 8e2fa9ec..de1d5a2d 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -562,7 +562,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory var chat = new ChatVM( d.Communication.Chat, displayLimit: 200, - commandTargets: d.Communication.CommandTargets); + commandTargets: d.Communication.CommandTargets) + { + // Issue #363 / #367: routes ChatCommandRouter's 0x1A + // (ClientLocal) command refusals to the same SpewBox + // chokepoint every other interface-text producer uses, + // instead of the chat scroll. + OnInterfaceText = text => + d.Communication.AddText(text, RetailLogTextType.ClientLocal), + }; AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore = d.Settings.LayoutStore; RetailUiPersistenceBindings? persistence = layoutStore is null diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs index f681fd87..d5d6c2ba 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using AcDream.Core.Chat; namespace AcDream.UI.Abstractions.Panels.Chat; @@ -51,8 +52,23 @@ public static class ChatCommandRouter { if (!clientCommand.HasValidArguments) { - vm.ShowSystemMessage(clientCommand.InvalidArgumentsText - ?? $"Usage: {clientCommand.Usage}"); + // #363 / register row AP-183: retail's bad-args refusal is + // ALWAYS 0x1A (ClientLocal / SpewBox-only) — verified + // against five decompiled handlers (DoDie, DoChannelList/ + // On/Off, DoAllegiance, DoHouseAvailableList). A Definition + // with its own InvalidArgumentsText is the handler's own + // bespoke refusal string, printed before it returns + // "handled" (1) so retail's generic fallback never fires + // for it. A Definition with none falls to that generic + // fallback: ClientCommunicationSystem::DoCommand + // @0x0057E46D calls HandleFailureEvent(0x26) when a + // registered handler returns 0 (bad args) — + // "That is not a valid command." (WeenieErrorMessages + // [0x026]) — never the acdream-invented "Usage: {Usage}" + // line this branch used to synthesize. + vm.ShowInterfaceText(clientCommand.InvalidArgumentsText + ?? WeenieErrorMessages.Resolve(0x026u, null).Text + ?? "That is not a valid command."); return SubmitOutcome.ClientHandled; } @@ -66,10 +82,14 @@ public static class ChatCommandRouter // Command-shaped but no letter verb ("/", "//shrug", "@ x"): // refuse locally rather than putting junk on the wire or in speech. + // #363/#367: this is one of retail's DoHelp-family "Unknown + // command" fallbacks (0x1A ClientLocal, SpewBox-only) — routed + // through the interface-text seam now that one exists, instead of + // the chat scroll. if (trimmed[0] is '/' or '@' && (trimmed.Length == 1 || !char.IsLetter(trimmed[1]))) { - vm.ShowSystemMessage( + vm.ShowInterfaceText( $"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands."); return SubmitOutcome.UnknownCommand; } @@ -97,6 +117,23 @@ public static class ChatCommandRouter return SubmitOutcome.Sent; } + // #363 / register row AP-183: retail's registered legacy-channel + // verbs (DoStupidChannelHack @0x0057B144) and /reply + // (DoReply @0x00577910) refuse locally at 0x1A instead of silently + // dropping the line the way ChatInputParser.Parse's pure "return + // null" shape does for these cases. + if (ChatInputParser.IsBareRegisteredChannelVerb(trimmed)) + { + vm.ShowInterfaceText("You must specify the text you wish to say!"); + return SubmitOutcome.ClientHandled; + } + + if (ChatInputParser.IsReplyMissingLastTeller(trimmed, vm.LastIncomingTellSender)) + { + vm.ShowInterfaceText("Someone must @tell you first!"); + return SubmitOutcome.ClientHandled; + } + var parsed = ChatInputParser.Parse( trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); if (parsed is { } chat) @@ -247,12 +284,10 @@ public static class ChatCommandRouter return; } - // Retail types this 0x1A (ClientLocal) -> SpewBox-only; ChatVM has - // no SpewBox routing capability yet, so this still renders via the - // chat scroll — a pre-existing gap, not new this round. See the - // class remarks on RetailCommandHelpTable.UnknownCommand and - // ISSUES.md #367. - vm.ShowSystemMessage(RetailCommandHelpTable.UnknownCommand); + // Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367: + // now routed through ChatVM.ShowInterfaceText instead of the chat + // scroll — see the class remarks on RetailCommandHelpTable.UnknownCommand. + vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand); } private static bool EqAny(string value, params string[] options) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index f9b4defb..ae9a900b 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -124,6 +124,64 @@ public static class ChatInputParser ("/o", ChatChannelKind.Olthoi), }; + /// + /// The six retail channel groups whose registered-verb handler is + /// ClientCommunicationSystem::DoStupidChannelHack @0x0057B144 + /// (command-registry doc §2.3) — the legacy 0x0147 channels. The + /// Turbine-only channels (General/Trade/Lfg/Roleplay/Society/Olthoi) + /// are never this handler's clients. + /// (retail's @a) still belongs here even though acdream always + /// sends it over Turbine once connected ('s + /// own doc comment) — DoStupidChannelHack dispatches on the REGISTERED + /// VERB, before Turbine send-time routing decides where the text goes; + /// retail's "a"/"ab" are one registered verb group. + /// + private static readonly HashSet LegacyChannelHackKinds = + [ + ChatChannelKind.Fellowship, + ChatChannelKind.Allegiance, + ChatChannelKind.AllegianceBroadcast, + ChatChannelKind.Vassals, + ChatChannelKind.Patron, + ChatChannelKind.Monarch, + ChatChannelKind.CoVassals, + ]; + + /// + /// True when is one of the + /// verbs with NO message — retail's + /// DoStupidChannelHack "You must specify the text you wish to + /// say!" refusal (0x1A ClientLocal, + /// acclient_2013_pseudo_c.txt:1030730, data_7da9b0). This parser stays + /// pure (no side effects — just returns + /// for this shape); + /// owns the actual refusal text and routes it to the SpewBox. + /// + public static bool IsBareRegisteredChannelVerb(string trimmed) + { + foreach (var (verb, channel) in ChannelVerbs) + { + if (LegacyChannelHackKinds.Contains(channel) && IsBareVerb(trimmed, [verb])) + return true; + } + + return false; + } + + /// + /// True when is /reply//r/ + /// /rp WITH a message but no prior incoming Tell — retail's + /// ClientCommunicationSystem::DoReply @0x00577910 "Someone must + /// @tell you first!" refusal (0x1A ClientLocal, + /// acclient_2013_pseudo_c.txt:387538, data_7da974), the + /// gmCCommunicationSystem::GetLastTeller() == 0 branch. Bare + /// /r with no message at all is a DIFFERENT retail branch (its + /// own copy of the "you must specify text" string) and is deliberately + /// out of scope here — not named by register row AP-183 / issue #363. + /// + public static bool IsReplyMissingLastTeller(string trimmed, string? lastTellSender) => + string.IsNullOrEmpty(lastTellSender) && TryParseMessageOnly(trimmed, ReplyAliases, out _); + /// /// Parse into a /// triple, or null if the input is empty / whitespace / diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 7c81aab4..586847f2 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -73,6 +73,21 @@ public sealed class ChatVM : IDisposable /// public Func? PositionProvider { get; init; } + /// + /// Optional hook routing retail-0x1A () interface text — command + /// refusals and bad-argument usage lines — to the SpewBox instead of + /// the chat transcript. AcDream.UI.Abstractions must stay + /// Runtime-independent (Code Structure Rules), so it cannot call + /// RuntimeCommunicationState.AddText directly; the App-layer + /// composition host wires this the same way it wires + /// /. Closes + /// ISSUES.md #367 / register row AP-186 — + /// no longer has to render every 0x1A refusal through the chat + /// scroll. + /// + public Action? OnInterfaceText { get; init; } + /// Monotonic revision of the underlying transcript content. public long Revision => _log.Revision; @@ -139,6 +154,33 @@ public sealed class ChatVM : IDisposable /// public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0x00u); + /// + /// Route a retail-0x1A () + /// command refusal / usage line to the SpewBox — retail's + /// ClientSystem::AddTextToScroll(text, 0x1A, 1, windowId) @0x00563C50 + /// destination for this text type is the SpewBox exclusively, never a + /// chat window (docs/research/2026-08-09-chat-retail-interface-text.md + /// §2.1/§2.2). + /// + /// + /// Prefers when the App-layer host wired + /// it (the production graphical client). When unwired — headless, the + /// automation probe runner, or a test fixture that only exercises the + /// pure UI.Abstractions layer — the text still needs to reach the + /// player somewhere, so it falls back to the ordinary chat transcript + /// tagged with the real + /// color rather than being silently dropped. That fallback lands in + /// the wrong PANEL (chat instead of SpewBox) but keeps the right TYPE + /// and never loses the line — the safe default issue #363 requires. + /// + public void ShowInterfaceText(string text) + { + if (OnInterfaceText is { } hook) + hook(text); + else + _log.OnSystemMessage(text, chatType: (uint)RetailLogTextType.ClientLocal); + } + /// /// Drain the chat log. Used by the /clear client-side command. /// diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs index aa76c0ed..c72247f8 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs @@ -346,11 +346,18 @@ public static class RetailClientCommandCatalog // ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510. Exact // retail help: acclient_2013_pseudo_c.txt:1031049 (data_7dd9d0). + // #363 / register row AP-183: the bad-args refusal is retail's own + // specific string too, verified at acclient_2013_pseudo_c.txt:381481 + // (AddTextToScroll(..., 0x1a, ...)), full text at + // acclient_2013_pseudo_c.txt:1029383 (data_7d0a98) — NOT the + // acdream-synthesized "Usage: /hslist " line this + // Definition used to fall back to. private static readonly Definition HouseAvailableList = new( ClientCommandId.HouseAvailableList, Usage: "/hslist ", HelpText: "@hslist - Lists the number and, if appropriate, positions of houses currently available for purchase. Types include: Apartment, Cottage, Villa, Mansion", - ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim())); + ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim()), + InvalidArgumentsText: "Please see @help hslist for more information on how to use this command"); // ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help // string was extracted for the bare form; the verb is admin/advocate/ diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs index 949a272a..956690d2 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs @@ -91,14 +91,17 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// , typed 0x1A (ClientLocal) — /// retail routes that type to the SpewBox exclusively, never the chat /// window (docs/research/2026-08-09-chat-retail-interface-text.md -/// §2.1/§2.2). ChatCommandRouter operates on ChatVM -/// (AcDream.UI.Abstractions), which has no SpewBox routing -/// capability — wiring that would mean threading a -/// RuntimeCommunicationState-shaped dependency down into a layer -/// that must stay presentation/Runtime-independent, out of this round's -/// scope. The unknown-verb fallback therefore still renders via the chat -/// scroll, a pre-existing (not newly introduced) gap now tracked at -/// ISSUES.md #367 instead of silently continuing unregistered. +/// §2.1/§2.2). +/// +/// +/// +/// Issue #363 (2026-08-10): ChatCommandRouter now routes this +/// fallback (and every other 0x1A command-refusal call site) through +/// ChatVM.ShowInterfaceText — an optional hook the App-layer host +/// wires to RuntimeCommunicationState.AddText, the same SpewBox +/// chokepoint every other producer of interface text uses. UI.Abstractions +/// still never references Runtime directly (Code Structure Rules); the hook +/// is the seam. Closes ISSUES.md #367 and retires register row AP-186. /// /// public static class RetailCommandHelpTable @@ -147,9 +150,9 @@ public static class RetailCommandHelpTable // acclient_2013_pseudo_c.txt:395052 (u"Unknown command", UTF-16LE) -- // DoHelp's fallback when the verb hash lookup fails, or resolves to an // entry with no registered help callback. Retail types this 0x1A - // (ClientLocal) -- SpewBox-only; see the class remarks' routing note - // and ISSUES.md #367 for why ChatCommandRouter still shows it in the - // chat scroll. + // (ClientLocal) -- SpewBox-only; see the class remarks' routing note -- + // ChatCommandRouter now routes it through ChatVM.ShowInterfaceText + // (issue #363), closing #367. public const string UnknownCommand = "Unknown command"; // @mr/@pr are registered with a NULL function pointer in the 2013 diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs index 087437a5..d7d66450 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -22,6 +22,23 @@ public class ChatCommandRouterTests return (vm, log, new CaptureBus()); } + /// + /// #363: a fixture with wired to a + /// capturing list, so tests can assert a 0x1A refusal reached the + /// SpewBox seam directly instead of only observing the chat-log + /// null-fallback. + /// + private static (ChatVM vm, ChatLog log, CaptureBus bus, List interfaceTexts) FixtureWithInterfaceSink() + { + var log = new ChatLog(); + var interfaceTexts = new List(); + var vm = new ChatVM(log, displayLimit: 50) + { + OnInterfaceText = interfaceTexts.Add, + }; + return (vm, log, new CaptureBus(), interfaceTexts); + } + [Fact] public void PlainText_PublishesOnDefaultChannel() { @@ -76,15 +93,37 @@ public class ChatCommandRouterTests } [Fact] - public void LifestoneWithArguments_ShowsUsageAndPublishesNothing() + public void LifestoneWithArguments_ShowsRetailBadArgsRefusal_ViaInterfaceTextSeam() { - var (vm, log, bus) = Fixture(); + // #363 / register row AP-183: a Definition with no bespoke + // InvalidArgumentsText falls to retail's own generic bad-args + // fallback (HandleFailureEvent(0x26), "That is not a valid + // command.") at 0x1A ClientLocal — never the acdream-invented + // "Usage: /lifestone" line this used to synthesize. + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); var outcome = ChatCommandRouter.Submit("/ls now", vm, bus, ChatChannelKind.Say); Assert.Equal(SubmitOutcome.ClientHandled, outcome); Assert.Empty(bus.Published); - Assert.Contains(log.Snapshot(), entry => entry.Text == "Usage: /lifestone"); + Assert.Equal("That is not a valid command.", Assert.Single(interfaceTexts)); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void LifestoneWithArguments_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal() + { + // Headless / no-window null-fallback safety: when the App-layer + // host hasn't wired ChatVM.OnInterfaceText, the refusal must still + // reach the player instead of being silently dropped. + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit("/ls now", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + var entry = Assert.Single(log.Snapshot()); + Assert.Equal("That is not a valid command.", entry.Text); + Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType); } [Fact] @@ -201,14 +240,19 @@ public class ChatCommandRouterTests [InlineData("/all boot Bob")] public void AllegianceUnrecognizedSubcommand_ShowsRetailRefusal_NeverBroadcastsOrSends(string input) { - var (vm, log, bus) = Fixture(); + // #363 / register row AP-183: retail types this refusal 0x1A + // (ClientLocal / SpewBox-only) — routed through the interface-text + // seam, not the chat log. + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say); Assert.Equal(SubmitOutcome.ClientHandled, outcome); Assert.Empty(bus.Published); // no SendRawChannelCmd, no SendServerCommandCmd - Assert.Contains(log.Snapshot(), entry => - entry.Text == "Please see @help Allegiance for more information on how to use this command."); + Assert.Equal( + "Please see @help Allegiance for more information on how to use this command.", + Assert.Single(interfaceTexts)); + Assert.Empty(log.Snapshot()); } [Fact] @@ -263,23 +307,36 @@ public class ChatCommandRouterTests } [Fact] - public void HelpVerb_UnknownVerb_ShowsRetailUnknownCommandText() + public void HelpVerb_UnknownVerb_ShowsRetailUnknownCommandText_ViaInterfaceTextSeam() { // Campaign CH user-gate round 3 (2026-08-10): retail's own DoHelp // fallback text is "Unknown command" (swept verbatim), not an // acdream-invented "No help available" message. Retail types this - // 0x1A (ClientLocal / SpewBox-only); ChatVM has no SpewBox routing - // capability yet (ISSUES.md #367), so it still lands in the chat - // scroll here as ONE entry (no HelpPrefixNote wrapper — DoHelp's - // fallback bypasses the two-entry shape entirely). - var (vm, log, bus) = Fixture(); + // 0x1A (ClientLocal / SpewBox-only). Issue #363/#367: now routed + // through the interface-text seam as ONE entry (no HelpPrefixNote + // wrapper — DoHelp's fallback bypasses the two-entry shape + // entirely), not the chat scroll. + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say); Assert.Equal(SubmitOutcome.ClientHandled, outcome); Assert.Empty(bus.Published); + Assert.Equal(RetailCommandHelpTable.UnknownCommand, Assert.Single(interfaceTexts)); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void HelpVerb_UnknownVerb_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal() + { + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); var entry = Assert.Single(log.Snapshot()); Assert.Equal(RetailCommandHelpTable.UnknownCommand, entry.Text); + Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType); } [Fact] @@ -299,4 +356,169 @@ public class ChatCommandRouterTests Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text); Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text); } + + // ── Issue #363 / register row AP-183 (2026-08-10) ─────────────────── + // Retail's 0x1A (ClientLocal / SpewBox-only) command-refusal call + // sites now route through ChatVM.OnInterfaceText instead of the chat + // scroll. Each site below is pinned two ways: with the seam wired + // (reaches the SpewBox sink, log stays empty) and with it unwired + // (safe null-fallback into chat, tagged ClientLocal). + + [Theory] + [InlineData("/clist")] + [InlineData("/clist a b")] + [InlineData("/on")] + [InlineData("/off nonsense extra")] + public void ChannelListOnOff_BadArgumentShape_ShowsRetailRefusal_ViaInterfaceTextSeam(string input) + { + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); + + var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Empty(bus.Published); + Assert.Equal("Please specify the channel name.", Assert.Single(interfaceTexts)); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void HouseAvailableList_BadHouseType_ShowsRetailRefusal_ViaInterfaceTextSeam() + { + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); + + var outcome = ChatCommandRouter.Submit("/hslist nonsense", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Empty(bus.Published); + Assert.Equal( + "Please see @help hslist for more information on how to use this command", + Assert.Single(interfaceTexts)); + Assert.Empty(log.Snapshot()); + } + + [Theory] + [InlineData("/g")] + [InlineData("/f")] + [InlineData("/fellowship")] + [InlineData("/a")] + [InlineData("/ab")] + [InlineData("/m")] + [InlineData("/p")] + [InlineData("/v")] + [InlineData("/c")] + public void BareRegisteredChannelVerb_ShowsDoStupidChannelHackRefusal_ViaInterfaceTextSeam(string input) + { + // Retail ClientCommunicationSystem::DoStupidChannelHack + // @0x0057B144 — a registered legacy-channel verb with no message + // refuses locally instead of silently dropping the line. + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); + + var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Empty(bus.Published); + Assert.Equal("You must specify the text you wish to say!", Assert.Single(interfaceTexts)); + Assert.Empty(log.Snapshot()); + } + + [Theory] + [InlineData("/lfg")] + [InlineData("/trade")] + [InlineData("/general")] + [InlineData("/roleplay")] + [InlineData("/society")] + [InlineData("/olthoi")] + public void BareTurbineOnlyChannelVerb_NeverShowsDoStupidChannelHackRefusal(string input) + { + // The seven Turbine-only channels are never DoStupidChannelHack's + // clients (command-registry doc §2.3) — a bare verb here is + // silently dropped, matching the pre-#363 behavior for these + // specific channels (unchanged by this fix). + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); + + var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.Dropped, outcome); + Assert.Empty(bus.Published); + Assert.Empty(interfaceTexts); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void Reply_WithMessage_NoLastTeller_ShowsDoReplyRefusal_ViaInterfaceTextSeam() + { + // Retail ClientCommunicationSystem::DoReply @0x00577910 — + // gmCCommunicationSystem::GetLastTeller() == 0 branch. + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); + + var outcome = ChatCommandRouter.Submit("/r hi there", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Empty(bus.Published); + Assert.Equal("Someone must @tell you first!", Assert.Single(interfaceTexts)); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void Reply_WithMessage_NoLastTeller_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal() + { + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit("/reply hi there", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + var entry = Assert.Single(log.Snapshot()); + Assert.Equal("Someone must @tell you first!", entry.Text); + Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType); + } + + [Fact] + public void Reply_WithMessage_WithLastTeller_StillSendsNormally() + { + // Sanity: the new missing-last-teller predicate must not shadow + // the ordinary reply path once a Tell has arrived. + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); + log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042, logTextType: 0x03u); + + var outcome = ChatCommandRouter.Submit("/r hi there", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.Sent, outcome); + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal("Bestie", command.TargetName); + Assert.Empty(interfaceTexts); + } + + [Fact] + public void DegeneratePrefix_UnknownCommand_ShowsRefusal_ViaInterfaceTextSeam() + { + // "/" alone (no letter verb) — the pre-existing "Unknown command: + // {verb}." refusal, now also routed through the interface-text + // seam (issue #367). + var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink(); + + var outcome = ChatCommandRouter.Submit("/", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + Assert.Empty(bus.Published); + Assert.Contains("Unknown command:", Assert.Single(interfaceTexts)); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void EnduranceCommand_ValidArguments_NeverTouchesTheInterfaceTextSeam() + { + // AP-183 sanity check: DoEndurance/DoSpeaker/DoTitle are ALREADY + // correct at 0x00 (informational) — their actual output text is + // produced by ClientCommandController (App layer), not + // ChatCommandRouter, so this pins that a valid catalog dispatch + // never emits through the new 0x1A seam at all. + var (vm, _, bus, interfaceTexts) = FixtureWithInterfaceSink(); + + var outcome = ChatCommandRouter.Submit("/endurance", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.Endurance, command.Command); + Assert.Empty(interfaceTexts); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs index 220da0ab..e45e193c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs @@ -423,4 +423,95 @@ public sealed class ChatInputParserTests Assert.Null(parsed.Value.TargetName); Assert.Equal(text, parsed.Value.Text); } + + // ── Issue #363: IsBareRegisteredChannelVerb / IsReplyMissingLastTeller ── + + [Theory] + [InlineData("/g")] + [InlineData("/f")] + [InlineData("/fellow")] + [InlineData("/fellows")] + [InlineData("/fellowship")] + [InlineData("/group")] + [InlineData("/party")] + [InlineData("/a")] + [InlineData("/guild")] + [InlineData("/gu")] + [InlineData("/ab")] + [InlineData("/m")] + [InlineData("/monarch")] + [InlineData("/p")] + [InlineData("/patron")] + [InlineData("/v")] + [InlineData("/vassal")] + [InlineData("/vassals")] + [InlineData("/c")] + [InlineData("/covassal")] + [InlineData("/covassals")] + [InlineData("/co-vassals")] + public void IsBareRegisteredChannelVerb_TrueForEveryLegacyChannelAlias(string verb) + { + Assert.True(ChatInputParser.IsBareRegisteredChannelVerb(verb)); + } + + [Theory] + [InlineData("/general")] + [InlineData("/cg")] + [InlineData("/lfg")] + [InlineData("/clfg")] + [InlineData("/trade")] + [InlineData("/ct")] + [InlineData("/roleplay")] + [InlineData("/crp")] + [InlineData("/society")] + [InlineData("/soc")] + [InlineData("/olthoi")] + [InlineData("/o")] + public void IsBareRegisteredChannelVerb_FalseForTurbineOnlyChannels(string verb) + { + // The seven Turbine-only channels are never DoStupidChannelHack's + // clients (command-registry doc §2.3). + Assert.False(ChatInputParser.IsBareRegisteredChannelVerb(verb)); + } + + [Theory] + [InlineData("/g hi gang")] + [InlineData("/a hey")] + [InlineData("hello")] + [InlineData("/say hi")] + [InlineData("/r hi")] + public void IsBareRegisteredChannelVerb_FalseWithMessageOrNotAChannelVerb(string input) + { + Assert.False(ChatInputParser.IsBareRegisteredChannelVerb(input)); + } + + [Theory] + [InlineData("/r hi there")] + [InlineData("/reply hi there")] + [InlineData("/rp hi there")] + public void IsReplyMissingLastTeller_TrueWithMessageAndNoLastTeller(string input) + { + Assert.True(ChatInputParser.IsReplyMissingLastTeller(input, lastTellSender: null)); + Assert.True(ChatInputParser.IsReplyMissingLastTeller(input, lastTellSender: "")); + } + + [Fact] + public void IsReplyMissingLastTeller_FalseWhenLastTellerPresent() + { + Assert.False(ChatInputParser.IsReplyMissingLastTeller("/r hi there", lastTellSender: "Bestie")); + } + + [Theory] + [InlineData("/r")] + [InlineData("/r ")] + [InlineData("/g hi gang")] + [InlineData("hello")] + public void IsReplyMissingLastTeller_FalseWithoutAMessageOrNotAReplyVerb(string input) + { + // Bare "/r" (no message at all) is a DIFFERENT retail branch + // (DoReply's own copy of the "you must specify text" string) and + // is deliberately out of scope for this predicate — see its doc + // comment. + Assert.False(ChatInputParser.IsReplyMissingLastTeller(input, lastTellSender: null)); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs index a1b516f9..fd172aa8 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs @@ -138,4 +138,33 @@ public sealed class ChatVMRetellAndProvidersTests Assert.Contains("provider unavailable", log.Snapshot()[0].Text); } + + // ── Issue #363: ShowInterfaceText / OnInterfaceText seam ──────────── + + [Fact] + public void ShowInterfaceText_WithHook_InvokesHook_NeverTouchesTheChatLog() + { + var log = new ChatLog(); + var received = new List(); + var vm = new ChatVM(log) { OnInterfaceText = received.Add }; + + vm.ShowInterfaceText("Someone must @tell you first!"); + + Assert.Equal("Someone must @tell you first!", Assert.Single(received)); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void ShowInterfaceText_NoHook_FallsBackToChatLog_TaggedClientLocal() + { + var log = new ChatLog(); + var vm = new ChatVM(log); + + vm.ShowInterfaceText("Someone must @tell you first!"); + + var entry = Assert.Single(log.Snapshot()); + Assert.Equal("Someone must @tell you first!", entry.Text); + Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType); + Assert.Equal(ChatKind.System, entry.Kind); + } }