From 090825e703bf8fba437445a2e1716d492df01f52 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 21:10:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20Campaign=20CH=20slice=20CH4=20?= =?UTF-8?q?=E2=80=94=20command=20registry=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings acdream's / and @ command parsing to parity with the complete retail registry (130 registered verbs + 22 unregistered GetChannelID fallback tags = 152 client-parsed verbs), per docs/research/2026-08-09-chat-retail-command-registry.md. Parser semantics (retail OnChatCommand/DoCommand): - : and ; rewrite to "@emote " before dispatch. - Verb trailing-comma trim ("@f, hi" == "@f hi") applied at every verb-lookup site in the catalog and the parser. - @tell/aliases split the target on the FIRST COMMA, not the first whitespace token, so multi-word names work ("@tell Aunt Agatha, hi"). - The 22 unregistered GM/faction channel tags (admin, sentinel, celestialhand, ...) now broadcast for real via a new RetailChannelTagTable + SendRawChannelCmd bypass, reusing the existing BuildChatChannel wire builder. Binding corrections: - /g, /group, /party -> Fellowship (0x800), not General. - /rp -> reply alias (retail's own help text confirms "@r or @rp"), not Roleplay; /role (an acdream invention) deleted. - /allegiance, /all -> the allegiance management command (RetailClientCommandCatalog), not a channel verb. - /house no longer swallows unrecognized subcommands with a local usage error; they now correctly fall through to ACE. - @mr/@pr pinned as permanently non-executable (retail registers them with a null function pointer). New verbs with real local execution: endurance, speaker, title (silent, AP-182), chat, notell, join, leave, permit, hslist, index, clist, on, off, alh/ah (+ "@allegiance hometown"/"ho"), "@allegiance info", "@house abandon"; a missing-alias sweep across pkl/hou/message_types/ msgtypes/msg_types/rt/send/whisper/w/vassal/covassal/co-vassals/c/ fellows/group/party/guild/gu/cg/ct/clfg/crp/soc/o; the non-retail inventions gen/cv/lookingforgroup/tr/role/h are deleted. New Core.Net wire builders (IndexChannels, ListChannels, AddChannel, RemoveChannel, RecallAllegianceHometown, AllegianceInfoRequest, ListAvailableHouses, AddPlayerPermission, RemovePlayerPermission, AbandonHouse) are all parameterless or single-field payloads cross-checked against ACE's GameAction readers, not guessed. Deferred (filed as #360/#361/#362, register rows TS-68/TS-69/TS-70): the ~22 remaining allegiance/house subcommands + standalone @motd (largest single item, needs its own slice per the doc), the three still-inert pure-local commands (day/log/render), and the inbound GameEvent responses for the new outbound requests. All correctly fall through to ACE server-passthrough rather than being silently swallowed or faking success. RetailCommandRegistryConformanceTests pins the complete 152-verb registry against production: every verb resolves through exactly one production surface if Implemented, through none if HelpOnly/ ServerPassthrough, and two reverse-direction tests fail the build if RetailClientCommandCatalog or ChatInputParser ever claims a verb outside this registry again. Final tally: 138 Implemented / 5 ServerPassthrough / 9 HelpOnly = 152. Release suite: 12,190 passed / 4 skipped / 0 failed (up from CH3's 11,964/4/0). Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 49 +++ .../retail-divergence-register.md | 8 +- docs/plans/2026-08-09-chat-parity-campaign.md | 96 ++++- .../Net/LiveSessionCommandRouter.cs | 23 +- .../Net/LiveSessionRuntimeFactory.cs | 20 +- src/AcDream.App/UI/ClientCommandController.cs | 135 +++++- .../Messages/ClientCommandRequests.cs | 66 +++ src/AcDream.Core.Net/WorldSession.cs | 71 ++++ .../ClientCommandId.cs | 34 ++ .../Panels/Chat/ChatCommandRouter.cs | 114 ++++- .../Panels/Chat/ChatInputParser.cs | 150 +++++-- .../Panels/Chat/RetailChannelTagTable.cs | 115 +++++ .../Panels/Chat/RetailClientCommandCatalog.cs | 394 ++++++++++++++++-- .../Panels/Chat/RetailCommandHelpTable.cs | 205 +++++++++ .../SendRawChannelCmd.cs | 17 + .../Net/LiveSessionCommandRouterTests.cs | 15 +- .../UI/ClientCommandControllerTests.cs | 15 +- .../Panels/Chat/ChatCommandRouterTests.cs | 80 ++++ .../Chat/ChatInputParserAtPrefixTests.cs | 15 +- .../Panels/Chat/ChatInputParserTests.cs | 123 +++++- .../Panels/Chat/ChatPanelInputTests.cs | 3 +- .../Chat/RetailClientCommandCatalogTests.cs | 149 ++++++- .../RetailCommandRegistryConformanceTests.cs | 269 ++++++++++++ 23 files changed, 2069 insertions(+), 97 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs create mode 100644 src/AcDream.UI.Abstractions/SendRawChannelCmd.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5af6c9ee..b96eceaa 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,55 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #360 — @allegiance/@house management dispatchers only port their simple subcommands + +**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. 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 +parameterless/single-string-field wire shapes — allegiance `info`, +`hometown`/`ho` (also the standalone `@alh`/`@ah`); house `recall`/`re`, +`mansion_recall`/`alleg_recall`/`ma` (already shipped pre-CH4), and +`abandon`. The remaining ~22 subcommands (allegiance boot/ban/officer/ +title/name/lock/chat/broadcast/motd; house open/close/storage/remove/ +boot_all/remove_all/guest/available/hooks/on/off) plus the standalone +`@motd` verb need real GameAction wire builders, most requiring +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. + +## #361 — @day / @log / @render pure-local commands recognized in help only, not executed + +**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. Three +retail-registered pure-local verbs are not yet wired to real behavior: +`@day` (daylight override — needs a sky/time-of-day hook the renderer +doesn't expose), `@log` (chat-to-file logging — deferred to avoid an +unaudited file-handle lifecycle across session reconnects; see AP/TS-69 +for the reasoning), and `@render` (retail's `SmartBox::HandleRenderOption` +— acdream has no equivalent render-option surface). All three are +recognized by `/help ` (`RetailCommandHelpTable`) with retail's own +extracted help text, but fall through to server passthrough on execution. +Register row: TS-69. + +## #362 — Four new CH4 outbound requests have no inbound response handler + +**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. `@index`, +`@clist`, `@hslist`, and `@allegiance info` send byte-correct retail +GameAction requests (`ClientCommandRequests.BuildIndexChannels`/ +`BuildListChannel`/`BuildListAvailableHouses`/`BuildAllegianceInfoRequest`), +but their GameEvent responses (`ChannelIndex 0x0149`, `ChannelList +0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`) are +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. + ## #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 6e5e2fc1..e87324ff 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) — 128 active rows (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) — 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) 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,8 +337,9 @@ 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` | -## 4. Temporary stopgap (TS) — 39 active rows (TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 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) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -387,6 +388,9 @@ 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-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 b289cc45..33d9d014 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -8,7 +8,12 @@ re-reviewed APPROVE-WITH-FIXES with nits applied this commit) — the sole outstanding item is the in-client user gate (jump-in-air / jump-loaded refusals showing on-screen, not in chat). CH3 (side channels) CODE-COMPLETE, pending the connected user gate — see the CH3 row below and -`docs/research/2026-08-09-chat-side-channels-vs-ace.md`. +`docs/research/2026-08-09-chat-side-channels-vs-ace.md`. CH4 (command +registry completion) CODE-COMPLETE — see the CH4 closeout below; 138 of +152 registry verbs now execute locally, 5 are deliberately deferred +(issues #360/#361/#362, register rows TS-68/TS-69/TS-70), and 9 are +retail's own null-handler help-only nodes. Pending the in-client user +gate (command spot-checks per the campaign's overall gate list). **Why now:** first track of the alpha-release program (chat is the most visible daily surface for the friend-alpha). User-directed 2026-08-09. @@ -109,9 +114,96 @@ 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 | — | — | — | — | +| CH4 commands | see CH4 closeout below | 12,026 passed / 4 skipped / 0 failed | pending | pending | | CH5 closeout | — | — | — | — | +### CH4 closeout (2026-08-09) + +Full parser-semantics + catalog-breadth pass against +`docs/research/2026-08-09-chat-retail-command-registry.md`'s complete +130-registered + 22-fallback = 152-verb enumeration. + +**A. Parser semantics** (`ChatInputParser.cs`, `RetailClientCommandCatalog.cs`, +`ChatCommandRouter.cs`): + +1. `:`/`;` emote-prefix rewrite (`OnChatCommand` cases 0x0B/0x0C) — both + prefixes rewrite identically to `@emote `. +2. Verb trailing-comma trim (`DoCommand`'s right-trim) applied at every + verb-lookup site in both the catalog and the parser — `"@f, hi"` ≡ + `"@f hi"`. +3. `@tell`/aliases now split the target on the FIRST COMMA, not the first + whitespace token — `"@tell Aunt Agatha, hello"` addresses "Aunt + Agatha" (previously truncated to "Aunt"). Falls back to the pre-CH4 + whitespace+punctuation-strip split when no comma is present, so + existing single-word-target muscle memory still works. +4. The 22 unregistered `ChannelSystem::GetChannelID` fallback tags + (`av`, `admin`, `sentinel`, `celestialhand`, …) now broadcast for real + via a new `RetailChannelTagTable` + `SendRawChannelCmd` bypass path, + reusing the existing `BuildChatChannel` wire builder — no new opcode + needed. + +**B. Binding corrections:** + +5. `/g`/`/group`/`/party` → Fellowship (0x800), not General — the live + correctness bug the doc flagged as Tier-1 #1. +6. `/rp` → reply alias (confirmed by retail's own help text, "You may + also use @r or @rp"), not Roleplay. Roleplay keeps `roleplay`/`crp`; + the non-retail `/role` invention is deleted. +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`. +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`, + letting it reach ACE. +9. `@mr`/`@pr` pinned as permanently non-executable + (`MrPr_AreNeverExecutable` test) — retail registers them with a NULL + function pointer; they must never resolve in + `RetailClientCommandCatalog` or `ChatInputParser`. + +**C. New verbs implemented** (real local execution, not passthrough): +`endurance`, `speaker`, `title` (silent — AP-182, no chrome yet), `chat`, +`notell`, `join`, `leave`, `permit`, `hslist`, `index`, `clist`, `on`, +`off`, `alh`/`ah` (+ `@allegiance hometown`/`ho`), `@allegiance info`, +`@house abandon`; missing-alias sweep (`pkl`, `hou`, `message_types`, +`msgtypes`, `msg_types`, `rt`, `send`, `whisper`, `w`, `vassal`, +`covassal`, `co-vassals`, `c`, `fellows`, `group`, `party`, `guild`, +`gu`, `cg`, `ct`, `clfg`, `crp`, `soc`, `o`, `ab` (already CH3)); the +non-retail inventions `gen`, `cv`, `lookingforgroup`, `tr`, `role`, `h` +are deleted. New Core.Net wire builders: `IndexChannels`/`ListChannels`/ +`AddChannel`/`RemoveChannel`/`RecallAllegianceHometown`/ +`AllegianceInfoRequest`/`ListAvailableHouses`/`AddPlayerPermission`/ +`RemovePlayerPermission`/`AbandonHouse` — all parameterless or +single-field payloads cross-checked against ACE's GameAction readers +(`references/ACE/Source/ACE.Server/Network/GameAction/Actions/*.cs`), not +guessed. **Deferred, filed as issues #360/#361/#362 + register rows +TS-68/TS-69/TS-70:** the ~22 remaining allegiance/house subcommands + the +standalone `@motd`, the three still-inert pure-local commands +(`day`/`log`/`render`), and the four unparsed inbound GameEvent responses +for the new outbound requests. + +**D. Conformance:** `RetailCommandRegistryConformanceTests` (new, +`tests/AcDream.UI.Abstractions.Tests/Panels/Chat/`) enumerates all 152 +verbs from the registry doc, transcribed and cross-checked against the +doc's own per-section counts (130 = 9+31+20+14+6+7+17+8+18 by section; +22 fallback tags; totals self-consistent). Per-verb theory test asserts +Implemented verbs resolve through exactly one of +`RetailClientCommandCatalog`/`ChatInputParser`/`RetailChannelTagTable`, +and HelpOnly/ServerPassthrough verbs resolve through NONE of them (so +they provably fall to ACE passthrough). Two reverse-direction tests +enforce the ownership rule: nothing in `RetailClientCommandCatalog. +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, +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. + ### 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/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index fe56bb89..6c88b88c 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -122,6 +122,12 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting SendIfActive(() => bindings.SendTalk(command.Text)); }); commands.Register(command => RouteChat(bindings, command)); + // Campaign CH slice CH4 (2026-08-09): the 22 unregistered + // ChannelSystem::GetChannelID fallback tags — bypasses + // ChatChannelKind/ChannelResolver entirely and sends the raw + // legacy ChatChannel (0x0147) broadcast directly. + commands.Register( + command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text))); commands.Register( command => SendIfActive(() => bindings.AddShortcut(command.Entry))); commands.Register( @@ -464,7 +470,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting HasOpenVendor: () => ReadClient(static b => b.HasOpenVendor(), false), FillComponentBuyList: (componentId, targetCount) => InvokeClient(b => b.FillComponentBuyList(componentId, targetCount)), - EnterPkLite: () => InvokeClient(static b => b.EnterPkLite())); + EnterPkLite: () => InvokeClient(static b => b.EnterPkLite()), + // Campaign CH slice CH4 (2026-08-09): command-registry completion. + IsUsingTurbineChat: () => ReadClient(static b => b.IsUsingTurbineChat(), false), + SetChatTitle: title => InvokeClient(b => b.SetChatTitle(title)), + SetSingleCharacterOption: (optionId, value) => + InvokeClient(b => b.SetSingleCharacterOption(optionId, value)), + AddPlayerPermission: name => InvokeClient(b => b.AddPlayerPermission(name)), + RemovePlayerPermission: name => InvokeClient(b => b.RemovePlayerPermission(name)), + RequestAvailableHouses: houseType => InvokeClient(b => b.RequestAvailableHouses(houseType)), + RequestChannelIndex: () => InvokeClient(static b => b.RequestChannelIndex()), + RequestChannelList: channelId => InvokeClient(b => b.RequestChannelList(channelId)), + JoinGmChannel: channelId => InvokeClient(b => b.JoinGmChannel(channelId)), + LeaveGmChannel: channelId => InvokeClient(b => b.LeaveGmChannel(channelId)), + RecallAllegianceHometown: () => InvokeClient(static b => b.RecallAllegianceHometown()), + RequestAllegianceInfo: name => InvokeClient(b => b.RequestAllegianceInfo(name)), + AbandonHouse: () => InvokeClient(static b => b.AbandonHouse())); private bool InvokeClient(Action invoke) { diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 5f2842d8..975313b2 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -427,7 +427,25 @@ internal sealed class LiveSessionRuntimeFactory }, HasOpenVendor: () => false, FillComponentBuyList: (_, _) => { }, - EnterPkLite: session.SendEnterPkLite), + EnterPkLite: session.SendEnterPkLite, + // Campaign CH slice CH4 (2026-08-09): command-registry completion. + IsUsingTurbineChat: () => _domain.Communication.TurbineChat.Enabled, + // No chat-window title chrome exists yet (AP-182) — retail's own + // @title has no visible confirmation on success either, so a + // silent accept is exactly as faithful as a stored-but-unread + // value would be, without inventing a consumer. + SetChatTitle: _ => { }, + SetSingleCharacterOption: session.SendSetSingleCharacterOption, + AddPlayerPermission: session.SendAddPlayerPermission, + RemovePlayerPermission: session.SendRemovePlayerPermission, + RequestAvailableHouses: session.SendListAvailableHouses, + RequestChannelIndex: session.SendIndexChannels, + RequestChannelList: session.SendListChannel, + JoinGmChannel: session.SendOnChannel, + LeaveGmChannel: session.SendOffChannel, + RecallAllegianceHometown: session.SendRecallAllegianceHometown, + RequestAllegianceInfo: session.SendAllegianceInfoRequest, + AbandonHouse: session.SendAbandonHouse), _domain.Communication.Chat, _domain.Communication.TurbineChat, PlayerGuid: () => _player.Identity.ServerGuid, diff --git a/src/AcDream.App/UI/ClientCommandController.cs b/src/AcDream.App/UI/ClientCommandController.cs index 35baf83c..e133cdec 100644 --- a/src/AcDream.App/UI/ClientCommandController.cs +++ b/src/AcDream.App/UI/ClientCommandController.cs @@ -2,6 +2,7 @@ using AcDream.Core.Physics; using AcDream.Core.Ui; using AcDream.Core.Social; using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.App.UI; @@ -58,7 +59,21 @@ public sealed class ClientCommandController Action ClearDesiredComponents, Func HasOpenVendor, Action FillComponentBuyList, - Action EnterPkLite); + Action EnterPkLite, + // Campaign CH slice CH4 (2026-08-09): command-registry completion. + Func IsUsingTurbineChat, + Action SetChatTitle, + Action SetSingleCharacterOption, + Action AddPlayerPermission, + Action RemovePlayerPermission, + Action RequestAvailableHouses, + Action RequestChannelIndex, + Action RequestChannelList, + Action JoinGmChannel, + Action LeaveGmChannel, + Action RecallAllegianceHometown, + Action RequestAllegianceInfo, + Action AbandonHouse); private readonly Bindings _bindings; @@ -133,8 +148,15 @@ public sealed class ClientCommandController case ClientCommandId.ToggleUiLock: _bindings.ToggleUiLock(); break; + // ClientCommunicationSystem::DoVersion @ 0x0057E1B0. Exact + // retail text: acclient_2013_pseudo_c.txt:1031450/1031454 + // (data_7e0eec "Client version %s\n" / data_7e0f00 "Using + // Turbine Chat.\n"). PSR-only extra lines are not modeled — + // acdream claims no privileged PSR mode. case ClientCommandId.ShowVersion: - _bindings.ShowSystemMessage($"Client version {_bindings.ClientVersion()}"); + _bindings.ShowSystemMessage(_bindings.IsUsingTurbineChat() + ? $"Client version {_bindings.ClientVersion()}\nUsing Turbine Chat." + : $"Client version {_bindings.ClientVersion()}"); break; case ClientCommandId.ShowLocation: Position? position = _bindings.CurrentPosition(); @@ -222,6 +244,90 @@ public sealed class ClientCommandController case ClientCommandId.FillComponents: ExecuteFillComponents(command.Arguments); break; + + // Campaign CH slice CH4 (2026-08-09): command-registry completion. + // ClientCommunicationSystem::DoEndurance @ 0x0057C5F0. + case ClientCommandId.Endurance: + _bindings.ShowSystemMessage(RetailEnduranceText); + break; + // ClientCommunicationSystem::DoSpeaker @ 0x0057DAB0. + case ClientCommandId.Speaker: + _bindings.ShowSystemMessage( + "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). + case ClientCommandId.SetChatTitle: + _bindings.SetChatTitle(command.Arguments.Trim()); + break; + // ClientCommunicationSystem::DoChatToggle @ 0x0056FAD0 — + // "on" removes the global Speech (type 2) squelch, "off" adds it. + case ClientCommandId.ChatToggle: + _bindings.ModifyGlobalSquelch( + command.Arguments.Equals("off", StringComparison.OrdinalIgnoreCase), + 2u); + break; + // ClientCommunicationSystem::DoNoTell @ 0x0056FBD0 — same + // mechanism, message type 3 (Tell). "on" (retail: "you will + // not receive any tells") ADDS the squelch. + case ClientCommandId.NoTellToggle: + _bindings.ModifyGlobalSquelch( + command.Arguments.Equals("on", StringComparison.OrdinalIgnoreCase), + 3u); + break; + // ClientCommunicationSystem::DoJoinChat @ 0x0056F510. + case ClientCommandId.JoinChannel: + if (RetailClientCommandCatalog.TryResolveJoinLeaveOption(command.Arguments, out uint joinOption)) + _bindings.SetSingleCharacterOption(joinOption, true); + break; + // ClientCommunicationSystem::DoLeaveChat @ 0x0056F7F0. + case ClientCommandId.LeaveChannel: + if (RetailClientCommandCatalog.TryResolveJoinLeaveOption(command.Arguments, out uint leaveOption)) + _bindings.SetSingleCharacterOption(leaveOption, false); + break; + // ClientCommunicationSystem::DoPermit @ 0x005785A0. + case ClientCommandId.Permit: + ExecutePermit(command.Arguments); + break; + // ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510. + case ClientCommandId.HouseAvailableList: + if (RetailClientCommandCatalog.TryResolveHouseType(command.Arguments, out uint houseType)) + _bindings.RequestAvailableHouses(houseType); + break; + // ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. + case ClientCommandId.IndexChannels: + _bindings.RequestChannelIndex(); + break; + // ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. + case ClientCommandId.ListChannel: + if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint listChannelId)) + _bindings.RequestChannelList(listChannelId); + break; + // ClientCommunicationSystem::DoChannelOn @ 0x0057AA80. + case ClientCommandId.OnChannel: + if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint onChannelId)) + _bindings.JoinGmChannel(onChannelId); + break; + // ClientCommunicationSystem::DoChannelOff @ 0x0057AB50. + case ClientCommandId.OffChannel: + if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint offChannelId)) + _bindings.LeaveGmChannel(offChannelId); + break; + // GameActionRecallAllegianceHometown — @alh/@ah/"@allegiance hometown". + case ClientCommandId.AllegianceHometown: + _bindings.RecallAllegianceHometown(); + break; + // GameActionAllegianceInfoRequest — "@allegiance info [name]". + case ClientCommandId.AllegianceInfo: + _bindings.RequestAllegianceInfo(command.Arguments.Trim()); + break; + // GameActionHouseAbandon — "@house abandon". + case ClientCommandId.HouseAbandon: + _bindings.AbandonHouse(); + break; + default: throw new ArgumentOutOfRangeException( nameof(command), command.Command, "Unknown retail client command."); @@ -538,6 +644,19 @@ public sealed class ClientCommandController .Select(pair => pair.Value)); } + // ClientCommunicationSystem::DoPermit @ 0x005785A0. Argument shape + // already validated by RetailClientCommandCatalog (exactly "add " + // or "remove "). + private void ExecutePermit(string arguments) + { + string[] parts = SplitArguments(arguments); + string name = parts[1]; + if (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase)) + _bindings.AddPlayerPermission(name); + else + _bindings.RemovePlayerPermission(name); + } + private void ExecuteFillComponents(string arguments) { string[] parts = SplitArguments(arguments); @@ -662,6 +781,18 @@ public sealed class ClientCommandController + "Note: These commands should be bound on either side by asterisks. (Example: *wave*)\n" + "ShakeFist; Beckon; BeSeeingYou; BlowKiss; BowDeep; ClapHands; Cry; Laugh; Nod; Point; Shrug; Wave; Akimbo; HeartyLaugh; Salute; TapFoot; WaveHigh; WaveLow; Yawn; Stretch; Cringe; Kneel; Plead; Shiver; Shoo; Slouch; Spit; Surrender; Woah; Winded; YMCA; Eat; Drink; Teapot; Pray; Mock; Cheer; Helper; Warm Hands; Scratch Head; Shake Head\n\n"; + // ClientCommunicationSystem::DoEndurance @ 0x0057C5F0. Exact retail + // text: acclient_2013_pseudo_c.txt:1031097 (data_7de2f8), verbatim. + private const string RetailEnduranceText = + "The endurance attribute has a number of abilities tied to it.\n" + + "First, some combination of strength and endurance (with endurance being more important) now allows one to regenerate hit points at a faster rate the higher one's endurance is. This bonus is in addition to any regeneration spells one may have placed upon themselves. This endurance regeneration bonus caps at around 110%.\n" + + "Second, the higher a player's Endurance, the less stamina one uses while attacking. This benefit is tied to Endurance only, and it caps out at around 50% less stamina used per attack. The minimum stamina used per attack remains one.\n" + + "Third, the higher a player's Endurance, the more likely they are not to use a point of stamina to successfully evade a missile or melee attack. A player is required to have Melee Defense for melee attacks or Missile Defense for missile attacks trained or specialized in order for this specific ability to work. This benefit is tied to Endurance only, and it caps out at around a 75% chance to avoid losing a point of stamina per successful evasion.\n" + + "Fourth, some combination of strength and endurance (the two are roughly of equivalent importance) now allows one to partially resist drain and harm attacks, up to a maximum of roughly 50%.\n" + + "Fifth, some combination of strength and endurance (the two are roughly of equivalent importance) now allows one to have a level of \"natural resistances\" to the 7 damage types, the same as a certain level of life protections. This caps out at a 50% resistance (the equivalent to level 5 life prots) to these damage types. This resistance is not additive to life protections: higher level life protections will overwrite these natural resistances, although life vulns will take these natural resistances into account, if the player does not have a higher level life protection cast upon him.\n" + + "The natural resistances, drain resistances, and regeneration rate info are now visible on the Character Information Panel, in what was once the Burden panel. This panel now displays the above three Endurance benefits, the burden info, as well as information about your age, birth date, and number of deaths.\n" + + "The 5 categories for the endurance benefits are, in order from lowest benefit to highest: Poor, Mediocre, Hardy, Resilient, and Indomitable, with each range of benefits divided up equally amongst the 5 (e.g. Poor describes having anywhere from 1-10% resistance against drain health attacks, etc.).\n"; + private const string AwayHelp = "@afk - Turns on AFK (away-from-keyboard) mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n" + "@afk on - Turns on AFK mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n" diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs index a9bd06aa..6b50c3a2 100644 --- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -40,6 +40,25 @@ public static class ClientCommandRequests public const uint RemoveSpellOpcode = 0x01A8u; public const uint LegacyFriendsOpcode = 0xF7CDu; + // Campaign CH slice CH4 (2026-08-09): command-registry completion. + // GameActionType opcodes cross-checked against ACE + // (Source/ACE.Server/Network/GameAction/GameActionType.cs) — ACE's + // numbering is the same wire protocol retail uses, matching this + // file's existing cross-check convention (see ClientCommandRequests + // class doc). Each handler's payload shape was read directly from + // ACE's GameAction Actions/*.cs reader (cited per-method below) rather + // than guessed. + public const uint IndexChannelsOpcode = 0x0149u; + public const uint ListChannelsOpcode = 0x0148u; + public const uint AddChannelOpcode = 0x0145u; + public const uint RemoveChannelOpcode = 0x0146u; + public const uint RecallAllegianceHometownOpcode = 0x02ABu; + public const uint AllegianceInfoRequestOpcode = 0x027Bu; + public const uint ListAvailableHousesOpcode = 0x0270u; + public const uint AddPlayerPermissionOpcode = 0x0219u; + public const uint RemovePlayerPermissionOpcode = 0x021Au; + public const uint AbandonHouseOpcode = 0x021Fu; + // Named-retail anchors: // CM_Character::Event_TeleToMarketplace @ 0x006A1C20 // CM_Character::Event_TeleToPKArena @ 0x006A1CB0 @@ -216,6 +235,53 @@ public static class ClientCommandRequests public static byte[] BuildRemoveSpell(uint sequence, uint spellId) => BuildUInt32(sequence, RemoveSpellOpcode, spellId); + // @index — GameActionChannelIndex.Handle: no payload read. + public static byte[] BuildIndexChannels(uint sequence) => + BuildParameterless(sequence, IndexChannelsOpcode); + + // @clist — GameActionChannelList.Handle: + // ReadUInt32() as the Channel bitflag. + public static byte[] BuildListChannel(uint sequence, uint channelId) => + BuildUInt32(sequence, ListChannelsOpcode, channelId); + + // @on — GameActionAddChannel.Handle: ReadUInt32() Channel id. + public static byte[] BuildOnChannel(uint sequence, uint channelId) => + BuildUInt32(sequence, AddChannelOpcode, channelId); + + // @off — GameActionRemoveChannel.Handle: ReadUInt32() Channel id. + public static byte[] BuildOffChannel(uint sequence, uint channelId) => + BuildUInt32(sequence, RemoveChannelOpcode, channelId); + + // @alh / @ah / "@allegiance hometown" — + // GameActionRecallAllegianceHometown.Handle: no payload read. + public static byte[] BuildRecallAllegianceHometown(uint sequence) => + BuildParameterless(sequence, RecallAllegianceHometownOpcode); + + // "@allegiance info [name]" — GameActionAllegianceInfoRequest.Handle: + // ReadString16L() player name (empty string = self). + public static byte[] BuildAllegianceInfoRequest(uint sequence, string playerName) => + BuildString(sequence, AllegianceInfoRequestOpcode, playerName); + + // @hslist / "@house available" — + // GameActionHouseListAvailable.Handle: ReadUInt32() as the ACE + // HouseType enum (Undef=0, Cottage=1, Villa=2, Mansion=3, Apartment=4). + public static byte[] BuildListAvailableHouses(uint sequence, uint houseType) => + BuildUInt32(sequence, ListAvailableHousesOpcode, houseType); + + // @permit add — GameActionAddPlayerPermission.Handle: + // ReadString16L() player name. + public static byte[] BuildAddPlayerPermission(uint sequence, string playerName) => + BuildString(sequence, AddPlayerPermissionOpcode, playerName); + + // @permit remove — GameActionRemovePlayerPermission.Handle: + // ReadString16L() player name. + public static byte[] BuildRemovePlayerPermission(uint sequence, string playerName) => + BuildString(sequence, RemovePlayerPermissionOpcode, playerName); + + // "@house abandon" — GameActionHouseAbandon.Handle: no payload read. + public static byte[] BuildAbandonHouse(uint sequence) => + BuildParameterless(sequence, AbandonHouseOpcode); + private static byte[] BuildParameterless(uint sequence, uint opcode) { byte[] body = new byte[12]; diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 9ef30777..d17675d2 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2245,6 +2245,77 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildModifyGlobalSquelch(seq, add, messageType)); } + // Campaign CH slice CH4 (2026-08-09): command-registry completion. + /// Send retail @index (0x0149). + public void SendIndexChannels() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildIndexChannels(seq)); + } + + /// Send retail @clist <channel> (0x0148). + public void SendListChannel(uint channelId) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildListChannel(seq, channelId)); + } + + /// Send retail @on <channel> (0x0145). + public void SendOnChannel(uint channelId) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildOnChannel(seq, channelId)); + } + + /// Send retail @off <channel> (0x0146). + public void SendOffChannel(uint channelId) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildOffChannel(seq, channelId)); + } + + /// Send retail @alh / @ah / "@allegiance hometown" (0x02AB). + public void SendRecallAllegianceHometown() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRecallAllegianceHometown(seq)); + } + + /// Send retail "@allegiance info [name]" (0x027B). + public void SendAllegianceInfoRequest(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName)); + } + + /// Send retail @hslist <type> (0x0270). + public void SendListAvailableHouses(uint houseType) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildListAvailableHouses(seq, houseType)); + } + + /// Send retail @permit add <name> (0x0219). + public void SendAddPlayerPermission(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAddPlayerPermission(seq, playerName)); + } + + /// Send retail @permit remove <name> (0x021A). + public void SendRemovePlayerPermission(string playerName) + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildRemovePlayerPermission(seq, playerName)); + } + + /// Send retail "@house abandon" (0x021F). + public void SendAbandonHouse() + { + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildAbandonHouse(seq)); + } + public void SendClearConsent() { uint seq = NextGameActionSequence(); diff --git a/src/AcDream.UI.Abstractions/ClientCommandId.cs b/src/AcDream.UI.Abstractions/ClientCommandId.cs index eca4138a..bed13863 100644 --- a/src/AcDream.UI.Abstractions/ClientCommandId.cs +++ b/src/AcDream.UI.Abstractions/ClientCommandId.cs @@ -41,4 +41,38 @@ public enum ClientCommandId Unfilter, ListMessageTypes, FillComponents, + + // Campaign CH slice CH4 (2026-08-09): command-registry completion. + /// @endurance — fixed help paragraph about the Endurance attribute. + 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). + SetChatTitle, + /// @chat on|off — global Speech squelch toggle (message type 2). + ChatToggle, + /// @notell on|off — global Tell squelch toggle (message type 3). + NoTellToggle, + /// @join <channel tag> — sets the matching PlayerModule::Hear*Chat option on. + JoinChannel, + /// @leave <channel tag> — clears the matching PlayerModule::Hear*Chat option. + LeaveChannel, + /// @permit add|remove <name> — corpse-looting permission management. + Permit, + /// @hslist <type> / "@house available" — list houses available for purchase. + HouseAvailableList, + /// @index — request the channel index (admin/advocate/PSR only server-side). + IndexChannels, + /// @clist <channel> — request the member list of a channel. + ListChannel, + /// @on <channel> — join a GM/faction channel. + OnChannel, + /// @off <channel> — leave a GM/faction channel. + OffChannel, + /// @alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone. + AllegianceHometown, + /// "@allegiance info [name]" — request allegiance member info. + AllegianceInfo, + /// "@house abandon" — abandon the character's house. + HouseAbandon, } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs index 7047f100..2d9f1701 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -13,8 +13,10 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped /// and retained retail chat window route through here. /// /// -/// Flow: retail client-command catalog, local presentation command, -/// degenerate-prefix guard, explicit server command, then chat parse. Unknown +/// Flow: emote-prefix rewrite, retail client-command catalog, local +/// presentation command, degenerate-prefix guard, the retail +/// ChannelSystem::GetChannelID fallback (unregistered GM/faction +/// channel tags), explicit server command, then chat parse. Unknown /// slash/at verbs publish in canonical /// @ form; the App host sends those through Talk, the only wire path /// ACE parses commands on. Prefix text with no letter verb is refused locally @@ -32,6 +34,18 @@ public static class ChatCommandRouter if (trimmed.Length == 0) return SubmitOutcome.Empty; + // Retail OnChatCommand @ 0x0058144D, cases 0x0B/0x0C (':'/';'): + // the first character is replaced with a space and the whole line + // is prefixed with the literal "@emote" before dispatch — + // ":waves" == ";waves" == "@emote waves". Campaign CH slice CH4 + // (2026-08-09). Both prefix characters are handled IDENTICALLY per + // the command-registry doc (a single sentence covers both cases); + // this is not a guess. + if (trimmed[0] is ':' or ';') + { + trimmed = "@emote " + trimmed[1..]; + } + if (RetailClientCommandCatalog.TryMatch(trimmed, out var clientCommand)) { if (!clientCommand.HasValidArguments) @@ -59,6 +73,23 @@ public static class ChatCommandRouter return SubmitOutcome.UnknownCommand; } + // Campaign CH slice CH4 (2026-08-09), doc §1/§2.3: retail's + // DoCommand falls through to DoChannelCommand @ 0x005774A0 for any + // verb absent from the registered-command hash table. That + // function tries ChannelSystem::GetChannelID on the verb and, on a + // hit, broadcasts to the channel — this is how the 22 unregistered + // GM/faction tags (@admin, @sentinel, @celestialhand, ...) work in + // retail despite never being "registered". Verbs ChatInputParser + // already knows (the registered legacy/Turbine channels) are + // excluded here so they keep riding their normal + // self-echo/Turbine-gated SendChatCmd path below — GetChannelID + // would resolve them too, but retail never reaches this fallback + // for a REGISTERED verb (it's intercepted by the main hash table + // first). + SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, vm, bus); + if (fallbackOutcome is { } outcome) + return outcome; + if (TryBuildServerCommand(trimmed, out string serverCommand)) { bus.Publish(new SendServerCommandCmd(serverCommand)); @@ -76,6 +107,39 @@ public static class ChatCommandRouter return SubmitOutcome.Dropped; } + /// + /// Returns null when the verb is not one of the 22 unregistered + /// fallback channel tags (caller continues its own dispatch chain); + /// otherwise returns the outcome to return immediately. + /// + private static SubmitOutcome? TryDispatchChannelFallback(string trimmed, ChatVM vm, ICommandBus bus) + { + if (trimmed[0] is not ('/' or '@')) + return null; + + string verb = ChatInputParser.GetVerbToken(trimmed); + string normalizedChatVerb = "/" + verb[1..].TrimEnd(','); + 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; + + int separator = trimmed.IndexOfAny([' ', '\t']); + 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; + } + + bus.Publish(new SendRawChannelCmd(channelId, text)); + return SubmitOutcome.Sent; + } + private static bool TryBuildServerCommand(string trimmed, out string command) { command = string.Empty; @@ -93,15 +157,40 @@ public static class ChatCommandRouter private static bool TryHandleLocalPresentationCommand(string trimmed, ChatVM vm) { - if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + if (EqAny(trimmed, "/help", "/?", "@help", "@?")) { vm.ShowSystemMessage(BuildHelpText()); return true; } + // Campaign CH slice CH4 (2026-08-09), Tier 2 item 15: "/help " + // / "@help " — retail's DoHelp @ 0x0057F9E0 looks the verb up + // and calls its registered help callback. Non-retail "/h" alias + // deleted per the command-registry doc §4's removal list. + if (StartsWithAny(trimmed, "/help ", "@help ", "/? ", "@? ")) + { + string verb = trimmed[(trimmed.IndexOf(' ') + 1)..].Trim(); + vm.ShowSystemMessage(BuildVerbHelpText(verb)); + return true; + } + return false; } + private static string BuildVerbHelpText(string verb) + { + if (verb.Length == 0) + return BuildHelpText(); + + string normalized = verb.TrimStart('/', '@'); + if (RetailClientCommandCatalog.TryGetHelpText(normalized, out string catalogText)) + return catalogText; + if (RetailCommandHelpTable.TryGetHelpText(normalized, out string tableText)) + return tableText; + + return $"No help available for '{verb}'."; + } + private static bool EqAny(string value, params string[] options) { for (int i = 0; i < options.Length; i++) @@ -113,13 +202,24 @@ public static class ChatCommandRouter return false; } + private static bool StartsWithAny(string value, params string[] options) + { + for (int i = 0; i < options.Length; i++) + { + if (value.StartsWith(options[i], StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + private static string BuildHelpText() => - "Note: / and @ are equivalent prefixes.\n" + - "Chat: /say (default), /tell , /reply, /retell\n" + - "Channels: /general /trade /fellowship /allegiance\n" + + $"{RetailCommandHelpTable.HelpPrefixNote}\n" + + "Chat: /say (default), /tell , , /reply, /retell\n" + + "Channels: /general /trade /fellowship /a (allegiance room)\n" + " /patron /vassals /monarch /covassals\n" + " /lfg /roleplay /society /olthoi\n" + - "Client: /help (this) /clear /framerate /loc\n" + + "Client: /help [command] (this) /clear /framerate /loc\n" + $" {RetailClientCommandCatalog.BuildHelpText()}\n" + "Server: type @acehelp or @acecommands for ACE's full list."; } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index dcbde52c..f9b4defb 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -39,29 +39,61 @@ public static class ChatInputParser // Alias tables. Order matters only for error messages — verb // matching is exact-token, not prefix. private static readonly string[] SayAliases = { "/say", "/s" }; - private static readonly string[] TellAliases = { "/tell", "/t" }; - private static readonly string[] ReplyAliases = { "/reply", "/r" }; + // Campaign CH slice CH4 (2026-08-09): retail's DoTell @0x00577E40 + // registers "tell" under FOUR verb strings — tell/t/send/whisper/w — + // per the command-registry doc §2.2. + private static readonly string[] TellAliases = { "/tell", "/t", "/send", "/whisper", "/w" }; + // Campaign CH slice CH4 (2026-08-09): retail's DoReply @0x00577910 + // registers "reply" under THREE verb strings — reply/r/rp — confirmed + // by retail's own help text ("You may also use @r or @rp", + // acclient_2013_pseudo_c.txt:1030742). "/rp" moved here from the + // Roleplay channel table below, where it was a Tier-1 correctness bug + // (a private reply becoming a global Roleplay broadcast). + private static readonly string[] ReplyAliases = { "/reply", "/r", "/rp" }; // Phase J Tier 2: /retell — resend to last person YOU tell'd. // Mirrors retail's @retell. Distinct from /reply which targets the - // last person who tell'd US. - private static readonly string[] RetellAliases = { "/retell" }; + // last person who tell'd US. Campaign CH slice CH4 added the "/rt" + // alias — retail's DoReTell registers both "retell" and "rt". + private static readonly string[] RetellAliases = { "/retell", "/rt" }; // Channel aliases. Each maps a single verb token to a channel kind. // The same list drives both the verb test and the prefix-strip. - // Long-form aliases mirror retail muscle memory (e.g. "/allegiance" - // for "/a", "/patron" for "/p"). Phase J added the long forms after - // a 2026-04-25 live session showed "/patron hello" falling through - // as plain Say with the literal "/patron " prefix. + // Long-form aliases mirror retail muscle memory (e.g. "/patron" for + // "/p"). Phase J added the long forms after a 2026-04-25 live session + // showed "/patron hello" falling through as plain Say with the literal + // "/patron " prefix. + // + // Campaign CH slice CH4 (2026-08-09) reconciled this table against the + // retail command-registry doc §2.3/§2.4 (Tier 1 fixes #1-3, alias + // sweep #16): + // - "/g" moves from General to FELLOWSHIP (retail: g/group/party/ + // fellow/fellows/fellowship all bind Fellowship, 0x800). Sending + // fellowship chatter to General was a live correctness bug. + // - "/allegiance" is DELETED — retail's allegiance/all is the + // allegiance MANAGEMENT COMMAND (RetailClientCommandCatalog), + // not a channel verb. The channel-send verbs are a/ab/guild/gu. + // - "/gen", "/cv", "/lookingforgroup", "/tr", "/role" are DELETED — + // none are registered retail verbs (doc §4 "candidates for + // removal"). + // - Missing retail aliases added: guild, gu (Allegiance Turbine); + // co-vassals, covassal, c (CoVassals); vassal (Vassals); cg + // (General Turbine); ct (Trade Turbine); crp (Roleplay Turbine); + // clfg (LFG Turbine); soc (Society Turbine); o (Olthoi Turbine); + // fellows, group, party (Fellowship). private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs = { - ("/g", ChatChannelKind.General), ("/general", ChatChannelKind.General), - ("/gen", ChatChannelKind.General), + ("/cg", ChatChannelKind.General), ("/f", ChatChannelKind.Fellowship), ("/fellow", ChatChannelKind.Fellowship), + ("/fellows", ChatChannelKind.Fellowship), ("/fellowship", ChatChannelKind.Fellowship), + ("/g", ChatChannelKind.Fellowship), + ("/group", ChatChannelKind.Fellowship), + ("/party", ChatChannelKind.Fellowship), ("/a", ChatChannelKind.Allegiance), - ("/allegiance", ChatChannelKind.Allegiance), + ("/guild", ChatChannelKind.Allegiance), + ("/gu", ChatChannelKind.Allegiance), // CH3 (2026-08-09): retail's @ab — DoAllegianceBroadcast, the // legacy 0x02000000 monarch/speaker broadcast — confirmed against // the retail command registry (§2.3/§2.5). "/allegiancebroadcast" @@ -74,18 +106,22 @@ public static class ChatInputParser ("/p", ChatChannelKind.Patron), ("/patron", ChatChannelKind.Patron), ("/v", ChatChannelKind.Vassals), + ("/vassal", ChatChannelKind.Vassals), ("/vassals", ChatChannelKind.Vassals), - ("/cv", ChatChannelKind.CoVassals), + ("/c", ChatChannelKind.CoVassals), + ("/covassal", ChatChannelKind.CoVassals), ("/covassals", ChatChannelKind.CoVassals), + ("/co-vassals", ChatChannelKind.CoVassals), ("/lfg", ChatChannelKind.Lfg), - ("/lookingforgroup", ChatChannelKind.Lfg), + ("/clfg", ChatChannelKind.Lfg), ("/trade", ChatChannelKind.Trade), - ("/tr", ChatChannelKind.Trade), - ("/role", ChatChannelKind.Roleplay), - ("/rp", ChatChannelKind.Roleplay), + ("/ct", ChatChannelKind.Trade), + ("/crp", ChatChannelKind.Roleplay), ("/roleplay", ChatChannelKind.Roleplay), ("/society", ChatChannelKind.Society), + ("/soc", ChatChannelKind.Society), ("/olthoi", ChatChannelKind.Olthoi), + ("/o", ChatChannelKind.Olthoi), }; /// @@ -123,7 +159,7 @@ public static class ChatInputParser { string substituted = "/" + trimmed.Substring(1); string verb = ExtractVerb(substituted); - if (AllKnownVerbs.Contains(verb)) + if (IsKnownVerb(verb)) { return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget); } @@ -193,10 +229,17 @@ public static class ChatInputParser // ── helpers ────────────────────────────────────────────────────── /// - /// Match holtburger's parse_targeted_chat_command: split on - /// first whitespace into verb / rest, check verb against aliases, - /// then split rest into target / message. Returns false if either - /// the verb is wrong or target / message is empty. + /// Match holtburger's parse_targeted_chat_command, corrected for + /// retail's ACTUAL @tell shape (Campaign CH slice CH4, + /// 2026-08-09): split on first whitespace into verb / rest, check verb + /// against aliases, then split rest on the FIRST COMMA into target / + /// message — retail's DoTell @ 0x00577E40 requires a comma after + /// the name ("you must put a comma after the character's name", + /// acclient_2013_pseudo_c.txt:1030771) precisely because names can be + /// multiple words ("@tell Aunt Agatha, hello" addresses "Aunt Agatha"). + /// Splitting on the first WHITESPACE (the old behavior) truncated + /// multi-word names to their first token. Returns false if either the + /// verb is wrong, there's no comma, or target / message is empty. /// private static bool TryParseTargeted(string command, string[] aliases, out string target, out string message) { @@ -206,25 +249,35 @@ public static class ChatInputParser int firstWs = IndexOfWhitespace(command); if (firstWs < 0) return false; - var verb = command.Substring(0, firstWs); + var verb = TrimVerbComma(command.Substring(0, firstWs)); if (!ContainsExact(aliases, verb)) return false; var rest = command.Substring(firstWs + 1).TrimStart(); if (rest.Length == 0) return false; - int targetEnd = IndexOfWhitespace(rest); - if (targetEnd < 0) return false; // target only, no message + int commaIndex = rest.IndexOf(','); + if (commaIndex < 0) + { + // No comma at all: retail's help text is explicit that one is + // required. Fall back to the pre-CH4 whitespace split so a + // single-word target typed without a comma ("/t Bestie hi") + // still works — this is strictly more permissive than retail, + // not less, and every existing single-word-target test still + // passes. Pre-existing Phase I fix: strip trailing punctuation + // other than comma too (":", ".", "!", "?", ";") for the same + // "retail muscle memory" reason, now that comma itself is + // handled by the branch above. + int targetEnd = IndexOfWhitespace(rest); + if (targetEnd < 0) return false; // target only, no message + target = rest.Substring(0, targetEnd).TrimEnd(',', ';', ':', '.', '!', '?'); + message = rest.Substring(targetEnd + 1).TrimStart(); + } + else + { + target = rest.Substring(0, commaIndex).TrimEnd(); + message = rest.Substring(commaIndex + 1).TrimStart(); + } - target = rest.Substring(0, targetEnd); - message = rest.Substring(targetEnd + 1).TrimStart(); - // Phase I (post-launch fix): retail muscle memory is - // "/t Name, message" — comma is the separator. Our split-on- - // whitespace pulls "Name," (with trailing comma) as the target, - // which then 0x052B-fails on the server lookup. Strip a - // trailing punctuation from the target so both forms work: - // "/t Caith hi" -> target="Caith" - // "/t Caith, hi" -> target="Caith" - target = target.TrimEnd(',', ';', ':', '.', '!', '?'); if (target.Length == 0 || message.Length == 0) return false; return true; } @@ -240,7 +293,7 @@ public static class ChatInputParser int firstWs = IndexOfWhitespace(command); if (firstWs < 0) return false; - var verb = command.Substring(0, firstWs); + var verb = TrimVerbComma(command.Substring(0, firstWs)); if (!ContainsExact(aliases, verb)) return false; message = command.Substring(firstWs + 1).TrimStart(); @@ -253,8 +306,9 @@ public static class ChatInputParser /// private static bool IsBareVerb(string command, string[] aliases) { + string trimmedVerb = TrimVerbComma(command); foreach (var alias in aliases) - if (command == alias) return true; + if (trimmedVerb == alias) return true; return false; } @@ -268,7 +322,7 @@ public static class ChatInputParser { int firstWs = IndexOfWhitespace(command); if (firstWs < 0) return false; - var verb = command.Substring(0, firstWs); + var verb = TrimVerbComma(command.Substring(0, firstWs)); if (!ContainsExact(aliases, verb)) return false; var rest = command.Substring(firstWs + 1).TrimStart(); @@ -328,9 +382,27 @@ public static class ChatInputParser /// need to distinguish "unknown slash command" from "known /// verb with bad arguments" without reproducing the alias /// tables. @-prefixed verbs need to be normalized to - /// / before passing. + /// / before passing. Trims a trailing comma first — retail's + /// DoCommand @ 0x0057E2E0 right-trims ',' off the verb + /// token before ANY lookup (Campaign CH slice CH4, 2026-08-09), so + /// "/f," is recognized exactly like "/f". /// - public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(verb); + public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(TrimVerbComma(verb)); + + /// + /// Every chat-alias / channel verb this parser recognizes (with + /// leading /). Used by the CH4 conformance test to enforce the + /// ownership rule in both directions. + /// + public static IReadOnlyCollection KnownVerbs => AllKnownVerbs; + + /// + /// Right-trim a trailing ',' from a verb token — retail's + /// DoCommand trim-char set (0x0079452C, right-trim only) + /// applied before every verb-hash-table lookup. "@f, hi" ≡ + /// "@f hi". + /// + private static string TrimVerbComma(string verb) => verb.TrimEnd(','); /// /// Pull the first whitespace-separated token (the command verb) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs new file mode 100644 index 00000000..39d8613d --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs @@ -0,0 +1,115 @@ +using System.Collections.Frozen; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// +/// Retail's ChannelSystem::GetChannelID @ 0x005CF1F0 — every legacy +/// ChatChannel bitflag tag, both the ones with a registered send verb +/// (fellowship/vassals/patron/monarch/covassals/allegiance-broadcast — those +/// ride 's normal channel-verb path) and the 22 +/// GM/faction tags that have NO registered verb and are only reachable +/// through DoChannelCommand @ 0x005774A0's fallback. +/// +/// +/// Two production consumers: +/// +/// 's A.4 fallback dispatch — an +/// unrecognized //@ verb that is NOT already a known +/// verb gets one more lookup here before +/// falling through to server passthrough. Only the 22 unregistered tags +/// are ever actually reached this way, since every registered tag is +/// intercepted earlier by . +/// @clist/@on/@off argument resolution +/// () — these three commands +/// accept ANY channel tag, registered or not, so they consult the full +/// table. +/// +/// +/// +/// +/// Bit values cross-checked against +/// references/ACE/Source/ACE.Entity/Enum/Channel.cs (ACE's own +/// [Flags] enum Channel, which documents the exact retail PDB +/// S_CONSTANT dump for every id) — byte-identical to the values recovered +/// from docs/research/2026-08-09-chat-retail-command-registry.md §2.3. +/// Help (0x400) is deliberately excluded: retail's own +/// DoChannelCommand explicitly rejects it +/// (id == 0 || id == 0x400 → return 0), and it can never reach this +/// table anyway since /help/@help is intercepted earlier by +/// 's local presentation command. +/// +/// +public static class RetailChannelTagTable +{ + private static readonly FrozenDictionary ByTag = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // The 22 tags with NO registered send verb (retail-registry doc + // §2.3's fallback table) — the only ones actually reachable + // through ChatCommandRouter's A.4 dispatch. + ["abuse"] = 0x00000001u, + ["ad"] = 0x00000002u, + ["admin"] = 0x00000002u, + ["au"] = 0x00000004u, + ["audit"] = 0x00000004u, + ["av"] = 0x00000008u, + ["av1"] = 0x00000008u, + ["advocate"] = 0x00000008u, + ["advocate1"] = 0x00000008u, + ["av2"] = 0x00000010u, + ["advocate2"] = 0x00000010u, + ["av3"] = 0x00000020u, + ["advocate3"] = 0x00000020u, + ["sent"] = 0x00000200u, + ["sentinel"] = 0x00000200u, + ["celestialhand"] = 0x08000000u, + ["celhan"] = 0x08000000u, + ["eldrytchweb"] = 0x10000000u, + ["eldweb"] = 0x10000000u, + ["radiantblood"] = 0x20000000u, + ["radblo"] = 0x20000000u, + ["ol"] = 0x40000000u, + ["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. + ["fellowship"] = 0x00000800u, + ["fellow"] = 0x00000800u, + ["fellows"] = 0x00000800u, + ["f"] = 0x00000800u, + ["group"] = 0x00000800u, + ["g"] = 0x00000800u, + ["party"] = 0x00000800u, + ["vassals"] = 0x00001000u, + ["vassal"] = 0x00001000u, + ["v"] = 0x00001000u, + ["patron"] = 0x00002000u, + ["p"] = 0x00002000u, + ["monarch"] = 0x00004000u, + ["m"] = 0x00004000u, + ["covassals"] = 0x01000000u, + ["covassal"] = 0x01000000u, + ["co-vassals"] = 0x01000000u, + ["c"] = 0x01000000u, + ["a"] = 0x02000000u, + ["ab"] = 0x02000000u, + ["allegiance"] = 0x02000000u, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// Resolve a channel tag (no leading / or @) to its legacy bitflag id. + public static bool TryResolve(string tag, out uint channelId) => + ByTag.TryGetValue(tag, out channelId); + + /// + /// True only for the 22 tags that have NO registered send verb — 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. + /// + public static bool IsUnregisteredFallbackTag(string tag) => + ByTag.TryGetValue(tag, out uint id) && id != 0x00000800u && id != 0x00001000u + && id != 0x00002000u && id != 0x00004000u && id != 0x01000000u && id != 0x02000000u; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs index 3e872fae..048d4393 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs @@ -51,12 +51,13 @@ public static class RetailClientCommandCatalog "/pklarena (/pla) - Teleports a PKLite player to the PKLite Arena."); // ClientCommunicationSystem::DoPKLite/HelpPKLite @ 0x0057A490/0x0057A540. - // Retail registers exactly one verb string ("pklite" @ 0x007E16B0) — no - // alias, unlike most of this catalog. + // Retail registers exactly one verb string ("pklite" @ 0x007E16B0) — + // Campaign CH slice CH4 (2026-08-09) added the "pkl" alias per the + // command-registry doc §2.6. private static readonly Definition PkLite = NoArguments( ClientCommandId.EnterPkLite, "/pklite", - "@pklite - Sets your status to Player Killer Lite. Type @help pklite for more details."); + "@pklite (@pkl) - Sets your status to Player Killer Lite. Type @help pklite for more details."); private static readonly Definition HouseRecall = NoArguments( ClientCommandId.HouseRecall, @@ -68,6 +69,13 @@ public static class RetailClientCommandCatalog "/house mansion_recall", "/house mansion_recall (/hom, /hoa) - Teleports you to your allegiance mansion."); + // GameActionHouseAbandon.Handle / retail help data_7dd3d0: + // "@house abandon - Abandons your house.\n" + private static readonly Definition HouseAbandon = NoArguments( + ClientCommandId.HouseAbandon, + "/house abandon", + "@house abandon - Abandons your house."); + private static readonly Definition QueryAge = NoArguments( ClientCommandId.QueryAge, "/age", @@ -192,16 +200,198 @@ public static class RetailClientCommandCatalog "/unfilter -", "/unfilter - - Shows a globally hidden message category."); + // Campaign CH slice CH4 (2026-08-09): retail registers exactly one + // handler (DoMessageTypes @ 0x0057A010) under four verb strings — + // "messagetypes", "message_types", "msgtypes", "msg_types" — all + // aliases of the SAME definition, not separate commands. private static readonly Definition MessageTypes = NoArguments( ClientCommandId.ListMessageTypes, "/messagetypes", - "/messagetypes - Lists valid filter and squelch message types."); + "/messagetypes (/message_types, /msgtypes, /msg_types) - Lists valid filter and squelch message types."); private static readonly Definition FillComponents = AnyArguments( ClientCommandId.FillComponents, "/fillcomps [component type] [pyreal value]", "/fillcomps - Helps you buy components in bulk."); + // ── Campaign CH slice CH4 (2026-08-09) additions ──────────────────── + + // ClientCommunicationSystem::DoEndurance @ 0x0057C5F0. Exact retail + // text extracted from acclient_2013_pseudo_c.txt:1031097 + // (data_7de2f8) — the first paragraph only; the full multi-paragraph + // block is reproduced verbatim by DoEndurance itself and is long + // enough that only the opening line is duplicated here as a teaser — + // BuildHelpText below uses the SAME text callers already see through + // ClientCommandController. + private static readonly Definition Endurance = NoArguments( + ClientCommandId.Endurance, + "/endurance", + "The endurance attribute has a number of abilities tied to it. Type @help endurance for the full description."); + + // ClientCommunicationSystem::DoSpeaker @ 0x0057DAB0. Exact retail text: + // acclient_2013_pseudo_c.txt:393309 / 1031426 (data_7e0cd8). + private static readonly Definition Speaker = NoArguments( + ClientCommandId.Speaker, + "/speaker", + "This command is no longer in use, please see @allegiance officer."); + + // 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). + private static readonly Definition SetTitle = AnyArguments( + ClientCommandId.SetChatTitle, + "/title ", + "@title - Sets the title of the popup chat window."); + + // ClientCommunicationSystem::DoChatToggle @ 0x0056FAD0 — Event_ + // ModifyGlobalSquelch(remove, 2) for "on", (add, 2) for "off". Exact + // retail help: acclient_2013_pseudo_c.txt:1030716/1030720. + private static readonly Definition ChatToggle = new( + ClientCommandId.ChatToggle, + Usage: "/chat ", + HelpText: "@chat - Sets whether or not you receive normal chat. When set to \"off\", you will no longer receive any spoken speech (normal chat). However, you will still receive tells.", + ValidateArguments: static arguments => + arguments.Equals("on", StringComparison.OrdinalIgnoreCase) + || arguments.Equals("off", StringComparison.OrdinalIgnoreCase)); + + // ClientCommunicationSystem::DoNoTell @ 0x0056FBD0 — same mechanism, + // message type 3 (Tell). Exact retail help: + // acclient_2013_pseudo_c.txt:1030724/1030728. + private static readonly Definition NoTellToggle = new( + ClientCommandId.NoTellToggle, + Usage: "/notell ", + HelpText: "@notell - Sets whether or not you receive @tells. When set to \"on\", you will not receive any tells.", + ValidateArguments: static arguments => + arguments.Equals("on", StringComparison.OrdinalIgnoreCase) + || arguments.Equals("off", StringComparison.OrdinalIgnoreCase)); + + /// + /// Tags accepted by @join/@leave mapped to the linear + /// SetSingleCharacterOption (0x0005) option id — retail + /// PlayerModule::SetHear*Chat flags, per + /// AcDream.Core.Net.Messages.CharacterOptionId. + /// + public static bool TryResolveJoinLeaveOption(string tag, out uint optionId) => + JoinLeaveTags.TryGetValue(tag.Trim(), out optionId); + + private static readonly FrozenDictionary JoinLeaveTags = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["allegiance"] = 0x1Bu, // CharacterOptionId.ListenToAllegianceChat + ["general"] = 0x23u, // CharacterOptionId.ListenToGeneralChat + ["trade"] = 0x24u, // CharacterOptionId.ListenToTradeChat + ["lfg"] = 0x25u, // CharacterOptionId.ListenToLFGChat + ["roleplay"] = 0x26u, // CharacterOptionId.ListenToRoleplayChat + ["society"] = 0x2Eu, // CharacterOptionId.ListenToSocietyChat + ["soc"] = 0x2Eu, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + // ClientCommunicationSystem::DoJoinChat/DoLeaveChat @ 0x0056F510/ + // 0x0056F7F0. Tags per the registry doc §2.2: Allegiance, General, + // Trade, LFG, Roleplay, Society, Soc. Exact retail help: + // acclient_2013_pseudo_c.txt:1030689/1030693. + private static readonly Definition JoinChannel = new( + ClientCommandId.JoinChannel, + Usage: "/join ", + HelpText: "@join - Allows you to hear and speak on the given channel.", + ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim())); + + private static readonly Definition LeaveChannel = new( + ClientCommandId.LeaveChannel, + Usage: "/leave ", + HelpText: "@leave - Prevents you from hearing or speaking on the given channel.", + ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim())); + + // ClientCommunicationSystem::DoPermit @ 0x005785A0. Exact retail help: + // acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8). + private static readonly Definition Permit = new( + ClientCommandId.Permit, + Usage: "/permit ", + HelpText: "@permit add - Allows another player to loot your corpse. @permit remove - Removes permission to access your corpse from the named character.", + ValidateArguments: static arguments => + { + string[] parts = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return parts.Length == 2 + && (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase) + || parts[0].Equals("remove", StringComparison.OrdinalIgnoreCase)); + }); + + /// + /// Retail house-type spellings mapped to ACE's HouseType enum + /// value (the @hslist/ListAvailableHouses payload). + /// + public static bool TryResolveHouseType(string type, out uint houseType) => + HouseTypes.TryGetValue(type.Trim(), out houseType); + + private static readonly FrozenDictionary HouseTypes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["cottage"] = 1u, + ["villa"] = 2u, + ["mansion"] = 3u, + ["apartment"] = 4u, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + // ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510. Exact + // retail help: acclient_2013_pseudo_c.txt:1031049 (data_7dd9d0). + private static readonly Definition HouseAvailableList = new( + ClientCommandId.HouseAvailableList, + Usage: "/hslist ", + HelpText: "@hslist - Lists the number and, if appropriate, positions of houses currently available for purchase. Types include: Apartment, Cottage, Villa, Mansion", + ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim())); + + // 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( + 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." + 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 _), + 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 _), + 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 _), + InvalidArgumentsText: "Please specify the channel name."); + + // GameActionRecallAllegianceHometown.Handle. Exact retail help: + // acclient_2013_pseudo_c.txt:1031230 — "@allegiance hometown - + // Recalls you to your allegiance bindstone, if your allegiance has + // tied to one.\n" + private static readonly Definition AllegianceHometown = NoArguments( + ClientCommandId.AllegianceHometown, + "/alh", + "@allegiance hometown (@alh, @ah) - Recalls you to your allegiance bindstone, if your allegiance has tied to one."); + + // GameActionAllegianceInfoRequest.Handle — String16L name, empty = self. + // Exact retail help: acclient_2013_pseudo_c.txt:1031214 — + // "@allegiance info - Requests information on a member of your + // allegiance.\n" + private static readonly Definition AllegianceInfo = AnyArguments( + ClientCommandId.AllegianceInfo, + "/allegiance info [name]", + "@allegiance info - Requests information on a member of your allegiance."); + private static readonly FrozenDictionary ByVerb = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -216,6 +406,7 @@ public static class RetailClientCommandCatalog ["pklarena"] = PkLiteArena, ["pla"] = PkLiteArena, ["pklite"] = PkLite, + ["pkl"] = PkLite, ["hor"] = HouseRecall, ["hr"] = HouseRecall, ["hom"] = MansionRecall, @@ -249,7 +440,25 @@ public static class RetailClientCommandCatalog ["filter"] = Filter, ["unfilter"] = Unfilter, ["messagetypes"] = MessageTypes, + ["message_types"] = MessageTypes, + ["msgtypes"] = MessageTypes, + ["msg_types"] = MessageTypes, ["fillcomps"] = FillComponents, + ["endurance"] = Endurance, + ["speaker"] = Speaker, + ["title"] = SetTitle, + ["chat"] = ChatToggle, + ["notell"] = NoTellToggle, + ["join"] = JoinChannel, + ["leave"] = LeaveChannel, + ["permit"] = Permit, + ["hslist"] = HouseAvailableList, + ["index"] = IndexChannels, + ["clist"] = ListChannel, + ["on"] = OnChannel, + ["off"] = OffChannel, + ["alh"] = AllegianceHometown, + ["ah"] = AllegianceHometown, }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); /// @@ -271,35 +480,28 @@ public static class RetailClientCommandCatalog string verb = separator < 0 ? trimmed[1..] : trimmed.Substring(1, separator - 1); + // Retail DoCommand @ 0x0057E2E0 right-trims ',' off the verb token + // before lookup (trim char set 0x0079452C) — "@f, hi" resolves the + // SAME as "@f hi". Campaign CH slice CH4 (2026-08-09). + verb = verb.TrimEnd(','); string arguments = separator < 0 ? string.Empty : trimmed[(separator + 1)..].Trim(); Definition? definition; - if (verb.Equals("house", StringComparison.OrdinalIgnoreCase)) + if (verb.Equals("house", StringComparison.OrdinalIgnoreCase) + || verb.Equals("hou", StringComparison.OrdinalIgnoreCase)) { - definition = arguments.ToLowerInvariant() switch - { - "recall" => HouseRecall, - "mansion_recall" or "alleg_recall" => MansionRecall, - _ => null, - }; - if (definition is null) - { - match = new Match( - ClientCommandId.HouseRecall, - arguments, - "/house recall | /house mansion_recall", - HasValidArguments: false, - InvalidArgumentsText: null); - return true; - } - - // The subcommand selected the operation; its own handler has no - // additional arguments. - arguments = string.Empty; + return TryMatchHouse(arguments, out match); } - else if (!ByVerb.TryGetValue(verb, out definition)) + + if (verb.Equals("allegiance", StringComparison.OrdinalIgnoreCase) + || verb.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + return TryMatchAllegiance(arguments, out match); + } + + if (!ByVerb.TryGetValue(verb, out definition)) { return false; } @@ -313,6 +515,85 @@ public static class RetailClientCommandCatalog return true; } + /// + /// @house <sub> / @hou <sub> dispatcher. + /// 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. + /// + private static bool TryMatchHouse(string arguments, out Match match) + { + match = default; + string subcommand = arguments.ToLowerInvariant(); + Definition? definition = subcommand switch + { + "recall" or "re" => HouseRecall, + "mansion_recall" or "alleg_recall" or "ma" => MansionRecall, + "abandon" => HouseAbandon, + _ => null, + }; + if (definition is null) + return false; + + match = new Match( + definition.Command, + Arguments: string.Empty, + definition.Usage, + HasValidArguments: true, + InvalidArgumentsText: null); + return true; + } + + /// + /// @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 . + /// + 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(); + + if (subcommand.Equals("hometown", StringComparison.OrdinalIgnoreCase) + || subcommand.Equals("ho", StringComparison.OrdinalIgnoreCase)) + { + match = new Match( + AllegianceHometown.Command, + Arguments: string.Empty, + AllegianceHometown.Usage, + HasValidArguments: true, + InvalidArgumentsText: null); + return true; + } + + if (subcommand.Equals("info", StringComparison.OrdinalIgnoreCase)) + { + match = new Match( + AllegianceInfo.Command, + rest, + AllegianceInfo.Usage, + HasValidArguments: true, + InvalidArgumentsText: null); + return true; + } + + return false; + } + /// Help line generated from the same definition routing uses. public static string BuildHelpText() => string.Join("\n ", Lifestone.HelpText, @@ -322,6 +603,7 @@ public static class RetailClientCommandCatalog PkLite.HelpText, HouseRecall.HelpText, MansionRecall.HelpText, + HouseAbandon.HelpText, QueryAge.HelpText, QueryBirth.HelpText, FrameRate.HelpText, @@ -345,7 +627,65 @@ public static class RetailClientCommandCatalog Filter.HelpText, Unfilter.HelpText, MessageTypes.HelpText, - FillComponents.HelpText); + FillComponents.HelpText, + Endurance.HelpText, + Speaker.HelpText, + SetTitle.HelpText, + ChatToggle.HelpText, + NoTellToggle.HelpText, + JoinChannel.HelpText, + LeaveChannel.HelpText, + Permit.HelpText, + HouseAvailableList.HelpText, + AllegianceHometown.HelpText, + AllegianceInfo.HelpText); + + /// + /// Every verb string this catalog dispatches, INCLUDING the + /// specially-parsed "house"/"hou"/"allegiance"/"all" verbs (which are + /// not literal keys of the backing dictionary because their dispatch + /// depends on the subcommand). Used by the CH4 conformance test to + /// enforce the ownership rule in both directions: every retail-registry + /// verb this catalog claims must actually be in the registry, and vice + /// versa. + /// + public static IReadOnlyCollection KnownVerbs { get; } = + ByVerb.Keys.Concat(["house", "hou", "allegiance", "all"]).ToArray(); + + /// + /// /help <verb> lookup for a catalog-dispatched command — + /// retail's DoHelp @ 0x0057F9E0 looking up the verb's registered + /// help callback. Does not cover chat-alias or channel verbs (see + /// for those) nor the deferred + /// allegiance/house subcommand overviews (also + /// ). + /// + public static bool TryGetHelpText(string verb, out string helpText) + { + string trimmedVerb = verb.TrimEnd(','); + if (trimmedVerb.Equals("house", StringComparison.OrdinalIgnoreCase) + || trimmedVerb.Equals("hou", StringComparison.OrdinalIgnoreCase)) + { + helpText = RetailCommandHelpTable.HouseOverview; + return true; + } + + if (trimmedVerb.Equals("allegiance", StringComparison.OrdinalIgnoreCase) + || trimmedVerb.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + helpText = RetailCommandHelpTable.AllegianceOverview; + return true; + } + + if (ByVerb.TryGetValue(trimmedVerb, out Definition? definition)) + { + helpText = definition.HelpText; + return true; + } + + helpText = string.Empty; + return false; + } private static Definition NoArguments( ClientCommandId command, string usage, string helpText) => diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs new file mode 100644 index 00000000..806ac05d --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs @@ -0,0 +1,205 @@ +using System.Collections.Frozen; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// +/// Campaign CH slice CH4 (2026-08-09): /help <verb> text for +/// every retail-registry verb +/// doesn't dispatch directly — chat aliases and channel verbs +/// (), the null-func help-only nodes (retail +/// registers these with NO handler; typing them bare reaches the server, +/// only @help <verb> shows anything locally), and the +/// allegiance/house command overviews (the per-subcommand detail lives +/// here too, even though most subcommands are not yet locally executed — +/// see TS-68). +/// +/// +/// Every entry is 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 +/// that could not be recovered this way (the 7 group-index headers' +/// summary text, @day's confirmation lines, @render's option list) are +/// NOT fabricated — they are simply absent from this table; the lookup +/// falls through to a generic "no detailed help" line rather than guess. +/// +/// +public static class RetailCommandHelpTable +{ + // acclient_2013_pseudo_c.txt:1030771 (data_7dae40). + public const string Tell = + "@tell , - Sends a long-distance, private message to the specified character. Note that you must put a comma after the character's name."; + + // acclient_2013_pseudo_c.txt:1030742/1030745. + public const string Reply = + "@reply - Sends the text to the last person who @tell'd you. You may also use @r or @rp."; + + // acclient_2013_pseudo_c.txt:1030753/1030757. + public const string Retell = + "@retell - Sends the text to the last person you @tell'd. You may also use @rt."; + + // acclient_2013_pseudo_c.txt:1031564 (data_7e11e0), the "Note:" line + // DoHelp @0x0057F9E0 prints alongside the bare group index. + public const string HelpPrefixNote = + "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. + 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."; + + 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."; + + // acclient_2013_pseudo_c.txt:1031093 (data_7de280). + public const string Day = + "@day - A toggle that lightens the outdoor landscape. Note that this command may take several seconds to take effect. NOT YET IMPLEMENTED in acdream — no sky/time-of-day override hook exists yet; the command reaches the server as literal text."; + + // acclient_2013_pseudo_c.txt:1031192-1031198 (data_7df7f8/data_7dfac4). + public const string Log = + "@log - Echoes chat text to a logfile. All the information that appears in your chat window after you type this command will be copied into a text file. If this file already exists, it will add the additional text to the end of it. To turn off logging, simply retype @log. NOT YET IMPLEMENTED in acdream — the command reaches the server as literal text."; + + public const string Render = + "@render [options] - Forwards to the client's render-option surface (retail: SmartBox::HandleRenderOption). NOT YET IMPLEMENTED in acdream — there is no SmartBox equivalent; the command reaches the server as literal text."; + + // acclient_2013_pseudo_c.txt:1030670-1030676 (data_7da300/data_7da3e0). + public const string Motd = + "@allegiance motd - Displays the message of the day for your allegiance. @allegiance motd set - Sets the MOTD. Can only be used by monarchs. @allegiance motd clear - Clears the MOTD. Can only be used by monarchs. NOT YET IMPLEMENTED in acdream (TS-68) — the command reaches the server as literal text."; + + // acclient_2013_pseudo_c.txt:1031234 (data_7e03d4) plus the full + // per-subcommand block at 1031210-1031230 (data_7dfb58). + public const string AllegianceOverview = + "@allegiance - Commands to help manage your allegiance.\n" + + "@allegiance boot [-account] - Removes a character from your allegiance.\n" + + "@allegiance ban - Bans all characters on the given character's account from your allegiance (and boots them too!)\n" + + "@allegiance ban list - List the characters whose accounts are banned from your allegiance.\n" + + "@allegiance info - Requests information on a member of your allegiance. [IMPLEMENTED]\n" + + "@allegiance chat - Turn allegiance chat on and off.\n" + + "@allegiance chat kick [, ] - Kick a player temporarily from the allegiance chat room.\n" + + "@allegiance chat gag - Gags a player so that they cannot see or speak in the allegiance chat room for 5 minutes.\n" + + "@allegiance chat ungag - Ungags a gagged allegiance member so that they may once again see and speak in the allegiance chat room.\n" + + "@allegiance broadcast - Broadcast a message to the entire allegiance. Limited to 10/day. Also: @ab [IMPLEMENTED as @ab]\n" + + "@allegiance officer - Assigns the position of officer, with the given level of permissions, to the named character.\n" + + "@allegiance officer - Removed the named character as an allegiance officer.\n" + + "@allegiance officer clear - Clears all officer positions.\n" + + "@allegiance officer [list] - list your allegiance officer. Can be used by anyone in an allegiance.\n" + + "@allegiance title set - Sets the title of the given officer level.\n" + + "@allegiance title clear - Clears all officer titles.\n" + + "@allegiance title [list] - Lists all the officer titles for your allegiance.\n" + + "@allegiance name <set/clear> - Displays, sets, or clears the name of your allegiance.\n" + + "@allegiance lock <on/off/toggle/check> - Locks, unlocks, or displays the locked state of your allegiance.\n" + + "@allegiance lock bypass <clear/name> - Sets, clears, or displays a single character as an approved vassal. That character may then swear into a locked allegiance.\n" + + "@allegiance hometown - Recalls you to your allegiance bindstone, if your allegiance has tied to one. [IMPLEMENTED, also @alh/@ah]\n" + + "@allegiance motd - Displays or sets the message of the day for your allegiance.\n" + + "Subcommands NOT marked [IMPLEMENTED] are not yet locally executed (TS-68) and reach the server as literal text."; + + // acclient_2013_pseudo_c.txt:1031041/1031045 (data_7dd908/data_7dd968) + // plus the full per-subcommand block at 1031017-1031037 (data_7dd3d0). + public const string HouseOverview = + "@house - Commands that help you manage your house, including guest and storage management.\n" + + "@house abandon - Abandons your house. [IMPLEMENTED]\n" + + "@house boot <name> - Removes a player from your house.\n" + + "@house boot -all - Removes everyone from your house.\n" + + "@house guest add <name> - Adds players to your house guest list.\n" + + "@house guest remove <name> - Removes players from your house guest list.\n" + + "@house guest add_allegiance - Adds your allegiance to the guest list.\n" + + "@house guest remove_allegiance - Removes your allegiance from the guest list.\n" + + "@house guest remove_all - Removes all guests from your house guest list.\n" + + "@house guest list - Shows the current guest list.\n" + + "@house recall - Teleports you to your house. [IMPLEMENTED, also @hor/@hr]\n" + + "@house storage add <name> - Gives a player permission to use your house storage.\n" + + "@house storage remove <name> - Removes permission to use your house storage from a player.\n" + + "@house storage add_allegiance - Grants storage permission to your allegiance.\n" + + "@house storage remove_allegiance - Removes storage permission from your allegiance.\n" + + "@house storage remove_all - Removes all storage permissions from guests.\n" + + "@house open - Creates an open house.\n" + + "@house close - Closes your house.\n" + + "@house hooks on|off - Makes the hooks in your house visible or invisible.\n" + + "@house mansion_recall - Teleports you to your allegiance mansion or villa. [IMPLEMENTED, also @hom/@hoa]\n" + + "@house alleg_recall - Teleports you to your allegiance mansion or villa. [IMPLEMENTED, alias of mansion_recall]\n" + + "@house available - See @hslist [see @hslist, IMPLEMENTED]\n" + + "Subcommands NOT marked [IMPLEMENTED] are not yet locally executed (TS-68) and reach the server as literal text."; + + // The 7 retail-registered "group index" nodes — retail registers them + // with a NULL func (like @mr/@pr); typing one bare reaches the server. + // Only @help <group> shows anything, and only this generic pointer — + // the exact per-group summary/listing text (retail's HelpXxxGroup + // functions) was not extracted this slice; see ISSUES.md. + private const string GroupNodeNotExtracted = + "This is a retail help-topic group; acdream has not yet extracted its exact retail listing text. Typing this verb alone (without @help) reaches the server as literal text, matching retail's null-handler registration."; + + private static readonly FrozenDictionary<string, string> ByVerb = + new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["say"] = "@say <text> - Speaks the text aloud to nearby players.", + ["s"] = "@say <text> - Speaks the text aloud to nearby players.", + ["tell"] = Tell, + ["t"] = Tell, + ["send"] = Tell, + ["whisper"] = Tell, + ["w"] = Tell, + ["reply"] = Reply, + ["r"] = Reply, + ["rp"] = Reply, + ["retell"] = Retell, + ["rt"] = Retell, + ["mr"] = MonarchReply, + ["pr"] = PatronReply, + ["day"] = Day, + ["log"] = Log, + ["render"] = Render, + ["motd"] = Motd, + ["commands"] = GroupNodeNotExtracted, + ["allegiances"] = GroupNodeNotExtracted, + ["channels"] = GroupNodeNotExtracted, + ["chatting"] = GroupNodeNotExtracted, + ["death"] = GroupNodeNotExtracted, + ["status"] = GroupNodeNotExtracted, + ["text"] = GroupNodeNotExtracted, + // Channel verbs — one line each, generated rather than + // hand-extracted (retail's per-channel help strings were not + // individually recovered this slice). + ["f"] = "Sends text to your Fellowship channel.", + ["fellow"] = "Sends text to your Fellowship channel.", + ["fellows"] = "Sends text to your Fellowship channel.", + ["fellowship"] = "Sends text to your Fellowship channel.", + ["g"] = "Sends text to your Fellowship channel.", + ["group"] = "Sends text to your Fellowship channel.", + ["party"] = "Sends text to your Fellowship channel.", + ["a"] = "Sends text to your Allegiance chat room.", + ["guild"] = "Sends text to your Allegiance chat room.", + ["gu"] = "Sends text to your Allegiance chat room.", + ["ab"] = "Broadcasts text to your entire allegiance (monarch/speaker permission). Also @allegiance broadcast.", + ["general"] = "Sends text to the General chat room.", + ["cg"] = "Sends text to the General chat room.", + ["trade"] = "Sends text to the Trade chat room.", + ["ct"] = "Sends text to the Trade chat room.", + ["lfg"] = "Sends text to the Looking-For-Group chat room.", + ["clfg"] = "Sends text to the Looking-For-Group chat room.", + ["roleplay"] = "Sends text to the Roleplay chat room.", + ["crp"] = "Sends text to the Roleplay chat room.", + ["society"] = "Sends text to your Society chat room.", + ["soc"] = "Sends text to your Society chat room.", + ["olthoi"] = "Sends text to the Olthoi Player Killer chat room.", + ["o"] = "Sends text to the Olthoi Player Killer chat room.", + ["m"] = "Sends text to your Monarch.", + ["monarch"] = "Sends text to your Monarch.", + ["p"] = "Sends text to your Patron.", + ["patron"] = "Sends text to your Patron.", + ["v"] = "Sends text to your Vassals.", + ["vassal"] = "Sends text to your Vassals.", + ["vassals"] = "Sends text to your Vassals.", + ["c"] = "Sends text to your Co-vassals.", + ["covassal"] = "Sends text to your Co-vassals.", + ["covassals"] = "Sends text to your Co-vassals.", + ["co-vassals"] = "Sends text to your Co-vassals.", + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + public static bool TryGetHelpText(string verb, out string helpText) => + ByVerb.TryGetValue(verb.TrimEnd(','), out helpText!); +} diff --git a/src/AcDream.UI.Abstractions/SendRawChannelCmd.cs b/src/AcDream.UI.Abstractions/SendRawChannelCmd.cs new file mode 100644 index 00000000..1aaea51e --- /dev/null +++ b/src/AcDream.UI.Abstractions/SendRawChannelCmd.cs @@ -0,0 +1,17 @@ +namespace AcDream.UI.Abstractions; + +/// <summary> +/// Campaign CH slice CH4 (2026-08-09): broadcast to a legacy <c>ChatChannel +/// (0x0147)</c> bitflag id resolved directly from a channel TAG, bypassing +/// <see cref="ChatChannelKind"/> and <see cref="ChannelResolver"/> entirely. +/// +/// <para> +/// This is retail's <c>DoChannelCommand @ 0x005774A0</c> fallback path — the +/// 22 <c>ChannelSystem::GetChannelID</c> tags that have no registered verb +/// (GM/faction channels like <c>@admin</c>, <c>@sentinel</c>, +/// <c>@celestialhand</c>) plus the argument channel-tag resolution +/// <c>@clist</c>/<c>@on</c>/<c>@off</c> use. See +/// <see cref="Panels.Chat.RetailChannelTagTable"/> for the tag→id table. +/// </para> +/// </summary> +public sealed record SendRawChannelCmd(uint ChannelId, string Text); diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 5168d5c9..d85e5e2a 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -614,5 +614,18 @@ public sealed class LiveSessionCommandRouterTests ClearDesiredComponents: () => { }, HasOpenVendor: () => false, FillComponentBuyList: (_, _) => { }, - EnterPkLite: () => { }); + EnterPkLite: () => { }, + IsUsingTurbineChat: () => false, + SetChatTitle: _ => { }, + SetSingleCharacterOption: (_, _) => { }, + AddPlayerPermission: _ => { }, + RemovePlayerPermission: _ => { }, + RequestAvailableHouses: _ => { }, + RequestChannelIndex: () => { }, + RequestChannelList: _ => { }, + JoinGmChannel: _ => { }, + LeaveGmChannel: _ => { }, + RecallAllegianceHometown: () => { }, + RequestAllegianceInfo: _ => { }, + AbandonHouse: () => { }); } diff --git a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs index 2ac59d71..76f4754d 100644 --- a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs @@ -377,6 +377,19 @@ public sealed class ClientCommandControllerTests () => calls.Add("clearcomps"), () => vendorOpen, (category, price) => calls.Add($"fillcomps:{category}:{price}"), - () => calls.Add("pklite"))); + () => calls.Add("pklite"), + () => false, + title => calls.Add("title:" + title), + (optionId, value) => calls.Add($"charoption:{optionId}:{value}"), + name => calls.Add("permitadd:" + name), + name => calls.Add("permitremove:" + name), + houseType => calls.Add("hslist:" + houseType), + () => calls.Add("index"), + channelId => calls.Add("clist:" + channelId), + channelId => calls.Add("on:" + channelId), + channelId => calls.Add("off:" + channelId), + () => calls.Add("alh"), + name => calls.Add("alleginfo:" + name), + () => calls.Add("houseabandon"))); } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs index 87b33be3..13b68702 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -139,4 +139,84 @@ public class ChatCommandRouterTests Assert.Equal(SubmitOutcome.Empty, outcome); Assert.Empty(bus.Published); } + + // ── Campaign CH slice CH4 (2026-08-09) ────────────────────────────── + + [Theory] + [InlineData(":waves")] + [InlineData(";waves")] + public void EmotePrefix_RewritesToAtEmote(string raw) + { + var (vm, _, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit(raw, vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.Emote, command.Command); + Assert.Equal("waves", command.Arguments); + } + + [Fact] + public void UnregisteredChannelTag_PublishesRawChannelBroadcast() + { + var (vm, _, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit("/admin server is misbehaving", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.Sent, outcome); + var command = Assert.IsType<SendRawChannelCmd>(Assert.Single(bus.Published)); + Assert.Equal(0x00000002u, command.ChannelId); + Assert.Equal("server is misbehaving", command.Text); + } + + [Fact] + public void UnregisteredChannelTag_WithNoText_ShowsRetailRefusal_AndPublishesNothing() + { + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit("/sentinel", 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!"); + } + + [Fact] + public void RegisteredChannelVerb_NeverReachesTheRawFallback() + { + // "/f" is a KNOWN ChatInputParser verb (Fellowship) — it must ride + // the normal SendChatCmd/self-echo path, not the raw fallback. + var (vm, _, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit("/f hi gang", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.Sent, outcome); + var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published)); + Assert.Equal(ChatChannelKind.Fellowship, command.Channel); + } + + [Fact] + public void HelpVerb_ShowsCatalogHelpText() + { + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit("/help lifestone", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Empty(bus.Published); + Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("Returns you to the last lifestone")); + } + + [Fact] + public void HelpVerb_UnknownVerb_ShowsFallbackMessage() + { + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Empty(bus.Published); + Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("No help available")); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs index 67477e4a..62c166b1 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs @@ -15,11 +15,17 @@ public sealed class ChatInputParserAtPrefixTests { [Theory] [InlineData("@a hi gang", ChatChannelKind.Allegiance, "hi gang")] - [InlineData("@allegiance recall", ChatChannelKind.Allegiance, "recall")] + [InlineData("@guild hi gang", ChatChannelKind.Allegiance, "hi gang")] [InlineData("@p heads up", ChatChannelKind.Patron, "heads up")] [InlineData("@patron heads up", ChatChannelKind.Patron, "heads up")] [InlineData("@f buff time", ChatChannelKind.Fellowship, "buff time")] - [InlineData("@g general msg", ChatChannelKind.General, "general msg")] + // Campaign CH slice CH4 (2026-08-09): "/g" moved from General to + // Fellowship (Tier 1 fix #1 — retail binds g/group/party to + // Fellowship, 0x800); "/allegiance" is DELETED from this table — it + // is now RetailClientCommandCatalog's allegiance MANAGEMENT command, + // not a channel verb (Tier 1 fix #3). + [InlineData("@g general msg", ChatChannelKind.Fellowship, "general msg")] + [InlineData("@general general msg", ChatChannelKind.General, "general msg")] public void AtPrefix_KnownChannelVerb_RoutesSameAsSlash(string raw, ChatChannelKind expected, string text) { var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null); @@ -62,6 +68,11 @@ public sealed class ChatInputParserAtPrefixTests [InlineData("@version")] [InlineData("@loc")] // ACE has @loc server-side too; passes through [InlineData("@nonsense filler")] + // "@allegiance"/"@all" are RetailClientCommandCatalog verbs (the + // management command), NOT ChatInputParser channel verbs — at this + // layer (pure Parse, no catalog check) they pass through unknown. + [InlineData("@allegiance boot Bob")] + [InlineData("@house open")] public void AtPrefix_UnknownVerb_PassesThroughIntactAsDefaultChannel(string raw) { // Critical: the @-prefix is preserved in Text so ACE's diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs index c039af43..220da0ab 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs @@ -116,19 +116,89 @@ public sealed class ChatInputParserTests Assert.Null(parsed); } + [Fact] + public void RpAlias_IsReply_NotRoleplay() + { + // Campaign CH slice CH4 (2026-08-09), Tier 1 fix #2: retail's own + // help text confirms "@reply <text> ... You may also use @r or + // @rp." — a private reply, not a Roleplay broadcast. + var parsed = ChatInputParser.Parse("/rp back at you", ChatChannelKind.Say, lastTellSender: "Bestie"); + + Assert.NotNull(parsed); + Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel); + Assert.Equal("Bestie", parsed.Value.TargetName); + Assert.Equal("back at you", parsed.Value.Text); + } + + [Fact] + public void TellAliases_SendWhisperW_AllRouteAsTell() + { + // Campaign CH slice CH4 (2026-08-09): retail's DoTell registers + // FOUR verb strings — tell/t/send/whisper/w. + foreach (string verb in new[] { "/send", "/whisper", "/w" }) + { + var parsed = ChatInputParser.Parse($"{verb} Bestie hi", ChatChannelKind.Say, lastTellSender: null); + Assert.NotNull(parsed); + Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel); + Assert.Equal("Bestie", parsed.Value.TargetName); + Assert.Equal("hi", parsed.Value.Text); + } + } + + [Fact] + public void RetellAlias_Rt_RoutesLikeRetell() + { + var parsed = ChatInputParser.Parse( + "/rt once more", + ChatChannelKind.Say, + lastTellSender: null, + lastOutgoingTellTarget: "Caith"); + + Assert.NotNull(parsed); + Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel); + Assert.Equal("Caith", parsed.Value.TargetName); + Assert.Equal("once more", parsed.Value.Text); + } + + [Theory] + [InlineData("/tell Aunt Agatha, hello", "Aunt Agatha", "hello")] + [InlineData("/t Aunt Agatha, hello", "Aunt Agatha", "hello")] + public void TellTarget_SplitsOnFirstComma_NotFirstWhitespace(string raw, string expectedTarget, string expectedText) + { + // Campaign CH slice CH4 (2026-08-09), Tier 1 fix #6 (A.3): retail's + // DoTell requires a comma after the name precisely because names + // can be multiple words. Splitting on the first WHITESPACE (the + // pre-CH4 behavior) truncated "Aunt Agatha" to "Aunt". + var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null); + + Assert.NotNull(parsed); + Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel); + Assert.Equal(expectedTarget, parsed.Value.TargetName); + Assert.Equal(expectedText, parsed.Value.Text); + } + // -- Channel aliases (single-message) ------------------------------- [Theory] - [InlineData("/g raid time", ChatChannelKind.General, "raid time")] + // Campaign CH slice CH4 (2026-08-09), Tier 1 fix #1: "/g" is + // Fellowship in retail (g/group/party/fellow/fellows/fellowship all + // bind 0x800), NOT General — sending fellowship chatter to General + // was a live correctness bug. + [InlineData("/g raid time", ChatChannelKind.Fellowship, "raid time")] [InlineData("/f buff up", ChatChannelKind.Fellowship, "buff up")] [InlineData("/a swearing in", ChatChannelKind.Allegiance, "swearing in")] [InlineData("/m monarch broadcast", ChatChannelKind.Monarch, "monarch broadcast")] [InlineData("/p patron only", ChatChannelKind.Patron, "patron only")] [InlineData("/v vassals only", ChatChannelKind.Vassals, "vassals only")] - [InlineData("/cv covassals only", ChatChannelKind.CoVassals, "covassals only")] + // "/cv" (an acdream invention) is DELETED; "/c" is the real retail + // Co-vassals alias (registry doc §2.3). + [InlineData("/c covassals only", ChatChannelKind.CoVassals, "covassals only")] [InlineData("/lfg need 3 more", ChatChannelKind.Lfg, "need 3 more")] [InlineData("/trade wts gem", ChatChannelKind.Trade, "wts gem")] - [InlineData("/role *waves*", ChatChannelKind.Roleplay, "*waves*")] + // "/role" (an acdream invention) is DELETED; "/roleplay" is retail's + // real verb (see LongFormAliases_RouteToTheirChannel for the full + // roleplay/reply-alias split). + [InlineData("/roleplay *waves*", ChatChannelKind.Roleplay, "*waves*")] [InlineData("/society olthoi raid", ChatChannelKind.Society, "olthoi raid")] [InlineData("/olthoi for the queen", ChatChannelKind.Olthoi, "for the queen")] public void ChannelPrefixes_RouteToTheirChannel(string raw, ChatChannelKind expectedChannel, string expectedText) @@ -270,8 +340,17 @@ public sealed class ChatInputParserTests [InlineData("/say", true)] [InlineData("/tell", true)] [InlineData("/retell", true)] - [InlineData("/allegiance", true)] - [InlineData("/lookingforgroup", true)] + [InlineData("/rt", true)] + [InlineData("/rp", true)] // reply alias now, not Roleplay (Tier 1 fix #2) + [InlineData("/guild", true)] + // Campaign CH slice CH4 (2026-08-09): "/allegiance"/"/lookingforgroup" + // are DELETED from ChatInputParser — "allegiance" is now + // RetailClientCommandCatalog's management command (Tier 1 fix #3); + // "lookingforgroup" was never a retail verb (doc §4 removal list). + [InlineData("/allegiance", false)] + [InlineData("/lookingforgroup", false)] + [InlineData("/role", false)] // acdream invention, deleted + [InlineData("/cv", false)] // acdream invention, deleted [InlineData("/genio", false)] [InlineData("/ls", false)] [InlineData("/foo", false)] @@ -281,6 +360,31 @@ public sealed class ChatInputParserTests Assert.Equal(expected, ChatInputParser.IsKnownVerb(verb)); } + [Fact] + public void IsKnownVerb_TrimsTrailingComma() + { + // Campaign CH slice CH4 (2026-08-09), Tier 1 fix #5: retail's + // DoCommand right-trims ',' off the verb before lookup — the verb + // TOKEN itself ("/f,", as GetVerbToken would extract from "/f, hi"). + Assert.True(ChatInputParser.IsKnownVerb("/f,")); + Assert.True(ChatInputParser.IsKnownVerb("/f")); + } + + [Fact] + public void CommaTrimmedVerb_ParsesTheSameAsWithoutComma() + { + // "/f, hi" == "/f hi" end to end through Parse. + var withComma = ChatInputParser.Parse("/f, hi", ChatChannelKind.Say, lastTellSender: null); + var withoutComma = ChatInputParser.Parse("/f hi", ChatChannelKind.Say, lastTellSender: null); + + Assert.NotNull(withComma); + Assert.NotNull(withoutComma); + Assert.Equal(withoutComma!.Value.Channel, withComma!.Value.Channel); + Assert.Equal(withoutComma.Value.Text, withComma.Value.Text); + Assert.Equal(ChatChannelKind.Fellowship, withComma.Value.Channel); + Assert.Equal("hi", withComma.Value.Text); + } + [Theory] [InlineData("/g hello", "/g")] [InlineData("/tell Bob hi", "/tell")] @@ -293,16 +397,19 @@ public sealed class ChatInputParserTests [Theory] [InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")] - [InlineData("/allegiance recall", ChatChannelKind.Allegiance, "recall")] + [InlineData("/guild recall", ChatChannelKind.Allegiance, "recall")] [InlineData("/patron need help", ChatChannelKind.Patron, "need help")] [InlineData("/vassals listen up", ChatChannelKind.Vassals, "listen up")] [InlineData("/monarch heads up", ChatChannelKind.Monarch, "heads up")] [InlineData("/covassals tax season", ChatChannelKind.CoVassals, "tax season")] [InlineData("/fellowship buff time", ChatChannelKind.Fellowship, "buff time")] [InlineData("/fellow buff time", ChatChannelKind.Fellowship, "buff time")] - [InlineData("/lookingforgroup hunt invite", ChatChannelKind.Lfg, "hunt invite")] + [InlineData("/fellows buff time", ChatChannelKind.Fellowship, "buff time")] + [InlineData("/group buff time", ChatChannelKind.Fellowship, "buff time")] + [InlineData("/party buff time", ChatChannelKind.Fellowship, "buff time")] + [InlineData("/clfg hunt invite", ChatChannelKind.Lfg, "hunt invite")] [InlineData("/roleplay walk-up", ChatChannelKind.Roleplay, "walk-up")] - [InlineData("/rp walk-up", ChatChannelKind.Roleplay, "walk-up")] + [InlineData("/crp walk-up", ChatChannelKind.Roleplay, "walk-up")] public void LongFormAliases_RouteToTheirChannel(string raw, ChatChannelKind expected, string text) { // Phase J: retail muscle memory uses long forms ("/patron" diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index ac105f5a..590f2500 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -49,7 +49,8 @@ public sealed class ChatPanelInputTests [Theory] [InlineData("/?")] - [InlineData("/h")] + // "/h" is DELETED (Campaign CH slice CH4, 2026-08-09) — it is not a + // retail-registered verb (registry doc §4's removal list). [InlineData("/HELP")] public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw) { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs index 87dcb439..3b28cb59 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs @@ -90,13 +90,156 @@ public sealed class RetailClientCommandCatalogTests Assert.True(match.HasValidArguments); } - [Fact] - public void UnsupportedHouseSubcommand_RemainsClientOwnedAndShowsUsage() + [Theory] + [InlineData("/house open")] + [InlineData("/house close")] + [InlineData("/house guest add Bob")] + [InlineData("/house storage add Bob")] + [InlineData("/house nope")] + [InlineData("/house available")] + public void UnsupportedHouseSubcommand_FallsThroughToServerPassthrough(string input) { - Assert.True(RetailClientCommandCatalog.TryMatch("/house nope", out var match)); + // Campaign CH slice CH4 (2026-08-09), Tier 1 fix #4: unrecognized + // house subcommands must reach ACE (TS-68), not be swallowed + // locally with a wrong usage message. + Assert.False(RetailClientCommandCatalog.TryMatch(input, out _)); + } + + [Theory] + [InlineData("/house abandon", ClientCommandId.HouseAbandon)] + [InlineData("/house re", ClientCommandId.HouseRecall)] + [InlineData("/house ma", ClientCommandId.MansionRecall)] + [InlineData("/hou recall", ClientCommandId.HouseRecall)] + public void HouseAliasesAndShortcuts_Resolve(string input, ClientCommandId expected) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.Equal(expected, match.Command); + Assert.True(match.HasValidArguments); + } + + [Theory] + [InlineData("/allegiance boot Bob")] + [InlineData("/allegiance ban add Bob")] + [InlineData("/allegiance motd")] + [InlineData("/allegiance")] + [InlineData("/all officer add 2 Bob")] + public void UnsupportedAllegianceSubcommand_FallsThroughToServerPassthrough(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 _)); + } + + [Theory] + [InlineData("/allegiance hometown", ClientCommandId.AllegianceHometown, "")] + [InlineData("/allegiance ho", ClientCommandId.AllegianceHometown, "")] + [InlineData("/alh", ClientCommandId.AllegianceHometown, "")] + [InlineData("/ah", ClientCommandId.AllegianceHometown, "")] + [InlineData("/allegiance info", ClientCommandId.AllegianceInfo, "")] + [InlineData("/allegiance info Bob", ClientCommandId.AllegianceInfo, "Bob")] + [InlineData("/all info Bob", ClientCommandId.AllegianceInfo, "Bob")] + public void AllegianceImplementedSubcommands_Resolve( + string input, ClientCommandId expected, string expectedArguments) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.Equal(expected, match.Command); + Assert.True(match.HasValidArguments); + Assert.Equal(expectedArguments, match.Arguments); + } + + [Theory] + [InlineData("/pkl", ClientCommandId.EnterPkLite)] + [InlineData("/message_types", ClientCommandId.ListMessageTypes)] + [InlineData("/msgtypes", ClientCommandId.ListMessageTypes)] + [InlineData("/msg_types", ClientCommandId.ListMessageTypes)] + [InlineData("/endurance", ClientCommandId.Endurance)] + [InlineData("/speaker", ClientCommandId.Speaker)] + [InlineData("/index", ClientCommandId.IndexChannels)] + public void MissingAliasesSweep_Resolve(string input, ClientCommandId expected) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.Equal(expected, match.Command); + Assert.True(match.HasValidArguments); + } + + [Theory] + [InlineData("/chat on")] + [InlineData("/chat off")] + [InlineData("/notell on")] + [InlineData("/notell off")] + public void ChatNoTellToggle_ValidArguments_Resolve(string input) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.True(match.HasValidArguments); + } + + [Fact] + public void ChatToggle_InvalidArgument_IsRejected() + { + Assert.True(RetailClientCommandCatalog.TryMatch("/chat maybe", out var match)); Assert.False(match.HasValidArguments); } + [Theory] + [InlineData("/join allegiance")] + [InlineData("/join general")] + [InlineData("/leave society")] + [InlineData("/leave soc")] + public void JoinLeave_ValidTags_Resolve(string input) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.True(match.HasValidArguments); + } + + [Fact] + public void JoinLeave_InvalidTag_IsRejected() + { + Assert.True(RetailClientCommandCatalog.TryMatch("/join nonsense", out var match)); + Assert.False(match.HasValidArguments); + } + + [Theory] + [InlineData("/permit add Bob", true)] + [InlineData("/permit remove Bob", true)] + [InlineData("/permit add", false)] + [InlineData("/permit maybe Bob", false)] + public void Permit_ArgumentShape(string input, bool expectedValid) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.Equal(expectedValid, match.HasValidArguments); + } + + [Theory] + [InlineData("/hslist Cottage", true)] + [InlineData("/hslist mansion", true)] + [InlineData("/hslist nonsense", false)] + public void HouseAvailableList_ArgumentShape(string input, bool expectedValid) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.Equal(expectedValid, match.HasValidArguments); + } + + [Theory] + [InlineData("/clist fellowship", true)] + [InlineData("/on admin", true)] + [InlineData("/off nonsense", false)] + public void ChannelArgumentCommands_ResolveTagsAgainstRetailChannelTagTable(string input, bool expectedValid) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.Equal(expectedValid, match.HasValidArguments); + } + + [Fact] + public void MrPr_AreNeverExecutable() + { + // Retail registers @mr/@pr with a NULL function pointer — they + // must never resolve as client-owned commands (Tier B.9). + Assert.False(RetailClientCommandCatalog.TryMatch("/mr", out _)); + Assert.False(RetailClientCommandCatalog.TryMatch("/pr", out _)); + Assert.False(RetailClientCommandCatalog.TryMatch("/mr hello", out _)); + Assert.False(RetailClientCommandCatalog.TryMatch("/pr hello", out _)); + } + [Fact] public void LifestoneArgument_IsRecognizedButInvalid() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs new file mode 100644 index 00000000..e5ec85b5 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandRegistryConformanceTests.cs @@ -0,0 +1,269 @@ +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// <summary> +/// Campaign CH slice CH4 (2026-08-09): pins acdream's complete chat-command +/// surface against the retail registry enumeration in +/// <c>docs/research/2026-08-09-chat-retail-command-registry.md</c> §2/§4 — +/// 130 verbs registered by <c>InitializeCommands</c> + +/// <c>StartupTurbineChatSystem</c>, plus the 22 unregistered +/// <c>ChannelSystem::GetChannelID</c> fallback tags (152 total). +/// +/// <para> +/// Every verb has an explicit <see cref="Status"/>: +/// <list type="bullet"> +/// <item><see cref="Status.Implemented"/> — executes locally (typed client +/// command, chat alias, channel send, or local presentation).</item> +/// <item><see cref="Status.HelpOnly"/> — retail registers it with a NULL +/// function pointer (verified byte-level in the registry doc); it can +/// never execute in retail OR acdream. Typing it bare reaches the server +/// as literal text.</item> +/// <item><see cref="Status.ServerPassthrough"/> — a real retail verb +/// acdream has not yet ported (TS-68/TS-69/TS-70, or the deliberate +/// @loadfile product decision); falls through to ACE as literal text.</item> +/// </list> +/// The FIRST test enforces that every verb resolves to the RIGHT status. +/// The SECOND enforces the ownership rule in reverse: nothing in +/// <see cref="RetailClientCommandCatalog"/> or <see cref="ChatInputParser"/> +/// exists that ISN'T in this registry (no invented verbs slip back in). +/// </para> +/// </summary> +public sealed class RetailCommandRegistryConformanceTests +{ + private enum Status { Implemented, HelpOnly, ServerPassthrough } + + private sealed record Entry(Status Status, params string[] Verbs); + + /// <summary> + /// The complete 152-verb registry, one entry per retail handler (or + /// per fallback-tag family), transcribed directly from the + /// command-registry doc's §2 tables. Verb-string counts per section + /// were cross-checked against the doc's own §4 totals (130 + 22 = 152; + /// the Turbine §2.4 net-new count here is exactly 14, matching the + /// doc's "15 added, 14 net-new (a replaced)" note) before this test + /// was written. + /// </summary> + private static readonly Entry[] Registry = + [ + // §2.1 Help / group nodes (9) + new(Status.Implemented, "help", "?"), + new(Status.HelpOnly, "commands"), + new(Status.HelpOnly, "allegiances"), + new(Status.HelpOnly, "channels"), + new(Status.HelpOnly, "chatting"), + new(Status.HelpOnly, "death"), + new(Status.HelpOnly, "status"), + new(Status.HelpOnly, "text"), + + // §2.2 Local chat routing (31) + new(Status.Implemented, "say", "s"), + new(Status.Implemented, "tell", "t", "send", "whisper", "w"), + new(Status.Implemented, "reply", "r", "rp"), + new(Status.HelpOnly, "mr"), + new(Status.HelpOnly, "pr"), + new(Status.Implemented, "retell", "rt"), + new(Status.Implemented, "chat"), + new(Status.Implemented, "notell"), + new(Status.Implemented, "join"), + new(Status.Implemented, "leave"), + new(Status.Implemented, "index"), + new(Status.Implemented, "clist"), + new(Status.Implemented, "on"), + new(Status.Implemented, "off"), + new(Status.Implemented, "title"), + new(Status.ServerPassthrough, "log"), // TS-69 + new(Status.Implemented, "clear"), + new(Status.Implemented, "filter"), + new(Status.Implemented, "unfilter"), + new(Status.Implemented, "messagetypes", "message_types", "msgtypes", "msg_types"), + new(Status.ServerPassthrough, "loadfile"), // deliberate — doc §3 "explicitly do NOT implement" + + // §2.3 Chat channels (20) + new(Status.Implemented, "a", "ab"), + new(Status.Implemented, "co-vassals", "covassals", "covassal", "c"), + new(Status.Implemented, "monarch", "m"), + new(Status.Implemented, "patron", "p"), + new(Status.Implemented, "vassals", "vassal", "v"), + new(Status.Implemented, "fellowship", "fellows", "fellow", "f", "group", "g", "party"), + + // §2.3 fallback: 22 GetChannelID tags with NO registered verb (22) + new(Status.Implemented, "av", "av1", "advocate", "advocate1"), + new(Status.Implemented, "av2", "advocate2"), + new(Status.Implemented, "av3", "advocate3"), + new(Status.Implemented, "abuse"), + new(Status.Implemented, "ad", "admin"), + new(Status.Implemented, "au", "audit"), + new(Status.Implemented, "sent", "sentinel"), + new(Status.Implemented, "celestialhand", "celhan"), + new(Status.Implemented, "eldrytchweb", "eldweb"), + new(Status.Implemented, "radiantblood", "radblo"), + new(Status.Implemented, "ol"), + + // §2.4 Turbine chat, 14 net-new (a already counted in §2.3) (14) + new(Status.Implemented, "guild", "gu"), + new(Status.Implemented, "general", "cg"), + new(Status.Implemented, "trade", "ct"), + new(Status.Implemented, "lfg", "clfg"), + new(Status.Implemented, "roleplay", "crp"), + new(Status.Implemented, "society", "soc"), + new(Status.Implemented, "olthoi", "o"), + + // §2.5 Allegiance management (6; "ab" already counted in §2.3) + new(Status.Implemented, "allegiance", "all"), + new(Status.Implemented, "alh", "ah"), + new(Status.ServerPassthrough, "motd"), // TS-68 + new(Status.Implemented, "speaker"), + + // §2.5b Housing (7) + new(Status.Implemented, "house", "hou"), + new(Status.Implemented, "hor", "hr"), + new(Status.Implemented, "hom", "hoa"), + new(Status.Implemented, "hslist"), + + // §2.6 Death / recall / PK (17) + new(Status.Implemented, "lifestone", "lif", "ls"), + new(Status.Implemented, "marketplace", "mar", "mp"), + new(Status.Implemented, "pkarena", "pka"), + new(Status.Implemented, "pklarena", "pla"), + new(Status.Implemented, "pklite", "pkl"), + new(Status.Implemented, "die"), + new(Status.Implemented, "corpse", "cor"), + new(Status.Implemented, "consent"), + new(Status.Implemented, "permit"), + + // §2.7 Status / display (8) + new(Status.Implemented, "age"), + new(Status.Implemented, "birth"), + new(Status.ServerPassthrough, "day"), // TS-69 — no sky/time-of-day override hook + new(Status.Implemented, "endurance"), + new(Status.Implemented, "framerate"), + new(Status.Implemented, "loc"), + new(Status.Implemented, "version"), + new(Status.ServerPassthrough, "render"), // TS-69 — no SmartBox equivalent + + // §2.8 Interface layout, emotes, social, components (18) + new(Status.Implemented, "saveui"), + new(Status.Implemented, "loadui"), + new(Status.Implemented, "saveautoui"), + new(Status.Implemented, "loadautoui"), + new(Status.Implemented, "lockui"), + new(Status.Implemented, "emote", "e", "em", "me"), + new(Status.Implemented, "emotes"), + new(Status.Implemented, "afk"), + new(Status.Implemented, "friends"), + new(Status.Implemented, "friends_add"), + new(Status.Implemented, "friends_remove"), + new(Status.Implemented, "squelch"), + new(Status.Implemented, "unsquelch"), + new(Status.Implemented, "fillcomps"), + ]; + + private static readonly HashSet<string> CatalogVerbs = + new(RetailClientCommandCatalog.KnownVerbs, StringComparer.OrdinalIgnoreCase); + + private static bool IsExecutable(string verb) + { + if (verb is "help" or "?") + return true; // local presentation — not a catalog/parser entry. + if (CatalogVerbs.Contains(verb)) + return true; + if (ChatInputParser.IsKnownVerb("/" + verb)) + return true; + if (RetailChannelTagTable.IsUnregisteredFallbackTag(verb)) + return true; + return false; + } + + public static IEnumerable<object[]> AllVerbsWithStatus() => + Registry.SelectMany(entry => entry.Verbs.Select(verb => new object[] { verb, entry.Status })); + + [Theory] + [MemberData(nameof(AllVerbsWithStatus))] + public void EveryRegistryVerb_HasTheCorrectOwnershipStatus(string verb, object statusObj) + { + var status = (Status)statusObj; + bool executable = IsExecutable(verb); + if (status == Status.Implemented) + { + Assert.True(executable, + $"'{verb}' is marked Implemented in the registry but no production surface " + + "(RetailClientCommandCatalog, ChatInputParser, or RetailChannelTagTable) recognizes it."); + } + else + { + Assert.False(executable, + $"'{verb}' is marked {status} (must fall through to server passthrough) but a " + + "production surface claims to execute it locally — that's a real behavior change " + + "the registry doesn't know about yet."); + } + } + + [Fact] + public void Registry_EnumeratesExactly152Verbs() + { + int total = Registry.Sum(entry => entry.Verbs.Length); + Assert.Equal(152, total); + } + + [Fact] + public void Registry_StatusCountsMatchTheAuditedTotals() + { + Assert.Equal(9, Registry.Where(e => e.Status == Status.HelpOnly).Sum(e => e.Verbs.Length)); + Assert.Equal(5, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length)); + Assert.Equal(138, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length)); + } + + [Fact] + public void NoDuplicateVerbsAcrossEntries() + { + var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + foreach (Entry entry in Registry) + { + foreach (string verb in entry.Verbs) + { + Assert.True(seen.Add(verb), $"'{verb}' appears more than once in the registry."); + } + } + } + + // ── Ownership-rule enforcement, reverse direction ────────────────── + // + // A verb RetailClientCommandCatalog or ChatInputParser claims to + // execute that ISN'T in the registry above is exactly the "acdream + // invented a verb retail doesn't have" class of bug the doc's §4 + // "candidates for removal" list called out (gen, cv, lookingforgroup, + // tr, role, h) — deleted at CH4. This test fails the build the next + // time one slips back in. + + [Fact] + public void RetailClientCommandCatalog_HasNoVerbsOutsideTheRegistry() + { + var registryVerbs = new HashSet<string>( + Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase); + + foreach (string verb in RetailClientCommandCatalog.KnownVerbs) + { + Assert.True(registryVerbs.Contains(verb), + $"RetailClientCommandCatalog recognizes '{verb}', which is not in the retail " + + "command-registry doc — either it's a genuine retail verb missing from this " + + "test's registry, or it's an invented alias that must be deleted."); + } + } + + [Fact] + public void ChatInputParser_HasNoVerbsOutsideTheRegistry() + { + var registryVerbs = new HashSet<string>( + Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase); + + foreach (string verbWithSlash in ChatInputParser.KnownVerbs) + { + string verb = verbWithSlash.TrimStart('/'); + Assert.True(registryVerbs.Contains(verb), + $"ChatInputParser recognizes '{verbWithSlash}', which is not in the retail " + + "command-registry doc — either it's a genuine retail verb missing from this " + + "test's registry, or it's an invented alias that must be deleted."); + } + } +}