From 172c6f9aa3b6581fd8890e863e2af11b171f86d9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 15:23:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(chat):=20Campaign=20CH=20slice=20CH1=20?= =?UTF-8?q?=E2=80=94=20retail=20LogTextType=20color=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail colors chat lines by the 34-value wire LogTextType (ACE's ChatMessageType), NOT by acdream's synthetic 9-value ChatKind. The old ChatWindowController.RetailChatColor(ChatKind) collapsed distinct retail colors onto one bucket per ChatKind — e.g. every Channel line rendered colorLightBlue (Magic's slot) when retail's actual palette spans five different colors across the Turbine rooms and legacy allegiance family. Ports ChatInterface::BuildChatColorLookupTable @0x004F31C0 verbatim (RetailChatColorTable, all 34 RGBA floats read from the PDB-paired binary's .data section) and threads a new ChatEntry.LogTextType field through every ingestion site to the correct retail wire value: HearSpeech/Tell pass the wire chatType through verbatim; Emote/SoulEmote hard-code 0x0C; the Tell self-echo hard-codes 0x04; legacy ChatChannel broadcasts derive their type from the channel bit via the new LegacyChannelChatType helper (ported from the decompiled Handle_Communication__ChannelBroadcast dispatch, hear vs. own-send); TurbineChat rooms map through TurbineChatDisplayNames.LogTextType; CombatChatTranslator's hit/miss/evade lines map to ACE's CombatSelf/ CombatEnemy per Player_Combat.cs; kill/death lines use retail's decompiled 0x00 Default (not a combat color). ChatWindowController's transcript now folds LogTextType through RetailChatColorTable with retail's exact "out-of-range keeps the previous line's color" carry rule; ChatPanel's combat highlighting sources the same table. Corrects HearSpeech.cs's doc-comment ChatType legend (4 of 6 entries were wrong). Adds register row AP-175 for the pre-existing (unchanged) Popup-renders-in-chat divergence and updates AP-39's stale per-ChatKind description. Narrows ISSUES #139 — its chat-colors half is done. Retail renders no chat timestamp prefix path exists in acdream today, so the "timestamp is always colorGrey 0x0C" rule has nothing to attach to; noted here per the research doc rather than left silent. Research: docs/research/2026-08-09-chat-retail-color-table.md Full Release suite: 11,833 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 17 +- .../retail-divergence-register.md | 5 +- docs/plans/2026-08-09-chat-parity-campaign.md | 2 +- .../2026-08-09-chat-retail-color-table.md | 485 ++++++++++++++++++ .../Net/LiveSessionCommandRouter.cs | 12 +- .../Net/LiveSessionRuntimeFactory.cs | 4 +- .../UI/Layout/ChatWindowController.cs | 38 +- src/AcDream.Core.Net/GameEventWiring.cs | 33 +- src/AcDream.Core.Net/Messages/HearSpeech.cs | 22 +- src/AcDream.Core/Chat/ChatLog.cs | 152 +++++- src/AcDream.Core/Chat/CombatChatTranslator.cs | 31 +- .../Chat/LegacyChannelChatType.cs | 77 +++ .../Hosting/HeadlessGameplayOperations.cs | 4 +- .../Session/LiveSessionEventRouter.cs | 14 +- .../Session/TurbineChatDisplayNames.cs | 27 + .../Panels/Chat/ChatPanel.cs | 14 +- .../Panels/Chat/ChatVM.cs | 22 +- .../Panels/Chat/RetailChatColorTable.cs | 109 ++++ .../GameEventWiringTests.cs | 7 + tests/AcDream.Core.Tests/Chat/ChatLogTests.cs | 154 ++++++ .../Chat/CombatChatTranslatorTests.cs | 13 + .../Chat/LegacyChannelChatTypeTests.cs | 39 ++ .../Session/TurbineChatDisplayNamesTests.cs | 37 ++ .../ChatVMTests.cs | 15 + .../Panels/Chat/ChatVMCombatTests.cs | 9 +- .../Panels/Chat/RetailChatColorTableTests.cs | 103 ++++ 26 files changed, 1360 insertions(+), 85 deletions(-) create mode 100644 docs/research/2026-08-09-chat-retail-color-table.md create mode 100644 src/AcDream.Core/Chat/LegacyChannelChatType.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs create mode 100644 tests/AcDream.Core.Tests/Chat/LegacyChannelChatTypeTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Session/TurbineChatDisplayNamesTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailChatColorTableTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index be8276f4..1bd005e1 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -9255,27 +9255,24 @@ See divergence register **AP-59**. --- -## #139 — D.2b retail UI polish: chat text colors + buttons +## #139 — D.2b retail UI polish: chat buttons -**Status:** OPEN +**Status:** OPEN (narrowed 2026-08-09 — the chat-text-colors half CLOSED, see below) **Severity:** LOW (cosmetic fit-and-finish — the widget generalization works and matches the prior hand-made build; this is polish vs a side-by-side retail client) **Filed:** 2026-06-16 -**Component:** ui — D.2b retail UI (chat window + buttons) +**Component:** ui — D.2b retail UI (chat buttons) -**Description (user):** After the widget-generalization pass landed (2026-06-16), two areas want a polish pass against retail: -1. **Chat text colors** — the per-`ChatKind` transcript text colors need tuning to match retail more precisely. Current values come from a live cdb dump of the named `RGBAColor` constants (colorWhite / BrightPurple / LightBlue / Green / LightRed / Grey) mapped per `ChatKind` in `ChatWindowController.RetailChatColor`. The four common kinds (speech/tell/channel/system) are confirmed; the rarer kinds (emote, soul-emote, combat, popup) map to the nearest named color and may be off — verify each against a side-by-side retail client. +**Description (user):** After the widget-generalization pass landed (2026-06-16), two areas wanted a polish pass against retail. Item 1 (chat text colors) is now DONE — see below. Item 2 remains open: +1. ~~**Chat text colors**~~ — **CLOSED 2026-08-09, Campaign CH slice CH1.** `ChatWindowController.RetailChatColor(ChatKind)` (the old best-effort per-`ChatKind` map) is deleted; coloring now keys off the entry's retail wire `LogTextType` through the exact 34-entry `RetailChatColorTable`, a faithful port of `ChatInterface::BuildChatColorLookupTable @0x004F31C0` with every RGBA float read from the PDB-paired binary's `.data` section — see `docs/research/2026-08-09-chat-retail-color-table.md` and `docs/plans/2026-08-09-chat-parity-campaign.md`. 2. **Buttons** — the chat buttons (Send, Max/Min, and the channel "Chat ▸" menu button) want visual polish: **pressed / hover state feedback** (`UiButton` currently draws only its default-state sprite; the dat carries `Normal`/`Pressed`/`Highlight` states it does not yet switch on), plus a check that the face 3-slice + autosize read cleanly at all widths. -**Root cause / status:** Deferred polish, NOT a regression — the generalized chat matches the prior hand-made build (user-confirmed 2026-06-16). `UiButton` intentionally mirrors `UiDatElement`'s single-state render (pressed-state was out of the generalization's scope); chat colors are best-effort from the cdb dump. +**Root cause / status:** Deferred polish, NOT a regression — the generalized chat matches the prior hand-made build (user-confirmed 2026-06-16). `UiButton` intentionally mirrors `UiDatElement`'s single-state render (pressed-state was out of the generalization's scope). **Files:** -- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — `RetailChatColor(ChatKind)` per-kind color map. - `src/AcDream.App/UI/UiButton.cs` — `ActiveFile()` / `OnEvent` (no pressed-state swap yet; dat has Normal/Pressed/Highlight). - `src/AcDream.App/UI/UiMenu.cs` — `DrawButtonFace` (Normal vs Pressed sprite) for the channel button. -**Research:** `claude-memory/reference_retail_chat_colors.md` (the cdb chat-color dump + recipe). - -**Acceptance:** Chat text colors and button (pressed/hover) states match a side-by-side retail client — user's visual sign-off. +**Acceptance:** Button (pressed/hover) states match a side-by-side retail client — user's visual sign-off. (Chat text colors already user-gated at Campaign CH's campaign-level gate.) --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a2fdec0a..5afc5a77 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) — 123 active rows (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) — 124 active rows (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 @@ -230,8 +230,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | | AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures | -| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | 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) | +| 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-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/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 582501b6..7bb0aedf 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -99,7 +99,7 @@ implementer per slice against a pinned contract (per | Slice | Commit | Suite | Review | User gate | |---|---|---|---|---| | R1–R4 research | — | — | — | — | -| CH1 colors | — | — | — | — | +| CH1 colors | `06f448fc` | 11,833 passed / 4 skipped / 0 failed | — | pending | | CH2 interface text | — | — | — | — | | CH3 side channels | — | — | — | — | | CH4 commands | — | — | — | — | diff --git a/docs/research/2026-08-09-chat-retail-color-table.md b/docs/research/2026-08-09-chat-retail-color-table.md new file mode 100644 index 00000000..ebf4e080 --- /dev/null +++ b/docs/research/2026-08-09-chat-retail-color-table.md @@ -0,0 +1,485 @@ +# Retail chat color table — the exact type→color lookup + +**Date:** 2026-08-09 +**Status:** RESEARCH ONLY — no production code changed. +**Primary source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` +(Binary Ninja pseudo-C of the Sept 2013 EoR `acclient.exe`, PDB-named) + +direct byte-read of the PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe` +(v11.4186, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). +**Ghidra MCP:** not reachable this session (ports 8080 and 8081 both refused); +everything below comes from the committed static decomp plus the binary itself. + +--- + +## 0. TL;DR + +- The lookup table has **exactly 34 entries**, indices `0x00`–`0x21`. +- The index is the **`LogTextType`** value — the same integer that arrives on + the wire as ACE's `ChatMessageType`. **There is no remapping anywhere**: + the wire byte is passed hand-to-hand from the message handler down to the + font-color setter and used as a raw array index. +- The table's **default fill is `colorGreen`** (0.5, 1, 0.498). Seven indices + are never overwritten and therefore stay green. +- The colors are **hard-coded C++ constants**. There is no chat-color option, + no registry key, no DAT override. What retail *did* let the player configure + is which text types go to which chat *window* (a 64-bit bitfield), not what + color they are. +- All 13 cdb-dumped RGBA values are confirmed byte-for-byte against the binary, + and the one address the 2026-06-16 cdb session missed (`0x81c4d8`) is + resolved here. + +--- + +## 1. The builder — `ChatInterface::BuildChatColorLookupTable @ 0x004F31C0` + +Called unconditionally from `ChatInterface::PostInit @ 0x004F3DD0` (call site +`0x004F3F13`), once per chat-window construction. + +### 1.1 What it actually does + +``` +if (m_chatLog == null) return; + +var list = new BaseProperty(name = 0x1B); // the font-color LIST property +var color = new BaseProperty(name = 0x19); // one RGBAColor-valued element + +color.SetColor(&colorGreen); // 0x81C578 +for (i = 0x22; i != 1; i--) // 34 iterations + list.Append(color); // → indices 0..0x21 all green + +color.SetColor(&colorWhite); list.SetValue(0x02, color); +color.SetColor(&colorGrey); list.SetValue(0x0C, color); +color.SetColor(&yellow); list.SetValue(0x03, 0x1F, 0x0A, 0x13); +color.SetColor(&darkYellow); list.SetValue(0x04, 0x0B); +color.SetColor(&colorPink); list.SetValue(0x08, 0x09); +color.SetColor(&orange); list.SetValue(0x12, 0x21); +color.SetColor(&colorBlueGrey); list.SetValue(0x1B, 0x1C, 0x1D, 0x1E, 0x20, 0x0E); +color.SetColor(&colorDarkRed); list.SetValue(0x0F, 0x06, 0x15); +color.SetColor(&colorLightRed); list.SetValue(0x16); +color.SetColor(&colorLightBlue); list.SetValue(0x07, 0x11); +color.SetColor(&colorCyan); list.SetValue(0x0D); +color.SetColor(&colorBrightPurple); list.SetValue(0x05); +color.SetColor(&colorBrightRed); list.SetValue(0x1A); + +m_chatLog->SetProperty(list); // 0x004F3799 +``` + +Decoding notes for anyone re-reading the raw pseudo-C: + +- `var_18` is the list property (`SetPropertyName(&var_18, 0x1b)`), `var_10` is + the single color property (`SetPropertyName(&var_10, 0x19)`). +- vtable slot `+0x9C` on the color property = "set value to this `RGBAColor*`"; + the argument is a raw `.data` address. +- vtable slot `+0xFC` on the list = **Append** (one arg); slot `+0xF8` = + **SetValue(index, value)** (two args). This is how you tell the default-fill + loop from the per-index overrides. +- Loop trip count: `i_1 = 0x22; do { append; i = i_1; i_1--; } while (i != 1);` + runs 34 times → indices `0x00`–`0x21`. The highest index the function ever + writes is `0x21`. Independent confirmation of the 34-value space is the + squelch enumerator at `0x00589DEB`: `for (uint i = 0; i < 0x22; i++)`. +- 27 indices are written explicitly; 7 (`0x00, 0x01, 0x10, 0x14, 0x17, 0x18, + 0x19`) keep the green default. +- The function builds property `0x1B` only — the **main font-color list**. + It does *not* build property `0x1D`, the *tag* font-color list that + `UIElement_Text::SetFontColorHelper` also consults; that one comes from the + LayoutDesc. + +### 1.2 The color constants (verified against the binary) + +Read from `.data` (image base `0x400000`, `.data` VA `0x80A000` → raw +`0x40A000`). Each is a 16-byte `RGBAColor` = four little-endian floats +R, G, B, A. **Every alpha is 1.0.** + +| Address | Symbol | R | G | B | A | ≈8-bit | +|---|---|---|---|---|---|---| +| `0x0081C4A8` | `colorBrightRed` | 1.0 | 0.0 | 0.0 | 1.0 | `#FF0000` | +| `0x0081C4B8` | `colorWhite` | 1.0 | 1.0 | 1.0 | 1.0 | `#FFFFFF` | +| `0x0081C4C8` | *(unnamed)* **yellow** | 1.0 | 1.0 | 0.247 | 1.0 | `#FFFF3F` | +| `0x0081C4D8` | *(unnamed)* **dark yellow** | 0.824 | 0.824 | 0.392 | 1.0 | `#D2D264` | +| `0x0081C4E8` | `colorBrightPurple` | 1.0 | 0.498 | 1.0 | 1.0 | `#FF7FFF` | +| `0x0081C4F8` | `colorDarkRed` | 1.0 | 0.247 | 0.247 | 1.0 | `#FF3F3F` | +| `0x0081C508` | `colorLightRed` | 0.96 | 0.459 | 0.447 | 1.0 | `#F57572` | +| `0x0081C518` | `colorLightBlue` | 0.247 | 0.749 | 1.0 | 1.0 | `#3FBFFF` | +| `0x0081C528` | `colorPink` | 1.0 | 0.588 | 0.588 | 1.0 | `#FF9696` | +| `0x0081C538` | `colorCyan` | 0.247 | 0.863 | 0.863 | 1.0 | `#3FDCDC` | +| `0x0081C548` | `colorBlueGrey` | 0.706 | 0.863 | 0.941 | 1.0 | `#B4DCF0` | +| `0x0081C558` | `colorGrey` | 0.824 | 0.824 | 0.784 | 1.0 | `#D2D2C8` | +| `0x0081C568` | *(unnamed)* **orange** | 0.933 | 0.573 | 0.118 | 1.0 | `#EE921E` | +| `0x0081C578` | `colorGreen` | 0.5 | 1.0 | 0.498 | 1.0 | `#80FF7F` | + +**`0x0081C4D8` is new** — the 2026-06-16 cdb session skipped it, and it is +used by two table slots (`0x04` Speech_Direct_Send and `0x0B` Social_Send). +Its exact bytes are `aa f1 52 3f aa f1 52 3f 39 b4 c8 3e 00 00 80 3f`; +`0.8235294` and `0.39215687` are exactly 210/255 and 100/255, so retail +authored it as RGB(210, 210, 100). Adjacent entries not used by the chat +table: `0x0081C588` = white again, `0x0081C598` = black. + +Ship the **float** values, not the 8-bit approximations — several +(`colorLightRed`, `orange`) are not integral /255 and were authored as +decimal floats. + +**Provenance rule:** these 14 addresses appear in the whole 66 MB pseudo-C +dump exactly 14 times, and every one of those occurrences is inside +`BuildChatColorLookupTable`. Nothing else in the client reads or writes them. + +--- + +## 2. The index enum — `LogTextType` + +`docs/research/named-retail/acclient.h` has an `enum eChatTypes` +(line 4935) that stops at `eTextTypeTotalNumChannels = 0x19`. **That header +enum is stale** — it predates the 2013 build and does not cover the table. +The authoritative 2013 names come from +`LogTextTypeEnumMapper::LogTextTypeToString @ 0x006AFF90`, which is a literal +`switch` over `0x00`–`0x1F` emitting the canonical strings; anything `> 0x1F` +(and `0x1A`–`0x1E`, which fall through) returns `"Unknown"`. + +The field name `ChatDisplayInfo::m_ltt` (acclient.h line 40739, +written at `0x005CD89A`) is what pins the enum's name: **L**og**T**ext**T**ype. + +### 2.1 The complete table + +| Idx | `LogTextType` name | Color symbol | RGBA (float) | Hex | Source | +|---:|---|---|---|---|---| +| `0x00` | `Default` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | +| `0x01` | `All` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | +| `0x02` | `Speech` | `colorWhite` | 1, 1, 1, 1 | `#FFFFFF` | explicit | +| `0x03` | `Tell` | yellow `0x81C4C8` | 1, 1, 0.247, 1 | `#FFFF3F` | explicit | +| `0x04` | `Speech_Direct_Send` | dark yellow `0x81C4D8` | 0.824, 0.824, 0.392, 1 | `#D2D264` | explicit | +| `0x05` | `System` | `colorBrightPurple` | 1, 0.498, 1, 1 | `#FF7FFF` | explicit | +| `0x06` | `Combat` | `colorDarkRed` | 1, 0.247, 0.247, 1 | `#FF3F3F` | explicit | +| `0x07` | `Magic` | `colorLightBlue` | 0.247, 0.749, 1, 1 | `#3FBFFF` | explicit | +| `0x08` | `Channel` | `colorPink` | 1, 0.588, 0.588, 1 | `#FF9696` | explicit | +| `0x09` | `Channel_Send` | `colorPink` | 1, 0.588, 0.588, 1 | `#FF9696` | explicit | +| `0x0A` | `Social` | yellow `0x81C4C8` | 1, 1, 0.247, 1 | `#FFFF3F` | explicit | +| `0x0B` | `Social_Send` | dark yellow `0x81C4D8` | 0.824, 0.824, 0.392, 1 | `#D2D264` | explicit | +| `0x0C` | `Emote` | `colorGrey` | 0.824, 0.824, 0.784, 1 | `#D2D2C8` | explicit | +| `0x0D` | `Advancement` | `colorCyan` | 0.247, 0.863, 0.863, 1 | `#3FDCDC` | explicit | +| `0x0E` | `Abuse` | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | +| `0x0F` | `Help` | `colorDarkRed` | 1, 0.247, 0.247, 1 | `#FF3F3F` | explicit | +| `0x10` | `Appraisal` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | +| `0x11` | `Spellcasting` | `colorLightBlue` | 0.247, 0.749, 1, 1 | `#3FBFFF` | explicit | +| `0x12` | `Allegiance` | orange `0x81C568` | 0.933, 0.573, 0.118, 1 | `#EE921E` | explicit | +| `0x13` | `Fellowship` | yellow `0x81C4C8` | 1, 1, 0.247, 1 | `#FFFF3F` | explicit | +| `0x14` | `World_Broadcast` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | +| `0x15` | `Combat_Enemy` | `colorDarkRed` | 1, 0.247, 0.247, 1 | `#FF3F3F` | explicit | +| `0x16` | `Combat_Self` | `colorLightRed` | 0.96, 0.459, 0.447, 1 | `#F57572` | explicit | +| `0x17` | `Recall` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | +| `0x18` | `Craft` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | +| `0x19` | `Salvaging` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | +| `0x1A` | *(no string; "Unknown")* — **client-local text** | `colorBrightRed` | 1, 0, 0, 1 | `#FF0000` | explicit | +| `0x1B` | *(no string)* — **Turbine General** | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | +| `0x1C` | *(no string)* — **Turbine Trade** | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | +| `0x1D` | *(no string)* — **Turbine LFG** | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | +| `0x1E` | *(no string)* — **Turbine Roleplay** | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | +| `0x1F` | `Admin_Tell` | yellow `0x81C4C8` | 1, 1, 0.247, 1 | `#FFFF3F` | explicit | +| `0x20` | *(no string)* — **Turbine Society** (all 4 rooms) | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | +| `0x21` | *(no string; no producer found)* | orange `0x81C568` | 0.933, 0.573, 0.118, 1 | `#EE921E` | explicit | + +### 2.2 Notes on the unnamed slots + +- **`0x1A`** is not dead. `LogTextTypeToString` has no name for it, but it is + the single most-used literal in the client: **client-local text that never + reaches the wire** — `"You need an open vendor."`, the three + `cant_jump_*` strings, most command-parser errors. It also has a special + path in `AddTextToScroll` (§3.2): type `0x1A` skips the timestamp prefix + **and** skips the chat log file. Color: bright red. +- **`0x1B`–`0x1E`, `0x20`** are the Turbine (global community) chat rooms. + Their identity is not guesswork — `ChatRoomTracker::GetChatFormat @ + 0x005CD7C0` writes `ChatDisplayInfo::m_ltt` directly: + + | room field | `m_ltt` | + |---|---| + | `m_allegianceRoomID` | `0x12` (Allegiance) | + | `mGeneralChatRoomID` | `0x1B` | + | `mTradeChatRoomID` | `0x1C` | + | `mLFGChatRoomID` | `0x1D` | + | `mRoleplayChatRoomID` | `0x1E` | + | `mOlthoiChatRoomID` | `0x12` (reuses Allegiance) | + | `mSocietyChatRoomID`, `mSocietyCelHanChatRoomID`, `mSocietyEldWebChatRoomID`, `mSocietyRadBloChatRoomID` | `0x20` | + + This is why ACE's `ChatMessageType.cs` labels `0x1B`/`0x1E` "light cyan, + unknown purpose" — they are the Turbine rooms, and `colorBlueGrey` is + exactly that sky-blue. +- **`0x21`** has a color slot and a filter bit but **no producer anywhere in + the 2013 client**: `LogTextTypeToString` returns `"Unknown"`, + `IsLegalChannel` returns 0, no `AddTextToScroll` / `m_ltt` site emits it. + Treat it as reserved. Its orange matches `0x12` Allegiance, which hints at + an allegiance-adjacent type that was never shipped (or was server-only). + +--- + +## 3. Consumption trace — wire byte → pixel color + +### 3.1 The chain + +``` + + └─ ClientCommunicationSystem::Handle_Communication__* (type comes off the wire) + └─ ClientSystem::AddTextToScroll(text, type, fireToPlugin, windowId) @0x00563C50 + └─ ECM_UI::SendNotice_DisplayFinalStringInfo(type, body, prefix, windowId) @0x00692550 + └─ ChatInterface::RecvNotice_DisplayFinalStringInfo(type, body, prefix, windowId) @0x004F4640 + ├─ AppendStringInfoWithFont(m_chatLog, prefix, font=0, colorIdx=0x0C) ← timestamp, ALWAYS grey + └─ AppendStringInfoWithFont(m_chatLog, body, font=0, colorIdx=type) + └─ UIElement_Text::SetFontColorHelper(this, prop 0x1B, &m_curFontColor, type) @0x00466AC0 + └─ list.GetValue(type) → m_curFontColor +``` + +`type` is never transformed. It is the wire value, used as the array index. + +### 3.2 Two details worth porting + +**Timestamp prefix is hard-coded to index `0x0C`.** In +`RecvNotice_DisplayFinalStringInfo @0x004F46E9` the second `StringInfo` (the +`"%#H:%M:%S "` prefix built in `AddTextToScroll` when +`PlayerModule::DisplayTimeStamps()` is on) is appended with color index +`0x0C` — i.e. `colorGrey`, regardless of the message's own type. Retail's +timestamps are always grey. + +**Out-of-range index leaves the color alone.** `SetFontColorHelper` reads the +list's element count into `arg2`, then `if (arg4 < arg2) { GetValue(arg4) ... }`. +If the index is `>= 34`, it falls through without touching `m_curFontColor`, +so the run inherits the *previous* line's color. It does **not** fall back to +a default. (The green default only applies to the seven in-range slots the +builder never overwrites.) + +**Type `0x1A` bypasses the timestamp and the log file.** `AddTextToScroll` +branches on `arg3 == 0x1a` at `0x00563DE6`; the non-`0x1A` path is the one +that formats the timestamp and `fprintf`s to `ClientSystem::s_pLogFile`. + +### 3.3 Wire → type for the message kinds you asked about + +Anchors are the `ClientCommunicationSystem::Handle_Communication__*` handlers. + +| Inbound kind | Opcode (ACE) | Handler | Type passed | +|---|---|---|---| +| Local speech | `0x02BB` | `Handle_Communication__HearSpeech @0x005712A0` | **`arg5` verbatim from the wire** (`AddTextToScroll(..., arg5, ...)` at `0x0057154D` for "X says", `0x00571325` for the "You say" self-echo) | +| Ranged speech | `0x02BC` | `Handle_Communication__HearRangedSpeech @0x0056E550` → `gmCCommunicationSystem::HandleRangedTalkEvent @0x00589F60` | wire type (`arg6`) | +| Tell | `0x02BD` | `Handle_Communication__HearDirectSpeech @0x005715A0` | **`arg6` verbatim** (`AddTextToScroll(..., arg6, ...)` at `0x005718D8` / `0x00571834` / `0x0057160C`) | +| Emote / soul emote | `0x01E0` / `0x01E2` | `Handle_Communication__HearEmote @0x0057CBE0` (soul emote tail-calls it) | **hard-coded `0x0C`** at `0x0057CF94` | +| Legacy channel speech | `0x0147` `ChannelBroadcast` | `Handle_Communication__ChannelBroadcast @0x00570B90` | derived from the channel bit — see below | +| Turbine room speech | `0xF7DE` | `ChatRoomTracker::GetChatFormat @0x005CD7C0` → `m_ltt` | `0x12` / `0x1B`–`0x1E` / `0x20` — table in §2.2 | +| Server message / system | `0xF7E0` | dispatched through `RecvNotice_DisplayStringInfo @0x0056E890` | **wire type verbatim** | +| Client-local errors, command output | *(none)* | many sites | `0x1A` (or `0x00` for informational command output) | +| Combat / magic / advancement / recall / craft / salvage / appraisal | `0xF7E0` and friends | server-chosen | wire type verbatim — `0x06`/`0x15`/`0x16`, `0x07`/`0x11`, `0x0D`, `0x17`, `0x18`, `0x19`, `0x10` | +| Death messages | `0x019E` etc. | server-chosen | wire type verbatim (retail carries no special client-side death color) | + +`Handle_Communication__ChannelBroadcast` channel-bit → type (the `m_buffer_5` +variable feeding `AddTextToScroll` at `0x00571169`): + +| Channel bit | Prefix retail prints | Type | +|---|---|---| +| `0x0800` Fellowship | `[Fellowship]` | `0x13` | +| `0x1000` Patron / `0x2000` Vassal | `Your patron …` / `Your vassal …` | `0x0A` | +| `0x4000` Follower/Monarch | `Your follower …` | `0x0A` (hear) / `0x0B` (own send) | +| `0x1000000` Co-Vassals | `[Co-Vassals]` | `0x0A` | +| `0x2000000` Allegiance Broadcast | `[Allegiance Broadcast]` | `0x0A` | +| `0x4000000` | — | `0x13` | +| admin/audit/sentinel channels | `[]` | `0x0E` / `0x0F` | + +Note the split that surprises people: **legacy allegiance-family chat arrives +as `Social` (`0x0A`, yellow) / `Social_Send` (`0x0B`, dark yellow)**, while +`Allegiance` (`0x12`, orange) is reserved for the *Turbine* allegiance room. + +### 3.4 The plugin hook sees the same integer + +`AddTextToScroll` calls `IACPlugin::OnChatWindowText(bstr, type, &suppress)` +at `0x00563CA4` **before** any formatting, and a plugin returning +`suppress != 0` drops the line entirely. Relevant if acdream's plugin chat +API wants retail parity: the plugin contract is `(text, LogTextType, out +bool eaten)`. + +--- + +## 4. Configurability — colors are NOT user-settable; filters are + +**Colors: hard-coded, no override path.** + +1. The 14 `RGBAColor` addresses are referenced from exactly one function in + the entire binary (§1.2). +2. `BuildChatColorLookupTable` runs unconditionally at `PostInit` and ends with + `m_chatLog->SetProperty(list)`, which **replaces** property `0x1B` wholesale + — so even a LayoutDesc that authored a color list would be overwritten. +3. `ChatInterface::RecvNotice_GameplayOptionChanged @0x004F30E0` and + `ChatInterface::OnSetAttribute @0x004F3F60` handle only the window's text-type + filter and the two opacity values. Neither touches color. + +Conclusion: **the table above is the shipped behavior, not a default.** +There is nothing for acdream to make configurable for retail parity. + +**Filters: genuinely user-settable, per window.** + +`ChatInterface::UpdateFromPlayerModule @0x004F3920` reads +`PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)` into a 64-bit +`m_llTextTypeFilter`, and `RecvNotice_GameplayOptionChanged` live-updates it +when the option changes. `ChatInterface::TypeIsActive @0x004F2F10` tests +`(1ULL << type) & m_llTextTypeFilter`. Two consequences: + +- `UpdateFromPlayerModule` early-returns when `m_eWindowID == 0`, so the + **main window has no user filter** — it keeps the `PostInit` default. +- `RecvNotice_DisplayFinalStringInfo` displays when + `windowId == m_eWindowID` (explicitly addressed — e.g. command output uses + `m_idCurrentCommandSource`) **or** `windowId == 0 && TypeIsActive(type)` + (broadcast). + +`PostInit` default filters (`0x004F3DF9` switch on `m_oldState`; Binary Ninja +mis-attributes the low dword to `m_chatNewNonVisibleTextIndicator` — it is the +low half of the 64-bit filter, and the following `m_llTextTypeFilter = 0` +is the high half): + +| `m_oldState` | Low dword | Types | +|---|---|---| +| 1, 8 | `0xFBFFFFFF` | everything `0x00`–`0x1F` except `0x1A` | +| 2 | `0x0000101C` | `0x02` Speech, `0x03` Tell, `0x04` Speech_Direct_Send, `0x0C` Emote | +| 3 | `0x00040C00` | `0x0A` Social, `0x0B` Social_Send, `0x12` Allegiance | +| 4 | `0x00080000` | `0x13` Fellowship | +| 5 | `0x78000000` | `0x1B` General, `0x1C` Trade, `0x1D` LFG, `0x1E` Roleplay | + +Every default sets the **high** dword to 0, so `0x20` (Society) and `0x21` are +in no window's default filter — Society chat only appears once the player +enables it, which matches retail's opt-in Society channel. + +Squelching is a separate axis: `LogTextTypeEnumMapper::IsLegalChannel @ +0x006AFF40` whitelists exactly `0x02, 0x03, 0x06, 0x07, 0x0C, 0x10, 0x11, +0x12, 0x13, 0x15, 0x16, 0x17, 0x18, 0x19` as squelchable. + +--- + +## 5. acdream correction list + +**Current code:** +`src/AcDream.App/UI/Layout/ChatWindowController.cs:542` +(`RetailChatColor(ChatKind)`), driven from line 462. The `ChatKind` enum is +`src/AcDream.Core/Chat/ChatLog.cs:337`. (`ChatChannelKind` in +`src/AcDream.UI.Abstractions/ChatChannelKind.cs` is the **outbound** channel +selector and never reaches the color path — it needs no color change.) + +### 5.1 Per-arm verdict + +| `ChatKind` | acdream RGBA now | Retail type it represents | Retail RGBA | Verdict | +|---|---|---|---|---| +| `LocalSpeech` | 1, 1, 1, 1 `colorWhite` | `0x02` Speech | 1, 1, 1, 1 | ✅ **CONFIRMED** | +| `RangedSpeech` | 1, 1, 1, 1 `colorWhite` | wire type on `0x02BC`; ACE's own header documents `0x0C` for this opcode | 0.824, 0.824, 0.784, 1 `colorGrey` | ❌ **CHANGE** — must follow the wire type, not a constant | +| `Channel` | 0.247, 0.749, 1, 1 `colorLightBlue` | Turbine rooms `0x1B`–`0x1E`/`0x20`; legacy `0x0A`/`0x0B`/`0x13`; Turbine allegiance `0x12` | Turbine rooms 0.706, 0.863, 0.941 `colorBlueGrey`; Social 1, 1, 0.247; Social_Send 0.824, 0.824, 0.392; Fellowship 1, 1, 0.247; Allegiance 0.933, 0.573, 0.118 | ❌ **CHANGE** — `colorLightBlue` is `0x07` Magic / `0x11` Spellcasting, never a channel | +| `Tell` | 1, 0.498, 1, 1 `colorBrightPurple` | `0x03` Tell (incoming), `0x04` Speech_Direct_Send (own "You tell …") | incoming 1, 1, 0.247 `yellow`; own send 0.824, 0.824, 0.392 `dark yellow` | ❌ **CHANGE** — `colorBrightPurple` is `0x05` System | +| `System` | 0.5, 1, 0.498, 1 `colorGreen` | wire type; ACE `0xF7E0` uses `0x00`, `0x03`, `0x04`, `0x05`, `0x06`, `0x07`, `0x0D`, `0x10`, `0x11`, `0x17`, `0x18` | `0x00` → 0.5, 1, 0.498 (green, matches today); `0x05` → 1, 0.498, 1 (purple) | ❌ **CHANGE** — green is right only for wire type `0x00`; the collapse to one color is the bug | +| `Popup` | 0.5, 1, 0.498, 1 `colorGreen` | `0x0004 PopUpString` → `Handle_Communication__PopUpString @0x0057FE80` — a modal dialog, **not** a chat-log line in retail | n/a | ⚪ **OUT OF SCOPE** — no retail color; acdream's choice to render it in chat is an acdream divergence | +| `Emote` | 0.824, 0.824, 0.784, 1 `colorGrey` | `0x0C` Emote (hard-coded by `HearEmote`) | 0.824, 0.824, 0.784, 1 | ✅ **CONFIRMED** | +| `SoulEmote` | 0.824, 0.824, 0.784, 1 `colorGrey` | `0x0C` — `HearSoulEmote` tail-calls `HearEmote` at `0x0057D096` | 0.824, 0.824, 0.784, 1 | ✅ **CONFIRMED** | +| `Combat` | 0.96, 0.459, 0.447, 1 `colorLightRed` | `0x06` Combat, `0x15` Combat_Enemy, `0x16` Combat_Self | `0x06`/`0x15` → 1, 0.247, 0.247 `colorDarkRed`; `0x16` → 0.96, 0.459, 0.447 `colorLightRed` | ❌ **CHANGE** — today's value is correct only for `Combat_Self` | +| `_` fallback | 0.824, 0.824, 0.784, 1 `colorGrey` | unassigned in-range slots | 0.5, 1, 0.498, 1 `colorGreen` | ❌ **CHANGE** — retail's unset default is green; out-of-range indices keep the *previous* line's color | + +**Score: 3 confirmed, 6 changed, 1 out of scope.** + +### 5.2 The structural correction + +Every "CHANGE" above has the same root cause: acdream colors by a **synthetic +9-value `ChatKind`**, while retail colors by the **34-value wire +`LogTextType`**. Keeping `ChatKind` for routing/formatting is fine, but the +color must key off the wire integer. + +The wire value is already parsed and already in hand — no new parsing needed: + +- `HearSpeech.Parsed.ChatType` (`src/AcDream.Core.Net/Messages/HearSpeech.cs:64`) + — parsed today and **discarded** at the `OnLocalSpeech` call site + (`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:260`). +- `ServerMessage.Parsed.ChatType` (`src/AcDream.Core.Net/Messages/ServerMessage.cs:33`) + — already threaded through as + `social.Chat.OnSystemMessage(message.Message, message.ChatType)` + (`LiveSessionEventRouter.cs:268`), where `ChatLog.OnSystemMessage` parks it + in `ChatEntry.ChannelId`. It is available; it just is not used for color. +- `TurbineChat.ChatType` — available at + `LiveSessionEventRouter.RouteTurbineChat` (line 484), already used for the + display name via `TurbineChatDisplayNames.Resolve`. +- `GameEvents` tell/emote payloads (`src/AcDream.Core.Net/Messages/GameEvents.cs:53`) + carry `ChatType` too. + +The retail-faithful shape is a 34-entry `Vector4[]` built once (exactly as +`BuildChatColorLookupTable` does), indexed by the wire type, with +"index ≥ 34 → keep the previous run's color". Several call sites that +currently pass a made-up type would need the real one: +`ChatLog.OnSystemMessage(text, 0x1Au)` appears at five App composition sites +and `chatType: 0u` at three `GameEventWiring` sites — those are placeholders, +and `0x1A` happens to be the *correct* retail type for client-local text +(bright red), so those five are already right by accident. + +### 5.3 Coverage gap + +acdream has no representation at all for 22 of the 34 retail types: +`All`(0x01), `Speech_Direct_Send`(0x04), `Channel`(0x08), `Channel_Send`(0x09), +`Social`(0x0A), `Social_Send`(0x0B), `Advancement`(0x0D), `Abuse`(0x0E), +`Help`(0x0F), `Appraisal`(0x10), `Spellcasting`(0x11), `Allegiance`(0x12), +`Fellowship`(0x13), `World_Broadcast`(0x14), `Combat_Enemy`(0x15), +`Combat_Self`(0x16), `Recall`(0x17), `Craft`(0x18), `Salvaging`(0x19), +`Admin_Tell`(0x1F), Turbine `0x1B`–`0x1E`/`0x20`, and reserved `0x21`. +A table keyed by the wire integer closes all of them at once. + +### 5.4 Two documentation defects found in passing + +1. **`src/AcDream.Core.Net/Messages/HearSpeech.cs:39-48`** — the `ChatType` + legend in the XML doc comment is wrong on 4 of 6 entries. It claims + `0x02 = Combat`, `0x0B = Speech`, `0x0F = Emote`, `0x10 = Tell`. Retail: + `0x02 = Speech`, `0x0B = Social_Send`, `0x0F = Help`, `0x10 = Appraisal`, + `0x03 = Tell`, `0x0C = Emote`. Only `0x01` (Broadcast/AllChannels — retail + calls it `All`) and `0x11` (Spellcasting, the comment's "Syllables") are + close. +2. **`ChatWindowController.cs:536-541`** — the doc comment asserts "the four + common kinds (speech/tell/channel/system) are confirmed by the named + symbols". Speech is confirmed; tell, channel and system are all wrong. + That sentence should be deleted along with the fix. + +--- + +## 6. Cross-check against ACE + +`references/ACE/Source/ACE.Entity/Enum/ChatMessageType.cs` is the same index +space (its `LogTextTypeEnumMapper:` doc lines are literally the strings from +`LogTextTypeToString`), and its informal color notes independently corroborate +the decompiled table on every entry it comments: + +| ACE note | Retail table | Agrees? | +|---|---|---| +| `0x08` Channel "Light Pink Text" | `colorPink` | ✅ | +| `0x0A` Social "Bright Yellow Text" | yellow `#FFFF3F` | ✅ | +| `0x0B` Social_Send "Light Yellow Text" | dark yellow `#D2D264` | ✅ | +| `0x0E` Abuse "Light Cyan (skyblue?)" | `colorBlueGrey` | ✅ | +| `0x0F` Help "Red Text" | `colorDarkRed` | ✅ | +| `0x13` Fellowship "Bright Yellow Text" | yellow | ✅ | +| `0x14` WorldBroadcast "Green Text" | `colorGreen` (default fill) | ✅ | +| `0x15` CombatEnemy "Red Text" | `colorDarkRed` | ✅ | +| `0x16` CombatSelf "Pink Text" | `colorLightRed` (salmon) | ✅ | +| `0x19` Salvaging "Green Text" | `colorGreen` (default fill) | ✅ | +| `0x1B`, `0x1E` "Light cyan (sky blue)" | `colorBlueGrey` | ✅ | +| `0x1F` AdminTell "Bright Yellow Text" | yellow | ✅ | + +ACE stops at `0x1F` and comments `0x1A` out as "client doesn't display it" — +both are ACE gaps, not retail behavior: retail has 34 slots and `0x1A` is its +busiest client-local type. + +--- + +## 7. Reproduction commands + +```bash +# The builder +grep -n "BuildChatColorLookupTable" docs/research/named-retail/acclient_2013_pseudo_c.txt +sed -n '246117,246440p' docs/research/named-retail/acclient_2013_pseudo_c.txt + +# The 2013 type names +sed -n '695462,695790p' docs/research/named-retail/acclient_2013_pseudo_c.txt # LogTextTypeToString +sed -n '695397,695425p' docs/research/named-retail/acclient_2013_pseudo_c.txt # IsLegalChannel + +# Turbine room -> m_ltt +sed -n '479725,479870p' docs/research/named-retail/acclient_2013_pseudo_c.txt + +# Consumption +sed -n '247282,247330p' docs/research/named-retail/acclient_2013_pseudo_c.txt # RecvNotice_DisplayFinalStringInfo +sed -n '113699,113760p' docs/research/named-retail/acclient_2013_pseudo_c.txt # SetFontColorHelper +sed -n '368347,368520p' docs/research/named-retail/acclient_2013_pseudo_c.txt # AddTextToScroll +``` + +The RGBA quads were read straight out of the PDB-paired binary by walking the +PE section table (`.data` VA `0x80A000` → raw `0x40A000`) and unpacking +`<4f` at each 16-byte stride from `0x0081C4A8`. Re-run +`py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe"` +first — it must report `MATCH` before any address in this document is valid. diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 757ec2a1..b7b02c52 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -240,7 +240,11 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting bindings.Chat.OnSelfSent( ChatKind.Tell, command.Text, - targetOrChannel: command.TargetName); + targetOrChannel: command.TargetName, + // Retail's own "You tell ..." echo is Speech_Direct_Send + // (0x04), distinct from an incoming Tell's 0x03 — see + // ChatMessageType.OutgoingTell's "You tell ..." comment. + logTextType: 0x04u); return; } @@ -283,7 +287,11 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting bindings.Chat.OnSelfSent( ChatKind.Channel, command.Text, - targetOrChannel: legacy.Value.DisplayName); + targetOrChannel: legacy.Value.DisplayName, + // Precise per-bit own-send type (LegacyChannelChatType.Resolve's + // ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/ + // Vassal/Follower become 0x0B, the admin catch-all stays 0x0E. + logTextType: LegacyChannelChatType.Resolve(legacy.Value.ChannelId, ownSend: true)); } private ClientCommandController.Bindings BuildGuardedClientCommands( diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 530fa54d..12a76231 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -327,8 +327,10 @@ internal sealed class LiveSessionRuntimeFactory ToggleUiLock: () => _interaction.Settings.SetUiLocked( !_interaction.Settings.Gameplay.LockUI), + // Client-local text (never reaches the wire), 0x1A — same + // category as ChatVM.ShowSystemMessage. ShowSystemMessage: - text => _domain.Communication.Chat.OnSystemMessage(text, 0u), + text => _domain.Communication.Chat.OnSystemMessage(text, 0x1Au), ShowWeenieError: code => _domain.Communication.Chat.OnWeenieError(code, null), PlayerPublicWeenieBitfield: () => diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index c41561dd..7c7625ba 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -423,8 +423,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// /// Convert the ChatVM's detailed lines to the transcript's - /// record format, applying retail-faithful - /// per- colors. + /// record format, applying retail's exact + /// colors keyed by each entry's + /// (NOT ). /// private IReadOnlyList GetTranscriptLines(ChatVM vm) { @@ -456,12 +457,19 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta : debugFont is { } bf ? s => bf.MeasureWidth(s) : static s => s.Length * 7f; + // Retail's font-color state (m_curFontColor) persists across every + // appended line — an out-of-range LogTextType leaves it unchanged + // rather than reverting to a default (research doc §3.2). Seed the + // carry with retail's own unfilled-slot default (colorGreen, index + // 0x00) and fold forward across the transcript in order. + RetailChatColorTable.TryGetColor(0x00u, out Vector4 currentColor); var result = new List(detailed.Count); foreach (var d in detailed) { - var color = RetailChatColor(d.Kind); + if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) + currentColor = resolved; foreach (var frag in WrapText(d.Text, maxW, measure)) - result.Add(new UiText.Line(frag, color)); + result.Add(new UiText.Line(frag, currentColor)); } return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont); } @@ -531,28 +539,6 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta if (line.Length > 0) yield return line.ToString(); } - /// - /// Per- text color — the EXACT retail RGBA values read from a - /// live retail client via cdb (the named RGBAColor constants at acclient - /// 0x81c4a8+, e.g. colorWhite/colorBrightPurple/colorLightBlue/ - /// colorGreen, used by ChatInterface::BuildChatColorLookupTable @0x4f31c0). - /// The four common kinds (speech/tell/channel/system) are confirmed by the named - /// symbols + universal AC convention; the rarer kinds map to the nearest named color. - /// - private static Vector4 RetailChatColor(ChatKind kind) => kind switch - { - ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite - ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout) - ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue - ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple - ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen - ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast) - ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey - ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey - ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed - _ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback) - }; - public void Dispose() { if (_disposed) return; diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 46151420..11b6f632 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -102,21 +102,28 @@ public static class GameEventWiring registrar.Register(GameEventType.ChannelBroadcast, e => { var p = GameEvents.ParseChannelBroadcast(e.Payload.Span); + // logTextType left unset — ChatLog.OnChannelBroadcast derives it + // from ChannelId via LegacyChannelChatType.Resolve(ownSend: false), + // the correct branch for this inbound (hear) 0x0147 handler. if (p is not null) chat.OnChannelBroadcast(p.Value.ChannelId, p.Value.SenderName, p.Value.Message); }); registrar.Register(GameEventType.Tell, e => { var p = GameEvents.ParseTell(e.Payload.Span); - if (p is not null) chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid); + // p.Value.ChatType is the wire LogTextType (normally 0x03 Tell) — + // passed through verbatim, matching HearSpeech's zero-remap rule. + if (p is not null) + chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid, p.Value.ChatType); }); 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. The exact - // retail rendering style for transient strings belongs to the - // chat colour/text work, not to this parser. + // 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. var s = GameEvents.ParseTransient(e.Payload.Span); if (s is not null) chat.OnSystemMessage(s, chatType: 0u); }); @@ -132,6 +139,10 @@ public static class GameEventWiring string text = string.IsNullOrEmpty(p.Value.Name) ? $"You have played for {p.Value.Age}." : $"{p.Value.Name} has played for {p.Value.Age}."; + // Decomp-confirmed 0x00 Default: + // CM_Character::DispatchUI_QueryAgeResponse @0x006A2E40 -> + // Handle_Character__QueryAgeResponse @0x005711D0 -> + // AddTextToScroll(..., 0, 1, 0), pc:382186. chat.OnSystemMessage(text, chatType: 0u); }); if (onConfirmationRequest is not null) @@ -247,7 +258,12 @@ public static class GameEventWiring registrar.Register(GameEventType.VictimNotification, e => { var p = GameEvents.ParseVictimNotification(e.Payload.Span); - if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error); + // VictimNotification (0x01AC) and KillerNotification (0x01AD) + // both dispatch through the SAME retail handler, + // ClientCombatSystem::HandleKillerNotificationEvent @0x0056C410 + // (pc:359548-359559), which calls AddTextToScroll(..., 0, 1, 0) + // — LogTextType 0x00 Default, not a combat color. + if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error, logTextType: 0x00u); }); registrar.Register(GameEventType.DefenderNotification, e => { @@ -285,7 +301,8 @@ public static class GameEventWiring registrar.Register(GameEventType.KillerNotification, e => { var p = GameEvents.ParseKillerNotification(e.Payload.Span); - if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info); + // Same handler/type as VictimNotification above — 0x00 Default. + if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info, logTextType: 0x00u); }); // ── Spells ──────────────────────────────────────────────── @@ -526,6 +543,10 @@ 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); }); diff --git a/src/AcDream.Core.Net/Messages/HearSpeech.cs b/src/AcDream.Core.Net/Messages/HearSpeech.cs index e0fc5669..6c86e61f 100644 --- a/src/AcDream.Core.Net/Messages/HearSpeech.cs +++ b/src/AcDream.Core.Net/Messages/HearSpeech.cs @@ -36,16 +36,24 @@ namespace AcDream.Core.Net.Messages; /// /// /// -/// ChatType (from ACE): +/// ChatType (LogTextType — corrected 2026-08-09, Campaign CH slice CH1; +/// the previous legend here had 4 of 6 entries wrong, cf. research doc +/// docs/research/2026-08-09-chat-retail-color-table.md §5.4.1): /// -/// 0x01 = Broadcast -/// 0x02 = Combat -/// 0x0B = Speech -/// 0x0F = Emote -/// 0x10 = Tell -/// 0x11 = Syllables (spell casting) +/// 0x01 = All (Broadcast/AllChannels) +/// 0x02 = Speech +/// 0x03 = Tell +/// 0x0B = Social_Send +/// 0x0C = Emote +/// 0x0F = Help +/// 0x10 = Appraisal +/// 0x11 = Spellcasting (syllables) /// other values in ACE ChatMessageType.cs /// +/// This value is passed through VERBATIM as the chat line's +/// LogTextType — zero remapping (retail's +/// Handle_Communication__HearSpeech @0x005712A0 feeds the raw +/// wire word straight into AddTextToScroll). /// /// public static class HearSpeech diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 6b5cbea8..2623ae6b 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -93,7 +93,15 @@ public sealed class ChatLog /// Port from holtburger /// references/holtburger/.../client/messages.rs lines 476-487. /// - public void OnLocalSpeech(string sender, string text, uint senderGuid, bool isRanged) + /// + /// The wire chatType carried by HearSpeech/HearRangedSpeech + /// (speech.ChatType) — passed through VERBATIM, with zero + /// remapping, matching retail's Handle_Communication__HearSpeech + /// @0x005712A0 (the raw arg5 feeds AddTextToScroll + /// directly). Defaults to 0x02 (Speech) for callers that + /// don't have a wire value in hand. + /// + public void OnLocalSpeech(string sender, string text, uint senderGuid, bool isRanged, uint logTextType = 0x02u) { // Phase J: ACE's HandleActionTalk broadcasts a HearSpeech echo // back to the sender too. Detect own echo by guid match and @@ -107,7 +115,10 @@ public sealed class ChatLog Sender: effectiveSender, Text: text, SenderGuid: senderGuid, - ChannelId: 0)); + ChannelId: 0) + { + LogTextType = logTextType, + }); } /// EmoteText (0x01E0) — server-driven third-person emote. @@ -118,7 +129,13 @@ public sealed class ChatLog Sender: senderName, Text: text, SenderGuid: senderGuid, - ChannelId: 0)); + ChannelId: 0) + { + // Retail hard-codes Emote (0x0C) for every HearEmote line — + // ClientCommunicationSystem::HearEmote @0x0057CBE0, the + // literal constant at 0x0057CF94. Not a wire value. + LogTextType = 0x0Cu, + }); } /// SoulEmote (0x01E2) — complex emote (chat + paired animation). @@ -129,7 +146,12 @@ public sealed class ChatLog Sender: senderName, Text: text, SenderGuid: senderGuid, - ChannelId: 0)); + ChannelId: 0) + { + // HearSoulEmote tail-calls HearEmote @0x0057D096 — same + // hard-coded 0x0C. + LogTextType = 0x0Cu, + }); } /// PlayerKilled (0x019E) — death announcement. @@ -147,7 +169,18 @@ public sealed class ChatLog Sender: "", Text: deathMessage, SenderGuid: victimGuid, - ChannelId: killerGuid)); + ChannelId: killerGuid) + { + // Inferred by analogy from the sibling GameEvents this opcode + // shares a dispatch pattern with: VictimNotification (0x01AC) + // and KillerNotification (0x01AD) both route through the SAME + // retail handler, ClientCombatSystem::HandleKillerNotification + // Event @0x0056C410 (cases 0xa/0xb of the combat-envelope + // switch, pc:359548-359559), which calls + // AddTextToScroll(..., 0, 1, 0) — type 0x00 Default. No direct + // decomp citation was traced for 0x019E PlayerKilled itself. + LogTextType = 0x00u, + }); } /// WeenieError (0x028A) / WeenieErrorWithString (0x028B). @@ -178,7 +211,15 @@ public sealed class ChatLog Sender: "", Text: text, SenderGuid: 0, - ChannelId: errorId)); + ChannelId: errorId) + { + // Retail's HandleFailureEvent @0x00571990 dispatches per ERROR + // CODE across an ~87-case switch, mostly AddTextToScroll(..., + // 0, ...) with a scattered handful at 0x1a (client-local red). + // A full per-code port is future work; 0x00 (Default) matches + // the switch's majority behavior and is the safe baseline. + LogTextType = 0x00u, + }); } /// @@ -190,7 +231,21 @@ public sealed class ChatLog /// ChatVM formatter renders entries as /// "[ChannelName] Sender says, \"text\"" when set. /// - public void OnChannelBroadcast(uint channelId, string sender, string text, string channelName = "") + /// + /// The retail LogTextType for this line. When + /// (the legacy 0x0147 default), it is derived + /// from via + /// as a HEARD (not + /// own-send) message — correct for this method's only production + /// caller, the inbound ChannelBroadcast GameEvent handler. + /// TurbineChat-sourced calls MUST pass an explicit value computed + /// from the room's TurbineChat.ChatType instead — the wire + /// there is an opaque per-session room + /// GUID, not a legacy channel bitflag, and the two id spaces must + /// never be conflated. + /// + public void OnChannelBroadcast( + uint channelId, string sender, string text, uint? logTextType = null, string channelName = "") { Append(new ChatEntry( Kind: ChatKind.Channel, @@ -200,18 +255,28 @@ public sealed class ChatLog ChannelId: channelId) { ChannelName = channelName, + LogTextType = logTextType ?? LegacyChannelChatType.Resolve(channelId, ownSend: false), }); } /// GameEvent Tell (0x02BD) — whisper received. - public void OnTellReceived(string sender, string text, uint senderGuid) + /// + /// The wire chatType from the Tell GameEvent payload + /// (GameEvents.Tell.ChatType) — retail's normal value is + /// 0x03 (Tell), which is also this parameter's default for + /// callers without a wire value in hand. + /// + public void OnTellReceived(string sender, string text, uint senderGuid, uint logTextType = 0x03u) { Append(new ChatEntry( Kind: ChatKind.Tell, Sender: sender, Text: text, SenderGuid: senderGuid, - ChannelId: 0)); + ChannelId: 0) + { + LogTextType = logTextType, + }); } /// @@ -241,10 +306,28 @@ public sealed class ChatLog Sender: "", Text: text, SenderGuid: 0, - ChannelId: chatType)); + ChannelId: chatType) + { + // `chatType` IS the retail LogTextType here — every caller + // (ServerMessage.ChatType, GameEventWiring's transient/ + // query-age/use-done sites, App's client-command echoes) + // already passes the wire/retail-correct value. + LogTextType = chatType, + }); } - /// GameEvent PopupString (0x0004) — modal dialog text. + /// + /// GameEvent PopupString (0x0004) — modal dialog text. + /// + /// + /// Retail shows PopUpString as a MODAL DIALOG + /// (Handle_Communication__PopUpString @0x0057FE80), never as a + /// chat-log line — acdream's choice to render it in chat at all is a + /// registered divergence (register row AP-175). Fixed at LogTextType + /// 0x00 (Default/green) to preserve the color this entry has + /// always rendered with; retail has no chat color for this type since + /// it never reaches the chat log. + /// public void OnPopup(string text) { Append(new ChatEntry( @@ -252,7 +335,10 @@ public sealed class ChatLog Sender: "", Text: text, SenderGuid: 0, - ChannelId: 0)); + ChannelId: 0) + { + LogTextType = 0x00u, + }); } /// @@ -266,7 +352,19 @@ public sealed class ChatLog /// warning().combat() / error().combat() tag flow at /// chat.rs:221-308. /// - public void OnCombatLine(string text, Combat.CombatLineKind kind = Combat.CombatLineKind.Info) + /// + /// The retail LogTextType for this combat line. Callers should + /// pass one of the ACE-cited combat types (0x16 Combat_Self for + /// lines about the local player's OWN offensive action, 0x15 + /// Combat_Enemy for lines about an enemy's action against the local + /// player — see ) or 0x00 + /// Default for retail's decompiled kill/death-notification color + /// (HandleKillerNotificationEvent @0x0056C410). Defaults to + /// 0x06 (the generic Combat slot) for callers with no more + /// specific classification in hand. + /// + public void OnCombatLine( + string text, Combat.CombatLineKind kind = Combat.CombatLineKind.Info, uint logTextType = 0x06u) { Append(new ChatEntry( Kind: ChatKind.Combat, @@ -276,6 +374,7 @@ public sealed class ChatLog ChannelId: 0) { CombatKind = kind, + LogTextType = logTextType, }); } @@ -300,7 +399,18 @@ public sealed class ChatLog /// discriminator the formatter uses to render outgoing-vs-incoming /// (a real incoming Tell carries the sender's player guid). /// - public void OnSelfSent(ChatKind kind, string text, string targetOrChannel = "") + /// + /// The retail LogTextType for this self-sent line. When + /// , defaults to 0x04 Speech_Direct_Send + /// for Tell (retail's own-echo "You tell ..." type — cross-check + /// ACE's ChatMessageType.OutgoingTell comment "You tell ...") or + /// 0x0B Social_Send for Channel (the simplified own-send default + /// research doc §3.3 records; the LiveSessionCommandRouter production + /// caller overrides this with the precise per-channel-bit value from + /// instead of relying on + /// this fallback). + /// + public void OnSelfSent(ChatKind kind, string text, string targetOrChannel = "", uint? logTextType = null) { Append(new ChatEntry( Kind: kind, @@ -315,6 +425,7 @@ public sealed class ChatLog ChannelId: 0) { ChannelName = kind == ChatKind.Channel ? targetOrChannel : "", + LogTextType = logTextType ?? (kind == ChatKind.Tell ? 0x04u : 0x0Bu), }); } @@ -378,4 +489,17 @@ public readonly record struct ChatEntry( /// Falls back to "ch {ChannelId}" if not populated. /// public string ChannelName { get; init; } = ""; + + /// + /// Campaign CH slice CH1: the retail wire LogTextType + /// (0x00-0x21) that keys + /// RetailChatColorTable/ChatInterface::BuildChatColorLookupTable + /// @0x004F31C0. This is the FULL 34-value retail index space, NOT + /// — retail colors by this integer, never by our + /// synthetic 9-value . Populated by every + /// OnXxx ingestion method above; defaults to 0x00 + /// (Default/green, retail's own unfilled-slot default) for any entry + /// constructed without setting it explicitly. + /// + public uint LogTextType { get; init; } = 0x00u; } diff --git a/src/AcDream.Core/Chat/CombatChatTranslator.cs b/src/AcDream.Core/Chat/CombatChatTranslator.cs index d1b8ab3a..73d2304b 100644 --- a/src/AcDream.Core/Chat/CombatChatTranslator.cs +++ b/src/AcDream.Core/Chat/CombatChatTranslator.cs @@ -115,7 +115,11 @@ public sealed class CombatChatTranslator : IDisposable // grows that field, append " Critical hit." here. "", FormatAttackConditionsSuffix(0)); - _chat.OnCombatLine(line, CombatLineKind.Info); + // Combat_Self (0x16): retail squelch-checks the attacker's OWN + // outgoing-hit notification against ChatMessageType.CombatSelf — + // references/ACE/Source/ACE.Server/WorldObjects/Player_Combat.cs:162-163 + // (GameEventAttackerNotification, "You hit X..."). + _chat.OnCombatLine(line, CombatLineKind.Info, logTextType: 0x16u); } private void HandleDamageTaken(CombatState.DamageIncoming e) @@ -138,21 +142,33 @@ public sealed class CombatChatTranslator : IDisposable sb.Append('.'); if (e.Critical) sb.Append(" Critical hit."); sb.Append(FormatAttackConditionsSuffix(0)); - _chat.OnCombatLine(sb.ToString(), CombatLineKind.Warning); + // Combat_Enemy (0x15): retail squelch-checks the defender's + // incoming-hit notification against ChatMessageType.CombatEnemy — + // references/ACE/Source/ACE.Server/WorldObjects/Player_Combat.cs:541 + // (GameEventDefenderNotification, "X hit you..."). + _chat.OnCombatLine(sb.ToString(), CombatLineKind.Warning, logTextType: 0x15u); } private void HandleMissedOutgoing(string defenderName) { // chat.rs:286-291 — EvasionAttackerNotification: // "{} evaded your attack." - _chat.OnCombatLine($"{defenderName} evaded your attack.", CombatLineKind.Info); + // Combat_Self (0x16): this is about the LOCAL PLAYER'S OWN attack + // missing — retail squelch-checks GameEventEvasionAttackerNotification + // against CombatSelf, same family as the hit-dealt line above — + // references/ACE/Source/ACE.Server/WorldObjects/Player_Combat.cs:150. + _chat.OnCombatLine($"{defenderName} evaded your attack.", CombatLineKind.Info, logTextType: 0x16u); } private void HandleEvadedIncoming(string attackerName) { // chat.rs:292-297 — EvasionDefenderNotification: // "You evaded {}'s attack." - _chat.OnCombatLine($"You evaded {attackerName}'s attack.", CombatLineKind.Info); + // Combat_Enemy (0x15): this is about an ENEMY'S attack (that the + // local player evaded) — retail squelch-checks + // GameEventEvasionDefenderNotification against CombatEnemy — + // references/ACE/Source/ACE.Server/WorldObjects/Player_Combat.cs:345. + _chat.OnCombatLine($"You evaded {attackerName}'s attack.", CombatLineKind.Info, logTextType: 0x15u); } private void HandleKillLanded(string victimName, uint victimGuid) @@ -164,7 +180,12 @@ public sealed class CombatChatTranslator : IDisposable // synthesize a minimal "You killed Foo." line here. The // detailed sentence (used by retail) arrives separately via // ChatLog.OnPlayerKilled and is rendered as ChatKind.System. - _chat.OnCombatLine($"You killed {victimName}.", CombatLineKind.Info); + // LogTextType 0x00 Default: retail's own kill/death notification + // handler (VictimNotification 0x01AC + KillerNotification 0x01AD, + // both via ClientCombatSystem::HandleKillerNotificationEvent + // @0x0056C410) calls AddTextToScroll(..., 0, 1, 0) — see + // ChatLog.OnPlayerKilled's identical citation. + _chat.OnCombatLine($"You killed {victimName}.", CombatLineKind.Info, logTextType: 0x00u); } // ── Formatters (ported VERBATIM from chat.rs:561-595) ─────────────────── diff --git a/src/AcDream.Core/Chat/LegacyChannelChatType.cs b/src/AcDream.Core/Chat/LegacyChannelChatType.cs new file mode 100644 index 00000000..e77e16e1 --- /dev/null +++ b/src/AcDream.Core/Chat/LegacyChannelChatType.cs @@ -0,0 +1,77 @@ +namespace AcDream.Core.Chat; + +/// +/// Maps a legacy ChatChannel (0x0147) bitflag id to retail's wire +/// LogTextType for the chat color table. +/// +/// +/// Faithful port of ClientCommunicationSystem::Handle_Communication__ +/// ChannelBroadcast @0x00570B90 (Sept 2013 EoR build). Retail branches +/// on the channel bit AND on whether the sender-name buffer is the +/// single-char "self" sentinel (m_buffer->m_len == 1) — i.e. +/// whether this is the local player's OWN outgoing message +/// ("You say to your patron, ...") vs. hearing someone else +/// ("Your patron X tells you, ..."). 's +/// models that same branch. +/// +/// +/// +/// Per-bit findings from the decompiled dispatch (not all bits behave +/// the same for own-send — only Patron/Vassal/Follower get a distinct +/// send-color; Fellowship, Co-Vassals, Allegiance Broadcast, and the +/// generic/admin catch-all use the SAME type for hear and send): +/// +/// +public static class LegacyChannelChatType +{ + /// + /// Resolve (the ChannelBroadcast + /// wire channel id, a single flag bit) to a LogTextType. + /// + /// The wire channel id / bitflag. + /// + /// when this is the local player's own outgoing + /// message (retail's m_buffer->m_len == 1 self-sentinel + /// branch); when hearing another sender. + /// + public static uint Resolve(uint channelBit, bool ownSend) => channelBit switch + { + // Fellowship: same type ("[Fellowship] ...") for hear and send. + // pc:00570e48 (hear, m_buffer_5=0x13) / pc:00570d08 (send, 0x13). + 0x0800u => 0x13u, + + // Patron / Vassal / Follower(Monarch): hear = Social (0xA, + // "Your patron/vassal/follower X tells you..."); OWN send = + // Social_Send (0xB, "You say to your patron/vassal/follower..."). + // pc:00570e50/00570e58 (Patron/Vassal hear, 0xa) + pc:00570e40 + // (Follower hear, 0xa); pc:00570c07/00570c21 (all three own-send + // via the shared label_570c21, 0xb). + 0x1000u => ownSend ? 0x0Bu : 0x0Au, // Patron + 0x2000u => ownSend ? 0x0Bu : 0x0Au, // Vassal + 0x4000u => ownSend ? 0x0Bu : 0x0Au, // Follower / Monarch + + // Co-Vassals / Allegiance Broadcast: same type for hear and send. + // pc:00571025/00571014 (hear, 0xa) / pc:00570e17/00570df7 (send, 0xa). + 0x1000000u => 0x0Au, // Co-Vassals + 0x2000000u => 0x0Au, // Allegiance Broadcast + + // Unnamed bit — no producer traced, but retail's own dispatch + // assigns it 0x13 (Fellowship's slot) for both hear and send. + // pc:00570d43 (send, 0x13); the hear branch mirrors it via the + // same ebp==0x4000000 special case one level up in the dispatch. + 0x4000000u => 0x13u, + + // The single named non-family bit inside the generic catch-all: + // 0x400 gets its OWN color (Help, 0xF) where every other + // unmatched bit gets the catch-all (Abuse, 0xE). Both branches + // (hear label_570f0a and send label_570d4f) test `ebp != 0x400` + // with the identical 0xe/0xf split. + 0x0400u => 0x0Fu, + + // Generic/admin/audit/sentinel catch-all: retail's + // " says on the X channel" / "You say on the X channel" + // template, color 0xE (Abuse). pc:00570f1d (hear) / pc:00570d62 + // (send) — both compute the same constant 0xe. + _ => 0x0Eu, + }; +} diff --git a/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs b/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs index 2c1ad9c5..1ab4f079 100644 --- a/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs +++ b/src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs @@ -223,10 +223,12 @@ internal sealed class HeadlessGameplayOperations session!.SendCastTargetedSpell(targetId, spellId); } + // Client-local text (a bot script's own injected message, never + // reaches the wire), 0x1A — same category as ChatVM.ShowSystemMessage. public void DisplayMessage(string message) => RequireRuntime().CommunicationOwner.Chat.OnSystemMessage( message, - chatType: 0u); + chatType: 0x1Au); public void IncrementBusy() => RequireRuntime().ActionOwner.Transactions diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index aabf5772..8e9fc6fa 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -261,7 +261,12 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting speech.SenderName, speech.Text, speech.SenderGuid, - speech.IsRanged)); + speech.IsRanged, + // speech.ChatType is passed through VERBATIM — retail's + // Handle_Communication__HearSpeech @0x005712A0 feeds the + // raw wire word straight into AddTextToScroll with zero + // remapping (research doc §3.3 / HearSpeech.cs doc). + speech.ChatType)); Subscribe( h => session.ServerMessageReceived += h, h => session.ServerMessageReceived -= h, @@ -481,11 +486,16 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message) return; + // message.RoomId is an opaque per-session Turbine room GUID, not a + // legacy channel bitflag — ChatLog.OnChannelBroadcast's default + // (legacy-bit) LogTextType derivation would misclassify it, so the + // room's own ChatType maps to LogTextType explicitly here instead. chat.OnChannelBroadcast( message.RoomId, message.SenderName, message.Message, - TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType)); + logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType), + channelName: TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType)); } private static void Validate( diff --git a/src/AcDream.Runtime/Session/TurbineChatDisplayNames.cs b/src/AcDream.Runtime/Session/TurbineChatDisplayNames.cs index ba1e6fe4..8557ecc8 100644 --- a/src/AcDream.Runtime/Session/TurbineChatDisplayNames.cs +++ b/src/AcDream.Runtime/Session/TurbineChatDisplayNames.cs @@ -19,4 +19,31 @@ internal static class TurbineChatDisplayNames TurbineChat.ChatType.Olthoi => "Olthoi", _ => $"Room 0x{roomId:X8}", }; + + /// + /// Map a Turbine room's TurbineChat.ChatType to retail's wire + /// LogTextType for the chat color table. Faithful port of + /// ChatRoomTracker::GetChatFormat @0x005CD7C0, which writes + /// ChatDisplayInfo::m_ltt directly per room field (research doc + /// docs/research/2026-08-09-chat-retail-color-table.md §2.2): + /// General/Trade/LFG/Roleplay each get their OWN dedicated slot + /// (0x1B-0x1E); every Society variant collapses to the + /// SAME slot (0x20); Allegiance and Olthoi both reuse the + /// Allegiance slot (0x12) rather than getting their own. + /// + public static uint LogTextType(uint chatType) => + (TurbineChat.ChatType)chatType switch + { + TurbineChat.ChatType.Allegiance => 0x12u, + TurbineChat.ChatType.General => 0x1Bu, + TurbineChat.ChatType.Trade => 0x1Cu, + TurbineChat.ChatType.Lfg => 0x1Du, + TurbineChat.ChatType.Roleplay => 0x1Eu, + TurbineChat.ChatType.Society => 0x20u, + TurbineChat.ChatType.SocietyCelHan => 0x20u, + TurbineChat.ChatType.SocietyEldWeb => 0x20u, + TurbineChat.ChatType.SocietyRadBlo => 0x20u, + TurbineChat.ChatType.Olthoi => 0x12u, + _ => 0x00u, + }; } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index 9cb8cb1f..9e6bf097 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -153,9 +153,19 @@ public sealed class ChatPanel : IPanel for (int i = 0; i < lines.Count; i++) { var line = lines[i]; - if (line.Kind == ChatKind.Combat && line.CombatKind is { } ck) + if (line.Kind == ChatKind.Combat) { - renderer.TextColored(ColorForCombat(ck), line.Text); + // Campaign CH slice CH1: color combat lines from the + // retail LogTextType table (Combat_Self/Combat_Enemy/ + // Default per CombatChatTranslator's ACE-cited + // mapping) instead of the CombatLineKind info/ + // warning/error severity bucket. Every LogTextType + // CombatChatTranslator emits is in-range (<0x22), so + // the fallback below is defensive only. + Vector4 color = RetailChatColorTable.TryGetColor(line.LogTextType, out var resolved) + ? resolved + : ColorForCombat(line.CombatKind ?? CombatLineKind.Info); + renderer.TextColored(color, line.Text); } else { diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index a6864c61..aecacdc5 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -112,7 +112,13 @@ public sealed class ChatVM : IDisposable /// client-handled commands (/help, /clear, future) to surface /// local feedback without round-tripping the server. /// - public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0); + /// + /// LogTextType 0x1A: this text never reaches the wire — the + /// same "client-local text" category as retail's own command-parser + /// errors and cant_jump_* strings (research doc + /// docs/research/2026-08-09-chat-retail-color-table.md §2.2). + /// + public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0x1Au); /// /// Drain the chat log. Used by the /clear client-side command. @@ -256,7 +262,9 @@ public sealed class ChatVM : IDisposable /// can pick the right rendering primitive /// per entry (plain Text for most kinds; TextColored /// for combat lines, with the rgba chosen from - /// ). + /// ). Campaign CH slice CH1 also + /// carries through — the retail + /// color key, keyed independently of . /// public IReadOnlyList RecentLinesDetailed() { @@ -272,7 +280,8 @@ public sealed class ChatVM : IDisposable lines[i] = new FormattedLine( Text: FormatEntry(entry), Kind: entry.Kind, - CombatKind: entry.CombatKind); + CombatKind: entry.CombatKind, + LogTextType: entry.LogTextType); } return lines; } @@ -284,7 +293,12 @@ public sealed class ChatVM : IDisposable /// to pick a rendering primitive /// (Text vs TextColored(rgba)). /// +/// +/// Campaign CH slice CH1: the retail wire LogTextType that keys +/// — see . +/// public readonly record struct FormattedLine( string Text, ChatKind Kind, - CombatLineKind? CombatKind); + CombatLineKind? CombatKind, + uint LogTextType); diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs new file mode 100644 index 00000000..3e6fff8b --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs @@ -0,0 +1,109 @@ +using System.Numerics; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// +/// The exact retail chat font-color lookup table, indexed by the wire +/// LogTextType (the same integer ACE calls ChatMessageType). +/// +/// +/// Faithful port of ChatInterface::BuildChatColorLookupTable @0x004F31C0 +/// (Sept 2013 EoR build; verified against the PDB-paired binary's .data +/// section, image base 0x400000, RGBAColor constants at 0x0081C4A8+). +/// Retail default-fills all 34 slots (indices 0x00-0x21, 34 loop +/// iterations) with colorGreen, then overwrites 27 of them with 13 named +/// colors; the 7 slots retail never overwrites (0x00, 0x01, 0x10, 0x14, 0x17, +/// 0x18, 0x19) stay green. Every color's alpha is 1.0 in the source data. +/// Full derivation: docs/research/2026-08-09-chat-retail-color-table.md §1-2. +/// +/// +/// +/// Out-of-range rule. Retail's consumer, +/// UIElement_Text::SetFontColorHelper @0x00466AC0, reads the list's +/// element count and only applies a new color when the index is IN range; +/// an index >= 34 falls through leaving m_curFontColor untouched — the +/// line inherits whatever color the PREVIOUS line resolved to, not a default. +/// models this: it returns +/// for an out-of-range index instead of substituting a fallback color, so +/// callers can implement the same "keep the last color" carry-forward. +/// +/// +public static class RetailChatColorTable +{ + // Named RGBAColor constants (retail .data, 0x0081C4A8-0x0081C578). + private static readonly Vector4 ColorWhite = new(1f, 1f, 1f, 1f); + private static readonly Vector4 Yellow = new(1f, 1f, 0.247f, 1f); + private static readonly Vector4 DarkYellow = new(0.824f, 0.824f, 0.392f, 1f); + private static readonly Vector4 ColorBrightPurple = new(1f, 0.498f, 1f, 1f); + private static readonly Vector4 ColorDarkRed = new(1f, 0.247f, 0.247f, 1f); + private static readonly Vector4 ColorLightRed = new(0.96f, 0.459f, 0.447f, 1f); + private static readonly Vector4 ColorLightBlue = new(0.247f, 0.749f, 1f, 1f); + private static readonly Vector4 ColorPink = new(1f, 0.588f, 0.588f, 1f); + private static readonly Vector4 ColorCyan = new(0.247f, 0.863f, 0.863f, 1f); + private static readonly Vector4 ColorBlueGrey = new(0.706f, 0.863f, 0.941f, 1f); + private static readonly Vector4 ColorGrey = new(0.824f, 0.824f, 0.784f, 1f); + private static readonly Vector4 Orange = new(0.933f, 0.573f, 0.118f, 1f); + private static readonly Vector4 ColorGreen = new(0.5f, 1f, 0.498f, 1f); + private static readonly Vector4 ColorBrightRed = new(1f, 0f, 0f, 1f); + + /// + /// The 34-entry table, index == LogTextType (0x00-0x21). + /// Default-fill is colorGreen; explicit overrides match the builder's + /// SetValue calls verbatim. + /// + public static readonly IReadOnlyList Colors = new[] + { + /* 0x00 Default */ ColorGreen, + /* 0x01 All */ ColorGreen, + /* 0x02 Speech */ ColorWhite, + /* 0x03 Tell */ Yellow, + /* 0x04 Speech_Direct_Send */ DarkYellow, + /* 0x05 System */ ColorBrightPurple, + /* 0x06 Combat */ ColorDarkRed, + /* 0x07 Magic */ ColorLightBlue, + /* 0x08 Channel */ ColorPink, + /* 0x09 Channel_Send */ ColorPink, + /* 0x0A Social */ Yellow, + /* 0x0B Social_Send */ DarkYellow, + /* 0x0C Emote */ ColorGrey, + /* 0x0D Advancement */ ColorCyan, + /* 0x0E Abuse */ ColorBlueGrey, + /* 0x0F Help */ ColorDarkRed, + /* 0x10 Appraisal */ ColorGreen, + /* 0x11 Spellcasting */ ColorLightBlue, + /* 0x12 Allegiance */ Orange, + /* 0x13 Fellowship */ Yellow, + /* 0x14 World_Broadcast */ ColorGreen, + /* 0x15 Combat_Enemy */ ColorDarkRed, + /* 0x16 Combat_Self */ ColorLightRed, + /* 0x17 Recall */ ColorGreen, + /* 0x18 Craft */ ColorGreen, + /* 0x19 Salvaging */ ColorGreen, + /* 0x1A (client-local text) */ ColorBrightRed, + /* 0x1B (Turbine General) */ ColorBlueGrey, + /* 0x1C (Turbine Trade) */ ColorBlueGrey, + /* 0x1D (Turbine LFG) */ ColorBlueGrey, + /* 0x1E (Turbine Roleplay) */ ColorBlueGrey, + /* 0x1F Admin_Tell */ Yellow, + /* 0x20 (Turbine Society) */ ColorBlueGrey, + /* 0x21 (reserved) */ Orange, + }; + + /// + /// Resolve to its retail color. + /// Returns for an out-of-range index (>= + /// .Count) — per retail's SetFontColorHelper, + /// the caller should keep whatever color the previous line resolved to + /// rather than substitute a default. + /// + public static bool TryGetColor(uint logTextType, out Vector4 color) + { + if (logTextType < (uint)Colors.Count) + { + color = Colors[(int)logTextType]; + return true; + } + color = default; + return false; + } +} diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 96007581..de77452c 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -154,6 +154,9 @@ public sealed class GameEventWiringTests var entry = chat.Snapshot()[0]; Assert.Equal(ChatKind.Channel, entry.Kind); Assert.Equal("Alice", entry.Sender); + // channelId 42 (0x2A) matches no named legacy bit — generic + // admin/audit catch-all (LegacyChannelChatType.Resolve → 0x0E). + Assert.Equal(0x0Eu, entry.LogTextType); } [Fact] @@ -584,6 +587,10 @@ public sealed class GameEventWiringTests Assert.Equal(ChatKind.Combat, entry.Kind); Assert.Equal(CombatLineKind.Info, entry.CombatKind); Assert.Equal("You killed the drudge!", entry.Text); + // Default (0x00), not a combat color — HandleKillerNotificationEvent + // @0x0056C410 calls AddTextToScroll(..., 0, 1, 0) for both + // VictimNotification and KillerNotification. + Assert.Equal(0x00u, entry.LogTextType); } [Fact] diff --git a/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs b/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs index 04b778f5..948548a7 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatLogTests.cs @@ -211,4 +211,158 @@ public sealed class ChatLogTests CombatLineKind.Error); Assert.Equal(CombatLineKind.Error, log.Snapshot()[1].CombatKind); } + + // ── Campaign CH slice CH1: LogTextType ingestion-site mapping ────────── + + [Fact] + public void OnLocalSpeech_DefaultsLogTextType_ToSpeech() + { + var log = new ChatLog(); + log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false); + Assert.Equal(0x02u, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnLocalSpeech_PassesWireChatTypeVerbatim() + { + // HearSpeech/HearRangedSpeech carry the LogTextType on the wire — + // zero remapping (research doc §3.3 / HearSpeech.cs doc comment). + var log = new ChatLog(); + log.OnLocalSpeech("Mosswart", "grumble", 0x5000_1234u, isRanged: false, logTextType: 0x0Cu); + Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnEmote_HardCodesLogTextType_0x0C() + { + var log = new ChatLog(); + log.OnEmote("Caith", "waves at you", 0xCAFE); + Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnSoulEmote_HardCodesLogTextType_0x0C() + { + var log = new ChatLog(); + log.OnSoulEmote("Bob", "dances", 0xBEEF); + Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnPlayerKilled_LogTextType_IsDefault() + { + var log = new ChatLog(); + log.OnPlayerKilled("Caith was killed by a Drudge.", 0x1u, 0x2u); + Assert.Equal(0x00u, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnWeenieError_LogTextType_IsDefault() + { + var log = new ChatLog(); + log.OnWeenieError(errorId: 0x1234, param: null); + Assert.Equal(0x00u, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnPopup_LogTextType_IsDefault() + { + var log = new ChatLog(); + log.OnPopup("A modal message."); + var e = log.Snapshot()[0]; + Assert.Equal(ChatKind.Popup, e.Kind); + Assert.Equal(0x00u, e.LogTextType); + } + + [Fact] + public void OnTellReceived_DefaultsLogTextType_ToTell() + { + var log = new ChatLog(); + log.OnTellReceived("Alice", "psst", 0xAA); + Assert.Equal(0x03u, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnTellReceived_PassesWireChatTypeVerbatim() + { + var log = new ChatLog(); + log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x1Fu); + Assert.Equal(0x1Fu, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnSelfSent_Tell_DefaultsLogTextType_ToSpeechDirectSend() + { + var log = new ChatLog(); + log.OnSelfSent(ChatKind.Tell, "hey", targetOrChannel: "Alice"); + Assert.Equal(0x04u, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnSelfSent_Channel_DefaultsLogTextType_ToSocialSend() + { + var log = new ChatLog(); + log.OnSelfSent(ChatKind.Channel, "hi all", targetOrChannel: "Fellowship"); + Assert.Equal(0x0Bu, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnSelfSent_ExplicitLogTextType_Overrides() + { + var log = new ChatLog(); + log.OnSelfSent(ChatKind.Channel, "hi all", targetOrChannel: "Fellowship", logTextType: 0x13u); + Assert.Equal(0x13u, log.Snapshot()[0].LogTextType); + } + + [Theory] + // Fellowship — same type hear + send. + [InlineData(0x0800u, "Fellowship", 0x13u)] + // Patron/Vassal/Follower — hear is Social (0xA). + [InlineData(0x1000u, "Patron", 0x0Au)] + [InlineData(0x2000u, "Vassal", 0x0Au)] + [InlineData(0x4000u, "Follower", 0x0Au)] + // Co-Vassals / Allegiance Broadcast. + [InlineData(0x1000000u, "Co-Vassals", 0x0Au)] + [InlineData(0x2000000u, "Allegiance Broadcast", 0x0Au)] + // Unnamed bit reuses Fellowship's slot. + [InlineData(0x4000000u, "?", 0x13u)] + // The one named non-family bit inside the generic bucket (Help). + [InlineData(0x0400u, "Help", 0x0Fu)] + // Generic admin/audit/sentinel catch-all. + [InlineData(0x0900u, "Audit", 0x0Eu)] + public void OnChannelBroadcast_DerivesLogTextType_FromLegacyChannelBit( + uint channelBit, string channelName, uint expectedLogTextType) + { + var log = new ChatLog(); + log.OnChannelBroadcast(channelBit, sender: "Someone", text: "hi", channelName: channelName); + Assert.Equal(expectedLogTextType, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnChannelBroadcast_ExplicitLogTextType_OverridesLegacyDerivation() + { + // TurbineChat rooms share the ChannelId slot with an opaque room + // GUID (not a legacy bitflag) — callers MUST override. + var log = new ChatLog(); + log.OnChannelBroadcast( + channelId: 0x7000_0001u, sender: "Someone", text: "hi", + logTextType: 0x1Bu, channelName: "General"); + Assert.Equal(0x1Bu, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnSystemMessage_LogTextType_MatchesChatType() + { + var log = new ChatLog(); + log.OnSystemMessage("Your spell fizzled!", chatType: 5); + Assert.Equal(5u, log.Snapshot()[0].LogTextType); + } + + [Fact] + public void OnCombatLine_DefaultLogTextType_IsGenericCombat() + { + var log = new ChatLog(); + log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%)."); + Assert.Equal(0x06u, log.Snapshot()[0].LogTextType); + } } diff --git a/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs b/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs index 197d03ef..b74bdec0 100644 --- a/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs +++ b/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs @@ -34,6 +34,9 @@ public sealed class CombatChatTranslatorTests Assert.Equal(ChatKind.Combat, entry.Kind); Assert.Equal(CombatLineKind.Info, entry.CombatKind); Assert.Equal("You hit Mosswart Defiler for 12 slashing damage (54.0%).", entry.Text); + // Combat_Self (0x16) — retail's own-attack squelch type, + // references/ACE/.../Player_Combat.cs:162-163. + Assert.Equal(0x16u, entry.LogTextType); } [Fact] @@ -50,6 +53,9 @@ public sealed class CombatChatTranslatorTests Assert.Equal(ChatKind.Combat, entry.Kind); Assert.Equal(CombatLineKind.Warning, entry.CombatKind); Assert.Equal("Mosswart Stalker hit you for 7 fire damage to your chest.", entry.Text); + // Combat_Enemy (0x15) — retail's incoming-attack squelch type, + // references/ACE/.../Player_Combat.cs:541. + Assert.Equal(0x15u, entry.LogTextType); } [Fact] @@ -77,6 +83,8 @@ public sealed class CombatChatTranslatorTests Assert.Equal(ChatKind.Combat, entry.Kind); Assert.Equal(CombatLineKind.Info, entry.CombatKind); Assert.Equal("Mosswart Sniper evaded your attack.", entry.Text); + // Combat_Self (0x16) — about the local player's OWN attack missing. + Assert.Equal(0x16u, entry.LogTextType); } [Fact] @@ -88,6 +96,8 @@ public sealed class CombatChatTranslatorTests var entry = Assert.Single(chat.Snapshot()); Assert.Equal(CombatLineKind.Info, entry.CombatKind); Assert.Equal("You evaded Drudge Slinker's attack.", entry.Text); + // Combat_Enemy (0x15) — about an ENEMY'S attack. + Assert.Equal(0x15u, entry.LogTextType); } [Fact] @@ -121,6 +131,9 @@ public sealed class CombatChatTranslatorTests Assert.Equal(ChatKind.Combat, entry.Kind); Assert.Equal(CombatLineKind.Info, entry.CombatKind); Assert.Equal("You killed Phyntos Wasp.", entry.Text); + // Default (0x00) — retail's decompiled kill/death color, not a + // combat color; see HandleKillerNotificationEvent @0x0056C410. + Assert.Equal(0x00u, entry.LogTextType); } [Fact] diff --git a/tests/AcDream.Core.Tests/Chat/LegacyChannelChatTypeTests.cs b/tests/AcDream.Core.Tests/Chat/LegacyChannelChatTypeTests.cs new file mode 100644 index 00000000..32d58361 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/LegacyChannelChatTypeTests.cs @@ -0,0 +1,39 @@ +using AcDream.Core.Chat; +using Xunit; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Campaign CH slice CH1: pins +/// against the decompiled dispatch in +/// ClientCommunicationSystem::Handle_Communication__ChannelBroadcast +/// @0x00570B90 — both the HEAR (someone else's message) and OWN-SEND +/// (the local player's own outgoing message) branches, which diverge for +/// exactly three bits (Patron/Vassal/Follower). +/// +public sealed class LegacyChannelChatTypeTests +{ + [Theory] + [InlineData(0x0800u, 0x13u)] // Fellowship + [InlineData(0x1000000u, 0x0Au)] // Co-Vassals + [InlineData(0x2000000u, 0x0Au)] // Allegiance Broadcast + [InlineData(0x4000000u, 0x13u)] // unnamed bit + [InlineData(0x0400u, 0x0Fu)] // Help + [InlineData(0x0100u, 0x0Eu)] // generic/admin catch-all + [InlineData(0xDEADu, 0x0Eu)] // any other unmatched bit + public void Resolve_SameForHearAndSend(uint channelBit, uint expected) + { + Assert.Equal(expected, LegacyChannelChatType.Resolve(channelBit, ownSend: false)); + Assert.Equal(expected, LegacyChannelChatType.Resolve(channelBit, ownSend: true)); + } + + [Theory] + [InlineData(0x1000u)] // Patron + [InlineData(0x2000u)] // Vassal + [InlineData(0x4000u)] // Follower / Monarch + public void Resolve_HearIsSocial_SendIsSocialSend(uint channelBit) + { + Assert.Equal(0x0Au, LegacyChannelChatType.Resolve(channelBit, ownSend: false)); + Assert.Equal(0x0Bu, LegacyChannelChatType.Resolve(channelBit, ownSend: true)); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/TurbineChatDisplayNamesTests.cs b/tests/AcDream.Runtime.Tests/Session/TurbineChatDisplayNamesTests.cs new file mode 100644 index 00000000..e48a3e46 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/TurbineChatDisplayNamesTests.cs @@ -0,0 +1,37 @@ +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Session; +using Xunit; + +namespace AcDream.Runtime.Tests.Session; + +/// +/// Campaign CH slice CH1: pins +/// against retail's ChatRoomTracker::GetChatFormat @0x005CD7C0, which +/// writes ChatDisplayInfo::m_ltt directly per Turbine room field — +/// General/Trade/LFG/Roleplay each get their OWN slot, every Society variant +/// collapses to one slot, and Allegiance/Olthoi share the Allegiance slot. +/// +public sealed class TurbineChatDisplayNamesTests +{ + [Theory] + [InlineData(TurbineChat.ChatType.Allegiance, 0x12u)] + [InlineData(TurbineChat.ChatType.General, 0x1Bu)] + [InlineData(TurbineChat.ChatType.Trade, 0x1Cu)] + [InlineData(TurbineChat.ChatType.Lfg, 0x1Du)] + [InlineData(TurbineChat.ChatType.Roleplay, 0x1Eu)] + [InlineData(TurbineChat.ChatType.Society, 0x20u)] + [InlineData(TurbineChat.ChatType.SocietyCelHan, 0x20u)] + [InlineData(TurbineChat.ChatType.SocietyEldWeb, 0x20u)] + [InlineData(TurbineChat.ChatType.SocietyRadBlo, 0x20u)] + [InlineData(TurbineChat.ChatType.Olthoi, 0x12u)] + public void LogTextType_MatchesRetailChatFormat(TurbineChat.ChatType chatType, uint expected) + { + Assert.Equal(expected, TurbineChatDisplayNames.LogTextType((uint)chatType)); + } + + [Fact] + public void LogTextType_UnknownChatType_FallsBackToDefault() + { + Assert.Equal(0x00u, TurbineChatDisplayNames.LogTextType((uint)TurbineChat.ChatType.Undef)); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs index 418ca4ec..defd8cf3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/ChatVMTests.cs @@ -141,4 +141,19 @@ public sealed class ChatVMTests Assert.Single(after); Assert.Equal("Caith says, \"hello\"", after[0]); } + + [Fact] + public void ShowSystemMessage_UsesClientLocalLogTextType() + { + // Campaign CH slice CH1: client-local command output (/help, /clear, + // /framerate, /loc, ...) never reaches the wire — retail's own + // client-local text is LogTextType 0x1A (bright red). + var log = new ChatLog(); + var vm = new ChatVM(log); + + vm.ShowSystemMessage("Unknown command: foo"); + + var entry = Assert.Single(log.Snapshot()); + Assert.Equal(0x1Au, entry.LogTextType); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs index 6e8f9969..c9f3433c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMCombatTests.cs @@ -71,7 +71,11 @@ public sealed class ChatVMCombatTests panel.Render(new PanelContext(0.016f, bus), renderer); - // Plain LocalSpeech entry → Text; combat entry → TextColored. + // Plain LocalSpeech entry → Text; combat entry → TextColored, now + // sourced from RetailChatColorTable (Campaign CH slice CH1) keyed + // by LogTextType, not ChatPanel.ColorForCombat's severity bucket. + // OnCombatLine's default logTextType (0x06, generic Combat slot, + // colorDarkRed) applies here since no explicit type was passed. Assert.Contains(renderer.Calls, c => c.Method == "Text" && (string?)c.Args[0] == "Alice says, \"hi\""); var coloredCall = Assert.Single( @@ -80,8 +84,9 @@ public sealed class ChatVMCombatTests Assert.Equal( "You hit Mosswart for 5 slashing damage (50.0%).", (string?)coloredCall.Args[1]); + RetailChatColorTable.TryGetColor(0x06u, out var expectedColor); Assert.Equal( - ChatPanel.ColorForCombat(CombatLineKind.Info), + expectedColor, (System.Numerics.Vector4)coloredCall.Args[0]!); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailChatColorTableTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailChatColorTableTests.cs new file mode 100644 index 00000000..8e180d98 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailChatColorTableTests.cs @@ -0,0 +1,103 @@ +using System.Numerics; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Campaign CH slice CH1: pins every entry of the 34-value retail chat +/// color table (ChatInterface::BuildChatColorLookupTable @0x004F31C0) +/// against the exact floats recovered from the PDB-paired binary's +/// .data section (research doc +/// docs/research/2026-08-09-chat-retail-color-table.md §1.2/§2.1), +/// plus the 7 slots retail's builder never overwrites (stay green) and the +/// out-of-range "keep previous color" rule. +/// +public sealed class RetailChatColorTableTests +{ + [Theory] + [InlineData(0x00u, 0.5f, 1f, 0.498f, 1f)] // Default (default-fill) + [InlineData(0x01u, 0.5f, 1f, 0.498f, 1f)] // All (default-fill) + [InlineData(0x02u, 1f, 1f, 1f, 1f)] // Speech — colorWhite + [InlineData(0x03u, 1f, 1f, 0.247f, 1f)] // Tell — yellow + [InlineData(0x04u, 0.824f, 0.824f, 0.392f, 1f)] // Speech_Direct_Send — dark yellow + [InlineData(0x05u, 1f, 0.498f, 1f, 1f)] // System — colorBrightPurple + [InlineData(0x06u, 1f, 0.247f, 0.247f, 1f)] // Combat — colorDarkRed + [InlineData(0x07u, 0.247f, 0.749f, 1f, 1f)] // Magic — colorLightBlue + [InlineData(0x08u, 1f, 0.588f, 0.588f, 1f)] // Channel — colorPink + [InlineData(0x09u, 1f, 0.588f, 0.588f, 1f)] // Channel_Send — colorPink + [InlineData(0x0Au, 1f, 1f, 0.247f, 1f)] // Social — yellow + [InlineData(0x0Bu, 0.824f, 0.824f, 0.392f, 1f)] // Social_Send — dark yellow + [InlineData(0x0Cu, 0.824f, 0.824f, 0.784f, 1f)] // Emote — colorGrey + [InlineData(0x0Du, 0.247f, 0.863f, 0.863f, 1f)] // Advancement — colorCyan + [InlineData(0x0Eu, 0.706f, 0.863f, 0.941f, 1f)] // Abuse — colorBlueGrey + [InlineData(0x0Fu, 1f, 0.247f, 0.247f, 1f)] // Help — colorDarkRed + [InlineData(0x10u, 0.5f, 1f, 0.498f, 1f)] // Appraisal (default-fill) + [InlineData(0x11u, 0.247f, 0.749f, 1f, 1f)] // Spellcasting — colorLightBlue + [InlineData(0x12u, 0.933f, 0.573f, 0.118f, 1f)] // Allegiance — orange + [InlineData(0x13u, 1f, 1f, 0.247f, 1f)] // Fellowship — yellow + [InlineData(0x14u, 0.5f, 1f, 0.498f, 1f)] // World_Broadcast (default-fill) + [InlineData(0x15u, 1f, 0.247f, 0.247f, 1f)] // Combat_Enemy — colorDarkRed + [InlineData(0x16u, 0.96f, 0.459f, 0.447f, 1f)] // Combat_Self — colorLightRed + [InlineData(0x17u, 0.5f, 1f, 0.498f, 1f)] // Recall (default-fill) + [InlineData(0x18u, 0.5f, 1f, 0.498f, 1f)] // Craft (default-fill) + [InlineData(0x19u, 0.5f, 1f, 0.498f, 1f)] // Salvaging (default-fill) + [InlineData(0x1Au, 1f, 0f, 0f, 1f)] // client-local text — colorBrightRed + [InlineData(0x1Bu, 0.706f, 0.863f, 0.941f, 1f)] // Turbine General — colorBlueGrey + [InlineData(0x1Cu, 0.706f, 0.863f, 0.941f, 1f)] // Turbine Trade — colorBlueGrey + [InlineData(0x1Du, 0.706f, 0.863f, 0.941f, 1f)] // Turbine LFG — colorBlueGrey + [InlineData(0x1Eu, 0.706f, 0.863f, 0.941f, 1f)] // Turbine Roleplay — colorBlueGrey + [InlineData(0x1Fu, 1f, 1f, 0.247f, 1f)] // Admin_Tell — yellow + [InlineData(0x20u, 0.706f, 0.863f, 0.941f, 1f)] // Turbine Society — colorBlueGrey + [InlineData(0x21u, 0.933f, 0.573f, 0.118f, 1f)] // reserved — orange + public void TryGetColor_PinsExactRetailFloat( + uint logTextType, float r, float g, float b, float a) + { + Assert.True(RetailChatColorTable.TryGetColor(logTextType, out Vector4 color)); + Assert.Equal(new Vector4(r, g, b, a), color); + } + + [Fact] + public void Colors_HasExactlyThirtyFourEntries() + { + // The builder's default-fill loop runs 0x22 (34) iterations, + // covering indices 0x00-0x21 inclusive — independently confirmed + // by the squelch enumerator's `for (uint i = 0; i < 0x22; i++)`. + Assert.Equal(34, RetailChatColorTable.Colors.Count); + } + + [Theory] + [InlineData(0x00u)] + [InlineData(0x01u)] + [InlineData(0x10u)] + [InlineData(0x14u)] + [InlineData(0x17u)] + [InlineData(0x18u)] + [InlineData(0x19u)] + public void SevenUnoverwrittenSlots_StayDefaultGreen(uint logTextType) + { + RetailChatColorTable.TryGetColor(logTextType, out Vector4 color); + Assert.Equal(new Vector4(0.5f, 1f, 0.498f, 1f), color); + } + + [Theory] + [InlineData(0x22u)] // one past the last valid slot + [InlineData(0x23u)] + [InlineData(100u)] + [InlineData(uint.MaxValue)] + public void TryGetColor_OutOfRange_ReturnsFalse(uint logTextType) + { + // SetFontColorHelper @0x00466AC0's bound check (arg4 < arg2) falls + // through without touching m_curFontColor for an out-of-range + // index — the caller must keep whatever color the PREVIOUS line + // resolved to, not substitute a default (research doc §3.2). + Assert.False(RetailChatColorTable.TryGetColor(logTextType, out _)); + } + + [Fact] + public void EveryAlphaChannelIsOne() + { + foreach (Vector4 color in RetailChatColorTable.Colors) + Assert.Equal(1f, color.W); + } +}