From 724ef2d389e4e3d1cec76e9e1911fb604a758bdf Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 21:59:35 +0200 Subject: [PATCH] =?UTF-8?q?fix(chat):=20CH4=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20allegiance=20ownership=20guard,=20house-abandon=20confirmati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 1: an unrecognized "@allegiance " subcommand escaped TryMatchAllegiance (which only claimed "info"/"hometown") and fell through the unregistered-tag channel fallback, broadcasting the raw subcommand text to the Allegiance chat channel (0x02000000). Retail's own DoAllegiance never reaches DoChannelCommand for an unrecognized subcommand — it claims the whole verb and prints its own client-local refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership guard in TryDispatchChannelFallback as defense in depth. Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation. Retail runs a real two-stage dialog before Event_AbandonHouse(); ported both verbatim strings and chained two ShowConfirmation calls. Should-fixes: a bare unregistered tag with no text now passes through silently instead of showing a refusal that belongs to a different retail function; @join/@leave update RuntimeCharacterOptionsState locally (new SetOptionBit) before the wire push so the Turbine membership gate stops refusing a just-joined room; @permit accepts multi-word names; @clist/ @on/@off validate shape only and raise WeenieError 0x422 for an unknown tag; @mr/@pr help text is now the verbatim retail strings; corrected issue #360, register row TS-68, the campaign doc's B.7 note, and a stale RetailChannelTagTable comment; filed issue #363 + register row AP-183 for the deferred error-typing debt. Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's olthoi false-positive, added /g and /rp binding-level conformance pins, made @index ignore extra arguments, and noted the six removed invented verbs in ISSUES.md. Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's 12,190/4/0 — net +26 tests, no removals. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 63 +++++++-- .../retail-divergence-register.md | 7 +- docs/plans/2026-08-09-chat-parity-campaign.md | 99 ++++++++++++- .../Net/LiveSessionRuntimeFactory.cs | 13 +- src/AcDream.App/UI/ClientCommandController.cs | 66 +++++++-- .../Gameplay/RuntimeCharacterState.cs | 52 +++++++ .../ClientCommandId.cs | 19 ++- .../Panels/Chat/ChatCommandRouter.cs | 37 ++++- .../Panels/Chat/RetailChannelTagTable.cs | 61 +++++++- .../Panels/Chat/RetailClientCommandCatalog.cs | 122 ++++++++++++---- .../Panels/Chat/RetailCommandHelpTable.cs | 45 ++++-- .../UI/ClientCommandControllerTests.cs | 131 +++++++++++++++++- .../Gameplay/RuntimeCharacterStateTests.cs | 55 ++++++++ .../TurbineChatMembershipGateTests.cs | 45 ++++++ .../Panels/Chat/ChatCommandRouterTests.cs | 52 ++++++- .../Chat/RetailClientCommandCatalogTests.cs | 40 +++++- .../RetailCommandRegistryConformanceTests.cs | 31 +++++ 17 files changed, 853 insertions(+), 85 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b96eceaa..cf4eabf9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,7 +26,8 @@ What does NOT go here: ## #360 — @allegiance/@house management dispatchers only port their simple subcommands -**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. Retail's +**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4; corrected +2026-08-09 at the CH4 REJECT-review (Blocker 1). Retail's `@allegiance`/`@all` and `@house`/`@hou` are 12- and 15-subcommand local command dispatchers (`ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0` / `DoHouse @ 0x00580860`). CH4 ports the subset with simple @@ -40,13 +41,23 @@ boot_all/remove_all/guest/available/hooks/on/off) plus the standalone target-name/guid resolution, confirmation dialogs, or multi-field payloads this session did not attempt to build without byte-level verification against both the retail decomp and ACE's reader — see the doc's own -framing ("largest single item; deserves its own slice"). Today these -subcommands correctly fall through to ACE as server-passthrough text -(`RetailClientCommandCatalog.TryMatchHouse`/`TryMatchAllegiance`) rather -than being swallowed locally, which was the Tier-1 correctness fix this -slice DID land — but they don't yet execute. Register row: TS-68. -Registry doc: `docs/research/2026-08-09-chat-retail-command-registry.md` -§2.5/§2.5b. +framing ("largest single item; deserves its own slice"). For `@house`, +these subcommands correctly fall through to ACE as server-passthrough +text (`RetailClientCommandCatalog.TryMatchHouse`) rather than being +swallowed locally, which was the Tier-1 correctness fix CH4 landed — but +they don't yet execute. **For `@allegiance`/`@all`, the original filing's +"falls through to ACE" claim was wrong**: retail's own `DoAllegiance` +never reaches server passthrough for an unrecognized subcommand — it +prints "Please see @help Allegiance for more information on how to use +this command." locally and stays entirely client-side +(`ClientCommunicationSystem::DoAllegiance`, label at 0x0057DA4B). The +CH4 REJECT-review found acdream had instead been broadcasting the +unmatched subcommand text to the Allegiance chat channel — a real +chat-visible bug, now fixed (`TryMatchAllegiance` claims ownership +unconditionally and shows retail's own refusal text). The 22 subcommands +themselves still don't execute; only the fallback behavior changed. +Register row: TS-68. Registry doc: +`docs/research/2026-08-09-chat-retail-command-registry.md` §2.5/§2.5b. ## #361 — @day / @log / @render pure-local commands recognized in help only, not executed @@ -73,6 +84,42 @@ registered in `GameEventType` with no `GameEventWiring` handler — ACE's reply is silently dropped. The request itself is correct and verifiable on the wire; only the response rendering is missing. Register row: TS-70. +## #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. + +## Note — six invented chat verbs removed for registry parity (2026-08-09) + +Campaign CH slice CH4 deleted `/gen`, `/cv`, `/lookingforgroup`, `/tr`, +`/role`, `/h` from `ChatInputParser`/`ChatCommandRouter` — none are +retail-registered verbs; the retail command registry doc +(`docs/research/2026-08-09-chat-retail-command-registry.md` §4, +"candidates for removal") confirmed none exist in the real client. Not a +bug, no issue number — recorded here so they aren't reintroduced later as +"missing aliases." `RetailCommandRegistryConformanceTests`'s two +reverse-direction ownership tests now fail the build if any of the six +(or any other invented verb) resurfaces. + ## #356 — Alt-tab during login crashed the client: focus loss faulted on an unpublished movement controller **Status:** CLOSED 2026-08-08 — `972c7ab3`. Window focus loss runs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e87324ff..4d3bc40e 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) — 129 active rows (AP-182 filed 2026-08-09 at Campaign CH slice CH4 — `@title` stores a value with no title-bar chrome consumer yet; 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) — 130 active rows (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) 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 @@ -337,7 +337,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-182 | **Filed 2026-08-09 (Campaign CH slice CH4).** `@title ` stores the requested popup-chat-window title locally but has no consumer — no title-bar chrome exists on acdream's retained chat window yet, matching 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). `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 | `ClientCommunicationSystem::DoTitle @ 0x0057A640` | +| 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` | ## 4. Temporary stopgap (TS) — 42 active rows (TS-68/TS-69/TS-70 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render), and the four unparsed inbound GameEvent responses for CH4's new outbound requests; TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) @@ -388,7 +389,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-64 | **Retail's sound-preference surface is only partly present.** Retail registers eight `[Sound]` keys in `SoundManager::InitPrefs` @ `0x005503F0`; two are unimplemented in acdream. (a) `s_bPlaySoundOnlyWhenActive` (default **1**) is checked against `Device::m_bIsActiveApp` in every entry point and in both `PlaySoundInternal` overloads, so an unfocused retail client is SILENT; acdream keeps playing when the window loses focus. (b) `s_SoundFeatures == 1` forces pan to dead centre; acdream's `RetailSoundMixer.Mix`/`GetPan` take a `panningEnabled` flag with conformance coverage, but no preference is wired behind it, so panning can never be turned off. The three enable bools (`Sound Disabled`, `Ambient Sound Disabled`, `Interface Sound Disabled`) also have no acdream counterpart — note retail's on-disk polarity is inverted relative to its backing variables, so a future reader must not assume the sense. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (no focus gate); `src/AcDream.Core/Audio/RetailSoundMixer.cs` (`panningEnabled`, unwired) | Slice A2 kept its blast radius on the mixing model: window-focus state and a preference surface are host plumbing rather than mixing math, and the mixer parameter exists so wiring them later needs no math change. | Alt-tabbed acdream keeps making noise where retail goes quiet; users cannot disable panning or the individual sound classes. | `SoundManager::InitPrefs @ 0x005503F0`; `SoundManager::PlaySoundInternal @ 0x0054FEC0` and `@ 0x00550170`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §1 | | TS-65 | **Volume-squared quirk applied on the ambient path only.** Retail multiplies its volume knob twice on several paths: `PlaySoundA(DataID, CPhysicsObj*)` passes `effect_sound_volume` as the `vol` argument and `GetAttenuation` then multiplies by `effect_sound_volume` again, and both `PlayAmbientSound*` entry points pre-multiply by `ambient_sound_volume` before that same second multiply — so those sliders are effectively squared. acdream's `RetailSoundMixer.TryGetAttenuation` applies the knob exactly once (which is what `GetAttenuation` itself does) and the animation-hook path does not pre-multiply. Slice A5 squares the ambient path, where two independent lanes byte-confirmed the double application. | `src/AcDream.Core/Audio/RetailSoundMixer.cs` (`TryGetAttenuation` remarks); `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`Play3DWave`) | Which `PlaySoundA` overload the animation-hook path reaches was not pinned by the lane-1 decode, and inventing a squaring on an unconfirmed overload would change every hook sound's loudness curve on a guess. Single-multiply is the conservative, decoded-function-exact choice; the open question is cheap to settle with a cdb breakpoint on the two overloads. | At a non-unity effect slider, hook sounds are louder than retail (slider 0.5 gives −6 dB where retail gives −12). At the default slider of 1.0 the two are identical, so this is inert until the user moves the slider. | `SoundManager::PlaySoundA @ 0x00550AF0`/`@ 0x00550B70`/`@ 0x005507A0`; `SoundManager::GetAttenuation @ 0x00550020`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §3 D12 | | ~~TS-66~~ | **RETIRED 2026-08-08 (Campaign A listening-gate fix; user-reported).** `seen_outside` interiors now keep the OUTDOOR ambient set: the listener source resolves the per-cell `CEnvCell.seen_outside` bit through the physics cache's `CellPhysics` record (the same #107 field `AdjustPosition` reads) and converts the ENVCELL-local origin through the cell's `WorldTransform` into landblock coordinates before the 3×3 walk centres on it — an outdoor Position's origin is already landblock-local, an envcell's is not, and skipping the conversion would centre the walk on a wrong point by up to a landblock. A cell record not yet resident resolves to silence for that rebuild rather than a wrong walk. Sealed interiors (dungeons) remain silent, which is retail-correct. | retired | — | — | `Ambient` gate per `docs/research/2026-08-08-audio-retail-ambient-authoring.md` §6/§8; `CEnvCell::add_ambient_sounds` (folded `ret`); user listening gate 2026-08-08 ("in retail I get both outside ambient and the ambient from indoors") | -| TS-68 | **Filed 2026-08-09 (Campaign CH slice CH4).** `@allegiance`/`@all` and `@house`/`@hou` are real retail management-command dispatchers with 12 and 15 subcommands respectively (registry doc §2.5/§2.5b). acdream ports only the subset with simple parameterless/single-field wire shapes (allegiance `info`/`hometown`/`ho`; house `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`) — the remaining ~22 subcommands (boot, ban, officer, title, name, lock, chat, broadcast, motd; house open/close/storage/remove/boot_all/remove_all/guest/available/hooks/on/off) fall through to ACE server-passthrough (which replies "Unknown command") instead of executing locally. The standalone `@motd` verb is the same gap. `RetailClientCommandCatalog.TryMatchHouse`/`TryMatchAllegiance` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`) | Retail would execute these locally (with its own usage/confirmation text); acdream instead sends literal text to ACE, which does not implement them as chat commands either — no functional loss on a real server (both are "does nothing"), but a user typing e.g. `@house open` gets ACE's generic "Unknown command" instead of retail's real behavior | `ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0`; `DoHouse @ 0x00580860`; ACE `GameActionType` opcodes for each subcommand (all exist server-side) | +| TS-68 | **Filed 2026-08-09 (Campaign CH slice CH4); corrected 2026-08-09 at the CH4 REJECT-review, Blocker 1.** `@allegiance`/`@all` and `@house`/`@hou` are real retail management-command dispatchers with 12 and 15 subcommands respectively (registry doc §2.5/§2.5b). acdream ports only the subset with simple parameterless/single-field wire shapes (allegiance `info`/`hometown`/`ho`; house `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`). For `@house`, every other subcommand (open, close, storage, remove, boot, boot_all, remove_all, guest, available, hooks, on, off) still falls through to ACE server-passthrough (which replies "Unknown command") — unchanged from the original filing. **The original filing was WRONG for `@allegiance`/`@all`: retail's own `DoAllegiance` never reaches DoChannelCommand/server-passthrough for an unrecognized subcommand** — it prints "Please see @help Allegiance for more information on how to use this command." locally (`label_57da4b`, 0x0057DA4B) and stays entirely client-side. acdream now matches this: every allegiance subcommand beyond `info`/`hometown`/`ho` (boot, ban, officer, title, name, lock, chat, broadcast, motd) shows the same retail refusal client-side instead of reaching ACE — closing a real bug where the unmatched subcommand text was instead broadcast to the Allegiance chat channel (0x02000000). The standalone `@motd` verb (reached directly, not via `@allegiance motd`) remains a separate, still-open gap. `RetailClientCommandCatalog.TryMatchHouse`/`TryMatchAllegiance` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`) | Retail would execute these locally (with its own usage/confirmation/refusal text). House's unported subcommands still reach ACE, which does not implement them as chat commands either — no functional loss on a real server, but a user typing e.g. `@house open` gets ACE's generic "Unknown command" instead of retail's real behavior. Allegiance's unported subcommands now correctly stay local with retail's own refusal text instead of reaching ACE at all — matching retail, not merely harmless. | `ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0`; `DoHouse @ 0x00580860`; ACE `GameActionType` opcodes for each subcommand (all exist server-side) | | TS-69 | **Filed 2026-08-09 (Campaign CH slice CH4).** `@day`, `@log`, and `@render` are registered retail verbs acdream recognizes only in the `/help ` lookup table, not as executable client commands. `@day` needs a sky/time-of-day override hook the renderer doesn't expose; `@log` needs a safely-lifecycled chat-to-file writer (deferred to avoid an unaudited file-handle leak across reconnects); `@render` has no acdream equivalent to retail's `SmartBox::HandleRenderOption` render-option surface. All three fall through to server passthrough. `RetailCommandHelpTable` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs`) | A user typing `@day`/`@log`/`@render` gets ACE's "Unknown command" instead of retail's local toggle/file-copy/render-option behavior — cosmetic/QoL only, no gameplay impact | `ClientCommunicationSystem::DoDay @ 0x005706F0`; `DoSetOutput @ 0x0057E4F0`; `DoRenderOption @ 0x0057E120` | | TS-70 | **Filed 2026-08-09 (Campaign CH slice CH4).** The new `@index`/`@clist`/`@on`/`@off`/`@hslist`/`@allegiance info` outbound requests (`ClientCommandRequests.BuildIndexChannels`/`BuildListChannel`/`BuildOnChannel`/`BuildOffChannel`/`BuildListAvailableHouses`/`BuildAllegianceInfoRequest`) send the byte-correct retail wire request, but the corresponding inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`) are registered in `GameEventType` but have no `GameEventWiring` handler — the server's reply is silently dropped rather than rendered. `src/AcDream.Core.Net/GameEventWiring.cs` | The request reaches ACE correctly (verifiable on the wire / server-side log) but the client shows nothing in response — looks like the command silently failed | ACE `GameEventChannelIndex`/`GameEventChannelList`/`GameEventHouseListAvailable`/`GameEventAllegianceInfoResponse` (`references/ACE/Source/ACE.Server/Network/GameEvent/Events/`) | | TS-67 | **Ambient contributions are computed in-plane.** Retail's `CLandBlock::add_ambient_sounds` @ `0x530310` positions each contributing land cell at its own SW terrain VERTEX, including that vertex's height, and `Ambient::CalcWeight` deliberately includes Z in its distance (where `CalcDir` deliberately excludes it — the two differ on purpose). acdream's gatherer supplies Z = 0 for the offset, so a cell's weight ignores the height difference between the listener and the terrain under that cell. | `src/AcDream.Core/Audio/AmbientSoundGatherer.cs` (`ContributeLandblock`) | Sampling the height needs the landblock's height table threaded into the walk alongside the terrain words; the walk already runs only on a 24 m crossing so the cost is not the obstacle, the extra plumbing at slice end was. The error is bounded by terrain relief inside 120 m and affects the crossfade weight only, never the direction. | On steep ground an ambient reads slightly louder than retail, because the true 3-D distance is longer than the planar one. | `CLandBlock::add_ambient_sounds @ 0x530310`; `Ambient::CalcWeight @ 0x550DD0` | diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 33d9d014..948ba981 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -114,7 +114,7 @@ implementer per slice against a pinned contract (per | CH1 colors | `172c6f9a` | 11,835 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `34d8a3c0` | pending | | CH2 interface text | `77c8296e`, reworked `e0e78883` | 11,916 passed / 4 skipped / 0 failed | REJECT → reworked `e0e78883` → re-review APPROVE-WITH-FIXES → nits `233c30d1` | pending | | CH3 side channels | `614a1e05` | 11,964 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `e07fba57` | pending (connected gate — see handoff below) | -| CH4 commands | see CH4 closeout below | 12,026 passed / 4 skipped / 0 failed | pending | pending | +| CH4 commands | `090825e7` | 12,190 passed / 4 skipped / 0 failed | REJECT; fixed `` | pending | | CH5 closeout | — | — | — | — | ### CH4 closeout (2026-08-09) @@ -152,6 +152,19 @@ Full parser-semantics + catalog-breadth pass against 7. `/allegiance`/`/all` are now `RetailClientCommandCatalog`'s allegiance MANAGEMENT command (a new `TryMatchAllegiance` dispatcher), not a channel verb. The channel-send verbs stay `a`/`ab`/`guild`/`gu`. + **Corrected 2026-08-09 at the CH4 REJECT-review (Blocker 1): the + original implementation above only claimed ownership for the 2 ported + subcommands (`info`/`hometown`/`ho`) and let every OTHER subcommand + fall through the unregistered-tag channel-fallback path, which + broadcast the raw subcommand text to the Allegiance chat channel + (`0x02000000`) — a real chat-visible bug (`@allegiance boot Bob` sent + "boot Bob" to allegiance chat). Retail's own `DoAllegiance` claims the + ENTIRE verb unconditionally: an unrecognized subcommand prints "Please + see @help Allegiance for more information on how to use this command." + locally and never reaches `DoChannelCommand` or the server. + `TryMatchAllegiance` now matches this exactly — it always returns + ownership for `allegiance`/`all`, showing retail's refusal text for + any subcommand beyond the 2 ported ones.** 8. `/house`/`/hou` no longer swallows unrecognized subcommands with a local usage error — `TryMatchHouse` returns no match for anything beyond `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`, @@ -196,14 +209,92 @@ KnownVerbs` or `ChatInputParser.KnownVerbs` may exist outside this registry — a future invented alias fails the build immediately. Final tally: **138 Implemented / 5 ServerPassthrough / 9 HelpOnly = 152.** -Suite: 12,026 passed / 4 skipped / 0 failed (Release), up from CH3's -11,964/4/0 — net +62 tests (157 new conformance-family cases plus net -test churn from updated existing coverage). One pre-existing, +Suite: 12,190 passed / 4 skipped / 0 failed (Release), up from CH3's +11,964/4/0 — net +226 tests. (Corrected 2026-08-09 at the CH4 +REJECT-review, item 8: this paragraph originally read "12,026 ... net ++62 (157 new conformance-family cases plus net test churn)"; the actual +measured CH4-landing count was 12,190, matching CLAUDE.md's Current +Suite baseline — only the raw counts are corrected here, the +62/157 +breakdown was not re-derived.) One pre-existing, environment-specific Debug-only failure (`LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty`) was confirmed present on the unmodified baseline via `git stash` before and after this slice's changes — passes in Release, unrelated to chat. +### CH4 REJECT-review fixes (2026-08-09) + +Two blockers, seven should-fixes, and six nits from the CH4 review landed: + +**Blockers:** (1) `@allegiance ` for an unrecognized subcommand was +broadcasting the raw subcommand text to the Allegiance chat channel +(`SendRawChannelCmd(0x02000000, ...)`) because `TryDispatchChannelFallback` +only guarded on `ChatInputParser.IsKnownVerb`, and `/allegiance` had been +deleted from that parser at CH4. Fixed at both ends: `TryMatchAllegiance` +now claims ownership of `allegiance`/`all` unconditionally (matching +retail's own `DoAllegiance`, which never falls through to +`DoChannelCommand`) and shows retail's own "Please see @help Allegiance +for more information on how to use this command." refusal client-side; +`TryDispatchChannelFallback` also gained a blanket +`RetailClientCommandCatalog.KnownVerbs` ownership guard as defense in +depth for the rest of the catalog. (2) `@house abandon` sent `0x021F` +immediately with zero confirmation; retail's `DoHouse` abandon branch runs +a real two-stage dialog ("Do you really want to abandon your house? ..." +then "Are you absolutely certain you wish to abandon your house? Click +yes only if you are sure!") before `Event_AbandonHouse()`. +`ClientCommandController`'s `HouseAbandon` case now chains two +`ShowConfirmation` calls with retail's verbatim text; `AbandonHouse` only +fires after both accepts. + +**Should-fixes:** a bare unregistered tag with no text (`@admin`) now +passes through to the server silently, matching retail's `DoChannelCommand` +returning 0 on `argc<=0`, instead of showing "You must specify the text +you wish to say!" (that string belongs to the registered-verb-only +`DoStupidChannelHack`); `@join`/`@leave` now update +`RuntimeCharacterOptionsState` locally (a new `SetOptionBit` method) +before the wire push, so `TurbineChatMembershipGate` stops refusing a +just-joined room without waiting on a fresh `PlayerDescription`; +`@permit add/remove` now accepts a multi-word name (`>= 2` tokens, +joins the remainder, matching retail's `JoinArgsAsName`); `@clist`/`@on`/ +`@off` now validate only argument SHAPE (exactly one token) at the +catalog layer and raise `WeenieError 0x422` ("That channel doesn't +exist.") for an unresolved tag, instead of silently doing nothing; +`@mr`/`@pr`'s help text is now the verbatim retail strings from +`data_7daa08`/`data_7daa80` (previously fabricated acdream summaries), +and the class doc no longer overclaims every table entry is verbatim +(the ~35 channel one-liners are acknowledged as acdream summaries); +issues #360 and register row TS-68 corrected — `@house`'s unported +subcommands still reach ACE, but `@allegiance`'s now correctly stay +client-side; the campaign doc's own B.7 note and `RetailChannelTagTable`'s +stale "IsKnownVerb intercepts them first" comment are corrected to +describe the catalog-ownership interception path; the ledger suite counts +above are corrected from a stale 12,026 to the actual 12,190. The +error-typing debt (~10 new refusal sites at `ClientLocal 0x00` where +retail types several `0x1A`, plus `DoCommand`'s real `HandleFailureEvent +(0x26)` bad-args response) was deliberately NOT re-plumbed — filed as +issue #363 and register row AP-183, CH5-or-later. + +**Nits:** `TryMatchHouse`'s doc comment no longer describes a +local-swallow path that doesn't exist in the code; AP-182 and the +`SetChatTitle`/`@title` comments across three files no longer claim the +value is "stored" (the binding is `_ => { }`, a pure no-op) and AP-182 +now lists `DoTitle`'s three omitted failure messages; `RetailChannelTagTable +.IsUnregisteredFallbackTag` now excludes by TAG STRING instead of channel +ID, fixing a false-positive on `"olthoi"` (which shares an id with the +genuinely-unregistered `"ol"` but has its own registered Turbine verb); +two binding-level conformance pins (`/g`→Fellowship `0x800`, `/rp`→reply) +were added to `RetailCommandRegistryConformanceTests` so a rebind +regression fails there, not just a narrower parser test; `@index foo` is +now accepted (retail's `DoChannelIndex` ignores argc); an ISSUES.md note +records the six invented verbs (`gen`/`cv`/`lookingforgroup`/`tr`/`role`/`h`) +removed at CH4 for registry parity. + +Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's +12,190/4/0 — net +26 tests (new/expanded theory cases across +`ChatCommandRouterTests`, `RetailClientCommandCatalogTests`, +`RetailCommandRegistryConformanceTests`, `ClientCommandControllerTests`, +`RuntimeCharacterStateTests`, and `TurbineChatMembershipGateTests`; no +tests removed, several renamed/retargeted in place). + ### CH3 closeout handoff (2026-08-09) All nine steps of the research doc's §6 fix list landed: diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 975313b2..8c566213 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -435,7 +435,18 @@ internal sealed class LiveSessionRuntimeFactory // silent accept is exactly as faithful as a stored-but-unread // value would be, without inventing a consumer. SetChatTitle: _ => { }, - SetSingleCharacterOption: session.SendSetSingleCharacterOption, + // CH4 REJECT-review SHOULD-FIX 4 (2026-08-09): only @join/@leave + // reach this binding (see ClientCommandController.Execute). + // Retail's PlayerModule::SetHear*Chat family writes the bit into + // the local options copy FIRST, then notifies the server — match + // that ordering so TurbineChatMembershipGate (which reads + // _domain.Character.Options) stops refusing the newly-joined + // room before the next PlayerDescription happens to arrive. + SetSingleCharacterOption: (optionId, value) => + { + _domain.Character.Options.SetOptionBit(optionId, value); + session.SendSetSingleCharacterOption(optionId, value); + }, AddPlayerPermission: session.SendAddPlayerPermission, RemovePlayerPermission: session.SendRemovePlayerPermission, RequestAvailableHouses: session.SendListAvailableHouses, diff --git a/src/AcDream.App/UI/ClientCommandController.cs b/src/AcDream.App/UI/ClientCommandController.cs index e133cdec..c49358b2 100644 --- a/src/AcDream.App/UI/ClientCommandController.cs +++ b/src/AcDream.App/UI/ClientCommandController.cs @@ -256,9 +256,13 @@ public sealed class ClientCommandController "This command is no longer in use, please see @allegiance officer."); break; // ClientCommunicationSystem::DoTitle @ 0x0057A640. No local - // chat-window title chrome exists yet (AP-182) — the value is - // stored for a future consumer, matching retail's silent - // success (no confirmation text was found at the success site). + // chat-window title chrome exists yet (AP-182) — the binding is + // a pure no-op, matching retail's silent success (no + // confirmation text was found at the success site). Corrected + // 2026-08-09 at the CH4 REJECT-review nit 11: the earlier + // wording here claimed the value "is stored for a future + // consumer", which was false — see LiveSessionRuntimeFactory's + // SetChatTitle binding (`_ => { }`). case ClientCommandId.SetChatTitle: _bindings.SetChatTitle(command.Arguments.Trim()); break; @@ -300,20 +304,32 @@ public sealed class ClientCommandController case ClientCommandId.IndexChannels: _bindings.RequestChannelIndex(); break; - // ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. + // ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. CH4 + // REJECT-review SHOULD-FIX 6 (2026-08-09): the catalog only + // validated argument SHAPE (exactly one token); an unresolved + // tag reaches here and raises retail's own + // HandleFailureEvent(0x422) ("That channel doesn't exist."). case ClientCommandId.ListChannel: if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint listChannelId)) _bindings.RequestChannelList(listChannelId); + else + _bindings.ShowWeenieError(0x0422u); break; - // ClientCommunicationSystem::DoChannelOn @ 0x0057AA80. + // ClientCommunicationSystem::DoChannelOn @ 0x0057AA80. Same + // 0x422 shape as ListChannel above. case ClientCommandId.OnChannel: if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint onChannelId)) _bindings.JoinGmChannel(onChannelId); + else + _bindings.ShowWeenieError(0x0422u); break; - // ClientCommunicationSystem::DoChannelOff @ 0x0057AB50. + // ClientCommunicationSystem::DoChannelOff @ 0x0057AB50. Same + // 0x422 shape as ListChannel above. case ClientCommandId.OffChannel: if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint offChannelId)) _bindings.LeaveGmChannel(offChannelId); + else + _bindings.ShowWeenieError(0x0422u); break; // GameActionRecallAllegianceHometown — @alh/@ah/"@allegiance hometown". case ClientCommandId.AllegianceHometown: @@ -323,9 +339,34 @@ public sealed class ClientCommandController case ClientCommandId.AllegianceInfo: _bindings.RequestAllegianceInfo(command.Arguments.Trim()); break; - // GameActionHouseAbandon — "@house abandon". + // GameActionHouseAbandon — "@house abandon". Retail's abandon + // branch (DoHouse @ 0x00580D58) opens a FIRST confirmation + // dialog (DialogFactory::MakeCallbackDialogInCurrentUI → + // HouseAbandonDialogCallback_First @0x00580E1A); only on + // accept does that callback open a SECOND dialog + // (HouseAbandonDialogCallback_Second @0x0057BE90), and only + // THAT callback's accept calls Event_AbandonHouse() + // (0x0057BF01 — the ONLY call site). Both strings recovered + // verbatim from acclient_2013_pseudo_c.txt (data_7e1460 / + // data_7e1370). CH4 REJECT-review Blocker 2 (2026-08-09): + // acdream previously sent 0x021F immediately with NO + // confirmation at all. case ClientCommandId.HouseAbandon: - _bindings.AbandonHouse(); + _bindings.ShowConfirmation( + "Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.", + firstAccepted => + { + if (!firstAccepted) + return; + + _bindings.ShowConfirmation( + "Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!", + secondAccepted => + { + if (secondAccepted) + _bindings.AbandonHouse(); + }); + }); break; default: @@ -645,12 +686,15 @@ public sealed class ClientCommandController } // ClientCommunicationSystem::DoPermit @ 0x005785A0. Argument shape - // already validated by RetailClientCommandCatalog (exactly "add " - // or "remove "). + // already validated by RetailClientCommandCatalog (a mode word plus at + // least one more token). CH4 REJECT-review SHOULD-FIX 5 (2026-08-09): + // retail's DoPermit joins every token after the mode word into the + // name (JoinArgsAsName), so a multi-word character name — "@permit add + // Aunt Agatha" — must resolve to "Aunt Agatha", not just "Aunt". private void ExecutePermit(string arguments) { string[] parts = SplitArguments(arguments); - string name = parts[1]; + string name = string.Join(' ', parts, 1, parts.Length - 1); if (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase)) _bindings.AddPlayerPermission(name); else diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index ffba4abb..95e25610 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -651,6 +651,58 @@ public sealed class RuntimeCharacterOptionsState Interlocked.Increment(ref _revision); } + /// + /// Set ONE character-option bit locally, by its linear + /// CharacterOptionId (the same id carried on the wire by + /// SetSingleCharacterOption (0x0005)). Retail's + /// PlayerModule::SetHearGeneralChat @0x005D35C0 (and its five + /// SetHear*Chat siblings) write the bit into this LOCAL copy + /// FIRST, before the client ever notifies the server. CH4 + /// REJECT-review SHOULD-FIX 4 (2026-08-09): acdream's @join/ + /// @leave previously pushed only the wire message and left this + /// state untouched, so + /// kept refusing a room the player had just joined until the next + /// PlayerDescription happened to arrive. Only the six + /// ListenTo*Chat ids CharacterOptionId models are + /// recognized here; any other id is a silent no-op — this state only + /// tracks what the Turbine-chat membership gate needs, not a complete + /// PlayerModule mirror. + /// + public void SetOptionBit(uint characterOptionId, bool value) + { + (bool isOptions1, uint mask) = characterOptionId switch + { + (uint)CharacterOptionId.ListenToAllegianceChat => + (true, (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat), + (uint)CharacterOptionId.ListenToGeneralChat => + (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat), + (uint)CharacterOptionId.ListenToTradeChat => + (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat), + (uint)CharacterOptionId.ListenToLFGChat => + (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat), + (uint)CharacterOptionId.ListenToRoleplayChat => + (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat), + (uint)CharacterOptionId.ListenToSocietyChat => + (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat), + _ => (false, 0u), + }; + if (mask == 0u) + return; + + if (isOptions1) + { + uint updated = value ? (Options1 | mask) : (Options1 & ~mask); + Volatile.Write(ref _options1, updated); + } + else + { + uint updated = value ? (Options2 | mask) : (Options2 & ~mask); + Volatile.Write(ref _options2, updated); + } + + Interlocked.Increment(ref _revision); + } + public void ResetSession() { Volatile.Write(ref _options1, DefaultOptions1); diff --git a/src/AcDream.UI.Abstractions/ClientCommandId.cs b/src/AcDream.UI.Abstractions/ClientCommandId.cs index bed13863..d4223d63 100644 --- a/src/AcDream.UI.Abstractions/ClientCommandId.cs +++ b/src/AcDream.UI.Abstractions/ClientCommandId.cs @@ -47,7 +47,14 @@ public enum ClientCommandId Endurance, /// @speaker — fixed deprecation notice ("see @allegiance officer"). Speaker, - /// @title <text> — sets the popup chat window's title (local state only; no title-bar chrome yet, AP-182). + /// + /// @title <text> — retail sets the popup chat window's title; + /// acdream's binding is a pure no-op (the value is neither stored nor + /// consumed — no title-bar chrome exists yet, AP-182, corrected + /// 2026-08-09 at the CH4 REJECT-review nit 11, which found this + /// summary's earlier "local state only" wording implied storage that + /// does not happen). + /// SetChatTitle, /// @chat on|off — global Speech squelch toggle (message type 2). ChatToggle, @@ -75,4 +82,14 @@ public enum ClientCommandId AllegianceInfo, /// "@house abandon" — abandon the character's house. HouseAbandon, + /// + /// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with + /// any subcommand beyond the 2 ported ones (info, hometown/ho). Never + /// dispatched — is always + /// false for this id, so ChatCommandRouter shows retail's own + /// "Please see @help Allegiance..." refusal and never publishes an + /// ExecuteClientCommandCmd. + /// + AllegianceUnrecognizedSubcommand, } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs index 2d9f1701..08128295 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; namespace AcDream.UI.Abstractions.Panels.Chat; @@ -118,11 +119,28 @@ public static class ChatCommandRouter return null; string verb = ChatInputParser.GetVerbToken(trimmed); - string normalizedChatVerb = "/" + verb[1..].TrimEnd(','); + string tag = verb[1..].TrimEnd(','); + + // CH4 REJECT-review Blocker 1 (2026-08-09): apply the catalog + // ownership rule BEFORE any channel-tag resolution. Retail's + // registered-command hash table is checked FIRST (doc §1) and + // unconditionally wins over DoChannelCommand's fallback — a + // catalog-owned verb must never resolve as a channel broadcast + // here, even if its own TryMatch declines ownership for some + // OTHER reason than "not owned" (RetailClientCommandCatalog's + // "allegiance"/"all" already claim ownership unconditionally via + // TryMatchAllegiance, so this line is currently redundant for + // that specific verb — it's the blanket guard for the rest of the + // catalog, e.g. "house", "lifestone", …). Skipping this check is + // exactly how an unmatched "@allegiance boot Bob" used to reach + // this method and broadcast "boot Bob" to the Allegiance channel. + if (RetailClientCommandCatalog.KnownVerbs.Contains(tag, StringComparer.OrdinalIgnoreCase)) + return null; + + string normalizedChatVerb = "/" + tag; if (ChatInputParser.IsKnownVerb(normalizedChatVerb)) return null; // registered verb — handled by the normal channel path. - string tag = normalizedChatVerb[1..]; if (!RetailChannelTagTable.TryResolve(tag, out uint channelId)) return null; @@ -130,10 +148,17 @@ public static class ChatCommandRouter string text = separator < 0 ? string.Empty : trimmed[(separator + 1)..].Trim(); if (text.Length == 0) { - // Retail's DoChannelCommand: "You must specify the text you - // wish to say." — a real local error, not a passthrough. - vm.ShowSystemMessage("You must specify the text you wish to say!"); - return SubmitOutcome.ClientHandled; + // CH4 REJECT-review SHOULD-FIX 3 (2026-08-09): retail's + // DoChannelCommand @0x005774A7 returns 0 SILENTLY when argc<=0 + // for an UNREGISTERED tag; DoCommand's own final fallback then + // sends the raw @-line to the server via Event_Talk — this is + // passthrough, not a local refusal. "You must specify the text + // you wish to say!" belongs to DoStupidChannelHack + // @0x0057B144, which only runs for the REGISTERED channel + // verbs (fellowship, vassals, patron, monarch, covassals, + // allegiance-broadcast), never these 22 GM/faction tags. Let + // TryBuildServerCommand's passthrough handle it instead. + return null; } bus.Publish(new SendRawChannelCmd(channelId, text)); diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs index 39d8613d..eb84802e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs @@ -72,10 +72,22 @@ public static class RetailChannelTagTable ["olthoi"] = 0x40000000u, // Registered-verb tags (already reachable via ChatInputParser's - // normal channel-verb path). Present here ONLY so @clist/@on/@off - // accept the same tag spellings retail's GetChannelID resolves — - // ChatCommandRouter's A.4 fallback never reaches these entries - // because IsKnownVerb intercepts them first. + // normal channel-verb path — OR, for "allegiance"/"all", via + // RetailClientCommandCatalog's unconditional ownership of that + // verb, see TryMatchAllegiance). Present here ONLY so + // @clist/@on/@off accept the same tag spellings retail's + // GetChannelID resolves — ChatCommandRouter's A.4 fallback never + // reaches these entries: "a"/"ab"/"fellowship"/"vassals"/ + // "patron"/"monarch"/"co-vassals"/etc. are intercepted by + // ChatInputParser.IsKnownVerb; "allegiance" is intercepted + // EARLIER still, by RetailClientCommandCatalog.TryMatch claiming + // the verb before A.4 dispatch is ever attempted (CH4 + // REJECT-review Blocker 1, 2026-08-09) — the previous wording + // here ("IsKnownVerb intercepts them first", unqualified) was + // false for "allegiance" specifically, and that gap is exactly + // how the broadcast-to-channel bug happened: an unmatched + // subcommand fell through past both catalog and IsKnownVerb, + // all the way to this table's own "allegiance" entry. ["fellowship"] = 0x00000800u, ["fellow"] = 0x00000800u, ["fellows"] = 0x00000800u, @@ -104,12 +116,47 @@ public static class RetailChannelTagTable ByTag.TryGetValue(tag, out channelId); /// - /// True only for the 22 tags that have NO registered send verb — the + /// Tag strings that have a genuine send verb elsewhere — either + /// 's channel-verb table, or, for + /// "allegiance"/"all", 's + /// unconditional ownership of that verb — even though they also + /// resolve in above. "olthoi" is the deliberate + /// odd one out: retail's GetChannelID fallback resolves it + /// PRE-Turbine (registry doc §2.3, "ol (and olthoi pre-Turbine)"), but + /// once StartupTurbineChatSystem runs — acdream's assumed + /// steady state, see TurbineChatState — "olthoi"/"o" become + /// registered Turbine verbs (DoTurbineChat_Olthoi, §2.4) and + /// owns them instead. "ol" itself is + /// never added by StartupTurbineChatSystem, so it remains a + /// genuine fallback-only tag in every state. + /// + private static readonly FrozenSet RegisteredVerbTags = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "fellowship", "fellow", "fellows", "f", "group", "g", "party", + "vassals", "vassal", "v", + "patron", "p", + "monarch", "m", + "covassals", "covassal", "co-vassals", "c", + "a", "ab", "allegiance", + "olthoi", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + /// + /// True only for tags that have NO registered send verb anywhere — the /// actual reachable set of 's A.4 /// fallback dispatch. Used by the conformance test to enumerate exactly /// the registry doc's §2.3 fallback list. /// + /// + /// CH4 REJECT-review nit 12 (2026-08-09): previously excluded by + /// channel-ID membership, which wrongly reported "olthoi" as + /// unregistered — it shares id 0x40000000 with the genuinely- + /// unregistered "ol", but "olthoi" (unlike "ol") has its own + /// registered Turbine verb. Excluding by TAG STRING instead + /// () fixes "olthoi" without changing + /// "ol"'s (correct, unregistered) answer. + /// public static bool IsUnregisteredFallbackTag(string tag) => - ByTag.TryGetValue(tag, out uint id) && id != 0x00000800u && id != 0x00001000u - && id != 0x00002000u && id != 0x00004000u && id != 0x01000000u && id != 0x02000000u; + ByTag.ContainsKey(tag) && !RegisteredVerbTags.Contains(tag); } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs index 048d4393..9a56ead5 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs @@ -238,8 +238,11 @@ public static class RetailClientCommandCatalog // ClientCommunicationSystem::DoTitle @ 0x0057A640. Exact retail help: // acclient_2013_pseudo_c.txt:1031162 (data_7df2c4) — "@title - Sets the title of the popup chat window.\n". No confirmation - // text was found at the success site; acdream stores the title but has - // no title-bar chrome to render it yet (AP-182). + // text was found at the success site; acdream's binding is a pure + // no-op (the value is neither stored nor consumed — no title-bar + // chrome exists to render it yet, AP-182; corrected 2026-08-09 at the + // CH4 REJECT-review nit 11, which found the earlier "acdream stores + // the title" wording false). private static readonly Definition SetTitle = AnyArguments( ClientCommandId.SetChatTitle, "/title ", @@ -305,7 +308,14 @@ public static class RetailClientCommandCatalog ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim())); // ClientCommunicationSystem::DoPermit @ 0x005785A0. Exact retail help: - // acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8). + // acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8). CH4 + // REJECT-review SHOULD-FIX 5 (2026-08-09): DoPermit joins every token + // after "add"/"remove" into the name (JoinArgsAsName) so a multi-word + // character name works — "@permit add Aunt Agatha" grants Aunt Agatha, + // not just "Aunt". The old exactly-2-tokens gate rejected that input + // outright; the shape check now only requires a mode word plus AT + // LEAST one more token, and ExecutePermit + // (ClientCommandController.cs) joins the remainder. private static readonly Definition Permit = new( ClientCommandId.Permit, Usage: "/permit ", @@ -313,7 +323,7 @@ public static class RetailClientCommandCatalog ValidateArguments: static arguments => { string[] parts = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); - return parts.Length == 2 + return parts.Length >= 2 && (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase) || parts[0].Equals("remove", StringComparison.OrdinalIgnoreCase)); }); @@ -344,36 +354,51 @@ public static class RetailClientCommandCatalog // ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help // string was extracted for the bare form; the verb is admin/advocate/ - // PSR gated server-side (GameActionChannelIndex.Handle). - private static readonly Definition IndexChannels = NoArguments( + // PSR gated server-side (GameActionChannelIndex.Handle). CH4 + // REJECT-review nit 14 (2026-08-09): DoChannelIndex ignores its argc — + // "@index foo" sends the SAME Event_ChannelIndex() as bare "@index" — + // so acdream must accept (and discard) any arguments too, not just none. + private static readonly Definition IndexChannels = AnyArguments( ClientCommandId.IndexChannels, "/index", "@index - Requests the channel index (restricted)."); // ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. Exact retail // no-arg text: acclient_2013_pseudo_c.txt:1031202 (data_7dfaf8) - // "Please specify the channel name." + // "Please specify the channel name." CH4 REJECT-review SHOULD-FIX 6 + // (2026-08-09): retail's own argc check is "!= 1" — a resolved-but- + // UNKNOWN tag still reaches the handler and raises + // HandleFailureEvent(0x422) ("That channel doesn't exist.", + // WeenieErrorMessages[0x422]); only a MISSING or MULTI-WORD argument + // prints this usage line locally. Using tag resolution itself as the + // argument-shape gate (the old behavior) silently swallowed an unknown + // tag instead of raising 0x422 — see ClientCommandController's + // dispatch (ListChannel/OnChannel/OffChannel cases) for the + // ShowWeenieError(0x422) call this shape-only gate now allows through. private static readonly Definition ListChannel = new( ClientCommandId.ListChannel, Usage: "/clist ", HelpText: "@clist - Requests the member list of a channel (restricted).", - ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _), + ValidateArguments: static arguments => IsSingleToken(arguments), InvalidArgumentsText: "Please specify the channel name."); private static readonly Definition OnChannel = new( ClientCommandId.OnChannel, Usage: "/on ", HelpText: "@on - Joins a channel (restricted).", - ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _), + ValidateArguments: static arguments => IsSingleToken(arguments), InvalidArgumentsText: "Please specify the channel name."); private static readonly Definition OffChannel = new( ClientCommandId.OffChannel, Usage: "/off ", HelpText: "@off - Leaves a channel (restricted).", - ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _), + ValidateArguments: static arguments => IsSingleToken(arguments), InvalidArgumentsText: "Please specify the channel name."); + private static bool IsSingleToken(string arguments) => + arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length == 1; + // GameActionRecallAllegianceHometown.Handle. Exact retail help: // acclient_2013_pseudo_c.txt:1031230 — "@allegiance hometown - // Recalls you to your allegiance bindstone, if your allegiance has @@ -392,6 +417,30 @@ public static class RetailClientCommandCatalog "/allegiance info [name]", "@allegiance info - Requests information on a member of your allegiance."); + // ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0. Exact retail + // text: acclient_2013_pseudo_c.txt:1031375 (data_7e0bd0) — "Please see + // @help Allegiance for more information on how to use this command.". + // Printed at label_57da4b (0x0057DA4B) whenever NO subcommand string + // matches ANY of the 12 retail dispatches (boot/info/chat/broadcast/ + // ban/officer/title/hometown/ho/motd/name/lock/house) — retail keeps + // this ENTIRELY client-side; DoAllegiance never falls through to + // DoChannelCommand or the server for an unrecognized subcommand. CH4 + // REJECT-review Blocker 1 (2026-08-09): acdream previously let an + // unmatched subcommand escape TryMatchAllegiance (return false, "not + // owned"), which fell all the way through to the unregistered-tag + // channel-fallback and broadcast the raw subcommand text ("boot Bob") + // to the legacy Allegiance channel (0x02000000) — a real chat-visible + // bug. TryMatchAllegiance below now claims ownership of "allegiance"/ + // "all" UNCONDITIONALLY, exactly like retail's registered-command hash + // table does, and shows this refusal for every subcommand beyond the + // 2 ported ones (info/hometown/ho — TS-68 tracks the other 10). + private static readonly Definition AllegianceUnrecognizedSubcommand = new( + ClientCommandId.AllegianceUnrecognizedSubcommand, + Usage: "/allegiance ", + HelpText: "Please see @help Allegiance for more information on how to use this command.", + ValidateArguments: static _ => false, + InvalidArgumentsText: "Please see @help Allegiance for more information on how to use this command."); + private static readonly FrozenDictionary ByVerb = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -520,14 +569,17 @@ public static class RetailClientCommandCatalog /// Retail's real DoHouse @ 0x00580860 handles 15 subcommands /// (see the registry doc §2.5b) locally; acdream Campaign CH slice CH4 /// (2026-08-09) ports 4 of them (recall/re, mansion_recall/alleg_recall/ - /// ma, abandon) plus the pre-existing HasValidArguments==false swallow - /// for a MISSPELLED recall variant. Every OTHER subcommand — open, - /// close, storage, remove, boot, boot_all, remove_all, guest, available, - /// hooks, on, off — is NOT yet ported (TS-68) and must reach ACE - /// (which replies "Unknown command") rather than being swallowed - /// locally with a wrong usage message — the Tier-1 #4 fix from the - /// command-registry doc. Returning false here lets - /// fall through to server passthrough. + /// ma, abandon). Every OTHER subcommand — open, close, storage, remove, + /// boot, boot_all, remove_all, guest, available, hooks, on, off, and + /// any misspelling of the 4 ported ones — returns false + /// uniformly (there is no separate local-swallow branch; CH4 + /// REJECT-review nit 10, 2026-08-09, corrected this comment, which + /// previously described a swallow path that does not exist in the code + /// below), letting fall through to + /// server passthrough (ACE replies "Unknown command") rather than + /// being swallowed locally with a wrong usage message — the Tier-1 #4 + /// fix from the command-registry doc. The 12 unported subcommands are + /// tracked by TS-68. /// private static bool TryMatchHouse(string arguments, out Match match) { @@ -556,14 +608,26 @@ public static class RetailClientCommandCatalog /// @allegiance <sub> / @all <sub> dispatcher. /// Retail's real DoAllegiance @ 0x0057D5A0 handles 12 /// subcommands (see the registry doc §2.5) locally; acdream Campaign CH - /// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho). Every - /// OTHER subcommand — boot, ban, officer, title, name, lock, house, - /// motd, chat, broadcast — is NOT yet ported (TS-68) and falls through - /// to server passthrough, same reasoning as . + /// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho). /// + /// + /// CH4 REJECT-review Blocker 1 correction (2026-08-09): every + /// OTHER subcommand — boot, ban, officer, title, name, lock, house, + /// motd, chat, broadcast, or garbage — is NOT yet ported (TS-68), but + /// unlike this method NEVER returns + /// false for the "allegiance"/"all" verb: retail's own + /// DoAllegiance claims the ENTIRE verb unconditionally and + /// prints its own client-local refusal + /// () for an unrecognized + /// subcommand — it never falls through to DoChannelCommand or + /// the server. The original CH4 implementation returned false + /// here (matching 's reasoning), which let + /// an unmatched subcommand escape all the way to the unregistered-tag + /// channel-fallback and broadcast the raw text to the Allegiance + /// channel — a real bug, not merely an incomplete port. + /// private static bool TryMatchAllegiance(string arguments, out Match match) { - match = default; int separator = IndexOfWhitespace(arguments); string subcommand = separator < 0 ? arguments : arguments[..separator]; string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim(); @@ -591,7 +655,17 @@ public static class RetailClientCommandCatalog return true; } - return false; + // Every other subcommand (or none at all) — claim ownership + // anyway and show retail's own refusal text. See the remarks + // above; this is what stops "allegiance"/"all" from ever reaching + // ChatCommandRouter's channel-fallback or server-passthrough path. + match = new Match( + AllegianceUnrecognizedSubcommand.Command, + arguments, + AllegianceUnrecognizedSubcommand.Usage, + HasValidArguments: false, + AllegianceUnrecognizedSubcommand.InvalidArgumentsText); + return true; } /// Help line generated from the same definition routing uses. diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs index 806ac05d..8a9cf74e 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs @@ -14,7 +14,11 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// see TS-68). /// /// -/// Every entry is verbatim retail text recovered from +/// The named constants above (, +/// , , , +/// , , , +/// , , , +/// ) are verbatim retail text recovered from /// docs/research/named-retail/acclient_2013_pseudo_c.txt by the /// recipe in the command-registry doc §5 (scan each Help* /// function's byte extent for push imm32 into .rdata). Entries @@ -23,6 +27,19 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// NOT fabricated — they are simply absent from this table; the lookup /// falls through to a generic "no detailed help" line rather than guess. /// +/// +/// +/// Corrected 2026-08-09 at the CH4 REJECT-review, SHOULD-FIX 7: the +/// paragraph above previously claimed "every entry" was verbatim retail +/// text, which was FALSE and directly contradicted 's +/// own inline comment a few dozen lines below it. The ~35 CHANNEL +/// one-liners in ("Sends text to your Fellowship +/// channel.", etc.) are acdream-authored SUMMARIES, not individually +/// hand-extracted retail strings — retail's own per-channel help text was +/// not recovered this slice. Recovering them (or deleting the class-doc +/// overclaim) is future work; this comment now says so honestly instead +/// of leaving the contradiction standing. +/// /// public static class RetailCommandHelpTable { @@ -44,17 +61,27 @@ public static class RetailCommandHelpTable "Note: You may substitute a forward slash (/) for the at symbol (@)."; // @mr/@pr are registered with a NULL function pointer in the 2013 - // build (verified at 0x00583041/0x005830C1 — arg3 is 0). Retail's own - // HelpReply @0x00577A50 is shared between @reply/@r/@rp (which DO - // execute) and @mr/@pr (which do NOT — they fall through to - // DoChannelCommand, miss, and reach the server as literal text). The - // shared help text is Reply's text above; acdream additionally notes - // the non-execution here so /help mr doesn't imply it works. + // build (verified at 0x00583041/0x005830C1 — arg3 is 0), so they never + // execute locally in retail OR acdream — typing one sends the literal + // text to the server. CH4 REJECT-review SHOULD-FIX 7 (2026-08-09): + // the strings below were previously FABRICATED acdream summaries; the + // real retail-registered help function (HelpReply @0x00577A50) is + // shared across @reply/@r/@rp/@mr/@pr and IS the source of a per-verb + // detail line for each, extracted verbatim below — + // acclient_2013_pseudo_c.txt:1030734 (data_7daa08) for @mr, + // acclient_2013_pseudo_c.txt:1030738 (data_7daa80) for @pr. Retail's + // own strings have a double space before "you" — confirmed byte-level, + // not a typo. (HelpReply's full concatenation across all 5 shared + // verbs is more involved than a single-string extraction can safely + // confirm from the pseudo-C alone — a BN decomp string-temporary + // pattern reuses the output-parameter stack slot, which risks a + // misread; only the two per-verb detail lines requested by the review + // are pinned here, not a full re-derivation of HelpReply's output.) public const string MonarchReply = - "@mr - Reply to the last person who @m'd you (monarch chat only). NOTE: this command is registered with no handler in the named retail build — it does not execute locally in retail OR acdream; typing it sends the literal text to the server."; + "@mr - Sends the text to the last person who used @m to send you a message. This only works for monarchs."; public const string PatronReply = - "@pr - Reply to the last person who @p'd you (patron chat only). NOTE: this command is registered with no handler in the named retail build — it does not execute locally in retail OR acdream; typing it sends the literal text to the server."; + "@pr - Sends the text to the last vassal who used @p to send you a message."; // acclient_2013_pseudo_c.txt:1031093 (data_7de280). public const string Day = diff --git a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs index 76f4754d..1b3bf159 100644 --- a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs @@ -300,6 +300,124 @@ public sealed class ClientCommandControllerTests Assert.Equal(["Component list cleared.", "You need an open vendor."], messages); } + // ── CH4 REJECT-review Blocker 2 (2026-08-09) ──────────────────────── + // "@house abandon" must run retail's real two-stage confirmation + // (DoHouse's abandon branch @0x00580D58 → HouseAbandonDialogCallback_ + // First @0x00580E1A → HouseAbandonDialogCallback_Second @0x0057BE90, + // the ONLY Event_AbandonHouse() call site @0x0057BF01) before sending + // 0x021F — previously it sent immediately with no confirmation at all. + + [Fact] + public void HouseAbandon_BothStagesAccepted_ShowsBothPromptsThenSendsExactlyOnce() + { + var calls = new List(); + var controller = NewController(calls); + + controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, "")); + + Assert.Equal( + [ + "confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.", + "confirm:Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!", + "houseabandon", + ], + calls); + } + + [Fact] + public void HouseAbandon_DeclineFirstStage_ShowsOnlyOnePromptAndNeverSends() + { + var calls = new List(); + var controller = NewController(calls, confirmationResponses: new Queue([false])); + + controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, "")); + + Assert.Equal( + [ + "confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.", + ], + calls); + Assert.DoesNotContain("houseabandon", calls); + } + + [Fact] + public void HouseAbandon_DeclineSecondStage_ShowsBothPromptsAndNeverSends() + { + var calls = new List(); + var controller = NewController(calls, confirmationResponses: new Queue([true, false])); + + controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, "")); + + Assert.Equal( + [ + "confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.", + "confirm:Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!", + ], + calls); + Assert.DoesNotContain("houseabandon", calls); + } + + // ── CH4 REJECT-review SHOULD-FIX 5 (2026-08-09) ───────────────────── + // "@permit add/remove " joins every token after the + // mode word into the name (retail's JoinArgsAsName). + + [Fact] + public void Permit_MultiWordName_JoinsTheRemainderIntoOneName() + { + var calls = new List(); + var controller = NewController(calls); + + controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Permit, "add Aunt Agatha")); + controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Permit, "remove Lord Gnarly Beard")); + + Assert.Equal( + ["permitadd:Aunt Agatha", "permitremove:Lord Gnarly Beard"], + calls); + } + + // ── CH4 REJECT-review SHOULD-FIX 6 (2026-08-09) ───────────────────── + // @clist/@on/@off with an unresolvable (but single-token) tag raises + // retail's WeenieError 0x422 ("That channel doesn't exist.") instead + // of silently doing nothing. + + [Fact] + public void ChannelArgumentCommands_UnknownTag_ShowsWeenieError422WithoutSending() + { + var calls = new List(); + var errors = new List(); + var controller = NewController(calls, errors); + + Execute(ClientCommandId.ListChannel, "nonsense"); + Execute(ClientCommandId.OnChannel, "nonsense"); + Execute(ClientCommandId.OffChannel, "nonsense"); + + Assert.Empty(calls); + Assert.Equal([0x0422u, 0x0422u, 0x0422u], errors); + + void Execute(ClientCommandId id, string arguments) => + controller.Execute(new ExecuteClientCommandCmd(id, arguments)); + } + + [Fact] + public void ChannelArgumentCommands_KnownTag_SendsWithoutError() + { + var calls = new List(); + var errors = new List(); + var controller = NewController(calls, errors); + + Execute(ClientCommandId.ListChannel, "fellowship"); + Execute(ClientCommandId.OnChannel, "admin"); + Execute(ClientCommandId.OffChannel, "sentinel"); + + Assert.Empty(errors); + Assert.Equal( + ["clist:2048", "on:2", "off:512"], + calls); + + void Execute(ClientCommandId id, string arguments) => + controller.Execute(new ExecuteClientCommandCmd(id, arguments)); + } + [Fact] public void UnknownCommandId_FailsAtApplicationBoundary() { @@ -319,7 +437,13 @@ public sealed class ClientCommandControllerTests FriendsState? friends = null, SquelchState? squelch = null, string? lastTeller = null, - bool vendorOpen = false) + bool vendorOpen = false, + // CH4 REJECT-review Blocker 2 (2026-08-09): lets a test drive a + // specific accept/decline sequence through consecutive + // ShowConfirmation calls (e.g. house-abandon's two-stage prompt). + // Defaults to "always accept" so every pre-existing single-stage + // test (Die, etc.) keeps its original behavior unchanged. + Queue? confirmationResponses = null) { calls ??= []; errors ??= []; @@ -347,7 +471,10 @@ public sealed class ClientCommandControllerTests (message, completed) => { calls.Add($"confirm:{message}"); - completed(true); + bool accepted = confirmationResponses is { Count: > 0 } + ? confirmationResponses.Dequeue() + : true; + completed(accepted); }, () => calls.Add("suicide"), all => calls.Add("clear:" + all), diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index 6a707308..9a05cc0f 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -1,4 +1,5 @@ using AcDream.Core.Items; +using AcDream.Core.Net.Messages; using AcDream.Core.Properties; using AcDream.Core.Spells; using AcDream.Core.Player; @@ -231,6 +232,60 @@ public sealed class RuntimeCharacterStateTests Assert.Equal(-1, state.MovementSkills.JumpSkill); } + // ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ──────────────────── + + [Theory] + [InlineData(CharacterOptionId.ListenToGeneralChat, PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)] + [InlineData(CharacterOptionId.ListenToTradeChat, PlayerDescriptionParser.CharacterOptions2.HearTradeChat)] + [InlineData(CharacterOptionId.ListenToLFGChat, PlayerDescriptionParser.CharacterOptions2.HearLFGChat)] + [InlineData(CharacterOptionId.ListenToRoleplayChat, PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)] + [InlineData(CharacterOptionId.ListenToSocietyChat, PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)] + public void SetOptionBit_Options2Ids_ToggleOnlyTheirOwnBit( + CharacterOptionId optionId, PlayerDescriptionParser.CharacterOptions2 bit) + { + var options = new RuntimeCharacterOptionsState(); + options.Replace(options.Options1, 0u); // every Hear*Chat bit off + + options.SetOptionBit((uint)optionId, true); + Assert.Equal((uint)bit, options.Options2 & (uint)bit); + Assert.Equal(RuntimeCharacterOptionsState.DefaultOptions1, options.Options1); + + options.SetOptionBit((uint)optionId, false); + Assert.Equal(0u, options.Options2 & (uint)bit); + } + + [Fact] + public void SetOptionBit_AllegianceId_TogglesOptions1NotOptions2() + { + var options = new RuntimeCharacterOptionsState(); + options.Replace(0u, options.Options2); // HearAllegianceChat off + + options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, true); + Assert.Equal( + (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat, + options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat); + + options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, false); + Assert.Equal( + 0u, + options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat); + } + + [Fact] + public void SetOptionBit_UnrecognizedId_IsANoOp() + { + var options = new RuntimeCharacterOptionsState(); + uint before1 = options.Options1; + uint before2 = options.Options2; + long beforeRevision = options.Revision; + + options.SetOptionBit(0xFFFFu, true); + + Assert.Equal(before1, options.Options1); + Assert.Equal(before2, options.Options2); + Assert.Equal(beforeRevision, options.Revision); + } + // ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ─── // ── run/jump skill (pseudocode doc §9) ───────────────────────────── diff --git a/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs index 7b2ce253..43a1c0b2 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs @@ -207,6 +207,51 @@ public sealed class TurbineChatMembershipGateTests Assert.Equal(expectedType, refusal.Value.Type); } + // ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ──────────────────── + // @join/@leave must update RuntimeCharacterOptionsState locally so + // this SAME-SESSION gate stops refusing without waiting on a fresh + // PlayerDescription (retail's PlayerModule::SetHear*Chat family + // writes the bit locally FIRST, then notifies). + + [Fact] + public void JoinChannel_SetOptionBit_FlipsGateToAllowed_WithoutFreshPlayerDescription() + { + TurbineChatState turbine = ReceivedRooms(); + var options = new RuntimeCharacterOptionsState(); + options.Replace(options.Options1, 0u); // every Hear*Chat bit off — starts refused + + Assert.Equal( + TurbineChatGateStatus.NotListening, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status); + + options.SetOptionBit((uint)CharacterOptionId.ListenToGeneralChat, true); + + Assert.Equal( + TurbineChatGateStatus.Allowed, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status); + } + + [Fact] + public void LeaveChannel_SetOptionBit_FlipsGateToNotListening() + { + TurbineChatState turbine = ReceivedRooms(); + var options = new RuntimeCharacterOptionsState(); // General on by default + + Assert.Equal( + TurbineChatGateStatus.Allowed, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status); + + options.SetOptionBit((uint)CharacterOptionId.ListenToGeneralChat, false); + + Assert.Equal( + TurbineChatGateStatus.NotListening, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status); + } + private static TurbineChatState ReceivedRooms( uint allegianceRoom = 0x10u, uint generalRoom = 0x11u, diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs index 13b68702..8bdbb29a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -171,15 +171,44 @@ public class ChatCommandRouterTests } [Fact] - public void UnregisteredChannelTag_WithNoText_ShowsRetailRefusal_AndPublishesNothing() + public void UnregisteredChannelTag_WithNoText_PassesThroughToServer() { + // CH4 REJECT-review SHOULD-FIX 3 (2026-08-09): retail's + // DoChannelCommand @0x005774A7 returns 0 SILENTLY on argc<=0 for an + // UNREGISTERED tag; DoCommand's own final fallback then sends the + // raw @-line to the server via Event_Talk. "You must specify the + // text you wish to say!" belongs to DoStupidChannelHack + // @0x0057B144, which only runs for REGISTERED channel verbs — it + // must never appear for one of the 22 unregistered fallback tags. var (vm, log, bus) = Fixture(); var outcome = ChatCommandRouter.Submit("/sentinel", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal("@sentinel", command.Text); + Assert.DoesNotContain(log.Snapshot(), entry => entry.Text.Contains("You must specify the text")); + } + + // ── CH4 REJECT-review Blocker 1 (2026-08-09) ──────────────────────── + // "@allegiance " must never broadcast to the Allegiance channel + // or reach the server for an unrecognized subcommand — retail's own + // DoAllegiance claims the entire verb unconditionally and shows its + // own client-local refusal. + + [Theory] + [InlineData("/allegiance boot Bob")] + [InlineData("/all boot Bob")] + public void AllegianceUnrecognizedSubcommand_ShowsRetailRefusal_NeverBroadcastsOrSends(string input) + { + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); - Assert.Empty(bus.Published); - Assert.Contains(log.Snapshot(), entry => entry.Text == "You must specify the text you wish to say!"); + 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."); } [Fact] @@ -208,6 +237,23 @@ public class ChatCommandRouterTests Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("Returns you to the last lifestone")); } + [Theory] + [InlineData("/help mr", "@mr - Sends the text to the last person who used @m to send you a message. This only works for monarchs.")] + [InlineData("/help pr", "@pr - Sends the text to the last vassal who used @p to send you a message.")] + public void HelpVerb_MrPr_ShowsVerbatimRetailText(string input, string expected) + { + // CH4 REJECT-review SHOULD-FIX 7 (2026-08-09): these were + // previously fabricated acdream summaries; now the verbatim retail + // strings from data_7daa08/data_7daa80 (the double space before + // "you" is confirmed byte-level, not a typo). + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Contains(log.Snapshot(), entry => entry.Text == expected); + } + [Fact] public void HelpVerb_UnknownVerb_ShowsFallbackMessage() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs index 3b28cb59..e7e3e8e3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs @@ -123,11 +123,23 @@ public sealed class RetailClientCommandCatalogTests [InlineData("/allegiance motd")] [InlineData("/allegiance")] [InlineData("/all officer add 2 Bob")] - public void UnsupportedAllegianceSubcommand_FallsThroughToServerPassthrough(string input) + public void UnsupportedAllegianceSubcommand_ShowsRetailRefusal_ClientSide(string input) { - // Same Tier-1-class fix, applied to the allegiance management - // dispatcher (TS-68): unrecognized subcommands reach ACE. - Assert.False(RetailClientCommandCatalog.TryMatch(input, out _)); + // CH4 REJECT-review Blocker 1 (2026-08-09): unlike @house (whose + // unrecognized subcommands correctly reach ACE, see the test + // above), retail's own DoAllegiance NEVER falls through to + // DoChannelCommand/the server for an unrecognized subcommand — it + // claims the whole verb unconditionally and prints its own + // client-local refusal (label_57da4b, 0x0057DA4B). The earlier + // "falls through to server passthrough" behavior here was itself + // the bug: an unmatched subcommand used to escape all the way to + // the unregistered-tag channel fallback and broadcast to the + // Allegiance chat channel. + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.False(match.HasValidArguments); + Assert.Equal( + "Please see @help Allegiance for more information on how to use this command.", + match.InvalidArgumentsText); } [Theory] @@ -155,6 +167,9 @@ public sealed class RetailClientCommandCatalogTests [InlineData("/endurance", ClientCommandId.Endurance)] [InlineData("/speaker", ClientCommandId.Speaker)] [InlineData("/index", ClientCommandId.IndexChannels)] + // CH4 REJECT-review nit 14 (2026-08-09): DoChannelIndex ignores argc — + // "@index foo" sends the same request as bare "@index". + [InlineData("/index foo", ClientCommandId.IndexChannels)] public void MissingAliasesSweep_Resolve(string input, ClientCommandId expected) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); @@ -201,6 +216,11 @@ public sealed class RetailClientCommandCatalogTests [Theory] [InlineData("/permit add Bob", true)] [InlineData("/permit remove Bob", true)] + // CH4 REJECT-review SHOULD-FIX 5 (2026-08-09): retail's DoPermit joins + // every token after the mode word into the name (JoinArgsAsName), so a + // multi-word character name is a VALID shape, not a rejected one. + [InlineData("/permit add Aunt Agatha", true)] + [InlineData("/permit remove Lord Gnarly Beard", true)] [InlineData("/permit add", false)] [InlineData("/permit maybe Bob", false)] public void Permit_ArgumentShape(string input, bool expectedValid) @@ -222,8 +242,16 @@ public sealed class RetailClientCommandCatalogTests [Theory] [InlineData("/clist fellowship", true)] [InlineData("/on admin", true)] - [InlineData("/off nonsense", false)] - public void ChannelArgumentCommands_ResolveTagsAgainstRetailChannelTagTable(string input, bool expectedValid) + // CH4 REJECT-review SHOULD-FIX 6 (2026-08-09): the catalog only + // validates argument SHAPE (retail's argc != 1 check) — a resolved-but- + // UNKNOWN single-token tag is now a VALID shape that reaches + // ClientCommandController, which raises WeenieError 0x422 ("That + // channel doesn't exist.") instead of the catalog silently rejecting + // it with the wrong "Please specify the channel name." usage line. + [InlineData("/off nonsense", true)] + [InlineData("/clist", false)] + [InlineData("/on fellowship extra", false)] + public void ChannelArgumentCommands_RequireExactlyOneToken(string input, bool expectedValid) { Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); Assert.Equal(expectedValid, match.HasValidArguments); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs index e5ec85b5..3ca66f74 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs @@ -1,3 +1,4 @@ +using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.UI.Abstractions.Tests.Panels.Chat; @@ -266,4 +267,34 @@ public sealed class RetailCommandRegistryConformanceTests "test's registry, or it's an invented alias that must be deleted."); } } + + // ── CH4 REJECT-review nit 13 (2026-08-09) ─────────────────────────── + // + // The tests above only prove a verb STRING is recognized somewhere — + // not which channel it actually resolves to. A rebind regression (e.g. + // "/g" quietly reverting to General, the exact Tier-1 #1 bug this + // campaign fixed) would still pass every case above. These two pin the + // real dispatch outcome so a rebind regression fails THIS suite, not + // just a narrower parser-only test. + + [Fact] + public void GVerb_BindsToFellowship_NotGeneral() + { + ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse( + "/g hi gang", ChatChannelKind.Say, lastTellSender: null); + + Assert.NotNull(parsed); + Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel); + } + + [Fact] + public void RpVerb_BindsToReply_NotRoleplay() + { + ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse( + "/rp hello back", ChatChannelKind.Say, lastTellSender: "Aunt Agatha"); + + Assert.NotNull(parsed); + Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel); + Assert.Equal("Aunt Agatha", parsed!.Value.TargetName); + } }