From 614a1e055f3b926a135aeb8a7a70e61458ce0776 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 19:39:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20Campaign=20CH=20slice=20CH3=20?= =?UTF-8?q?=E2=80=94=20side-channel=20membership,=20wire,=20and=20echo=20p?= =?UTF-8?q?arity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new TurbineChatMembershipGate checks Turbine availability and the player's own Hear*Chat option before sending, raising "Turbine chat is not available." or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter) and headless (DirectGameRuntimeCommandAdapter) send paths so they can't diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md — ACE's TurbineChat implementation is complete and on by default; the real bug was treating Hear*Chat as a display filter instead of room membership. Also: implements SetSingleCharacterOption (0x0005), the only wire message that actually joins/leaves a Turbine room, and wires the five Settings Chat toggles to it (publish on Save, changed bits only) plus seeds ChatSettings from the server's own CharacterOptions2 on every PlayerDescription. Fixes the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally consulted). Routes /a to Turbine unconditionally (retail's @a never falls back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead of discarding it silently. Deletes the malformed, callerless SetCharacterOptions (0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders. Files every AC-specific algorithm change cites the named retail decomp (SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0, GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks. Register rows AP-181 (no client-side spam throttle) and UN-9 (an incidentally-discovered CharacterOptions1.Default literal mismatch, not investigated further) filed per the divergence-register rule. 11,957 passed / 4 skipped / 0 failed (full Release suite, up from the 11,916/4/0 baseline). Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 2 +- .../retail-divergence-register.md | 6 +- docs/plans/2026-04-11-roadmap.md | 2 +- docs/plans/2026-08-09-chat-parity-campaign.md | 76 ++++++- .../Composition/SessionPlayerComposition.cs | 18 +- .../Net/LiveSessionCommandRouter.cs | 193 +++++++++++------- .../Net/LiveSessionRuntimeFactory.cs | 12 +- .../CurrentGameRuntimeCommandAdapter.cs | 8 +- .../Settings/RuntimeSettingsController.cs | 86 ++++++++ .../Settings/RuntimeSettingsTargets.cs | 17 ++ .../Messages/PlayerDescriptionParser.cs | 25 +++ .../Messages/SocialActions.cs | 73 ++++--- src/AcDream.Core.Net/WorldSession.cs | 9 +- src/AcDream.Core/Chat/ChatChannelInfo.cs | 8 + src/AcDream.Core/Chat/ClientTextRefusals.cs | 17 ++ src/AcDream.Runtime/GameRuntimeCommands.cs | 24 ++- .../Gameplay/RuntimeCharacterState.cs | 22 ++ .../Gameplay/TurbineChatMembershipGate.cs | 143 +++++++++++++ .../DirectGameRuntimeCommandAdapter.cs | 92 +++++---- .../Session/LiveSessionEventRouter.cs | 61 ++++-- .../ChannelResolver.cs | 6 +- .../ChatChannelKind.cs | 27 ++- .../Panels/Chat/ChatInputParser.cs | 7 + .../Panels/Settings/ChatSettings.cs | 17 +- .../Panels/Settings/SettingsVM.cs | 16 ++ .../InteractionUiRuntimeSourcesTests.cs | 5 +- .../Net/LiveSessionCommandRouterTests.cs | 172 ++++++++++++++-- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 12 +- .../RuntimeSettingsControllerTests.cs | 152 ++++++++++++++ .../Messages/SocialActionsTests.cs | 37 ++-- .../Gameplay/RuntimeCharacterStateTests.cs | 38 ++++ .../TurbineChatMembershipGateTests.cs | 185 +++++++++++++++++ .../DirectGameRuntimeCommandAdapterTests.cs | 21 +- .../Session/LiveSessionEventRouterTests.cs | 84 ++++++++ .../LiveCommandBusTests.cs | 9 +- 35 files changed, 1453 insertions(+), 229 deletions(-) create mode 100644 src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 129c04a3..5af6c9ee 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -15229,7 +15229,7 @@ parallel to existing handlers (no behavior change). **Closed:** 2026-04-25 **Commit:** `ca968fc` -**Resolution:** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Note: ACE doesn't run a TurbineChat server — codec is ready for retail-server-emulating setups.** +**Resolution:** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't run a TurbineChat server" note above was FALSE.** ACE has a complete TurbineChat implementation (`TurbineChatHandler.cs`, 387 lines), on by default (`use_turbine_chat = true`), and our own launch logs have shown parsed `SetTurbineChatChannels` room ids since at least 2026-05-21. See `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §1. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ab9871c9..2aae0a92 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 (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-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 @@ -336,6 +336,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-137 | **Filed 2026-08-04 (C4 route 4b-2); rewritten 2026-08-04 at the dual Opus review; REWRITTEN AGAIN 2026-08-04 (C4 route 4b-3) — the cell-less enqueue-vs-place delta this row existed to record is RETIRED, not merely re-scoped: the teleport arm now ports retail's `teleport_hook` verbatim and places unconditionally through the canonical Runtime placement owner, exactly like retail's `this_1->cell == 0` @0x00516386 branch. What survives is the two acdream-only divergences retail has no state for at all.** acdream can classify a remote's accepted Position into two states retail cannot reach, sharing ONE stated handler (`RuntimeRemoteFarSnapPosition.ResolveArm`'s `UnroutedCatchUp`, AP-87's shared `ApplyInterpolate` catch-up) instead of a duplicated near/far block. The states: (a) **no classification at all** — `RuntimeAcceptedPositionRouteRequests.TryBuild` refuses to fabricate a local-player position, so `ClassifyRemoteAcceptedPosition` returns null for EVERY remote packet until the local movement controller exists (the login window) and whenever the canonical record has not claimed a local id; **route 4b-3 adds two more null-producing reasons** — the merge observed no PRIOR canonical record for this entity, so the classifier has no honest pre-merge cell to feed the teleport predicate and declines rather than fabricate one (D1); and the dormant initial-residence enqueue path (`RuntimeEntityObjectLifetime.TryApplyPosition`'s `EnqueueDormant` return, reached BEFORE the method's own `PreMergeCommittedCellId` write), whose timestamps therefore always carry `PreMergeCommittedCellId: null` too — fix round 2026-08-04 (R8), unverified from static reading whether `OnPosition` reaches `ClassifyRemoteAcceptedPosition` for an enqueued packet at all, stated honestly rather than guessed; (b) **`RejectedAuthority`/`RejectedData`** — acdream validates wire authority and payload finiteness, retail validates neither. **D1 — the visibility arm is deleted, not merely narrowed.** Before 4b-3, `LiveEntityRuntime.TryApplyPosition` computed `projectionRequiresTeleportHook` as `pre-merge FullCellId == 0 OR !IsSpatiallyProjected OR !IsSpatiallyVisible` — a presentation predicate with NO retail analogue, since retail's `MoveOrTeleport` never reads visibility. That whole computation, the lifetime parameter, and the headless `false` argument are deleted; a not-visible remote's Position now classifies purely by distance/contact like any other, and visibility is presentation-only. **D2 — the wire-airborne leftover shape.** After the teleport/cell-less classification moves onto its own arm, a packet whose classification is null/`RejectedAuthority`/`RejectedData` AND whose wire contact bit is clear takes retail's return-0 shape: AP-135's two bookkeeping writes only (server-cell adopt, `LastServerPos`/`LastServerPosTime`), no body/queue/render write, no leash arm. This deletes the legacy player-arm fallback's entity-revert quirk (`entity.SetPosition(rmState.Body.Position)`) and unifies player and NPC remotes on one behaviour. **R3 (retained from the prior rewrite) — `RejectedData` is APPLIED anyway** when grounded. It is the one classification meaning "this payload failed validation" (`ClassifyAcceptedPosition` emits it for a `ValidPosition` failure and for a non-finite/negative derived `player_distance`), and `UnroutedCatchUp` hands the same payload to `ApplyInterpolate`. Not a regression — the legacy block did the same. **Headless (contract item 6) is satisfied vacuously and that is stated, not implied:** nothing in `AcDream.Headless` constructs `RuntimeRemotePlacementDriveController` (`SessionPlayerComposition` is the only construction site) and `RuntimeLiveEntitySessionController.OnPositionUpdated` returns early for every non-local GUID, so both the far snap and the teleport arm are graphical-host-only paths | `src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs` (`ResolveArm`, `RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp`); `src/AcDream.Runtime/Physics/RuntimeRemoteTeleportPosition.cs` (`OwnsTeleportPlacement`, the retired predicate's replacement); applied at `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`'s teleport check and default arm, the D2 wire-airborne shape); headless statement on `IRuntimeRemotePlacementServiceWindow` | AP-87's own snap conditions (`firstUp \|\| !willBeDrTicked \|\| bodyToTarget > 4 m`) still PLACE an unplaced or badly-lagging body for the two survivors, so a remote keeps tracking the server through the login window and through a rejected packet | The two survivors are unaffected by 4b-3: a leftover-classified remote beyond 96 m that is already tracking catches up over a packet interval instead of snapping — invisible in practice at that range. If AP-87's 4 m backstop were ever weakened, this arm would become a silent-freeze path | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x00516386 cell-0/teleport — now ported, @0x005163AF near, @0x005163C1-E8 far); `CPhysicsObj::teleport_hook` @0x00514ED0; `RuntimeAcceptedPositionRouteRequests.TryBuild`; `GameRuntime.cs:288-290` (the no-fabricated-Vector3.Zero rule) | | 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).** Retail's `SendTurbineChat @0x0057db10` local-refuses on a per-account spam throttle (`IsMessageSpam()` → "You must wait %ds before communicating again!") ahead of the wire send; acdream's `TurbineChatMembershipGate`/`RouteLegacyChannel` port only the Turbine-unavailable and Hear-option gates, not the throttle. | `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 complaint; porting a client-side throttle 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 abusive local test) would see every send attempted rather than refused after the first — cosmetic only, since ACE's own `chat_echo_reject`/spam handling (if any) still governs what actually reaches other players. | `ClientCommunicationSystem::SendTurbineChat @0x0057db10` (`IsMessageSpam()` branch); research doc `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §4.2 | ## 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) @@ -390,7 +391,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. --- -## 5. Unclear (UN) — 4 rows (UN-8 retired 2026-07-30 by the P1 Opus review: CanJump polarity byte-proven `load < 2.0` from the PDB-paired binary — fld/fcomp [0x007c5e24=2.0f]/test ah,5/jp; unordered refuses. Evidence: stat-coupled pseudocode doc §12) +## 5. Unclear (UN) — 5 rows (UN-9 filed 2026-08-09, Campaign CH slice CH3 — acdream's `CharacterOptions1.Default` literal diverges from ACE's own by one byte, with no recorded reason. UN-8 retired 2026-07-30 by the P1 Opus review: CanJump polarity byte-proven `load < 2.0` from the PDB-paired binary — fld/fcomp [0x007c5e24=2.0f]/test ah,5/jp; unordered refuses. Evidence: stat-coupled pseudocode doc §12) These rows have a missing, contradictory, or never-argued justification. They are the highest-priority audits: each needs either a recorded @@ -402,6 +403,7 @@ equivalence argument (promote to AD/AP) or a fix. | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | +| UN-9 | **Filed 2026-08-09 (Campaign CH slice CH3, incidental discovery — not investigated, only located.)** `PlayerDescriptionParser.CharacterOptions1.Default = 0x50C4A54A`; ACE's own `CharacterOptions1.Default` (OR of its 11 named flags) is `0x50C48D4A` — the two differ by one byte (`0xA5` vs `0x8D` at bit offset 16-23). CH3 added `HearAllegianceChat (0x40000000)` to this same enum and confirmed that bit is unaffected by the discrepancy (both literals carry it), so it did not block CH3's work, but the discrepancy itself was never explained anywhere in the codebase. | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` (`CharacterOptions1.Default`) | None recorded — no comment, commit message, or register row explains which bit(s) acdream's literal has wrong (or whether ACE's own OR'd constant is the one that's stale relative to a later ACE version) | Any future code that branches on one of the un-audited bits in the 0x280000 XOR delta (candidates include `ToggleRun`/`AutoTarget`/similar per ACE's `CharacterOptions1.cs` flag list) would silently disagree with ACE's real default for a freshly created character | `references/ACE/Source/ACE.Entity/Enum/CharacterOptions1.cs` (`Default` OR expression) | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 1950d150..dd17bad5 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -426,7 +426,7 @@ W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../s | I.3 | `LiveCommandBus` + `WorldSession.SendTalk` / `SendTell` / `SendChannel` — replaces `NullCommandBus.Instance` with a real handler-registry `ICommandBus`. New `SendChatCmd` record + `ChannelResolver` legacy-id mapping (per holtburger). 3-line wrappers around existing `ChatRequests.BuildTalk/Tell/ChatChannel`. | Tests ✓ | | I.4 | `ChatPanel` input field + slash commands — Enter-to-submit input field; `ChatInputParser` recognises `/say` `/t` `/tell` `/r` `/g` `/f` `/a` `/m` `/p` `/v` `/cv` `/lfg` `/trade` `/role` `/society` `/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on focus. | Live ✓ | | I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ | -| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** | Tests ✓ | +| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't host a TurbineChat server" note above was FALSE — ACE has a complete, on-by-default TurbineChat implementation; see `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §1.** | Tests ✓ | | I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `KillLanded`; `AttackDone` is control-only and deliberately silent. | Tests ✓ | | K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ | | L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ | diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 0a7d4ebc..425f93b3 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -6,7 +6,9 @@ gate (`77c8296e`, REJECT-reviewed at `docs/research/2026-08-09-ch2-review-findings.md`, reworked `e0e78883`, 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) next. +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`. **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. @@ -106,6 +108,76 @@ implementer per slice against a pinned contract (per | R1–R4 research | `see docs/research/2026-08-09-chat-retail-*` | — | — | — | | 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 | — | — | — | — | +| CH3 side channels | `PENDING-SHA` | 11,957 passed / 4 skipped / 0 failed | not yet reviewed | pending (connected gate — see handoff below) | | CH4 commands | — | — | — | — | | CH5 closeout | — | — | — | — | + +### CH3 closeout handoff (2026-08-09) + +All nine steps of the research doc's §6 fix list landed: + +1. **The false "ACE doesn't run a TurbineChat server" claim retracted** in + `docs/ISSUES.md` (#19) and `docs/plans/2026-04-11-roadmap.md` (I.6, ×2). +2. **`TurbineChatMembershipGate`** (new, `AcDream.Runtime.Gameplay`) ports + retail's `SendTurbineChat @0x0057db10` local pre-send gate — Turbine + off/room-0 → `"Turbine chat is not available."`; Hear-option off → + `0x0551 YouAreNotListeningTo_Channel` — both raised through + `RuntimeCommunicationState.AddText`. Wired into BOTH + `LiveSessionCommandRouter` (graphical) and `DirectGameRuntimeCommandAdapter` + (headless) so the two hosts can't diverge. `RuntimeCharacterState.IsOlthoiPlayer` + added (heritage-gated, not an option) for the Olthoi room. +3. **`SetSingleCharacterOption (0x0005)`** implemented end to end (codec, + `WorldSession.SendSetSingleCharacterOption`, `IRuntimeCharacterCommands. + SetSingleOption`, both adapters, `LiveSessionCommandRouter` registration) + and wired to the 5 Settings Chat toggles via `RuntimeSettingsController. + SaveChat` (publishes only the CHANGED bits) through a hoisted + `LiveSessionCommandSurface` now shared with `RuntimeSettingsTargets`. + No 6th (Allegiance) toggle was added — `ChatSettings` has never had one + and retail's own Settings UI was not confirmed to have one either; flagged + for the user rather than guessed. +4. **`ChatSettings` seeded from server truth** — `RuntimeSettingsController. + SyncChatFromServerOptions` reseeds both the persisted snapshot and any + live unsaved draft from `CharacterOptions2` whenever a fresh + PlayerDescription lands (`LiveCharacterSessionBindings. + OnCharacterOptionsChanged`, new optional hook). +5. **Self-echo double-print fixed** — `LiveSessionCommandRouter. + RouteLegacyChannel` now consults `ChatChannelInfo.Legacy(...). + IsSelfEchoChannel()`; Fellow/Vassals/Patron/Monarch/CoVassals skip the + local echo (server resends with `""` sender), AllegianceBroadcast/Say/Tell + keep it. Existing `TellAndLegacyChannel_PreserveOutboundAndEchoPolicy` + test corrected to the fixed (single-print) expectation. +6. **TurbineChat ack HResult surfaced** — `LiveSessionEventRouter. + RouteTurbineChat` now switches on `Payload.Response{HResult}`; nonzero + surfaces as a system chat line, zero (the common case) stays silent + matching retail. +7. **`/a` routes to Turbine unconditionally** — `ChatChannelKind.Allegiance` + is exhaustively dispatched to the Turbine pipeline (never falls through to + legacy); a new `ChatChannelKind.AllegianceBroadcast` + `/ab` verb owns the + legacy `0x02000000` path retail's own `@ab` verb uses. + `/allegiancebroadcast` was deliberately NOT added — the retail command + registry (`docs/research/2026-08-09-chat-retail-command-registry.md`) + only has `ab`, not that long form. +8. **Malformed builders resolved** — `SocialActions.BuildSetCharacterOptions` + (0x01A1, no caller), `BuildAddChannel`/`BuildRemoveChannel` (0x0145/0x0146, + wrong payload type, no caller) DELETED along with their entire call chain + (`WorldSession.SendSetCharacterOptions`, `IRuntimeCharacterCommands. + SetOptions1`, `SetCharacterOptionsRuntimeCmd`) — replaced by + `SetSingleCharacterOption`, the message step 3 actually needed. +9. **Register + memory** — AP-181 (no client-side chat spam throttle) and + UN-9 (an incidentally-discovered, unexplained one-byte + `CharacterOptions1.Default` mismatch vs ACE's own literal — not + investigated further, flagged for a future pass) filed in + `docs/architecture/retail-divergence-register.md`. + `claude-memory/project_chat_pipeline.md` line ~111 corrected. + +**Deviations from the literal ordered list:** none structural; the two +notes above (no 6th Allegiance toggle, no `/allegiancebroadcast` verb) are +scope-narrowing decisions made against the retail command registry and the +existing `ChatSettings` shape, not skipped work. + +**What the connected gate must verify (not run this session — build+test +only per the CH3 task's hard constraint):** General/Trade/LFG round-trip +send+receive; Roleplay is now silent-but-correctly-refused until the user +turns it on via Settings (then works); `/a` with and without an allegiance; +`/ab`; the legacy family no longer double-prints; the TurbineChat ack +HResult line never appears on an ordinary successful send. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 1fd72929..8e41bb18 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -322,6 +322,14 @@ internal sealed class SessionPlayerCompositionPhase Fault(SessionPlayerCompositionPoint.StreamingCreated); bindings = new SessionPlayerRuntimeBindings(); + // CH3 (2026-08-09): constructed here (rather than at its original + // site further down, alongside LiveSessionAppSource) purely so + // RuntimeSettingsTargets can publish SetSingleCharacterOption + // through the SAME stable command surface the retained UI uses — + // LiveSessionCommandSurface has no dependencies of its own, so + // hoisting its construction is inert; the later site now reuses + // this instance instead of constructing a second one. + var liveSessionCommands = new LiveSessionCommandSurface(); var settingsTargets = new RuntimeSettingsTargets( new SilkRuntimeDisplayWindowTarget(d.Window), live.DrawDispatcher, @@ -329,6 +337,7 @@ internal sealed class SessionPlayerCompositionPhase streaming, d.RenderRange, interaction.RetainedUi?.Host.Root, + liveSessionCommands, d.Log); bindings.Adopt( "runtime settings targets", @@ -419,6 +428,7 @@ internal sealed class SessionPlayerCompositionPhase worldReveal, spawnClaimClassifier, bindings, + liveSessionCommands, ref bindingsOwnedByScope); } @@ -436,6 +446,10 @@ internal sealed class SessionPlayerCompositionPhase WorldRevealCoordinator worldReveal, DatSpawnClaimHydrationClassifier spawnClaimClassifier, SessionPlayerRuntimeBindings bindings, + // CH3 (2026-08-09): threaded through from ComposeCore, where it was + // constructed early (alongside RuntimeSettingsTargets) instead of at + // its original site below — see the construction-site comment. + LiveSessionCommandSurface liveSessionCommands, ref bool bindingsOwnedByScope) { SessionPlayerDependencies d = _dependencies; @@ -474,7 +488,9 @@ internal sealed class SessionPlayerCompositionPhase d.Log); LiveSessionController liveSession = d.Runtime.Session; - var liveSessionCommands = new LiveSessionCommandSurface(); + // liveSessionCommands arrives as a parameter — constructed in + // ComposeCore alongside RuntimeSettingsTargets (CH3, 2026-08-09) and + // threaded through, not reconstructed here. var liveSessionSource = new LiveSessionAppSource( liveSession, liveSessionCommands); diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index f4c077f5..3ed9cf21 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -2,6 +2,7 @@ using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; using AcDream.UI.Abstractions; @@ -28,7 +29,6 @@ internal sealed record LiveSessionCommandBindings( Action RaiseVital, Action RaiseSkill, Action TrainSkill, - Action SetCharacterOptions, Action AddFriend, Action RemoveFriend, Action ClearFriends, @@ -36,6 +36,15 @@ internal sealed record LiveSessionCommandBindings( Action ModifyCharacterSquelch, Action ModifyAccountSquelch, Action ModifyGlobalSquelch, + // Campaign CH slice CH3 (2026-08-09): the RuntimeCommunicationState. + // AddText chokepoint (local refusals — Turbine unavailable / not + // listening) and the RuntimeCharacterState owner (Hear*Chat options + + // IsOlthoiPlayer) the Turbine membership gate reads, plus the + // SetSingleCharacterOption (0x0005) sender that replaced the malformed, + // callerless full-blob SetCharacterOptions (0x01A1) path. + RuntimeCommunicationState Communication, + RuntimeCharacterState CharacterState, + Action SendSingleCharacterOption, Action? Log = null); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); @@ -57,7 +66,9 @@ internal readonly record struct RaiseAttributeRuntimeCmd(uint StatId, ulong Cost internal readonly record struct RaiseVitalRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct RaiseSkillRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost); -internal readonly record struct SetCharacterOptionsRuntimeCmd(uint Options); +internal readonly record struct SetSingleCharacterOptionRuntimeCmd( + uint OptionId, + bool Value); internal readonly record struct AddFriendRuntimeCmd(string Name); internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); internal readonly record struct ClearFriendsRuntimeCmd; @@ -97,6 +108,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting ArgumentNullException.ThrowIfNull(bindings.SendTell); ArgumentNullException.ThrowIfNull(bindings.SendChannel); ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat); + ArgumentNullException.ThrowIfNull(bindings.Communication); + ArgumentNullException.ThrowIfNull(bindings.CharacterState); _clientCommands = bindings.ClientCommands; var commands = new LiveCommandBus(); @@ -145,9 +158,11 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting commands.Register( command => SendIfActive(() => bindings.TrainSkill(command.StatId, command.Cost))); - commands.Register( + commands.Register( command => SendIfActive(() => - bindings.SetCharacterOptions(command.Options))); + bindings.SendSingleCharacterOption( + command.OptionId, + command.Value))); commands.Register( command => SendIfActive(() => bindings.AddFriend(command.Name))); commands.Register( @@ -216,6 +231,28 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting commands?.Clear(); } + /// + /// The seven values that ride Turbine + /// (0xF7DE), mapped to the lighter + /// reads. Every OTHER channel + /// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast) + /// is legacy-only (0x0147) — the two pipelines never overlap, so this + /// dispatch is exhaustive rather than "try Turbine, fall back to + /// legacy." That fallback was the bug (CH3 research doc §5.3): with no + /// allegiance, /a silently downgraded to the legacy + /// AllegianceBroadcast bitflag instead of retail's local refusal. + /// + private static readonly Dictionary TurbineChannelKinds = new() + { + [ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance, + [ChatChannelKind.General] = ChatChannelKindLite.General, + [ChatChannelKind.Trade] = ChatChannelKindLite.Trade, + [ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg, + [ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay, + [ChatChannelKind.Society] = ChatChannelKindLite.Society, + [ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi, + }; + private void RouteChat( LiveSessionCommandBindings bindings, SendChatCmd command) @@ -248,45 +285,100 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting return; } - TurbineResolution? turbine = TurbineChatRouting.Resolve( - command.Channel, - bindings.TurbineChat); - if (turbine is not null) + if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind)) { - uint cookie = bindings.TurbineChat.NextContextId(); - uint senderGuid = bindings.PlayerGuid(); - bindings.Log?.Invoke( - $"chat: outbound TurbineChat {turbine.Value.DisplayName} " + - $"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " + - $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={command.Text.Length}"); - SendIfActive(() => bindings.SendTurbineChat( - turbine.Value.RoomId, - turbine.Value.ChatType, - (uint)TurbineChat.DispatchType.SendToRoomById, - senderGuid, - command.Text, - cookie)); + RouteTurbineChat(bindings, liteKind, command.Text); return; } - ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(command.Channel); + RouteLegacyChannel(bindings, command.Channel, command.Text); + } + + /// + /// Step 2 of the CH3 fix list: retail + /// ClientCommunicationSystem::SendTurbineChat @0x0057db10's local + /// membership gate, raised through the same + /// RuntimeCommunicationState.AddText chokepoint CH2 built for + /// every other client-raised refusal. + /// + private void RouteTurbineChat( + LiveSessionCommandBindings bindings, + ChatChannelKindLite kind, + string text) + { + TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( + kind, + bindings.TurbineChat, + bindings.CharacterState.Options, + bindings.CharacterState.IsOlthoiPlayer); + + switch (gate.Status) + { + case TurbineChatGateStatus.Unavailable: + bindings.Communication.AddText( + ClientTextRefusals.TurbineChatUnavailable, + RetailLogTextType.Default); + return; + case TurbineChatGateStatus.NotListening: + { + (string? refusal, RetailLogTextType type) = + WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName); + if (refusal is not null) + bindings.Communication.AddText(refusal, type); + return; + } + } + + uint cookie = bindings.TurbineChat.NextContextId(); + uint senderGuid = bindings.PlayerGuid(); + bindings.Log?.Invoke( + $"chat: outbound TurbineChat {gate.DisplayName} " + + $"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " + + $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}"); + SendIfActive(() => bindings.SendTurbineChat( + gate.RoomId, + gate.ChatType, + (uint)TurbineChat.DispatchType.SendToRoomById, + senderGuid, + text, + cookie)); + } + + private void RouteLegacyChannel( + LiveSessionCommandBindings bindings, + ChatChannelKind channel, + string text) + { + ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel); if (legacy is null) { bindings.Log?.Invoke( - $"chat: SendChatCmd kind={command.Channel} dropped " + - $"(turbine.Enabled={bindings.TurbineChat.Enabled} no legacy id)"); + $"chat: SendChatCmd kind={channel} dropped (no legacy id)"); return; } bindings.Log?.Invoke( $"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " + - $"id=0x{legacy.Value.ChannelId:X8} len={command.Text.Length}"); + $"id=0x{legacy.Value.ChannelId:X8} len={text.Length}"); if (!SendIfActive(() => - bindings.SendChannel(legacy.Value.ChannelId, command.Text))) + bindings.SendChannel(legacy.Value.ChannelId, text))) return; + + // Step 5: wire ChatChannelInfo.IsSelfEchoChannel() — ACE resends + // Fellow/Vassals/Patron/Monarch/CoVassals to the sender with an + // empty sender name, so a local optimistic echo double-prints. + // AllegianceBroadcast includes the sender in its real-name broadcast + // with no such server echo, so it keeps the local echo (research + // doc §3.7/§5.4). + bool serverEchoes = new ChatChannelInfo.Legacy( + legacy.Value.ChannelId, + legacy.Value.DisplayName).IsSelfEchoChannel(); + if (serverEchoes) + return; + bindings.Chat.OnSelfSent( ChatKind.Channel, - command.Text, + text, targetOrChannel: legacy.Value.DisplayName, // Precise per-bit own-send type (LegacyChannelChatType.Resolve's // ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/ @@ -389,50 +481,3 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting } } } - -internal readonly record struct TurbineResolution( - uint RoomId, - uint ChatType, - string DisplayName); - -internal static class TurbineChatRouting -{ - /// - /// Resolve the server-assigned Turbine room for one UI channel. This is - /// the existing holtburger resolve_turbine_channel mapping - /// (references/holtburger/.../client/commands.rs, lines 64-98), - /// moved intact from GameWindow with its runtime-room gate preserved. - /// - public static TurbineResolution? Resolve( - ChatChannelKind kind, - TurbineChatState state) - { - ArgumentNullException.ThrowIfNull(state); - if (!state.Enabled) - return null; - - (uint Room, uint ChatType, string Name) = kind switch - { - ChatChannelKind.Allegiance => - (state.AllegianceRoom, (uint)TurbineChat.ChatType.Allegiance, "Allegiance"), - ChatChannelKind.General => - (state.GeneralRoom, (uint)TurbineChat.ChatType.General, "General"), - ChatChannelKind.Trade => - (state.TradeRoom, (uint)TurbineChat.ChatType.Trade, "Trade"), - ChatChannelKind.Lfg => - (state.LfgRoom, (uint)TurbineChat.ChatType.Lfg, "LFG"), - ChatChannelKind.Roleplay => - (state.RoleplayRoom, (uint)TurbineChat.ChatType.Roleplay, "Roleplay"), - ChatChannelKind.Society => - (state.SocietyRoom, (uint)TurbineChat.ChatType.Society, "Society"), - ChatChannelKind.Olthoi => - (state.OlthoiRoom, (uint)TurbineChat.ChatType.Olthoi, "Olthoi"), - _ => (0u, 0u, string.Empty), - }; - - return Room == 0u - ? null - : new TurbineResolution(Room, ChatType, Name); - } - -} diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 6ca43560..00ead329 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -310,7 +310,13 @@ internal sealed class LiveSessionRuntimeFactory // (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime // movement owner's typed application entry — see // LiveMovementStatsApplier. - OnMovementStatsUpdated: () => _movementStats.Apply("stats")); + OnMovementStatsUpdated: () => _movementStats.Apply("stats"), + // Campaign CH slice CH3 (2026-08-09): reseed the Settings Chat + // draft from server truth every time a PlayerDescription lands + // (research doc §5.2/§6.4 — the local ChatSettings.Default lied + // relative to ACE's CharacterOptions2.Default). + OnCharacterOptionsChanged: (_, options2) => + _interaction.Settings.SyncChatFromServerOptions(options2)); } private LiveSessionCommandBindings CreateCommandBindings( @@ -448,7 +454,6 @@ internal sealed class LiveSessionRuntimeFactory RaiseVital: session.SendRaiseVital, RaiseSkill: session.SendRaiseSkill, TrainSkill: session.SendTrainSkill, - SetCharacterOptions: session.SendSetCharacterOptions, AddFriend: session.SendAddFriend, RemoveFriend: session.SendRemoveFriend, ClearFriends: session.SendClearFriends, @@ -456,6 +461,9 @@ internal sealed class LiveSessionRuntimeFactory ModifyCharacterSquelch: session.SendModifyCharacterSquelch, ModifyAccountSquelch: session.SendModifyAccountSquelch, ModifyGlobalSquelch: session.SendModifyGlobalSquelch, + Communication: _domain.Communication, + CharacterState: _domain.Character, + SendSingleCharacterOption: session.SendSetSingleCharacterOption, Log: _log); private static double ClientTimerNow() => diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index b4d19237..e68fd275 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -665,14 +665,15 @@ internal sealed class CurrentGameRuntimeCommandAdapter command.StatId); } - public RuntimeCommandResult SetOptions1( + public RuntimeCommandResult SetSingleOption( RuntimeGenerationToken expectedGeneration, - uint options) + uint optionId, + bool value) { RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - _commands.Publish(new SetCharacterOptionsRuntimeCmd(options)); + _commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value)); return EmitResult( RuntimeCommandDomain.Character, operation: 4, @@ -835,6 +836,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeChatChannel.Tell => ChatChannelKind.Tell, RuntimeChatChannel.Fellowship => ChatChannelKind.Fellowship, RuntimeChatChannel.Allegiance => ChatChannelKind.Allegiance, + RuntimeChatChannel.AllegianceBroadcast => ChatChannelKind.AllegianceBroadcast, RuntimeChatChannel.Vassals => ChatChannelKind.Vassals, RuntimeChatChannel.Patron => ChatChannelKind.Patron, RuntimeChatChannel.Monarch => ChatChannelKind.Monarch, diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index 5b2ad44d..5b006d28 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -1,4 +1,5 @@ using AcDream.App.Combat; +using AcDream.Core.Net.Messages; using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Settings; @@ -121,6 +122,15 @@ internal interface IRuntimeSettingsTargets void ApplyQuality(QualitySettings quality); void ApplyUiLock(bool locked); + + /// + /// Campaign CH slice CH3 (2026-08-09): the generation-gated seam + /// (matching J4.4's pattern) that publishes retail's + /// SetSingleCharacterOption (0x0005) for one Settings Chat + /// toggle. is an ACE + /// CharacterOption id (e.g. ListenToGeneralChat = 0x23). + /// + void SetSingleCharacterOption(uint optionId, bool value); } internal interface IRuntimeSettingsPreviewSource @@ -499,6 +509,7 @@ internal sealed class RuntimeSettingsController : private void SaveChat(ChatSettings chat) { + ChatSettings previous = Chat; try { _storage.SaveChat(chat); @@ -508,6 +519,81 @@ internal sealed class RuntimeSettingsController : catch (Exception ex) { _log($"settings: chat save failed: {ex.Message}"); + return; + } + + // CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes + // SetSingleCharacterOption (0x0005) in the same step (mirrors + // SaveGameplay's ApplyUiLock push above) — ACE's handler both flips + // the option AND joins/leaves the matching Turbine room. + PublishHearOptionChange( + previous.HearGeneralChat, chat.HearGeneralChat, + (uint)CharacterOptionId.ListenToGeneralChat); + PublishHearOptionChange( + previous.HearTradeChat, chat.HearTradeChat, + (uint)CharacterOptionId.ListenToTradeChat); + PublishHearOptionChange( + previous.HearLFGChat, chat.HearLFGChat, + (uint)CharacterOptionId.ListenToLFGChat); + PublishHearOptionChange( + previous.HearRoleplayChat, chat.HearRoleplayChat, + (uint)CharacterOptionId.ListenToRoleplayChat); + PublishHearOptionChange( + previous.HearSocietyChat, chat.HearSocietyChat, + (uint)CharacterOptionId.ListenToSocietyChat); + } + + private void PublishHearOptionChange(bool previous, bool current, uint optionId) + { + if (previous == current) + return; + _runtimeTargets?.SetSingleCharacterOption(optionId, current); + } + + /// + /// CH3 (2026-08-09): reseed the persisted + draft Chat snapshot from the + /// server's own CharacterOptions2 bitfield (already parsed out of + /// PlayerDescription) — called whenever a fresh description lands. The + /// local lies relative to ACE's + /// default (Roleplay/Society start OFF server-side), so this is the only + /// way the checkbox ever reflects truth for a character that never + /// explicitly saved a Chat preference. + /// + public void SyncChatFromServerOptions(uint options2) + { + // Applied identically to BOTH the persisted snapshot and the live + // draft (mirrors ApplyExternalGameplayChange's own idempotent-update + // shape) so an unsaved draft edit to an unrelated field (font size, + // timestamps, ...) survives the reseed instead of being clobbered by + // a value computed once against the persisted snapshot. + ChatSettings Reseed(ChatSettings current) => current with + { + HearGeneralChat = (options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat) != 0u, + HearTradeChat = (options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat) != 0u, + HearLFGChat = (options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat) != 0u, + HearRoleplayChat = (options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat) != 0u, + HearSocietyChat = (options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat) != 0u, + }; + + ChatSettings synced = Reseed(Chat); + if (synced == Chat) + return; + + Chat = synced; + _viewModel?.ApplyExternalChatChange(Reseed); + try + { + _storage.SaveChat(synced); + _log($"settings: chat synced from server options2=0x{options2:X8}"); + } + catch (Exception ex) + { + _log($"settings: chat sync save failed: {ex.Message}"); } } diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index e772e08f..89b717b7 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -1,8 +1,10 @@ using AcDream.App.Audio; +using AcDream.App.Net; using AcDream.App.Rendering; using AcDream.App.Rendering.Wb; using AcDream.App.Streaming; using AcDream.App.UI; +using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Settings; using Silk.NET.Maths; @@ -203,6 +205,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets private readonly IRuntimeDisplayWindowTarget _displayWindow; private readonly IRuntimeQualityApplicationTarget _quality; private readonly IRuntimeUiLockTarget _uiLock; + private readonly ICommandBus _commands; private readonly Action _log; public RuntimeSettingsTargets( @@ -212,6 +215,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets StreamingController streaming, WorldRenderRangeState renderRange, UiRoot? uiRoot, + ICommandBus commands, Action? log = null) : this( displayWindow, @@ -223,6 +227,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets uiRoot is null ? NullRuntimeUiLockTarget.Instance : new RuntimeUiLockTarget(uiRoot), + commands, log) { } @@ -231,12 +236,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets IRuntimeDisplayWindowTarget displayWindow, IRuntimeQualityApplicationTarget quality, IRuntimeUiLockTarget uiLock, + ICommandBus commands, Action? log = null) { _displayWindow = displayWindow ?? throw new ArgumentNullException(nameof(displayWindow)); _quality = quality ?? throw new ArgumentNullException(nameof(quality)); _uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock)); + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _log = log ?? Console.WriteLine; } @@ -257,4 +264,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets } public void ApplyUiLock(bool locked) => _uiLock.Apply(locked); + + /// + /// CH3 (2026-08-09): publishes through the SAME + /// generation-gated route every + /// other outbound Settings/chat command uses — a no-op when no route is + /// currently attached (disconnected / reconnecting), exactly like every + /// other ICommandBus.Publish call site. + /// + public void SetSingleCharacterOption(uint optionId, bool value) => + _commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value)); } diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 7358a20e..765b97fe 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -207,10 +207,35 @@ public static class PlayerDescriptionParser { None = 0, AllowGive = 0x00000040, + // Campaign CH slice CH3 (2026-08-09): retail's Turbine-room + // membership gate for the Allegiance chat room + // (ClientCommunicationSystem::SendTurbineChat @0x0057db10 reads + // PlayerModule::HearAllegianceChat as its "hearOption" argument for + // DoTurbineChat_Allegiance). ACE: CharacterOptions1.cs. + HearAllegianceChat = 0x40000000, DragItemOnPlayerOpensSecureTrade = 0x04000000, Default = 0x50C4A54A, } + /// + /// Second character-option bitfield (the trailer's + /// CharacterOptions2 u32, present when + /// is set). + /// Campaign CH slice CH3 (2026-08-09): only the five Hear*Chat + /// membership bits retail's SendTurbineChat reads are modeled + /// here — ACE CharacterOptions2.cs has the complete bitfield. + /// + [Flags] + public enum CharacterOptions2 : uint + { + None = 0, + HearGeneralChat = 0x00000100, + HearTradeChat = 0x00000200, + HearLFGChat = 0x00000400, + HearRoleplayChat = 0x00000800, + HearSocietyChat = 0x00080000, + } + /// One inventory entry — a guid plus a ContainerType /// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger /// events.rs:143-168 validates ContainerType <= 2 diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index 09ce74cf..d59c04d3 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -23,6 +23,24 @@ namespace AcDream.Core.Net.Messages; /// References: r08 §3 rows for each opcode. /// /// +/// +/// ACE CharacterOption ids (a LINEAR enum, distinct from the +/// CharacterOptions1/CharacterOptions2 BITFIELDS) — the first +/// u32 of a SetSingleCharacterOption (0x0005) payload. Only +/// the six ListenTo*Chat ids Campaign CH slice CH3 (2026-08-09) needs +/// are modeled here; ACE Source/ACE.Entity/Enum/CharacterOption.cs +/// has the complete list. +/// +public enum CharacterOptionId : uint +{ + ListenToAllegianceChat = 0x1B, + ListenToGeneralChat = 0x23, + ListenToTradeChat = 0x24, + ListenToLFGChat = 0x25, + ListenToRoleplayChat = 0x26, + ListenToSocietyChat = 0x2E, +} + public static class SocialActions { public const uint GameActionEnvelope = 0xF7B1u; @@ -40,11 +58,15 @@ public static class SocialActions public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open // Character options - public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap - - // Chat channels - public const uint AddChannelOpcode = 0x0145u; // string16L channelName - public const uint RemoveChannelOpcode = 0x0146u; // string16L channelName + // CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder + // and the string-payload AddChannel/RemoveChannel (0x0145/0x0146) + // builders were deleted here — none had a production caller, and all + // three were malformed against ACE's real reader (research doc + // 2026-08-09-chat-side-channels-vs-ace.md §3.8/§3.7/§5.5). Only + // SetSingleCharacterOption (0x0005) — the message that actually + // changes Turbine room membership — has a caller (WorldSession. + // SendSetSingleCharacterOption), so only it is implemented. + public const uint SetSingleCharacterOptionOpcode = 0x0005u; // u32 optionId, u32 value (0/1) /// Query a target's health — server replies with UpdateHealth (0x01C0). public static byte[] BuildQueryHealth(uint seq, uint targetGuid) @@ -132,25 +154,29 @@ public static class SocialActions return body; } - /// Push the client's character-options bitmap to the server. - public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap) + /// + /// Toggle one character option and push it to the server. + /// GameActionSetSingleCharacterOption @ GameActionType 0x0005 — + /// the ONLY wire message that changes Turbine room membership: for the + /// ListenTo*Chat option ids, ACE's handler both flips the option + /// AND calls JoinTurbineChatChannel/LeaveTurbineChatChannel + /// (re-pushing SetTurbineChatChannels). Payload + /// u32 option, u32 value confirmed against ACE + /// (GameActionSetSingleCharacterOption.cs:11-12) and holtburger + /// (SetSingleCharacterOptionActionData::pack, + /// messages/player/actions.rs:149-157) — both agree byte-for-byte. + /// + public static byte[] BuildSetSingleCharacterOption(uint seq, uint optionId, bool value) { - byte[] body = new byte[16]; + byte[] body = new byte[20]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetCharacterOptionsOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetSingleCharacterOptionOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionId); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), value ? 1u : 0u); return body; } - /// Subscribe to a named chat channel. - public static byte[] BuildAddChannel(uint seq, string channelName) - => SingleString(seq, AddChannelOpcode, channelName); - - /// Unsubscribe from a named chat channel. - public static byte[] BuildRemoveChannel(uint seq, string channelName) - => SingleString(seq, RemoveChannelOpcode, channelName); - // ── Helpers ────────────────────────────────────────────────────────────── private static byte[] SingleGuid(uint seq, uint sub, uint guid) @@ -163,17 +189,6 @@ public static class SocialActions return body; } - private static byte[] SingleString(uint seq, uint sub, string s) - { - byte[] str = PackString16L(s); - byte[] body = new byte[12 + str.Length]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub); - Array.Copy(str, 0, body, 12, str.Length); - return body; - } - private static byte[] PackString16L(string s) { ArgumentNullException.ThrowIfNull(s); diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 9931aa47..9ef30777 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2195,13 +2195,14 @@ public sealed class WorldSession : IDisposable } /// - /// Send retail SetCharacterOptions (0x01A1) for the first character - /// option bitmap. + /// Send retail SetSingleCharacterOption (0x0005) — toggles one character + /// option. For the six ListenTo*Chat ids this is the message that + /// actually joins/leaves a Turbine room server-side (CH3, 2026-08-09). /// - public void SendSetCharacterOptions(uint options) + public void SendSetSingleCharacterOption(uint optionId, bool value) { uint seq = NextGameActionSequence(); - SendGameAction(SocialActions.BuildSetCharacterOptions(seq, options)); + SendGameAction(SocialActions.BuildSetSingleCharacterOption(seq, optionId, value)); } public void SendAddFriend(string name) diff --git a/src/AcDream.Core/Chat/ChatChannelInfo.cs b/src/AcDream.Core/Chat/ChatChannelInfo.cs index 73dad11e..757b4bd1 100644 --- a/src/AcDream.Core/Chat/ChatChannelInfo.cs +++ b/src/AcDream.Core/Chat/ChatChannelInfo.cs @@ -50,6 +50,14 @@ public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Sou // channels are the ones the server echoes back to the sender // with an empty sender field. Bitflag values from // references/holtburger/.../messages/chat/types.rs::ChatChannel. + // + // CH3 (2026-08-09, research doc §3.7/§5.4): AllegianceBroadcast + // (0x02000000) deliberately falls to the `false` default below — + // ACE's GameActionChatChannel handler includes the sender in the + // normal real-name member broadcast for that channel (no + // separate "" -sender echo the way Fellow/Vassals/Patron/ + // Monarch/CoVassals get), so the client must keep its own local + // optimistic echo or the sender never sees their own line. return ChannelId switch { 0x00000800u => true, // Fellow diff --git a/src/AcDream.Core/Chat/ClientTextRefusals.cs b/src/AcDream.Core/Chat/ClientTextRefusals.cs index 99adeb62..599607ba 100644 --- a/src/AcDream.Core/Chat/ClientTextRefusals.cs +++ b/src/AcDream.Core/Chat/ClientTextRefusals.cs @@ -106,4 +106,21 @@ public static class ClientTextRefusals /// "You can't use chat emotes from t…". /// public const string CantEmotePosition = "You can't use chat emotes from this position"; + + /// + /// Campaign CH slice CH3 (2026-08-09): retail + /// ClientCommunicationSystem::SendTurbineChat @0x0057db10's local + /// refusal when Turbine chat is disabled or the target room id is 0 (no + /// allegiance, no society, etc.) — raised BEFORE any wire send, exactly + /// like the jump refusals above. Byte-read off the PDB-paired binary at + /// 0x007e0d1c: "Turbine chat is not available.\n". The call + /// site (0057dd64) passes type 0 (Default), not 0x1A + /// (ClientLocal) — confirmed by direct comparison against the + /// unambiguous cant_jump_in_air call sites, which use the same + /// AddTextToScroll(this, &text, type, allowPluginFilter, + /// windowId) argument order with a literal 0x1a in the type + /// slot; this call has a literal 0 there instead. So this + /// refusal lands in the chat transcript, not the SpewBox. + /// + public const string TurbineChatUnavailable = "Turbine chat is not available."; } diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index f03d7836..acd5a5b4 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -76,6 +76,17 @@ public enum RuntimeChatChannel Roleplay, Society, Olthoi, + + /// + /// Campaign CH slice CH3 (2026-08-09): the legacy + /// ChatChannel.AllegianceBroadcast (0x02000000) — the monarch/ + /// speaker-permission broadcast bound to retail's @ab verb. + /// Distinct from , which is ALWAYS the Turbine + /// room now (retail's @a binds unconditionally to + /// DoTurbineChat_Allegiance once Turbine chat starts — see the + /// research doc §4.3/§6.7). + /// + AllegianceBroadcast, } public readonly record struct RuntimeChatCommand( @@ -227,9 +238,18 @@ public interface IRuntimeCharacterCommands RuntimeGenerationToken expectedGeneration, in RuntimeAdvancementCommand command); - RuntimeCommandResult SetOptions1( + /// + /// Retail SetSingleCharacterOption (GameActionType 0x0005) — + /// toggles one character option. Campaign CH slice CH3 (2026-08-09) + /// replaced the former SetOptions1 (the malformed, callerless + /// full-Options1-blob 0x01A1 builder) with this: the only wire message + /// that actually changes Turbine room membership for the six + /// ListenTo*Chat ids. + /// + RuntimeCommandResult SetSingleOption( RuntimeGenerationToken expectedGeneration, - uint options); + uint optionId, + bool value); } public enum RuntimeFriendCommandKind diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 587d1f92..ffba4abb 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -2,6 +2,7 @@ using AcDream.Core.Player; using AcDream.Core.Net.Messages; using AcDream.Core.Spells; using AcDream.Core.Items; +using AcDream.Core.Properties; namespace AcDream.Runtime.Gameplay; @@ -107,6 +108,27 @@ public sealed class RuntimeCharacterState : IDisposable public IRuntimeCharacterView View { get; } public bool IsDisposed => _disposed; + /// + /// Campaign CH slice CH3 (2026-08-09): retail + /// PlayerModule::IsOlthoi / ACE Player.IsOlthoiPlayer + /// (Player.cs:169, HeritageGroup == Olthoi || + /// HeritageGroup == OlthoiAcid) — the gate + /// TurbineChatMembershipGate consults for the Olthoi room instead + /// of a Hear*Chat option (there is no such toggle; only heritage + /// gates that room). Reads the already-parsed + /// PropertyInt.HeritageGroup (188) off the local player's own + /// property bundle — 0 (unparsed/unknown) reads as "not Olthoi", matching + /// every other heritage-gated check in this codebase. + /// + public bool IsOlthoiPlayer + { + get + { + int heritage = LocalPlayer.Properties.GetInt((uint)PropertyInt.HeritageGroup, 0); + return heritage == 12 || heritage == 13; // HeritageGroup.Olthoi / OlthoiAcid + } + } + /// Retail CommandInterpreter::GetAutonomyLevel. public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel); diff --git a/src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs b/src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs new file mode 100644 index 00000000..475325f7 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs @@ -0,0 +1,143 @@ +using AcDream.Core.Chat; +using AcDream.Core.Net.Messages; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Outcome of . +/// +public enum TurbineChatGateStatus +{ + /// The send may proceed to the wire. + Allowed, + + /// + /// Turbine chat is disabled, or this room's id is still 0 (not + /// populated by SetTurbineChatChannels — e.g. no allegiance, no + /// society). Retail: "Turbine chat is not available." + /// (), raised + /// locally with NOTHING sent. + /// + Unavailable, + + /// + /// The room exists and Turbine chat is enabled, but the player's own + /// Hear*Chat option (or, for Olthoi, heritage) is off. Retail: + /// WeenieErrorWithString 0x0551 YouAreNotListeningTo_Channel, + /// raised locally with NOTHING sent. + /// + NotListening, +} + +/// One evaluated gate decision, carrying the room/chatType/display +/// name a caller needs whether the send proceeds or gets refused. +public readonly record struct TurbineChatGateResult( + TurbineChatGateStatus Status, + uint RoomId, + uint ChatType, + string DisplayName); + +/// +/// Retail's local pre-send membership gate for a Turbine-room chat channel, +/// ported from ClientCommunicationSystem::SendTurbineChat @0x0057db10 +/// (Sept 2013 EoR build; research doc +/// docs/research/2026-08-09-chat-side-channels-vs-ace.md §4.2/§6.2). +/// +/// +/// Both the graphical (LiveSessionCommandRouter) and headless +/// (DirectGameRuntimeCommandAdapter) outbound chat paths call this +/// SAME chokepoint so the two hosts can never diverge on which channels are +/// joined and which local refusal a blocked send produces. Retail refuses +/// LOCALLY (nothing reaches the wire) in exactly two cases — Turbine chat +/// off / room id 0, or the player's own Hear*Chat option off — before +/// ever building a TurbineChatBlob. +/// +/// +public static class TurbineChatMembershipGate +{ + public static TurbineChatGateResult Evaluate( + ChatChannelKindLite kind, + TurbineChatState turbineChat, + RuntimeCharacterOptionsState options, + bool isOlthoiPlayer) + { + ArgumentNullException.ThrowIfNull(turbineChat); + ArgumentNullException.ThrowIfNull(options); + + (uint room, uint chatType, string name) = kind switch + { + ChatChannelKindLite.Allegiance => ( + turbineChat.AllegianceRoom, + (uint)TurbineChat.ChatType.Allegiance, + "Allegiance"), + ChatChannelKindLite.General => ( + turbineChat.GeneralRoom, + (uint)TurbineChat.ChatType.General, + "General"), + ChatChannelKindLite.Trade => ( + turbineChat.TradeRoom, + (uint)TurbineChat.ChatType.Trade, + "Trade"), + ChatChannelKindLite.Lfg => ( + turbineChat.LfgRoom, + (uint)TurbineChat.ChatType.Lfg, + "LFG"), + ChatChannelKindLite.Roleplay => ( + turbineChat.RoleplayRoom, + (uint)TurbineChat.ChatType.Roleplay, + "Roleplay"), + ChatChannelKindLite.Society => ( + turbineChat.SocietyRoom, + (uint)TurbineChat.ChatType.Society, + "Society"), + ChatChannelKindLite.Olthoi => ( + turbineChat.OlthoiRoom, + (uint)TurbineChat.ChatType.Olthoi, + "Olthoi"), + _ => (0u, 0u, string.Empty), + }; + + if (!turbineChat.Enabled || room == 0u) + { + return new TurbineChatGateResult( + TurbineChatGateStatus.Unavailable, room, chatType, name); + } + + bool hearOption = kind switch + { + ChatChannelKindLite.Allegiance => + (options.Options1 + & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat) + != 0u, + ChatChannelKindLite.General => + (options.Options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat) + != 0u, + ChatChannelKindLite.Trade => + (options.Options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat) + != 0u, + ChatChannelKindLite.Lfg => + (options.Options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat) + != 0u, + ChatChannelKindLite.Roleplay => + (options.Options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat) + != 0u, + ChatChannelKindLite.Society => + (options.Options2 + & (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat) + != 0u, + // Retail's Olthoi entry point passes CPlayerSystem::IsOlthoi() + // as its hearOption argument instead of a PlayerModule flag — + // there is no "Hear Olthoi chat" toggle, only heritage. + ChatChannelKindLite.Olthoi => isOlthoiPlayer, + _ => true, + }; + + return hearOption + ? new TurbineChatGateResult(TurbineChatGateStatus.Allowed, room, chatType, name) + : new TurbineChatGateResult(TurbineChatGateStatus.NotListening, room, chatType, name); + } +} diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index f4daf6ea..fb716654 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -649,15 +649,16 @@ public sealed class DirectGameRuntimeCommandAdapter command.StatId); } - public RuntimeCommandResult SetOptions1( + public RuntimeCommandResult SetSingleOption( RuntimeGenerationToken expectedGeneration, - uint options) + uint optionId, + bool value) { RuntimeCommandStatus gate = Validate(expectedGeneration, out WorldSession? session); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - session!.SendSetCharacterOptions(options); + session!.SendSetSingleCharacterOption(optionId, value); return EmitResult( RuntimeCommandDomain.Character, operation: 4, @@ -951,31 +952,49 @@ public sealed class DirectGameRuntimeCommandAdapter RuntimeChatChannel channel, string text) { - if (TryMapTurbine( - channel, - out ChatChannelKindLite turbineKind, - out TurbineChat.ChatType chatType)) + if (TryMapTurbine(channel, out ChatChannelKindLite turbineKind)) { - TurbineChatState state = - _runtime.CommunicationOwner.TurbineChat; - uint roomId = state.RoomFor(turbineKind); - if (state.Enabled && roomId != 0u) + // CH3 (2026-08-09): the SAME membership gate the graphical host + // uses (LiveSessionCommandRouter.RouteTurbineChat) — retail + // SendTurbineChat @0x0057db10 never falls through to the legacy + // ChatChannel bitflag; it refuses locally instead. + TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( + turbineKind, + _runtime.CommunicationOwner.TurbineChat, + _runtime.CharacterOwner.Options, + _runtime.CharacterOwner.IsOlthoiPlayer); + + switch (gate.Status) { - session.SendTurbineChatTo( - roomId, - (uint)chatType, - (uint)TurbineChat.DispatchType.SendToRoomById, - _runtime.PlayerIdentity.ServerGuid, - text, - state.NextContextId()); - return true; + case TurbineChatGateStatus.Unavailable: + _runtime.CommunicationOwner.AddText( + ClientTextRefusals.TurbineChatUnavailable, + RetailLogTextType.Default); + return true; + case TurbineChatGateStatus.NotListening: + { + (string? refusal, RetailLogTextType type) = + WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName); + if (refusal is not null) + _runtime.CommunicationOwner.AddText(refusal, type); + return true; + } } + + session.SendTurbineChatTo( + gate.RoomId, + gate.ChatType, + (uint)TurbineChat.DispatchType.SendToRoomById, + _runtime.PlayerIdentity.ServerGuid, + text, + _runtime.CommunicationOwner.TurbineChat.NextContextId()); + return true; } uint? legacyChannel = channel switch { RuntimeChatChannel.Fellowship => 0x00000800u, - RuntimeChatChannel.Allegiance => 0x02000000u, + RuntimeChatChannel.AllegianceBroadcast => 0x02000000u, RuntimeChatChannel.Vassals => 0x00001000u, RuntimeChatChannel.Patron => 0x00002000u, RuntimeChatChannel.Monarch => 0x00004000u, @@ -990,32 +1009,17 @@ public sealed class DirectGameRuntimeCommandAdapter private static bool TryMapTurbine( RuntimeChatChannel channel, - out ChatChannelKindLite kind, - out TurbineChat.ChatType chatType) + out ChatChannelKindLite kind) { - (kind, chatType) = channel switch + kind = channel switch { - RuntimeChatChannel.Allegiance => ( - ChatChannelKindLite.Allegiance, - TurbineChat.ChatType.Allegiance), - RuntimeChatChannel.General => ( - ChatChannelKindLite.General, - TurbineChat.ChatType.General), - RuntimeChatChannel.Trade => ( - ChatChannelKindLite.Trade, - TurbineChat.ChatType.Trade), - RuntimeChatChannel.LookingForGroup => ( - ChatChannelKindLite.Lfg, - TurbineChat.ChatType.Lfg), - RuntimeChatChannel.Roleplay => ( - ChatChannelKindLite.Roleplay, - TurbineChat.ChatType.Roleplay), - RuntimeChatChannel.Society => ( - ChatChannelKindLite.Society, - TurbineChat.ChatType.Society), - RuntimeChatChannel.Olthoi => ( - ChatChannelKindLite.Olthoi, - TurbineChat.ChatType.Olthoi), + RuntimeChatChannel.Allegiance => ChatChannelKindLite.Allegiance, + RuntimeChatChannel.General => ChatChannelKindLite.General, + RuntimeChatChannel.Trade => ChatChannelKindLite.Trade, + RuntimeChatChannel.LookingForGroup => ChatChannelKindLite.Lfg, + RuntimeChatChannel.Roleplay => ChatChannelKindLite.Roleplay, + RuntimeChatChannel.Society => ChatChannelKindLite.Society, + RuntimeChatChannel.Olthoi => ChatChannelKindLite.Olthoi, _ => default, }; return channel is RuntimeChatChannel.Allegiance diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 84fefa01..a8e1e7e1 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -57,7 +57,15 @@ public sealed record LiveCharacterSessionBindings( // OnSkillsUpdated's existing shape. Optional/nullable so every existing // caller (including Headless's OnSkillsUpdated: null pattern) compiles // unchanged. - Action? OnMovementStatsUpdated = null); + Action? OnMovementStatsUpdated = null, + // Campaign CH slice CH3 (2026-08-09): fires with the raw + // (options1, options2) pair whenever a fresh PlayerDescription lands — + // AFTER Character.Options.Replace has already committed them. Lets the + // graphical host reseed its Settings "Hear * Chat" draft from server + // truth (research doc §5.2/§6.4: the local ChatSettings.Default lies + // relative to ACE's CharacterOptions2.Default). Optional/nullable so + // every existing caller compiles unchanged. + Action? OnCharacterOptionsChanged = null); public sealed record LiveSocialSessionBindings( ChatLog Chat, @@ -197,7 +205,11 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting friends: social.Friends, squelch: social.Squelch, onDesiredComponents: null, - onCharacterOptions: character.Character.Options.Replace, + onCharacterOptions: (options1, options2) => + { + character.Character.Options.Replace(options1, options2); + character.OnCharacterOptionsChanged?.Invoke(options1, options2); + }, clientTime: character.ClientTime, externalContainers: inventory.ExternalContainers, vendor: inventory.Vendor, @@ -501,19 +513,40 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed) { - if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message) - return; + switch (parsed.Body) + { + case TurbineChat.Payload.EventSendToRoom message: + // message.RoomId is an opaque per-session Turbine room GUID, + // not a legacy channel bitflag — ChatLog.OnChannelBroadcast's + // default (legacy-bit) LogTextType derivation would + // misclassify it, so the room's own ChatType maps to + // LogTextType explicitly here instead. + chat.OnChannelBroadcast( + message.RoomId, + message.SenderName, + message.Message, + logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType), + channelName: TurbineChatDisplayNames.Resolve( + message.RoomId, + message.ChatType)); + return; - // message.RoomId is an opaque per-session Turbine room GUID, not a - // legacy channel bitflag — ChatLog.OnChannelBroadcast's default - // (legacy-bit) LogTextType derivation would misclassify it, so the - // room's own ChatType maps to LogTextType explicitly here instead. - chat.OnChannelBroadcast( - message.RoomId, - message.SenderName, - message.Message, - logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType), - channelName: TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType)); + case TurbineChat.Payload.Response { HResult: not 0 } response: + // CH3 (2026-08-09, research doc §2.7/§6.6): previously + // discarded unconditionally — a server-side send rejection + // was completely invisible. HResult==0 (the overwhelmingly + // common case) stays silent, matching retail's own quiet + // success ack. + chat.OnSystemMessage( + "TurbineChat send rejected " + + $"(hresult=0x{unchecked((uint)response.HResult):X8}).", + (uint)RetailLogTextType.Default); + return; + + default: + // Response with HResult==0, or Unknown — nothing to surface. + return; + } } private static void Validate( diff --git a/src/AcDream.UI.Abstractions/ChannelResolver.cs b/src/AcDream.UI.Abstractions/ChannelResolver.cs index f734a9ae..f357efc5 100644 --- a/src/AcDream.UI.Abstractions/ChannelResolver.cs +++ b/src/AcDream.UI.Abstractions/ChannelResolver.cs @@ -37,7 +37,11 @@ public static class ChannelResolver // CoVassals = 0x01000000 // AllegianceBroadcast = 0x02000000 ChatChannelKind.Fellowship => new Resolved(0x00000800u, "Fellowship"), - ChatChannelKind.Allegiance => new Resolved(0x02000000u, "Allegiance"), + // CH3 (2026-08-09): Allegiance itself is ALWAYS Turbine now — see + // ChatChannelKind.AllegianceBroadcast's doc comment. This resolver + // handles only the legacy pipeline, so the id 0x02000000 moved to + // that dedicated kind (retail's @ab verb). + ChatChannelKind.AllegianceBroadcast => new Resolved(0x02000000u, "Allegiance"), ChatChannelKind.Vassals => new Resolved(0x00001000u, "Vassals"), ChatChannelKind.Patron => new Resolved(0x00002000u, "Patron"), ChatChannelKind.Monarch => new Resolved(0x00004000u, "Monarch"), diff --git a/src/AcDream.UI.Abstractions/ChatChannelKind.cs b/src/AcDream.UI.Abstractions/ChatChannelKind.cs index f31db4c6..a1861c5a 100644 --- a/src/AcDream.UI.Abstractions/ChatChannelKind.cs +++ b/src/AcDream.UI.Abstractions/ChatChannelKind.cs @@ -11,10 +11,17 @@ namespace AcDream.UI.Abstractions; /// /// Channels split into: /// -/// Legacy (Fellowship..CoVassals): map to a fixed ChatChannel -/// bitflag id via and ride 0x0147 ChatChannel. -/// Turbine (General..Olthoi): require a TurbineChat channel id -/// resolved at runtime — not yet wired (Phase I.6 owns TurbineChat). +/// Legacy (Fellowship, Vassals, Patron, Monarch, CoVassals, +/// AllegianceBroadcast): map to a fixed ChatChannel bitflag id +/// via and ride 0x0147 ChatChannel. +/// Turbine (Allegiance, General, Trade, Lfg, Roleplay, Society, +/// Olthoi): resolve to a server-assigned Turbine room id at runtime via +/// TurbineChatMembershipGate and ride 0xF7DE TurbineChat. +/// Campaign CH slice CH3 (2026-08-09) moved +/// here unconditionally — retail's @a binds to +/// DoTurbineChat_Allegiance, never the legacy bitflag; see +/// for the legacy sibling +/// (retail's @ab). /// Say / Tell: route to the dedicated 0x0015 / 0x005D /// opcodes — no channel id needed. /// @@ -36,5 +43,17 @@ public enum ChatChannelKind Roleplay, Society, Olthoi, + + /// + /// Campaign CH slice CH3 (2026-08-09): the legacy + /// ChatChannel.AllegianceBroadcast (0x02000000) bound to retail's + /// @ab verb — a monarch/speaker-permission broadcast over the + /// legacy 0x0147 pipe. Distinct from , which is + /// ALWAYS the Turbine room now: retail's @a binds unconditionally + /// to DoTurbineChat_Allegiance once Turbine chat starts (research + /// doc §4.3), so it never falls back to this bitflag. + /// + AllegianceBroadcast, + Unknown, } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index eeea0d6c..dcbde52c 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -62,6 +62,13 @@ public static class ChatInputParser ("/fellowship", ChatChannelKind.Fellowship), ("/a", ChatChannelKind.Allegiance), ("/allegiance", 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" + // was deliberately NOT added: it is not a registered retail verb + // (only "ab" is), and CH3's scope is "cross-check the registry; + // do not add verbs it doesn't have." + ("/ab", ChatChannelKind.AllegianceBroadcast), ("/m", ChatChannelKind.Monarch), ("/monarch", ChatChannelKind.Monarch), ("/p", ChatChannelKind.Patron), diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs index 74972fc9..a5f417b0 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs @@ -9,12 +9,17 @@ namespace AcDream.UI.Abstractions.Panels.Settings; /// retail bit values. /// /// -/// L.0 scope: local-only like the rest of L.0. The Hear*Chat -/// flags affect client-side display filtering of the existing -/// channels — the server still streams every line; the client decides -/// what to render. Server-sync arrives in a later phase that flips the -/// retail-faithful "tell server which channels I'm subscribed to" -/// switch. +/// Campaign CH slice CH3 (2026-08-09): these five Hear*Chat flags +/// are no longer local-only display filters — they ARE retail's server-side +/// Turbine room membership. The App host seeds this draft from +/// RuntimeCharacterOptionsState.Options2 (already parsed out of +/// PlayerDescription) whenever a fresh description lands, and Save publishes +/// any changed bit through SetSingleCharacterOption (0x0005) — +/// RuntimeSettingsController.SyncChatFromServerOptions / +/// SaveChat. There is no sixth toggle for +/// HearAllegianceChat (CharacterOptions1 0x40000000): retail's own +/// Settings UI does not expose one either — Allegiance chat membership rides +/// allegiance membership, not a standalone preference. /// /// public sealed record ChatSettings( diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs index da2f59f3..88f08ca6 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs @@ -215,6 +215,22 @@ public sealed class SettingsVM _chatDraft = value ?? throw new ArgumentNullException(nameof(value)); } + /// + /// Apply one externally learned Chat change (server truth — CH3, + /// 2026-08-09) to both snapshots, matching + /// 's shape. Existing unsaved + /// edits to unrelated fields remain drafts rather than being accidentally + /// promoted to persisted state. + /// + public void ApplyExternalChatChange(Func update) + { + ArgumentNullException.ThrowIfNull(update); + _chatPersisted = update(_chatPersisted) + ?? throw new InvalidOperationException("Chat update returned null."); + _chatDraft = update(_chatDraft) + ?? throw new InvalidOperationException("Chat update returned null."); + } + /// /// Replace the entire Character draft with . /// Per-toon — the host knows which toon's bag we're editing because diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index 8c2a356d..7483d0ae 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -301,9 +301,10 @@ public sealed class InteractionUiRuntimeSourcesTests in RuntimeAdvancementCommand command) => Accepted(expectedGeneration, command.StatId); - public RuntimeCommandResult SetOptions1( + public RuntimeCommandResult SetSingleOption( RuntimeGenerationToken expectedGeneration, - uint options) => + uint optionId, + bool value) => Accepted(expectedGeneration); private RuntimeCommandResult Accepted( diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 162d8c1b..6ad819fc 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -71,17 +71,16 @@ public sealed class LiveSessionCommandRouterTests Assert.Equal([("Friend", "hello")], tells); Assert.Equal([(0x00000800u, "group")], channels); + // CH3 (2026-08-09): Fellowship is one of the ACE server-echoing + // legacy channels (resends with an empty sender) — the router must + // NOT also emit a local optimistic echo, or the line double-prints. + // Only the Tell echo (which the server never resends) survives. Assert.Collection( chat.Snapshot(), entry => { Assert.Equal(ChatKind.Tell, entry.Kind); Assert.Equal("Friend", entry.Sender); - }, - entry => - { - Assert.Equal(ChatKind.Channel, entry.Kind); - Assert.Equal("Fellowship", entry.ChannelName); }); } @@ -124,6 +123,151 @@ public sealed class LiveSessionCommandRouterTests Assert.Equal(0, chat.Count); } + // ── Campaign CH slice CH3 (2026-08-09): Turbine membership gate ── + + [Fact] + public void TurbineUnavailable_RaisesRetailStringThroughAddText_AndSendsNothing() + { + var communication = new RuntimeCommunicationState(); + var sent = new List(); + var router = NewRouter( + chat: communication.Chat, + communication: communication, + turbine: new TurbineChatState(), // never received SetTurbineChatChannels + sendTurbine: (_, _, _, _, text, _) => sent.Add(text)); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.General, null, "hello")); + + Assert.Empty(sent); + Assert.Equal(1, communication.Chat.Count); + Assert.Equal( + "Turbine chat is not available.", + communication.Chat.Snapshot()[0].Text); + } + + [Fact] + public void HearOptionOff_RaisesNotListeningRefusal_AndSendsNothing() + { + // Fresh RuntimeCharacterOptionsState defaults omit HearRoleplayChat + // (matches ACE CharacterOptions2.Default) — a populated Roleplay + // room must still refuse locally rather than send. + var turbine = new TurbineChatState(); + turbine.OnChannelsReceived( + 0u, 0u, 0u, 0u, roleplayRoom: 0x14u, 0u, 0u, 0u, 0u, 0u); + var communication = new RuntimeCommunicationState(); + var sent = new List(); + var router = NewRouter( + chat: communication.Chat, + communication: communication, + turbine: turbine, + sendTurbine: (_, _, _, _, text, _) => sent.Add(text)); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.Roleplay, null, "hi")); + + Assert.Empty(sent); + Assert.Equal(1, communication.Chat.Count); + Assert.Equal( + "You are not listening to the Roleplay channel!", + communication.Chat.Snapshot()[0].Text); + } + + [Fact] + public void AllegianceWithNoRoom_RefusesLocally_NeverDowngradesToLegacyChannel() + { + // The CH3 headline /a bug (research doc §5.3): retail's @a is bound + // unconditionally to Turbine — no allegiance must refuse locally, + // NOT silently fall through to the legacy AllegianceBroadcast + // bitflag. + var communication = new RuntimeCommunicationState(); + var legacySent = new List<(uint Id, string Text)>(); + var turbineSent = new List(); + var router = NewRouter( + chat: communication.Chat, + communication: communication, + turbine: new TurbineChatState(), // AllegianceRoom stays 0 + sendChannel: (id, text) => legacySent.Add((id, text)), + sendTurbine: (_, _, _, _, text, _) => turbineSent.Add(text)); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.Allegiance, null, "guild hi")); + + Assert.Empty(legacySent); + Assert.Empty(turbineSent); + Assert.Equal( + "Turbine chat is not available.", + communication.Chat.Snapshot()[0].Text); + } + + [Fact] + public void AllegianceBroadcast_AbVerbChannel_RoutesLegacyWithSelfEcho() + { + // /ab (retail DoAllegianceBroadcast) rides the legacy 0x0147 pipe — + // distinct from /a, which is always Turbine now. + var chat = new ChatLog(); + var legacySent = new List<(uint Id, string Text)>(); + var router = NewRouter( + chat: chat, + sendChannel: (id, text) => legacySent.Add((id, text))); + router.Activate(); + + router.Publish(new SendChatCmd( + ChatChannelKind.AllegianceBroadcast, null, "to the whole allegiance")); + + Assert.Equal([(0x02000000u, "to the whole allegiance")], legacySent); + Assert.Collection( + chat.Snapshot(), + entry => + { + Assert.Equal(ChatKind.Channel, entry.Kind); + Assert.Equal("Allegiance", entry.ChannelName); + }); + } + + // ── Campaign CH slice CH3: per-channel self-echo matrix ── + + [Theory] + [InlineData(ChatChannelKind.Fellowship, 0x00000800u)] + [InlineData(ChatChannelKind.Vassals, 0x00001000u)] + [InlineData(ChatChannelKind.Patron, 0x00002000u)] + [InlineData(ChatChannelKind.Monarch, 0x00004000u)] + [InlineData(ChatChannelKind.CoVassals, 0x01000000u)] + public void ServerEchoingLegacyChannels_SkipLocalOptimisticEcho( + ChatChannelKind kind, + uint expectedId) + { + var chat = new ChatLog(); + var legacySent = new List<(uint Id, string Text)>(); + var router = NewRouter( + chat: chat, + sendChannel: (id, text) => legacySent.Add((id, text))); + router.Activate(); + + router.Publish(new SendChatCmd(kind, null, "hi")); + + Assert.Equal([(expectedId, "hi")], legacySent); + Assert.Equal(0, chat.Count); // no local echo — the server's own + // "" -sender resend is the only echo. + } + + [Fact] + public void AllegianceBroadcast_KeepsLocalOptimisticEcho() + { + var chat = new ChatLog(); + var router = NewRouter( + chat: chat, + sendChannel: (_, _) => { }); + router.Activate(); + + router.Publish(new SendChatCmd( + ChatChannelKind.AllegianceBroadcast, null, "hi")); + + Assert.Equal(1, chat.Count); // real-name broadcast includes the + // sender — no separate "" echo, so the + // client keeps its own. + } + [Fact] public void ActivateAfterDispose_IsRejected() { @@ -226,17 +370,17 @@ public sealed class LiveSessionCommandRouterTests public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert() { var shortcuts = new List(); - var options = new List(); + var options = new List<(uint OptionId, bool Value)>(); LiveSessionCommandRouter router = NewRouter( addShortcut: shortcuts.Add, - setCharacterOptions: options.Add); + sendSingleCharacterOption: (id, value) => options.Add((id, value))); router.Publish(new AddShortcutRuntimeCmd( new ShortcutEntry(1, 0x80000001u, 0u))); router.Activate(); router.Publish(new AddShortcutRuntimeCmd( new ShortcutEntry(2, 0x80000002u, 0u))); - router.Publish(new SetCharacterOptionsRuntimeCmd(0x50C4A54Au)); + router.Publish(new SetSingleCharacterOptionRuntimeCmd(0x26u, true)); router.Dispose(); router.Publish(new AddShortcutRuntimeCmd( new ShortcutEntry(3, 0x80000003u, 0u))); @@ -248,7 +392,7 @@ public sealed class LiveSessionCommandRouterTests Assert.Equal(2, entry.Index); Assert.Equal(0x80000002u, entry.ObjectId); }); - Assert.Equal([0x50C4A54Au], options); + Assert.Equal([(0x26u, true)], options); } [Fact] @@ -308,9 +452,11 @@ public sealed class LiveSessionCommandRouterTests Action? sendChannel = null, Action? sendTurbine = null, Action? addShortcut = null, - Action? setCharacterOptions = null, Action? log = null, - ClientCommandController.Bindings? clientBindings = null) => new( + ClientCommandController.Bindings? clientBindings = null, + RuntimeCommunicationState? communication = null, + RuntimeCharacterState? characterState = null, + Action? sendSingleCharacterOption = null) => new( new LiveSessionCommandBindings( clientBindings ?? NewClientBindings(), chat ?? new ChatLog(), @@ -332,7 +478,6 @@ public sealed class LiveSessionCommandRouterTests RaiseVital: (_, _) => { }, RaiseSkill: (_, _) => { }, TrainSkill: (_, _) => { }, - SetCharacterOptions: setCharacterOptions ?? (_ => { }), AddFriend: _ => { }, RemoveFriend: _ => { }, ClearFriends: () => { }, @@ -340,6 +485,9 @@ public sealed class LiveSessionCommandRouterTests ModifyCharacterSquelch: (_, _, _, _) => { }, ModifyAccountSquelch: (_, _) => { }, ModifyGlobalSquelch: (_, _) => { }, + Communication: communication ?? new RuntimeCommunicationState(), + CharacterState: characterState ?? new RuntimeCharacterState(), + SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }), Log: log)); [MethodImpl(MethodImplOptions.NoInlining)] diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 39774fee..357d3552 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -426,9 +426,10 @@ public sealed class CurrentGameRuntimeAdapterTests RuntimeAdvancementKind.Skill, StatId: 6u, Cost: 500u)); - RuntimeCommandResult options = commands.Character.SetOptions1( + RuntimeCommandResult options = commands.Character.SetSingleOption( generation, - 0x50C4A54Au); + 0x26u, + true); RuntimeCommandResult friend = commands.Social.Execute( generation, new RuntimeFriendCommand( @@ -471,7 +472,7 @@ public sealed class CurrentGameRuntimeAdapterTests static command => command is RaiseSkillRuntimeCmd); Assert.Contains( harness.Commands.Published, - static command => command is SetCharacterOptionsRuntimeCmd); + static command => command is SetSingleCharacterOptionRuntimeCmd); Assert.Contains( harness.Commands.Published, static command => command is AddFriendRuntimeCmd); @@ -514,9 +515,10 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(10u, storedDesired); int published = harness.Commands.Published.Count; - RuntimeCommandResult stale = commands.Character.SetOptions1( + RuntimeCommandResult stale = commands.Character.SetSingleOption( new RuntimeGenerationToken(generation.Value - 1), - 0u); + 0x26u, + false); Assert.Equal(RuntimeCommandStatus.StaleGeneration, stale.Status); Assert.Equal(published, harness.Commands.Published.Count); } diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index b08611ab..cbf094aa 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -1,6 +1,8 @@ using AcDream.App.Diagnostics; using AcDream.App.Rendering; using AcDream.App.Settings; +using AcDream.Core.Net.Messages; +using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Settings; @@ -195,6 +197,7 @@ public sealed class RuntimeSettingsControllerTests displayTarget, qualityTarget, uiTarget, + NullCommandBus.Instance, static _ => { }); var quality = new QualitySettings(6, 17, 4, 12, true, 7); @@ -231,6 +234,7 @@ public sealed class RuntimeSettingsControllerTests new InspectingDisplayWindowTarget(static _ => { }), new FailingQualityApplicationTarget(events, failureIndex), new RecordingUiLockTarget(events), + NullCommandBus.Instance, static _ => { }); Assert.Throws(() => @@ -291,6 +295,143 @@ public sealed class RuntimeSettingsControllerTests Assert.Equal(resolved, controller.ResolvedQuality); } + [Fact] + public void SaveChatPublishesSetSingleCharacterOptionOnlyForChangedBits() + { + // CH3 (2026-08-09): SaveChat must publish SetSingleCharacterOption + // (0x0005) for exactly the Hear*Chat bits that actually changed — + // touching one checkbox must not resend the other four. + var storage = new FakeStorage(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + static _ => { }); + var targets = new FakeRuntimeTargets([]); + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + // ChatSettings.Default starts every Hear*Chat bit true — flip one + // off first so the second edit below can flip it back on. + Assert.True(viewModel.ChatDraft.HearRoleplayChat); + viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = false }); + viewModel.Save(); + + Assert.Equal( + [((uint)CharacterOptionId.ListenToRoleplayChat, false)], + targets.SingleOptionCalls); + + targets.SingleOptionCalls.Clear(); + viewModel.SetChat(viewModel.ChatDraft with + { + HearRoleplayChat = true, + HearSocietyChat = false, + }); + viewModel.Save(); + + Assert.Equal( + [ + ((uint)CharacterOptionId.ListenToRoleplayChat, true), + ((uint)CharacterOptionId.ListenToSocietyChat, false), + ], + targets.SingleOptionCalls); + } + + [Fact] + public void SaveChatWithNoHearOptionChangePublishesNothing() + { + var controller = CreateController(); + var targets = new FakeRuntimeTargets([]); + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + // Touch a Chat field that is NOT a Hear*Chat membership bit. + viewModel.SetChat(viewModel.ChatDraft with { ShowTimestamps = false }); + viewModel.Save(); + + Assert.Empty(targets.SingleOptionCalls); + } + + [Fact] + public void SyncChatFromServerOptionsReseedsPersistedAndDraft() + { + // Research doc §5.2: ACE's CharacterOptions2.Default omits + // HearRoleplayChat/HearSocietyChat even though acdream's local + // ChatSettings.Default claims both are on. + var storage = new FakeStorage(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + static _ => { }); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + Assert.True(controller.Chat.HearRoleplayChat); + Assert.True(viewModel.ChatDraft.HearRoleplayChat); + + controller.SyncChatFromServerOptions(0x00948700u); // ACE's real default + + Assert.True(controller.Chat.HearGeneralChat); + Assert.True(controller.Chat.HearTradeChat); + Assert.True(controller.Chat.HearLFGChat); + Assert.False(controller.Chat.HearRoleplayChat); + Assert.False(controller.Chat.HearSocietyChat); + Assert.False(viewModel.ChatDraft.HearRoleplayChat); + Assert.False(viewModel.ChatDraft.HearSocietyChat); + Assert.Same(controller.Chat, storage.ChatValue); + } + + [Fact] + public void SyncChatFromServerOptionsPreservesUnsavedUnrelatedDraftEdits() + { + var controller = CreateController(); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + viewModel.SetChat(viewModel.ChatDraft with { FontSize = 18f }); + + controller.SyncChatFromServerOptions(0x00948700u); + + Assert.Equal(18f, viewModel.ChatDraft.FontSize); + Assert.False(viewModel.ChatDraft.HearRoleplayChat); + } + + [Fact] + public void SyncChatFromServerOptionsIsANoOpWhenUnchanged() + { + var storage = new FakeStorage(); + var controller = new RuntimeSettingsController( + storage, + static preset => QualitySettings.From(preset), + static _ => { }); + storage.ClearEvents(); + + // ChatSettings.Default already has all five Hear*Chat bits on — + // options2 with only those five bits set (any other bits are + // irrelevant, the sync only masks these) reproduces exactly that, + // so the sync must be a true no-op. + const uint allFiveHearBitsOn = + (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat + | (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat + | (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat + | (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat + | (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat; + controller.SyncChatFromServerOptions(allFiveHearBitsOn); + + Assert.Equal(0, storage.ChatSaves); + } + [Fact] public void DraftPreviewAndExternalCommandsShareCanonicalState() { @@ -910,6 +1051,14 @@ public sealed class RuntimeSettingsControllerTests throw new InvalidOperationException("UI-lock target failed"); } } + + public List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = []; + + public void SetSingleCharacterOption(uint optionId, bool value) + { + SingleOptionCalls.Add((optionId, value)); + events.Add($"target-single-option:0x{optionId:X}:{value}"); + } } private sealed class InspectingDisplayWindowTarget( @@ -1048,6 +1197,8 @@ public sealed class RuntimeSettingsControllerTests public int GameplaySaves { get; private set; } + public int ChatSaves { get; private set; } + public string? LastLoadedCharacter { get; private set; } public bool ThrowOnDisplaySave { get; init; } @@ -1128,6 +1279,7 @@ public sealed class RuntimeSettingsControllerTests public void SaveChat(ChatSettings chat) { + ChatSaves++; _events.Add("save-chat"); if (ThrowOnChatSave) throw new IOException("chat persistence failed"); diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs index 4cf08110..ab0fc87c 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs @@ -102,29 +102,32 @@ public sealed class SocialActionsTests } [Fact] - public void BuildSetCharacterOptions_HasBitmap() + public void BuildSetSingleCharacterOption_HasOptionIdThenValue() { - byte[] body = SocialActions.BuildSetCharacterOptions(seq: 1, optionsBitmap: 0xDEADBEEFu); - Assert.Equal(0xDEADBEEFu, + // ACE GameActionSetSingleCharacterOption.cs:11-12 and holtburger + // SetSingleCharacterOptionActionData::pack: u32 option, u32 value. + byte[] body = SocialActions.BuildSetSingleCharacterOption( + seq: 1, optionId: 0x26u, value: true); + + Assert.Equal(20, body.Length); + Assert.Equal(SocialActions.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body)); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(SocialActions.SetSingleCharacterOptionOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x26u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(1u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); } [Fact] - public void BuildAddChannel_ContainsName() + public void BuildSetSingleCharacterOption_FalseValueEncodesZero() { - byte[] body = SocialActions.BuildAddChannel(seq: 1, channelName: "Trade"); - Assert.Equal(SocialActions.AddChannelOpcode, - BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); - ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12)); - Assert.Equal(5, len); - Assert.Equal("Trade", Encoding.ASCII.GetString(body.AsSpan(14, 5))); - } + byte[] body = SocialActions.BuildSetSingleCharacterOption( + seq: 2, optionId: 0x23u, value: false); - [Fact] - public void BuildRemoveChannel_HasOpcode0x0146() - { - byte[] body = SocialActions.BuildRemoveChannel(seq: 1, channelName: "General"); - Assert.Equal(SocialActions.RemoveChannelOpcode, - BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index 9179d1e9..6a707308 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -1,3 +1,5 @@ +using AcDream.Core.Items; +using AcDream.Core.Properties; using AcDream.Core.Spells; using AcDream.Core.Player; using AcDream.Runtime.Gameplay; @@ -6,6 +8,42 @@ namespace AcDream.Runtime.Tests.Gameplay; public sealed class RuntimeCharacterStateTests { + // ── Campaign CH slice CH3 (2026-08-09): IsOlthoiPlayer ── + + [Fact] + public void IsOlthoiPlayer_FalseByDefault_NoHeritageParsedYet() + { + using var state = new RuntimeCharacterState(); + + Assert.False(state.IsOlthoiPlayer); + } + + [Theory] + [InlineData(12)] // HeritageGroup.Olthoi + [InlineData(13)] // HeritageGroup.OlthoiAcid + public void IsOlthoiPlayer_TrueForOlthoiHeritageGroups(int heritageGroup) + { + using var state = new RuntimeCharacterState(); + var properties = new PropertyBundle(); + properties.Ints[(uint)PropertyInt.HeritageGroup] = heritageGroup; + + state.LocalPlayer.OnProperties(properties); + + Assert.True(state.IsOlthoiPlayer); + } + + [Fact] + public void IsOlthoiPlayer_FalseForNonOlthoiHeritage() + { + using var state = new RuntimeCharacterState(); + var properties = new PropertyBundle(); + properties.Ints[(uint)PropertyInt.HeritageGroup] = 1; // Aluvian + + state.LocalPlayer.OnProperties(properties); + + Assert.False(state.IsOlthoiPlayer); + } + [Fact] public void OwnsOneCoupledSpellbookAndLocalPlayerGraph() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs new file mode 100644 index 00000000..e3886c00 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs @@ -0,0 +1,185 @@ +using AcDream.Core.Chat; +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign CH slice CH3 (2026-08-09): pins the retail +/// SendTurbineChat @0x0057db10 local pre-send gate — the fix for +/// "side channels don't work" (research doc §5.2/§6.2). +/// +public sealed class TurbineChatMembershipGateTests +{ + [Fact] + public void TurbineDisabled_ReturnsUnavailable_EvenWithRoomIdAndOptionOn() + { + var turbine = new TurbineChatState(); // never received SetTurbineChatChannels + var options = new RuntimeCharacterOptionsState(); + + TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false); + + Assert.Equal(TurbineChatGateStatus.Unavailable, result.Status); + Assert.Equal(0u, result.RoomId); + } + + [Fact] + public void RoomIdZero_ReturnsUnavailable_NoAllegiance() + { + TurbineChatState turbine = ReceivedRooms(allegianceRoom: 0u); + var options = new RuntimeCharacterOptionsState(); + + TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Allegiance, turbine, options, isOlthoiPlayer: false); + + Assert.Equal(TurbineChatGateStatus.Unavailable, result.Status); + } + + [Theory] + [InlineData(ChatChannelKindLite.General)] + [InlineData(ChatChannelKindLite.Trade)] + [InlineData(ChatChannelKindLite.Lfg)] + [InlineData(ChatChannelKindLite.Roleplay)] + [InlineData(ChatChannelKindLite.Society)] + public void HearOptionOff_ReturnsNotListening_RoomIdAndTypeStillReported( + ChatChannelKindLite kind) + { + TurbineChatState turbine = ReceivedRooms(); + var options = new RuntimeCharacterOptionsState(); + options.Replace(options.Options1, 0u); // every Hear*Chat bit off + + TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate( + kind, turbine, options, isOlthoiPlayer: false); + + Assert.Equal(TurbineChatGateStatus.NotListening, result.Status); + Assert.NotEqual(0u, result.RoomId); + Assert.NotEqual(string.Empty, result.DisplayName); + } + + [Fact] + public void AllegianceHearOptionOff_ReturnsNotListening() + { + TurbineChatState turbine = ReceivedRooms(); + var options = new RuntimeCharacterOptionsState(); + options.Replace(0u, options.Options2); // HearAllegianceChat bit off + + TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Allegiance, turbine, options, isOlthoiPlayer: false); + + Assert.Equal(TurbineChatGateStatus.NotListening, result.Status); + } + + [Fact] + public void OlthoiRoom_GatesOnHeritageNotAnOption() + { + TurbineChatState turbine = ReceivedRooms(); + var options = new RuntimeCharacterOptionsState(); // no Hear-Olthoi bit exists + + TurbineChatGateResult notOlthoi = TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Olthoi, turbine, options, isOlthoiPlayer: false); + TurbineChatGateResult olthoi = TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Olthoi, turbine, options, isOlthoiPlayer: true); + + Assert.Equal(TurbineChatGateStatus.NotListening, notOlthoi.Status); + Assert.Equal(TurbineChatGateStatus.Allowed, olthoi.Status); + } + + [Theory] + [InlineData(ChatChannelKindLite.Allegiance, 0x10u, (uint)TurbineChat.ChatType.Allegiance, "Allegiance")] + [InlineData(ChatChannelKindLite.General, 0x11u, (uint)TurbineChat.ChatType.General, "General")] + [InlineData(ChatChannelKindLite.Trade, 0x12u, (uint)TurbineChat.ChatType.Trade, "Trade")] + [InlineData(ChatChannelKindLite.Lfg, 0x13u, (uint)TurbineChat.ChatType.Lfg, "LFG")] + [InlineData(ChatChannelKindLite.Society, 0x16u, (uint)TurbineChat.ChatType.Society, "Society")] + [InlineData(ChatChannelKindLite.Olthoi, 0x17u, (uint)TurbineChat.ChatType.Olthoi, "Olthoi")] + public void AllowedResultCarriesRoomIdChatTypeAndDisplayName( + ChatChannelKindLite kind, + uint expectedRoom, + uint expectedChatType, + string expectedName) + { + TurbineChatState turbine = ReceivedRooms( + allegianceRoom: 0x10u, + generalRoom: 0x11u, + tradeRoom: 0x12u, + lfgRoom: 0x13u, + roleplayRoom: 0x14u, + olthoiRoom: 0x17u, + societyRoom: 0x16u); + var options = new RuntimeCharacterOptionsState(); + options.Replace( + options.Options1, + options.Options2 + | (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat); + + TurbineChatGateResult result = TurbineChatMembershipGate.Evaluate( + kind, turbine, options, isOlthoiPlayer: true); + + Assert.Equal(TurbineChatGateStatus.Allowed, result.Status); + Assert.Equal(expectedRoom, result.RoomId); + Assert.Equal(expectedChatType, result.ChatType); + Assert.Equal(expectedName, result.DisplayName); + } + + [Fact] + public void FreshDefaultOptions_MatchAceMembership_RoleplayAndSocietyAreOff() + { + // The CH3 headline finding (research doc §5.2): ACE's own + // CharacterOptions2.Default (0x00948700, byte-identical to + // RuntimeCharacterOptionsState.DefaultOptions2) OMITS + // HearRoleplayChat/HearSocietyChat — General/Trade/LFG/Allegiance + // are on by default, Roleplay/Society are NOT, and a fresh + // acdream character must reproduce that exact split. + TurbineChatState turbine = ReceivedRooms(); + var options = new RuntimeCharacterOptionsState(); // untouched defaults + + Assert.Equal( + TurbineChatGateStatus.Allowed, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Allegiance, turbine, options, false).Status); + Assert.Equal( + TurbineChatGateStatus.Allowed, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, false).Status); + Assert.Equal( + TurbineChatGateStatus.Allowed, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Trade, turbine, options, false).Status); + Assert.Equal( + TurbineChatGateStatus.Allowed, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Lfg, turbine, options, false).Status); + Assert.Equal( + TurbineChatGateStatus.NotListening, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Roleplay, turbine, options, false).Status); + Assert.Equal( + TurbineChatGateStatus.NotListening, + TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.Society, turbine, options, false).Status); + } + + private static TurbineChatState ReceivedRooms( + uint allegianceRoom = 0x10u, + uint generalRoom = 0x11u, + uint tradeRoom = 0x12u, + uint lfgRoom = 0x13u, + uint roleplayRoom = 0x14u, + uint olthoiRoom = 0x15u, + uint societyRoom = 0x16u) + { + var state = new TurbineChatState(); + state.OnChannelsReceived( + allegianceRoom, + generalRoom, + tradeRoom, + lfgRoom, + roleplayRoom, + olthoiRoom, + societyRoom, + societyCelestialHandRoom: 0u, + societyEldrytchWebRoom: 0u, + societyRadiantBloodRoom: 0u); + return state; + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index 1c5edeab..df191d75 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -116,6 +116,17 @@ public sealed class DirectGameRuntimeCommandAdapterTests new RuntimeChatCommand( RuntimeChatChannel.General, "global")), + // CH3 (2026-08-09): Roleplay's room id is populated (0x14u + // above) but RuntimeCharacterOptionsState's fresh default omits + // HearRoleplayChat (matches ACE's own CharacterOptions2.Default) + // — the membership gate must refuse LOCALLY (via AddText, "You + // are not listening to the Roleplay channel!") rather than + // silently sending or silently dropping. + adapter.Chat.Execute( + runtime.Generation, + new RuntimeChatCommand( + RuntimeChatChannel.Roleplay, + "should be refused")), adapter.InventoryState.AddShortcut( runtime.Generation, new RuntimeShortcutCommand(0, 0x70000001u, 0u)), @@ -167,9 +178,10 @@ public sealed class DirectGameRuntimeCommandAdapterTests RuntimeAdvancementKind.TrainSkill, StatId: 4u, Cost: 1u)), - adapter.Character.SetOptions1( + adapter.Character.SetSingleOption( runtime.Generation, - options: 0x1234u), + optionId: 0x26u, + value: true), adapter.Social.Execute( runtime.Generation, new RuntimeFriendCommand( @@ -236,6 +248,11 @@ public sealed class DirectGameRuntimeCommandAdapterTests staleMovement.Status); Assert.False(runtime.MovementOwner.HasCommandInput); Assert.True(gameActions.Count >= 20); + Assert.Contains( + runtime.CommunicationOwner.Chat.Snapshot(), + entry => entry.Text.Contains( + "not listening to the Roleplay channel", + StringComparison.OrdinalIgnoreCase)); Assert.Contains( trace.Entries, entry => entry.Kind == RuntimeTraceKind.Command diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index 18c6f7f9..a3f4bf46 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -154,6 +154,90 @@ public sealed class LiveSessionEventRouterTests router.Dispose(); } + // ── Campaign CH slice CH3: TurbineChat ack HResult surfacing ── + + [Fact] + public void TurbineChat_ResponseWithNonZeroHResult_SurfacesAsSystemMessage() + { + using var session = NewSession(); + var chat = new ChatLog(); + var router = NewRouter(session, new Counters(), chat: chat); + + EventDelegate>( + session, nameof(session.TurbineChatReceived))( + new AcDream.Core.Net.Messages.TurbineChat.Parsed( + AcDream.Core.Net.Messages.TurbineChat.BlobType.ResponseBinary, + AcDream.Core.Net.Messages.TurbineChat.DispatchType.Unknown, + 0u, 0u, 0u, 0u, 0u, + new AcDream.Core.Net.Messages.TurbineChat.Payload.Response( + ContextId: 5u, + ResponseId: 2u, + MethodId: 2u, + HResult: -1))); + + Assert.Equal(1, chat.Count); + Assert.Contains( + "rejected", + chat.Snapshot()[0].Text, + StringComparison.OrdinalIgnoreCase); + + router.Dispose(); + } + + [Fact] + public void TurbineChat_ResponseWithZeroHResult_StaysSilent() + { + // Retail's ack is silent on ordinary success — only a genuine + // server-side rejection should surface. + using var session = NewSession(); + var chat = new ChatLog(); + var router = NewRouter(session, new Counters(), chat: chat); + + EventDelegate>( + session, nameof(session.TurbineChatReceived))( + new AcDream.Core.Net.Messages.TurbineChat.Parsed( + AcDream.Core.Net.Messages.TurbineChat.BlobType.ResponseBinary, + AcDream.Core.Net.Messages.TurbineChat.DispatchType.Unknown, + 0u, 0u, 0u, 0u, 0u, + new AcDream.Core.Net.Messages.TurbineChat.Payload.Response( + ContextId: 5u, + ResponseId: 2u, + MethodId: 2u, + HResult: 0))); + + Assert.Equal(0, chat.Count); + + router.Dispose(); + } + + [Fact] + public void TurbineChat_EventSendToRoom_StillRoutesToChannelBroadcast() + { + using var session = NewSession(); + var chat = new ChatLog(); + var router = NewRouter(session, new Counters(), chat: chat); + + EventDelegate>( + session, nameof(session.TurbineChatReceived))( + new AcDream.Core.Net.Messages.TurbineChat.Parsed( + AcDream.Core.Net.Messages.TurbineChat.BlobType.EventBinary, + AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomByName, + 0u, 0u, 0u, 0u, 0u, + new AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom( + RoomId: 2u, + SenderName: "Someone", + Message: "hi", + ExtraDataSize: 0x0Cu, + SenderId: 0x50000001u, + HResult: 0, + ChatType: 2u))); + + Assert.Equal(1, chat.Count); + Assert.Equal("hi", chat.Snapshot()[0].Text); + + router.Dispose(); + } + [Fact] public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter() { diff --git a/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs b/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs index e5115b5a..6a0fd80a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs @@ -60,7 +60,11 @@ public sealed class ChannelResolverTests // and holtburger-core/src/client/commands.rs:50-62. Six legacy channels // map to known ChatChannel ids; the rest fall through to TurbineChat. Assert.Equal((0x00000800u, "Fellowship"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Fellowship))); - Assert.Equal((0x02000000u, "Allegiance"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Allegiance))); + // CH3 (2026-08-09): 0x02000000 moved from ChatChannelKind.Allegiance + // (now ALWAYS Turbine — retail's @a binds unconditionally to + // DoTurbineChat_Allegiance) to the dedicated AllegianceBroadcast + // kind (retail's @ab verb). + Assert.Equal((0x02000000u, "Allegiance"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.AllegianceBroadcast))); Assert.Equal((0x00001000u, "Vassals"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Vassals))); Assert.Equal((0x00002000u, "Patron"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Patron))); Assert.Equal((0x00004000u, "Monarch"), AsTuple(ChannelResolver.Resolve(ChatChannelKind.Monarch))); @@ -72,10 +76,13 @@ public sealed class ChannelResolverTests { // Say + Tell are handled separately by the SendChatCmd dispatcher. // General/Trade/etc. require a TurbineChat channel id we don't yet wire. + // Allegiance (CH3, 2026-08-09) is ALWAYS Turbine now — the legacy + // resolver no longer has an entry for it. Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Say)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Tell)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.General)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Trade)); + Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Allegiance)); Assert.Null(ChannelResolver.Resolve(ChatChannelKind.Unknown)); }