From c1f1582576bc7f0de7be0554c28cde18c53d433b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 10 Aug 2026 08:40:24 +0200 Subject: [PATCH] fix(chat): Campaign CH user-gate round 2 -- portal notice rerouted to SpewBox, verbatim /help extraction, jump-in-air evidence Item 2: retail's portal-space "In Portal Space..." notice is the SpewBox (ECM_UI::SendNotice_DisplayStringInfo(0x1A,...) -> AddTextToScroll(str, 0x1A, 1, 0), hardcoded to the SpewBox per the decomp), not a dedicated centered overlay. PortalWaitNoticeController and its lease are deleted; PortalTunnelPresentation's per-rotation-segment cadence now writes straight into RuntimeCommunicationState.AddText(ClientLocal) -- the SpewBox's own dedupe-at-index-0 handles the repetition exactly as retail's does. Register row AP-184 records the surface fix and the AP-178 scope extension. Items 4+5: /help text was partially fabricated -- the user caught the "/help death" meta-message. Generalized tools/pdb-extract/sweep_weenie_strings.py to decode narrow PStringBase literals (the ClientCommunicationSystem::Help* family's shape) alongside its original UTF-16LE support, then swept every HelpXxxGroup function's exact byte extent against the PDB-paired acclient.exe. 4 of 7 group topics (death/status/text/allegiances) are now complete verbatim listings; the other 3 (channels/chatting/commands) keep an honest UNVERIFIED note citing HelpStupidChannelHack @0x0056f290 (a genuinely undecodable BN-mislabeled-fragment mechanism) instead of the old fabricated sentinel. 7 of ~35 channel one-liners are also now verbatim. ISSUES.md #364 tracks the remainder; RetailCommandHelpTableTests.cs pins every result byte-exact. Item 1: jump-in-air refusal still silent live is NOT reproduced and NOT speculatively fixed. Exhaustive static re-audit found the mechanism correct by construction (single-writer OnWalkable, exactly-once-per-frame Update()/Capture(), no interfering edge-history resets). A live headless repro (new jump-probe bot policy, real ACE connect) was blocked -- probeaccount2 has no character, and the graphical client already owned testaccount this session so the task's own fallback rule forbade using it. Two temporary probes are left behind ACDREAM_PROBE_JUMP=1 (blocked entirely in Headless by the existing multi-session static-state guard -- graphical-only for the next round). Item 3 confirmed fixed, no regression. Item 6 (resize: no diagonal cursors, cannot grow Y from bottom-right) folded into CH6a's existing scope. Full Release suite: 12,267 passed / 4 skipped / 0 failed (up from 12,221/4/0). Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 32 +++ .../retail-divergence-register.md | 2 +- docs/plans/2026-08-09-chat-parity-campaign.md | 51 +++- .../LivePresentationComposition.cs | 45 ++-- .../Rendering/PortalTunnelPresentation.cs | 81 +++---- .../UI/PortalWaitNoticeController.cs | 74 ------ src/AcDream.App/UI/SpewBoxController.cs | 14 +- .../Physics/PhysicsDiagnostics.cs | 13 + .../Policies/HeadlessBotPolicy.cs | 165 +++++++++++++ .../Gameplay/PlayerMovementController.cs | 27 +++ .../Panels/Chat/RetailCommandHelpTable.cs | 227 +++++++++++++++--- .../UI/PortalWaitNoticeControllerTests.cs | 45 ---- .../Chat/RetailCommandHelpTableTests.cs | 168 +++++++++++++ tools/pdb-extract/sweep_weenie_strings.py | 98 ++++++-- 14 files changed, 788 insertions(+), 254 deletions(-) delete mode 100644 src/AcDream.App/UI/PortalWaitNoticeController.cs delete mode 100644 tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 8d56ecbe..f69864bf 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -121,6 +121,38 @@ than a REJECT-review fix batch; CH5-or-later. **Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH, CH4 REJECT-review). +## #364 — Three `/help` group topics still partial: HelpStupidChannelHack unresolved + +**Status:** OPEN — filed 2026-08-09, Campaign CH user-gate round 2, item 3. +The user caught `/help death` printing an acdream meta-message instead of +retail's real listing; all 7 `ClientCommunicationSystem::HelpXxxGroup` +nodes were re-extracted verbatim from the PDB-paired binary via a +generalized `tools/pdb-extract/sweep_weenie_strings.py --ascii-only`. +4 of 7 (death/status/text/allegiances) are now COMPLETE verbatim listings +(`RetailCommandHelpTable.DeathGroupDetail` etc.). 3 remain PARTIAL +(`ChannelsGroupDetail`, `ChattingGroupDetail`, `CommandsGroupDetail`): +their detail text is built (in full or in part) by +`ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which +constructs its output from three string fragments Binary Ninja +mis-attributes to unrelated vtable slots (the classic BN pooled/ +mislabeled-data artifact — `&ClientCommunicationSystem::\`vftable'. +RecvNotice_StartBarberNotice` etc. are NOT real vtable dispatch) +concatenated around a live `ChannelSystem::GetChannelName` call — +genuinely not decodable from a static string sweep. Each partial group +keeps its own summary line (fully verbatim) and an explicit UNVERIFIED +note citing the address; `chatting` additionally resolves 7 of its 8 +detail entries (only the 5 HelpStupidChannelHack-delegated channel-alias +lines are missing). The same mechanism also blocks 5 of ~35 channel +one-liners in `ByVerb` (fellowship/monarch/patron/vassals/covassal +family), which stay acdream-authored summaries. Resolving +HelpStupidChannelHack requires reading the three mislabeled string +fragments directly out of `.rdata` by address (not by BN symbol name) and +confirming the channel-name substring logic — a focused follow-up, not +guessed. + +**Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH, +user gate round 2). + ## Note — six invented chat verbs removed for registry parity (2026-08-09) Campaign CH slice CH4 deleted `/gen`, `/cv`, `/lookingforgroup`, `/tr`, diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 082584cf..5ac99aea 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -170,7 +170,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 129 active rows (AP-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) +## 3. Documented approximation (AP) — 130 active rows (AP-184 filed 2026-08-09 at Campaign CH user-gate round 2, item 3 — three of the seven retail `/help` group-topic listings (channels/chatting/commands) remain PARTIAL, not fully verbatim: their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which constructs its output from three BN-mislabeled data fragments (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc. are NOT real vtable dispatch) concatenated around a live `ChannelSystem::GetChannelName` lookup, not decodable with confidence from a static string sweep; each partial group keeps its own verbatim summary line and an explicit UNVERIFIED note instead of the fully-fabricated meta-message the user caught on `/help death` (that group, plus status/text/allegiances, are now COMPLETE verbatim listings); tracked as ISSUES.md #364. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). 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 diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 8344310c..819b7e7d 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -18,11 +18,21 @@ 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. **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 +round 1" below.** Items A–G are fixed in round 1'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. +one corner, transparency/artifacts) are out of round 1's scope and filed +as a new slice, CH6. **User gate round 2 ran 2026-08-09 and found six +more findings; see "User gate — round 2" below.** Items 2 ("In Portal +Space…" on the wrong surface) and 4+5 (`/help` text fabricated/misaligned) +are fixed in this round's commit; item 3 is confirmed-fixed with no +regression; item 6 is folded into CH6a's existing scope. Item 1 +(jump-in-air still silent live) is NOT reproduced and NOT speculatively +fixed — round 1's press-edge logic is provably correct by construction +from source, a live headless repro was attempted and blocked (no +character on the probe account), and two temporary graphical-only probes +are left behind for the next round. Status stays CODE-COMPLETE pending +the next user gate round (still needed for item 1, CH6, and a final +in-client visual pass on everything fixed so far). **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. @@ -117,7 +127,14 @@ implementer per slice against a pinned contract (per teach `LayoutImporter` element type 9 (`UIElement_Resizebar`, 8 authored grips: 4 edges + 4 corners via bools 0x2A–0x2D); fix the resize mask that excludes Top. Expected to also clear the round-1 - artifact report. + artifact report. **User gate round 2 (2026-08-09) added two more + symptoms to fold into this same slice:** resize has no diagonal + (corner) cursor feedback — only edge cursors show, the 4 corner grips + from the 8-grip authored set above have no matching cursor affordance + yet; and the window cannot grow in the Y axis when dragging from the + bottom-right corner (a corner-grip axis-composition bug, likely the + same resize-mask gap already scoped for the Top edge above, now + confirmed to also affect corner grips specifically). - **CH6b — floating windows 1–4.** Mount `0x2100005B` ×4 as always-resident children per `gmGamePlayUI::SetupChildren @0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510); @@ -158,6 +175,7 @@ implementer per slice against a pinned contract (per | 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 | +| User gate round 2 | (this commit) | 12,267 passed / 4 skipped / 0 failed | — | items 2/4/5 fixed this commit, item 3 confirmed-fixed, item 6 folded into CH6a's spec, item 1 NOT reproduced (see "User gate — round 2" below) | ### CH4 closeout (2026-08-09) @@ -512,3 +530,26 @@ 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). + +## User gate — round 2 (2026-08-09) + +The user tested round 1's fixes live and reported six more findings. + +| # | User finding (condensed) | Disposition | +|---|---|---| +| 1 | Jumping while already airborne is STILL silent live — round 1's press-edge branch (`PlayerMovementController._prevJumpHeld`) has a passing unit test but never visibly fires in the running client. | **NOT REPRODUCED; NO SPECULATIVE FIX SHIPPED.** Exhaustive static re-audit of the whole live path (branch logic, `_body.OnWalkable`'s single writer — the quantum-loop resolve, driven only by real physics results, never anything else — the exact-once-per-frame `Update()`/`Capture()` call site, `TakeControlFromServer`'s edge-history reset scope, mouse-look's extra `Capture()` calls) found no bug: the mechanism is provably correct BY CONSTRUCTION from source. Attempted a live headless repro (new `jump-probe` bot policy exercising the SAME typed `commands.Movement.SetIntent` surface, real ACE connect at 127.0.0.1:9000) — blocked: `probeaccount2` connects but has NO CHARACTER ("no available characters on account"), and per the task's own constraint the fallback to `testaccount` is refused because the graphical client (PID confirmed running this session) owns that account. Left TWO temporary probes behind a `PhysicsDiagnostics`-family flag (`ACDREAM_PROBE_JUMP=1`, blocked entirely in Headless by the existing multi-session static-state guard, so this is a GRAPHICAL-client-only diagnostic for the next round): `[jump]` in `ReportJumpRefusal` (prints unconditionally, even when `OnInterfaceText` is null, to separate "branch never evaluated true" from "callback dropped it"), and `[jump-tick]` (per-tick trace bracketing every frame where Jump is/was held). Next round: launch with `ACDREAM_PROBE_JUMP=1`, reproduce, and read the console trace — it will show exactly which of the three jump branches fires and what `OnWalkable`/`_prevJumpHeld` were at that instant. | +| 2 | Retail shows "In Portal Space…" at the TOP of the screen, SMALL font, tell-yellow (the SpewBox) — acdream renders a big centered white/yellow overlay instead. | **FIXED this SHA.** Verified in the decomp: `gmSmartBoxUI::UseTime`'s notice emits via `ECM_UI::SendNotice_DisplayStringInfo(0x1A, ...)`, which forwards to `AddTextToScroll(str, 0x1A, 1, 0)` — type `0x1A` is HARDCODED to the SpewBox (`docs/research/2026-08-09-chat-retail-interface-text.md` §1.1/§4.2), the same surface every other `ClientLocal` refusal (jump-in-air, etc.) already uses. `PortalWaitNoticeController` (the dedicated centered-overlay presentation) and its lease are DELETED outright; `PortalTunnelPresentation`'s round-1 per-rotation-segment emission cadence is unchanged, now writing straight into `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)` — the SpewBox's own dedupe-at-index-0 (`SpewBoxState.Tick`) collapses the per-segment repetition exactly as retail's `gmSpewBoxUI::Update` does. No conflict with the decomp — the fix and the user's report agree exactly. | +| 3 | (Round 1 item B, `[System] ` prefix) | **CONFIRMED FIXED** — no regression, no further action. | +| 4+5 | `/help` output differs from retail; `/help death` prints an acdream META-MESSAGE ("This is a retail help-topic group; acdream has not yet extracted its exact retail listing text…"); spacing/alignment is off. | **FIXED this SHA via verbatim extraction, not authorship.** `tools/pdb-extract/sweep_weenie_strings.py` generalized to decode narrow `PStringBase` literals (the Help* family's shape) alongside its original UTF-16LE support, then swept every `ClientCommunicationSystem::HelpXxxGroup` function's exact byte extent (read from the pseudo-C's own function-header addresses) against the PDB-paired `C:\Users\erikn\Downloads\acclient.exe` (verified MATCH). 4 of 7 groups (death/status/text/allegiances) are now COMPLETE verbatim listings — `/help death` now prints retail's real 8-line text, byte-exact including the retail-authentic trailing space on the `@day` line. The other 3 (channels/chatting/commands) delegate part or all of their detail text to `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which builds its output from BN-mislabeled data fragments around a live channel-name lookup — genuinely not decodable with confidence; each keeps its own verbatim summary line plus an explicit UNVERIFIED note citing the address, never a fabricated meta-message. 7 of the ~35 channel one-liners are also now verbatim (a/guild/gu, general/cg, trade/ct, lfg/clfg, roleplay/crp, society/soc, olthoi/o), each including retail's own "Also: @alias" text — this is also the alignment/spacing fix, since the fabricated summaries never matched retail's exact wording. Register row AP-184 filed; ISSUES.md #364 tracks the remaining 3-group gap. `RetailCommandHelpTableTests.cs` (new) pins every complete listing and the partial/UNVERIFIED shape byte-exact. | +| 6 | Resize is still buggy: missing diagonal (corner) cursor feedback, and the window cannot grow in the Y axis when dragging from the bottom-right corner. | **FOLDED into CH6a's spec** (not fixed this SHA — CH6a itself has not started; see the slice list above, now carrying these two symptoms alongside the pre-existing resize-mask/LayoutDesc-import scope). | + +Evidence for items 2, 4, and 5: this commit's diff + `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs` (new) + the deletion of +`tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs` (its subject +class no longer exists) + `PortalTunnelAssetTests.cs` (unchanged; verified by +inspection — its `CreateRequired` call never passed the removed +`displayNoticeLifetime` parameter, so it needed no update). Item 1's evidence +is negative: no code changed in the jump branch logic itself, only +diagnostics; the two temporary probes and the `jump-probe` headless bot +policy are the round's deliverable for item 1, pending either a successful +next-round repro or a character created on `probeaccount2`. Full Release +suite green (see the commit message for the exact count). diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index d6802e3d..9598d47e 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -1061,25 +1061,30 @@ internal sealed class LivePresentationCompositionPhase ?? throw new InvalidOperationException( "The graphics backend must publish a world pass scope.")), static value => value.Dispose()); - CompositionAcquisitionScope.CompositionAcquisitionLease< - PortalWaitNoticeController>? portalWaitNoticeLease = null; - if (interaction.RetainedUi is { } portalRetainedUi) - { - portalWaitNoticeLease = scope.Acquire( - "portal wait notice", - () => new PortalWaitNoticeController( - portalRetainedUi.Host.Root), - static value => value.Dispose()); - } - Action? displayPortalWaitNotice = - portalWaitNoticeLease is { } waitNoticeLease - ? waitNoticeLease.Resource.Set - : null; + // Campaign CH user-gate round 2, item 2: the user reported retail's + // portal-space notice at the TOP of the screen in SMALL tell-yellow + // text — the SpewBox — while the former PortalWaitNoticeController + // rendered a big centered white/yellow overlay of its own. The + // decomp confirms the user's report: gmSmartBoxUI::UseTime + // @0x004D6E30 emits via ECM_UI::SendNotice_DisplayStringInfo(0x1A, + // ...), which forwards to AddTextToScroll(str, 0x1A, 1, 0) — type + // 0x1A is HARDCODED to the SpewBox + // (docs/research/2026-08-09-chat-retail-interface-text.md + // §1.1/§4.2), the SAME surface every other ClientLocal refusal uses + // (PlayerMovementController.ReportJumpRefusal, etc.), not a + // dedicated overlay. PortalWaitNoticeController is deleted along + // with its lease; this delegate now writes straight into the + // canonical AddText router every other on-screen interface-text + // site already uses. Unlike the deleted controller, this has no + // retained-UI dependency (Runtime state, not a UI element), so it + // is always wired, not gated on interaction.RetainedUi. + Action displayPortalWaitNotice = text => + d.Runtime.CommunicationOwner.AddText( + text, + AcDream.Core.Chat.RetailLogTextType.ClientLocal); // Campaign CH slice CH2: the SpewBox is retail's OTHER on-screen - // interface-text surface (research doc §1.1/§7.3/§7.4) — modeled - // directly on the PortalWaitNoticeController lease immediately - // above, wired into the same retained-UI host. Like the wait notice - // in its no-tunnel arm, the transferred controller is reclaimed by + // interface-text surface (research doc §1.1/§7.3/§7.4), wired into + // the retained-UI host. The transferred controller is reclaimed by // the retained-UI root's own teardown (its Dispose only detaches // children from that root). CompositionAcquisitionScope.CompositionAcquisitionLease< @@ -1120,8 +1125,7 @@ internal sealed class LivePresentationCompositionPhase portalDispatcher, foundation.SceneLighting!, foundation.MeshAdapter!, - displayPortalWaitNotice, - portalWaitNoticeLease?.Resource), + displayPortalWaitNotice), static tunnel => tunnel.PrepareResources()); } catch (Exception acquisitionFailure) @@ -1304,7 +1308,6 @@ internal sealed class LivePresentationCompositionPhase clipFrameLease.Transfer(); portalDepthLease.Transfer(); portalTunnelLease?.Transfer(); - portalWaitNoticeLease?.Transfer(); spewBoxLease?.Transfer(); skyLease.Transfer(); particleLease.Transfer(); diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index ed9134d9..ba4614cf 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -86,8 +86,7 @@ public sealed class PortalTunnelPresentation : IDisposable private readonly WorldEntity _entity; private readonly PortalTunnelCamera _camera = new(); private readonly Random _random; - private readonly Action? _displayNotice; - private IDisposable? _displayNoticeLifetime; + private readonly Action? _displayNotice; private readonly SyntheticEntityMeshReferenceOwner _meshReferences; private bool _visible; @@ -113,8 +112,7 @@ public sealed class PortalTunnelPresentation : IDisposable IAnimationLoader animationLoader, IAnimationHookSink hookSink, Random random, - Action? displayNotice, - IDisposable? displayNoticeLifetime) + Action? displayNotice) { _scope = scope; _frames = frames; @@ -128,7 +126,6 @@ public sealed class PortalTunnelPresentation : IDisposable _sequence.HookObj = _animationHooks; _random = random; _displayNotice = displayNotice; - _displayNoticeLifetime = displayNoticeLifetime; _entity = new WorldEntity { @@ -164,8 +161,7 @@ public sealed class PortalTunnelPresentation : IDisposable WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo, IWbMeshAdapter meshAdapter, - Action? displayNotice = null, - IDisposable? displayNoticeLifetime = null, + Action? displayNotice = null, Random? random = null) { ArgumentNullException.ThrowIfNull(scope); @@ -202,8 +198,7 @@ public sealed class PortalTunnelPresentation : IDisposable animationLoader, hookSink, random ?? Random.Shared, - displayNotice, - displayNoticeLifetime); + displayNotice); } internal static void EnsureRequiredAssets( @@ -260,7 +255,7 @@ public sealed class PortalTunnelPresentation : IDisposable _rotationEndAngle = 0f; _rotationCurrentAngle = 0f; _camera.DirectionDegrees = 0f; - ClearWaitCueNotice(); + _waitCueVisible = false; _visible = true; RebuildPose(); } @@ -271,7 +266,7 @@ public sealed class PortalTunnelPresentation : IDisposable if (_disposed) return; _visible = false; - ClearWaitCueNotice(); + _waitCueVisible = false; _animationHooks.Clear(); _sequence.ClearAnimations(); } @@ -291,38 +286,19 @@ public sealed class PortalTunnelPresentation : IDisposable /// 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 + /// deliberately NOT the retail cue-emission path — 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. + /// somewhere to land; it is pure bookkeeping now (Campaign CH user-gate + /// round 2, item 2). The former text-clear invoke made sense only for + /// the deleted PortalWaitNoticeController's overwrite-only slot — + /// the SpewBox now targets has no "hide" + /// concept; a line disappears when its own timeout elapses + /// (SpewBoxState.DefaultLifetime), exactly like retail's + /// gmSpewBoxUI. / reset this + /// same bookkeeping flag directly. /// - public void SetWaitCue(bool visible) - { - if (_waitCueVisible == visible) - return; - - _waitCueVisible = visible; - _displayNotice?.Invoke( - 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); - } + public void SetWaitCue(bool visible) => _waitCueVisible = visible; /// /// Draw retail portal space into the active viewport. The caller suppresses @@ -420,12 +396,21 @@ public sealed class PortalTunnelPresentation : IDisposable // 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. + // bookkeeping, but that call is pure bookkeeping now, so it + // never fights this unconditional per-segment write. + // Campaign CH user-gate round 2, item 2: this now targets the + // SpewBox (RuntimeCommunicationState.AddText, ClientLocal), + // retail's real destination for this notice + // (ECM_UI::SendNotice_DisplayStringInfo(0x1A, ...) -> + // AddTextToScroll(str, 0x1A, 1, 0), hardcoded to the SpewBox — + // docs/research/2026-08-09-chat-retail-interface-text.md + // §1.1/§4.2), not the former dedicated centered-overlay + // controller. Neither Enter/Exit/Dispose nor SetWaitCue clears + // anything any more — the SpewBox has no "hide" concept, a line + // simply times out (SpewBoxState.DefaultLifetime) exactly like + // retail's gmSpewBoxUI, and its own dedupe-at-index-0 + // (SpewBoxState.Tick) collapses this call's per-segment + // repetition into one refreshed line, same as retail. _displayNotice?.Invoke("In Portal Space - Please Wait..."); } else @@ -517,14 +502,12 @@ public sealed class PortalTunnelPresentation : IDisposable try { _visible = false; - ClearWaitCueNotice(); + _waitCueVisible = false; _animationHooks.Clear(); _sequence.ClearAnimations(); _meshReferences.Dispose(); if (_meshReferences.IsDisposed) { - _displayNoticeLifetime?.Dispose(); - _displayNoticeLifetime = null; _disposed = true; } } diff --git a/src/AcDream.App/UI/PortalWaitNoticeController.cs b/src/AcDream.App/UI/PortalWaitNoticeController.cs deleted file mode 100644 index 20e1d5e9..00000000 --- a/src/AcDream.App/UI/PortalWaitNoticeController.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Retained presentation of retail's ECM_UI display-string notice emitted by -/// gmSmartBoxUI::UseTime @ 0x004D6E30 while portal-space cell blocking -/// continues. It is a centered overlay, not a chat message. -/// -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 = []; - private bool _disposed; - - public PortalWaitNoticeController(UiRoot root) - { - _root = root ?? throw new ArgumentNullException(nameof(root)); - _text = new UiText - { - Name = "PortalSpaceWaitNotice", - Left = 0f, - Top = 0f, - Width = root.Width, - Height = root.Height, - Anchors = AnchorEdges.Left - | AnchorEdges.Top - | AnchorEdges.Right - | AnchorEdges.Bottom, - Centered = true, - OneLine = true, - ClickThrough = true, - ZOrder = int.MaxValue, - DefaultColor = RetailWaitCueColor, - Visible = false, - }; - _text.LinesProvider = () => _lines; - _root.AddChild(_text); - } - - public void Set(string? message) - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (string.IsNullOrEmpty(message)) - { - _text.Visible = false; - _lines = []; - return; - } - - _lines = [new UiText.Line(message, RetailWaitCueColor)]; - _text.Visible = true; - } - - public void Dispose() - { - if (_disposed) - return; - - _root.RemoveChild(_text); - _disposed = true; - } -} diff --git a/src/AcDream.App/UI/SpewBoxController.cs b/src/AcDream.App/UI/SpewBoxController.cs index bd5a979f..61a8a541 100644 --- a/src/AcDream.App/UI/SpewBoxController.cs +++ b/src/AcDream.App/UI/SpewBoxController.cs @@ -7,11 +7,15 @@ namespace AcDream.App.UI; /// /// Retained presentation of retail's gmSpewBoxUI (research doc /// §1.1/§7.3/§7.4) — the transient top-of-viewport interface-text queue. -/// Modeled directly on : a single -/// ClickThrough block at a high -/// . Unlike that controller's single -/// overwrite-only slot, this reads 's bounded, -/// newest-on-top, per-entry-expiring queue every frame. +/// A single ClickThrough block at a high +/// . Campaign CH user-gate round 2, item 2: +/// this is now the ONLY on-screen interface-text presentation surface — +/// the former PortalWaitNoticeController (a dedicated centered +/// overlay with a single overwrite-only slot) is deleted; the portal-space +/// wait notice routes here too, through the same +/// RuntimeCommunicationState.AddText chokepoint every other +/// ClientLocal refusal uses. This reads 's +/// bounded, newest-on-top, per-entry-expiring queue every frame. /// /// /// CH2 REJECT-review rework, BLOCKER 1 diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 8cee4874..7af14853 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1434,6 +1434,19 @@ public static class PhysicsDiagnostics public static bool ProbeSupportEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1"; + /// + /// Initial state from ACDREAM_PROBE_JUMP=1. Campaign CH user-gate + /// round 2, item 1 (jump-in-air refusal reported STILL silent live after + /// round 1's press-edge branch landed with a passing unit test). Enables + /// the [jump] line in PlayerMovementController.ReportJumpRefusal + /// — printed UNCONDITIONALLY, even when OnInterfaceText is null, so + /// the probe can distinguish "the branch never evaluated true" from "the + /// branch fired but the callback dropped it." TEMPORARY — strip once the + /// live mechanism is confirmed and fixed. + /// + public static bool ProbeJumpEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_JUMP") == "1"; + /// Vertical agreement window, in metres, inside which the contact /// plane's height and the terrain's height at the same XY are called the /// same surface. diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs index a38ae1ad..53483d48 100644 --- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -20,6 +20,7 @@ internal static class HeadlessBotPolicyFactory "lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(), "observer-movement" => new ObserverMovementHeadlessBotPolicy(), "portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(), + "jump-probe" => new JumpProbeHeadlessBotPolicy(), _ => throw new HeadlessConfigurationException( $"Unknown headless bot policy '{id}'."), }; @@ -573,3 +574,167 @@ internal sealed class PortalRouteSmokeHeadlessBotPolicy ForwardTwo, } } + +/// +/// Campaign CH user-gate round 2, item 1 (TEMPORARY, evidence-gathering +/// only — not a shipped bot behavior). Round 1 added a press-edge branch in +/// PlayerMovementController.Update +/// (input.Jump && !_prevJumpHeld && !_body.OnWalkable +/// → ReportJumpRefusal(NotGrounded)) with a passing unit test, but the +/// user reported it STILL silent live. This policy drives the SAME typed +/// commands.Movement.SetIntent surface a headless bot uses (the +/// RuntimeLocalPlayerMovementState.HasCommandInput route — distinct +/// from the graphical dispatcher's held-key polling path) to charge and fire +/// a jump, wait for to go +/// true under REAL physics against a live ACE connection, then press jump +/// again mid-air. Pair with ACDREAM_PROBE_JUMP=1 (prints +/// [jump]/[jump-tick] lines from PlayerMovementController +/// itself) to see whether OnWalkable actually clears while genuinely +/// airborne and whether ReportJumpRefusal is reached. +/// +internal sealed class JumpProbeHeadlessBotPolicy : IHeadlessBotPolicy +{ + private enum Stage + { + WaitForPlayer, + Charging, + WaitReleaseAirborne, + WaitMidAirPress, + Done, + } + + private Stage _stage = Stage.WaitForPlayer; + private double _stageDeadline; + private bool _wasAirborne; + + public bool IsComplete => _stage == Stage.Done; + + public void Tick(IGameRuntimeView view, IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + + RuntimeMovementSnapshot movement = view.Movement.Snapshot; + if (movement.IsAirborne != _wasAirborne) + { + Console.WriteLine( + $"[jump-probe] airborne-transition {_wasAirborne} -> " + + $"{movement.IsAirborne} stage={_stage} " + + $"simTime={view.Clock.SimulationTimeSeconds:F3} " + + $"hasCommandInput={movement.HasCommandInput} " + + $"commandInput.Jump={movement.CommandInput.Jump}"); + _wasAirborne = movement.IsAirborne; + } + + switch (_stage) + { + case Stage.WaitForPlayer: + if (!HasLocalPlayer(view)) + return; + Console.WriteLine("[jump-probe] local player present; charging jump"); + Require(commands.Movement.SetIntent( + view.Generation, + new MovementInput(Jump: true))); + _stageDeadline = view.Clock.SimulationTimeSeconds + 0.6; + _stage = Stage.Charging; + break; + + case Stage.Charging: + if (view.Clock.SimulationTimeSeconds < _stageDeadline) + return; + Console.WriteLine("[jump-probe] releasing jump (fire)"); + Require(commands.Movement.SetIntent( + view.Generation, + new MovementInput(Jump: false))); + _stageDeadline = view.Clock.SimulationTimeSeconds + 3.0; + _stage = Stage.WaitReleaseAirborne; + break; + + case Stage.WaitReleaseAirborne: + if (movement.IsAirborne) + { + Console.WriteLine( + "[jump-probe] airborne confirmed; pressing jump mid-air " + + $"simTime={view.Clock.SimulationTimeSeconds:F3}"); + Require(commands.Movement.SetIntent( + view.Generation, + new MovementInput(Jump: true))); + _stageDeadline = view.Clock.SimulationTimeSeconds + 1.0; + _stage = Stage.WaitMidAirPress; + return; + } + if (view.Clock.SimulationTimeSeconds >= _stageDeadline) + { + Console.WriteLine( + "[jump-probe] TIMEOUT waiting for airborne after jump " + + "fire -- the released jump never registered as " + + "airborne."); + Require(commands.Movement.ClearIntent(view.Generation)); + _stage = Stage.Done; + } + return; + + case Stage.WaitMidAirPress: + if (view.Clock.SimulationTimeSeconds < _stageDeadline) + return; + Console.WriteLine( + "[jump-probe] probe complete; final airborne=" + + $"{movement.IsAirborne}"); + Require(commands.Movement.ClearIntent(view.Generation)); + _stage = Stage.Done; + return; + } + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + public void Dispose() + { + } + + private static bool HasLocalPlayer(IGameRuntimeView view) => + view.Lifecycle.State is RuntimeLifecycleState.InWorld + && view.Lifecycle.PlayerGuid != 0u + && view.Entities.TryGet( + view.Lifecycle.PlayerGuid, + out _); + + private static void Require(in RuntimeCommandResult result) + { + if (!result.Accepted) + { + throw new InvalidOperationException( + $"Jump probe command was rejected with {result.Status}."); + } + } +} diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index b4eb9e86..b18ab208 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -967,6 +967,20 @@ public sealed class PlayerMovementController /// private void ReportJumpRefusal(WeenieError result) { + // Campaign CH user-gate round 2, item 1 (TEMPORARY): print + // UNCONDITIONALLY, before the OnInterfaceText null-check, so the + // probe distinguishes "ReportJumpRefusal was never called with a + // reportable result" from "it was called but OnInterfaceText was + // null" from "the callback fired but downstream dropped the line." + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeJumpEnabled) + { + Console.WriteLine( + $"[jump] ReportJumpRefusal result={result} " + + $"hasCallback={OnInterfaceText is not null} " + + $"onWalkable={_body.OnWalkable} " + + $"prevJumpHeld={_prevJumpHeld}"); + } + if (OnInterfaceText is null) return; @@ -2595,6 +2609,19 @@ public sealed class PlayerMovementController // in-air raises exactly one report, not one per frame. ReportJumpRefusal(WeenieError.NotGrounded); } + + // Campaign CH user-gate round 2, item 1 (TEMPORARY): per-tick trace + // bracketing every frame where jump is (or was) held, so the probe + // can see the OnWalkable transition around a live jump attempt + // without spamming every ordinary frame. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeJumpEnabled + && (input.Jump || _prevJumpHeld)) + { + Console.WriteLine( + $"[jump-tick] input.Jump={input.Jump} prevJumpHeld={_prevJumpHeld} " + + $"onWalkable={_body.OnWalkable} jumpCharging={_jumpCharging} " + + $"jumpExtent={_jumpExtent:F2}"); + } _prevJumpHeld = input.Jump; // ── 2. Run admitted complete-object quanta ──────────────────────────── diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs index 8a9cf74e..fb8863dc 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailCommandHelpTable.cs @@ -22,10 +22,9 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// docs/research/named-retail/acclient_2013_pseudo_c.txt by the /// recipe in the command-registry doc §5 (scan each Help* /// function's byte extent for push imm32 into .rdata). Entries -/// that could not be recovered this way (the 7 group-index headers' -/// summary text, @day's confirmation lines, @render's option list) are -/// NOT fabricated — they are simply absent from this table; the lookup -/// falls through to a generic "no detailed help" line rather than guess. +/// that could not be recovered this way (@render's option list) are NOT +/// fabricated — they are simply absent from this table; the lookup falls +/// through to a generic "no detailed help" line rather than guess. /// /// /// @@ -36,9 +35,28 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// one-liners in ("Sends text to your Fellowship /// channel.", etc.) are acdream-authored SUMMARIES, not individually /// hand-extracted retail strings — retail's own per-channel help text was -/// not recovered this slice. Recovering them (or deleting the class-doc -/// overclaim) is future work; this comment now says so honestly instead -/// of leaving the contradiction standing. +/// not recovered this slice. +/// +/// +/// +/// Campaign CH user-gate round 2, item 3 (2026-08-09): the user +/// caught that /help death printed an acdream META-MESSAGE ("This is +/// a retail help-topic group; acdream has not yet extracted its exact +/// retail listing text…") instead of retail's real listing — this was the +/// class-doc overclaim's SHARP end. All 7 HelpXxxGroup nodes are now +/// resolved: , , +/// , and +/// are COMPLETE verbatim listings; , +/// , and +/// are PARTIAL (their own summary line, plus — for chatting — 7 of 8 +/// entries) with an explicit UNVERIFIED note citing +/// ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290 for +/// the un-decodable remainder, never a guess. 7 of the ~35 channel +/// one-liners (a/guild/gu, general/cg, trade/ct, lfg/clfg, roleplay/crp, +/// society/soc, olthoi/o) are now verbatim too; the rest (fellowship/ +/// monarch/patron/vassals/covassal family) route through the same +/// unresolved mechanism and remain acdream summaries. See the remarks on +/// for the full extraction method. /// /// public static class RetailCommandHelpTable @@ -154,11 +172,138 @@ public static class RetailCommandHelpTable // The 7 retail-registered "group index" nodes — retail registers them // with a NULL func (like @mr/@pr); typing one bare reaches the server. - // Only @help shows anything, and only this generic pointer — - // the exact per-group summary/listing text (retail's HelpXxxGroup - // functions) was not extracted this slice; see ISSUES.md. - private const string GroupNodeNotExtracted = - "This is a retail help-topic group; acdream has not yet extracted its exact retail listing text. Typing this verb alone (without @help) reaches the server as literal text, matching retail's null-handler registration."; + // Only @help shows anything. + // + // Campaign CH user-gate round 2, item 3 (2026-08-09): the user caught + // that "/help death" printed an acdream META-MESSAGE instead of + // retail's real listing. Extracted verbatim via + // tools/pdb-extract/sweep_weenie_strings.py (generalized this round for + // narrow PStringBase literals — the Help* family uses 8-bit ASCII, + // not the UTF-16LE ECM_UI notice shape the script originally targeted) + // against the PDB-paired C:\Users\erikn\Downloads\acclient.exe. Method: + // each HelpXxxGroup function (ClientCommunicationSystem::HelpXxxGroup, + // decomp §5) is a single `if (arg2 != Summary_HelpType) { } else { }`; the exact byte extent (this + // function's start VA to the next function's start VA) was read from + // the pseudo-C's own function-header addresses, and every extent was + // swept with --ascii-only. Four groups decode COMPLETELY (every + // subcommand summary is either an inline literal in the group function + // itself or a direct `HelpXxx(this, Summary_HelpType, "@tag", arg4)` + // call whose OWN summary text was independently swept from HelpXxx's + // own extent): death, status, text, allegiances. Three groups + // (commands, channels, chatting) delegate part or all of their detail + // text to ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290, + // which builds its output from THREE string fragments Binary Ninja + // mis-attributes to unrelated vtable slots + // (`ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` + // etc. — the classic BN pooled/mislabeled-data artifact, not real + // vtable dispatch) concatenated around a live channel-name lookup + // (`ChannelSystem::GetChannelName`) — genuinely not decodable via this + // sweep with confidence. Those three keep an honest UNVERIFIED fallback + // (still real retail text where available — each group's own one-line + // summary IS fully decoded and included) rather than inventing the + // missing portion. Tracked as ISSUES.md #364. + private const string ChannelsGroupSummary = + "@help channels - How to communicate with people in your allegiance or fellowship."; + + // acclient_2013_pseudo_c.txt:391002 (0x0057b1b0). HelpChattingGroup's + // own one-liner — not used standalone below (its Detail branch does + // NOT prepend it, matching every other group), kept for documentation + // parity with the other two Summary consts above/below. + private const string ChattingGroupSummaryVerbatim = + "@help chatting - How to chat publically and privately."; + + private const string CommandsGroupSummary = + "@help commands - Lists all commands."; + + private const string GroupDetailUnverifiedSuffix = + " UNVERIFIED: the rest of this group's retail listing routes through ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290, which builds its text from BN-mislabeled data fragments around a live channel-name lookup — not decodable with confidence from a static string sweep. See docs/research/2026-08-09-chat-retail-command-registry.md §5 and ISSUES.md #364."; + + // acclient_2013_pseudo_c.txt:391365-391436 (HelpDeathGroup, decomp + // §5 sweep, addresses 0x57b913-0x57ba56). The Detail branch is a + // straight-line concatenation with NO leading group-summary line (that + // string is exclusive to the Summary_HelpType branch, i.e. what a + // PARENT listing shows about "death" as one entry — @help death itself + // never shows it). + public const string DeathGroupDetail = + "@permit - Commands to give or revoke permission for others to loot your corpse.\n" + + "@consent - Commands to help you manage the corpse-looting permissions that others give you.\n" + + "@corpse - Displays the location of your last outdoor death.\n" + + "@die - Kills your character and leaves a corpse, returning you to your lifestone.\n" + + "@lifestone - Returns you to the last lifestone you used without killing you.\n" + + "@marketplace - Teleports you to the Marketplace of Dereth.\n" + + "@pkarena - Teleports you to the PK Arena. You must be PK to use this command.\n" + + "@pklarena - Teleports you to the PKL Arena. You must be PKL to use this command.\n"; + + // acclient_2013_pseudo_c.txt:392030-392093 (HelpStatusGroup). Same + // shape as HelpDeathGroup — Detail branch, no leading group-summary + // line. The trailing space before "\n" on the @day line is retail's + // own byte content (data_0x7de280), not an acdream typo. + public const string StatusGroupDetail = + "@age - Displays your total gameplay time.\n" + + "@birth - Displays when your character was created.\n" + + "@day - A toggle that lightens the outdoor landscape. Note that this command may take several seconds to take effect. \n" + + "@endurance - Explains how endurance affects your character.\n" + + "@framerate - Toggles the framerate display.\n" + + "@loc - Displays your current position.\n" + + "@pklite - Sets your status to Player Killer Lite. Type @help pklite for more details.\n" + + "@version - Tells you what version of the software you are using.\n"; + + // acclient_2013_pseudo_c.txt:392209-392279 (HelpTextGroup). + public const string TextGroupDetail = + "@clear - Clears the chat box of all text.\n" + + "@filter - Commands to filter out incoming messages.\n" + + "@unfilter - Commands to remove filters from incoming messages.\n" + + "@loadfile - Reads in the given text file and executes each line in the chat entry field.\n" + + "@log - Commands to echo chat text to a logfile.\n" + + "@title - Sets the title of the popup chat window.\n"; + + // acclient_2013_pseudo_c.txt:393033-393059 (HelpAllegiancesGroup) — + // the shortest group, only 2 lines. + public const string AllegiancesGroupDetail = + "@allegiance - Commands to help manage your allegiance.\n" + + "@allegiance motd - Displays or sets the message of the day for your allegiance, see @help motd for more information.\n"; + + // Partial: own summary (fully verbatim, swept) + the honest UNVERIFIED + // note for the HelpStupidChannelHack-delegated remainder. HelpChannelsGroup's + // Detail branch (acclient_2013_pseudo_c.txt:387166-387194) is ENTIRELY + // 6 HelpStupidChannelHack calls -- none of its own text is decodable. + public const string ChannelsGroupDetail = + ChannelsGroupSummary + GroupDetailUnverifiedSuffix; + + // Partial: HelpChattingGroup's Detail branch + // (acclient_2013_pseudo_c.txt:390909-390984) resolves 7 of 8 entries + // verbatim (chat/notell/reply/retell/say/tell/afk, each an inline + // literal or a direct HelpXxx(Summary_HelpType,...) call whose own text + // was independently swept) -- only the 5 HelpStupidChannelHack calls in + // the middle (channel-alias entries) are unresolved. + public const string ChattingGroupDetail = + "@chat - Sets whether or not you receive normal chat.\n" + + "@notell - Sets whether or not you receive @tell's.\n" + + "@reply - Sends some text to the last person who @tell'd you.\n" + + "@retell - Sends some text to the last person you @tell'd.\n" + + "@say - Says some text to everyone around you.\n" + + "@tell - Sends a private message to another character.\n" + + "[5 more retail channel-alias one-liners not yet extracted verbatim -- ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290, UNVERIFIED; see the class remarks above.]\n" + + "@afk - Set your away-from-keyboard status.\n"; + + // HelpAllGroup's Detail branch (acclient_2013_pseudo_c.txt:394006-394176) + // recursively calls HelpAllegiancesGroup AND HelpChattingGroup in Detail + // mode (not Summary), plus the same 5 HelpStupidChannelHack calls + // directly, plus HelpDeathGroup in Detail mode, plus several more direct + // entries, plus a CONFIRMED retail duplicate (the saveui/loadui pair + // appears twice in the straight-line decompiled body with no + // conditional between the two occurrences -- verified at + // acclient_2013_pseudo_c.txt:394089-394137, not an acdream artifact). + // The nesting compounds two independent HelpStupidChannelHack gaps + // (its own 5 calls + HelpChattingGroup's 5) with enough structural + // uncertainty (unconfirmed HelpType arg on the trailing @status/@text + // calls) that a full reconstruction risks presenting an inaccurate + // listing as verbatim. Kept UNVERIFIED with its own summary (fully + // decoded) rather than risk that. + public const string CommandsGroupDetail = + CommandsGroupSummary + GroupDetailUnverifiedSuffix; private static readonly FrozenDictionary ByVerb = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -181,16 +326,24 @@ public static class RetailCommandHelpTable ["log"] = Log, ["render"] = Render, ["motd"] = Motd, - ["commands"] = GroupNodeNotExtracted, - ["allegiances"] = GroupNodeNotExtracted, - ["channels"] = GroupNodeNotExtracted, - ["chatting"] = GroupNodeNotExtracted, - ["death"] = GroupNodeNotExtracted, - ["status"] = GroupNodeNotExtracted, - ["text"] = GroupNodeNotExtracted, - // Channel verbs — one line each, generated rather than - // hand-extracted (retail's per-channel help strings were not - // individually recovered this slice). + ["commands"] = CommandsGroupDetail, + ["allegiances"] = AllegiancesGroupDetail, + ["channels"] = ChannelsGroupDetail, + ["chatting"] = ChattingGroupDetail, + ["death"] = DeathGroupDetail, + ["status"] = StatusGroupDetail, + ["text"] = TextGroupDetail, + // Channel verbs. Campaign CH user-gate round 2, item 3: 7 of + // these are now VERBATIM retail text (each its own + // HelpTurbineChat_Xxx function, decomp §5 sweep, + // acclient_2013_pseudo_c.txt:387294-387411/387410 area, + // addresses 0x577620-0x577850) — the "Also: @alias" suffix is + // retail's own text, not an acdream addition. The remaining + // ones (f/fellow(s)/fellowship/g/group/party, m/monarch, + // p/patron, v/vassal(s), c/covassal(s)/co-vassals) still route + // through the unresolved HelpStupidChannelHack pooled-string + // mechanism (see the class remarks above) and stay + // acdream-authored summaries — an honest gap, not a silent one. ["f"] = "Sends text to your Fellowship channel.", ["fellow"] = "Sends text to your Fellowship channel.", ["fellows"] = "Sends text to your Fellowship channel.", @@ -198,22 +351,22 @@ public static class RetailCommandHelpTable ["g"] = "Sends text to your Fellowship channel.", ["group"] = "Sends text to your Fellowship channel.", ["party"] = "Sends text to your Fellowship channel.", - ["a"] = "Sends text to your Allegiance chat room.", - ["guild"] = "Sends text to your Allegiance chat room.", - ["gu"] = "Sends text to your Allegiance chat room.", + ["a"] = "@a - Sends a message to your Allegiance. Also: @guild, @gu", + ["guild"] = "@a - Sends a message to your Allegiance. Also: @guild, @gu", + ["gu"] = "@a - Sends a message to your Allegiance. Also: @guild, @gu", ["ab"] = "Broadcasts text to your entire allegiance (monarch/speaker permission). Also @allegiance broadcast.", - ["general"] = "Sends text to the General chat room.", - ["cg"] = "Sends text to the General chat room.", - ["trade"] = "Sends text to the Trade chat room.", - ["ct"] = "Sends text to the Trade chat room.", - ["lfg"] = "Sends text to the Looking-For-Group chat room.", - ["clfg"] = "Sends text to the Looking-For-Group chat room.", - ["roleplay"] = "Sends text to the Roleplay chat room.", - ["crp"] = "Sends text to the Roleplay chat room.", - ["society"] = "Sends text to your Society chat room.", - ["soc"] = "Sends text to your Society chat room.", - ["olthoi"] = "Sends text to the Olthoi Player Killer chat room.", - ["o"] = "Sends text to the Olthoi Player Killer chat room.", + ["general"] = "@general - Sends a message to the global General chat channel. Also: @cg", + ["cg"] = "@general - Sends a message to the global General chat channel. Also: @cg", + ["trade"] = "@trade - Sends a message to the global Trade chat channel. Also: @ct", + ["ct"] = "@trade - Sends a message to the global Trade chat channel. Also: @ct", + ["lfg"] = "@lfg - Sends a message to the global Looking For Group (LFG) chat channel. Also: @clfg", + ["clfg"] = "@lfg - Sends a message to the global Looking For Group (LFG) chat channel. Also: @clfg", + ["roleplay"] = "@roleplay - Sends a message to the global Roleplay chat channel. Also: @crp", + ["crp"] = "@roleplay - Sends a message to the global Roleplay chat channel. Also: @crp", + ["society"] = "@society - Sends a message to the your Society chat channel. Also: @soc", + ["soc"] = "@society - Sends a message to the your Society chat channel. Also: @soc", + ["olthoi"] = "@olthoi - If you are an Olthoi, sends a message to the global Olthoi chat channel. Also: @o", + ["o"] = "@olthoi - If you are an Olthoi, sends a message to the global Olthoi chat channel. Also: @o", ["m"] = "Sends text to your Monarch.", ["monarch"] = "Sends text to your Monarch.", ["p"] = "Sends text to your Patron.", diff --git a/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs b/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs deleted file mode 100644 index 94f3de7a..00000000 --- a/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -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() - { - var root = new UiRoot - { - Width = 1280f, - Height = 720f, - }; - - using (var controller = new PortalWaitNoticeController(root)) - { - UiText text = Assert.IsType(Assert.Single(root.Children)); - Assert.False(text.Visible); - Assert.True(text.Centered); - 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); - } - - Assert.Empty(root.Children); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs new file mode 100644 index 00000000..ef24493a --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs @@ -0,0 +1,168 @@ +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Campaign CH user-gate round 2, item 3 (2026-08-09): pins a sample of +/// 's extracted strings byte-exact +/// against the values recovered from the PDB-paired +/// C:\Users\erikn\Downloads\acclient.exe via +/// tools/pdb-extract/sweep_weenie_strings.py --ascii-only (generalized +/// this round for narrow PStringBase<char> literals — see the +/// script's own docstring). Covers all four COMPLETE group listings +/// (death/status/text/allegiances — /help death was the user's own +/// example of the fabricated meta-message this round replaces), the three +/// PARTIAL groups' honest UNVERIFIED fallback, and a sample of the newly +/// verbatim channel one-liners. +/// +public sealed class RetailCommandHelpTableTests +{ + [Fact] + public void DeathGroup_IsCompleteVerbatimListing_NoLeadingSummaryLine() + { + // acclient_2013_pseudo_c.txt:391365-391436 (HelpDeathGroup Detail + // branch) -- straight-line concatenation, no "@help death - ..." + // prefix (that string is exclusive to the Summary_HelpType branch). + Assert.Equal( + "@permit - Commands to give or revoke permission for others to loot your corpse.\n" + + "@consent - Commands to help you manage the corpse-looting permissions that others give you.\n" + + "@corpse - Displays the location of your last outdoor death.\n" + + "@die - Kills your character and leaves a corpse, returning you to your lifestone.\n" + + "@lifestone - Returns you to the last lifestone you used without killing you.\n" + + "@marketplace - Teleports you to the Marketplace of Dereth.\n" + + "@pkarena - Teleports you to the PK Arena. You must be PK to use this command.\n" + + "@pklarena - Teleports you to the PKL Arena. You must be PKL to use this command.\n", + RetailCommandHelpTable.DeathGroupDetail); + + Assert.True(RetailCommandHelpTable.TryGetHelpText("death", out string viaLookup)); + Assert.Equal(RetailCommandHelpTable.DeathGroupDetail, viaLookup); + Assert.DoesNotContain("has not yet extracted", viaLookup); + } + + [Fact] + public void StatusGroup_IsCompleteVerbatimListing_PreservesRetailTrailingSpace() + { + // The trailing space before "\n" on the @day line is retail's own + // byte content (data_0x7de280) -- not an acdream typo. + Assert.Equal( + "@age - Displays your total gameplay time.\n" + + "@birth - Displays when your character was created.\n" + + "@day - A toggle that lightens the outdoor landscape. Note that this command may take several seconds to take effect. \n" + + "@endurance - Explains how endurance affects your character.\n" + + "@framerate - Toggles the framerate display.\n" + + "@loc - Displays your current position.\n" + + "@pklite - Sets your status to Player Killer Lite. Type @help pklite for more details.\n" + + "@version - Tells you what version of the software you are using.\n", + RetailCommandHelpTable.StatusGroupDetail); + } + + [Fact] + public void TextGroup_IsCompleteVerbatimListing() + { + Assert.Equal( + "@clear - Clears the chat box of all text.\n" + + "@filter - Commands to filter out incoming messages.\n" + + "@unfilter - Commands to remove filters from incoming messages.\n" + + "@loadfile - Reads in the given text file and executes each line in the chat entry field.\n" + + "@log - Commands to echo chat text to a logfile.\n" + + "@title - Sets the title of the popup chat window.\n", + RetailCommandHelpTable.TextGroupDetail); + } + + [Fact] + public void AllegiancesGroup_IsCompleteVerbatimListing_TwoLines() + { + Assert.Equal( + "@allegiance - Commands to help manage your allegiance.\n" + + "@allegiance motd - Displays or sets the message of the day for your allegiance, see @help motd for more information.\n", + RetailCommandHelpTable.AllegiancesGroupDetail); + } + + [Theory] + [InlineData("channels")] + [InlineData("commands")] + public void UnresolvedGroups_ContainVerbatimSummaryPlusHonestUnverifiedNote_NoMetaFabrication( + string verb) + { + Assert.True(RetailCommandHelpTable.TryGetHelpText(verb, out string text)); + // The old sentinel this round retired -- must never come back. + Assert.DoesNotContain("has not yet extracted", text); + Assert.Contains("UNVERIFIED", text); + Assert.Contains("HelpStupidChannelHack @0x0056f290", text); + } + + [Fact] + public void ChannelsGroup_SummaryLineIsVerbatim() + { + Assert.True(RetailCommandHelpTable.TryGetHelpText("channels", out string text)); + Assert.StartsWith( + "@help channels - How to communicate with people in your allegiance or fellowship.", + text); + } + + [Fact] + public void ChattingGroup_SevenOfEightEntriesAreVerbatim_FifthEntryMarkedUnverified() + { + Assert.True(RetailCommandHelpTable.TryGetHelpText("chatting", out string text)); + Assert.Contains( + "@chat - Sets whether or not you receive normal chat.\n", + text); + Assert.Contains( + "@notell - Sets whether or not you receive @tell's.\n", + text); + Assert.Contains( + "@reply - Sends some text to the last person who @tell'd you.\n", + text); + Assert.Contains( + "@retell - Sends some text to the last person you @tell'd.\n", + text); + Assert.Contains( + "@say - Says some text to everyone around you.\n", + text); + Assert.Contains( + "@tell - Sends a private message to another character.\n", + text); + Assert.Contains( + "@afk - Set your away-from-keyboard status.\n", + text); + Assert.Contains("HelpStupidChannelHack @0x0056f290", text); + } + + // Campaign CH user-gate round 2, item 3: 7 of the ~35 channel + // one-liners are now verbatim retail text (HelpTurbineChat_Xxx, + // acclient_2013_pseudo_c.txt:387294-387411, addresses + // 0x577620-0x577850) -- the "Also: @alias" suffix is retail's own text. + [Theory] + [InlineData("a", "@a - Sends a message to your Allegiance. Also: @guild, @gu")] + [InlineData("guild", "@a - Sends a message to your Allegiance. Also: @guild, @gu")] + [InlineData("general", "@general - Sends a message to the global General chat channel. Also: @cg")] + [InlineData("trade", "@trade - Sends a message to the global Trade chat channel. Also: @ct")] + [InlineData("lfg", "@lfg - Sends a message to the global Looking For Group (LFG) chat channel. Also: @clfg")] + [InlineData("roleplay", "@roleplay - Sends a message to the global Roleplay chat channel. Also: @crp")] + [InlineData("society", "@society - Sends a message to the your Society chat channel. Also: @soc")] + [InlineData("olthoi", "@olthoi - If you are an Olthoi, sends a message to the global Olthoi chat channel. Also: @o")] + public void ChannelVerb_VerbatimOneLiner_MatchesExactly(string verb, string expected) + { + Assert.True(RetailCommandHelpTable.TryGetHelpText(verb, out string text)); + Assert.Equal(expected, text); + } + + // The fellowship/monarch/patron/vassals/covassal family still routes + // through the unresolved HelpStupidChannelHack pooled-string mechanism + // (the "fvpca" BN artifact) and remains an honest acdream summary, not + // a claimed-verbatim string. Pinned so a future accidental "upgrade" to + // a fabricated retail-looking string doesn't slip in unnoticed. + [Theory] + [InlineData("f", "Sends text to your Fellowship channel.")] + [InlineData("monarch", "Sends text to your Monarch.")] + [InlineData("patron", "Sends text to your Patron.")] + [InlineData("vassal", "Sends text to your Vassals.")] + [InlineData("covassal", "Sends text to your Co-vassals.")] + public void ChannelVerb_StillAcdreamSummary_UnresolvedChannelHackFamily( + string verb, string expected) + { + Assert.True(RetailCommandHelpTable.TryGetHelpText(verb, out string text)); + Assert.Equal(expected, text); + } +} diff --git a/tools/pdb-extract/sweep_weenie_strings.py b/tools/pdb-extract/sweep_weenie_strings.py index b75bb0a6..3e53be4f 100644 --- a/tools/pdb-extract/sweep_weenie_strings.py +++ b/tools/pdb-extract/sweep_weenie_strings.py @@ -1,6 +1,8 @@ """Sweep `push imm32` (0x68) operands inside a VA range of a PE binary, dereference each into a data section (.rdata/.data), and decode any that -resolve to a printable UTF-16LE literal. +resolve to a printable string literal -- UTF-16LE (PStringBase, e.g. ECM_UI notice text) or narrow ASCII (PStringBase, e.g. +the ClientCommunicationSystem::Help* command-help family) alike. Built for the CH2 REJECT-review rework (BLOCKER 2, docs/research/2026-08-09-ch2-review-findings.md) to re-derive @@ -8,9 +10,22 @@ ClientCommunicationSystem::HandleFailureEvent (@0x00571990)'s 344-row display-string table from ground truth instead of the Binary Ninja pseudo-C's ~33-char inline preview -- the same class of problem check_exe_pdb.py and dump_pdb_info.py solve for PDB metadata, applied to -wide-string literal recovery. Not tied to WeenieError specifically: any VA +string literal recovery. Not tied to WeenieError specifically: any VA range in any PDB-paired PE binary works. +Generalized for Campaign CH user-gate round 2, item 3 (2026-08-09): the +retail help command family (ClientCommunicationSystem::Help*, e.g. +HelpAllGroup/HelpAllegiancesGroup/HelpChannelsGroup/HelpChattingGroup/ +HelpDeathGroup/HelpStatusGroup/HelpTextGroup) constructs its strings via +PStringBase (narrow 8-bit ASCII), NOT PStringBase +(wide UTF-16LE) like the WeenieError table or ECM_UI notices. Both +encodings now share one sweep: each push-imm32 hit is decoded as UTF-16LE +first (a real wide string reads back false as ASCII almost immediately -- +every other byte is 0x00, which read_ascii_cstr rejects as a control +character), then as ASCII if that fails. The reported tuple carries which +encoding matched so callers can tell narrow help text apart from wide +notice text at a glance. + ALWAYS run check_exe_pdb.py first to confirm the candidate .exe pairs with the PDB you're cross-referencing addresses against -- a mismatched binary will produce confident-looking garbage. @@ -30,6 +45,8 @@ Usage: directly encodes its own VA in hex) --min-len N minimum decoded string length to report (default 3); raise this to cut noise from short accidental hits +--ascii-only skip the UTF-16LE attempt entirely (narrow-string ranges + run faster and cannot false-positive against wide data) """ import argparse import struct @@ -109,14 +126,44 @@ class PeImage: out.append(chr(code)) return None # ran off the end without a NUL -- not a bounded literal + def read_ascii_cstr(self, va, max_chars=800): + """Decode a narrow (8-bit) NUL-terminated C string -- the + PStringBase literal shape the Help* command family uses, + distinct from read_utf16_cstr's PStringBase + shape.""" + off = self.va_to_off(va) + if off is None: + return None + out = [] + for i in range(max_chars): + chunk = self.data[off + i: off + i + 1] + if len(chunk) < 1: + break + code = chunk[0] + if code == 0: + return "".join(out) + # Same control-char allowance as read_utf16_cstr (\n, \t only); + # anything else (including high bytes outside printable ASCII) + # means this isn't a real narrow literal. + if code < 0x20 and code not in (0x0A, 0x09): + return None + if code > 0x7E: + return None + out.append(chr(code)) + return None # ran off the end without a NUL -- not a bounded literal + def is_data_section(self, va): s = self.section_for_va(va) return s is not None and s["name"] in (".rdata", ".data") - def sweep_push_imm32(self, lo, hi, min_len=3): + def sweep_push_imm32(self, lo, hi, min_len=3, ascii_only=False): """Scan [lo, hi) for `push imm32` (opcode 0x68) whose operand VA - dereferences to a UTF-16LE string in .rdata/.data. Returns a list - of (instr_va, target_va, text).""" + dereferences to a printable string literal in .rdata/.data -- + UTF-16LE tried first (unless ascii_only), narrow ASCII as the + fallback (a real wide string's alternating 0x00 bytes make the + ASCII decode reject it as a control character almost immediately, + so the two encodings do not cross-contaminate each other's hits). + Returns a list of (instr_va, target_va, text, encoding).""" hits = [] off_lo = self.va_to_off(lo) off_hi = self.va_to_off(hi) @@ -127,19 +174,27 @@ class PeImage: if self.data[i] == 0x68: operand = struct.unpack_from("= min_len: instr_va = lo + (i - off_lo) - hits.append((instr_va, operand, text)) + hits.append((instr_va, operand, text, encoding)) i += 1 return hits - def find_push_before(self, anchor_va, window=64, min_len=3): + def find_push_before(self, anchor_va, window=64, min_len=3, ascii_only=False): """Search backward from anchor_va (a call-site VA taken from the pseudo-C) for the nearest preceding `push imm32` whose operand - dereferences to a printable UTF-16LE string.""" + dereferences to a printable string literal.""" lo = anchor_va - window - return self.sweep_push_imm32(lo, anchor_va + 2, min_len=min_len) + return self.sweep_push_imm32( + lo, anchor_va + 2, min_len=min_len, ascii_only=ascii_only) def main(): @@ -150,6 +205,7 @@ def main(): ap.add_argument("--window", type=int, default=64) ap.add_argument("--deref", action="append", default=[]) ap.add_argument("--min-len", type=int, default=3) + ap.add_argument("--ascii-only", action="store_true") args = ap.parse_args() pe = PeImage(args.exe) @@ -162,22 +218,30 @@ def main(): if args.range: lo = int(args.range[0], 16) hi = int(args.range[1], 16) - hits = pe.sweep_push_imm32(lo, hi, min_len=args.min_len) + hits = pe.sweep_push_imm32( + lo, hi, min_len=args.min_len, ascii_only=args.ascii_only) print(f"# sweep 0x{lo:08x}-0x{hi:08x}: {len(hits)} string-valued push imm32 sites") - for instr_va, target_va, text in hits: - print(f"0x{instr_va:08x} -> data_0x{target_va:08x} {text!r}") + for instr_va, target_va, text, encoding in hits: + print(f"0x{instr_va:08x} -> data_0x{target_va:08x} [{encoding}] {text!r}") for a in args.anchor: anchor = int(a, 16) - hits = pe.find_push_before(anchor, window=args.window, min_len=args.min_len) + hits = pe.find_push_before( + anchor, window=args.window, min_len=args.min_len, + ascii_only=args.ascii_only) print(f"\n# anchor 0x{anchor:08x} (window={args.window}): {len(hits)} hits") - for instr_va, target_va, text in hits: - print(f"0x{instr_va:08x} -> data_0x{target_va:08x} {text!r}") + for instr_va, target_va, text, encoding in hits: + print(f"0x{instr_va:08x} -> data_0x{target_va:08x} [{encoding}] {text!r}") for d in args.deref: target = int(d, 16) text = pe.read_utf16_cstr(target) - print(f"\n# deref 0x{target:08x}: {text!r}") + if text is None: + text = pe.read_ascii_cstr(target) + kind = "ascii" + else: + kind = "utf16" + print(f"\n# deref 0x{target:08x}: [{kind}] {text!r}") if __name__ == "__main__":