From e07fba573137a660a37874633008a635f1f76ef3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 20:24:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(chat):=20CH3=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20phantom=20UN-9,=20allegiance-broadcast=20echo,=20/a=20legacy?= =?UTF-8?q?=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the Opus review of Campaign CH slice CH3 (614a1e05): - B1: UN-9 was a phantom divergence — ACE's CharacterOptions1.cs:47 OR-sum is 0x50C4A54A (its own comment confirms 1355064650), identical to acdream's literal. The wrong 0x50C48D4A existed only in the research doc. Row deleted, register §5 reverted to 4 rows, research doc corrected with dated notes. - S1/S4: AllegianceBroadcast (0x02000000) is a server-echoing channel — ACE's GameActionChatChannel handler includes the sender in its real-name Allegiance.Members broadcast (retail's DoAllegianceBroadcast has no AddTextToScroll), so the client must skip its local optimistic echo, not keep it. ChatChannelInfo.Legacy.IsSelfEchoChannel() now returns true for it; RouteLegacyChannel's comment corrected; Turbine.IsSelfEchoChannel()'s backwards comment rewritten truthfully. - S3: retail's /a stays on the legacy AllegianceBroadcast bitflag until StartupTurbineChatSystem successfully starts Turbine chat — "never started" (TurbineChatState.Enabled == false) now falls back to legacy in both LiveSessionCommandRouter.RouteChat and DirectGameRuntimeCommandAdapter.TrySendChannel, while "enabled but no allegiance room" still correctly refuses locally. - S5: added a LiveSessionEventRouter test proving the Options.Replace -> OnCharacterOptionsChanged seeding order, and RuntimeSettingsTargets / GameWindowLiveSessionOwnershipTests tests proving the concrete ICommandBus.Publish wiring and the single LiveSessionCommandSurface construction site. - S6: AP-181 rewritten to name both of retail's omitted pre-send checks (IsMessageSafe silent-drop, then IsMessageSpam) and stop misattributing either to RouteLegacyChannel, which has no such gates. - N1-N7: CharacterOptionId moved below SocialActions so its doc comment re-attaches; TurbineChatMembershipGate reuses TurbineChatDisplayNames instead of a duplicate table; the gate-to-refusal-text mapping is now shared via TurbineChatMembershipGate.ResolveRefusalText instead of duplicated in both hosts; ChatSettings.Default now matches ACE's real CharacterOptions2.Default (Roleplay/Society start off); a doc-comment clarifies only the five Hear toggles are server-backed; the register's §3 header recounted 129 -> 128. Suite: 11,964 passed / 4 skipped / 0 failed (baseline 11,957/4/0 + 7 new tests). Campaign ledger CH3 review column updated to APPROVE-WITH-FIXES. Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 7 +- docs/plans/2026-04-11-roadmap.md | 2 +- docs/plans/2026-08-09-chat-parity-campaign.md | 46 ++++++++- .../2026-08-09-chat-side-channels-vs-ace.md | 12 ++- .../Net/LiveSessionCommandRouter.cs | 63 ++++++++----- .../Net/LiveSessionRuntimeFactory.cs | 9 +- .../Settings/RuntimeSettingsController.cs | 14 ++- .../Settings/RuntimeSettingsTargets.cs | 9 +- .../Messages/SocialActions.cs | 36 +++---- src/AcDream.Core/Chat/ChatChannelInfo.cs | 52 ++++++++--- .../Gameplay/TurbineChatMembershipGate.cs | 58 ++++++++---- .../DirectGameRuntimeCommandAdapter.cs | 35 ++++--- .../Session/LiveSessionEventRouter.cs | 8 +- .../Panels/Settings/ChatSettings.cs | 29 +++++- .../GameWindowLiveSessionOwnershipTests.cs | 22 +++++ .../Net/LiveSessionCommandRouterTests.cs | 80 ++++++++++++---- .../RuntimeSettingsControllerTests.cs | 93 ++++++++++++++----- .../Chat/ChatChannelInfoTests.cs | 16 +++- .../TurbineChatMembershipGateTests.cs | 48 ++++++++++ .../Session/LiveSessionEventRouterTests.cs | 86 ++++++++++++++++- .../Panels/Settings/ChatSettingsTests.cs | 10 +- 21 files changed, 582 insertions(+), 153 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2aae0a92..6e5e2fc1 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -170,7 +170,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 129 active rows (AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 128 active rows (recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) 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,7 +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 | +| AP-181 | **Filed 2026-08-09 (Campaign CH slice CH3, side-channel gate); corrected 2026-08-09 at the CH3 Opus review (S6) — the original text named only the spam throttle and wrongly credited `RouteLegacyChannel` with porting gates it has no code for.** Retail's `SendTurbineChat @0x0057db10` runs TWO local pre-send refusals acdream has no port for, in this order: `IsMessageSafe(text)` first (a silent drop — no wire send, no local text at all), then, only if that passes, the per-account spam throttle `IsMessageSpam()` (→ "You must wait %ds before communicating again!"). acdream's `TurbineChatMembershipGate`/`RouteTurbineChat` port the Turbine-unavailable and Hear-option gates that run BEFORE both checks in retail's own function (§4.2) and stop there — neither `IsMessageSafe` nor `IsMessageSpam` exists anywhere in acdream. `RouteLegacyChannel` is the unrelated legacy 0x0147 `ChatChannel` pipeline and has no equivalent of either check in retail OR acdream — it was never the site these two gates belonged to. | `src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs`; `src/AcDream.App/Net/LiveSessionCommandRouter.cs` (`RouteTurbineChat`) | The user's target server (local ACE) leaves `chat_requires_account_15days`/`chat_requires_player_level` etc. at their disabled defaults (research doc §3.6) and has no observed rate-limit or unsafe-content complaint; porting a client-side throttle/safety check with no server-side counterpart to validate against risks inventing a threshold retail didn't use. | A future connected gate against a server that DOES rate-limit chat, or a deliberately unsafe test string, would see every send attempted rather than refused after the first — cosmetic only, since ACE's own server-side handling (if any) still governs what actually reaches other players. | `ClientCommunicationSystem::SendTurbineChat @0x0057db10` (`IsMessageSafe`/`IsMessageSpam` branches); research doc `docs/research/2026-08-09-chat-side-channels-vs-ace.md` §4.2 | ## 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) @@ -391,7 +391,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. --- -## 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) +## 5. Unclear (UN) — 4 rows (UN-9 FILED then RETRACTED 2026-08-09 at the CH3 Opus review — the "divergence" was a copy error in `docs/research/2026-08-09-chat-side-channels-vs-ace.md`, not a real code discrepancy: ACE's own `CharacterOptions1.cs:47` OR-sum is `0x50C4A54A` (its own inline comment `// 1355064650` confirms), identical to acdream's `PlayerDescriptionParser.cs:217`; the wrong literal `0x50C48D4A` existed only in the research doc. 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 @@ -403,7 +403,6 @@ 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 dd17bad5..12f8eded 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -945,7 +945,7 @@ the way retail + holtburger expect. - **✓ SHIPPED — I.3 — `LiveCommandBus` + `WorldSession.Send{Talk,Tell,Channel}`.** Replaces `NullCommandBus.Instance` with a real handler-registry `ICommandBus`. New `SendChatCmd` record + `ChatChannelKind` enum + `ChannelResolver` legacy-id mapping (per holtburger). `WorldSession.SendTalk` / `SendTell` / `SendChannel` are 3-line wrappers around existing `ChatRequests.BuildTalk/Tell/ChatChannel`. Commit `8e6e5a0`. - **✓ SHIPPED — I.4 — `ChatPanel` input field + slash commands.** Enter-to-submit input field on `ChatPanel`; `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 input focus. Commit `f14296c`. - **✓ SHIPPED — I.5 — Holtburger inbound chat parity + Windows-1252.** `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. Commit `ff5ed9e`. -- **✓ SHIPPED — 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.** Commit `ca968fc`. +- **✓ SHIPPED — 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.** Commit `ca968fc`. - **✓ SHIPPED — I.7 — `CombatChatTranslator`.** Retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to visible damage/evasion/miss/kill events; `AttackDone` was removed from chat after named retail + ACE proved its nonzero final status is control-only. Commit `3d26c8e`, corrected 2026-07-11. - **✓ SHIPPED — I.8 — Docs alignment.** Roadmap (this file) + `docs/ISSUES.md` issues #14-#20 closed + `memory/project_chat_pipeline.md` crib + `MEMORY.md` index entry + `CLAUDE.md` UI strategy paragraph all updated to reflect Phase I shipped state. Commit `(this commit)`. diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 8b151410..44458faf 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -108,7 +108,7 @@ 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 | `614a1e05` | 11,957 passed / 4 skipped / 0 failed | not yet reviewed | pending (connected gate — see handoff below) | +| CH3 side channels | `614a1e05` | 11,957 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `(this commit)` | pending (connected gate — see handoff below) | | CH4 commands | — | — | — | — | | CH5 closeout | — | — | — | — | @@ -175,6 +175,50 @@ 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. +### CH3 Opus review fixes (2026-08-09) + +The review found the CH3 closeout above had three items that no longer +match the fixed code — corrections, not a rewrite of the historical +record: + +- **Item 5 is now WRONG for AllegianceBroadcast.** ACE's + `GameActionChatChannel` handler iterates `player.Allegiance.Members` — + the sender IS a member, so they get their own real-name line back + through the same broadcast, same as Fellow/Vassals/Patron/Monarch/ + CoVassals (a different mechanism, same self-echo consequence). + `ChatChannelInfo.Legacy.IsSelfEchoChannel()` now returns `true` for + `0x02000000` too; `RouteLegacyChannel` skips the local echo for it. +- **Item 7 is now WRONG.** `/a` is NOT unconditionally Turbine. Retail's + base binding keeps it on the legacy `AllegianceBroadcast` bitflag until + `StartupTurbineChatSystem` successfully starts Turbine chat and rebinds + it (research doc §4.3). `LiveSessionCommandRouter.RouteChat` and + `DirectGameRuntimeCommandAdapter.TrySendChannel` now special-case + `TurbineChatState.Enabled == false` to fall back to the legacy send; + `Enabled == true` with `AllegianceRoom == 0` still correctly refuses + locally ("Turbine chat is not available."). +- **Item 9's UN-9 filing was a phantom.** ACE's own + `CharacterOptions1.cs:47` OR-sum is `0x50C4A54A` (confirmed by its own + inline comment, `// 1355064650`), identical to acdream's + `PlayerDescriptionParser.cs:217` — there was never a divergence. The + wrong literal `0x50C48D4A` existed only in + `docs/research/2026-08-09-chat-side-channels-vs-ace.md`. UN-9 is deleted + from the register; AP-181 is rewritten to name BOTH of retail's omitted + pre-send checks (`IsMessageSafe` silent-drop, THEN `IsMessageSpam`) and + no longer misattributes either to `RouteLegacyChannel`. + +Also fixed this review: `ChatSettings.Default` now matches ACE's real +`CharacterOptions2.Default` (Roleplay/Society start OFF, not the +previously-claimed "all on"); `TurbineChatMembershipGate` reuses +`TurbineChatDisplayNames.Resolve` instead of a second name table; the +gate-result-to-refusal-text mapping is shared via +`TurbineChatMembershipGate.ResolveRefusalText` instead of being +duplicated in both hosts; the `CharacterOptionId` enum in +`SocialActions.cs` moved below the class so its doc comment re-attaches +correctly; `docs/plans/2026-04-11-roadmap.md` line ~948 got the same +TurbineChat-server retraction already applied at line ~429; and the +register's §3 header count was corrected from a pre-existing off-by-one +(129 claimed vs 128 actual `| AP-` rows). + **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 diff --git a/docs/research/2026-08-09-chat-side-channels-vs-ace.md b/docs/research/2026-08-09-chat-side-channels-vs-ace.md index 95d74916..6a948c60 100644 --- a/docs/research/2026-08-09-chat-side-channels-vs-ace.md +++ b/docs/research/2026-08-09-chat-side-channels-vs-ace.md @@ -300,7 +300,13 @@ and from allegiance changes. | `0x00080000` | HearSocietyChat | **NO** | `Default = 0x00948700`. `CharacterOptions1.Default` includes -`HearAllegianceChat (0x40000000)`; `CharacterOptions1.Default = 0x50C48D4A`. +`HearAllegianceChat (0x40000000)`; `CharacterOptions1.Default = 0x50C4A54A` +(corrected 2026-08-09 at the CH3 Opus review — the original filing here +had `0x50C48D4A`, a copy error with no basis in ACE's own source; ACE's +`CharacterOptions1.cs:47` OR-sum is `0x50C4A54A`, confirmed by its own +inline comment `// 1355064650`, and is identical to acdream's +`PlayerDescriptionParser.cs:217`. This wrong literal is what filed the +now-retracted UN-9 register row). `PlayerFactory.CharacterCreateSetDefaultCharacterOptions` sets exactly these two defaults on every new character. @@ -620,7 +626,9 @@ unknown channel ids are dropped with no client-visible response. as `flags`, then reads `characterOptions1` past the end of the payload. Worse, `CharacterOptionDataFlag.CharacterOptions2 = 0x40` collides with `CharacterOptions1.AllowGive = 0x40`, which **is** set in -`CharacterOptions1.Default (0x50C48D4A)` — so ACE would also try to read an +`CharacterOptions1.Default (0x50C4A54A)` (corrected 2026-08-09 at the CH3 +Opus review; see the §3.5 correction note above — same copy error, same +fix) — so ACE would also try to read an options2 value. Reachable only via `IGameRuntimeCommands.SetOptions1`, which has no production call site (grep: only tests). **Latent, but this is the exact message a Settings-sync feature would reach for.** diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 3ed9cf21..fe56bb89 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -236,11 +236,19 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting /// (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. + /// is legacy-only (0x0147) — those pipelines never overlap Turbine. + /// is the one exception, and + /// special-cases it BEFORE this table is + /// consulted: S3 (CH3 Opus review, 2026-08-09) corrected the original + /// CH3 filing (research doc §5.3) — retail's /a is bound to the + /// LEGACY AllegianceBroadcast bitflag by default and is only + /// rebound to DoTurbineChat_Allegiance once + /// StartupTurbineChatSystem successfully starts Turbine chat + /// (research doc §4.3). So "Turbine never started" (TurbineChat. + /// Enabled == false) still falls back to legacy, while "Turbine is + /// up but this character has no allegiance room" (Enabled == true, + /// AllegianceRoom == 0) correctly keeps retail's local + /// "Turbine chat is not available." refusal at the membership gate. /// private static readonly Dictionary TurbineChannelKinds = new() { @@ -285,6 +293,18 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting return; } + // S3 (CH3 Opus review, 2026-08-09): see the TurbineChannelKinds doc + // comment above — Turbine chat never having started (no 0x0295 + // SetTurbineChatChannels received at all) still routes /a through + // the legacy AllegianceBroadcast bitflag, exactly like retail's + // default binding before StartupTurbineChatSystem runs. + if (command.Channel == ChatChannelKind.Allegiance + && !bindings.TurbineChat.Enabled) + { + RouteLegacyChannel(bindings, ChatChannelKind.AllegianceBroadcast, command.Text); + return; + } + if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind)) { RouteTurbineChat(bindings, liteKind, command.Text); @@ -312,21 +332,18 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting bindings.CharacterState.Options, bindings.CharacterState.IsOlthoiPlayer); - switch (gate.Status) + // N3 (CH3 Opus review): the gate-result-to-refusal-text mapping is + // now shared with DirectGameRuntimeCommandAdapter.TrySendChannel via + // TurbineChatMembershipGate.ResolveRefusalText — this used to be an + // independent copy of the same switch. + if (gate.Status != TurbineChatGateStatus.Allowed) { - case TurbineChatGateStatus.Unavailable: - bindings.Communication.AddText( - ClientTextRefusals.TurbineChatUnavailable, - RetailLogTextType.Default); - return; - case TurbineChatGateStatus.NotListening: + if (TurbineChatMembershipGate.ResolveRefusalText(gate) is + (string refusalText, RetailLogTextType refusalType)) { - (string? refusal, RetailLogTextType type) = - WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName); - if (refusal is not null) - bindings.Communication.AddText(refusal, type); - return; + bindings.Communication.AddText(refusalText, refusalType); } + return; } uint cookie = bindings.TurbineChat.NextContextId(); @@ -366,10 +383,14 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting // 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). + // empty sender name, so a local optimistic echo double-prints. S1 + // (CH3 Opus review, 2026-08-09) corrected AllegianceBroadcast into + // this SAME group: ACE's GameActionChatChannel handler iterates + // player.Allegiance.Members and the sender is one of them, so they + // get their own line back with their real name too — a different + // mechanism (no separate ""-sender resend) but the same + // double-print risk, so it must ALSO skip the local echo (research + // doc §3.7/§5.4, corrected). bool serverEchoes = new ChatChannelInfo.Legacy( legacy.Value.ChannelId, legacy.Value.DisplayName).IsSelfEchoChannel(); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 00ead329..5f2842d8 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -313,8 +313,13 @@ internal sealed class LiveSessionRuntimeFactory 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). + // (research doc §5.2/§6.4). N4 (CH3 Opus review, 2026-08-09) + // aligned ChatSettings.Default itself to ACE's real + // CharacterOptions2.Default, but this reseed stays load-bearing + // regardless — a per-character persisted settings.json can + // still diverge from server truth (e.g. an older save, or a + // character whose allegiance/society changed), and the server + // is always authoritative. OnCharacterOptionsChanged: (_, options2) => _interaction.Settings.SyncChatFromServerOptions(options2)); } diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index 5b006d28..b3691e02 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -553,11 +553,15 @@ internal sealed class RuntimeSettingsController : /// /// 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. + /// PlayerDescription) — called whenever a fresh description lands. N4 + /// (CH3 Opus review, 2026-08-09) aligned + /// to ACE's real default (Roleplay/Society start OFF server-side), but + /// this sync remains the only way the checkbox reflects truth for a + /// character whose PERSISTED settings.json diverges from the server — + /// an older save, or a character whose allegiance/society membership + /// changed since the file was last written. The server is always + /// authoritative, regardless of what the local default or a stale save + /// says. /// public void SyncChatFromServerOptions(uint options2) { diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index 89b717b7..db46efc4 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -270,7 +270,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets /// 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. + /// other ICommandBus.Publish call site. N6 (CH3 Opus review, + /// 2026-08-09): this silent drop is safe because + /// RuntimeSettingsController.SaveChat already wrote the toggle to + /// settings.json BEFORE calling here — the local preference is never + /// lost — and the next successful connect's PlayerDescription re-runs + /// SyncChatFromServerOptions, reconciling the draft/persisted + /// snapshot back to whatever the server actually has (which may or may + /// not match the dropped toggle, since the wire send never landed). /// public void SetSingleCharacterOption(uint optionId, bool value) => _commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value)); diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index d59c04d3..da72f74d 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -23,24 +23,6 @@ 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; @@ -205,3 +187,21 @@ public static class SocialActions return result; } } + +/// +/// 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, +} diff --git a/src/AcDream.Core/Chat/ChatChannelInfo.cs b/src/AcDream.Core/Chat/ChatChannelInfo.cs index 757b4bd1..b27f2b9a 100644 --- a/src/AcDream.Core/Chat/ChatChannelInfo.cs +++ b/src/AcDream.Core/Chat/ChatChannelInfo.cs @@ -33,9 +33,14 @@ public enum ChatChannelSource /// echoes the client's own outgoing messages back on this channel /// (so the client should suppress its optimistic local echo). Per /// holtburger's predicate at chat.rs::is_self_echo_channel -/// (lines 492-507) this is true ONLY for the legacy fellowship/vassals/ -/// patron/monarch/co-vassals channels — server resends those with -/// empty sender. Turbine and tells do not echo. +/// (lines 492-507) this is true for the legacy fellowship/vassals/ +/// patron/monarch/co-vassals channels — server resends those with an +/// empty sender. S1 (CH3 Opus review, 2026-08-09) added +/// AllegianceBroadcast to this same group: ACE's GameActionChatChannel +/// handler includes the sender as an ordinary member of its real-name +/// broadcast — a different mechanism from the other five's empty-sender +/// resend, but the same consequence for the client (suppress the local +/// echo). Turbine and tells do not echo. /// /// public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Source) @@ -47,17 +52,22 @@ public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Sou public override bool IsSelfEchoChannel() { // Per holtburger: the legacy fellowship + allegiance-tree - // channels are the ones the server echoes back to the sender - // with an empty sender field. Bitflag values from + // channels are the ones the server echoes back to the sender. + // 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. + // S1 (CH3 Opus review, 2026-08-09) — corrects the original CH3 + // filing at research doc §3.7/§5.4: AllegianceBroadcast + // (0x02000000) belongs in the `true` group below, NOT the + // `false` default. ACE's GameActionChatChannel handler iterates + // player.Allegiance.Members, and the sender IS a member, so + // they receive their own line back with their REAL name — a + // different mechanism from Fellow/Vassals/Patron/Monarch/ + // CoVassals' separate ""-sender resend, but the same + // consequence: keeping a local optimistic echo double-prints. + // Retail agrees: ClientCommunicationSystem::DoAllegianceBroadcast + // @0x005761F0 calls Event_ChannelBroadcast(0x2000000, &text) + // with no AddTextToScroll of its own. return ChannelId switch { 0x00000800u => true, // Fellow @@ -65,6 +75,7 @@ public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Sou 0x00002000u => true, // Patron 0x00004000u => true, // Monarch 0x01000000u => true, // CoVassals + 0x02000000u => true, // AllegianceBroadcast _ => false, }; } @@ -86,9 +97,20 @@ public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Sou { public override bool IsSelfEchoChannel() { - // Turbine rooms do NOT echo the sender's own messages back. - // The client must emit its own optimistic local echo to give - // the player feedback that the message was sent. + // S4 (CH3 Opus review, 2026-08-09): the comment this replaced + // was wrong in both directions. ACE's TurbineChatHandler + // resends via GetAllOnline() WITH the sender included — there + // is no sender exclusion, so the sender's own outgoing line + // comes back through the SAME broadcast every other member + // gets. Retail's SendTurbineChat @0x0057db10 emits no local + // AddTextToScroll on success either way. Production correctly + // shows no local optimistic echo for Turbine channels, but NOT + // because of this return value — RouteTurbineChat + // (LiveSessionCommandRouter / DirectGameRuntimeCommandAdapter) + // never calls OnSelfSent for a Turbine send at all, so this + // method is currently unread for the Turbine variant (only + // Legacy.IsSelfEchoChannel() has a caller). Kept `false` here + // since no caller depends on the value either way. return false; } } diff --git a/src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs b/src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs index 475325f7..40259b4f 100644 --- a/src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs +++ b/src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs @@ -1,5 +1,6 @@ using AcDream.Core.Chat; using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; namespace AcDream.Runtime.Gameplay; @@ -64,38 +65,36 @@ public static class TurbineChatMembershipGate ArgumentNullException.ThrowIfNull(turbineChat); ArgumentNullException.ThrowIfNull(options); - (uint room, uint chatType, string name) = kind switch + // N2 (CH3 Opus review): reuse TurbineChatDisplayNames.Resolve — the + // SAME assembly already has this room/chatType-to-display-name + // table (LiveSessionEventRouter's inbound path uses it) rather than + // maintaining a second copy of the same seven strings here. + (uint room, uint chatType) = kind switch { ChatChannelKindLite.Allegiance => ( turbineChat.AllegianceRoom, - (uint)TurbineChat.ChatType.Allegiance, - "Allegiance"), + (uint)TurbineChat.ChatType.Allegiance), ChatChannelKindLite.General => ( turbineChat.GeneralRoom, - (uint)TurbineChat.ChatType.General, - "General"), + (uint)TurbineChat.ChatType.General), ChatChannelKindLite.Trade => ( turbineChat.TradeRoom, - (uint)TurbineChat.ChatType.Trade, - "Trade"), + (uint)TurbineChat.ChatType.Trade), ChatChannelKindLite.Lfg => ( turbineChat.LfgRoom, - (uint)TurbineChat.ChatType.Lfg, - "LFG"), + (uint)TurbineChat.ChatType.Lfg), ChatChannelKindLite.Roleplay => ( turbineChat.RoleplayRoom, - (uint)TurbineChat.ChatType.Roleplay, - "Roleplay"), + (uint)TurbineChat.ChatType.Roleplay), ChatChannelKindLite.Society => ( turbineChat.SocietyRoom, - (uint)TurbineChat.ChatType.Society, - "Society"), + (uint)TurbineChat.ChatType.Society), ChatChannelKindLite.Olthoi => ( turbineChat.OlthoiRoom, - (uint)TurbineChat.ChatType.Olthoi, - "Olthoi"), - _ => (0u, 0u, string.Empty), + (uint)TurbineChat.ChatType.Olthoi), + _ => (0u, 0u), }; + string name = TurbineChatDisplayNames.Resolve(room, chatType); if (!turbineChat.Enabled || room == 0u) { @@ -140,4 +139,31 @@ public static class TurbineChatMembershipGate ? new TurbineChatGateResult(TurbineChatGateStatus.Allowed, room, chatType, name) : new TurbineChatGateResult(TurbineChatGateStatus.NotListening, room, chatType, name); } + + /// + /// N3 (CH3 Opus review): the single shared mapping from an evaluated + /// to the retail-exact local + /// refusal text/type an AddText caller should raise instead of + /// sending — collapses the identical + /// switch (gate.Status) { case Unavailable: ...; case NotListening: + /// ...; } block that used to live independently in both + /// LiveSessionCommandRouter.RouteTurbineChat and + /// DirectGameRuntimeCommandAdapter.TrySendChannel. Returns + /// null when the gate allows the send to proceed to the wire. + /// + public static (string Text, RetailLogTextType Type)? ResolveRefusalText( + TurbineChatGateResult gate) + { + switch (gate.Status) + { + case TurbineChatGateStatus.Unavailable: + return (ClientTextRefusals.TurbineChatUnavailable, RetailLogTextType.Default); + case TurbineChatGateStatus.NotListening: + (string? text, RetailLogTextType type) = + WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName); + return text is not null ? (text, type) : null; + default: + return null; + } + } } diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index fb716654..55ec8b8c 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -952,6 +952,22 @@ public sealed class DirectGameRuntimeCommandAdapter RuntimeChatChannel channel, string text) { + // S3 (CH3 Opus review): retail's @a stays bound to the legacy + // AllegianceBroadcast bitflag (0x02000000) until + // StartupTurbineChatSystem successfully starts Turbine chat (0x0295 + // SetTurbineChatChannels received) and rebinds it to + // DoTurbineChat_Allegiance (research doc §4.3). "Turbine chat never + // started" is NOT the same case as "Turbine is up but this + // character has no allegiance room" (roomId == 0 with Enabled == + // true), which keeps the "Turbine chat is not available." local + // refusal below at the membership gate's own Unavailable branch. + if (channel == RuntimeChatChannel.Allegiance + && !_runtime.CommunicationOwner.TurbineChat.Enabled) + { + session.SendChannel(0x02000000u, text); + return true; + } + if (TryMapTurbine(channel, out ChatChannelKindLite turbineKind)) { // CH3 (2026-08-09): the SAME membership gate the graphical host @@ -964,21 +980,16 @@ public sealed class DirectGameRuntimeCommandAdapter _runtime.CharacterOwner.Options, _runtime.CharacterOwner.IsOlthoiPlayer); - switch (gate.Status) + // N3 (CH3 Opus review): shared refusal-text mapping — see + // TurbineChatMembershipGate.ResolveRefusalText. + if (gate.Status != TurbineChatGateStatus.Allowed) { - case TurbineChatGateStatus.Unavailable: - _runtime.CommunicationOwner.AddText( - ClientTextRefusals.TurbineChatUnavailable, - RetailLogTextType.Default); - return true; - case TurbineChatGateStatus.NotListening: + if (TurbineChatMembershipGate.ResolveRefusalText(gate) is + (string refusalText, RetailLogTextType refusalType)) { - (string? refusal, RetailLogTextType type) = - WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName); - if (refusal is not null) - _runtime.CommunicationOwner.AddText(refusal, type); - return true; + _runtime.CommunicationOwner.AddText(refusalText, refusalType); } + return true; } session.SendTurbineChatTo( diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index a8e1e7e1..f9de40c3 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -62,9 +62,11 @@ public sealed record LiveCharacterSessionBindings( // (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. + // truth (research doc §5.2/§6.4) so a per-character persisted + // settings.json that diverges from the server (an older save, or a + // changed allegiance/society) always converges back to what ACE + // actually has — the server, not any local default, is authoritative. + // Optional/nullable so every existing caller compiles unchanged. Action? OnCharacterOptionsChanged = null); public sealed record LiveSocialSessionBindings( diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs index a5f417b0..9591ec82 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs @@ -21,6 +21,19 @@ namespace AcDream.UI.Abstractions.Panels.Settings; /// Settings UI does not expose one either — Allegiance chat membership rides /// allegiance membership, not a standalone preference. /// +/// +/// +/// N5 (CH3 Opus review, 2026-08-09): the server-backed sync/publish +/// wiring above covers ONLY the five Hear*Chat fields. +/// , , and +/// each correspond to a real retail +/// CharacterOptions2 bit (per the field comments below) but are +/// deliberately NOT wired to SyncChatFromServerOptions or +/// SaveChat's SetSingleCharacterOption publish — they stay +/// local-only display preferences. Do not extend the sync/publish pair to +/// them without a corresponding design decision; today they are read and +/// written from settings.json alone. +/// /// public sealed record ChatSettings( // CharacterOptions2 (32-bit) channel filters. @@ -35,13 +48,23 @@ public sealed record ChatSettings( // Visual / UX (no retail bitfield). float FontSize) // chat panel font, 10..20 pt { - /// Sensible starting values matching the retail "all on" stance. + /// + /// N4 (CH3 Opus review): matches ACE's ACTUAL + /// CharacterOptions2.Default (0x00948700) stance for the five + /// server-backed Hear*Chat bits — General/Trade/LFG are on, but + /// Roleplay (0x800) and Society (0x80000) are OFF (research + /// doc §5.2). The prior "matching the retail 'all on' stance" comment + /// here was wrong: two of the five synced flags are off by default. The + /// server reseed at login (SyncChatFromServerOptions) remains + /// authoritative regardless of this constant — this is only the + /// pre-login / never-connected starting value. + /// public static ChatSettings Default { get; } = new( HearGeneralChat: true, HearTradeChat: true, HearLFGChat: true, - HearRoleplayChat: true, - HearSocietyChat: true, + HearRoleplayChat: false, + HearSocietyChat: false, AppearOffline: false, ShowTimestamps: true, FilterProfanity: true, diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs index 1b38412c..309b26f3 100644 --- a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs @@ -100,6 +100,28 @@ public sealed class GameWindowLiveSessionOwnershipTests StringComparison.Ordinal)); } + [Fact] + public void ProductionSourceConstructsOnlyOneLiveSessionCommandSurface() + { + // CH3 review S5(b): LiveSessionCommandSurface has no dependencies of + // its own — CH3 deliberately hoisted its single construction site + // (SessionPlayerComposition.cs) so RuntimeSettingsTargets and the + // retained UI's chat/inventory panels share the SAME generation- + // gated command route. A second construction site anywhere under + // src/AcDream.App would silently split that route into two, each + // with its own activation/dispose lifecycle. + string root = FindRepositoryRoot(); + string appRoot = Path.Combine(root, "src", "AcDream.App"); + + int total = Directory + .EnumerateFiles(appRoot, "*.cs", SearchOption.AllDirectories) + .Sum(path => CountOccurrences( + File.ReadAllText(path), + "new LiveSessionCommandSurface(")); + + Assert.Equal(1, total); + } + [Theory] [InlineData("TryStartLiveSession")] [InlineData("ClearInboundEntityState")] diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 6ad819fc..5168d5c9 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -174,19 +174,29 @@ public sealed class LiveSessionCommandRouterTests } [Fact] - public void AllegianceWithNoRoom_RefusesLocally_NeverDowngradesToLegacyChannel() + public void AllegianceTurbineEnabledButNoRoom_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. + // S3 (CH3 Opus review, 2026-08-09) corrected the original CH3 + // headline /a fix (research doc §5.3): retail's @a is bound to + // Turbine only once StartupTurbineChatSystem succeeds — i.e. once + // Turbine chat is ENABLED. With Turbine up but no allegiance room + // (this test), retail hits SendTurbineChat's own roomId<=0 branch + // and refuses locally — it must NOT fall through to the legacy + // AllegianceBroadcast bitflag. (Contrast the Enabled==false case + // below, which DOES fall back — Turbine was never started at all.) var communication = new RuntimeCommunicationState(); var legacySent = new List<(uint Id, string Text)>(); var turbineSent = new List(); + var turbine = new TurbineChatState(); + turbine.OnChannelsReceived( + allegianceRoom: 0u, generalRoom: 0x70000001u, tradeRoom: 0u, + lfgRoom: 0u, roleplayRoom: 0u, olthoiRoom: 0u, societyRoom: 0u, + societyCelestialHandRoom: 0u, societyEldrytchWebRoom: 0u, + societyRadiantBloodRoom: 0u); // Enabled = true, AllegianceRoom stays 0 var router = NewRouter( chat: communication.Chat, communication: communication, - turbine: new TurbineChatState(), // AllegianceRoom stays 0 + turbine: turbine, sendChannel: (id, text) => legacySent.Add((id, text)), sendTurbine: (_, _, _, _, text, _) => turbineSent.Add(text)); router.Activate(); @@ -201,10 +211,40 @@ public sealed class LiveSessionCommandRouterTests } [Fact] - public void AllegianceBroadcast_AbVerbChannel_RoutesLegacyWithSelfEcho() + public void AllegianceTurbineNeverStarted_FallsBackToLegacyAllegianceBroadcast() + { + // S3 (CH3 Opus review, 2026-08-09): retail's BASE binding keeps /a + // on the legacy AllegianceBroadcast (0x02000000) channel until + // Turbine chat successfully starts and StartupTurbineChatSystem + // rebinds it (research doc §4.3) — with NO 0x0295 + // SetTurbineChatChannels ever received, retail does not print + // "Turbine chat is not available." (that refusal only exists once + // the Turbine-bound verb has actually been armed). + var chat = new ChatLog(); + var legacySent = new List<(uint Id, string Text)>(); + var turbineSent = new List(); + var router = NewRouter( + chat: chat, + turbine: new TurbineChatState(), // never received SetTurbineChatChannels + sendChannel: (id, text) => legacySent.Add((id, text)), + sendTurbine: (_, _, _, _, text, _) => turbineSent.Add(text)); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.Allegiance, null, "guild hi")); + + Assert.Equal([(0x02000000u, "guild hi")], legacySent); + Assert.Empty(turbineSent); + // S1: AllegianceBroadcast is now a server-echoing channel — no + // local optimistic echo. + Assert.Equal(0, chat.Count); + } + + [Fact] + public void AllegianceBroadcast_AbVerbChannel_RoutesLegacyWithNoLocalEcho() { // /ab (retail DoAllegianceBroadcast) rides the legacy 0x0147 pipe — - // distinct from /a, which is always Turbine now. + // distinct from /a, which routes through Turbine whenever Turbine + // chat is enabled (see the two Allegiance tests above). var chat = new ChatLog(); var legacySent = new List<(uint Id, string Text)>(); var router = NewRouter( @@ -216,13 +256,10 @@ public sealed class LiveSessionCommandRouterTests 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); - }); + // S1 (CH3 Opus review): ACE's GameActionChatChannel handler + // includes the sender in its real-name Allegiance.Members + // broadcast, so the client must NOT also echo locally. + Assert.Equal(0, chat.Count); } // ── Campaign CH slice CH3: per-channel self-echo matrix ── @@ -252,8 +289,15 @@ public sealed class LiveSessionCommandRouterTests } [Fact] - public void AllegianceBroadcast_KeepsLocalOptimisticEcho() + public void AllegianceBroadcast_SkipsLocalOptimisticEcho() { + // S1 (CH3 Opus review, 2026-08-09): flips this test's original + // (wrong) premise. ACE's GameActionChatChannel handler iterates + // player.Allegiance.Members — the sender IS a member, so their own + // line comes back with their real name through the SAME broadcast + // every other member gets. Retail agrees: + // ClientCommunicationSystem::DoAllegianceBroadcast @0x005761F0 has + // no AddTextToScroll of its own. Keeping a local echo double-prints. var chat = new ChatLog(); var router = NewRouter( chat: chat, @@ -263,9 +307,7 @@ public sealed class LiveSessionCommandRouterTests 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. + Assert.Equal(0, chat.Count); } [Fact] diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index cbf094aa..10c86607 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -1,4 +1,5 @@ using AcDream.App.Diagnostics; +using AcDream.App.Net; using AcDream.App.Rendering; using AcDream.App.Settings; using AcDream.Core.Net.Messages; @@ -243,6 +244,33 @@ public sealed class RuntimeSettingsControllerTests } } + [Fact] + public void ConcreteRuntimeTargetPublishesSetSingleCharacterOptionOntoTheBus() + { + // CH3 review S5(b): SaveChatPublishesSetSingleCharacterOption... above + // only proves RuntimeSettingsController calls the IRuntimeSettingsTargets + // INTERFACE (via the FakeRuntimeTargets test double) — nothing exercised + // the CONCRETE RuntimeSettingsTargets.SetSingleCharacterOption, which is + // the code that actually reaches the wire via ICommandBus.Publish. A + // silent unwiring there (wrong record, wrong bus, dropped call) would + // pass every test that only goes through the fake. + var bus = new CaptureCommandBus(); + var target = new RuntimeSettingsTargets( + new InspectingDisplayWindowTarget(static _ => { }), + new RecordingQualityApplicationTarget([]), + new RecordingUiLockTarget([]), + bus, + static _ => { }); + + target.SetSingleCharacterOption( + (uint)CharacterOptionId.ListenToRoleplayChat, value: false); + + var cmd = Assert.IsType( + Assert.Single(bus.Published)); + Assert.Equal((uint)CharacterOptionId.ListenToRoleplayChat, cmd.OptionId); + Assert.False(cmd.Value); + } + [Fact] public void SettingsViewModelSavePreservesSectionAndTargetOrder() { @@ -314,28 +342,30 @@ public sealed class RuntimeSettingsControllerTests 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 }); + // N4 (CH3 Opus review): ChatSettings.Default now matches ACE's real + // CharacterOptions2.Default — Roleplay/Society start FALSE (only + // General/Trade/LFG start true). Flip Roleplay ON first so the + // second edit below can flip it back off. + Assert.False(viewModel.ChatDraft.HearRoleplayChat); + viewModel.SetChat(viewModel.ChatDraft with { HearRoleplayChat = true }); viewModel.Save(); Assert.Equal( - [((uint)CharacterOptionId.ListenToRoleplayChat, false)], + [((uint)CharacterOptionId.ListenToRoleplayChat, true)], targets.SingleOptionCalls); targets.SingleOptionCalls.Clear(); viewModel.SetChat(viewModel.ChatDraft with { - HearRoleplayChat = true, - HearSocietyChat = false, + HearRoleplayChat = false, + HearSocietyChat = true, }); viewModel.Save(); Assert.Equal( [ - ((uint)CharacterOptionId.ListenToRoleplayChat, true), - ((uint)CharacterOptionId.ListenToSocietyChat, false), + ((uint)CharacterOptionId.ListenToRoleplayChat, false), + ((uint)CharacterOptionId.ListenToSocietyChat, true), ], targets.SingleOptionCalls); } @@ -363,9 +393,20 @@ public sealed class RuntimeSettingsControllerTests 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(); + // HearRoleplayChat/HearSocietyChat. N4 (CH3 Opus review) aligned + // ChatSettings.Default to that same stance, so this test now seeds + // storage with an explicitly stale PERSISTED value (both on — e.g. + // a save from before N4, or a user who had enabled them) to prove + // the server sync corrects local state to the server's truth, + // rather than merely observing the two already agree. + var storage = new FakeStorage + { + ChatValue = ChatSettings.Default with + { + HearRoleplayChat = true, + HearSocietyChat = true, + }, + }; var controller = new RuntimeSettingsController( storage, static preset => QualitySettings.From(preset), @@ -417,17 +458,16 @@ public sealed class RuntimeSettingsControllerTests 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 = + // N4 (CH3 Opus review): ChatSettings.Default now matches ACE's real + // CharacterOptions2.Default exactly — General/Trade/LFG on, + // Roleplay/Society off. Syncing with that SAME bit pattern (any + // other bits are irrelevant, the sync only masks these five) must + // be a true no-op. + const uint aceDefaultHearBits = (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat | (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat - | (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat - | (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat - | (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat; - controller.SyncChatFromServerOptions(allFiveHearBitsOn); + | (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat; + controller.SyncChatFromServerOptions(aceDefaultHearBits); Assert.Equal(0, storage.ChatSaves); } @@ -1061,6 +1101,17 @@ public sealed class RuntimeSettingsControllerTests } } + // CH3 review S5(b): records every ICommandBus.Publish call so a test can + // assert what the CONCRETE RuntimeSettingsTargets actually put on the + // bus, rather than only what the IRuntimeSettingsTargets fake recorded. + private sealed class CaptureCommandBus : ICommandBus + { + public readonly List Published = new(); + + public void Publish(T command) where T : notnull => + Published.Add(command!); + } + private sealed class InspectingDisplayWindowTarget( Action apply) : IRuntimeDisplayWindowTarget diff --git a/tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs b/tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs index 35778d95..a34d343a 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatChannelInfoTests.cs @@ -32,13 +32,19 @@ public sealed class ChatChannelInfoTests } [Fact] - public void Legacy_AllegianceBroadcastBitflag_IsNOTSelfEcho() + public void Legacy_AllegianceBroadcastBitflag_IsSelfEcho() { - // 0x02000000 AllegianceBroadcast is the read-only motd channel — - // server does not echo client messages back on it (per holtburger - // chat.rs:492-507 predicate, only Fellow+Vassals+Patron+Monarch+CoVassals). + // S1 (CH3 Opus review, 2026-08-09): flips this test's original + // (wrong) premise. ACE's GameActionChatChannel handler iterates + // player.Allegiance.Members — the sender IS a member, so their own + // line comes back with their real name through the SAME broadcast + // every other member gets (a different mechanism from holtburger's + // documented empty-sender resend for Fellow/Vassals/Patron/Monarch/ + // CoVassals, but the same self-echo consequence). Retail agrees: + // ClientCommunicationSystem::DoAllegianceBroadcast @0x005761F0 has + // no AddTextToScroll of its own. var c = new ChatChannelInfo.Legacy(0x02000000u, "AllegianceBroadcast"); - Assert.False(c.IsSelfEchoChannel()); + Assert.True(c.IsSelfEchoChannel()); } [Fact] diff --git a/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs index e3886c00..7b2ce253 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/TurbineChatMembershipGateTests.cs @@ -159,6 +159,54 @@ public sealed class TurbineChatMembershipGateTests ChatChannelKindLite.Society, turbine, options, false).Status); } + // ── N3 (CH3 Opus review): shared refusal-text mapping ── + + [Fact] + public void ResolveRefusalText_AllowedGate_ReturnsNull() + { + TurbineChatState turbine = ReceivedRooms(); + var options = new RuntimeCharacterOptionsState(); + TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false); + + Assert.Equal(TurbineChatGateStatus.Allowed, gate.Status); + Assert.Null(TurbineChatMembershipGate.ResolveRefusalText(gate)); + } + + [Fact] + public void ResolveRefusalText_UnavailableGate_ReturnsRetailUnavailableString() + { + var turbine = new TurbineChatState(); // never received SetTurbineChatChannels + var options = new RuntimeCharacterOptionsState(); + TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false); + + (string Text, RetailLogTextType Type)? refusal = + TurbineChatMembershipGate.ResolveRefusalText(gate); + + Assert.NotNull(refusal); + Assert.Equal(ClientTextRefusals.TurbineChatUnavailable, refusal!.Value.Text); + } + + [Fact] + public void ResolveRefusalText_NotListeningGate_ReturnsWeenieErrorString() + { + TurbineChatState turbine = ReceivedRooms(); + var options = new RuntimeCharacterOptionsState(); + options.Replace(options.Options1, 0u); // every Hear*Chat bit off + TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( + ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false); + + (string Text, RetailLogTextType Type)? refusal = + TurbineChatMembershipGate.ResolveRefusalText(gate); + + Assert.NotNull(refusal); + (string? expectedText, RetailLogTextType expectedType) = + WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName); + Assert.Equal(expectedText, refusal!.Value.Text); + Assert.Equal(expectedType, refusal.Value.Type); + } + private static TurbineChatState ReceivedRooms( uint allegianceRoom = 0x10u, uint generalRoom = 0x11u, diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index a3f4bf46..6eccabc8 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -1,9 +1,11 @@ +using System.Buffers.Binary; using System.Net; using System.Reflection; using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; +using AcDream.Core.Net.Messages; using AcDream.Core.Player; using AcDream.Core.Properties; using AcDream.Core.Social; @@ -238,6 +240,56 @@ public sealed class LiveSessionEventRouterTests router.Dispose(); } + [Fact] + public void PlayerDescription_ReplacesOptionsBeforeInvokingOnCharacterOptionsChanged() + { + // CH3 review S5(a): the §6.4 seeding chain + // (LiveSessionEventRouter.cs:208-212) is + // `character.Character.Options.Replace(...)` THEN + // `character.OnCharacterOptionsChanged?.Invoke(...)`. Nothing + // previously asserted this glue lambda itself — only + // GameEventWiring.WireAll's own onCharacterOptions?.Invoke — so a + // silent unwiring of either half would go unnoticed by the suite. + using var session = NewSession(); + var character = new RuntimeCharacterState(); + var observed = new List<(uint Options1, uint Options2, uint LiveOptions1AtCallback)>(); + + var router = new LiveSessionEventRouter( + session, + NoOpEntitySink(), + NoOpEnvironmentSink(), + NewInventoryBindings(), + new LiveCharacterSessionBindings( + new CombatState(), + character, + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: null, + OnConfirmationRequest: null, + OnConfirmationDone: null, + ClientTime: () => 0d, + OnCharacterOptionsChanged: (options1, options2) => + observed.Add((options1, options2, character.Options.Options1))), + NewSocialBindings()); + router.Attach(); + + session.GameEvents.Dispatch( + GameEventEnvelope.TryParse( + WrapPlayerDescriptionEnvelope(0x50C4A54Au, 0x00948700u))!.Value); + + var (options1, options2, liveOptions1AtCallback) = Assert.Single(observed); + Assert.Equal(0x50C4A54Au, options1); + Assert.Equal(0x00948700u, options2); + // If Replace ran AFTER the callback (or not at all), a listener + // reading character.Options from inside the callback — exactly + // what a Settings-panel seed handler does — would observe the + // stale default instead of the just-received value. + Assert.Equal(0x50C4A54Au, liveOptions1AtCallback); + Assert.Equal(0x50C4A54Au, character.Options.Options1); + Assert.Equal(0x00948700u, character.Options.Options2); + + router.Dispose(); + } + [Fact] public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter() { @@ -382,7 +434,7 @@ public sealed class LiveSessionEventRouterTests const uint playerGuid = 0x50000001u; var objects = new ClientObjectTable(); var character = new RuntimeCharacterState(); - character.InstallSpellMetadata(SpellTable.LoadFromReader(new StringReader( + character.InstallSpellMetadata(SpellTable.LoadFromReader(new System.IO.StringReader( "Spell ID,Name,Flags [Hex]\n42,Strength Test,0x4\n"))); int movementStatsUpdated = 0; @@ -621,6 +673,38 @@ public sealed class LiveSessionEventRouterTests new FriendsState(), new SquelchState()); + // Minimal PlayerDescription (0x0013) body carrying only the + // CharacterOptions1/2 trailer fields — mirrors + // GameEventWiringTests.WireAll_PlayerDescription_PublishesCharacterOptions's + // fixture layout. + private static byte[] WrapPlayerDescriptionEnvelope(uint options1, uint options2) + { + var stream = new MemoryStream(); + using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true)) + { + writer.Write(0u); // property flags + writer.Write(0x52u); // player weenie type + writer.Write(0u); // vector flags + writer.Write(0u); // has health + writer.Write(0x40u); // option flags: CharacterOptions2 + writer.Write(options1); + writer.Write(0u); // legacy hotbar count + writer.Write(0u); // spellbook filters + writer.Write(options2); + writer.Write(0u); // inventory count + writer.Write(0u); // equipped count + } + + byte[] payload = stream.ToArray(); + byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)GameEventType.PlayerDescription); + Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length); + return body; + } + private static WorldSession NewSession() => new(new IPEndPoint(IPAddress.Loopback, 9)); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs index 12ec900b..2e9f6deb 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs @@ -8,14 +8,18 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings; public sealed class ChatSettingsTests { [Fact] - public void Default_values_are_all_channels_on_with_timestamps_and_filter() + public void Default_values_match_AceCharacterOptions2Default_stance() { + // N4 (CH3 Opus review): ACE's real CharacterOptions2.Default + // (0x00948700) turns General/Trade/LFG on but leaves Roleplay + // (0x800) and Society (0x80000) off — this is no longer an + // invented "all channels on" stance. var d = ChatSettings.Default; Assert.True(d.HearGeneralChat); Assert.True(d.HearTradeChat); Assert.True(d.HearLFGChat); - Assert.True(d.HearRoleplayChat); - Assert.True(d.HearSocietyChat); + Assert.False(d.HearRoleplayChat); + Assert.False(d.HearSocietyChat); Assert.False(d.AppearOffline); Assert.True(d.ShowTimestamps); Assert.True(d.FilterProfanity);