From 77c8296e3f2e5321c2672368432adda9cfab94cf Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 17:04:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20Campaign=20CH=20slice=20CH2=20?= =?UTF-8?q?=E2=80=94=20retail=20SpewBox=20interface=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail routes on-screen refusals ("You can't jump while in the air", "You are too encumbered to carry that!") through a SEPARATE transient screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50) that never touches the chat scroll — type 0x1A is exactly the bit every ChatInterface window's default filter excludes (ChatInterface::ChatInterface @0x004F4550). acdream had no such split: every WeenieError rendered in chat at a single stand-in LogTextType 0x00 (CH1-era approximation, register AP-176), and locally-detected jump refusals were silently discarded. This slice ports the full mechanism per docs/research/2026-08-09-chat-retail-interface-text.md: CORE (AcDream.Core/Chat): - WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent @0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately excluded — its case body is a tangled decompiler artifact, not resolvable with confidence). Spot-checked ~20 rows directly against the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the jump family), beyond the ~10 the brief asked for, because the first pass surfaced two transcription classes the research doc's markdown silently ate: (1) 7 ids marked "shared string global" resolved by reading the case bodies directly (0x24/0x48/0x49 reuse the jump- refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough); (2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and therefore their %s marker) the markdown table's cell-trimming ate — fixed by re-reading each case body, several requiring a SECOND non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to recover text the ~33-char inline preview cut off. One retail typo is preserved verbatim: 0x4F4's second placeholder is literal "$s", not "%s" — only the first substitutes. - ClientTextRefusals: the 11 process-lifetime string globals, all byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe (MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search — 5 were truncated in the research doc's own transcription and all 5 turned out to end "...combat mode"/"...this position", not the shorter "...combat" a truncated read would suggest. - SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0, dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry expiry, one-frame enqueue/drain decoupling). Placed in Core (not Runtime as the brief's default) because AcDream.UI.Abstractions references Core but not Runtime, and SpewBoxVM needs to wrap it directly — the same constraint ChatVM already satisfies against ChatLog. - Folded the 4-entry WeenieErrorText.cs into the full table; deleted it. RUNTIME (AcDream.Runtime): - RuntimeCommunicationState.AddText(text, type, windowId): the AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never chat; everything else -> the existing transcript, tagged with type. - GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net cannot reference Runtime, so this follows the file's own established pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/ UseDone through the full table + router; fixes 0x02EB CommunicationTransientString's routing type from a CH1-era 0x00 guess to retail's hardcoded ClientLocal (Handle_Communication__ TransientString @0x0057D460). - LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes through AddText with the wire chatType verbatim instead of always writing ChatLog directly. - PlayerMovementController gains OnInterfaceText, applied by RuntimeLocalPlayerMovementState to every controller it installs. Reports ChargeJump/jump refusals exactly as ClientCombatSystem:: CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via their compiled dispatch that ONLY 0x24/0x48/0x49 produce text; 0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any other code are retail-SILENT (DoJump's jump table has exactly 4 real targets), which contradicts this task's brief ("0x47 -> the constrained/stamina row per §4.2") — the brief's reading of §4.2 described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump DISPLAY for it. Implemented the decomp-verified silent behavior. APP (AcDream.App / AcDream.UI.Abstractions): - The 5 composition sites that already used RetailLogTextType.ClientLocal now call Communication.AddText instead of Chat.OnSystemMessage directly, so they reach the SpewBox instead of the transcript. - SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled directly on PortalWaitNoticeController. Position/font/colour/ MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic exhaustively swept the installed client_portal.dat's entire LayoutDesc id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked against 3 known ids) and found ZERO elements of class 0x10000016 — gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so the dump cannot recover these values. REGISTER: AP-176 retired (its WeenieError half is now the full table port); its OnCombatLine half was never in this slice's scope and is split out to AP-179 so that divergence keeps a row. AP-177 (invented line lifetime) and AP-178 (invented position/font/colour/max-items) filed for the presentation placeholders above. AP-175 (PopUpString -> chat instead of modal) is untouched, not duplicated. Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net new tests, 0 regressions). Co-Authored-By: Claude Opus 5 --- .../retail-divergence-register.md | 6 +- .../InteractionRetainedUiComposition.cs | 8 +- .../LivePresentationComposition.cs | 14 + .../Composition/SessionPlayerComposition.cs | 2 +- .../Net/LiveSessionRuntimeFactory.cs | 3 +- src/AcDream.App/UI/SpewBoxController.cs | 112 ++++ src/AcDream.Core.Net/GameEventWiring.cs | 92 ++- .../Messages/WeenieErrorText.cs | 23 - src/AcDream.Core/Chat/ClientTextRefusals.cs | 109 ++++ src/AcDream.Core/Chat/SpewBoxState.cs | 150 +++++ src/AcDream.Core/Chat/WeenieErrorMessages.cs | 590 +++++++++++++----- .../Hosting/HeadlessSessionHost.cs | 3 +- src/AcDream.Runtime/GameRuntime.cs | 8 + .../Gameplay/PlayerMovementController.cs | 64 +- .../Gameplay/RuntimeCommunicationState.cs | 69 ++ .../RuntimeLocalPlayerMovementState.cs | 23 + .../Session/LiveSessionEventRouter.cs | 22 +- .../Panels/SpewBox/SpewBoxVM.cs | 59 ++ .../UI/SpewBoxControllerTests.cs | 46 ++ .../UI/SpewBoxLayoutDumpDiagnostic.cs | 195 ++++++ .../GameEventWiringTests.cs | 156 +++++ .../Chat/SpewBoxStateTests.cs | 171 +++++ .../Chat/WeenieErrorMessagesTests.cs | 120 +++- .../Gameplay/PlayerMovementControllerTests.cs | 60 ++ .../RuntimeCommunicationStateTests.cs | 76 +++ .../RuntimeLocalPlayerMovementStateTests.cs | 51 ++ .../Session/LiveSessionEventRouterTests.cs | 57 ++ .../Panels/SpewBox/SpewBoxVMTests.cs | 79 +++ 28 files changed, 2144 insertions(+), 224 deletions(-) create mode 100644 src/AcDream.App/UI/SpewBoxController.cs delete mode 100644 src/AcDream.Core.Net/Messages/WeenieErrorText.cs create mode 100644 src/AcDream.Core/Chat/ClientTextRefusals.cs create mode 100644 src/AcDream.Core/Chat/SpewBoxState.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/SpewBox/SpewBoxVM.cs create mode 100644 tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs create mode 100644 tests/AcDream.Core.Tests/Chat/SpewBoxStateTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/SpewBox/SpewBoxVMTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 67e24ad2..ebaabd49 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) — 125 active rows (AP-176 filed 2026-08-09 at the CH1 Opus review — `ChatLog.OnWeenieError` and `OnCombatLine` type chat lines with a single stand-in `LogTextType` (`0x00`/`0x06`) instead of retail's per-code/per-message dispatch; 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) — 127 active rows (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 records the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems, filed only after `SpewBoxLayoutDumpDiagnostic`'s exhaustive sweep of every installed LayoutDesc found zero elements of class 0x10000016 (gmSpewBoxUI is code-mounted, not dat-authored); 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 338-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 @@ -233,7 +233,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-39 | Chat lines carry one solid color per line (retail's exact 34-value `LogTextType` table as of Campaign CH slice CH1, 2026-08-09 — see `RetailChatColorTable`, no longer the earlier synthetic per-`ChatKind` approximation); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs`; consumers `src/AcDream.App/UI/Layout/ChatWindowController.cs`, `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line coloring is now the exact retail tonal palette (`ChatInterface::BuildChatColorLookupTable @0x004F31C0`), not an approximation of it | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp); `docs/research/2026-08-09-chat-retail-color-table.md` | | AP-40 | Chat uses one fixed `0.75` outer opacity and has no descendant-focus-driven active/default opacity transition | `src/AcDream.App/Rendering/GameWindow.cs` chat mount; `ChatWindowController.cs` | Font resolution is now live and per-element; only the opacity behavior remains deferred to the shared window/focus runtime | Focused chat remains too translucent and idle chat never restores the configured default alpha | `ChatInterface::SetOpacity @ 0x004F3120`; `SetDefaultOpacity @ 0x004F3BC0`; `SetActiveOpacity @ 0x004F3C40` | | AP-175 | PopUpString (`GameEvent 0x0004`) renders as an ordinary chat-log line (`ChatKind.Popup`) instead of retail's MODAL DIALOG. Filed 2026-08-09, Campaign CH slice CH1 (color table) — the color-table work routes this entry through the new 34-value `LogTextType` table (fixed at `0x00` Default/green, unchanged from the entry's pre-existing color) but does not change WHERE it renders; a modal-dialog port is out of this slice's scope | `src/AcDream.Core/Chat/ChatLog.cs` (`OnPopup`); `src/AcDream.Core.Net/GameEventWiring.cs:126` | Informational popup text still reaches the player via the chat transcript; a full modal-dialog port is deferred work, not a color-table concern | Any retail-specific PopUpString behavior contingent on being a blocking modal (e.g. must-acknowledge) is not reproduced; acdream's chat-log line can be missed or scrolled past instead | `ClientCommunicationSystem::Handle_Communication__PopUpString @0x0057FE80`; `docs/research/2026-08-09-chat-retail-color-table.md` §5.1 | -| AP-176 | `ChatLog.OnWeenieError` types EVERY WeenieError/WeenieErrorWithString chat line `0x00` Default; retail's `HandleFailureEvent` dispatches per ERROR CODE across an ~87-case switch, mostly `AddTextToScroll(..., 0, ...)` but a scattered handful at `0x1a` (client-local red). The same class of approximation covers `ChatLog.OnCombatLine`'s generic `0x06` Combat fallback, used by callers (kill/death notifications aside) with no more specific hit/miss/evade classification in hand — a single stand-in type where retail's per-message dispatch would pick one of several. Filed 2026-08-09 at the CH1 Opus review | `src/AcDream.Core/Chat/ChatLog.cs:195-223` (`OnWeenieError`); `src/AcDream.Core/Chat/ChatLog.cs:366-379` (`OnCombatLine`) | `0x00`/`0x06` match each switch's majority behavior and are safe baselines; a full per-code/per-message port is out of CH1's color-table scope | Wrong chat color for the WeenieError codes and combat-line kinds retail types distinctly; pre-SpewBox routing also means even a correctly-`0x1a`-typed WeenieError still renders in the transcript rather than retail's separate on-screen text | `ClientCommunicationSystem::HandleFailureEvent @0x00571990`; `docs/research/2026-08-09-chat-retail-interface-text.md` Appendix A; retired by CH2's full `HandleFailureEvent` table port | +| AP-177 | SpewBox line lifetime is an INVENTED 5-second placeholder. Retail's `gmSpewBoxUI` never raises the expiry element message (`0x10000003`) anywhere in its own compiled Sept 2013 EoR code — the real per-line timeout/fade curve is owned by keystone.dll's authored behaviour for layout `0x10000012` element `0x1000004A`, which this slice did not measure (a live cdb capture on `gmSpewBoxUI::ListenToElementMessage @0x004D57C0` against a real retail client would resolve it). Filed 2026-08-09, Campaign CH slice CH2 | `src/AcDream.Core/Chat/SpewBoxState.cs` (`DefaultLifetime`) | A round, conservative placeholder was chosen over guessing a retail-matching curve; no fade is modeled at all (the line pops on and off) | SpewBox lines may linger noticeably longer or shorter than retail's actual timing, and pop instead of fading | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.1 | +| AP-178 | SpewBox screen position (centered top-of-viewport, `y=60px`), extent, font, and colour (a warm-yellow placeholder), plus `SpewBoxState.MaxConcurrentItems` (fixed at retail's CODE default of 1, not any authored value), are all INVENTED. `SpewBoxLayoutDumpDiagnostic` swept the installed `client_portal.dat`'s ENTIRE LayoutDesc id range (`0x21000000`-`0x21000075`, 101 of 118 possible ids populated, sanity-checked against 3 independently-known ids — 0x2100002E character window, 0x21000023 inventory, 0x21000016 toolbar prototype) and found ZERO elements of class `0x10000016` (`gmSpewBoxUI`) anywhere — the SpewBox is mounted directly from C++ code in `gmClient`'s HUD registration block, not resolved from any authored LayoutDesc tree, so a dat dump cannot recover these values. Filed 2026-08-09, Campaign CH slice CH2 | `src/AcDream.App/UI/SpewBoxController.cs`; `src/AcDream.Core/Chat/SpewBoxState.cs` (`MaxConcurrentItems`) | The colour placeholder follows the user's own (unconfirmed) recollection that retail's SpewBox is yellow rather than an arbitrary pick; a live cdb capture of `gmSpewBoxUI`'s runtime rect/state would be the next resolution path, matching how `reference_retail_chat_colors.md` was built | SpewBox text may render in the wrong screen location, size, font, or colour versus retail; bursts of refusals collapse to one visible line where retail's authored `ListBox` may show more | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.2-§3.2.4; `tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs` | +| AP-179 | `ChatLog.OnCombatLine`'s generic `0x06` Combat fallback types combat-feedback lines with a single stand-in `LogTextType` for callers with no more specific hit/miss/evade classification in hand, instead of retail's per-message dispatch. Split out of AP-176 (RETIRED 2026-08-09, Campaign CH slice CH2 — the WeenieError half of that bundled row is now the full 338-row `HandleFailureEvent` port, `WeenieErrorMessages.Resolve`); this combat-line half was never in CH2's scope and keeps its own row so the divergence is not silently dropped | `src/AcDream.Core/Chat/ChatLog.cs` (`OnCombatLine`) | `0x06` matches the switch's majority combat-line behavior and is a safe baseline; a full per-combat-message dispatch port is out of Campaign CH's scope | Wrong chat color for the combat-line kinds retail types distinctly (hit/miss/evade variants) | `ClientCommunicationSystem::HandleFailureEvent @0x00571990`; originally filed at the CH1 Opus review 2026-08-09 as part of AP-176, split out at CH2 | | AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | | AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | | AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; this property stream has not yet joined the per-object freshness owner introduced for physics messages. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index b8794f74..9f8d3485 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -345,7 +345,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.Actions.Selection.SelectedObjectId ?? 0u, stackSplitQuantity: d.StackSplitQuantity, systemMessage: - text => d.Communication.Chat.OnSystemMessage(text, (uint)RetailLogTextType.ClientLocal), + text => d.Communication.AddText(text, RetailLogTextType.ClientLocal), sendPutItemInContainer: (item, container, placement) => session.CurrentSession?.SendPutItemInContainer( item, @@ -415,7 +415,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory target, spellId), displayMessage: - text => d.Communication.Chat.OnSystemMessage(text, (uint)RetailLogTextType.ClientLocal), + text => d.Communication.AddText(text, RetailLogTextType.ClientLocal), incrementBusy: itemInteraction.IncrementBusyCount, canSend: () => late.Session.IsInWorld); @@ -753,7 +753,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory iconComposer.GetIcon, itemInteraction, d.Actions.Selection, - text => d.Communication.Chat.OnSystemMessage(text, (uint)RetailLogTextType.ClientLocal)), + text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)), Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager), Confirmations: new ConfirmationRuntimeBindings( (type, context, accepted) => @@ -768,7 +768,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory item, inscription), text => - d.Communication.Chat.OnSystemMessage(text, (uint)RetailLogTextType.ClientLocal)), + d.Communication.AddText(text, RetailLogTextType.ClientLocal)), StackSplitQuantity: d.StackSplitQuantity, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index c953d0ec..b3396241 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -28,6 +28,7 @@ using AcDream.Core.World; using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.World; +using AcDream.UI.Abstractions.Panels.SpewBox; using DatReaderWriter; using DatReaderWriter.DBObjs; using Silk.NET.Windowing; @@ -1074,6 +1075,19 @@ internal sealed class LivePresentationCompositionPhase portalWaitNoticeLease is { } waitNoticeLease ? waitNoticeLease.Resource.Set : null; + // 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. + if (interaction.RetainedUi is { } spewBoxRetainedUi) + { + scope.Acquire( + "spew box", + () => new SpewBoxController( + spewBoxRetainedUi.Host.Root, + new SpewBoxVM(d.Runtime.CommunicationOwner.SpewBox)), + static value => value.Dispose()); + } CompositionAcquisitionScope.CompositionAcquisitionLease< PortalTunnelPresentation>? portalTunnelLease = null; // Campaign V slice V6m: portal space opens a backbuffer pass of its diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 7d815a0a..1fd72929 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1125,7 +1125,7 @@ internal sealed class SessionPlayerCompositionPhase d.Actions.CombatMode, d.Log, debugToast, - text => d.Communication.Chat.OnSystemMessage(text, (uint)RetailLogTextType.ClientLocal)); + text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)); bindings.Adopt( "live combat-mode commands", d.CombatModeCommands.BindOwned(combatCommand)); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index eef1696d..c04250a0 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -257,7 +257,8 @@ internal sealed class LiveSessionRuntimeFactory _domain.Communication.Chat, _domain.Communication.TurbineChat, _domain.Communication.Friends, - _domain.Communication.Squelch)); + _domain.Communication.Squelch, + (text, type) => _domain.Communication.AddText(text, type))); return new GraphicalSessionEventRoute( route, _domain.Runtime, diff --git a/src/AcDream.App/UI/SpewBoxController.cs b/src/AcDream.App/UI/SpewBoxController.cs new file mode 100644 index 00000000..068c7f89 --- /dev/null +++ b/src/AcDream.App/UI/SpewBoxController.cs @@ -0,0 +1,112 @@ +using System.Numerics; +using AcDream.UI.Abstractions.Panels.SpewBox; + +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 — the retained UI +/// tree has no separate per-frame "Update(dt)" hook, so +/// (already polled once per render pass) +/// doubles as this controller's tick source. +/// +/// +/// Position / font / colour / max-items are PLACEHOLDERS. The task +/// C.7 LayoutDesc dump (SpewBoxLayoutDumpDiagnostic) was attempted +/// and completed EXHAUSTIVELY against the installed DAT's entire LayoutDesc +/// id range (0x21000000-0x21000075, 101 of 118 possible ids +/// populated, sanity-checked against 3 independently-known ids) and found +/// ZERO elements of class 0x10000016 anywhere — gmSpewBoxUI is +/// mounted directly from C++ code, not resolved from any authored +/// LayoutDesc tree, so its screen position/extent/font/colour and the +/// authored MaxConcurrentItems ListBox property are simply not +/// recoverable this way. See the divergence register rows this class cites +/// for each specific placeholder. +/// +internal sealed class SpewBoxController : IDisposable +{ + /// + /// Register row AP-TBD (position/extent): retail's authored screen + /// position for the SpewBox host is unknown (see class remarks); this + /// centered-top placement is acdream's own choice, not a retail value. + /// + private const float TopOffset = 60f; + private const float BoxHeight = 40f; + + /// + /// Register row AP-TBD (colour): the chat colour table's 0x1A + /// entry (colorBrightRed) is explicitly NOT this — retail's own + /// BuildChatColorLookupTable writes to ChatInterface::m_chatLog, + /// a completely different element tree the SpewBox never touches + /// (research doc §3.2.3). This warm-yellow placeholder follows the + /// user's own recollection of the retail SpewBox's colour (unconfirmed + /// by any decompiled or DAT-authored source) rather than an arbitrary + /// choice. + /// + private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.4f, 1f); + + private readonly UiRoot _root; + private readonly UiText _text; + private readonly SpewBoxVM _vm; + private bool _disposed; + + public SpewBoxController(UiRoot root, SpewBoxVM vm) + { + _root = root ?? throw new ArgumentNullException(nameof(root)); + _vm = vm ?? throw new ArgumentNullException(nameof(vm)); + _text = new UiText + { + Name = "SpewBox", + Left = 0f, + Top = TopOffset, + Width = root.Width, + Height = BoxHeight, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, + Centered = true, + OneLine = true, + ClickThrough = true, + ZOrder = int.MaxValue, + DefaultColor = SpewBoxColor, + Visible = false, + }; + _text.LinesProvider = ComputeLines; + _root.AddChild(_text); + } + + /// + /// Polled once per render pass by — this IS the + /// SpewBox's per-frame tick (drains SpewBoxState's pending queue + /// and prunes expired entries; see ). + /// returns newest-first, matching retail's + /// InsertItem(item, 0); OneLine mode only ever draws + /// index 0, so with retail's code-default + /// MaxConcurrentItems == 1 this always shows the current line. + /// + private IReadOnlyList ComputeLines() + { + double nowSeconds = Environment.TickCount64 / 1000.0; + IReadOnlyList lines = _vm.Lines(nowSeconds); + _text.Visible = lines.Count > 0; + if (lines.Count == 0) + return Array.Empty(); + + var result = new UiText.Line[lines.Count]; + for (int i = 0; i < lines.Count; i++) + result[i] = new UiText.Line(lines[i].Text, SpewBoxColor); + return result; + } + + public void Dispose() + { + if (_disposed) + return; + + _root.RemoveChild(_text); + _disposed = true; + } +} diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 48b9b44d..254ebe9a 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -87,6 +87,15 @@ public static class GameEventWiring // itemMana/friends/squelch/externalContainers pattern — optional so // every existing caller compiles unchanged. VendorState? vendor = null, + // Campaign CH slice CH2: the retail-faithful text/type router + // (RuntimeCommunicationState.AddText — Core.Net cannot reference + // AcDream.Runtime directly, so this is a delegate hole exactly like + // every other Runtime-owned sink above). When null, WeenieError/ + // WeenieErrorWithString/CommunicationTransientString/UseDone fall + // back to their pre-CH2 behavior (still text-correct via the full + // WeenieErrorMessages table, just without the SpewBox split) so + // every existing caller compiles and behaves unchanged. + Action? onInterfaceText = null, Func? accepting = null) { ArgumentNullException.ThrowIfNull(dispatcher); @@ -117,15 +126,22 @@ public static class GameEventWiring }); registrar.Register(GameEventType.CommunicationTransientString, e => { - // 0x02EB carries no chat type on the wire (see ParseTransient). - // 0 is ACE's ChatMessageType.Broadcast, which its own - // LogTextTypeEnumMapper comment names "Default" — the right - // stand-in for a message the server sends untyped. Left at 0x00 - // by Campaign CH slice CH1 (retail color table): this is - // server-driven text, not client-local, so it keeps the - // Default/green color rather than moving to 0x1A. + // 0x02EB carries no chat type on the wire (see ParseTransient) — + // retail doesn't need one, because + // Handle_Communication__TransientString @0x0057D460 + // HARDCODES the destination: + // AddTextToScroll(text, 0x1A, 1, 0). Every 0x02EB message is + // SpewBox, unconditionally, regardless of who's the recipient + // (research doc §1.3/§2.4) — this corrects Campaign CH slice + // CH1's routing note, which assumed "server-driven" implied + // "not client-local" (it doesn't; retail's routing key is the + // TYPE ARGUMENT the handler passes, not who initiated the text). var s = GameEvents.ParseTransient(e.Payload.Span); - if (s is not null) chat.OnSystemMessage(s, chatType: 0u); + if (s is null) return; + if (onInterfaceText is not null) + onInterfaceText(s, RetailLogTextType.ClientLocal); + else + chat.OnSystemMessage(s, chatType: (uint)RetailLogTextType.ClientLocal); }); registrar.Register(GameEventType.PopupString, e => { @@ -227,16 +243,44 @@ public static class GameEventWiring // Phase I.5: WeenieError + WeenieErrorWithString parsers existed // (GameEvents.ParseWeenieError(WithString)) but were never registered. // The server fires these for game-logic failures: "not enough mana", - // "can't pick that up", "your spell fizzled". Routed to chat. + // "can't pick that up", "your spell fizzled". + // + // Campaign CH slice CH2: retail resolves BOTH the display text and + // the AddTextToScroll destination type from the SAME per-id switch + // (ClientCommunicationSystem::HandleFailureEvent @0x00571990 — see + // WeenieErrorMessages' full 338-row port). When a router is wired + // (the production path), the resolved type decides chat vs SpewBox; + // otherwise this falls back to the legacy chat-only path so callers + // that don't wire the router (older tests) keep their prior shape. registrar.Register(GameEventType.WeenieError, e => { var code = GameEvents.ParseWeenieError(e.Payload.Span); - if (code is not null) chat.OnWeenieError(code.Value, param: null); + if (code is null) return; + if (onInterfaceText is not null) + { + if (WeenieErrorMessages.IsSilentClientControlStatus(code.Value)) return; + var (text, type) = WeenieErrorMessages.Resolve(code.Value, null); + onInterfaceText(text, type); + } + else + { + chat.OnWeenieError(code.Value, param: null); + } }); registrar.Register(GameEventType.WeenieErrorWithString, e => { var p = GameEvents.ParseWeenieErrorWithString(e.Payload.Span); - if (p is not null) chat.OnWeenieError(p.Value.ErrorCode, p.Value.Interpolation); + if (p is null) return; + if (onInterfaceText is not null) + { + if (WeenieErrorMessages.IsSilentClientControlStatus(p.Value.ErrorCode)) return; + var (text, type) = WeenieErrorMessages.Resolve(p.Value.ErrorCode, p.Value.Interpolation); + onInterfaceText(text, type); + } + else + { + chat.OnWeenieError(p.Value.ErrorCode, p.Value.Interpolation); + } }); // ── Combat ──────────────────────────────────────────────── @@ -527,10 +571,15 @@ public static class GameEventWiring }); // UseDone (0x01C7) — the Use/UseWithTarget completion signal. A non-zero - // code is a WeenieError refusal ("You are not trained in healing!" etc.); - // retail surfaces the string-table text as a chat line. Interim text map: - // WeenieErrorText (#202 / register AP-74). Without this line a refused - // kit-heal looks like "nothing happened" — the 2026-07-03 session bug. + // code is a WeenieError refusal ("You are not trained in healing!" etc.). + // + // Campaign CH slice CH2: folds the former 4-entry + // WeenieErrorText.cs (#202 / register AP-74) into the full + // WeenieErrorMessages table, which resolves both the text AND the + // real per-code retail destination type (most UseDone refusal codes + // — 0x1D/0x4EB/0x4FC/0x4FE among them — route to the SpewBox, not + // chat, per HandleFailureEvent) instead of the previous hardcoded + // chatType 0. registrar.Register(GameEventType.UseDone, e => { uint? err = GameEvents.ParseUseDone(e.Payload.Span); @@ -543,12 +592,13 @@ public static class GameEventWiring if (err is null) return; Console.WriteLine($"[use-done] err=0x{err.Value:X4}"); onUseDone?.Invoke(err.Value); - // chatType 0x00 (Default): this text is client-formatted from a - // WeenieError CODE, the same shape as HandleFailureEvent's - // per-code switch (@0x00571990), whose majority case is 0x00 — - // see the identical reasoning on ChatLog.OnWeenieError. - if (err.Value != 0) - chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0); + if (err.Value == 0) return; + + var (text, type) = WeenieErrorMessages.Resolve(err.Value, null); + if (onInterfaceText is not null) + onInterfaceText(text, type); + else + chat.OnSystemMessage(text, chatType: (uint)type); }); // CloseGroundContainer (0x0052): clear ClientUISystem::groundObject and diff --git a/src/AcDream.Core.Net/Messages/WeenieErrorText.cs b/src/AcDream.Core.Net/Messages/WeenieErrorText.cs deleted file mode 100644 index 5cf4291a..00000000 --- a/src/AcDream.Core.Net/Messages/WeenieErrorText.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace AcDream.Core.Net.Messages; - -/// -/// Interim WeenieError → display text. Retail resolves error codes through -/// the portal String tables into lines like "You are not trained in -/// healing!"; porting that lookup is #202 — until then this map (codes from -/// ACE's WeenieError enum, texts phrased after the retail messages the -/// enum names encode) covers the errors our current use/heal flows can -/// produce. Register row AP-74. Unknown codes fall back to a generic line -/// carrying the code so nothing is ever silently dropped again (the -/// 2026-07-03 "kit did nothing" session was an unsurfaced 0x04FC). -/// -public static class WeenieErrorText -{ - public static string For(uint error) => error switch - { - 0x001Du => "You're too busy!", // YoureTooBusy - 0x04EBu => "You can't do that while in the air!", // YouCantDoThatWhileInTheAir - 0x04FCu => "You are not trained in healing!", // YouArentTrainedInHealing - 0x04FEu => "You can't heal that!", // YouCantHealThat - _ => $"You can't do that. (error 0x{error:X4})", - }; -} diff --git a/src/AcDream.Core/Chat/ClientTextRefusals.cs b/src/AcDream.Core/Chat/ClientTextRefusals.cs new file mode 100644 index 00000000..99adeb62 --- /dev/null +++ b/src/AcDream.Core/Chat/ClientTextRefusals.cs @@ -0,0 +1,109 @@ +namespace AcDream.Core.Chat; + +/// +/// The 11 process-lifetime string globals retail's +/// ClientCommunicationSystem static ctors initialize at +/// 0x00708F00-0x00709180 (Sept 2013 EoR build) and reuses for +/// BOTH locally-detected refusals (jump/posture/emote gates evaluated on the +/// client, no server round-trip — research doc +/// docs/research/2026-08-09-chat-retail-interface-text.md §4) AND the +/// matching server-sent WeenieError ids inside +/// HandleFailureEvent @0x00571990 (' +/// 0x024/0x048/0x049 rows point straight back to these +/// same constants — retail shares ONE string table for both paths). +/// +/// +/// Every value here was read byte-for-byte off the PDB-paired binary +/// C:\Users\erikn\Downloads\acclient.exe (v11.4186, CodeView GUID +/// 9e847e2f-777c-4bd9-886c-22256bb87f32 — verified MATCH via +/// tools/pdb-extract/check_exe_pdb.py) via a raw UTF-16LE prefix +/// search + null-terminator read, per +/// claude-memory/reference_pe_byte_decode.md. This was necessary +/// because the decompiled pseudo-C at +/// docs/research/named-retail/acclient_2013_pseudo_c.txt truncates +/// every one of these literals at ~33 characters — including 6 the research +/// doc's §4.1 table had already resolved correctly by inference (the +/// natural completion happened to be unambiguous) and 5 it explicitly +/// flagged as needing this exact re-read +/// (cant_sit_combat, cant_lie_down_combat, +/// cant_crouch_combat, cant_emote_combat, +/// cant_emote_position — all 5 turned out to end in +/// "... combat mode" / "... this position", NOT the shorter +/// "... combat" the truncated preview alone would suggest). +/// +/// +public static class ClientTextRefusals +{ + /// + /// WeenieError.CantJumpFromThisPosition (0x0048) — jump BLOCKED + /// by the current motion or position (a blocklist check, not an + /// airborne check). + /// + public const string CantJumpPosition = "You can't jump from this position"; + + /// + /// WeenieError.NotGrounded / YouCantJumpWhileInTheAir (0x0024) — + /// not grounded / no contact. + /// + public const string CantJumpInAir = "You can't jump while in the air"; + + /// + /// WeenieError.CantJumpLoadedDown (0x0049) — the weenie's + /// CanJump(jump_extent) virtual refused (stamina/burden gate). + /// + public const string CantJumpLoad = "You're too loaded down to jump"; + + /// + /// Dead in the Sept 2013 EoR build per the research doc (no call site + /// found) — kept for parity with retail's own string table. + /// + public const string CantJumpStamina = "You're too tired to jump!"; + + /// + /// Dead in the Sept 2013 EoR build per the research doc (no call site + /// found) — kept for parity with retail's own string table. + /// + public const string CantJumpRecent = "You've jumped too recently!"; + + /// + /// WeenieError.YouAreTooTiredToDoThat (0x003E) — + /// CommandInterpreter::MovePlayer's stamina-exhausted refusal + /// (code 0x3E in the movement/posture/emote family). + /// + public const string TooTired = "You are too tired to move!"; + + /// + /// WeenieError.SitInCombatStance / CantSitInCombat (0x0040). + /// Byte-recovered — the pseudo-C truncated this to + /// "You can't sit down while in comb…". + /// + public const string CantSitCombat = "You can't sit down while in combat mode"; + + /// + /// WeenieError.SleepInCombatStance / CantLieDownInCombat (0x0041). + /// Byte-recovered — the pseudo-C truncated this to + /// "You can't lie down while in comb…". + /// + public const string CantLieDownCombat = "You can't lie down while in combat mode"; + + /// + /// WeenieError.CrouchInCombatStance / CantCrouchInCombat (0x003F). + /// Byte-recovered — the pseudo-C truncated this to + /// "You can't crouch while in combat…". + /// + public const string CantCrouchCombat = "You can't crouch while in combat mode"; + + /// + /// WeenieError.ChatEmoteOutsideNonCombat / CantChatEmoteInCombat + /// (0x0042). Byte-recovered — the pseudo-C truncated this to + /// "You can't use chat emotes in com…". + /// + public const string CantEmoteCombat = "You can't use chat emotes in combat mode"; + + /// + /// WeenieError.CantChatEmoteNotStanding (0x0044). Byte-recovered + /// — the pseudo-C truncated this to + /// "You can't use chat emotes from t…". + /// + public const string CantEmotePosition = "You can't use chat emotes from this position"; +} diff --git a/src/AcDream.Core/Chat/SpewBoxState.cs b/src/AcDream.Core/Chat/SpewBoxState.cs new file mode 100644 index 00000000..9d13f264 --- /dev/null +++ b/src/AcDream.Core/Chat/SpewBoxState.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace AcDream.Core.Chat; + +/// +/// One visible SpewBox line: display text plus the caller-clock timestamp +/// (seconds) at which it should be pruned. +/// +public readonly record struct SpewBoxEntry(string Text, double ExpiresAtSeconds); + +/// +/// Retail's transient on-screen "interface text" — a direct port of +/// gmSpewBoxUI's pending/visible split +/// (docs/research/2026-08-09-chat-retail-interface-text.md §1.1/§3.1). +/// Pure state, no presentation. +/// +/// +/// Placement note (deviation from the CH2 brief): the brief names +/// AcDream.Runtime as this type's home. It lives in +/// AcDream.Core.Chat instead, directly beside , +/// because AcDream.UI.Abstractions (Code Structure Rule 3 — panels/ +/// ViewModels target UI.Abstractions only) references AcDream.Core +/// but NOT AcDream.Runtime, and the UI.Abstractions +/// SpewBoxVM needs to wrap this type directly — exactly the same +/// constraint already satisfies by wrapping +/// (also Core, not Runtime). RuntimeCommunicationState +/// still owns the canonical instance and is still the sole writer via its +/// AddText router, matching every other J4-era Runtime-owns/App-or- +/// UI.Abstractions-borrows pattern in this codebase. +/// +/// +/// +/// Retail decouples enqueue (RecvNotice_DisplayFinalStringInfo +/// @0x004D60A0, type-filtered to 0x1A only) from display +/// (Update @0x004D5DF0, driven once per UI tick by global message +/// 3) by exactly one frame. reproduces that: it +/// drains whatever is pending into the visible list (applying retail's +/// dedupe-against-index-0 and MaxConcurrentItems overflow rules) and +/// prunes expired entries, all in the caller's own per-frame cadence. +/// +/// +public sealed class SpewBoxState +{ + /// + /// Retail's own code default (gmSpewBoxUI::PostInit @0x004D5AB0) + /// when ListBox property 0x10000028 is absent or unreadable. The + /// shipped LayoutDesc's authored value was not resolved in this slice — + /// see the divergence register. + /// + public const int MaxConcurrentItems = 1; + + /// + /// Retail's own client never raises the expiry element message + /// (0x10000003) anywhere in the Sept 2013 EoR build — the real + /// per-line timeout is owned by keystone.dll's authored behaviour for + /// layout 0x10000012 element 0x1000004A and was not + /// measured in this slice (§3.2.1 of the research doc). This is an + /// INVENTED placeholder, not a retail-measured value — see the + /// divergence register. + /// + public static readonly TimeSpan DefaultLifetime = TimeSpan.FromSeconds(5); + + private readonly object _gate = new(); + private readonly Queue _pending = new(); + private readonly List _visible = new(); + private long _revision; + + /// Monotonic content revision — advances on any Tick that changes the visible set, and on Reset. + public long Revision => Interlocked.Read(ref _revision); + + /// Number of currently visible lines (0..). + public int Count + { + get { lock (_gate) return _visible.Count; } + } + + /// + /// Enqueue text for display — retail's + /// RecvNotice_DisplayFinalStringInfo type-0x1A branch. + /// Does not itself become visible until the next . + /// + public void Enqueue(string text) + { + lock (_gate) + _pending.Enqueue(text); + } + + /// + /// Drain any pending text into the visible list and prune expired + /// entries. Call once per UI frame/tick (retail's global message + /// 3, gmSpewBoxUI::Update). + /// + /// + /// Caller's own monotonic clock, in seconds. Only used to stamp new + /// entries' expiry and to prune old ones — never compared across + /// different clock sources. + /// + public void Tick(double nowSeconds) + { + bool changed; + lock (_gate) + { + changed = _pending.Count > 0; + while (_pending.Count > 0) + { + string text = _pending.Dequeue(); + + // Dedupe against index 0 ONLY (retail: 0x004D5EF6-0x004D5F91) + // — an identical repeat refreshes the newest line in place + // instead of stacking a duplicate. + if (_visible.Count > 0 && _visible[0].Text == text) + _visible.RemoveAt(0); + + // Newest at the top — retail's InsertItem(item, 0). + _visible.Insert(0, new SpewBoxEntry(text, nowSeconds + DefaultLifetime.TotalSeconds)); + + // Overflow drops the OLDEST (highest index) entry — retail's + // DeleteItem(count - 1) when count > m_maxConcurrentItems. + while (_visible.Count > MaxConcurrentItems) + _visible.RemoveAt(_visible.Count - 1); + } + + int removed = _visible.RemoveAll(e => e.ExpiresAtSeconds <= nowSeconds); + changed |= removed > 0; + } + + if (changed) + Interlocked.Increment(ref _revision); + } + + /// Snapshot of currently visible lines, newest first. + public SpewBoxEntry[] Snapshot() + { + lock (_gate) + return _visible.ToArray(); + } + + /// Clear all pending and visible state — session/generation reset. + public void Reset() + { + lock (_gate) + { + _pending.Clear(); + _visible.Clear(); + } + Interlocked.Increment(ref _revision); + } +} diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs index daaa85b5..1f262bcc 100644 --- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -4,30 +4,73 @@ namespace AcDream.Core.Chat; /// /// Translates ACE WeenieError + WeenieErrorWithString codes -/// into the human-readable templates the retail client showed. The -/// retail client baked these strings into string_table.bin; for -/// our purposes we mirror ACE's enum-doc comments -/// (references/ACE/Source/ACE.Entity/Enum/WeenieError.cs + -/// WeenieErrorWithString.cs) since they preserve the original -/// templates verbatim, including the literal _ placeholder where -/// the parameter goes. +/// into the human-readable templates retail actually showed, AND the retail +/// destination () each one routed to. /// /// -/// Why we need this: Many of the codes ACE sends — especially -/// the high-frequency ones the user actually sees in normal play — -/// are informational, not error-level (e.g. 0x051B = -/// "You have entered the X channel.", 0x051D = "Turbine Chat -/// is enabled."). Displaying them as WeenieError 0xNNNN is -/// noisy and misleading. With the proper template they read as the -/// retail player would have seen them. +/// Campaign CH slice CH2 (2026-08-09): this is a full port of +/// ClientCommunicationSystem::HandleFailureEvent @0x00571990 +/// (Sept 2013 EoR build), the 339-case switch that decides BOTH the display +/// string and the AddTextToScroll type argument for every +/// WeenieError/WeenieErrorWithString id retail's client knows +/// about. Transcribed from +/// docs/research/2026-08-09-chat-retail-interface-text.md Appendix A +/// (itself read off the named retail decomp), with the following +/// corrections made directly against +/// docs/research/named-retail/acclient_2013_pseudo_c.txt rather than +/// trusting the appendix's markdown transcription wholesale: /// /// +/// +/// 7 ids the appendix marked "no literal — shared string global" +/// (0x024, 0x048, 0x049, 0x4DE, 0x4DF, +/// 0x55A, 0x55E) were resolved by reading the case bodies +/// directly: 0x024/0x048/0x049 reuse the same +/// process-lifetime globals as the local jump-refusal family (see +/// ); 0x4DE/0x4DF are +/// arg3 + "\n"; 0x55A is sprintf("%s\n", arg3); +/// 0x55E passes arg3 straight through with no format string +/// at all. +/// Appendix A's markdown table trims leading whitespace from every +/// cell, which silently ate the leading " " retail's +/// arg3 + literal CONCATENATION sites (as opposed to a real +/// sprintf("%s...") site) depend on. 19 ids +/// (0x02B, 0x3EF, 0x46A, 0x4CE, 0x4CF, +/// 0x4F7, 0x4F9, 0x4FA, 0x4FF, 0x509, +/// 0x50B, 0x50C, 0x50D, 0x517, 0x518, +/// 0x51E, 0x521, 0x522) were fixed by re-reading their +/// case bodies and prepending the %s the concatenation implies. +/// Several of these (and the %s-prefixed but truncated +/// 0x4F4-0x4F6, 0x530, 0x534, 0x53E, +/// 0x541, 0x543, 0x54B, 0x562, 0x56D, +/// 0x57A, 0x57B, 0x580) were also truncated by the +/// pseudo-C's ~33-char inline preview; the full text was recovered from a +/// SECOND, non-truncated data_XXXXXXXX dump elsewhere in the same +/// oracle file — reading the oracle again, not guessing. +/// 0x4F4's retail literal is +/// "%s fails to affect you because $s cannot affect anyone!" — +/// note $s, not %s, for the second placeholder. That is a +/// genuine retail typo/bug (only the first %s substitutes; the +/// literal $s prints as-is) and is preserved verbatim rather than +/// corrected, per the "the client is probably right" rule. +/// 0x4F7's retail string is itself incomplete — it +/// concatenates arg3 with a literal that dangles on +/// "...as " with no closing word. Confirmed via exact byte-count +/// against the declared array size (not a display artifact); preserved +/// verbatim. +/// 0x4F8 could not be resolved with confidence — its case +/// body is a tangled multi-operator+ concatenation chain full of +/// decompiler self-referential artifacts (see +/// claude-memory/feedback_bn_decomp_field_names.md). Deliberately +/// EXCLUDED from the table rather than guessed; falls back to the generic +/// WeenieError 0xNNNN[: param] form like any other unmapped id. +/// +/// +/// /// -/// We don't translate every code — only the ~30 most likely to appear -/// in a normal session. Unknown codes fall back to the -/// WeenieError 0xNNNN[: param] form so nothing is silently -/// lost. New codes can be added in 30 seconds when the user reports -/// one. +/// This retires register row AP-176 (the CH1-era approximation that fixed +/// every WeenieError display at LogTextType.Default pending this +/// full port). /// /// public static class WeenieErrorMessages @@ -46,159 +89,394 @@ public static class WeenieErrorMessages public static bool IsSilentClientControlStatus(uint errorCode) => errorCode is 0x003Bu or 0x003Cu; + /// + /// One retail routing-table row: the display template (retail's own + /// %s placeholder, substituted verbatim — never retail's literal + /// $s, see 0x4F4 above) plus the exact + /// HandleFailureEvent passed to + /// AddTextToScroll for this id. + /// + public readonly record struct Entry(string Template, RetailLogTextType Type); + /// /// Format a WeenieError / WeenieErrorWithString into a human-readable - /// system-message string. + /// system-message string. Back-compat wrapper over + /// for callers that only need text, not the routing type. /// /// The wire error code. /// The interpolated substring (null for plain /// WeenieError, set for WeenieErrorWithString). - public static string Format(uint errorCode, string? param) + public static string Format(uint errorCode, string? param) => Resolve(errorCode, param).Text; + + /// + /// Resolve a WeenieError / WeenieErrorWithString code into its retail + /// display text AND the retail it routes + /// to. Unmapped codes fall back to the raw + /// WeenieError 0xNNNN[: param] form at + /// so nothing is silently lost. + /// + public static (string Text, RetailLogTextType Type) Resolve(uint errorCode, string? param) { - // WeenieErrorWithString templates use the literal underscore - // character `_` as the placeholder for the param. We - // substitute it for `param` if present, otherwise drop it. - if (param is not null && WithStringTemplates.TryGetValue(errorCode, out var withTemplate)) + if (Table.TryGetValue(errorCode, out Entry entry)) { - return withTemplate.Replace("_", param); + string text = param is not null && entry.Template.Contains("%s") + ? entry.Template.Replace("%s", param) + : entry.Template; + return (text, entry.Type); } - if (NoParamTemplates.TryGetValue(errorCode, out var template)) - { - // Some "no-param" codes do still arrive with a meaningless - // param string; ignore it. - return template; - } - - // Unknown code — fall back to the raw form. - return string.IsNullOrEmpty(param) + string fallback = string.IsNullOrEmpty(param) ? $"WeenieError 0x{errorCode:X4}" : $"WeenieError 0x{errorCode:X4}: {param}"; + return (fallback, RetailLogTextType.Default); } /// - /// Codes from WeenieError (no param). Templates copied - /// verbatim from ACE enum-doc comments. + /// The full retail routing table, transcribed from + /// ClientCommunicationSystem::HandleFailureEvent @0x00571990 (338 + /// of its 339 cases — see the class doc comment for the one deliberate + /// exclusion and every correction made against the raw decomp). /// - private static readonly Dictionary NoParamTemplates = new() + private static readonly Dictionary Table = new() { - // Command parser - [0x0026] = "That is not a valid command.", // ThatIsNotAValidCommand - [0x055F] = "Only Player Killer characters may use this command!", - [0x0560] = "Only Player Killer Lite characters may use this command!", - - // Tell-related - [0x052B] = "That person is not available now.", // CharacterNotAvailable - - // Chat-channel related - [0x051D] = "Turbine Chat is enabled.", // TurbineChatIsEnabled - - // Trade - [0x0529] = "Trade Complete!", // TradeComplete - - // Allegiance / Fellowship membership errors (high-frequency - // when player isn't in either group and tries the channel) - [0x0414] = "You are not in an allegiance!", // YouAreNotInAllegiance - [0x050F] = "You do not belong to a Fellowship.", // YouDoNotBelongToAFellowship - - // Allegiance - [0x0496] = "Your Allegiance has been dissolved!", // YourAllegianceHasBeenDissolved - [0x0497] = "Your patron's Allegiance to you has been broken!", - // YourPatronsAllegianceHasBeenBroken - [0x0535] = "You do not have the authority within your allegiance to do that.", - - // Movement / teleport / housing - [0x0036] = "Action cancelled!", // ActionCancelled - [0x003D] = "You charged too far!", // YouChargedTooFar - [0x004A] = "Ack! You killed yourself!", // YouKilledYourself - [0x0498] = "You have moved too far!", // YouHaveMovedTooFar - [0x0499] = "That is not a valid destination!", // TeleToInvalidPosition - [0x0532] = "You must wait 30 days after purchasing a house before you may purchase another with any character on the same account.", - [0x0550] = "Out of Range!", // MissileOutOfRange - - // Fellowship - [0x0528] = "The fellowship is locked; you cannot open locked fellowships.", - - // Player flavour - [0x0526] = "You chicken out.", // YouChickenOut - - // PK status (retail: ClientCommunicationSystem::HandleFailureEvent - // @0x00571990, decompiled at - // docs/research/named-retail/acclient_2013_pseudo_c.txt:382900+. - // Strings recovered from the PDB-paired - // C:\Users\erikn\Downloads\acclient.exe (imagebase 0x400000) via - // VA -> RVA -> file-offset mapping per - // claude-memory/reference_pe_byte_decode.md, because the pseudo-C - // dump for data_7d32c0 truncates at its declared array bound - // (0x5f wchar16) mid-sentence. Retail's literal ends with a - // trailing "\n" that we drop here — acdream's ChatEntry already - // renders one line per system message, unlike retail's single - // scrolling text buffer. - [0x0504] = "You are enveloped in a feeling of warmth as you are brought back into the protection of the Light. You are once again a Non-Player Killer.", - // YouAreNonPKAgain, case 0x504 @0x005745cd, data_7d32c0 - [0x0505] = "You're too close to your sanctuary!", // YoureTooCloseToYourSanctuary, case 0x505 @0x00574c65, data_7d2640 - [0x04EC] = "You cannot modify your player killer status while you are recovering from a PK death.", - // CannotChangePKStatusWhileRecovering, case 0x4ec @0x0057446f, data_7d3820 - [0x04ED] = "Advocates may not change their player killer status!", - // AdvocatesCannotChangePKStatus, case 0x4ed @0x005744a1, data_7d37b0 - }; - - /// - /// Codes from WeenieErrorWithString. Templates copied - /// verbatim from ACE enum-doc comments. The _ placeholder - /// is substituted with the param at format time. - /// - private static readonly Dictionary WithStringTemplates = new() - { - // Channel join / leave (high frequency at login) - [0x051B] = "You have entered the _ channel.", // YouHaveEnteredThe_Channel - [0x051C] = "You have left the _ channel.", // YouHaveLeftThe_Channel - - // Chat-server failures - [0x051E] = "_ will not receive your message, please use urgent assistance to speak with an in-game representative.", - [0x051F] = "Message Blocked: _", // MessageBlocked_ - - // Hear / loud-list - [0x0521] = "_ has been added to the list of people you can hear.", - [0x0522] = "_ has been removed from the list of people you can hear.", - [0x0525] = "You fail to remove _ from your loud list.", - - // Snooping (admin) - [0x052C] = "You are now snooping on _.", - [0x052D] = "You are no longer snooping on _.", - [0x052E] = "You fail to snoop on _.", - [0x052F] = "_ attempted to snoop on you.", - [0x0551] = "You are not listening to the _ channel.", - - // Allegiance - [0x046A] = "_ doesn't know what to do with that.", - [0x0413] = "_ is already one of your followers.", - [0x0416] = "_ cannot have any more Vassals.", - [0x03EF] = "_ is not accepting gifts right now.", - - // Combat / spell failures - [0x004E] = "You fail to affect _ because you cannot affect anyone!", - [0x004F] = "You fail to affect _ because they cannot be harmed!", - [0x0050] = "You fail to affect _ because beneficial spells do not affect them!", - [0x0051] = "You fail to affect _ because you are not a player killer!", - [0x0052] = "You fail to affect _ because they are not a player killer!", - [0x0053] = "You fail to affect _ because you are not the same sort of player killer as them!", - [0x0054] = "You fail to affect _ because you are acting across a house boundary!", - - // Healing - [0x04FF] = "_ is already at full health!", // _IsAtFullHealth - - // Inventory / etiquette - [0x001E] = "_ is too busy to accept gifts right now.", - [0x002B] = "_ cannot carry anymore.", - - // Fellowship - [0x0517] = "_ is not close enough to your level.", - [0x0518] = "This fellowship is locked; _ cannot be recruited into the fellowship.", - - // Hooks - [0x0510] = "Maximum number of _ hooked.", - [0x0514] = "Maximum number of _ hooked until one is removed.", - [0x0515] = "You no longer have the maximum number of _ hooked. You may hook additional.", + [0x017u] = new("You failed to go to non-combat mode.", RetailLogTextType.ClientLocal), + [0x01Du] = new("You're too busy!", RetailLogTextType.ClientLocal), + [0x01Eu] = new("You must control both objects!", RetailLogTextType.ClientLocal), + [0x020u] = new("You must control both objects!", RetailLogTextType.ClientLocal), + [0x023u] = new("Unable to move to object!", RetailLogTextType.ClientLocal), + [0x024u] = new(ClientTextRefusals.CantJumpInAir, RetailLogTextType.ClientLocal), + [0x026u] = new("That is not a valid command.", RetailLogTextType.ClientLocal), + [0x028u] = new("The item is under someone else's control!", RetailLogTextType.ClientLocal), + [0x029u] = new("You cannot pick that up!", RetailLogTextType.ClientLocal), + [0x02Au] = new("You are too encumbered to carry that!", RetailLogTextType.ClientLocal), + [0x02Bu] = new("%s cannot carry anymore.", RetailLogTextType.Default), + [0x036u] = new("Action cancelled!", RetailLogTextType.ClientLocal), + [0x037u] = new("Unable to move to object!", RetailLogTextType.ClientLocal), + [0x038u] = new("Unable to move to object!", RetailLogTextType.ClientLocal), + [0x039u] = new("Unable to move to object!", RetailLogTextType.ClientLocal), + [0x03Au] = new("You can't do that... you're dead!", RetailLogTextType.ClientLocal), + [0x03Du] = new("You charged too far!", RetailLogTextType.ClientLocal), + [0x03Eu] = new("You are too tired to do that!", RetailLogTextType.ClientLocal), + [0x048u] = new(ClientTextRefusals.CantJumpPosition, RetailLogTextType.ClientLocal), + [0x049u] = new(ClientTextRefusals.CantJumpLoad, RetailLogTextType.ClientLocal), + [0x04Au] = new("Ack! You killed yourself!", RetailLogTextType.Default), + [0x04Du] = new("Invalid PK status!", RetailLogTextType.ClientLocal), + [0x04Eu] = new("You fail to affect %s because you cannot affect anyone!", RetailLogTextType.Magic), + [0x050u] = new("You fail to affect %s because beneficial spells do not affect %s!", RetailLogTextType.Magic), + [0x051u] = new("You fail to affect %s because you cannot affect anyone!", RetailLogTextType.Magic), + [0x052u] = new("You fail to affect %s because %s is not a player killer!", RetailLogTextType.Magic), + [0x053u] = new("You fail to affect %s because you cannot affect anyone!", RetailLogTextType.Magic), + [0x054u] = new("You fail to affect %s because you cannot affect anyone!", RetailLogTextType.Magic), + [0x3EFu] = new("%s is not accepting gifts right now.", RetailLogTextType.Default), + [0x3F1u] = new("You failed to go to non-combat mode.", RetailLogTextType.ClientLocal), + [0x3F7u] = new("You are too fatigued to attack!", RetailLogTextType.ClientLocal), + [0x3F8u] = new("You are out of ammunition!", RetailLogTextType.ClientLocal), + [0x3F9u] = new("Your missile attack misfired!", RetailLogTextType.ClientLocal), + [0x3FAu] = new("You've attempted an impossible spell path!", RetailLogTextType.ClientLocal), + [0x3FEu] = new("You don't know that spell!", RetailLogTextType.ClientLocal), + [0x3FFu] = new("Incorrect target type", RetailLogTextType.ClientLocal), + [0x400u] = new("You don't have all the components for this spell.", RetailLogTextType.ClientLocal), + [0x401u] = new("You don't have enough Mana to cast this spell.", RetailLogTextType.ClientLocal), + [0x402u] = new("Your spell fizzled.", RetailLogTextType.Magic), + [0x403u] = new("Your spell's target is missing!", RetailLogTextType.ClientLocal), + [0x404u] = new("Your projectile spell mislaunched!", RetailLogTextType.ClientLocal), + [0x407u] = new("Your spell cannot be cast outside", RetailLogTextType.ClientLocal), + [0x40Au] = new("You are unprepared to cast a spell", RetailLogTextType.ClientLocal), + [0x40Bu] = new("You've already sworn your Allegiance", RetailLogTextType.ClientLocal), + [0x40Cu] = new("You don't have enough experience available to swear Allegiance", RetailLogTextType.ClientLocal), + [0x413u] = new("%s is already one of your followers", RetailLogTextType.ClientLocal), + [0x414u] = new("You are not in an allegiance!", RetailLogTextType.ClientLocal), + [0x416u] = new("%s cannot have any more Vassals", RetailLogTextType.ClientLocal), + [0x41Du] = new("You must be the leader of a Fellowship", RetailLogTextType.ClientLocal), + [0x41Eu] = new("Your Fellowship is full", RetailLogTextType.ClientLocal), + [0x41Fu] = new("That Fellowship name is not permitted", RetailLogTextType.ClientLocal), + [0x422u] = new("That channel doesn't exist.", RetailLogTextType.ClientLocal), + [0x423u] = new("You can't use that channel.", RetailLogTextType.ClientLocal), + [0x424u] = new("You're already on that channel.", RetailLogTextType.ClientLocal), + [0x425u] = new("You're not currently on that channel.", RetailLogTextType.ClientLocal), + [0x427u] = new("You cannot merge different stacks!", RetailLogTextType.ClientLocal), + [0x428u] = new("You cannot merge enchanted items!", RetailLogTextType.ClientLocal), + [0x429u] = new("You must control at least one stack!", RetailLogTextType.ClientLocal), + [0x432u] = new("Your craft attempt fails.", RetailLogTextType.ClientLocal), + [0x433u] = new("Your craft attempt fails.", RetailLogTextType.ClientLocal), + [0x434u] = new("Given that number of items, you cannot craft anything.", RetailLogTextType.ClientLocal), + [0x435u] = new("Your craft attempt fails.", RetailLogTextType.ClientLocal), + [0x437u] = new("Either you or one of the items involved does not pass the requirements for this craft interaction.", RetailLogTextType.ClientLocal), + [0x438u] = new("You do not have all the neccessary items.", RetailLogTextType.ClientLocal), + [0x439u] = new("Not all the items are avaliable.", RetailLogTextType.ClientLocal), + [0x43Au] = new("You must be at rest in peace mode to do trade skills.", RetailLogTextType.ClientLocal), + [0x43Bu] = new("You are not trained in that trade skill.", RetailLogTextType.ClientLocal), + [0x43Cu] = new("Your hands must be free.", RetailLogTextType.ClientLocal), + [0x43Du] = new("You cannot link to that portal!", RetailLogTextType.Magic), + [0x43Eu] = new("You have solved this quest too recently!", RetailLogTextType.Default), + [0x43Fu] = new("You have solved this quest too many times!", RetailLogTextType.Default), + [0x445u] = new("This item requires you to complete a specific quest before you can pick it up!", RetailLogTextType.Default), + [0x45Cu] = new("Player killers may not interact with that portal!", RetailLogTextType.Magic), + [0x45Du] = new("Non-player killers may not interact with that portal!", RetailLogTextType.Magic), + [0x45Eu] = new("You do not own a house!", RetailLogTextType.ClientLocal), + [0x45Fu] = new("You do not own a house!", RetailLogTextType.ClientLocal), + [0x466u] = new("You must purchase Asheron's Call -- Dark Majesty to use this function.", RetailLogTextType.Magic), + [0x469u] = new("You have used all the hooks you are allowed to use for this house.", RetailLogTextType.Default), + [0x46Au] = new("%s doesn't know what to do with that.", RetailLogTextType.Default), + [0x474u] = new("You must complete a quest to interact with that portal.", RetailLogTextType.Magic), + [0x47Fu] = new("You must own a house to use this command.", RetailLogTextType.ClientLocal), + [0x480u] = new("Your monarch does not own a mansion or a villa!", RetailLogTextType.ClientLocal), + [0x481u] = new("Your monarch does not own a mansion or a villa!", RetailLogTextType.ClientLocal), + [0x482u] = new("Your monarch has closed the mansion to the Allegiance.", RetailLogTextType.ClientLocal), + [0x488u] = new("You must be above level %s to purchase this dwelling.", RetailLogTextType.Default), + [0x489u] = new("You must be at or below level %s to purchase this dwelling.", RetailLogTextType.Default), + [0x48Bu] = new("You must be above allegiance rank %s to purchase this dwelling.", RetailLogTextType.Default), + [0x48Cu] = new("You must be at or below allegiance rank %s to purchase this dwelling.", RetailLogTextType.Default), + [0x48Eu] = new("Your offer of Allegiance has been ignored.", RetailLogTextType.ClientLocal), + [0x48Fu] = new("You are already involved in something!", RetailLogTextType.ClientLocal), + [0x490u] = new("You must be a monarch to use this command.", RetailLogTextType.ClientLocal), + [0x491u] = new("You must specify a character to boot.", RetailLogTextType.ClientLocal), + [0x492u] = new("You can't boot yourself!", RetailLogTextType.ClientLocal), + [0x493u] = new("That character does not exist.", RetailLogTextType.ClientLocal), + [0x494u] = new("That person is not a member of your Allegiance!", RetailLogTextType.ClientLocal), + [0x495u] = new("No patron from which to break!", RetailLogTextType.ClientLocal), + [0x496u] = new("Your Allegiance has been dissolved!", RetailLogTextType.Default), + [0x497u] = new("Your patron's Allegiance to you has been broken!", RetailLogTextType.Default), + [0x498u] = new("You have moved too far!", RetailLogTextType.ClientLocal), + [0x499u] = new("That is not a valid destination!", RetailLogTextType.ClientLocal), + [0x49Au] = new("You must purchase Asheron's Call -- Dark Majesty to use this function.", RetailLogTextType.ClientLocal), + [0x49Bu] = new("You fail to link with the lifestone!", RetailLogTextType.Magic), + [0x49Cu] = new("You wandered too far to link with the lifestone!", RetailLogTextType.Magic), + [0x49Du] = new("You successfully link with the lifestone!", RetailLogTextType.Magic), + [0x49Eu] = new("You must have linked with a lifestone in order to recall to it!", RetailLogTextType.Magic), + [0x49Fu] = new("You fail to recall to the lifestone!", RetailLogTextType.Magic), + [0x4A0u] = new("You fail to link with the portal!", RetailLogTextType.Magic), + [0x4A1u] = new("You successfully link with the portal!", RetailLogTextType.Magic), + [0x4A2u] = new("You fail to recall to the portal!", RetailLogTextType.Magic), + [0x4A3u] = new("You must have linked with a portal in order to summon it!", RetailLogTextType.Magic), + [0x4A4u] = new("You fail to summon the portal!", RetailLogTextType.Magic), + [0x4A5u] = new("You must have linked with a portal in order to summon it!", RetailLogTextType.Magic), + [0x4A6u] = new("You fail to teleport!", RetailLogTextType.Magic), + [0x4A7u] = new("You have been teleported too recently!", RetailLogTextType.Magic), + [0x4A8u] = new("You must be an Advocate to interact with that portal.", RetailLogTextType.Magic), + [0x4AAu] = new("Players may not interact with that portal.", RetailLogTextType.Magic), + [0x4ABu] = new("You are not powerful enough to interact with that portal!", RetailLogTextType.Magic), + [0x4ACu] = new("You are too powerful to interact with that portal!", RetailLogTextType.Magic), + [0x4ADu] = new("You cannot recall to that portal!", RetailLogTextType.Magic), + [0x4AEu] = new("You cannot summon that portal!", RetailLogTextType.Magic), + [0x4AFu] = new("The lock is already unlocked.", RetailLogTextType.ClientLocal), + [0x4B0u] = new("You can't lock or unlock that!", RetailLogTextType.ClientLocal), + [0x4B1u] = new("You can't lock or unlock what is open!", RetailLogTextType.ClientLocal), + [0x4B2u] = new("The key doesn't fit this lock.", RetailLogTextType.Default), + [0x4B3u] = new("The lock has been used too recently.", RetailLogTextType.ClientLocal), + [0x4B4u] = new("You aren't trained in lockpicking!", RetailLogTextType.ClientLocal), + [0x4B5u] = new("You must specify a character to boot.", RetailLogTextType.ClientLocal), + [0x4B6u] = new("Please use the allegiance panel to view your own information.", RetailLogTextType.ClientLocal), + [0x4B7u] = new("You have used that command too recently.", RetailLogTextType.ClientLocal), + [0x4B8u] = new("You do not own that salvage tool!", RetailLogTextType.Default), + [0x4B9u] = new("You do not own that salvage tool!", RetailLogTextType.Default), + [0x4BAu] = new("You do not own that salvage tool!", RetailLogTextType.Default), + [0x4BDu] = new("You do not own that salvage tool!", RetailLogTextType.Default), + [0x4BEu] = new("You do not own that item!", RetailLogTextType.Default), + [0x4BFu] = new("The %s was not suitable for salvaging.", RetailLogTextType.ClientLocal), + [0x4C0u] = new("The %s contains the wrong material.", RetailLogTextType.ClientLocal), + [0x4C1u] = new("The material cannot be created.", RetailLogTextType.Default), + [0x4C2u] = new("The list of items you are attempting to salvage is invalid.", RetailLogTextType.Default), + [0x4C3u] = new("You cannot salvage items that you are trading!", RetailLogTextType.Default), + [0x4C4u] = new("You must be a guest in this house to interact with that portal.", RetailLogTextType.Magic), + [0x4C5u] = new("Your Allegiance Rank is too low to use that item's magic.", RetailLogTextType.ClientLocal), + [0x4C6u] = new("You must be %s to use that item's magic.", RetailLogTextType.ClientLocal), + [0x4C7u] = new("Your Arcane Lore skill is too low to use that item's magic.", RetailLogTextType.ClientLocal), + [0x4C8u] = new("That item doesn't have enough Mana.", RetailLogTextType.ClientLocal), + [0x4C9u] = new("Your %s is too low to use that item's magic.", RetailLogTextType.ClientLocal), + [0x4CAu] = new("Only %s may use that item's magic.", RetailLogTextType.ClientLocal), + [0x4CBu] = new("You must have %s specialized to use that item's magic.", RetailLogTextType.ClientLocal), + [0x4CCu] = new("You have been involved in a player killer battle too recently to do that!", RetailLogTextType.Magic), + [0x4CEu] = new("%s is too busy to accept gifts right now.", RetailLogTextType.Default), + [0x4CFu] = new("%s cannot accept stacked objects. Try giving one at a time.", RetailLogTextType.Default), + [0x4D0u] = new("You have failed to alter your skill.", RetailLogTextType.Default), + [0x4D1u] = new("Your %s skill must be trained, not untrained or specialized, in order to be altered in this way!", RetailLogTextType.Default), + [0x4D2u] = new("You do not have enough skill credits to specialize your %s skill.", RetailLogTextType.Default), + [0x4D3u] = new("You have too many available experience points to be able to absorb the experience points from your %s skill. Please spend some of your experience points and try again.", RetailLogTextType.Default), + [0x4D4u] = new("Your %s skill is already untrained!", RetailLogTextType.Default), + [0x4D5u] = new("You are currently wielding items which require a certain level of %s. Your %s skill cannot be lowered while you are wielding these items. Please remove these items and try again.", RetailLogTextType.Default), + [0x4D6u] = new("You have succeeded in specializing your %s skill!", RetailLogTextType.Default), + [0x4D7u] = new("You have succeeded in lowering your %s skill from specialized to trained!", RetailLogTextType.Default), + [0x4D8u] = new("You have succeeded in untraining your %s skill!", RetailLogTextType.Default), + [0x4D9u] = new("Although you cannot untrain your %s skill, you have succeeded in recovering all the experience you had invested in it.", RetailLogTextType.Default), + [0x4DAu] = new("You have too many credits invested in specialized skills already! Before you can specialize your %s skill, you will need to unspecialize some other skill.", RetailLogTextType.Default), + [0x4DDu] = new("You have failed to alter your attributes.", RetailLogTextType.Default), + [0x4DEu] = new("%s", RetailLogTextType.Default), + [0x4DFu] = new("%s", RetailLogTextType.Default), + [0x4E0u] = new("You are currently wielding items which require a certain level of %s. Your %s skill cannot be lowered while you are wielding these items. Please remove these items and try again.", RetailLogTextType.Default), + [0x4E1u] = new("You have succeeded in transferring your attributes!", RetailLogTextType.Default), + [0x4E2u] = new("This hook is a duplicated housing object. You may not add items to a duplicated housing object. Please empty the hook and allow it to reset.", RetailLogTextType.Default), + [0x4E3u] = new("That item is of the wrong type to be placed on this hook.", RetailLogTextType.Default), + [0x4E4u] = new("This chest is a duplicated housing object. You may not add items to a duplicated housing object. Please empty everything -- including backpacks -- out of the chest and allow the chest to reset.", RetailLogTextType.Default), + [0x4E5u] = new("This hook was a duplicated housing object. Since it is now empty, it will be deleted momentarily. Once it is gone, it is safe to use the other, non-duplicated hook that is here.", RetailLogTextType.Default), + [0x4E6u] = new("This chest was a duplicated housing object. Since it is now empty, it will be deleted momentarily. Once it is gone, it is safe to use the other, non-duplicated chest that is here.", RetailLogTextType.Default), + [0x4E7u] = new("You cannot swear allegiance to anyone because you own a monarch-only house. Please abandon your house and try again.", RetailLogTextType.Default), + [0x4E9u] = new("The %s cannot be used while on a hook and only the owner may open the hook.", RetailLogTextType.Default), + [0x4EAu] = new("The %s can only be used while on a hook.", RetailLogTextType.Default), + [0x4EBu] = new("You can't do that while in the air!", RetailLogTextType.ClientLocal), + [0x4ECu] = new("You cannot modify your player killer status while you are recovering from a PK death.", RetailLogTextType.Default), + [0x4EDu] = new("Advocates may not change their player killer status!", RetailLogTextType.Default), + [0x4EEu] = new("Your level is too low to change your player killer status with this object.", RetailLogTextType.Default), + [0x4EFu] = new("Your level is too high to change your player killer status with this object.", RetailLogTextType.Default), + [0x4F0u] = new("You feel a harsh dissonance, and you sense that an act of killing you have committed recently is interfering with the conversion.", RetailLogTextType.Default), + [0x4F1u] = new("Bael'Zharon's power flows through you again. You are once more a player killer.", RetailLogTextType.Default), + [0x4F2u] = new("Bael'Zharon has granted you respite after your moment of weakness. You are temporarily no longer a player killer.", RetailLogTextType.Default), + [0x4F3u] = new("Lite Player Killers may not interact with that portal!", RetailLogTextType.Magic), + [0x4F4u] = new("%s fails to affect you because $s cannot affect anyone!", RetailLogTextType.Magic), + [0x4F5u] = new("%s fails to affect you because you cannot be harmed!", RetailLogTextType.Magic), + [0x4F6u] = new("%s fails to affect you because %s is not a player killer!", RetailLogTextType.Magic), + [0x4F7u] = new("%s fails to affect you because you are not the same sort of player killer as", RetailLogTextType.Magic), + // 0x4F8 deliberately excluded — see the class doc comment. + [0x4F9u] = new("%s fails to affect you across a house boundary!", RetailLogTextType.Magic), + [0x4FAu] = new("%s is an invalid target.", RetailLogTextType.Magic), + [0x4FBu] = new("You are an invalid target for the spell of %s.", RetailLogTextType.Magic), + [0x4FCu] = new("You aren't trained in healing!", RetailLogTextType.ClientLocal), + [0x4FDu] = new("You don't own that healing kit!", RetailLogTextType.ClientLocal), + [0x4FEu] = new("You can't heal that!", RetailLogTextType.ClientLocal), + [0x4FFu] = new("%s is already at full health!", RetailLogTextType.ClientLocal), + [0x500u] = new("You aren't ready to heal!", RetailLogTextType.ClientLocal), + [0x501u] = new("You can only use Healing Kits on player characters.", RetailLogTextType.ClientLocal), + [0x502u] = new("The Lifestone's magic protects you from the attack!", RetailLogTextType.Magic), + [0x503u] = new("The portal's residual energy protects you from the attack!", RetailLogTextType.Magic), + [0x504u] = new("You are enveloped in a feeling of warmth as you are brought back into the protection of the Light. You are once again a Non-Player Killer.", RetailLogTextType.Default), + [0x505u] = new("You're too close to your sanctuary!", RetailLogTextType.ClientLocal), + [0x506u] = new("You can't do that -- you're trading!", RetailLogTextType.ClientLocal), + [0x507u] = new("Only Non-Player Killers may enter PK Lite. Please see @help pklite for more details about this command.", RetailLogTextType.Default), + [0x508u] = new("A cold wind touches your heart. You are now a Player Killer Lite.", RetailLogTextType.Default), + [0x509u] = new("%s has no appropriate targets equipped for this spell.", RetailLogTextType.Magic), + [0x50Au] = new("You have no appropriate targets equipped for %s's spell.", RetailLogTextType.Magic), + [0x50Bu] = new("%s is now an open fellowship; anyone may recruit new members.", RetailLogTextType.Default), + [0x50Cu] = new("%s is now a closed fellowship.", RetailLogTextType.Default), + [0x50Du] = new("%s is now the leader of this fellowship.", RetailLogTextType.Default), + [0x50Eu] = new("You have passed leadership of the fellowship to %s", RetailLogTextType.Default), + [0x50Fu] = new("You do not belong to a Fellowship.", RetailLogTextType.ClientLocal), + [0x510u] = new("You may not hook any more %s on your house. You already have the maximum number of %s hooked or you are not permitted to hook any on your type of house.", RetailLogTextType.Default), + [0x512u] = new("You are now using the maximum number of hooks. You cannot use another hook until you take an item off one of your hooks.", RetailLogTextType.Default), + [0x513u] = new("You are no longer using the maximum number of hooks. You may again add items to your hooks.", RetailLogTextType.Default), + [0x514u] = new("You now have the maximum number of %s hooked. You cannot hook any additional %s until you remove one or more from your house.", RetailLogTextType.Default), + [0x515u] = new("You no longer have the maximum number of %s hooked. You may hook additional %s.", RetailLogTextType.Default), + [0x516u] = new("You are not permitted to use that hook.", RetailLogTextType.Default), + [0x517u] = new("%s is not close enough to your level.", RetailLogTextType.Default), + [0x518u] = new("%s cannot be recruited into the fellowship.", RetailLogTextType.Default), + [0x519u] = new("The fellowship is locked, you were not added to the fellowship.", RetailLogTextType.Default), + [0x51Au] = new("Only the original owner may use that item's magic.", RetailLogTextType.ClientLocal), + [0x51Bu] = new("You have entered the %s channel.", RetailLogTextType.Default), + [0x51Cu] = new("You have left the %s channel.", RetailLogTextType.Default), + [0x51Eu] = new("%s will not receive your message, please use urgent assistance to speak with an in-game representative", RetailLogTextType.Default), + [0x51Fu] = new("Message Blocked: %s", RetailLogTextType.ClientLocal), + [0x520u] = new("You cannot add anymore people to the list of players that you can hear.", RetailLogTextType.Default), + [0x521u] = new("%s has been added to the list of people you can hear.", RetailLogTextType.Default), + [0x522u] = new("%s has been removed from the list of people you can hear.", RetailLogTextType.Default), + [0x523u] = new("You are now deaf to player's screams.", RetailLogTextType.Default), + [0x524u] = new("You can hear all players once again.", RetailLogTextType.Default), + [0x525u] = new("You fail to remove %s from your loud list.", RetailLogTextType.Default), + [0x526u] = new("You chicken out.", RetailLogTextType.ClientLocal), + [0x527u] = new("You cannot posssibly succeed.", RetailLogTextType.ClientLocal), + [0x528u] = new("The fellowship is locked; you cannot open locked fellowships.", RetailLogTextType.Default), + [0x529u] = new("Trade Complete!", RetailLogTextType.ClientLocal), + [0x52Au] = new("That is not a salvaging tool.", RetailLogTextType.ClientLocal), + [0x52Bu] = new("That person is not available now.", RetailLogTextType.ClientLocal), + [0x52Cu] = new("You are now snooping on %s.", RetailLogTextType.Default), + [0x52Du] = new("You are no longer snooping on %s.", RetailLogTextType.Default), + [0x52Eu] = new("You fail to snoop on %s.", RetailLogTextType.Default), + [0x52Fu] = new("%s attempted to snoop on you.", RetailLogTextType.Default), + [0x530u] = new("%s is already being snooped on, only one person may snoop on another at a time.", RetailLogTextType.Default), + [0x531u] = new("%s is in limbo and cannot receive your message.", RetailLogTextType.Default), + [0x532u] = new("You must wait 30 days after purchasing a house before you may purchase another with any character on the same account. This applies to all housing except apartments.", RetailLogTextType.Default), + [0x533u] = new("You have been booted from your allegiance chat room. Use \"@allegiance chat on\" to rejoin. (%s).", RetailLogTextType.Default), + [0x534u] = new("%s has been booted from the allegiance chat room.", RetailLogTextType.Default), + [0x535u] = new("You do not have the authority within your allegiance to do that.", RetailLogTextType.Default), + [0x536u] = new("The account of %s is already banned from the allegiance.", RetailLogTextType.Default), + [0x537u] = new("The account of %s is not banned from the allegiance.", RetailLogTextType.Default), + [0x538u] = new("The account of %s was not unbanned from the allegiance.", RetailLogTextType.Default), + [0x539u] = new("The account of %s has been banned from the allegiance.", RetailLogTextType.Default), + [0x53Au] = new("The account of %s is no longer banned from the allegiance.", RetailLogTextType.Default), + [0x53Bu] = new("Banned Characters:", RetailLogTextType.Default), + [0x53Eu] = new("%s is banned from the allegiance!", RetailLogTextType.Default), + [0x53Fu] = new("You are banned from %s's allegiance!", RetailLogTextType.Default), + [0x540u] = new("You have the maximum number of accounts banned.!", RetailLogTextType.Default), + [0x541u] = new("%s is now an allegiance officer.", RetailLogTextType.Default), + [0x542u] = new("An unspecified error occurred while attempting to set %s as an allegiance officer.", RetailLogTextType.Default), + [0x543u] = new("%s is no longer an allegiance officer.", RetailLogTextType.Default), + [0x544u] = new("An unspecified error occurred while attempting to set %s as an allegiance officer.", RetailLogTextType.Default), + [0x545u] = new("You already have the maximum number of allegiance officers. You must remove some before you add any more.", RetailLogTextType.Default), + [0x546u] = new("Your allegiance officers have been cleared.", RetailLogTextType.Default), + [0x547u] = new("You must wait %s before communicating again!", RetailLogTextType.Default), + [0x548u] = new("You cannot join any chat channels while gagged.", RetailLogTextType.Default), + [0x549u] = new("Your allegiance officer status has been modified. You now hold the position of: %s.", RetailLogTextType.Default), + [0x54Au] = new("You are no longer an allegiance officer.", RetailLogTextType.Default), + [0x54Bu] = new("%s is already an allegiance officer of that level.", RetailLogTextType.Default), + [0x54Cu] = new("Your allegiance does not have a hometown.", RetailLogTextType.Default), + [0x54Du] = new("The %s is currently in use.", RetailLogTextType.ClientLocal), + [0x54Eu] = new("The hook does not contain a usable item. Use the '@house hooks on'command to make the hook openable.", RetailLogTextType.Default), + [0x54Fu] = new("The hook does not contain a usable item. Use the '@house hooks on'command to make the hook openable.", RetailLogTextType.Default), + [0x550u] = new("Out of Range!", RetailLogTextType.ClientLocal), + [0x551u] = new("You are not listening to the %s channel!", RetailLogTextType.Default), + [0x552u] = new("You must purchase Asheron's Call -- Dark Majesty to use this function.", RetailLogTextType.ClientLocal), + [0x553u] = new("You must purchase Asheron's Call -- Dark Majesty to use this function.", RetailLogTextType.ClientLocal), + [0x554u] = new("You must purchase Asheron's Call -- Dark Majesty to use this function.", RetailLogTextType.ClientLocal), + [0x555u] = new("You must purchase Asheron's Call -- Dark Majesty to use this function.", RetailLogTextType.ClientLocal), + [0x556u] = new("You have failed to complete the augmentation.", RetailLogTextType.Default), + [0x557u] = new("You have used this augmentation too many times already.", RetailLogTextType.Default), + [0x558u] = new("You have used augmentations of this type too many times already.", RetailLogTextType.Default), + [0x559u] = new("You do not have enough unspent experience available to purchase this augmentation.", RetailLogTextType.Default), + [0x55Au] = new("%s", RetailLogTextType.Default), + [0x55Bu] = new("Congratulations! You have succeeded in acquiring the %s augmentation.", RetailLogTextType.Default), + [0x55Cu] = new("Although your augmentation will not allow you to untrain your %s skill, you have succeeded in recovering all the experience you had invested in it.", RetailLogTextType.Default), + [0x55Du] = new("You must exit the Training Academy before that command will be available to you.", RetailLogTextType.Default), + [0x55Eu] = new("%s", RetailLogTextType.Default), + [0x55Fu] = new("Only Player Killer characters may use this command!", RetailLogTextType.Default), + [0x560u] = new("Only Player Killer Lite characters may use this command!", RetailLogTextType.Default), + [0x561u] = new("You may only have a maximum of 50 friends at once. If you wish to add more friends, you must first remove some.", RetailLogTextType.ClientLocal), + [0x562u] = new("%s is already on your friends list!", RetailLogTextType.Default), + [0x563u] = new("That character is not on your friends list!", RetailLogTextType.Default), + [0x564u] = new("Only the character who owns the house may use this command.", RetailLogTextType.Default), + [0x565u] = new("That allegiance name is invalid because it is empty. Please use the @allegiance name clear command to clear your allegiance name.", RetailLogTextType.Default), + [0x566u] = new("That allegiance name is too long. Please choose another name.", RetailLogTextType.Default), + [0x567u] = new("That allegiance name contains illegal characters. Please choose another name using only letters, spaces, - and '.", RetailLogTextType.Default), + [0x568u] = new("That allegiance name is not appropriate. Please choose another name.", RetailLogTextType.Default), + [0x569u] = new("That allegiance name is already in use. Please choose another name.", RetailLogTextType.Default), + [0x56Au] = new("You may only change your allegiance name once every 24 hours. You may change your allegiance name again in %s.", RetailLogTextType.Default), + [0x56Bu] = new("Your allegiance name has been cleared.", RetailLogTextType.Default), + [0x56Cu] = new("That is already the name of your allegiance!", RetailLogTextType.Default), + [0x56Du] = new("%s is the monarch and cannot be promoted or demoted.", RetailLogTextType.Default), + [0x56Eu] = new("That level of allegiance officer is now known as: %s.", RetailLogTextType.Default), + [0x56Fu] = new("That is an invalid officer level.", RetailLogTextType.Default), + [0x570u] = new("That allegiance officer title is not appropriate.", RetailLogTextType.Default), + [0x571u] = new("That allegiance name is too long. Please choose another name.", RetailLogTextType.Default), + [0x572u] = new("All of your allegiance officer titles have been cleared.", RetailLogTextType.Default), + [0x573u] = new("That allegiance title contains illegal characters. Please choose another name using only letters, spaces, - and '.", RetailLogTextType.Default), + [0x574u] = new("Your allegiance is currently: %s.", RetailLogTextType.Default), + [0x575u] = new("Your allegiance is now: %s.", RetailLogTextType.Default), + [0x576u] = new("You may not accept the offer of allegiance from %s because your allegiance is locked.", RetailLogTextType.Default), + [0x577u] = new("You may not swear allegiance at this time because the allegiance of %s is locked.", RetailLogTextType.Default), + [0x578u] = new("You have pre-approved %s to join your allegiance.", RetailLogTextType.Default), + [0x579u] = new("You have not pre-approved any vassals to join your allegiance.", RetailLogTextType.Default), + [0x57Au] = new("%s is already a member of your allegiance!", RetailLogTextType.Default), + [0x57Bu] = new("%s has been pre-approved to join your allegiance.", RetailLogTextType.Default), + [0x57Cu] = new("You have cleared the pre-approved vassal for your allegiance.", RetailLogTextType.Default), + [0x57Du] = new("That character is already gagged!", RetailLogTextType.Default), + [0x57Eu] = new("That character is not currently gagged!", RetailLogTextType.Default), + [0x57Fu] = new("Your allegiance chat privileges have been restored.", RetailLogTextType.Default), + [0x580u] = new("%s is now temporarily unable to view or speak in allegiance chat. The gag will run out in 5 minutes, or %s may be explicitly ungagged before then.", RetailLogTextType.Default), + [0x581u] = new("Your allegiance chat privileges have been restored.", RetailLogTextType.Default), + [0x582u] = new("Your allegiance chat privileges have been restored.", RetailLogTextType.Default), + [0x583u] = new("You have restored allegiance chat privileges to %s.", RetailLogTextType.Default), + [0x584u] = new("You cannot pick up more of that item!", RetailLogTextType.ClientLocal), + [0x585u] = new("You are restricted to clothes and armor created for your race.", RetailLogTextType.ClientLocal), + [0x586u] = new("That item was specifically created for another race.", RetailLogTextType.ClientLocal), + [0x587u] = new("Olthoi cannot interact with that!", RetailLogTextType.Magic), + [0x588u] = new("Olthoi cannot use regular lifestones! Asheron would not allow it!", RetailLogTextType.Magic), + [0x589u] = new("The vendor looks at you in horror!", RetailLogTextType.Magic), + [0x58Au] = new("%s cowers from you!", RetailLogTextType.Default), + [0x58Bu] = new("As a mindless engine of destruction an Olthoi cannot join a fellowship!", RetailLogTextType.Magic), + [0x58Cu] = new("The Olthoi only have an allegiance to the Olthoi Queen!", RetailLogTextType.Magic), + [0x58Du] = new("You cannot use that item!", RetailLogTextType.Magic), + [0x58Eu] = new("This person will not interact with you!", RetailLogTextType.Magic), + [0x58Fu] = new("Only Olthoi may pass through this portal!", RetailLogTextType.Magic), + [0x590u] = new("Olthoi may not pass through this portal!", RetailLogTextType.Magic), + [0x591u] = new("You may not pass through this portal while Vitae weakens you!", RetailLogTextType.Magic), + [0x592u] = new("This character must be two weeks old or have been created on an account at least two weeks old to use this portal!", RetailLogTextType.Magic), + [0x593u] = new("Olthoi characters can only use Lifestone and PK Arena recalls!", RetailLogTextType.Magic), }; } diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 23623cc0..9c2ece5b 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -732,7 +732,8 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime.CommunicationOwner.Chat, Runtime.CommunicationOwner.TurbineChat, Runtime.CommunicationOwner.Friends, - Runtime.CommunicationOwner.Squelch)); + Runtime.CommunicationOwner.Squelch, + (text, type) => Runtime.CommunicationOwner.AddText(text, type))); var eventRoute = new HeadlessSessionEventRoute( route, Runtime, diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index c5f742e3..fef9ffbe 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -212,6 +212,14 @@ public sealed class GameRuntime faultInjection); context.Movement = new RuntimeLocalPlayerMovementState(); + // Campaign CH slice CH2: local jump refusals (CommenceJump/ + // DoJump's WeenieError family — research doc §4.2/§6.4) reach + // the SpewBox through the SAME AddText router server-sent + // WeenieErrors use. Wired here, at construction, because + // Communication (built just above) always exists before any + // PlayerMovementController is installed. + context.Movement.OnInterfaceText = + (text, type) => context.Communication.AddText(text, type); construction.Own(context.Movement); Fault( GameRuntimeConstructionPoint.MovementCreated, diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index fd9474a6..6e9ba1e8 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -1,5 +1,6 @@ using System; using System.Numerics; +using AcDream.Core.Chat; using AcDream.Core.Physics; namespace AcDream.Runtime.Gameplay; @@ -166,6 +167,18 @@ public sealed class PlayerMovementController private uint _localEntityId; private AcDream.Core.Physics.Motion.PositionManager? _positionManager; + /// + /// Campaign CH slice CH2: reports a client-locally-detected jump refusal + /// (retail's CommenceJump @0x0056AF90 / DoJump @0x0056B110 + /// WeenieError family — research doc §4.2/§6.4) to the SpewBox router. + /// Wired by RuntimeLocalPlayerMovementState, whose + /// OnInterfaceText setter applies to every controller it installs + /// (including this one, at commit time). Never invoked directly for the + /// jump physics/charge behaviour itself — this is a REPORT of an + /// already-decided refusal, not a gate. + /// + public Action? OnInterfaceText { get; set; } + /// /// Maximum Z increase per movement step before the move is rejected. /// @@ -926,6 +939,41 @@ public sealed class PlayerMovementController "A sealed, retired, or discarded Runtime movement controller cannot be mutated."); } + /// + /// Campaign CH slice CH2: retail-exact CommenceJump/DoJump + /// refusal-text dispatch (research doc §4.2, verified directly against + /// the decomp — NOT the HandleFailureEvent/WeenieErrorMessages + /// table, which is a DIFFERENT switch that happens to share three of + /// these same string globals). + /// ClientCombatSystem::CommenceJump @0x0056AF90 and + /// ClientCombatSystem::DoJump @0x0056B110 each explicitly handle + /// only 0x24/0x48/0x49; every other code — + /// including 0x47 GeneralMovementFailure (fully constrained or + /// can't afford the jump's stamina cost, ) + /// and 0x08 NoPhysicsObject — falls through their dispatch with + /// NO AddTextToScroll call at all. Confirmed via DoJump's + /// compiled switch(eax_7) (raw 376288-376312): exactly 4 real + /// case targets (0, 0x24, 0x48, 0x49), every + /// other index routed to the function's silent fall-through end. A + /// fully-constrained or out-of-stamina jump refusal is therefore + /// SILENT in retail — no on-screen text, the player just doesn't jump. + /// + private void ReportJumpRefusal(WeenieError result) + { + if (OnInterfaceText is null) + return; + + string? text = result switch + { + WeenieError.NotGrounded => ClientTextRefusals.CantJumpInAir, // 0x24 + WeenieError.YouCantJumpFromThisPosition => ClientTextRefusals.CantJumpPosition, // 0x48 + WeenieError.CantJumpLoadedDown => ClientTextRefusals.CantJumpLoad, // 0x49 + _ => null, + }; + if (text is not null) + OnInterfaceText(text, RetailLogTextType.ClientLocal); + } + private void EnsurePublishedForRuntimeOperation() { if (_publicationLifecycle @@ -2470,7 +2518,13 @@ public sealed class PlayerMovementController // place StandingLongJump arms (grounded + Ready + no // sidestep/turn). Never called by production code before // this line despite the W3 port. - _motion.ChargeJump(); + // + // Campaign CH slice CH2: the return value used to be + // discarded — a refused charge (CantJumpLoadedDown / + // YouCantJumpFromThisPosition) silently drained the power + // bar with no explanation. Report it exactly as + // ClientCombatSystem::CommenceJump @0x0056AF90 does. + ReportJumpRefusal(_motion.ChargeJump()); } float chargeRate = _motion.InterpretedState.CurrentStyle == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle @@ -2509,6 +2563,14 @@ public sealed class PlayerMovementController // sent in JumpAction — local + remote stay in sync. _body.set_local_velocity(outJumpVelocity.Value, autonomous: true); } + else + { + // Campaign CH slice CH2: a refused fire used to reset the + // charge state and return silently — the power bar drained + // and nothing happened, with no explanation. Report it + // exactly as ClientCombatSystem::DoJump @0x0056B110 does. + ReportJumpRefusal(jumpResult); + } _jumpCharging = false; _jumpExtent = 0f; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs index a9a122a9..01372dab 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs @@ -60,6 +60,7 @@ public sealed class RuntimeCommunicationState : IDisposable public RuntimeCommunicationState(int maximumChatEntries = 500) { Chat = new ChatLog(maximumChatEntries); + SpewBox = new SpewBoxState(); CommandTargets = new ChatCommandTargetState(Chat); _events = new RuntimeCommunicationEventStream(Chat); TurbineChat = new TurbineChatState(); @@ -73,6 +74,17 @@ public sealed class RuntimeCommunicationState : IDisposable } public ChatLog Chat { get; } + + /// + /// Campaign CH slice CH2: retail's transient on-screen "interface text" + /// queue (gmSpewBoxUI) — the SECOND sink + /// can route to. Never written to directly by producers; always through + /// so the type == ClientLocal routing rule + /// stays centralized at one chokepoint, matching retail's + /// ClientSystem::AddTextToScroll. + /// + public SpewBoxState SpewBox { get; } + public ChatCommandTargetState CommandTargets { get; } public TurbineChatState TurbineChat { get; } public FriendsState Friends { get; } @@ -114,6 +126,62 @@ public sealed class RuntimeCommunicationState : IDisposable public void ResetFriends() => Friends.Clear(); public void ResetSquelch() => Squelch.Clear(); + /// + /// Campaign CH slice CH2: SpewBox lines are purely transient screen + /// flash — unlike the chat transcript (which survives a reconnect via + /// 's preserve-content reset), a fresh + /// generation must not resurrect a stale refusal line. + /// + public void ResetSpewBox() => SpewBox.Reset(); + + /// + /// The single chokepoint every producer of player-visible interface + /// text must go through — the direct analogue of retail's + /// ClientSystem::AddTextToScroll(text, type, allowPluginFilter, + /// windowId) @0x00563C50. + /// + /// + /// Retail's routing rule (research doc §2.1) is a receiver-side type + /// filter, not a sender-side destination switch: + /// + /// type == RetailLogTextType.ClientLocal (0x1A) → the + /// SpewBox ONLY. Every ChatInterface window is born with that + /// exact bit cleared from its type filter + /// (ChatInterface::ChatInterface @0x004F4550, + /// m_llTextTypeFilter &= 0xFBFFFFFF) — 0x1A is + /// precisely what every chat window refuses. Retail also skips the + /// timestamp prefix and the chat-log-file write for this type + /// (§2.1 step 4); since this branch never touches + /// at all, that behaviour falls out for free. + /// + /// Every other type → the existing chat transcript, tagged + /// with exactly as before. + /// + /// is accepted for future parity with + /// retail's per-window echo (a non-zero window ID lands in both the + /// SpewBox AND that specific chat window — research doc §2.3) but is + /// not yet consumed; every current production caller passes the + /// default 0. + /// + public void AddText(string text, RetailLogTextType type, uint windowId = 0) + { + ArgumentNullException.ThrowIfNull(text); + + // Retail's own first step (0x00563C50): trim trailing whitespace + // before anything else, regardless of destination. + text = text.TrimEnd(); + if (text.Length == 0) + return; + + if (type == RetailLogTextType.ClientLocal) + { + SpewBox.Enqueue(text); + return; + } + + Chat.OnSystemMessage(text, (uint)type); + } + public void Dispose() { if (_disposed) @@ -126,6 +194,7 @@ public sealed class RuntimeCommunicationState : IDisposable Friends.Clear(); Squelch.Clear(); Chat.ResetSessionIdentity(); + SpewBox.Reset(); } private sealed class CommunicationView(ChatLog chat) : IRuntimeChatView diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index 89f00213..464f5b9a 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Physics; @@ -97,6 +98,26 @@ public sealed class RuntimeLocalPlayerMovementState private MovementInput _commandInput; private bool _disposed; private long _revision; + private Action? _onInterfaceText; + + /// + /// Campaign CH slice CH2: the retail-faithful text/type router + /// (RuntimeCommunicationState.AddText) — applied to every + /// controller this owner installs, past and future, so a client-local + /// jump refusal (PlayerMovementController's local + /// ChargeJump/jump calls, research doc §4.2/§6.4) reaches + /// the SpewBox through the same chokepoint server-sent WeenieErrors use. + /// + public Action? OnInterfaceText + { + get => _onInterfaceText; + set + { + _onInterfaceText = value; + if (_controller is not null) + _controller.OnInterfaceText = value; + } + } public PlayerMovementController? Controller { @@ -114,6 +135,8 @@ public sealed class RuntimeLocalPlayerMovementState return; _controller?.RetireRuntimePublication(); _controller = value; + if (_controller is not null) + _controller.OnInterfaceText = _onInterfaceText; ControllerOwnershipEpoch++; Interlocked.Increment(ref _revision); } diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 8e9fc6fa..84fefa01 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -63,7 +63,13 @@ public sealed record LiveSocialSessionBindings( ChatLog Chat, TurbineChatState TurbineChat, FriendsState? Friends, - SquelchState? Squelch); + SquelchState? Squelch, + // Campaign CH slice CH2: the retail-faithful text/type router + // (RuntimeCommunicationState.AddText). Optional/nullable so every + // existing caller (including tests that build a bare ChatLog with no + // owning RuntimeCommunicationState) compiles unchanged; GameEventWiring + // falls back to its pre-CH2 chat-only behavior when this is null. + Action? AddText = null); /// /// Owns every inbound subscription for one exact live session. Domain state @@ -195,6 +201,7 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting clientTime: character.ClientTime, externalContainers: inventory.ExternalContainers, vendor: inventory.Vendor, + onInterfaceText: social.AddText, accepting: IsAccepting)); ConstructionCheckpoint(); @@ -267,10 +274,21 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting // raw wire word straight into AddTextToScroll with zero // remapping (research doc §3.3 / HearSpeech.cs doc). speech.ChatType)); + // 0xF7E0 ServerMessage — Campaign CH slice CH2: routed through + // AddText with the wire chatType verbatim, matching retail's + // Handle_Communication__TextboxString @0x0057D3A0 + // (AddTextToScroll(text, wireChatType, 1, 0) — the wire type + // decides chat vs SpewBox, exactly like every other producer). Subscribe( h => session.ServerMessageReceived += h, h => session.ServerMessageReceived -= h, - message => social.Chat.OnSystemMessage(message.Message, message.ChatType)); + message => + { + if (social.AddText is { } addText) + addText(message.Message, (RetailLogTextType)message.ChatType); + else + social.Chat.OnSystemMessage(message.Message, message.ChatType); + }); Subscribe(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote => social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid)); Subscribe(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote => diff --git a/src/AcDream.UI.Abstractions/Panels/SpewBox/SpewBoxVM.cs b/src/AcDream.UI.Abstractions/Panels/SpewBox/SpewBoxVM.cs new file mode 100644 index 00000000..208c2b7b --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/SpewBox/SpewBoxVM.cs @@ -0,0 +1,59 @@ +using AcDream.Core.Chat; + +namespace AcDream.UI.Abstractions.Panels.SpewBox; + +/// +/// One SpewBox display line plus its remaining lifetime at the moment +/// was called. +/// +public readonly record struct SpewBoxLine(string Text, double RemainingLifetimeSeconds); + +/// +/// ViewModel for the SpewBox panel. Reads and +/// projects its visible entries into ordered display lines, mirroring +/// ChatVM's wrap of (both are Core-layer state +/// so this stays inside Code Structure Rule 3 — panels/ViewModels target +/// AcDream.UI.Abstractions, which references AcDream.Core but +/// not AcDream.Runtime). +/// +public sealed class SpewBoxVM +{ + private readonly SpewBoxState _state; + + public SpewBoxVM(SpewBoxState state) + { + _state = state ?? throw new ArgumentNullException(nameof(state)); + } + + /// Monotonic revision of the underlying visible-line set. + public long Revision => _state.Revision; + + /// True while at least one line is currently visible. + public bool HasVisibleLines => _state.Count > 0; + + /// + /// Ordered visible lines (newest first, matching retail's + /// InsertItem(item, 0)) with each entry's remaining lifetime at + /// . + /// + /// + /// Drains the pending queue and prunes expired entries as a side + /// effect — this call IS gmSpewBoxUI::Update's once-per-tick + /// drain; callers should invoke it once per presentation frame. + /// + public IReadOnlyList Lines(double nowSeconds) + { + _state.Tick(nowSeconds); + SpewBoxEntry[] snapshot = _state.Snapshot(); + if (snapshot.Length == 0) + return Array.Empty(); + + var lines = new SpewBoxLine[snapshot.Length]; + for (int i = 0; i < snapshot.Length; i++) + { + double remaining = Math.Max(0d, snapshot[i].ExpiresAtSeconds - nowSeconds); + lines[i] = new SpewBoxLine(snapshot[i].Text, remaining); + } + return lines; + } +} diff --git a/tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs b/tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs new file mode 100644 index 00000000..c1f35559 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/SpewBoxControllerTests.cs @@ -0,0 +1,46 @@ +using AcDream.App.UI; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.SpewBox; + +namespace AcDream.App.Tests.UI; + +public sealed class SpewBoxControllerTests +{ + [Fact] + public void Controller_IsClickThroughOverlayAndDisposesFromRetainedRoot() + { + var root = new UiRoot + { + Width = 1280f, + Height = 720f, + }; + var state = new SpewBoxState(); + + using (var controller = new SpewBoxController(root, new SpewBoxVM(state))) + { + UiText text = Assert.IsType(Assert.Single(root.Children)); + Assert.False(text.Visible); + Assert.True(text.ClickThrough); + Assert.Equal(int.MaxValue, text.ZOrder); + + state.Enqueue("You can't jump while in the air"); + + UiText.Line line = Assert.Single(text.LinesProvider!()); + Assert.Equal("You can't jump while in the air", line.Text); + Assert.True(text.Visible); + } + + Assert.Empty(root.Children); + } + + [Fact] + public void Controller_NoContent_LeavesTextHiddenWithEmptyLines() + { + var root = new UiRoot { Width = 1280f, Height = 720f }; + using var controller = new SpewBoxController(root, new SpewBoxVM(new SpewBoxState())); + + UiText text = Assert.IsType(Assert.Single(root.Children)); + Assert.Empty(text.LinesProvider!()); + Assert.False(text.Visible); + } +} diff --git a/tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs b/tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs new file mode 100644 index 00000000..6970fc9b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign CH slice CH2, task C.7: a one-shot diagnostic sweep of every +/// installed for an element of retail's SpewBox +/// class (0x10000016gmSpewBoxUI::GetUIElementType @0x004D5AA0). +/// Not a gate/conformance test — just a discovery tool, per the research +/// doc's §8.1/§8.2 open question. Skips (does not fail) when the installed +/// DAT directory is unavailable, matching every other +/// ContentConformanceDats.ResolveDatDir()-gated diagnostic in this +/// tree. +/// +/// +/// RESULT (2026-08-09, run against the installed client_portal.dat): +/// the installed DAT's entire LayoutDesc id space is the CLOSED range +/// 0x21000000-0x21000075 (118 possible ids, 101 populated — +/// sanity-checked by confirming 3 independently-known ids, 0x2100002E +/// character window / 0x21000023 inventory / 0x21000016 toolbar prototype, +/// are all present). The sweep is therefore EXHAUSTIVE, and it finds +/// ZERO elements of class 0x10000016 anywhere in any of the +/// 101 layouts, and LayoutDesc 0x10000012 — the id the decompiled +/// CreateChildElementByEnum(null, 0x10000012, 0x1000004A) call +/// appears to reference — exists but has zero top-level elements (it is +/// not the per-line template catalog; that id must resolve through a +/// different mechanism than a direct dats.Get<LayoutDesc> hit, +/// which this slice did not crack). Conclusion: gmSpewBoxUI is +/// mounted directly from C++ code in gmClient's HUD registration +/// block (research doc §1.1) rather than resolved from any authored +/// LayoutDesc tree — its position/extent/font/color/max-items are NOT +/// recoverable via this dump technique. Every value the SpewBox +/// presentation uses below is therefore an invented placeholder with its +/// own divergence-register row, exactly as the research doc's §3.2 +/// PRESENTATION-UNKNOWN section predicted. +/// +/// +public sealed class SpewBoxLayoutDumpDiagnostic +{ + private readonly ITestOutputHelper _out; + public SpewBoxLayoutDumpDiagnostic(ITestOutputHelper output) => _out = output; + + private const uint SpewBoxElementClass = 0x10000016u; + private const uint ListBoxElementClass = 0x10000049u; + private const uint LineTemplateLayoutEnum = 0x10000012u; + private const uint LineTemplateElementId = 0x1000004Au; + private const uint ListBoxMaxItemsProperty = 0x10000028u; + + private static string? ResolveDatDir() + { + var fromEnv = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && System.IO.Directory.Exists(fromEnv)) + return fromEnv; + var def = System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return System.IO.Directory.Exists(def) ? def : null; + } + + [Fact] + public void SweepInstalledLayoutDescs_ForSpewBoxElementClass() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: installed retail DAT directory is unavailable."); + return; + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + int scanned = 0; + var hits = new List<(uint LayoutId, ElementDesc Element)>(); + var allIds = dats.GetAllIdsOfType().ToList(); + _out.WriteLine($"Portal.GetAllIdsOfType count: {dats.Portal.GetAllIdsOfType().Count()}"); + _out.WriteLine($"HighRes.GetAllIdsOfType count: {dats.HighRes.GetAllIdsOfType().Count()}"); + _out.WriteLine($"Contains known char-window 0x2100002E: {allIds.Contains(0x2100002Eu)}"); + _out.WriteLine($"Contains known inventory 0x21000023: {allIds.Contains(0x21000023u)}"); + _out.WriteLine($"Contains known toolbar 0x21000016: {allIds.Contains(0x21000016u)}"); + _out.WriteLine($"Min id: 0x{allIds.Min():X8} Max id: 0x{allIds.Max():X8}"); + + foreach (uint layoutId in allIds) + { + scanned++; + if (!dats.Portal.TryGet(layoutId, out LayoutDesc? ld) || ld is null) + continue; + + foreach (var kv in ld.Elements) + { + var found = FindByType(kv.Value, SpewBoxElementClass); + if (found is not null) + hits.Add((layoutId, found)); + } + } + + _out.WriteLine($"Scanned {scanned} installed LayoutDescs."); + _out.WriteLine($"Elements of class 0x{SpewBoxElementClass:X8} (gmSpewBoxUI): {hits.Count}"); + + foreach (var (layoutId, element) in hits) + { + _out.WriteLine( + $" LayoutDesc 0x{layoutId:X8} -> element 0x{element.ElementId:X8} " + + $"pos=({element.X},{element.Y}) size=({element.Width}x{element.Height}) " + + $"zLevel={element.ZLevel} readOrder={element.ReadOrder} " + + $"children={element.Children.Count}"); + + var listBox = FindByType(element, ListBoxElementClass); + if (listBox is not null) + { + _out.WriteLine( + $" ListBox child 0x{listBox.ElementId:X8} " + + $"pos=({listBox.X},{listBox.Y}) size=({listBox.Width}x{listBox.Height})"); + DumpProperties(listBox, " "); + } + else + { + _out.WriteLine(" (no ListBox child of class 0x10000049 found)"); + } + } + + // Also probe the presumed shared line-template catalog directly — + // gmSpewBoxUI::Update's CreateChildElementByEnum(null, 0x10000012, + // 0x1000004A) call implies LayoutDesc 0x10000012 hosts a template + // catalog with a top-level entry 0x1000004A. + if (dats.Portal.TryGet(LineTemplateLayoutEnum, out LayoutDesc? templateLd) + && templateLd is not null) + { + _out.WriteLine($"LayoutDesc 0x{LineTemplateLayoutEnum:X8} exists ({templateLd.Elements.Count} top-level elements)."); + if (templateLd.Elements.TryGetValue(LineTemplateElementId, out ElementDesc? lineTemplate)) + { + _out.WriteLine( + $" Line template 0x{LineTemplateElementId:X8}: type=0x{lineTemplate.Type:X8} " + + $"pos=({lineTemplate.X},{lineTemplate.Y}) size=({lineTemplate.Width}x{lineTemplate.Height})"); + DumpProperties(lineTemplate, " "); + } + else + { + _out.WriteLine($" No top-level element 0x{LineTemplateElementId:X8} in that LayoutDesc."); + } + } + else + { + _out.WriteLine($"LayoutDesc 0x{LineTemplateLayoutEnum:X8} does not exist in the installed DAT."); + } + + // Informational only — this is a discovery sweep, not a pass/fail gate. + // The findings are transcribed into WeenieErrorMessages/SpewBoxController + // doc comments and the divergence register by hand after reading this + // output; there is nothing here worth asserting on. + } + + private static ElementDesc? FindByType(ElementDesc d, uint type) + { + if (d.Type == type) + return d; + foreach (var kv in d.Children) + { + var found = FindByType(kv.Value, type); + if (found is not null) + return found; + } + return null; + } + + private static void DumpProperties(ElementDesc d, string indent) + { + if (d.StateDesc?.Properties is null) + { + System.Console.WriteLine($"{indent}(no direct-state properties)"); + return; + } + + foreach (var (propertyId, property) in d.StateDesc.Properties) + { + System.Console.WriteLine($"{indent}property 0x{propertyId:X8} = {Describe(property)}" + + (propertyId == ListBoxMaxItemsProperty ? " <-- MaxConcurrentItems" : "")); + } + } + + private static string Describe(object property) => property switch + { + DatReaderWriter.Types.EnumBaseProperty e => $"Enum({e.Value})", + DatReaderWriter.Types.DataIdBaseProperty did => $"DataId(0x{did.Value:X8})", + DatReaderWriter.Types.ArrayBaseProperty arr => $"Array[{arr.Value.Count}]({string.Join(", ", arr.Value.Select(Describe))})", + _ => property.ToString() ?? "?", + }; +} diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 0eaa3e36..ecbe753a 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -758,6 +758,162 @@ public sealed class GameEventWiringTests Assert.Contains("Mana Stone", e.Text); } + // ── Campaign CH slice CH2: onInterfaceText routing ─────────────────── + + private static (GameEventDispatcher Dispatcher, ChatLog Chat, List<(string Text, RetailLogTextType Type)> Reported) + MakeAllWithInterfaceTextRouter() + { + var dispatcher = new GameEventDispatcher(); + var chat = new ChatLog(); + var reported = new List<(string, RetailLogTextType)>(); + GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + new CombatState(), + new Spellbook(), + chat, + onInterfaceText: (text, type) => reported.Add((text, type))); + return (dispatcher, chat, reported); + } + + [Fact] + public void WireAll_WithRouter_WeenieError_ClientLocalCode_RoutesThroughRouter_NotChat() + { + // 0x024 = NotGrounded / YouCantJumpWhileInTheAir -> ClientLocal per + // the full HandleFailureEvent table. With a router wired, this must + // reach the router (and therefore, in production, the SpewBox) and + // NEVER the chat transcript — the exact CH1-era behavior this test + // replaces (chat.OnWeenieError used to type every code 0x00). + var (d, chat, reported) = MakeAllWithInterfaceTextRouter(); + + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x024u); + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(0, chat.Count); + var (text, type) = Assert.Single(reported); + Assert.Equal(ClientTextRefusals.CantJumpInAir, text); + Assert.Equal(RetailLogTextType.ClientLocal, type); + } + + [Fact] + public void WireAll_WithRouter_WeenieError_ChatTypeCode_RoutesThroughRouter_AsChatType() + { + // 0x402 = Your spell fizzled -> Magic (0x07) per the table. Routed + // text still goes through the SAME router (production forwards + // non-ClientLocal types to the chat transcript via AddText). + var (d, _, reported) = MakeAllWithInterfaceTextRouter(); + + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x402u); + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload)); + d.Dispatch(env!.Value); + + var (text, type) = Assert.Single(reported); + Assert.Equal("Your spell fizzled.", text); + Assert.Equal(RetailLogTextType.Magic, type); + } + + [Fact] + public void WireAll_WithRouter_WeenieErrorWithString_SubstitutesParam_AndRoutesByType() + { + var (d, chat, reported) = MakeAllWithInterfaceTextRouter(); + + // 0x521 = "%s has been added to the list of people you can hear." -> Default (0x00). + byte[] interpBytes = MakeString16L("Caith"); + byte[] payload = new byte[4 + interpBytes.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x521u); + Array.Copy(interpBytes, 0, payload, 4, interpBytes.Length); + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieErrorWithString, payload)); + d.Dispatch(env!.Value); + + var (text, type) = Assert.Single(reported); + Assert.Equal("Caith has been added to the list of people you can hear.", text); + Assert.Equal(RetailLogTextType.Default, type); + Assert.Equal(0, chat.Count); + } + + [Fact] + public void WireAll_WithRouter_SilentClientControlStatus_StillDropped() + { + var (d, chat, reported) = MakeAllWithInterfaceTextRouter(); + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x003Bu); // ILeftTheWorld + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload)); + d.Dispatch(env!.Value); + + Assert.Empty(reported); + Assert.Equal(0, chat.Count); + } + + [Fact] + public void WireAll_WithRouter_CommunicationTransientString_AlwaysClientLocal() + { + // Retail hardcodes AddTextToScroll(text, 0x1A, 1, 0) for 0x02EB — + // corrects the CH1-era 0x00 routing note in GameEventWiring.cs. + var (d, chat, reported) = MakeAllWithInterfaceTextRouter(); + + byte[] payload = MakeString16L("You are too encumbered to carry that!"); + var env = GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.CommunicationTransientString, payload)); + d.Dispatch(env!.Value); + + var (text, type) = Assert.Single(reported); + Assert.Equal("You are too encumbered to carry that!", text); + Assert.Equal(RetailLogTextType.ClientLocal, type); + Assert.Equal(0, chat.Count); + } + + [Fact] + public void WireAll_NoRouter_CommunicationTransientString_FallsBackToChatWithClientLocalType() + { + // No router wired (older/test caller shape) — the fallback still + // tags the entry with the retail-correct type even though it can't + // perform the SpewBox split. + var (d, _, _, _, chat) = MakeAll(); + + byte[] payload = MakeString16L("fallback text"); + var env = GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.CommunicationTransientString, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(1, chat.Count); + var e = chat.Snapshot()[0]; + Assert.Equal("fallback text", e.Text); + Assert.Equal((uint)RetailLogTextType.ClientLocal, e.LogTextType); + } + + [Fact] + public void WireAll_WithRouter_UseDone_NonZeroError_RoutesResolvedTextAndType() + { + // 0x1D = YoureTooBusy -> ClientLocal per the table. Folds the former + // WeenieErrorText.cs 4-entry map into the full table (register AP-74). + var (d, chat, reported) = MakeAllWithInterfaceTextRouter(); + + byte[] payload = new byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x001Du); + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UseDone, payload)); + d.Dispatch(env!.Value); + + var (text, type) = Assert.Single(reported); + Assert.Equal("You're too busy!", text); + Assert.Equal(RetailLogTextType.ClientLocal, type); + Assert.Equal(0, chat.Count); + } + + [Fact] + public void WireAll_WithRouter_UseDone_ZeroError_ReportsNothing() + { + var (d, _, reported) = MakeAllWithInterfaceTextRouter(); + + byte[] payload = new byte[4]; // err == 0 + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UseDone, payload)); + d.Dispatch(env!.Value); + + Assert.Empty(reported); + } + [Fact] public void PlayerDescription_RegistersInventoryEntries_InClientObjectTable() { diff --git a/tests/AcDream.Core.Tests/Chat/SpewBoxStateTests.cs b/tests/AcDream.Core.Tests/Chat/SpewBoxStateTests.cs new file mode 100644 index 00000000..81951791 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/SpewBoxStateTests.cs @@ -0,0 +1,171 @@ +using AcDream.Core.Chat; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Campaign CH slice CH2: unit tests for , the +/// pure-state port of retail's gmSpewBoxUI pending/visible queue +/// split (research doc §3.1/§7.2). +/// +public sealed class SpewBoxStateTests +{ + [Fact] + public void Enqueue_DoesNotBecomeVisibleUntilTick() + { + // Retail decouples enqueue (RecvNotice_DisplayFinalStringInfo) from + // display (Update, driven by UI tick global message 3) by one frame. + var state = new SpewBoxState(); + state.Enqueue("You can't jump while in the air"); + + Assert.Equal(0, state.Count); + Assert.Empty(state.Snapshot()); + } + + [Fact] + public void Tick_DrainsPendingIntoVisible() + { + var state = new SpewBoxState(); + state.Enqueue("You can't jump while in the air"); + + state.Tick(nowSeconds: 0d); + + Assert.Equal(1, state.Count); + SpewBoxEntry entry = Assert.Single(state.Snapshot()); + Assert.Equal("You can't jump while in the air", entry.Text); + } + + [Fact] + public void Tick_InsertsNewestAtIndexZero() + { + // Retail: InsertItem(item, 0) — with MaxConcurrentItems raised past + // the code default of 1, newer entries must lead the visible list. + var state = new SpewBoxState(); + state.Enqueue("first"); + state.Tick(0d); + state.Enqueue("second"); + state.Tick(0d); + + // MaxConcurrentItems == 1 (retail code default) means "first" was + // already evicted by the overflow rule — assert directly on the + // ordering guarantee instead by forcing a raised cap via reflection + // is out of scope; the dedupe/overflow tests below cover that + // interaction precisely. Here we only need the single surviving + // entry to be "second" (the newest), proving insert-at-front beat + // whatever eviction order a stack (insert-at-back) would produce. + SpewBoxEntry entry = Assert.Single(state.Snapshot()); + Assert.Equal("second", entry.Text); + } + + [Fact] + public void Tick_IdenticalRepeat_RefreshesInPlace_DoesNotStack() + { + // Retail (0x004D5EF6-0x004D5F91): if the current item 0 has + // byte-identical text, that older item is deleted first — a + // repeated message refreshes instead of stacking a duplicate. + var state = new SpewBoxState(); + state.Enqueue("You are too encumbered to carry that!"); + state.Tick(0d); + state.Enqueue("You are too encumbered to carry that!"); + state.Tick(1d); + + Assert.Equal(1, state.Count); + SpewBoxEntry entry = Assert.Single(state.Snapshot()); + Assert.Equal("You are too encumbered to carry that!", entry.Text); + // The refreshed entry carries the LATER expiry (re-inserted at t=1). + Assert.Equal(1d + SpewBoxState.DefaultLifetime.TotalSeconds, entry.ExpiresAtSeconds); + } + + [Fact] + public void Tick_DifferentText_DoesNotDedupe() + { + var state = new SpewBoxState(); + state.Enqueue("first message"); + state.Tick(0d); + state.Enqueue("second message"); + state.Tick(0d); + + // With MaxConcurrentItems == 1, "second message" evicts "first + // message" via overflow, not dedupe — either way only one survives, + // and it must be the newest. + SpewBoxEntry entry = Assert.Single(state.Snapshot()); + Assert.Equal("second message", entry.Text); + } + + [Fact] + public void Tick_Overflow_DropsOldest_RespectingMaxConcurrentItems() + { + var state = new SpewBoxState(); + Assert.Equal(1, SpewBoxState.MaxConcurrentItems); + + state.Enqueue("oldest"); + state.Tick(0d); + state.Enqueue("newer"); + state.Tick(0d); + + Assert.Equal(SpewBoxState.MaxConcurrentItems, state.Count); + SpewBoxEntry entry = Assert.Single(state.Snapshot()); + Assert.Equal("newer", entry.Text); + } + + [Fact] + public void Tick_PrunesExpiredEntries() + { + var state = new SpewBoxState(); + state.Enqueue("fading message"); + state.Tick(nowSeconds: 0d); + Assert.Equal(1, state.Count); + + double justPastExpiry = SpewBoxState.DefaultLifetime.TotalSeconds + 0.001; + state.Tick(justPastExpiry); + + Assert.Equal(0, state.Count); + Assert.Empty(state.Snapshot()); + } + + [Fact] + public void Tick_NoPendingNoExpired_RevisionUnchanged() + { + var state = new SpewBoxState(); + state.Enqueue("stays visible a while"); + state.Tick(0d); + long revisionAfterFirstTick = state.Revision; + + // Well within the lifetime window, nothing pending — a second Tick + // should be a pure no-op. + state.Tick(0.5d); + + Assert.Equal(revisionAfterFirstTick, state.Revision); + } + + [Fact] + public void Reset_ClearsPendingAndVisible() + { + var state = new SpewBoxState(); + state.Enqueue("pending, never ticked"); + state.Enqueue("about to be visible"); + state.Tick(0d); + Assert.True(state.Count > 0); + + state.Reset(); + + Assert.Equal(0, state.Count); + Assert.Empty(state.Snapshot()); + + // A Reset while text was still pending (never ticked) must also + // discard the pending queue — ticking afterward shows nothing. + state.Tick(1d); + Assert.Equal(0, state.Count); + } + + [Fact] + public void Revision_AdvancesOnTickThatChangesVisibleSet() + { + var state = new SpewBoxState(); + long initial = state.Revision; + + state.Enqueue("a line"); + state.Tick(0d); + + Assert.True(state.Revision > initial); + } +} diff --git a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs index be9fd72a..dd47c07c 100644 --- a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs +++ b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs @@ -42,11 +42,17 @@ public sealed class WeenieErrorMessagesTests // ── known codes — informational, no parameter ──────────────────── [Fact] - public void Format_TurbineChatIsEnabled_NoParamForm() + public void Format_0x051D_FallsBackToHex_NoRetailCaseExists() { - // 0x051D came in WeenieError (no param) form at login. + // Campaign CH slice CH2: the pre-CH2 "Turbine Chat is enabled." + // text for 0x051D was an ACE-derived guess, never decomp-confirmed. + // The full HandleFailureEvent port found NO case for 0x51D anywhere + // in the switch (only 0x51C has one — case 0x51c: at raw line + // 383115-383118 of acclient_2013_pseudo_c.txt) — retail's own + // client simply has no display text for this id. Falling back to + // the generic form is now the retail-faithful answer, not a gap. Assert.Equal( - "Turbine Chat is enabled.", + "WeenieError 0x051D", WeenieErrorMessages.Format(0x051D, param: null)); } @@ -149,14 +155,16 @@ public sealed class WeenieErrorMessagesTests } [Fact] - public void Format_AdjacentUnmappedPKCode_StillFallsBackToHex() + public void Format_LevelTooLowToChangePKStatus_NowResolvedByCH2() { - // 0x04EE (LevelTooLowToChangePKStatusWithObject) sits right next - // to the codes above but its retail literal - // ("Your level is too low to change…") was NOT independently - // byte-recovered in this pass — confirms the fallback still - // covers the untouched neighbours rather than silently guessing. - Assert.Equal("WeenieError 0x04EE", WeenieErrorMessages.Format(0x04EE, null)); + // 0x04EE (LevelTooLowToChangePKStatusWithObject) sits right next to + // the PK-status codes above; the pre-CH2 test asserted the + // fallback because only a curated ~60-entry subset was ported then. + // Campaign CH slice CH2's full 338-row HandleFailureEvent port + // (Appendix A row 0x4EE, Type 0x00) resolves it for real. + Assert.Equal( + "Your level is too low to change your player killer status with this object.", + WeenieErrorMessages.Format(0x04EE, null)); } // ── unknown codes — graceful fallback preserves debug info ─────── @@ -193,10 +201,17 @@ public sealed class WeenieErrorMessagesTests } [Fact] - public void Format_FailToAffectCannotBeHarmed_SubstitutesParam() + public void Format_0x004F_FallsBackToHex_NoRetailCaseExists() { + // Campaign CH slice CH2: the pre-CH2 "You fail to affect _ because + // they cannot be harmed!" text for 0x004F was an ACE-derived guess. + // Direct decomp verification (grepping every "case 0x4f:" in + // ClientCommunicationSystem::HandleFailureEvent's whole body) found + // NONE — only 0x4E, 0x50, 0x51, 0x52, 0x53, 0x54 have cases; 0x4F is + // skipped entirely, same as the many other gaps in that switch's + // sparse jump table. Retail has no display text for this id. Assert.Equal( - "You fail to affect Drudge because they cannot be harmed!", + "WeenieError 0x004F: Drudge", WeenieErrorMessages.Format(0x004F, "Drudge")); } @@ -207,4 +222,85 @@ public sealed class WeenieErrorMessagesTests "+Acdream is already at full health!", WeenieErrorMessages.Format(0x04FF, "+Acdream")); } + + // ── Campaign CH slice CH2: the full HandleFailureEvent table port ──── + + /// + /// Pins the table's size: 338 rows (Appendix A's 339 minus the one + /// deliberately-excluded 0x4F8, see the class doc comment on + /// ). A change to this number without + /// a matching research/commit citation is a red flag, not a routine + /// edit. + /// + [Fact] + public void Resolve_FullTable_HasExactly338Rows() + { + int count = 0; + for (uint id = 0; id <= 0x600u; id++) + { + var (text, _) = WeenieErrorMessages.Resolve(id, null); + if (!text.StartsWith("WeenieError 0x", StringComparison.Ordinal)) + count++; + } + Assert.Equal(338, count); + } + + [Fact] + public void Resolve_0x4F8_IsDeliberatelyExcluded_FallsBackToHex() + { + // See the class doc comment: 0x4F8's case body is a tangled + // multi-operator+ decompiler artifact that could not be resolved + // with confidence — excluded rather than guessed. + var (text, type) = WeenieErrorMessages.Resolve(0x4F8, "Someone"); + Assert.Equal("WeenieError 0x04F8: Someone", text); + Assert.Equal(RetailLogTextType.Default, type); + } + + // ── spot pins across all three retail routing destinations ────────── + + [Theory] + // ClientLocal (0x1A) — the SpewBox destination. + [InlineData(0x017u, "You failed to go to non-combat mode.", RetailLogTextType.ClientLocal)] + [InlineData(0x02Au, "You are too encumbered to carry that!", RetailLogTextType.ClientLocal)] + [InlineData(0x04EBu, "You can't do that while in the air!", RetailLogTextType.ClientLocal)] + [InlineData(0x550u, "Out of Range!", RetailLogTextType.ClientLocal)] + // Magic (0x07) — the light-blue spell/portal-failure channel. + [InlineData(0x402u, "Your spell fizzled.", RetailLogTextType.Magic)] + [InlineData(0x49Bu, "You fail to link with the lifestone!", RetailLogTextType.Magic)] + [InlineData(0x593u, "Olthoi characters can only use Lifestone and PK Arena recalls!", RetailLogTextType.Magic)] + // Default (0x00) — the ordinary broadcast/green channel. + [InlineData(0x4A, "Ack! You killed yourself!", RetailLogTextType.Default)] + [InlineData(0x50Cu, "%s is now a closed fellowship.", RetailLogTextType.Default)] + [InlineData(0x55Fu, "Only Player Killer characters may use this command!", RetailLogTextType.Default)] + public void Resolve_SpotPins_TextAndTypeMatchAppendixA(uint id, string expectedTemplate, RetailLogTextType expectedType) + { + var (text, type) = WeenieErrorMessages.Resolve(id, param: null); + Assert.Equal(expectedTemplate, text); + Assert.Equal(expectedType, type); + } + + [Fact] + public void Resolve_JumpFamily_SharesClientTextRefusalsConstantsVerbatim() + { + // HandleFailureEvent's 0x24/0x48/0x49 cases reuse the SAME string + // globals as the local jump-refusal sites — assert byte-identity, + // not just similar wording. + Assert.Equal(ClientTextRefusals.CantJumpInAir, WeenieErrorMessages.Resolve(0x024u, null).Text); + Assert.Equal(ClientTextRefusals.CantJumpPosition, WeenieErrorMessages.Resolve(0x048u, null).Text); + Assert.Equal(ClientTextRefusals.CantJumpLoad, WeenieErrorMessages.Resolve(0x049u, null).Text); + Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x024u, null).Type); + Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x048u, null).Type); + Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x049u, null).Type); + } + + [Fact] + public void Resolve_0x4F4_PreservesRetailDollarSTypo() + { + // Retail's own literal is "...because $s cannot affect anyone!" — + // a genuine retail typo (should have been %s). Only the FIRST %s + // substitutes; the literal "$s" must NOT be replaced. + var (text, type) = WeenieErrorMessages.Resolve(0x4F4u, "A drudge"); + Assert.Equal("A drudge fails to affect you because $s cannot affect anyone!", text); + Assert.Equal(RetailLogTextType.Magic, type); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index 30fdd1c3..cce96274 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.Core.Chat; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Runtime.Gameplay; @@ -880,6 +881,65 @@ public class PlayerMovementControllerTests Assert.Equal(baseline, restored, precision: 4); } + // ── Campaign CH slice CH2: local jump refusals reach the SpewBox ─────── + + [Fact] + public void ChargeJump_RefusedByOverBurden_ReportsCantJumpLoadedDown() + { + // CACQualities::CanJump refuses past 200% load (PlayerWeenie.CanJump, + // CanJumpLoadThreshold = 2.0f) — a real, production-reachable refusal, + // not a fake. Retail: ClientCombatSystem::CommenceJump @0x0056AF90's + // eax_3 == 0x49 branch -> cant_jump_load. + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); + controller.SetCharacterBurden(2.5f); + + var reported = new List<(string Text, AcDream.Core.Chat.RetailLogTextType Type)>(); + controller.OnInterfaceText = (text, type) => reported.Add((text, type)); + + controller.Update(0.016f, new MovementInput(Jump: true)); + + var report = Assert.Single(reported); + Assert.Equal(ClientTextRefusals.CantJumpLoad, report.Text); + Assert.Equal(AcDream.Core.Chat.RetailLogTextType.ClientLocal, report.Type); + Assert.False(controller.IsAirborne, "a refused charge must not launch the player"); + } + + [Fact] + public void ChargeJump_Succeeds_ReportsNothing() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); + + var reported = new List(); + controller.OnInterfaceText = (text, _) => reported.Add(text); + + controller.Update(1.0f, new MovementInput(Jump: true)); // full charge + controller.Update(0.016f, new MovementInput(Jump: false)); // release -> jump fires + + Assert.Empty(reported); + Assert.True(controller.IsAirborne); + } + + [Fact] + public void ChargeJump_RefusalWithNoObserverWired_DoesNotThrow() + { + // OnInterfaceText defaults to null (no production wiring in a bare + // test controller) — the refusal path must be a safe no-op, not a + // NullReferenceException. + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); + controller.SetCharacterBurden(2.5f); + + var exception = Record.Exception(() => + controller.Update(0.016f, new MovementInput(Jump: true))); + + Assert.Null(exception); + } + // ── Campaign P Slice P5 (2026-07-30): ConstraintManager leash arming (#167) ── // // docs/research/2026-07-30-constraint-leash-constants.md. The player's diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs index fcbada7f..96dad77c 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs @@ -167,6 +167,82 @@ public sealed class RuntimeCommunicationStateTests Assert.Null(second.CommandTargets.LastIncomingTellSender); } + // ── Campaign CH slice CH2: AddText routing chokepoint ──────────────── + + [Fact] + public void AddText_ClientLocal_RoutesToSpewBoxOnly_NeverChatTranscript() + { + // Retail: type == 0x1A (ClientLocal) is exactly the bit every + // ChatInterface window's default filter excludes + // (ChatInterface::ChatInterface @0x004F4550, + // m_llTextTypeFilter &= 0xFBFFFFFF). The CH1-era ChatLog.OnWeenieError + // path put EVERY WeenieError line in chat, including 0x1A ones — + // this is the fix: a ClientLocal line must reach SpewBox and never + // touch the chat transcript at all. + using var state = new RuntimeCommunicationState(); + + state.AddText("You can't jump while in the air", RetailLogTextType.ClientLocal); + + Assert.Equal(0, state.Chat.Count); + state.SpewBox.Tick(0d); + Assert.Equal(1, state.SpewBox.Count); + Assert.Equal("You can't jump while in the air", state.SpewBox.Snapshot()[0].Text); + } + + [Theory] + [InlineData(RetailLogTextType.Default)] + [InlineData(RetailLogTextType.Magic)] + [InlineData(RetailLogTextType.System)] + public void AddText_NonClientLocalTypes_RouteToChatOnly_NeverSpewBox(RetailLogTextType type) + { + using var state = new RuntimeCommunicationState(); + + state.AddText("Your spell fizzled.", type); + + Assert.Equal(1, state.Chat.Count); + Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text); + Assert.Equal((uint)type, state.Chat.Snapshot()[0].LogTextType); + state.SpewBox.Tick(0d); + Assert.Equal(0, state.SpewBox.Count); + } + + [Fact] + public void AddText_TrimsTrailingWhitespace_LikeRetailAddTextToScroll() + { + using var state = new RuntimeCommunicationState(); + + state.AddText("Out of Range! ", RetailLogTextType.ClientLocal); + + state.SpewBox.Tick(0d); + Assert.Equal("Out of Range!", state.SpewBox.Snapshot()[0].Text); + } + + [Fact] + public void AddText_EmptyAfterTrim_IsDropped() + { + using var state = new RuntimeCommunicationState(); + + state.AddText(" ", RetailLogTextType.ClientLocal); + state.AddText(" ", RetailLogTextType.Default); + + state.SpewBox.Tick(0d); + Assert.Equal(0, state.SpewBox.Count); + Assert.Equal(0, state.Chat.Count); + } + + [Fact] + public void Dispose_ResetsSpewBox() + { + var state = new RuntimeCommunicationState(); + state.AddText("about to be torn down", RetailLogTextType.ClientLocal); + state.SpewBox.Tick(0d); + Assert.Equal(1, state.SpewBox.Count); + + state.Dispose(); + + Assert.Equal(0, state.SpewBox.Count); + } + private sealed class RecordingObserver : IRuntimeCommunicationObserver { public List Events { get; } = []; diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs index 1703f19c..f2723e9c 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs @@ -153,6 +153,57 @@ public sealed class RuntimeLocalPlayerMovementStateTests Assert.NotEqual(first.Snapshot.Position, second.Snapshot.Position); } + // ── Campaign CH slice CH2: OnInterfaceText propagation ────────────── + + [Fact] + public void OnInterfaceText_SetBeforeControllerInstall_AppliesToTheInstalledController() + { + using var movement = new RuntimeLocalPlayerMovementState(); + var received = new List<(string Text, AcDream.Core.Chat.RetailLogTextType Type)>(); + movement.OnInterfaceText = (text, type) => received.Add((text, type)); + + var controller = new PlayerMovementController(new PhysicsEngine()); + movement.Controller = controller; + + controller.OnInterfaceText!("test line", AcDream.Core.Chat.RetailLogTextType.ClientLocal); + + Assert.Single(received); + Assert.Equal("test line", received[0].Text); + Assert.Equal(AcDream.Core.Chat.RetailLogTextType.ClientLocal, received[0].Type); + } + + [Fact] + public void OnInterfaceText_SetAfterControllerInstall_StillAppliesToTheCurrentController() + { + using var movement = new RuntimeLocalPlayerMovementState(); + var controller = new PlayerMovementController(new PhysicsEngine()); + movement.Controller = controller; + + var received = new List(); + movement.OnInterfaceText = (text, _) => received.Add(text); + + controller.OnInterfaceText!("late-bound line", AcDream.Core.Chat.RetailLogTextType.ClientLocal); + + Assert.Equal(["late-bound line"], received); + } + + [Fact] + public void OnInterfaceText_SwappingController_AppliesToTheReplacement() + { + using var movement = new RuntimeLocalPlayerMovementState(); + var received = new List(); + movement.OnInterfaceText = (text, _) => received.Add(text); + + var first = new PlayerMovementController(new PhysicsEngine()); + movement.Controller = first; + var second = new PlayerMovementController(new PhysicsEngine()); + movement.Controller = second; + + second.OnInterfaceText!("from replacement", AcDream.Core.Chat.RetailLogTextType.ClientLocal); + + Assert.Equal(["from replacement"], received); + } + [Fact] public void MotionPreparationPublishesOnlyTheConstructionSeam() { diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index 5372a595..18c6f7f9 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -97,6 +97,63 @@ public sealed class LiveSessionEventRouterTests Assert.Equal(1, chat.Count); } + // ── Campaign CH slice CH2: 0xF7E0 ServerMessage routes through AddText ── + + [Fact] + public void ServerMessage_RoutesThroughAddText_WithWireChatTypeVerbatim_WhenWired() + { + using var session = NewSession(); + var chat = new ChatLog(); + var reported = new List<(string Text, RetailLogTextType Type)>(); + var social = new LiveSocialSessionBindings( + chat, + new TurbineChatState(), + new FriendsState(), + new SquelchState(), + (text, type) => reported.Add((text, type))); + var router = new LiveSessionEventRouter( + session, + new LiveEntitySessionSink( + _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, + _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }, + _ => { }), + new LiveEnvironmentSessionSink(_ => { }, _ => { }), + NewInventoryBindings(), + NewCharacterBindings(), + social); + router.Attach(); + + // 0x1A carried verbatim -- retail's Handle_Communication__TextboxString + // @0x0057D3A0 feeds the RAW wire chatType into AddTextToScroll. + EventDelegate>( + session, nameof(session.ServerMessageReceived))( + new AcDream.Core.Net.Messages.ServerMessage.Parsed("You are too encumbered to carry that!", 0x1Au)); + + var (text, type) = Assert.Single(reported); + Assert.Equal("You are too encumbered to carry that!", text); + Assert.Equal(RetailLogTextType.ClientLocal, type); + Assert.Equal(0, chat.Count); + + router.Dispose(); + } + + [Fact] + public void ServerMessage_FallsBackToChatDirectly_WhenNoRouterWired() + { + using var session = NewSession(); + var chat = new ChatLog(); + var router = NewRouter(session, new Counters(), chat: chat); + + EventDelegate>( + session, nameof(session.ServerMessageReceived))( + new AcDream.Core.Net.Messages.ServerMessage.Parsed("fallback path", 0x00u)); + + Assert.Equal(1, chat.Count); + Assert.Equal("fallback path", chat.Snapshot()[0].Text); + + router.Dispose(); + } + [Fact] public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/SpewBox/SpewBoxVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/SpewBox/SpewBoxVMTests.cs new file mode 100644 index 00000000..216765bf --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/SpewBox/SpewBoxVMTests.cs @@ -0,0 +1,79 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.SpewBox; + +namespace AcDream.UI.Abstractions.Tests.Panels.SpewBox; + +public sealed class SpewBoxVMTests +{ + [Fact] + public void Lines_NoContent_ReturnsEmpty() + { + var vm = new SpewBoxVM(new SpewBoxState()); + + Assert.Empty(vm.Lines(0d)); + Assert.False(vm.HasVisibleLines); + } + + [Fact] + public void Lines_DrainsPendingAndReturnsRemainingLifetime() + { + var state = new SpewBoxState(); + var vm = new SpewBoxVM(state); + state.Enqueue("You can't jump while in the air"); + + IReadOnlyList lines = vm.Lines(nowSeconds: 10d); + + SpewBoxLine line = Assert.Single(lines); + Assert.Equal("You can't jump while in the air", line.Text); + Assert.Equal(SpewBoxState.DefaultLifetime.TotalSeconds, line.RemainingLifetimeSeconds, precision: 3); + Assert.True(vm.HasVisibleLines); + } + + [Fact] + public void Lines_RemainingLifetime_CountsDownAndFloorsAtZero() + { + var state = new SpewBoxState(); + var vm = new SpewBoxVM(state); + state.Enqueue("fading"); + + vm.Lines(nowSeconds: 0d); + double halfway = SpewBoxState.DefaultLifetime.TotalSeconds / 2; + SpewBoxLine midLine = Assert.Single(vm.Lines(nowSeconds: halfway)); + Assert.Equal(halfway, midLine.RemainingLifetimeSeconds, precision: 3); + + // Past expiry: the line is pruned by the same Lines() call that ticks it. + Assert.Empty(vm.Lines(nowSeconds: SpewBoxState.DefaultLifetime.TotalSeconds + 1d)); + } + + [Fact] + public void Lines_NewestFirst_MatchesRetailInsertAtZero() + { + var state = new SpewBoxState(); + var vm = new SpewBoxVM(state); + state.Enqueue("older"); + vm.Lines(0d); + state.Enqueue("newer"); + + IReadOnlyList lines = vm.Lines(0d); + + // MaxConcurrentItems == 1 means only the newest survives, which is + // itself proof insertion happens at the front (retail's overflow + // rule drops the OLDEST / highest index, not the newest). + SpewBoxLine line = Assert.Single(lines); + Assert.Equal("newer", line.Text); + } + + [Fact] + public void Revision_TracksUnderlyingState() + { + var state = new SpewBoxState(); + var vm = new SpewBoxVM(state); + long initial = vm.Revision; + + state.Enqueue("a line"); + vm.Lines(0d); + + Assert.True(vm.Revision > initial); + Assert.Equal(state.Revision, vm.Revision); + } +}