diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a5a4eacf..8d56ecbe 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -94,21 +94,6 @@ Register row: TS-69. **Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH, slice CH4). -## #362 — Four new CH4 outbound requests have no inbound response handler - -**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. `@index`, -`@clist`, `@hslist`, and `@allegiance info` send byte-correct retail -GameAction requests (`ClientCommandRequests.BuildIndexChannels`/ -`BuildListChannel`/`BuildListAvailableHouses`/`BuildAllegianceInfoRequest`), -but their GameEvent responses (`ChannelIndex 0x0149`, `ChannelList -0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`) are -registered in `GameEventType` with no `GameEventWiring` handler — ACE's -reply is silently dropped. The request itself is correct and verifiable -on the wire; only the response rendering is missing. Register row: TS-70. - -**Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH, -slice CH4). - ## #363 — Chat refusal/usage call sites are typed ClientLocal 0x00 where retail types several 0x1A **Status:** OPEN — filed 2026-08-09, CH4 REJECT-review, SHOULD-FIX 9. @@ -1949,39 +1934,6 @@ overlaps the Slice-J ownership work. If it is ever *accepted* rather than fixed, it needs a divergence-register row; it is filed here as a defect because the intent is to fix it. -## #329 — The portal wait cue arms five seconds late; retail emits it per tunnel rotation segment, unconditionally - -**Status:** OPEN -**Severity:** LOW (cosmetic, but it is a retail divergence on every single -portal, in both directions) -**Filed:** 2026-08-06, #280 retail-conformance review, finding F2. -**Register row:** AP-150. - -acdream suppresses `"In Portal Space - Please Wait..."` until the hold has run -five seconds (`RuntimeWorldTransitState.RetailWaitCueDelay`), then re-emits it -per rotation segment only while `_waitCueVisible` -(`PortalTunnelPresentation.TickRotation`). - -Retail has no threshold. `gmSmartBoxUI::UseTime` @0x004D6E30 emits -`ECM_UI::SendNotice_DisplayStringInfo(0x1a, …)` in the `else` arm of the -rotation-segment-expiry test at 0x004D6FCD — i.e. every time a segment expires, -whether or not `CellManager::blocking_for_cells` is set. Segment duration is -`RandDouble(0.6, 1.8)` s, byte-decoded at 0x004D6FE6. acdream's own -`RotationDurationMin`/`Max` already match retail exactly, so only the arming is -wrong. - -The unrelated 5.0 s constant at VA 0x007991B0 is -`CellManager::CheckPrefetchStatus`'s retry cadence, and was mis-attributed to -the cue by #280's commit message (retracted in the contract doc). - -**Consequence:** every portal shorter than 5 s shows a silent tunnel where -retail shows the notice; every portal longer than 5 s shows it 3.2-4.4 s late. - -**Fix shape:** delete the delay and emit on the same segment boundary the -rotation already computes. One-line arming change, but it is a user-visible -presentation change and wants the user's eyes before it lands — do not fold it -into an unrelated commit. - ## #325 — Gate A's teleport test is narrower than retail's: a ForcePosition carrying a NEWER teleport stamp is misrouted into a full Apply **Status:** OPEN @@ -14162,6 +14114,84 @@ outdoors at the angle that previously erased it. # Recently closed +## #362 — [DONE 2026-08-09] Four new CH4 outbound requests have no inbound response handler + +**Closed:** 2026-08-09, Campaign CH user-gate round 1, item E. +**Filed:** 2026-08-09, Campaign CH slice CH4. +**Register row:** TS-70, RETIRED in the same commit. + +**Resolution:** `@index`, `@clist`, `@hslist`, and `@allegiance info` sent +byte-correct retail GameAction requests +(`ClientCommandRequests.BuildIndexChannels`/`BuildListChannel`/ +`BuildListAvailableHouses`/`BuildAllegianceInfoRequest`), but their +GameEvent responses (`ChannelIndex 0x0149`, `ChannelList 0x0148`, +`AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`) had no +`GameEventWiring` handler — ACE's reply was silently dropped. New +`ClientCommandResponses.cs` (`src/AcDream.Core.Net/Messages/`) parses all +four wire shapes (cross-checked against ACE's +`GameEventChannelIndex`/`GameEventChannelList`/`GameEventHouseAvailableHouses`/ +`GameEventAllegianceInfoResponse` writers) and renders retail-shaped +`LogTextType 0x00` (Default) lines ported verbatim from the named-retail +decomp: +- ChannelIndex/ChannelList: `Handle_Communication__ChannelIndex`/`ChannelList` + @0x0057d0c0/@0x0057d230 — a header line then one line per name. +- AvailableHouses: `Handle_House__Recv_AvailableHouses` + + `DisplayListOfCoords` @0x00585d50/@0x00585c20 — the "There are N + available." summary, then one indented `RadarCoordinates`-formatted + location line per landblock (skipped for apartments, which have no world + location, matching retail's `arg2 != 4` gate), then the >400-locations + truncation notice when `TotalAvailable > 0x190`. +- AllegianceInfoResponse: `Handle_Allegiance__AllegianceInfoResponseEvent` + @0x0056a1d0 — the asterisk-legend note, "Allegiance information for + <name><* if online>:", an optional "Patron:" line, and an + optional "Vassals:" block, all reconstructed from the wire's flat + parent-tagged record list via ports of retail's own + `GetData`/`GetPatron`/`GetFirstVassal`/`GetNextVassal` walk. A player with + no allegiance record produces NO lines, matching retail's own early + return — this is not a residual bug. + +17 new parser/format/routing tests +(`tests/AcDream.Core.Net.Tests/Messages/ClientCommandResponsesTests.cs`) +cover round-trips for all four shapes (including the empty-allegiance and +apartment-skip edge cases) and a `GameEventDispatcher`-level routing test +proving each reaches the `ChatLog` transcript with +`RetailLogTextType.Default`. + +## #329 — [DONE 2026-08-09] The portal wait cue arms five seconds late; retail emits it per tunnel rotation segment, unconditionally + +**Closed:** 2026-08-09, Campaign CH user-gate round 1, item D. +**Severity:** LOW (cosmetic, but it is a retail divergence on every single +portal, in both directions) +**Filed:** 2026-08-06, #280 retail-conformance review, finding F2. +**Register row:** AP-150, RETIRED in the same commit. + +**Resolution:** `PortalTunnelPresentation.TickRotation` now writes +`"In Portal Space - Please Wait..."` directly and unconditionally in the +rotation-segment-expiry branch, on every segment boundary, matching +`gmSmartBoxUI::UseTime`'s `else` arm at 0x004D6FCD verbatim — confirmed +against `docs/research/named-retail/acclient_2013_pseudo_c.txt:219499-219525` +before coding, which shows no hold/threshold test anywhere in that branch. +acdream's own `RotationDurationMin`/`Max` (0.6-1.8 s) already matched +retail's `RandDouble` window byte-for-byte; only the arming — gating the +write on `_waitCueVisible`, which only ever went true after the invented +five-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold — was wrong. +That hold/`ObserveWait`/`SetWaitCue` plumbing remains as +`LocalPlayerTeleportController`'s own telemetry +(`RuntimePortalSnapshot.WaitCueShown`) but no longer gates the on-screen +cue; a dedicated `ClearWaitCueNotice()` now hides the notice unconditionally +on Enter/Exit/Dispose so a line written by the per-segment path can never +survive past the presentation going invisible. The notice text also now +renders in the same bright yellow as an incoming Tell +(`(1, 1, 0.247, 1)`, `PortalWaitNoticeController`), per the user's live +side-by-side observation (round 1 also pinned the SpewBox to the same +colour, AP-178). + +**Consequence (fixed):** every portal, regardless of duration, now shows the +notice from the first rotation segment (which expires immediately on entry +since `_rotationDuration` starts at 0), refreshed every 0.6-1.8 s for as +long as the tunnel presentation is visible — matching retail instead of +silently skipping short transits and running 3.2-4.4 s late on long ones. + ## #234 — [DONE 2026-07-23] Cancelled close-range Use could strand the busy cursor **Closed:** 2026-07-23 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 48d20aa7..082584cf 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) — 130 active rows (AP-183 filed 2026-08-09 at the CH4 REJECT-review, item 9 — roughly 10 chat refusal/usage call sites this campaign added route through `ChatVM.ShowSystemMessage`'s single `ClientLocal 0x00` sink where retail types several of them `0x1A`: `DoStupidChannelHack`, `DoChannelList`/`On`/`Off`, `DoAllegiance`, `DoHouseAvailableList`, `DoReply`; three sites (`DoSpeaker`/`DoEndurance`/`DoTitle`) are already correct at `0x00`, matching retail. Retail's own bad-args fallback (`DoCommand @0x0057E46D`) also answers with `HandleFailureEvent(0x26)`, not a local "Usage:" line, which acdream's `ChatCommandRouter.Submit` synthesizes instead. Deliberately NOT re-plumbed this session — filed as issue #363, marked for CH5-or-later; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 129 active rows (AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 filed 2026-08-09 at the CH4 REJECT-review, item 9 — roughly 10 chat refusal/usage call sites this campaign added route through `ChatVM.ShowSystemMessage`'s single `ClientLocal 0x00` sink where retail types several of them `0x1A`: `DoStupidChannelHack`, `DoChannelList`/`On`/`Off`, `DoAllegiance`, `DoHouseAvailableList`, `DoReply`; three sites (`DoSpeaker`/`DoEndurance`/`DoTitle`) are already correct at `0x00`, matching retail. Retail's own bad-args fallback (`DoCommand @0x0057E46D`) also answers with `HandleFailureEvent(0x26)`, not a local "Usage:" line, which acdream's `ChatCommandRouter.Submit` synthesizes instead. Deliberately NOT re-plumbed this session — filed as issue #363, marked for CH5-or-later; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) 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 @@ -188,7 +188,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-147 | **Filed 2026-08-05 at the C5b architecture review (finding D3) — an unfiled delta-stream cardinality change C5b introduced, which its own conservation test could not see.** A cell-changing accepted steady-state Position now publishes **two** `RuntimeEntityDelta`s for the moved entity where it published one, and the intermediate one carries a torn cell/position pair. Pre-C5b the merge itself moved `FullCellId`, so it published `Rebucketed` and the `OnPosition` prologue rebucket's `CommitRebucket` then early-returned publish-less (`previous == fullCellId`) — stream `[Rebucketed]`. Post-C5b the merge moves nothing, so it publishes `Updated` and `CommitRebucket` publishes the `Rebucketed` — stream `[Updated, Rebucketed]`. The `Updated` element is assembled from the canonical record BETWEEN the two writes, so its `CellId` is the OLD (committed) cell while its `Position` is the NEW wire pose: a pair that did not previously exist on this stream, because pre-C5b both halves moved inside one publish. Total per packet is conserved in KIND and final VALUE — exactly one `Rebucketed`, at the same cell, from the same publisher — but not in COUNT, and not in intermediate consistency. **AMENDED 2026-08-05 at the C5b closeout (bookkeeping only — nothing in this row was false, it was un-updated).** This row was written from the graphical host at a moment when it was the only host producing the two-delta stream at all: pre-D1 the no-window host had no post-merge cell writer, so its accepted Position published `[Updated]` alone and simply LOST the `Rebucketed`. D1 gave that host its own `CommitWireCellRebucket` caller, so both hosts now produce `[Updated, Rebucketed]` with the same torn intermediate. The row's analysis, its "no production consumer identified today" verdict, and its retirement condition are unchanged; what changed is the population — a headless bot's event log is now a REAL instance of the "future consumer that SNAPSHOTS a delta" this row warns about, not a hypothetical one, because the no-window host is the one whose consumers are event streams by construction. | `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`'s terminal `AcknowledgeProjectionAndPublish`, and `CommitRebucket`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs` (`Snapshot` — the `record.FullCellId` / `record.Snapshot.Position` pairing that makes the intermediate torn) | Retail has no delta stream at all, so there is no retail shape to match — this is acdream's own observer contract. The alternative, suppressing the merge's `Updated` when a rebucket is about to follow, is not available at that layer: the merge cannot know whether its caller will reach W2 (the local force arm, the missile arm, and the `ChildUnparentDisposition` Superseded/Pending arm all return before it), so suppressing would silently drop the pose delta on exactly the packets where it is the only one. Collapsing the merge's ternary to a constant `Updated` is likewise wrong — the retained `Rebucketed` arm has a real producer, the cancelled-park rollback inside the merge. | Any consumer that treats one accepted Position as one entity delta now sees two, and any consumer that reads `CellId` and `Position` from the SAME delta and assumes they agree can transiently pair a new position with the old cell. No production consumer identified today: `LiveEntityRuntime` and the plugin/world-event surfaces re-read canonical state rather than trusting a delta's paired fields, and the pair reconverges inside the same `OnPosition` call. A future consumer that SNAPSHOTS a delta — a recorder, a plugin, a headless bot event log — would capture the torn intermediate. Retire together with W2, if the local player's canonical cell ever becomes per-crossing-fresh (AP-146/#320) and the merge and the rebucket can be one write again. | No retail anchor — acdream-only observer contract. Evidence: `RuntimeSteadyStatePositionMergeTests.CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation` asserts the complete ordered stream `[Updated, Rebucketed]` plus both elements' `CellId`/`Position.ObjCellId`, and `RuntimeSetPositionStateTests.AcceptedPositionCancellingWakeableParkPublishesRebucketedThroughTheMerge` pins the retained arm; both sabotage-verified in both directions at the C5b review. | | AP-148 | **Filed 2026-08-05 at the C5b closeout, from disassembly of the PDB-paired binary — NOT from the pseudo-C, which cannot show it.** acdream's local-player Gate A (the FORCE_POSITION self-echo shortcut) requires the wire TELEPORT_TS to be EXACTLY EQUAL to the stored one; retail requires only that it not be OLDER, so equal AND newer both take the shortcut. `SmartBox::HandleReceivedPosition` @0x0045402B-54 loads `player->update_times[4]` (TELEPORT_TS; base 0x164, 2 bytes/entry, confirmed by the POSITION_TS store `mov word [edx+0x164], ax` @0x00454084 and `acclient.h:6090`), takes `abs(stored - wire)`, picks a wrapped or unwrapped 16-bit compare on `> 0x7fff`, materialises the carry with `sbb eax,eax / neg eax`, and SKIPS Gate A on CF — where CF means the wire stamp is strictly older. It is `CPhysicsObj::newer_event` @0x00451B10's identical idiom with the compare operands swapped. **Binary Ninja drops the flag test and renders the whole sequence as `if (-((eax_7 - eax_7)) == 0)`, vacuously true**, which is why two C5b review rounds read this function carefully and both recorded the term backwards (`docs/research/2026-08-05-c5b-contract.md` §1 said first "teleport must NOT be newer", then "TELEPORT_TS equal"; both corrected at §15). **Consequence:** acdream's `ForcePosition` disposition is a strict SUBSET of retail's Gate A set. A local ForcePosition carrying a NEWER teleport stamp is misrouted into a full `Apply`, which is four separate behaviour changes at once — it takes the WIRE heading instead of preserving the body's (`InboundPhysicsStateController.ApplyAcceptedPosition:846-856`, force-gated), it UNPARENTS and may install a placement frame (`clearParent: !force`, `installPlacementFrame: !force && !hasAnimations` — C5b's own truth table), it sets `TeleportAdvanced` and therefore ZEROES local velocity (`:882-885`), and it advances TELEPORT_TS and calls `OfferTeleportDestination`, starting teleport/portal presentation for a packet retail never starts it for. Retail's Gate A deliberately lets a force ride PAST a pending teleport advance without consuming it (it returns @0x0045409D before `newer_event(arg2, TELEPORT_TS, arg8)` @0x00454158); the ordinary Position channel is what processes that teleport. **Not fixed in the filing commit**, deliberately: see issue #325 for why it is not a one-line comparison swap. **C5b made this marginally BETTER, not worse** — `clearParent` was unconditionally `true` pre-C5b and is unchanged for the misrouted packet, and `installPlacementFrame` went unconditional-`true` to `!force && !hasAnimations`, i.e. toward retail's "Gate A never reaches `SetPlacementFrame`". | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptPositionEvent:199`, the `teleport == _timestamps[Teleport]` term); `src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs` (`ValidAcceptedAuthority`, the `PreviousTeleportSequence == AcceptedTeleportSequence` term — the SAME predicate encoded a second time, and the reason the fix is not one line) | None argued — this is an unintended narrowing found at a closeout, not a chosen approximation. It is filed as an approximation rather than a defect only because the resulting behaviour is a strictly SMALLER shortcut set, i.e. more packets take the fully-processed path rather than fewer, which fails safe for pose correctness even where it is wrong about heading, parent, velocity, and presentation. The exact retail predicate already exists verbatim in the same file — `IsFreshTeleportStart:163` is `!IsNewer(teleport, _timestamps[Teleport])` — so the correction itself is trivial; the consumers are not. | A server correction that arrives while the client's TELEPORT_TS is behind ACE's (a teleport whose Position packet was lost, or arrived after the force) is promoted from "blip me in place" to a full teleporting apply: the player's facing snaps to the wire heading instead of staying where the mouse left it, local velocity is zeroed mid-stride, an equipped child is unparented, and the portal/transit presentation owner is offered a destination for a packet that is not a teleport. Reachability against ACE is UNMEASURED — ACE's two `ObjectForcePosition` bumps (`Player.cs:1148` PKLite re-placement, `Player_Tick.cs:488` z-hack correction) do not themselves bump the teleport sequence, but `PositionPack` serialises the CURRENT teleport sequence, so any client whose TELEPORT_TS lags ACE's is in the divergent window on its next force. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (Gate A's teleport test @0x0045402B-0x00454054; the return @0x0045409D; the TELEPORT_TS advance it skips @0x00454158); `CPhysicsObj::newer_event` 0x00451B10 (the same idiom, operands unswapped); `acclient.h:6090` (`update_times[4] == TELEPORT_TS`) | | AP-149 | **Filed 2026-08-05 at the #280 fix (portal destination prefetch).** The reveal gate's OUTER ring accepts terrain-only publication where retail requires the landblock's full static-DAT closure. Retail's `LScape::PreFetchCells` @0x00505660 walks the whole `mid_radius` square and, for EVERY in-bounds landblock, requires (1) its terrain record resident, (2) its `LandBlockInfo` type-2 record resident, and (3) via `CLandBlock::PreFetchCells` @0x00530240 -> `CLandBlockInfo::PreFetchCells` @0x0052E7C0 -> `CBldPortal::PreFetchCells` @0x0053BD00, every EnvCell of every building it contains. acdream's outer ring is Far-tier: heightmap + terrain render mesh + terrain collision, with NO LandBlockInfo, no buildings, no building EnvCells and no procedural scenery, because the Far tier does not load them at all. The gate therefore converges on a strictly weaker condition than retail's out beyond `NearRadius`. **#280 closed the 11.4:1 reveal-window/visible-window ratio; it did NOT close this. Do not let a later closeout claim parity.** | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`, the far arm); `src/AcDream.App/Streaming/LandblockBuildFactory.cs` (the Far build's contents); `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs` | Closing it would mean promoting the entire Far window to Near, i.e. deleting the two-tier streaming design that exists precisely because full hydration of a 25x25 window is unaffordable. Retail affords it because retail's ONE square is 17x17 at its default draw distance and it blocks the whole simulation while loading it (`CellManager::blocking_for_cells`), which acdream deliberately does not do (see AD-2). The residual is bounded to content that is only ever seen at Far distances. | A distant BUILDING, its interior EnvCell shells, or distant procedural scenery can still appear after the viewport opens, at Far-ring distances (beyond ~768 m at the shipped High preset), where retail would have kept blocking. Distant TERRAIN — the reported #280 symptom — no longer can. | `LScape::PreFetchCells` 0x00505660; `CLandBlock::PreFetchCells` 0x00530240; `CLandBlockInfo::PreFetchCells` 0x0052E7C0; `CBldPortal::PreFetchCells` 0x0053BD00 | -| AP-150 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F2).** acdream arms the `"In Portal Space - Please Wait..."` cue only after the hold has run five seconds (`RuntimeWorldTransitState.RetailWaitCueDelay = TimeSpan.FromSeconds(5)`, enforced at the readiness tick; `PortalTunnelPresentation.TickRotation` then re-emits per rotation segment only `if (_waitCueVisible)`). Retail has no such threshold. The emit site is inside `gmSmartBoxUI::UseTime`'s `TAS_TUNNEL*` branch, in the `else` arm of the rotation-segment-expiry test at 0x004D6FCD: when a segment expires retail picks a new random segment and calls `ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...)` UNCONDITIONALLY, whether or not `CellManager::blocking_for_cells` is set — the notice is a property of being in the tunnel, not of being blocked. Byte-decoded at 0x004D6FE6-0x004D7049: `teleportRotationDuration = RandDouble(0.6, 1.8)` s (`0x3ffccccc/0xcccccccd` = 1.8, `0x3fe33333/0x33333333` = 0.6) and `teleportRotationEndAngle = RandDouble(0, 360)` (`0x40768000`). The unrelated 5.0 s constant at 0x007991B0 belongs to `CellManager::CheckPrefetchStatus` @0x00455BE0, the prefetch RETRY cadence, and was mis-attributed to the cue by #280's commit message. acdream's own segment constants (`RotationDurationMin = 0.6f`, `RotationDurationMax = 1.8f`) already match retail exactly, so the cadence is faithful and only the ARMING is not. Pre-dates #280; filed here because #280 reasoned from the wrong model and because the row did not exist. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs` (`RetailWaitCueDelay`); `src/AcDream.App/Rendering/PortalTunnelPresentation.cs` (`RotationDurationMin`/`Max`, `TickRotation`); `src/AcDream.App/UI/PortalWaitNoticeController.cs` | Deliberate at the time as a "don't flash a scary notice on a fast portal" softening, but it was never recorded as a divergence and AD-2/AP-115 described it as acdream behaviour without stating that retail has no threshold. Adopting retail's unconditional per-segment emit is a one-line arming change; it is not made here because it is a user-visible presentation change outside the defect this commit fixes, and it wants the user's eyes. Filed as issue #329. | Every acdream portal shorter than 5 s shows a silent tunnel where retail shows the notice; every portal longer than 5 s shows it 3.2-4.4 s late (retail's first segment expires at 0.6-1.8 s). #280 makes holds longer, which MASKS this rather than fixing it. | `gmSmartBoxUI::UseTime` 0x004D6E30 (emit at the 0x004D6FCD segment-expiry else-arm); `ECM_UI::SendNotice_DisplayStringInfo` call @0x004D70A1 (-> 0x006925B0); the wait-cue string's `PStringBase` construction is the neighbouring @0x004D7064, `:219516` — **corrected 2026-08-06 at the D-1 fix review; this row originally cited the string constructor as the call site**; wait-cue string VA 0x007BD6A8; `CellManager::CheckPrefetchStatus` 0x00455BE0 (the 5.0 s constant, VA 0x007991B0) | | AP-151 | **Filed 2026-08-06 at the #280 retail-conformance review (finding F3).** The reveal gate is materially STRICTER than retail's prefetch predicate on the mesh-build/GPU-upload axis, over an equally large square. Retail's `LScape::PreFetchCells` @0x00505660 requires, per member, only that the DAT records be resident in memory (`DBObj::PreFetch` -> `IN_MEMORY` or `IN_FILE` -> `DBObj::Get` non-null); no geometry construction, no vertex arrays and no GPU upload are part of the blocking predicate — that work happens lazily at draw. acdream's gate requires, for every member of the derived window (25x25 at the shipped High preset): a worker-thread DAT read, a terrain mesh build, a render-thread `TerrainModernRenderer.AddLandblock` upload, a spatial commit, a physics collision-generation admission, and a spawn-adapter activation, all metered at `MaxCompletionsPerFrame`. The hold is therefore systematically longer than retail's for identical content, and nothing currently bounds it. Note this is the OPPOSITE asymmetry from AP-149, which records where the outer ring is WEAKER than retail; both are live simultaneously, on different axes. | `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Streaming/StreamingWorkBudget.cs` | It is what makes "no visible assembly after reveal" true at all: acdream draws through a bindless/MDI pipeline whose landblock slots must exist before the viewport opens, where retail can begin drawing a landblock the frame its DAT record lands. Weakening the predicate to DAT residency would restore retail's hold duration and reintroduce the visible-assembly artifact #280 exists to remove. AD-2's blanket "async readiness gates replace retail's synchronous destination cell load" pre-dates the window being 625 members wide and does not name this axis. | Portal/recall holds of several seconds where retail (warm cache) is near-instant, on EVERY transit rather than only on cold DAT. No upper bound is enforced and no progress readout is shown (#327). A slow disk or a saturated upload budget lengthens the hold without limit. | `LScape::PreFetchCells` 0x00505660; `DBObj::PreFetch`/`DBObj::Get` call sites @0x0050575C, @0x0050579C; `CellManager::PreFetchCells` 0x00455820 | | AP-153 | **Filed 2026-08-06 at the AP-152 retirement — a modelling difference the fix itself introduces.** Retail's shape-dispatch flag is CACHED ONCE. `CPartArray::CacheHasPhysicsBSP` @0x00518110 walks the part array, ORs 0x10000 into `CPartArray::pa_state` on the first part whose `gfxobj->physics_bsp` is non-null, and `CPhysicsObj::CacheHasPhysicsBSP` @0x0050f570 mirrors it onto `CPhysicsObj::state+0xa8`. A full `.text` scan for direct call/jmp to 0x0050f570 finds EXACTLY ONE caller, `CPhysicsObj::InitPartArrayObject+0x7e` @0x0051272e — so after an `AnimPartChanged` part swap retail's DISPATCH flag is stale while its per-part test (`CPhysicsPart::find_obj_collisions` @0x0050d8d0) stays live. acdream's step-0 gate is LIVE in both: it re-derives from the effective part identities on every `FromSetup` call. | `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromSetup` step 0); `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs` (`ReconcileAppearance`) | The two disagree only when a swap adds or removes the LAST physics-BSP part. Humanoid part swaps (clothing / armour) involve no physics-BSP GfxObj on either side, so this is unreachable against ACE today. Deliberately NOT modelled with cached state — that would be inventing staleness to reproduce a retail bug. | If a server ever swapped a prop's part array across the physics-BSP boundary, acdream would switch its collision geometry on the swap where retail would keep dispatching on the construction-time flag: a prop that gained a BSP part would lose its primitive immediately in acdream and only on re-init in retail. | `CPartArray::CacheHasPhysicsBSP` 0x00518110; `CPhysicsObj::CacheHasPhysicsBSP` 0x0050f570; sole caller `CPhysicsObj::InitPartArrayObject+0x7e` 0x0051272e | | AP-154 | **Filed 2026-08-06 at the AP-152 retirement (contract §11.6) — an undeclared dependency on a specific server implementation.** Retail COMPUTES `HAS_PHYSICS_BSP_PS` itself from its own part array (AP-153's anchors). acdream's query-time guard `Transition.BspOnlyDispatch` reads it out of the SERVER's wire `PhysicsState`: `LiveEntityCollisionBuilder.cs:161` copies `exactRecord.FinalPhysicsState` into `ShadowEntry.State`, and a repo-wide grep for `PhysicsStateFlags.HasPhysicsBsp` in `src/` returns only that predicate and one unrelated mover-state read. acdream never ORs the bit in client-side. It happens to be correct because ACE derives the same DAT bit (`WorldObject_Networking.cs:665-668` from `SetupFlags.HasPhysicsBSP`), overriding the weenie's authored value — which is why a 2018 weenie dump showing `PhysicsState = 0x8` for the cottage door does not contradict our own live capture of `0x10008`. | `src/AcDream.Core/Physics/TransitionTypes.cs:1348` (`BspOnlyDispatch`), call sites `:3911` / `:3954`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs:161` | Narrowed, not closed, by the AP-152 fix: the shape list no longer contains a primitive for a BSP-bearing object, so the guard has nothing left to skip and the OUTCOME is now independent of the wire. The guard itself still keys on the wire. Not bundled — changing `registration.State` touches every consumer of `FinalPhysicsState` (Hidden, Missile, ethereal layer 2, the `[setstate]` log) and needs its own gate. | Against a server that does not derive the bit from the DAT, a BSP-bearing object built by a producer other than `FromSetup` would have its primitive tested where retail tests only the BSP. | `CPartArray::CacheHasPhysicsBSP` 0x00518110 (derives) vs `LiveEntityCollisionBuilder.cs:161` (copies); `HAS_PHYSICS_BSP_PS` acclient.h:2833 | @@ -234,7 +233,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-40 | Chat uses one fixed `0.75` outer opacity and has no descendant-focus-driven active/default opacity transition | `src/AcDream.App/Rendering/GameWindow.cs` chat mount; `ChatWindowController.cs` | Font resolution is now live and per-element; only the opacity behavior remains deferred to the shared window/focus runtime | Focused chat remains too translucent and idle chat never restores the configured default alpha | `ChatInterface::SetOpacity @ 0x004F3120`; `SetDefaultOpacity @ 0x004F3BC0`; `SetActiveOpacity @ 0x004F3C40` | | AP-175 | PopUpString (`GameEvent 0x0004`) renders as an ordinary chat-log line (`ChatKind.Popup`) instead of retail's MODAL DIALOG. Filed 2026-08-09, Campaign CH slice CH1 (color table) — the color-table work routes this entry through the new 34-value `LogTextType` table (fixed at `0x00` Default/green, unchanged from the entry's pre-existing color) but does not change WHERE it renders; a modal-dialog port is out of this slice's scope | `src/AcDream.Core/Chat/ChatLog.cs` (`OnPopup`); `src/AcDream.Core.Net/GameEventWiring.cs:126` | Informational popup text still reaches the player via the chat transcript; a full modal-dialog port is deferred work, not a color-table concern | Any retail-specific PopUpString behavior contingent on being a blocking modal (e.g. must-acknowledge) is not reproduced; acdream's chat-log line can be missed or scrolled past instead | `ClientCommunicationSystem::Handle_Communication__PopUpString @0x0057FE80`; `docs/research/2026-08-09-chat-retail-color-table.md` §5.1 | | AP-177 | SpewBox line lifetime is an INVENTED 5-second placeholder. Retail's `gmSpewBoxUI` never raises the expiry element message (`0x10000003`) anywhere in its own compiled Sept 2013 EoR code — the real per-line timeout/fade curve is owned by keystone.dll's authored behaviour for layout `0x10000012` element `0x1000004A`, which this slice did not measure (a live cdb capture on `gmSpewBoxUI::ListenToElementMessage @0x004D57C0` against a real retail client would resolve it). Filed 2026-08-09, Campaign CH slice CH2 | `src/AcDream.Core/Chat/SpewBoxState.cs` (`DefaultLifetime`) | A round, conservative placeholder was chosen over guessing a retail-matching curve; no fade is modeled at all (the line pops on and off) | SpewBox lines may linger noticeably longer or shorter than retail's actual timing, and pop instead of fading | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.1 | -| AP-178 | **NARROWED 2026-08-09 at the CH2 REJECT-review rework (NIT 3, `docs/research/2026-08-09-ch2-review-findings.md`), WORDING CORRECTED at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6):** the original filing's `dats.Portal` pass used an id source (`DatCollection`'s top-level AGGREGATE `GetAllIdsOfType()`) that is NOT `dats.Portal`'s own id space (`dats.Portal.GetAllIdsOfType()` reports a count of ZERO for this type), so querying those ids against `dats.Portal.TryGet` established nothing about Portal either way — the "swept only dats.Portal and found ZERO... all-invented" framing overclaimed a search that never meaningfully happened. Extending the sweep to `dats.Local` (`client_local_English.dat`), this time correctly paired, FOUND it: LayoutDesc `0x21000011`, element `0x10000048`, whose sole child (ListBox `0x10000049`, matching `gmSpewBoxUI::PostInit`'s `GetChildRecursive(0x10000049)` verbatim) carries ListBox property `0x10000028` = the integer `4`. Whether `dats.Portal` ALSO carries a copy remains UNESTABLISHED, not ruled out. Two sub-claims RETIRE: extent is now AUTHORED (`450×72`, not a placeholder size) and `MaxConcurrentItems` is now AUTHORED (`4`, not retail's code-default `1`). Three sub-claims REMAIN open: (1) absolute screen position — the recovered position is `(0,0)` RELATIVE TO A PARENT this sweep could not identify (the element is presumably still mounted via the C++ `gmClient` HUD registration block the research doc's §1.1 describes, just parented under something dat-authored rather than the root view directly), so `TopOffset=60px` + a centered `Left` recomputed every frame (corrected from a one-time computation at nit 1 — see `SpewBoxController.Tick`) remain acdream's own placeholder, not a resolved retail value; (2) colour — the element/ListBox's direct-state `StateDesc.Properties` dump found only a bool at `0x3B` and the `MaxConcurrentItems` integer, no colour property, and the per-`UIStateId` `States` dictionary (hover/pressed/etc. variants) was not walked this pass; (3) vertical content flow — the block now renders TOP-aligned (newest line at the top, via `UiText.VerticalJustify`/`HonorVerticalJustification`, nit 2) because that is the only placement consistent with "newest on top," but retail's own authored vertical justification for this element is unmeasured, so this is also acdream's invention pending measurement, not a resolved retail value. Retail's edge codes (`leftEdge=3`/`rightEdge=3`, "centered" per `ElementReader.ToAnchors`'s own doc comment; `topEdge=1`, top-anchored) confirm the box is a fixed-width centered block, not a full-viewport stretch — `SpewBoxController`'s anchor shape was corrected to match (`AnchorEdges.None` + a centered `Left` recomputed every frame against the current root width, `OneLine=false` since 4 concurrent lines can now actually be visible instead of collapsing to 1) | `src/AcDream.App/UI/SpewBoxController.cs`; `src/AcDream.Core/Chat/SpewBoxState.cs` (`MaxConcurrentItems`); `src/AcDream.App/UI/UiText.cs` (`HonorVerticalJustification`) | The colour placeholder follows the user's own (unconfirmed) recollection that retail's SpewBox is yellow rather than an arbitrary pick; a live cdb capture of `gmSpewBoxUI`'s runtime rect/state (or walking the `States` dictionary this pass skipped, or identifying the C++-assigned parent) would be the next resolution path for the three still-open sub-claims | SpewBox text may render in the wrong absolute screen location, colour, or vertical flow versus retail; the size/max-items risk this row originally recorded ("bursts of refusals collapse to one visible line where retail's authored ListBox may show more") is RETIRED — up to 4 now render, matching the authored value | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.2-§3.2.4; `tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs`; `src/AcDream.App/UI/Layout/ElementReader.cs` (`ToAnchors`) | +| AP-178 | **NARROWED 2026-08-09 at the CH2 REJECT-review rework (NIT 3, `docs/research/2026-08-09-ch2-review-findings.md`), WORDING CORRECTED at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6):** the original filing's `dats.Portal` pass used an id source (`DatCollection`'s top-level AGGREGATE `GetAllIdsOfType()`) that is NOT `dats.Portal`'s own id space (`dats.Portal.GetAllIdsOfType()` reports a count of ZERO for this type), so querying those ids against `dats.Portal.TryGet` established nothing about Portal either way — the "swept only dats.Portal and found ZERO... all-invented" framing overclaimed a search that never meaningfully happened. Extending the sweep to `dats.Local` (`client_local_English.dat`), this time correctly paired, FOUND it: LayoutDesc `0x21000011`, element `0x10000048`, whose sole child (ListBox `0x10000049`, matching `gmSpewBoxUI::PostInit`'s `GetChildRecursive(0x10000049)` verbatim) carries ListBox property `0x10000028` = the integer `4`. Whether `dats.Portal` ALSO carries a copy remains UNESTABLISHED, not ruled out. Two sub-claims RETIRE: extent is now AUTHORED (`450×72`, not a placeholder size) and `MaxConcurrentItems` is now AUTHORED (`4`, not retail's code-default `1`). **CH USER-GATE ROUND 1 (2026-08-09):** colour PINS — the user tested live, side-by-side against retail, and confirmed the on-screen SpewBox text is the same bright yellow as an incoming Tell (`0x81C4C8`, `RetailChatColorTable.Yellow` = `(1, 1, 0.247, 1)`); `SpewBoxController.SpewBoxColor` now uses that exact value. The user's SAME live pass also reported that SIZE, POSITION, and FONT still visibly differ from retail — so despite extent's earlier AUTHORED status above, size is user-gate round 1: differs, iterating (re-opened pending a follow-up measurement pass, not yet root-caused). Three sub-claims therefore REMAIN open: (1) absolute screen position — the recovered position is `(0,0)` RELATIVE TO A PARENT this sweep could not identify (the element is presumably still mounted via the C++ `gmClient` HUD registration block the research doc's §1.1 describes, just parented under something dat-authored rather than the root view directly), so `TopOffset=60px` + a centered `Left` recomputed every frame (corrected from a one-time computation at nit 1 — see `SpewBoxController.Tick`) remain acdream's own placeholder, not a resolved retail value, and the user confirms this is visibly wrong; (2) size/font — the AUTHORED `450×72` extent and whatever font this renders with still do not match what the user sees live; unmeasured which of extent, the unresolved parent scale, or font metrics is the actual cause; (3) vertical content flow — the block now renders TOP-aligned (newest line at the top, via `UiText.VerticalJustify`/`HonorVerticalJustification`, nit 2) because that is the only placement consistent with "newest on top," but retail's own authored vertical justification for this element is unmeasured, so this is also acdream's invention pending measurement, not a resolved retail value. Retail's edge codes (`leftEdge=3`/`rightEdge=3`, "centered" per `ElementReader.ToAnchors`'s own doc comment; `topEdge=1`, top-anchored) confirm the box is a fixed-width centered block, not a full-viewport stretch — `SpewBoxController`'s anchor shape was corrected to match (`AnchorEdges.None` + a centered `Left` recomputed every frame against the current root width, `OneLine=false` since 4 concurrent lines can now actually be visible instead of collapsing to 1) | `src/AcDream.App/UI/SpewBoxController.cs`; `src/AcDream.Core/Chat/SpewBoxState.cs` (`MaxConcurrentItems`); `src/AcDream.App/UI/UiText.cs` (`HonorVerticalJustification`) Colour is CONFIRMED, not a placeholder — CH user-gate round 1 (2026-08-09) pinned it against the user's own live side-by-side retail observation, not a recollection. A live cdb capture of `gmSpewBoxUI`'s runtime rect/state (or walking the `States` dictionary this pass skipped, or identifying the C++-assigned parent) remains the resolution path for the three still-open sub-claims (position, size/font, vertical flow) | SpewBox text may render in the wrong absolute screen location, size/font, or vertical flow versus retail — all three CONFIRMED wrong by the user's CH round-1 live pass, not merely suspected; colour is CLOSED and no longer a risk. The size/max-items risk this row originally recorded ("bursts of refusals collapse to one visible line where retail's authored ListBox may show more") is RETIRED — up to 4 now render, matching the authored value, though the box's overall size/font still visibly differs from retail per the user | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.2-§3.2.4; `tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs`; `src/AcDream.App/UI/Layout/ElementReader.cs` (`ToAnchors`) | | AP-179 | `ChatLog.OnCombatLine`'s generic `0x06` Combat fallback types combat-feedback lines with a single stand-in `LogTextType` for callers with no more specific hit/miss/evade classification in hand, instead of retail's per-message dispatch. Split out of AP-176 (RETIRED 2026-08-09, Campaign CH slice CH2 — the WeenieError half of that bundled row is now the full 344-row `HandleFailureEvent` port, `WeenieErrorMessages.Resolve`); this combat-line half was never in CH2's scope and keeps its own row so the divergence is not silently dropped | `src/AcDream.Core/Chat/ChatLog.cs` (`OnCombatLine`) | `0x06` matches the switch's majority combat-line behavior and is a safe baseline; a full per-combat-message dispatch port is out of Campaign CH's scope | Wrong chat color for the combat-line kinds retail types distinctly (hit/miss/evade variants) | `ClientCommunicationSystem::HandleFailureEvent @0x00571990`; originally filed at the CH1 Opus review 2026-08-09 as part of AP-176, split out at CH2 | | AP-180 | `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed — retail's `ClientSystem::AddTextToScroll(text, type, allowPluginFilter, windowId)` delivers a `type == 0x1A` message with a non-zero `windowId` to BOTH the SpewBox and that specific chat window (research doc §2.3), the shape ~40 slash-command-output sites depend on. acdream's chokepoint routes on `type` alone; every current production caller passes `windowId = 0`, so the gap is latent, not yet visibly wrong. Filed 2026-08-09 at the CH2 REJECT-review rework (NIT 2, `docs/research/2026-08-09-ch2-review-findings.md`) | `src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs` (`AddText`) | No production caller passes a non-zero `windowId` yet, so nothing observably diverges today; implementing the dual-destination echo is CH4/CH5 scope at the earliest | A future slash-command-output caller that passes a non-zero `windowId` expecting it to echo into its originating chat window (matching retail) will silently land in the SpewBox only | `ClientSystem::AddTextToScroll @0x00563C50`; `docs/research/2026-08-09-chat-retail-interface-text.md` §2.3 | | AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | @@ -340,7 +339,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-183 | **Filed 2026-08-09 at the CH4 REJECT-review, item 9.** Roughly 10 chat refusal/usage call sites Campaign CH slice CH4 added route through `ChatVM.ShowSystemMessage`'s single `LogTextType 0x00` (ClientLocal-informational) sink; retail types several of them `0x1A` (bright red / genuine refusal) instead: `DoStupidChannelHack` (the "You must specify the text you wish to say!" family, registered channel verbs), `DoChannelList`/`DoChannelOn`/`DoChannelOff` ("Please specify the channel name."), `DoAllegiance` (the "Please see @help Allegiance..." refusal this session's Blocker 1 added), `DoHouseAvailableList`, and `DoReply` ("Someone must @tell you first!"). Three CH4 sites are already correct at `0x00` because retail itself types them informational: `DoSpeaker`, `DoEndurance`, `DoTitle`. Separately, retail's own bad-args fallback (`ClientCommunicationSystem::DoCommand @0x0057E46D`) answers a registered handler that returns 0 with `HandleFailureEvent(0x26)`, not a local "Usage: " line — `ChatCommandRouter.Submit` synthesizes a `"Usage: {clientCommand.Usage}"` string instead whenever a catalog command's `InvalidArgumentsText` is null. Filed as issue #363; deliberately NOT re-plumbed this session (re-typing every call site is larger than a REJECT-review fix batch). `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` (`ShowSystemMessage`); `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` (`Submit`'s `Usage:` fallback) | Every one of these sites shows correctly-worded text in the correctly-shaped chat log entry, just at the wrong color/destination classification — a real but low-severity divergence from retail's exact on-screen presentation | A user comparing acdream's chat window side-by-side with retail for one of these specific refusals sees the wrong color (informational white/default instead of bright red) or, for the generic "Usage:" fallback, different WORDING than retail's `HandleFailureEvent(0x26)` text entirely | `ClientCommunicationSystem::DoStupidChannelHack @ 0x0057B144`; `DoChannelList @ 0x0057A9B0`; `DoChannelOn @ 0x0057AA80`; `DoChannelOff @ 0x0057AB50`; `DoAllegiance @ 0x0057D5A0`; `DoHouseAvailableList @ 0x00570510`; `DoReply @ 0x00577910`; `DoCommand @ 0x0057E46D` (`HandleFailureEvent(0x26)`) | | AP-182 | **Filed 2026-08-09 (Campaign CH slice CH4); corrected 2026-08-09 at the CH4 REJECT-review (nit 11).** `@title ` is wired to a pure no-op — `LiveSessionRuntimeFactory`'s `SetChatTitle` binding is `_ => { }`; the requested title is neither stored nor consumed anywhere (the original filing's "stores the value locally" claim was false). This matches retail's own silent success (no confirmation text was recovered at the `DoTitle` success site, so a no-visible-effect accept is exactly as faithful as a stored-but-unread value would be). Also omitted: `DoTitle`'s three local failure messages — no title given, "You must provide a new title for the window."; length over 99 characters, "Window title length cannot exceed 100 characters."; and wrong source window (`m_idCurrentCommandSource` 1 or 8), "This command must be issued from a popup chat window." — acdream's catalog validator (`ClientCommandId.SetChatTitle`, `AnyArguments`) accepts any argument shape and never raises any of the three. `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`SetChatTitle`) | Retail's chat window presumably re-renders its title bar text; acdream's chat window has no title bar at all under the current retained-UI import, so there is nothing to visually diverge from yet | Once a titled chat-window chrome is built, `@title` needs to be re-wired to it — today it is a pure no-op, and the three failure messages above are silently absent | `ClientCommunicationSystem::DoTitle @ 0x0057A640` | -## 4. Temporary stopgap (TS) — 40 active rows (TS-68/TS-69/TS-70 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render), and the four unparsed inbound GameEvent responses for CH4's new outbound requests; TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 39 active rows (TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -391,7 +390,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~TS-66~~ | **RETIRED 2026-08-08 (Campaign A listening-gate fix; user-reported).** `seen_outside` interiors now keep the OUTDOOR ambient set: the listener source resolves the per-cell `CEnvCell.seen_outside` bit through the physics cache's `CellPhysics` record (the same #107 field `AdjustPosition` reads) and converts the ENVCELL-local origin through the cell's `WorldTransform` into landblock coordinates before the 3×3 walk centres on it — an outdoor Position's origin is already landblock-local, an envcell's is not, and skipping the conversion would centre the walk on a wrong point by up to a landblock. A cell record not yet resident resolves to silence for that rebuild rather than a wrong walk. Sealed interiors (dungeons) remain silent, which is retail-correct. | retired | — | — | `Ambient` gate per `docs/research/2026-08-08-audio-retail-ambient-authoring.md` §6/§8; `CEnvCell::add_ambient_sounds` (folded `ret`); user listening gate 2026-08-08 ("in retail I get both outside ambient and the ambient from indoors") | | TS-68 | **Filed 2026-08-09 (Campaign CH slice CH4); corrected 2026-08-09 at the CH4 REJECT-review, Blocker 1.** `@allegiance`/`@all` and `@house`/`@hou` are real retail management-command dispatchers with 12 and 15 subcommands respectively (registry doc §2.5/§2.5b). acdream ports only the subset with simple parameterless/single-field wire shapes (allegiance `info`/`hometown`/`ho`; house `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`). For `@house`, every other subcommand (open, close, storage, remove, boot, boot_all, remove_all, guest, available, hooks, on, off) still falls through to ACE server-passthrough (which replies "Unknown command") — unchanged from the original filing. **The original filing was WRONG for `@allegiance`/`@all`: retail's own `DoAllegiance` never reaches DoChannelCommand/server-passthrough for an unrecognized subcommand** — it prints "Please see @help Allegiance for more information on how to use this command." locally (`label_57da4b`, 0x0057DA4B) and stays entirely client-side. **Corrected again 2026-08-09 at the CH4 re-review, SHOULD-FIX 3.** Retail does NOT refuse boot/ban/officer/title/motd/name/lock/house/chat/broadcast — `DoAllegiance`'s dispatcher table EXECUTES each one locally through its own handler (e.g. `DoAllegianceBoot @ 0x0057D646` is the dispatcher's call site into `ClientCommunicationSystem::DoAllegianceBoot`; `DoAllegianceBan`/`DoAllegianceOfficer`/`DoAllegianceOfficerTitle`/`DoMotd`/`DoAllegianceName`/`DoAllegianceLock`/`DoAllegianceHouse` are its siblings in the same table). acdream has none of those nine handlers ported (tracked by issue #360) and instead shows the SAME unrecognized-subcommand refusal ("Please see @help Allegiance...", `label_57da4b`, 0x0057DA4B) for every one of them, pending the #360 port. What matches retail here is the OWNERSHIP RULE — the verb never reaches `DoChannelCommand`/server-passthrough for `@allegiance`/`@all` regardless of subcommand — NOT the subcommand's actual behavior, which retail executes and acdream does not yet. This still closes the real bug the original filing named (the unmatched subcommand text broadcast to the Allegiance chat channel, 0x02000000). The standalone `@motd` verb (reached directly, not via `@allegiance motd`) remains a separate, still-open gap. `RetailClientCommandCatalog.TryMatchHouse`/`TryMatchAllegiance` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`) | Retail would execute these locally (with its own usage/confirmation/refusal text). House's unported subcommands still reach ACE, which does not implement them as chat commands either — no functional loss on a real server, but a user typing e.g. `@house open` gets ACE's generic "Unknown command" instead of retail's real behavior. Allegiance's unported subcommands correctly stay local (never reach ACE) but show a generic refusal instead of retail's real per-subcommand execution — a user typing e.g. `@allegiance boot Name` gets "Please see @help Allegiance..." instead of retail's real boot confirmation/effect, until #360 ports the nine `DoAllegiance*`/`DoMotd`/`DoAllegianceHouse` handlers. | `ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0`; `DoHouse @ 0x00580860`; ACE `GameActionType` opcodes for each subcommand (all exist server-side) | | TS-69 | **Filed 2026-08-09 (Campaign CH slice CH4).** `@day`, `@log`, and `@render` are registered retail verbs acdream recognizes only in the `/help ` lookup table, not as executable client commands. `@day` needs a sky/time-of-day override hook the renderer doesn't expose; `@log` needs a safely-lifecycled chat-to-file writer (deferred to avoid an unaudited file-handle leak across reconnects); `@render` has no acdream equivalent to retail's `SmartBox::HandleRenderOption` render-option surface. All three fall through to server passthrough. `RetailCommandHelpTable` (`src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs`) | A user typing `@day`/`@log`/`@render` gets ACE's "Unknown command" instead of retail's local toggle/file-copy/render-option behavior — cosmetic/QoL only, no gameplay impact | `ClientCommunicationSystem::DoDay @ 0x005706F0`; `DoSetOutput @ 0x0057E4F0`; `DoRenderOption @ 0x0057E120` | -| TS-70 | **Filed 2026-08-09 (Campaign CH slice CH4).** The new `@index`/`@clist`/`@on`/`@off`/`@hslist`/`@allegiance info` outbound requests (`ClientCommandRequests.BuildIndexChannels`/`BuildListChannel`/`BuildOnChannel`/`BuildOffChannel`/`BuildListAvailableHouses`/`BuildAllegianceInfoRequest`) send the byte-correct retail wire request, but the corresponding inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`) are registered in `GameEventType` but have no `GameEventWiring` handler — the server's reply is silently dropped rather than rendered. `src/AcDream.Core.Net/GameEventWiring.cs` | The request reaches ACE correctly (verifiable on the wire / server-side log) but the client shows nothing in response — looks like the command silently failed | ACE `GameEventChannelIndex`/`GameEventChannelList`/`GameEventHouseListAvailable`/`GameEventAllegianceInfoResponse` (`references/ACE/Source/ACE.Server/Network/GameEvent/Events/`) | | TS-67 | **Ambient contributions are computed in-plane.** Retail's `CLandBlock::add_ambient_sounds` @ `0x530310` positions each contributing land cell at its own SW terrain VERTEX, including that vertex's height, and `Ambient::CalcWeight` deliberately includes Z in its distance (where `CalcDir` deliberately excludes it — the two differ on purpose). acdream's gatherer supplies Z = 0 for the offset, so a cell's weight ignores the height difference between the listener and the terrain under that cell. | `src/AcDream.Core/Audio/AmbientSoundGatherer.cs` (`ContributeLandblock`) | Sampling the height needs the landblock's height table threaded into the walk alongside the terrain words; the walk already runs only on a 24 m crossing so the cost is not the obstacle, the extra plumbing at slice end was. The error is bounded by terrain relief inside 120 m and affects the crossfade weight only, never the direction. | On steep ground an ambient reads slightly louder than retail, because the true 3-D distance is longer than the planar one. | `CLandBlock::add_ambient_sounds @ 0x530310`; `Ambient::CalcWeight @ 0x550DD0` | --- diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 49361514..facd609d 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -16,9 +16,13 @@ see the CH4 closeout below; 138 of 152 registry verbs now execute locally, 5 are deliberately deferred (issues #360/#361/#362, register rows TS-68/TS-69/TS-70), and 9 are retail's own null-handler help-only nodes. CH5 (this closeout sweep) is COMPLETE — plan/register/ISSUES/ -CLAUDE.md/roadmap ledger flip plus the chat memory digest. Pending the -in-client user gate (colors, side channels, on-screen text, command -spot-checks per the test script). +CLAUDE.md/roadmap ledger flip plus the chat memory digest. **User gate +round 1 ran 2026-08-09 and found ten live defects; see "User gate — +round 1" below.** Items A–G are fixed in this round's commit; the three +remaining findings (extra chat windows on 1/2/3/4, resize only working in +one corner, transparency/artifacts) are out of this round's scope and +filed as a new slice, CH6. Status stays CODE-COMPLETE pending the next +user gate round. **Why now:** first track of the alpha-release program (chat is the most visible daily surface for the friend-alpha). User-directed 2026-08-09. @@ -102,6 +106,10 @@ implementer per slice against a pinned contract (per commands implemented family-by-family. - **CH5 — closeout.** Register sweep, ledger flip, ISSUES updates, in-client test script for the user gate. +- **CH6 — chat-window shell parity (filed 2026-08-09 at user gate round + 1).** Retail multi-window chat, per-window type filters + PostInit + defaults (see color research §4), all-corner resize, opacity; research + first. ## Gates @@ -121,6 +129,8 @@ implementer per slice against a pinned contract (per | CH3 side channels | `614a1e05` | 11,964 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `e07fba57` | pending (connected gate — see handoff below) | | CH4 commands | `090825e7` | 12,221 passed / 4 skipped / 0 failed | REJECT; fixed `724ef2d3`; re-review APPROVE-WITH-FIXES; closed `5d247d55` | pending | | CH5 closeout | (this commit) | — (docs/memory only, no build) | — | pending (connected gate — see test script) | +| User gate round 1 | (this commit) | 12,221 passed / 4 skipped / 0 failed (baseline; items A–G fixed this commit) | — | items A–G user-gate round 1 fixed; ten findings total, see "User gate — round 1" below | +| CH6 chat-window shell parity | not started | — | — | filed 2026-08-09 at user gate round 1; research first | ### CH4 closeout (2026-08-09) @@ -443,3 +453,35 @@ send+receive; Roleplay is now silent-but-correctly-refused until the user turns it on via Settings (then works); `/a` with and without an allegiance; `/ab`; the legacy family no longer double-prints; the TurbineChat ack HResult line never appears on an ordinary successful send. + +## User gate — round 1 (2026-08-09) + +The user tested CH5's CODE-COMPLETE build live and reported ten defects. +Items A–G are fixed in this same commit; the last three are out of this +round's scope and filed as slice CH6. + +| # | User finding (condensed) | Disposition | +|---|---|---| +| A | Jumping while already airborne never shows retail's "You can't jump while in the air" refusal — the jump block only ever evaluates `input.Jump` inside the grounded-charge or already-charging branches. | **FIXED this SHA.** Rising-edge detection (`PlayerMovementController._prevJumpHeld`) reports `WeenieError.NotGrounded` once per press while airborne; holding the key or the grounded charge/fire path is unaffected. | +| B | Local system text shows an invented `"[System] "` prefix; retail prints it bare. | **FIXED this SHA.** `ChatVM.FormatEntry`'s `ChatKind.System` case now returns `entry.Text` unprefixed. `[Popup]` is unchanged (AP-175, a deliberate divergent marker). | +| C | The SpewBox's color doesn't match retail — the user recalls it as the same bright yellow as an incoming Tell. | **FIXED this SHA (color only).** `SpewBoxController.SpewBoxColor` is now the exact pinned value `(1, 1, 0.247, 1)` (`0x81C4C8`, same as `RetailChatColorTable.Yellow`). Size/position/font remain OPEN under register row AP-178 — the user reports all three still differ from retail; user gate round 1: differs, iterating. | +| D | The portal-space "In Portal Space - Please Wait..." text never shows, and when it does (via #329's 5-second delay) it's the wrong (white) color. | **FIXED this SHA, closes #329.** `PortalTunnelPresentation.TickRotation` now emits the notice unconditionally on every rotation-segment boundary, matching `gmSmartBoxUI::UseTime`'s decompiled `else`-arm exactly (no hold/threshold gate); `PortalWaitNoticeController` now renders it in the same pinned yellow as item C. Register row AP-150 retired. | +| E | `/hslist villas` (and the other three CH4-added request commands) is accepted server-side but produces no visible response. | **FIXED this SHA, closes #362.** New `ClientCommandResponses.cs` parses and renders `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`, each ported line-for-line from the named-retail decomp's inbound handlers. Register row TS-70 retired. | +| F | Multi-line server text (e.g. `/help`'s reply) doesn't split on embedded `\n` — "probably broken in many places." | **FIXED this SHA.** `ChatWindowController.WrapText` now splits on `\n`/`\r\n` first, then word-wraps each segment independently; the confirmed-correct single-line early-out is unchanged for text with no embedded newline. | +| G | The chat input line overflows the window's right edge when the window is resized. | **FIXED this SHA.** The input field's right edge no longer holds a fixed absolute pixel position across a resize (retail edge-mode 0's "frozen at current" fallback, or the `AnchorEdges` default with no `Right` bit) — `ChatWindowController.Bind` now upgrades it to retail edge-mode 1 (`UiLayoutPolicy`) or the equivalent `AnchorEdges.Right` stretch, so the right edge tracks every resize instead of only the bind-time/channel-change recompute. | +| H | Extra/duplicate chat windows appear on number keys 1/2/3/4. | **NEW SLICE CH6** — see the slice list above. Retail has real multi-window chat with per-window type filters; this needs research before a fix, not a quick patch. | +| I | Resizing the chat window only works from one corner, not every corner. | **NEW SLICE CH6** — bundled with H and J as "all-corner resize." | +| J | The chat window has transparency issues / visual artifacts, and the user wants a transparency setting eventually. | **NEW SLICE CH6** — bundled as "opacity"; the future user-facing transparency setting is noted in the slice's own scope, not promised for CH6's first cut. | + +Findings A–G's evidence: this commit's diff + the new/updated tests in +`tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`, +`tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs`, +`tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs` (unchanged; verified +by inspection — no test pinned the old color), +`tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs`, +`tests/AcDream.Core.Net.Tests/Messages/ClientCommandResponsesTests.cs`, +and `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`. Full +Release suite green (see the commit message for the exact count). Items +H/I/J need the next visual round once CH6 lands; A–G still want a final +in-client eyes-on pass to confirm the fix reads correctly on screen (build ++ test green is necessary, not sufficient, for a presentation change). diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index 28d43065..ed9134d9 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -260,7 +260,7 @@ public sealed class PortalTunnelPresentation : IDisposable _rotationEndAngle = 0f; _rotationCurrentAngle = 0f; _camera.DirectionDegrees = 0f; - SetWaitCue(false); + ClearWaitCueNotice(); _visible = true; RebuildPose(); } @@ -271,7 +271,7 @@ public sealed class PortalTunnelPresentation : IDisposable if (_disposed) return; _visible = false; - SetWaitCue(false); + ClearWaitCueNotice(); _animationHooks.Clear(); _sequence.ClearAnimations(); } @@ -287,6 +287,18 @@ public sealed class PortalTunnelPresentation : IDisposable TickRotation(dt); } + /// + /// The hold-delay-gated arm/disarm LocalPlayerTeleportController + /// still drives every frame from RuntimeWorldTransitState.ObserveWait + /// (own telemetry: RuntimePortalSnapshot.WaitCueShown). This is + /// deliberately NOT the retail cue-emission path any more — see + /// 's unconditional per-segment write (item D, + /// #329). Kept only so the controller's own hold bookkeeping still has + /// somewhere to land; because visible is false for the entire + /// common case (a transit that never crosses the invented 5-second + /// hold), this is a same-value no-op there and never contends with the + /// per-segment write above. + /// public void SetWaitCue(bool visible) { if (_waitCueVisible == visible) @@ -297,6 +309,21 @@ public sealed class PortalTunnelPresentation : IDisposable visible ? "In Portal Space - Please Wait..." : null); } + /// + /// Unconditionally hides any wait-cue notice text and resets the + /// hold-delay dedup state, independent of 's + /// current value. now writes the notice text + /// directly (bypassing 's dedup), so the old + /// `SetWaitCue(false)` calls at Enter/Exit/Dispose could no-op and leave + /// a stale "In Portal Space..." line on screen after the presentation + /// went invisible — this always clears it. + /// + private void ClearWaitCueNotice() + { + _waitCueVisible = false; + _displayNotice?.Invoke(null); + } + /// /// Draw retail portal space into the active viewport. The caller suppresses /// the normal world viewport while this scene is visible, then draws the @@ -377,8 +404,29 @@ public sealed class PortalTunnelPresentation : IDisposable _rotationDuration = NextDouble(RotationDurationMin, RotationDurationMax); _rotationStartAngle = _rotationCurrentAngle; _rotationEndAngle = (float)NextDouble(0.0, 360.0); - if (_waitCueVisible) - _displayNotice?.Invoke("In Portal Space - Please Wait..."); + // Campaign CH user-gate round 1 (item D, #329): retail's + // gmSmartBoxUI::UseTime @0x004D6E30 emits + // ECM_UI::SendNotice_DisplayStringInfo(0x1a, "In Portal Space - + // Please Wait...") in the else arm of the rotation-segment- + // expiry test at 0x004D6FCD UNCONDITIONALLY -- every time a + // segment expires, with no hold/threshold check anywhere in + // that decompiled function. acdream's own RotationDurationMin/ + // Max already match retail's RandDouble(0.6, 1.8) segment + // window decoded at 0x004D6FE6; the only bug was gating this + // call on `_waitCueVisible`, which only ever became true after + // RuntimeWorldTransitState.RetailWaitCueDelay's invented 5- + // second hold -- a threshold most local transits never reach, + // so the cue silently never fired. This write is deliberately + // independent of `_waitCueVisible`/SetWaitCue (see that + // method's own doc comment): LocalPlayerTeleportController + // still drives SetWaitCue every frame from its own hold-delay + // bookkeeping, but because that call is a same-value no-op for + // the entire common case (a transit that never crosses the 5s + // hold), it never fights this unconditional per-segment write. + // Enter/Exit/Dispose clear the notice directly (not through + // SetWaitCue's dedup) so a stale line can never survive past + // this presentation going invisible. + _displayNotice?.Invoke("In Portal Space - Please Wait..."); } else { @@ -469,7 +517,7 @@ public sealed class PortalTunnelPresentation : IDisposable try { _visible = false; - SetWaitCue(false); + ClearWaitCueNotice(); _animationHooks.Clear(); _sequence.ClearAnimations(); _meshReferences.Dispose(); diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index c3da0473..7e275348 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -238,6 +238,38 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta c.Input.SpriteResolve = resolve; c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); + // Campaign CH user-gate round 1 (item G): the imported field's right + // edge otherwise holds a FIXED absolute pixel position across a + // window resize — retail edge-mode 0's "frozen at current" fallback + // (UiLayoutPolicy.ApplyFar), or the AnchorEdges default (Left|Top, + // no stretch) when this field imported without a LayoutPolicy at + // all. ReflowInputRow below only repositions Left/Width at bind + // time and on channel change; nothing re-runs it on a plain window + // RESIZE, so shrinking the window below its authored width left the + // input's right edge frozen past the new, narrower client area — + // the reported overflow. Retail edge-mode 1 on a FAR edge + // ("originalEdge + parentDelta", UiLayoutPolicy.ApplyFar) keeps a + // CONSTANT MARGIN from the parent's right edge instead, so the + // field's right edge now tracks every resize, not just + // bind/channel-change moments; the compatibility AnchorEdges.Right + // stretch is the equivalent programmatic-widget fallback. Only the + // right-edge behavior changes — Left/Top/Bottom stay whatever the + // DAT authored (or the AnchorEdges default). + if (c.Input.LayoutPolicy is { } inputPolicy) + { + c.Input.LayoutPolicy = new UiLayoutPolicy( + inputPolicy.LeftMode, + inputPolicy.TopMode, + rightMode: 1u, + inputPolicy.BottomMode, + inputPolicy.OriginalChild, + inputPolicy.OriginalParent); + } + else + { + c.Input.Anchors |= AnchorEdges.Right; + } + // ── Scrollbar — bind the factory-built Type-11 track element ──────── // The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar // directly. Find it, bind it in place — no remove/add needed. @@ -508,9 +540,40 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// public static IEnumerable WrapText(string text, float maxW, Func measure) { - if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW) + if (string.IsNullOrEmpty(text)) { - yield return text ?? string.Empty; + yield return string.Empty; + yield break; + } + + // Campaign CH user-gate round 1 (item F): server text (e.g. /help's + // reply) carries embedded '\n's. This function used to hand the + // WHOLE blob — newlines and all — to the single early-out below, + // rendering multi-line text as one UiText.Line with literal newline + // characters in it instead of one rendered line per segment. Split + // on '\n' FIRST (normalizing "\r\n"/bare "\r" the same way), then + // word-wrap each segment independently; the early-out is now scoped + // to one already-newline-free segment, so it only ever collapses a + // single-segment text to one line, never a multi-line one. + string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n'); + foreach (string segment in normalized.Split('\n')) + { + foreach (string frag in WrapSingleLine(segment, maxW, measure)) + yield return frag; + } + } + + /// + /// Greedy word-wrap for a single, already newline-free line. Split out of + /// (Campaign CH user-gate round 1, item F) so the + /// multi-segment split there can call this once per '\n'-delimited + /// segment without re-deriving the per-line wrap algorithm. + /// + private static IEnumerable WrapSingleLine(string text, float maxW, Func measure) + { + if (text.Length == 0 || maxW <= 0f || measure(text) <= maxW) + { + yield return text; yield break; } diff --git a/src/AcDream.App/UI/PortalWaitNoticeController.cs b/src/AcDream.App/UI/PortalWaitNoticeController.cs index 957508cc..20e1d5e9 100644 --- a/src/AcDream.App/UI/PortalWaitNoticeController.cs +++ b/src/AcDream.App/UI/PortalWaitNoticeController.cs @@ -9,6 +9,16 @@ namespace AcDream.App.UI; /// internal sealed class PortalWaitNoticeController : IDisposable { + /// + /// Register row AP-150/AP-178: CH user-gate round 1 (2026-08-09) PINNED + /// this — the user confirmed live, side-by-side against retail, that + /// this notice renders in the same bright yellow as an incoming Tell + /// (0x81C4C8, RetailChatColorTable.Yellow = + /// (1, 1, 0.247, 1)), not white. Same exact value as the + /// SpewBox's pinned colour (). + /// + private static readonly Vector4 RetailWaitCueColor = new(1f, 1f, 0.247f, 1f); + private readonly UiRoot _root; private readonly UiText _text; private UiText.Line[] _lines = []; @@ -32,7 +42,7 @@ internal sealed class PortalWaitNoticeController : IDisposable OneLine = true, ClickThrough = true, ZOrder = int.MaxValue, - DefaultColor = Vector4.One, + DefaultColor = RetailWaitCueColor, Visible = false, }; _text.LinesProvider = () => _lines; @@ -49,7 +59,7 @@ internal sealed class PortalWaitNoticeController : IDisposable return; } - _lines = [new UiText.Line(message, Vector4.One)]; + _lines = [new UiText.Line(message, RetailWaitCueColor)]; _text.Visible = true; } diff --git a/src/AcDream.App/UI/SpewBoxController.cs b/src/AcDream.App/UI/SpewBoxController.cs index 1fbcf331..bd5a979f 100644 --- a/src/AcDream.App/UI/SpewBoxController.cs +++ b/src/AcDream.App/UI/SpewBoxController.cs @@ -106,23 +106,22 @@ internal sealed class SpewBoxController : IDisposable private const float SpewBoxHeight = 72f; /// - /// Register row AP-178 (colour): retail's authored colour for THIS - /// element remains unresolved — the LayoutDesc dump (see class remarks) - /// found only two direct-state properties on the SpewBox element/ListBox - /// (a bool at 0x3B and the MaxConcurrentItems integer at - /// 0x10000028); no colour property surfaced in that direct-state - /// dump, and the per-UIStateId States dictionary (hover/ - /// pressed/etc. variants, which could carry it) was not walked this - /// pass. The chat colour table's 0x1A entry - /// (colorBrightRed) is explicitly NOT this — retail's own + /// Register row AP-178 (colour): CH user-gate round 1 (2026-08-09) + /// PINNED this — the user confirmed live, side-by-side against retail, + /// that the on-screen SpewBox text is the same bright yellow as an + /// incoming Tell (0x81C4C8, RetailChatColorTable.Yellow = + /// (1, 1, 0.247, 1)). The chat colour table's 0x1A entry + /// (colorBrightRed) is still explicitly NOT this — retail's own /// BuildChatColorLookupTable writes to ChatInterface::m_chatLog, /// a completely different element tree the SpewBox never touches - /// (research doc §3.2.3). This warm-yellow placeholder follows the - /// user's own recollection of the retail SpewBox's colour (unconfirmed - /// by any decompiled or DAT-authored source) rather than an arbitrary - /// choice. + /// (research doc §3.2.3); the LayoutDesc dump (see class remarks) also + /// never surfaced a colour property for this element. The exact retail + /// value simply happens to coincide with the Tell colour, per the user's + /// live observation. SIZE/POSITION/FONT remain OPEN — the user reports + /// all three still differ from retail; user gate round 1: differs, + /// iterating. /// - private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.4f, 1f); + private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.247f, 1f); private readonly UiRoot _root; private readonly UiText _text; diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 2fb38f6e..414d06b1 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -161,6 +161,42 @@ public static class GameEventWiring // AddTextToScroll(..., 0, 1, 0), pc:382186. chat.OnSystemMessage(text, chatType: 0u); }); + + // #362 / register row TS-70 (Campaign CH user-gate round 1, item E): + // @index/@clist/@hslist/@allegiance info sent byte-correct requests + // with no inbound handler — ACE's reply was silently dropped. All + // four render LogTextType 0x00 Default lines, matching their retail + // handlers exactly (see ClientCommandResponses' per-method doc + // comments for the named-retail anchors). + registrar.Register(GameEventType.ChannelIndex, e => + { + var channels = ClientCommandResponses.ParseChannelIndex(e.Payload.Span); + if (channels is null) return; + foreach (string line in ClientCommandResponses.FormatChannelIndexLines(channels)) + chat.OnSystemMessage(line, chatType: 0u); + }); + registrar.Register(GameEventType.ChannelList, e => + { + var names = ClientCommandResponses.ParseChannelList(e.Payload.Span); + if (names is null) return; + foreach (string line in ClientCommandResponses.FormatChannelListLines(names)) + chat.OnSystemMessage(line, chatType: 0u); + }); + registrar.Register(GameEventType.AvailableHouses, e => + { + var houses = ClientCommandResponses.ParseAvailableHouses(e.Payload.Span); + if (houses is null) return; + foreach (string line in ClientCommandResponses.FormatAvailableHousesLines(houses.Value)) + chat.OnSystemMessage(line, chatType: 0u); + }); + registrar.Register(GameEventType.AllegianceInfoResponse, e => + { + var info = ClientCommandResponses.ParseAllegianceInfoResponse(e.Payload.Span); + if (info is null) return; + foreach (string line in ClientCommandResponses.FormatAllegianceInfoLines(info.Value)) + chat.OnSystemMessage(line, chatType: 0u); + }); + if (onConfirmationRequest is not null) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => diff --git a/src/AcDream.Core.Net/Messages/ClientCommandResponses.cs b/src/AcDream.Core.Net/Messages/ClientCommandResponses.cs new file mode 100644 index 00000000..3d765735 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ClientCommandResponses.cs @@ -0,0 +1,406 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using AcDream.Core.Ui; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound parsers + retail-shaped text rendering for the four CH4 +/// client-command GameEvent responses that had a request builder +/// () but no inbound handler — issue +/// #362 / register row TS-70. ACE's server-side writers are the wire-shape +/// oracle (cited per method); the retail CLIENT's decompiled handlers are +/// the oracle for what text gets printed and in what order — see +/// docs/research/named-retail/acclient_2013_pseudo_c.txt anchors +/// cited per method. +/// +public static class ClientCommandResponses +{ + // ── 0x0149 ChannelIndex / 0x0148 ChannelList ─────────────────────────── + // ACE: GameEventChannelIndex.cs / GameEventChannelList.cs — both write + // `Writer.Write(count)` (u32) then `count` WriteString16L entries; no + // other framing. Retail: ClientCommunicationSystem:: + // Handle_Communication__ChannelIndex @0x0057d0c0 / + // Handle_Communication__ChannelList @0x0057d230 — both parse a single + // PackableList the same way. + + /// 0x0149 ChannelIndex: the list of GM/staff channel names available to this account. + public static IReadOnlyList? ParseChannelIndex(ReadOnlySpan payload) => + ParseStringList(payload); + + /// 0x0148 ChannelList: the list of character names currently listening on the queried channel. + public static IReadOnlyList? ParseChannelList(ReadOnlySpan payload) => + ParseStringList(payload); + + private static IReadOnlyList? ParseStringList(ReadOnlySpan payload) + { + try + { + int pos = 0; + uint count = ReadU32(payload, ref pos); + var list = new List(); + for (uint i = 0; i < count; i++) + list.Add(StringReader.ReadString16L(payload, ref pos)); + return list; + } + catch (FormatException) { return null; } + } + + /// + /// Retail-shaped lines for a ChannelIndex response — verbatim port of + /// Handle_Communication__ChannelIndex's header + per-entry loop + /// (all LogTextType 0x00 Default). Header text at + /// docs/research/named-retail/acclient_2013_pseudo_c.txt:1031363 + /// (data_7e0b10). + /// + public static IEnumerable FormatChannelIndexLines(IReadOnlyList channels) + { + yield return "The following channels are available to you:"; + foreach (string channel in channels) + yield return channel; + } + + /// + /// Retail-shaped lines for a ChannelList response — verbatim port of + /// Handle_Communication__ChannelList. Header text at + /// acclient_2013_pseudo_c.txt:1031367 (data_7e0b40). + /// + public static IEnumerable FormatChannelListLines(IReadOnlyList names) + { + yield return "The following characters are currently listening on the channel:"; + foreach (string name in names) + yield return name; + } + + // ── 0x0271 AvailableHouses ────────────────────────────────────────────── + // ACE: GameEventHouseAvailableHouses.cs — Write((uint)type) + + // Write(locations: List, PackableList.cs:20 — u32 count then N u32 + // landblock ids) + Write(totalAvailable: int). Retail: + // ClientHousingSystem::Handle_House__Recv_AvailableHouses @0x00585d50 + + // DisplayListOfCoords @0x00585c20. + + public readonly record struct AvailableHousesResponse( + uint HouseType, + IReadOnlyList Locations, + int TotalAvailable); + + public static AvailableHousesResponse? ParseAvailableHouses(ReadOnlySpan payload) + { + try + { + int pos = 0; + uint houseType = ReadU32(payload, ref pos); + uint count = ReadU32(payload, ref pos); + var locations = new uint[count]; + for (uint i = 0; i < count; i++) + locations[i] = ReadU32(payload, ref pos); + int totalAvailable = unchecked((int)ReadU32(payload, ref pos)); + return new AvailableHousesResponse(houseType, locations, totalAvailable); + } + catch (FormatException) { return null; } + } + + /// + /// ACE HouseType enum values (Undef=0, Cottage=1, Villa=2, + /// Mansion=3, Apartment=4) — matches the switch at + /// acclient_2013_pseudo_c.txt:400205-400227 exactly (case 1..4; + /// out-of-range/0 leaves the type name empty, matching retail's + /// skipped-`if` fallthrough). + /// + private static string HouseTypeName(uint houseType) => houseType switch + { + 1u => "cottages", + 2u => "villas", + 3u => "mansions", + 4u => "apartments", + _ => "", + }; + + /// + /// Retail-shaped lines for an AvailableHouses response. Verbatim port of + /// Handle_House__Recv_AvailableHouses @0x00585d50 + + /// DisplayListOfCoords @0x00585c20: the summary line, then one + /// coordinate line per location UNLESS the type is Apartment (retail + /// skips DisplayListOfCoords entirely for arg2 == 4 — + /// apartments have no world location), then the >400-locations + /// truncation notice (data_7e1d70, + /// acclient_2013_pseudo_c.txt:1032459) if TotalAvailable > 0x190. + /// All lines are LogTextType 0x00 Default. Coordinate formatting is + /// — the same + /// (lcoord - 1024) * 0.1 + 0.5 port + /// CPlayerSystem::InqPlayerCoords uses — with retail's own + /// 5-space indent and "Y, X" order + /// (" %.1f%s, %.1f%s\n", args Y, Ysuffix, X[, Xsuffix]). + /// + public static IEnumerable FormatAvailableHousesLines(AvailableHousesResponse response) + { + yield return string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"There are {response.TotalAvailable} {HouseTypeName(response.HouseType)} available."); + + if (response.HouseType != 4u) + { + foreach (uint landblockId in response.Locations) + { + if (RadarCoordinates.TryFromCell(landblockId, out var coordinates)) + yield return $" {coordinates.YText}, {coordinates.XText}"; + } + + if (response.TotalAvailable > 0x190) + yield return "There were too many houses to display all the locations. Only the first 400 locations are displayed here."; + } + } + + // ── 0x027C AllegianceInfoResponse ─────────────────────────────────────── + // ACE: GameEventAllegianceInfoResponse.cs -> AllegianceProfileExtensions. + // Write / AllegianceHierarchyExtensions.Write / AllegianceDataExtensions. + // Write. Retail: ClientAllegianceSystem:: + // Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0, walking + // AllegianceProfile::GetData/GetPatron/GetFirstVassal/GetNextVassal. + + /// + /// One retail AllegianceData record. + /// is the wire's "treeParent" tag (0 for the monarch, who has none) — + /// retail's own GetPatron/GetFirstVassal walk the flat + /// record list by this tag rather than storing an explicit tree. + /// + public readonly record struct AllegianceMemberRecord( + uint CharacterId, + uint ParentGuid, + bool IsLoggedIn, + string Name); + + /// + /// /HasAllegianceAge/ + /// HasPackedLevel bit values — ACE + /// Source/ACE.Server/Network/Enum/AllegianceIndex.cs. + /// + private const uint LoggedInBit = 0x1u; + private const uint HasAllegianceAgeBit = 0x4u; + private const uint HasPackedLevelBit = 0x8u; + + public readonly record struct AllegianceInfoResponse( + uint TargetGuid, + uint TotalMembers, + uint TotalVassals, + ushort RecordCount, + string AllegianceName, + AllegianceMemberRecord? Monarch, + IReadOnlyList Records) + { + /// + /// Port of AllegianceProfile::GetData: find the record + /// (monarch or otherwise) whose own characterID matches + /// . + /// + public AllegianceMemberRecord? FindData(uint guid) + { + if (Monarch is { } monarch && monarch.CharacterId == guid) + return monarch; + foreach (AllegianceMemberRecord record in Records) + { + if (record.CharacterId == guid) + return record; + } + return null; + } + + /// + /// Port of AllegianceProfile::GetPatron: the monarch has no + /// patron; anyone else's patron is applied to + /// their own record's + /// (which is the monarch's own guid when the patron IS the monarch — + /// ACE never emits a separate patron record in that case, see + /// AllegianceHierarchy.Write's !node.Patron.IsMonarch + /// guard). + /// + public AllegianceMemberRecord? FindPatron(uint guid) + { + if (Monarch is { } monarch && monarch.CharacterId == guid) + return null; + foreach (AllegianceMemberRecord record in Records) + { + if (record.CharacterId == guid) + return FindData(record.ParentGuid); + } + return null; + } + + /// Port of GetFirstVassal/GetNextVassal: every record whose parent is . + public IEnumerable FindVassals(uint guid) + { + foreach (AllegianceMemberRecord record in Records) + { + if (record.ParentGuid == guid) + yield return record; + } + } + } + + public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan payload) + { + try + { + int pos = 0; + uint targetGuid = ReadU32(payload, ref pos); + uint totalMembers = ReadU32(payload, ref pos); + uint totalVassals = ReadU32(payload, ref pos); + ushort recordCount = ReadU16(payload, ref pos); + _ = ReadU16(payload, ref pos); // oldVersion — not consulted by the renderer + + // officers: PackableHashTable. + // ACE always sends this empty ("always sent as empty in retail?" + // per AllegianceHierarchy.cs) and retail's own chat renderer never + // reads it — skip the entries, keep the cursor faithful. + ushort officerCount = ReadU16(payload, ref pos); + _ = ReadU16(payload, ref pos); // numBuckets + for (int i = 0; i < officerCount; i++) + { + _ = ReadU32(payload, ref pos); // guid + _ = ReadU32(payload, ref pos); // officer level + } + + // officerTitles: List.Write — a bare int32 count (NOT the + // PackableHashTable u16/u16 header), then N String16L. + int titleCount = unchecked((int)ReadU32(payload, ref pos)); + for (int i = 0; i < titleCount; i++) + _ = StringReader.ReadString16L(payload, ref pos); + + _ = ReadU32(payload, ref pos); // monarchBroadcastTime + _ = ReadU32(payload, ref pos); // monarchBroadcastsToday + _ = ReadU32(payload, ref pos); // spokesBroadcastTime + _ = ReadU32(payload, ref pos); // spokesBroadcastsToday + _ = StringReader.ReadString16L(payload, ref pos); // motd + _ = StringReader.ReadString16L(payload, ref pos); // motdSetBy + _ = ReadU32(payload, ref pos); // chatRoomID + + // bindPoint Position: cell(u32) + pos(3xfloat) + rotation(4xfloat, + // W/X/Y/Z order) = 32 bytes. Not surfaced by the retail chat + // renderer (only the Allegiance UI panel's bind-point display + // would use it) — skip with bounds checking via ReadU32. + for (int i = 0; i < 8; i++) + _ = ReadU32(payload, ref pos); + + string allegianceName = StringReader.ReadString16L(payload, ref pos); + _ = ReadU32(payload, ref pos); // nameLastSetTime + _ = ReadU32(payload, ref pos); // isLocked + _ = ReadU32(payload, ref pos); // approvedVassal + + AllegianceMemberRecord? monarch = null; + var records = new List(); + if (recordCount > 0) + { + monarch = ReadAllegianceData(payload, ref pos, parentGuid: 0u); + for (int i = 1; i < recordCount; i++) + { + uint parentGuid = ReadU32(payload, ref pos); + records.Add(ReadAllegianceData(payload, ref pos, parentGuid)); + } + } + + return new AllegianceInfoResponse( + targetGuid, totalMembers, totalVassals, recordCount, + allegianceName, monarch, records); + } + catch (FormatException) { return null; } + } + + private static AllegianceMemberRecord ReadAllegianceData( + ReadOnlySpan payload, ref int pos, uint parentGuid) + { + uint characterId = ReadU32(payload, ref pos); + _ = ReadU32(payload, ref pos); // cpCached + _ = ReadU32(payload, ref pos); // cpTithed + uint bitfield = ReadU32(payload, ref pos); + _ = ReadByte(payload, ref pos); // gender + _ = ReadByte(payload, ref pos); // heritage group + _ = ReadU16(payload, ref pos); // rank + if ((bitfield & HasPackedLevelBit) != 0u) + _ = ReadU32(payload, ref pos); // level + _ = ReadU16(payload, ref pos); // loyalty + _ = ReadU16(payload, ref pos); // leadership + if ((bitfield & HasAllegianceAgeBit) != 0u) + { + _ = ReadU32(payload, ref pos); // timeOnline + _ = ReadU32(payload, ref pos); // allegianceAge + } + else + { + _ = ReadU32(payload, ref pos); // uTimeOnline low + _ = ReadU32(payload, ref pos); // uTimeOnline high + } + string name = StringReader.ReadString16L(payload, ref pos); + return new AllegianceMemberRecord( + characterId, parentGuid, (bitfield & LoggedInBit) != 0u, name); + } + + /// + /// Retail's " *" online marker (data_7cef28, + /// acclient_2013_pseudo_c.txt:1027329-1027330 — UTF-16 bytes + /// 20 00 2a 00 = space + asterisk) versus the empty string for an + /// offline member (data_794320, the generic empty-PStringBase + /// sentinel). + /// + private static string OnlineMarker(AllegianceMemberRecord member) => + member.IsLoggedIn ? " *" : ""; + + /// + /// Retail-shaped lines for an AllegianceInfoResponse — verbatim port of + /// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0. + /// Retail's own AllegianceProfile::GetData failure (the queried + /// player has no record at all — e.g. no allegiance) returns early with + /// NO text printed at all; this yields an empty sequence for that case, + /// matching retail exactly rather than inventing a "no allegiance" + /// message retail never shows. + /// + public static IEnumerable FormatAllegianceInfoLines(AllegianceInfoResponse response) + { + AllegianceMemberRecord? self = response.FindData(response.TargetGuid); + if (self is not { } selfRecord) + yield break; + + yield return "Note: An asterisk (*) indicates that the character is currently online."; + yield return $"Allegiance information for {selfRecord.Name}{OnlineMarker(selfRecord)}:"; + + if (response.FindPatron(response.TargetGuid) is { } patron) + yield return $" Patron: {patron.Name}{OnlineMarker(patron)}"; + + bool wroteVassalHeader = false; + foreach (AllegianceMemberRecord vassal in response.FindVassals(response.TargetGuid)) + { + if (!wroteVassalHeader) + { + yield return " Vassals: "; + wroteVassalHeader = true; + } + yield return $" {vassal.Name}{OnlineMarker(vassal)}"; + } + } + + // ── Shared primitive readers (throw on truncation, like StringReader) ── + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } + + private static ushort ReadU16(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 2) throw new FormatException("truncated u16"); + ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos)); + pos += 2; + return value; + } + + private static byte ReadByte(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 1) throw new FormatException("truncated byte"); + byte value = source[pos]; + pos += 1; + return value; + } +} diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 6e9ba1e8..b4eb9e86 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -455,6 +455,13 @@ public sealed class PlayerMovementController private bool _jumpCharging; private float _jumpExtent; + // Campaign CH user-gate round 1 (item A, #329 sibling finding): previous + // frame's raw Jump input, so an airborne jump press can be reported on + // its RISING edge only — retail's jump_is_allowed (called from + // ClientCombatSystem::DoJump @0x0056B110) refuses once per press, not + // once per frame the key is held. + private bool _prevJumpHeld; + /// /// Current retail jump-powerbar state. Power is always zero when no jump is /// pending and otherwise lies in [0,1]. @@ -2574,6 +2581,21 @@ public sealed class PlayerMovementController _jumpCharging = false; _jumpExtent = 0f; } + else if (input.Jump && !_prevJumpHeld && !_body.OnWalkable) + { + // Campaign CH user-gate round 1, item A: the whole jump block + // above only ever evaluates `input.Jump` inside + // `input.Jump && _body.OnWalkable` (charge) or `_jumpCharging` + // (fire/refuse) — pressing jump while airborne and NOT already + // charging never reached either branch, so retail's 0x24 "You + // can't jump while in the air" (jump_is_allowed via + // ClientCombatSystem::DoJump @0x0056B110) could never fire live. + // Report it exactly like the grounded refusals above, gated to + // the press EDGE only (see _prevJumpHeld) so holding space + // in-air raises exactly one report, not one per frame. + ReportJumpRefusal(WeenieError.NotGrounded); + } + _prevJumpHeld = input.Jump; // ── 2. Run admitted complete-object quanta ──────────────────────────── // CPhysicsObj::update_object (0x00515D10) retains a remainder at or diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 669aab1c..7c81aab4 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -237,7 +237,11 @@ public sealed class ChatVM : IDisposable ChatKind.Tell => entry.SenderGuid != 0 ? $"{entry.Sender} tells you, \"{entry.Text}\"" : $"You tell {entry.Sender}, \"{entry.Text}\"", - ChatKind.System => $"[System] {entry.Text}", + // Campaign CH user-gate round 1 (item B): retail prints system text + // bare, with no "[System]" prefix — that prefix was acdream's own + // invention. [Popup] stays (AP-175, a deliberate divergent + // presentation marker for a different kind). + ChatKind.System => entry.Text, ChatKind.Popup => $"[Popup] {entry.Text}", // Phase I.5: emote rendering matches retail's leading-asterisk // convention ("* Caith waves at you"). SoulEmote uses the same diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 63f2ef92..ac0d6ecd 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Chat; @@ -309,4 +310,181 @@ public class ChatWindowControllerTests Assert.Null(ctrl); } + + // ── Input field resize: Campaign CH user-gate round 1, item G ──────────── + // The input line overflowed the chat window's right edge on resize. Its + // right edge held a FIXED absolute pixel position (either the imported + // UiLayoutPolicy's mode-0 "frozen at current" fallback, or the + // AnchorEdges default with no Right bit) — nothing re-ran the + // Left/Width recompute on a plain window resize, only at bind time and + // on channel change. Bind now leaves the input's right edge tracking + // the live parent width either way. + + [Fact] + public void Bind_InputField_WithNoImportedLayoutPolicy_NeverOverflowsOnNarrowerResize() + { + // BuildTestTree's synthetic ElementInfo nodes never set + // HasOriginalParentSize, so DatWidgetFactory.CreateLayoutPolicy + // returns null for every widget here — this exercises the + // AnchorEdges fallback branch of the fix. + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + Assert.NotNull(ctrl); + Assert.Null(ctrl!.Input.LayoutPolicy); + Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, ctrl.Input.Anchors & (AnchorEdges.Left | AnchorEdges.Right)); + + // First frame after Bind(): the per-frame draw pass calls ApplyAnchor + // against the LIVE (still-authored, 490px) inputBar width — this is + // the lazy margin CAPTURE, matching UiElement.ApplyAnchor's + // "!_anchorCaptured" first-call semantics. Only THEN does a resize + // (a later frame, a smaller parent width) exercise the stretch. + const float authoredParentWidth = 490f; + ctrl.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height); + + const float narrowerParentWidth = 300f; + ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height); + + Assert.True( + ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth, + $"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})"); + } + + [Fact] + public void Bind_InputField_WithNoImportedLayoutPolicy_GrowsWithWiderResize() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + Assert.NotNull(ctrl); + + const float authoredParentWidth = 490f; + ctrl!.Input.ApplyAnchor(authoredParentWidth, ctrl.Input.Height); + float originalWidth = ctrl.Input.Width; + + const float widerParentWidth = 800f; + ctrl.Input.ApplyAnchor(widerParentWidth, ctrl.Input.Height); + + Assert.True(ctrl.Input.Width > originalWidth, "the input should widen when the window grows"); + Assert.True(ctrl.Input.Left + ctrl.Input.Width <= widerParentWidth); + } + + [Fact] + public void Bind_InputField_WithImportedLayoutPolicy_RightEdgeTracksParentDeltaInsteadOfFreezing() + { + // Mirror the REAL production import path: the input field carries an + // authored UiLayoutPolicy (HasOriginalParentSize=true, as a real + // ImportInfos-resolved LayoutDesc element would). Right=0 here is + // retail's raw edge mode BEFORE the fix's upgrade — proving Bind + // replaces it with mode 1 rather than leaving mode 0's "frozen at + // current pixel position" behavior in place. + var (rootInfo, layout, vm) = BuildTestTree(); + var inputInfo = FindById(rootInfo, 0x10000016u) + ?? throw new System.InvalidOperationException("test fixture missing the input node"); + inputInfo.HasOriginalParentSize = true; + inputInfo.OriginalParentWidth = 490f; + inputInfo.OriginalParentHeight = 17f; + inputInfo.Left = 1u; // near-edge: fixed to current (retail mode 1) + inputInfo.Top = 1u; + inputInfo.Right = 0u; // far-edge mode this fix must upgrade away from + inputInfo.Bottom = 1u; + + // Rebuild the widget tree now that the fixture carries the policy + // inputs (BuildTestTree already built one without them). + layout = LayoutImporter.Build(rootInfo, NoTex, null); + var bus = new CaptureBus(); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + + Assert.NotNull(ctrl); + Assert.NotNull(ctrl!.Input.LayoutPolicy); + Assert.Equal(1u, ctrl.Input.LayoutPolicy!.RightMode); + + const float narrowerParentWidth = 300f; + ctrl.Input.ApplyAnchor(narrowerParentWidth, ctrl.Input.Height); + + Assert.True( + ctrl.Input.Left + ctrl.Input.Width <= narrowerParentWidth, + $"input right edge ({ctrl.Input.Left + ctrl.Input.Width}) overflowed the narrower parent width ({narrowerParentWidth})"); + } + + private static ElementInfo? FindById(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (var child in node.Children) + { + if (FindById(child, id) is { } found) return found; + } + return null; + } + + // ── WrapText: Campaign CH user-gate round 1, item F ────────────────────── + // /help (and "probably many places") never split on embedded '\n' — the + // whole multi-line blob rode the single early-out as ONE line. Split on + // '\n' first, then word-wrap each segment; a single-segment text keeps + // the pre-existing early-out behavior exactly. + + private static float MeasureByCharCount(string s) => s.Length; + + [Fact] + public void WrapText_EmbeddedNewlines_ProduceOneRenderedLinePerSegment() + { + string text = "line one\nline two\nline three"; + + // maxW is generous — every segment fits without word-wrapping, so + // this isolates the newline-split behavior specifically. + var lines = new List(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount)); + + Assert.Equal(new[] { "line one", "line two", "line three" }, lines); + } + + [Fact] + public void WrapText_CarriageReturnNewline_NormalizesTheSameAsBareNewline() + { + string text = "line one\r\nline two"; + + var lines = new List(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount)); + + Assert.Equal(new[] { "line one", "line two" }, lines); + } + + [Fact] + public void WrapText_SegmentLongerThanMaxWidth_StillWordWraps() + { + // Each segment is independently word-wrapped by the SAME algorithm + // the single-line path always used — a multi-line server message + // whose second line overflows the window still wraps that line. + string text = "short\nthis segment is much too long to fit on one line"; + + var lines = new List(ChatWindowController.WrapText(text, 10f, MeasureByCharCount)); + + Assert.Equal("short", lines[0]); + Assert.True(lines.Count > 2, "the long second segment should have wrapped into multiple lines"); + Assert.All(lines, line => Assert.True(MeasureByCharCount(line) <= 10f)); + Assert.Equal( + "this segment is much too long to fit on one line", + string.Join(" ", lines.Skip(1))); + } + + [Fact] + public void WrapText_SingleSegmentText_KeepsTheEarlyOutBehavior() + { + // No '\n' at all — the pre-existing single-line early-out path + // (whole text fits => returned verbatim as one fragment) is + // unchanged. + string text = "no newlines here"; + + var lines = new List(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount)); + + Assert.Equal(new[] { text }, lines); + } + + [Fact] + public void WrapText_ConsecutiveNewlines_ProduceABlankLine() + { + string text = "first\n\nthird"; + + var lines = new List(ChatWindowController.WrapText(text, 1000f, MeasureByCharCount)); + + Assert.Equal(new[] { "first", "", "third" }, lines); + } } diff --git a/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs b/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs index 25aa6193..94f3de7a 100644 --- a/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs @@ -1,9 +1,15 @@ +using System.Numerics; using AcDream.App.UI; namespace AcDream.App.Tests.UI; public sealed class PortalWaitNoticeControllerTests { + // Campaign CH user-gate round 1 (item D): the user confirmed live, + // side-by-side against retail, that this notice renders in the same + // bright yellow as an incoming Tell (RetailChatColorTable.Yellow). + private static readonly Vector4 RetailWaitCueColor = new(1f, 1f, 0.247f, 1f); + [Fact] public void Notice_IsCenteredOverlayAndDisposesFromRetainedRoot() { @@ -21,12 +27,14 @@ public sealed class PortalWaitNoticeControllerTests Assert.True(text.ClickThrough); Assert.Equal(root.Width, text.Width); Assert.Equal(root.Height, text.Height); + Assert.Equal(RetailWaitCueColor, text.DefaultColor); controller.Set("In Portal Space - Please Wait..."); Assert.True(text.Visible); UiText.Line line = Assert.Single(text.LinesProvider!()); Assert.Equal("In Portal Space - Please Wait...", line.Text); + Assert.Equal(RetailWaitCueColor, line.Color); controller.Set(null); Assert.False(text.Visible); diff --git a/tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs b/tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs index 0b08c637..a32a6ba8 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs @@ -59,6 +59,23 @@ internal sealed class AceWireWriter return this; } + /// BinaryWriter.Write(int) — little-endian, same bit pattern as Write(uint). + public AceWireWriter Write(int value) => Write(unchecked((uint)value)); + + /// BinaryWriter.Write(byte). + public AceWireWriter Write(byte value) + { + _buffer.Add(value); + return this; + } + + /// BinaryWriter.Write(bool) — one byte, 0 or 1 (used via Convert.ToUInt32 sites as a plain u32 instead; kept for completeness). + public AceWireWriter Write(bool value) => Write(value ? (byte)1 : (byte)0); + + /// BinaryWriter.Write(ulong) — little-endian. + public AceWireWriter Write(ulong value) => + Write((uint)(value & 0xFFFFFFFFu)).Write((uint)(value >> 32)); + /// BinaryWriter.Write(float) — little-endian IEEE-754. public AceWireWriter Write(float value) => Write((uint)BitConverter.SingleToInt32Bits(value)); diff --git a/tests/AcDream.Core.Net.Tests/Messages/ClientCommandResponsesTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandResponsesTests.cs new file mode 100644 index 00000000..b93469d0 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandResponsesTests.cs @@ -0,0 +1,439 @@ +using System.Buffers.Binary; +using System.Linq; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using AcDream.Core.Player; +using AcDream.Core.Spells; +using AcDream.Core.Ui; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign CH user-gate round 1, item E (#362 / register row TS-70): +/// parser round-trips for the four previously-unhandled inbound responses +/// (ChannelIndex 0x0149, ChannelList 0x0148, AvailableHouses 0x0271, +/// AllegianceInfoResponse 0x027C), plus a routing test proving each reaches +/// the chat transcript with retail's LogTextType. +/// +public sealed class ClientCommandResponsesTests +{ + // ── ChannelIndex / ChannelList ────────────────────────────────────────── + + [Fact] + public void ParseChannelIndex_RoundTrips() + { + byte[] wire = new AceWireWriter() + .Write((uint)2) + .WriteString16L("Admin") + .WriteString16L("Help") + .ToArray(); + + var channels = ClientCommandResponses.ParseChannelIndex(wire); + + Assert.NotNull(channels); + Assert.Equal(new[] { "Admin", "Help" }, channels); + } + + [Fact] + public void ParseChannelIndex_EmptyList_ParsesToEmpty() + { + byte[] wire = new AceWireWriter().Write((uint)0).ToArray(); + + var channels = ClientCommandResponses.ParseChannelIndex(wire); + + Assert.NotNull(channels); + Assert.Empty(channels); + } + + [Fact] + public void FormatChannelIndexLines_MatchesRetailHeaderAndOrder() + { + var lines = ClientCommandResponses.FormatChannelIndexLines(new[] { "Admin", "Help" }).ToArray(); + + Assert.Equal( + new[] + { + "The following channels are available to you:", + "Admin", + "Help", + }, + lines); + } + + [Fact] + public void ParseChannelList_RoundTrips() + { + byte[] wire = new AceWireWriter() + .Write((uint)3) + .WriteString16L("Caith") + .WriteString16L("Vandal") + .WriteString16L("Elysia") + .ToArray(); + + var names = ClientCommandResponses.ParseChannelList(wire); + + Assert.NotNull(names); + Assert.Equal(new[] { "Caith", "Vandal", "Elysia" }, names); + } + + [Fact] + public void FormatChannelListLines_MatchesRetailHeaderAndOrder() + { + var lines = ClientCommandResponses.FormatChannelListLines(new[] { "Caith" }).ToArray(); + + Assert.Equal( + new[] + { + "The following characters are currently listening on the channel:", + "Caith", + }, + lines); + } + + // ── AvailableHouses ────────────────────────────────────────────────────── + + // Block x=10 (0x0A), y=10 (0x0A), low=1 -> a valid outdoor cell id + // (LandDefs.GidToLcoord requires low in [1,0x40]). + private const uint TestVillaLandblockId = 0x0A0A0001u; + + [Fact] + public void ParseAvailableHouses_RoundTrips() + { + byte[] wire = new AceWireWriter() + .Write((uint)2) // HouseType.Villa + .Write((uint)1) // locations count + .Write(TestVillaLandblockId) + .Write(5) // totalAvailable + .ToArray(); + + var response = ClientCommandResponses.ParseAvailableHouses(wire); + + Assert.NotNull(response); + Assert.Equal(2u, response.Value.HouseType); + Assert.Equal(new[] { TestVillaLandblockId }, response.Value.Locations); + Assert.Equal(5, response.Value.TotalAvailable); + } + + [Fact] + public void FormatAvailableHousesLines_VillasIncludesSummaryAndCoordinate() + { + var response = new ClientCommandResponses.AvailableHousesResponse( + HouseType: 2u, + Locations: new[] { TestVillaLandblockId }, + TotalAvailable: 5); + + var lines = ClientCommandResponses.FormatAvailableHousesLines(response).ToArray(); + + Assert.True(RadarCoordinates.TryFromCell(TestVillaLandblockId, out var coordinates)); + Assert.Equal( + new[] + { + "There are 5 villas available.", + $" {coordinates.YText}, {coordinates.XText}", + }, + lines); + } + + [Fact] + public void FormatAvailableHousesLines_ApartmentsSkipsCoordinateList() + { + // Retail's Handle_House__Recv_AvailableHouses only calls + // DisplayListOfCoords when arg2 != 4 (apartments have no world + // location) — acclient_2013_pseudo_c.txt:400247. + var response = new ClientCommandResponses.AvailableHousesResponse( + HouseType: 4u, + Locations: new[] { TestVillaLandblockId }, + TotalAvailable: 3); + + var lines = ClientCommandResponses.FormatAvailableHousesLines(response).ToArray(); + + Assert.Equal(new[] { "There are 3 apartments available." }, lines); + } + + [Fact] + public void FormatAvailableHousesLines_OverFourHundred_AddsTruncationNotice() + { + var response = new ClientCommandResponses.AvailableHousesResponse( + HouseType: 1u, + Locations: System.Array.Empty(), + TotalAvailable: 401); + + var lines = ClientCommandResponses.FormatAvailableHousesLines(response).ToArray(); + + Assert.Equal( + new[] + { + "There are 401 cottages available.", + "There were too many houses to display all the locations. Only the first 400 locations are displayed here.", + }, + lines); + } + + // ── AllegianceInfoResponse ─────────────────────────────────────────────── + + private static byte[] BuildAllegianceWire( + uint targetGuid, + System.Collections.Generic.List<(uint characterId, uint parentGuid, bool loggedIn, string name)> records) + { + var w = new AceWireWriter() + .Write(targetGuid) + .Write((uint)(records.Count)) // totalMembers (not consulted by renderer) + .Write((uint)0) // totalVassals (not consulted) + .Write((ushort)records.Count) // recordCount + .Write((ushort)0x000B) // oldVersion + // officers PackableHashTable header: 0 entries, 256 buckets. + .Write((ushort)0) + .Write((ushort)256) + // officerTitles: int32 count = 0. + .Write((uint)0) + .Write((uint)0) // monarchBroadcastTime + .Write((uint)0) // monarchBroadcastsToday + .Write((uint)0) // spokesBroadcastTime + .Write((uint)0) // spokesBroadcastsToday + .WriteString16L("") // motd + .WriteString16L("") // motdSetBy + .Write((uint)0) // chatRoomID + // bindPoint Position: cell + 3 floats + 4 floats = 32 bytes. + .Write((uint)0) + .Write(0f).Write(0f).Write(0f) + .Write(0f).Write(0f).Write(0f).Write(0f) + .WriteString16L("Test Allegiance") // allegianceName + .Write((uint)0) // nameLastSetTime + .Write((uint)0) // isLocked + .Write(0); // approvedVassal + + for (int i = 0; i < records.Count; i++) + { + var (characterId, parentGuid, loggedIn, name) = records[i]; + if (i > 0) + w.Write(parentGuid); // the wire's own "treeParent" tag precedes non-monarch records. + + uint bitfield = 0x4u | 0x8u; // HasAllegianceAge | HasPackedLevel (ACE's own always-set default) + if (loggedIn) bitfield |= 0x1u; // LoggedIn + + w.Write(characterId) + .Write((uint)0) // cpCached + .Write((uint)0) // cpTithed + .Write(bitfield) + .Write((byte)0) // gender + .Write((byte)0) // heritage group + .Write((ushort)1) // rank + .Write((uint)5) // level (HasPackedLevel set) + .Write((ushort)0) // loyalty + .Write((ushort)0) // leadership + .Write((uint)0) // timeOnline (HasAllegianceAge set) + .Write((uint)0) // allegianceAge + .WriteString16L(name); + } + + return w.ToArray(); + } + + [Fact] + public void ParseAllegianceInfoResponse_MonarchOnly_RoundTrips() + { + const uint monarchGuid = 0x50000010u; + byte[] wire = BuildAllegianceWire( + monarchGuid, + new() { (monarchGuid, 0u, true, "Grandmaster") }); + + var response = ClientCommandResponses.ParseAllegianceInfoResponse(wire); + + Assert.NotNull(response); + Assert.Equal(monarchGuid, response.Value.TargetGuid); + Assert.Equal((ushort)1, response.Value.RecordCount); + Assert.Equal("Test Allegiance", response.Value.AllegianceName); + Assert.NotNull(response.Value.Monarch); + Assert.Equal("Grandmaster", response.Value.Monarch!.Value.Name); + Assert.True(response.Value.Monarch!.Value.IsLoggedIn); + Assert.Empty(response.Value.Records); + } + + [Fact] + public void FormatAllegianceInfoLines_MonarchOnly_PrintsHeaderAndSelfNoPatronNoVassals() + { + const uint monarchGuid = 0x50000010u; + var response = new ClientCommandResponses.AllegianceInfoResponse( + TargetGuid: monarchGuid, + TotalMembers: 1, + TotalVassals: 0, + RecordCount: 1, + AllegianceName: "Test Allegiance", + Monarch: new ClientCommandResponses.AllegianceMemberRecord(monarchGuid, 0u, true, "Grandmaster"), + Records: System.Array.Empty()); + + var lines = ClientCommandResponses.FormatAllegianceInfoLines(response).ToArray(); + + Assert.Equal( + new[] + { + "Note: An asterisk (*) indicates that the character is currently online.", + "Allegiance information for Grandmaster *:", + }, + lines); + } + + [Fact] + public void ParseAndFormatAllegianceInfoResponse_PatronAndVassals_RendersFullTree() + { + const uint monarchGuid = 0x50000001u; + const uint patronGuid = 0x50000002u; + const uint selfGuid = 0x50000003u; + const uint vassalGuid = 0x50000004u; + + // Records order matches AllegianceHierarchy.Write: patron (parent=monarch), + // self (parent=patron), then vassals (parent=self). + byte[] wire = BuildAllegianceWire( + selfGuid, + new() + { + (monarchGuid, 0u, false, "Monarch"), + (patronGuid, monarchGuid, true, "Patron"), + (selfGuid, patronGuid, false, "Self"), + (vassalGuid, selfGuid, true, "Vassal"), + }); + + var response = ClientCommandResponses.ParseAllegianceInfoResponse(wire); + Assert.NotNull(response); + Assert.Equal(3, response.Value.Records.Count); + + var lines = ClientCommandResponses.FormatAllegianceInfoLines(response.Value).ToArray(); + + Assert.Equal( + new[] + { + "Note: An asterisk (*) indicates that the character is currently online.", + "Allegiance information for Self:", + " Patron: Patron *", + " Vassals: ", + " Vassal *", + }, + lines); + } + + [Fact] + public void FormatAllegianceInfoLines_NoAllegiance_PrintsNothing() + { + // Retail's AllegianceProfile::GetData fails for a player with no + // record at all (no allegiance) and the handler returns early with + // NO text printed — acclient_2013_pseudo_c.txt:375151-375155. + const uint targetGuid = 0x50000099u; + var response = new ClientCommandResponses.AllegianceInfoResponse( + TargetGuid: targetGuid, + TotalMembers: 0, + TotalVassals: 0, + RecordCount: 0, + AllegianceName: "", + Monarch: null, + Records: System.Array.Empty()); + + Assert.Empty(ClientCommandResponses.FormatAllegianceInfoLines(response)); + } + + [Fact] + public void ParseAllegianceInfoResponse_EmptyWire_ParsesToNoRecords() + { + // ACE omits monarchData/records entirely when allegiance/node are + // null (AllegianceHierarchy.Write) -- recordCount stays 0. + const uint targetGuid = 0x50000099u; + byte[] wire = BuildAllegianceWire(targetGuid, new()); + + var response = ClientCommandResponses.ParseAllegianceInfoResponse(wire); + + Assert.NotNull(response); + Assert.Equal((ushort)0, response.Value.RecordCount); + Assert.Null(response.Value.Monarch); + Assert.Empty(response.Value.Records); + Assert.Empty(ClientCommandResponses.FormatAllegianceInfoLines(response.Value)); + } + + // ── Routing (GameEventWiring -> ChatLog) ───────────────────────────────── + + private static byte[] WrapEnvelope(GameEventType type, byte[] payload) + { + 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)type); + payload.CopyTo(body, GameEventEnvelope.HeaderSize); + return body; + } + + [Fact] + public void WireAll_ChannelIndex_ReachesChatTranscriptAsDefaultLogTextType() + { + var dispatcher = new GameEventDispatcher(); + var chat = new ChatLog(); + GameEventWiring.WireAll(dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat); + + byte[] payload = new AceWireWriter() + .Write((uint)1) + .WriteString16L("Sentinel") + .ToArray(); + + GameEventEnvelope? envelope = GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.ChannelIndex, payload)); + Assert.NotNull(envelope); + dispatcher.Dispatch(envelope.Value); + + ChatEntry[] entries = chat.Snapshot(); + Assert.Equal(2, entries.Length); + Assert.All(entries, e => Assert.Equal(ChatKind.System, e.Kind)); + Assert.All(entries, e => Assert.Equal((uint)RetailLogTextType.Default, e.ChannelId)); + Assert.Equal("The following channels are available to you:", entries[0].Text); + Assert.Equal("Sentinel", entries[1].Text); + } + + [Fact] + public void WireAll_AvailableHouses_ReachesChatTranscript() + { + var dispatcher = new GameEventDispatcher(); + var chat = new ChatLog(); + GameEventWiring.WireAll(dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat); + + byte[] payload = new AceWireWriter() + .Write((uint)2) + .Write((uint)1) + .Write(TestVillaLandblockId) + .Write(2) + .ToArray(); + + GameEventEnvelope? envelope = GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.AvailableHouses, payload)); + Assert.NotNull(envelope); + dispatcher.Dispatch(envelope.Value); + + ChatEntry[] entries = chat.Snapshot(); + Assert.Equal(2, entries.Length); + Assert.Equal("There are 2 villas available.", entries[0].Text); + Assert.All(entries, e => Assert.Equal((uint)RetailLogTextType.Default, e.ChannelId)); + } + + [Fact] + public void WireAll_AllegianceInfoResponse_ReachesChatTranscript() + { + var dispatcher = new GameEventDispatcher(); + var chat = new ChatLog(); + GameEventWiring.WireAll(dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat); + + const uint monarchGuid = 0x50000010u; + byte[] payload = BuildAllegianceWire( + monarchGuid, + new() { (monarchGuid, 0u, false, "Grandmaster") }); + + GameEventEnvelope? envelope = GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.AllegianceInfoResponse, payload)); + Assert.NotNull(envelope); + dispatcher.Dispatch(envelope.Value); + + ChatEntry[] entries = chat.Snapshot(); + Assert.Equal(2, entries.Length); + Assert.Equal("Note: An asterisk (*) indicates that the character is currently online.", entries[0].Text); + Assert.Equal("Allegiance information for Grandmaster:", entries[1].Text); + Assert.All(entries, e => Assert.Equal((uint)RetailLogTextType.Default, e.ChannelId)); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index cce96274..c0652597 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -940,6 +940,51 @@ public class PlayerMovementControllerTests Assert.Null(exception); } + // ── Campaign CH user-gate round 1 (item A): airborne jump refusal ────── + + [Fact] + public void JumpPress_RisingEdgeWhileAirborne_ReportsCantJumpInAir_HeldOnlyOnce_NoneAfterLanding() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); + + // Launch into the air with an ordinary charged jump — the fix must + // not touch this grounded charge/fire path at all. + controller.Update(1.0f, new MovementInput(Jump: true)); // full charge + controller.Update(0.016f, new MovementInput(Jump: false)); // release -> jump fires + Assert.True(controller.IsAirborne); + controller.Update(0.05f, new MovementInput()); // clear the ground before pressing again + + var reported = new List(); + controller.OnInterfaceText = (text, _) => reported.Add(text); + + // Rising edge while airborne: exactly one "You can't jump while in + // the air" report. + controller.Update(0.016f, new MovementInput(Jump: true)); + var report = Assert.Single(reported); + Assert.Equal(ClientTextRefusals.CantJumpInAir, report); + + // Holding the key across multiple further updates raises no + // additional reports. + controller.Update(0.016f, new MovementInput(Jump: true)); + controller.Update(0.016f, new MovementInput(Jump: true)); + controller.Update(0.016f, new MovementInput(Jump: true)); + Assert.Single(reported); + + // Release, then land. + controller.Update(0.016f, new MovementInput(Jump: false)); + for (int i = 0; i < 60 && controller.IsAirborne; i++) + controller.Update(0.05f, new MovementInput()); + Assert.False(controller.IsAirborne, "should have landed"); + + // Landing then pressing again while grounded raises none (the + // grounded charge succeeds normally for an unburdened character). + reported.Clear(); + controller.Update(0.016f, new MovementInput(Jump: true)); + Assert.Empty(reported); + } + // ── Campaign P Slice P5 (2026-07-30): ConstraintManager leash arming (#167) ── // // docs/research/2026-07-30-constraint-leash-constants.md. The player's diff --git a/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs index 749f07cc..71515f29 100644 --- a/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs @@ -102,8 +102,10 @@ public sealed class ChatVMTests [Fact] public void FormatEntry_System_NoSenderShown() { + // Campaign CH user-gate round 1 (item B): retail prints system text + // bare, with no "[System]" prefix. var entry = new ChatEntry(ChatKind.System, Sender: "", "Your spell fizzled!", 0, 0); - Assert.Equal("[System] Your spell fizzled!", ChatVM.FormatEntry(entry)); + Assert.Equal("Your spell fizzled!", ChatVM.FormatEntry(entry)); } [Fact]