diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0e07ecbb..d1e4f885 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -284,6 +284,37 @@ no-workarounds rule forbids without explicit approval. collision generation spans more than a couple of scheduler ticks (the default case against a real DAT-loaded landblock). +## #369 — Unconfirmed whether retail's floating chat windows share the main window's currently-selected talk-focus channel + +**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6b (register row +AP-188). The floating chat window LayoutDesc (`0x2100005B`) authors no +talk-focus menu (`docs/research/2026-08-09-chat-retail-window-shell.md` +§2.2 — only the main window's `0x2100006F` has one, element `0x10000014`), +so acdream's `FloatingChatWindowController` hardcodes every floaty window's +chat entry to send on `ChatChannelKind.Say`. What is UNVERIFIED is retail's +actual send path: does a floaty `ChatInterface` instance's typed message go +out on a per-window channel (also always Say, since there's nothing to pick +from), or does it read the single globally-current talk-focus +channel/target the MAIN window's menu (and `gmMainChatUI::UseTime +@0x004CDB20`'s selected-target tracking) last set? If the latter, a real +retail floaty window sends on whatever channel the player most recently +picked from the main window — acdream would then need to promote +`ChatWindowController`'s private `_activeChannel` to a shared owner all +five window controllers read, rather than each owning its own (the main +window keeps its own local state; the four floaties currently have no +state at all, just the Say constant). + +**Where:** `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` +(`Bind`'s `OnSubmit`); `src/AcDream.App/UI/Layout/ChatWindowController.cs` +(`_activeChannel`, the eventual shared-state candidate). + +**Fix shape (needs research first):** trace `gmCCommunicationSystem`'s +send-command path starting from a floaty `ChatInterface` instance (not the +main window) to confirm which channel/target it actually uses; if it's +shared, wire a single shared active-channel owner (Runtime-level, matching +the J4.1 pattern the rest of chat state now follows) that all five +controllers read instead of the main window's private field. + ## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired **Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6a. The retail main diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c2d776a4..7543512b 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) — 132 active rows (AP-186 filed 2026-08-10 at Campaign CH user-gate round 3, while tracing DoHelp's print sequence — ChatCommandRouter's type-0x1A local-presentation fallbacks (now including the byte-exact "Unknown command" text) still render via the chat scroll instead of the SpewBox, an architectural layering gap (UI.Abstractions cannot see Runtime's `RuntimeCommunicationState.AddText`), tracked as ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 filed 2026-08-09 at Campaign CH user-gate round 2, item 3 — three of the seven retail `/help` group-topic listings (channels/chatting/commands) remain PARTIAL, not fully verbatim: their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which constructs its output from three BN-mislabeled data fragments (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc. are NOT real vtable dispatch) concatenated around a live `ChannelSystem::GetChannelName` lookup, not decodable with confidence from a static string sweep; each partial group keeps its own verbatim summary line and an explicit UNVERIFIED note instead of the fully-fabricated meta-message the user caught on `/help death` (that group, plus status/text/allegiances, are now COMPLETE verbatim listings); tracked as ISSUES.md #364. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 filed 2026-08-09 at the CH4 REJECT-review, item 9 — roughly 10 chat refusal/usage call sites this campaign added route through `ChatVM.ShowSystemMessage`'s single `ClientLocal 0x00` sink where retail types several of them `0x1A`: `DoStupidChannelHack`, `DoChannelList`/`On`/`Off`, `DoAllegiance`, `DoHouseAvailableList`, `DoReply`; three sites (`DoSpeaker`/`DoEndurance`/`DoTitle`) are already correct at `0x00`, matching retail. Retail's own bad-args fallback (`DoCommand @0x0057E46D`) also answers with `HandleFailureEvent(0x26)`, not a local "Usage:" line, which acdream's `ChatCommandRouter.Submit` synthesizes instead. Deliberately NOT re-plumbed this session — filed as issue #363, marked for CH5-or-later; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 134 active rows (AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 filed 2026-08-10 at Campaign CH user-gate round 3, while tracing DoHelp's print sequence — ChatCommandRouter's type-0x1A local-presentation fallbacks (now including the byte-exact "Unknown command" text) still render via the chat scroll instead of the SpewBox, an architectural layering gap (UI.Abstractions cannot see Runtime's `RuntimeCommunicationState.AddText`), tracked as ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 filed 2026-08-09 at Campaign CH user-gate round 2, item 3 — three of the seven retail `/help` group-topic listings (channels/chatting/commands) remain PARTIAL, not fully verbatim: their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which constructs its output from three BN-mislabeled data fragments (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc. are NOT real vtable dispatch) concatenated around a live `ChannelSystem::GetChannelName` lookup, not decodable with confidence from a static string sweep; each partial group keeps its own verbatim summary line and an explicit UNVERIFIED note instead of the fully-fabricated meta-message the user caught on `/help death` (that group, plus status/text/allegiances, are now COMPLETE verbatim listings); tracked as ISSUES.md #364. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 filed 2026-08-09 at the CH4 REJECT-review, item 9 — roughly 10 chat refusal/usage call sites this campaign added route through `ChatVM.ShowSystemMessage`'s single `ClientLocal 0x00` sink where retail types several of them `0x1A`: `DoStupidChannelHack`, `DoChannelList`/`On`/`Off`, `DoAllegiance`, `DoHouseAvailableList`, `DoReply`; three sites (`DoSpeaker`/`DoEndurance`/`DoTitle`) are already correct at `0x00`, matching retail. Retail's own bad-args fallback (`DoCommand @0x0057E46D`) also answers with `HandleFailureEvent(0x26)`, not a local "Usage:" line, which acdream's `ChatCommandRouter.Submit` synthesizes instead. Deliberately NOT re-plumbed this session — filed as issue #363, marked for CH5-or-later; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -340,6 +340,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-182 | **Filed 2026-08-09 (Campaign CH slice CH4); corrected 2026-08-09 at the CH4 REJECT-review (nit 11).** `@title ` is wired to a pure no-op — `LiveSessionRuntimeFactory`'s `SetChatTitle` binding is `_ => { }`; the requested title is neither stored nor consumed anywhere (the original filing's "stores the value locally" claim was false). This matches retail's own silent success (no confirmation text was recovered at the `DoTitle` success site, so a no-visible-effect accept is exactly as faithful as a stored-but-unread value would be). Also omitted: `DoTitle`'s three local failure messages — no title given, "You must provide a new title for the window."; length over 99 characters, "Window title length cannot exceed 100 characters."; and wrong source window (`m_idCurrentCommandSource` 1 or 8), "This command must be issued from a popup chat window." — acdream's catalog validator (`ClientCommandId.SetChatTitle`, `AnyArguments`) accepts any argument shape and never raises any of the three. `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`SetChatTitle`) | Retail's chat window presumably re-renders its title bar text; acdream's chat window has no title bar at all under the current retained-UI import, so there is nothing to visually diverge from yet | Once a titled chat-window chrome is built, `@title` needs to be re-wired to it — today it is a pure no-op, and the three failure messages above are silently absent | `ClientCommunicationSystem::DoTitle @ 0x0057A640` | | AP-185 | **Filed 2026-08-10 (Campaign CH slice CH6a — retail chat-window layout + 8-grip resize).** The main chat window's 8 cosmetic `_Locked` border-art twins (`0x10000693`-`0x1000069A`) are retail's `PlayerModule::LockUI`-driven alternate skin — `gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0` swaps them in for the 8 live Resizebar/Dragbar grips (`0x1000069B`-`0x100006A2`) when the UI is locked, and swaps them back out when unlocked. `ChatWindowController.Bind` always hides the twins and always shows the live set — i.e. it renders only retail's UNLOCKED skin, regardless of `UiRoot.UiLocked`. `src/AcDream.App/UI/Layout/ChatWindowController.cs` (`LockedTwinIds`) | `UiRoot.UiLocked` already gates the underlying move/resize INTERACTION generically and correctly in both states (locked ⇒ no move, no resize, regardless of which border art is drawn); the two art sets occupy identical rects, so always showing the interactive-grip skin is a cosmetic simplification, not a functional one, and the default matches `UiLocked`'s own `false` default | A user who locks the UI (`PlayerModule::LockUI`) sees the interactive-grip chat-window border art unchanged instead of retail's inert locked variant — cosmetic only; the window still correctly refuses to move or resize while locked | `gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0`; `PlayerModule::LockUI`; `docs/research/2026-08-09-chat-retail-window-shell.md` §1.6 | | AP-186 | **Filed 2026-08-10 at Campaign CH user-gate round 3, while tracing `ClientCommunicationSystem::DoHelp @0x0057f9e0`'s complete print sequence for findings (b)/(c).** Retail's DoHelp fallback for an unresolved `/help ` is `AddTextToScroll(u"Unknown command", 0x1A, 1, 0)` — type `0x1A` (`ClientLocal`) is HARDCODED to the SpewBox, never the chat window (same routing rule round 2 item 2 already ported for the portal-space notice; `docs/research/2026-08-09-chat-retail-interface-text.md` §2.1/§2.2). `RuntimeCommunicationState.AddText` (Runtime layer) implements this rule correctly. But `ChatCommandRouter`/`ChatVM` live in `AcDream.UI.Abstractions`, a layer beneath Runtime that must stay presentation/Runtime-independent (Code Structure Rules) — they have no path to the SpewBox, so every local-presentation fallback typed `ClientLocal` (now `RetailCommandHelpTable.UnknownCommand`; also the pre-existing "Unknown command: {verb}." command-shaped-input refusal in `ChatCommandRouter.Submit`'s main body) still writes through `ChatVM.ShowSystemMessage`, which only ever reaches `ChatLog`. Not a regression this round — the fallback already showed in the chat window before this round's fix (with fabricated text); this round corrected the TEXT and traced the routing divergence clearly enough to register it. Filed as ISSUES.md #367. `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` | This is an architectural layering gap (UI.Abstractions cannot see Runtime), not a data-availability gap — `RuntimeCommunicationState.AddText` already implements the correct routing rule one layer up, so fixing it is a plumbing exercise (an optional delegate hook), not new research | A user typing an unresolvable `/help ` sees "Unknown command" in the chat scroll instead of the top-of-screen SpewBox flash retail shows — text content is now byte-exact, only the SURFACE differs | `ClientSystem::AddTextToScroll @0x00563C50`; `docs/research/2026-08-09-chat-retail-interface-text.md` §2.1/§2.2; `src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs` (`AddText`, the correctly-implemented oracle) | +| AP-187 | **Filed 2026-08-10 (Campaign CH slice CH6b — floating chat windows).** The four floating chat windows' text-type filters (`AcDream.Core.Chat.ChatWindowState`, retail's `0x1000007F` per-window option) persist only in local `settings.json` (`ChatSettings.ChatWindow1Filter`..`ChatWindow4Filter`, `SettingsStore.LoadChat`/`SaveChat`). Retail's authoritative store for this same data is the per-window option array (`0x1000008C`) packed inside the character-scoped `GameplayOptions` blob, which ACE stores and echoes as opaque bytes without parsing (window-shell research doc §4.1/§4.4); acdream has no reader or writer for that blob (CH3 already deleted one malformed attempt at the outbound `SetCharacterOptions 0x01A1` builder — `SocialActions.cs`). Geometry and open/visible state for these same four windows do NOT need a row of their own: they persist through the pre-existing generic `RetailWindowLayoutPersistence` path (X/Y/W/H/visible/collapsed/maximized per window name), which is retail's OWN local-file mechanism too (`gmGamePlayUI::SaveScreenLayout`/`LoadScreenLayout`, window-shell research doc §4.3) — only the filter mask lacks any such local-file precedent in retail and is acdream's own addition to make the feature usable before CH6f lands. `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs` (`LoadChat`/`SaveChat`/`BuildChatObject`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountFloatingChatWindows`, `SaveChatWindowFilters`) | CH6a/CH6b's own port-shape recommendation (window-shell research doc §6.1) explicitly chose local persistence first and deferred the `0x1000008B`/`0x1000008C` wire to a dedicated CH6f slice, citing CH3's deleted malformed builder as the reason not to rush it | A character's floating-window filter customization does not travel to a different acdream install, and would not round-trip through a retail client sharing the same character (retail would see acdream's local-only values as unset, falling back to its own `PostInit` defaults) — cosmetic/preference-only, no gameplay effect | `PlayerModule::GetChatOptionStructure @0x005D5300`; `PlayerModule::InqChatWindowOption/SetChatWindowOption @0x005D5540/:70`; `docs/research/2026-08-09-chat-retail-window-shell.md` §4.1/§4.4/§6.1; `docs/plans/2026-08-09-chat-parity-campaign.md` (CH6f row) | +| AP-188 | **Filed 2026-08-10 (Campaign CH slice CH6b — floating chat windows).** A floating chat window's chat entry always sends on the `Say` channel (`FloatingChatWindowController.Bind`'s `OnSubmit` hardcodes `ChatChannelKind.Say`). The floaty LayoutDesc (`0x2100005B`) authors no talk-focus menu (window-shell research doc §2.2 — only the main window's layout has one, element `0x10000014`), so there is no visible channel picker on a floaty window either way, matching retail's authored UI exactly. What is UNVERIFIED is whether retail's actual SEND path for a floaty window's typed message reads a per-window channel or the single globally-current talk-focus channel/target the main window's menu (or the last-selected/last-speakable-target state `gmMainChatUI::UseTime @0x004CDB20` tracks) last set — if the latter, a real retail floaty window would send on whatever channel the player most recently picked from the MAIN window, not always `Say`. Confirming this requires tracing `gmCCommunicationSystem`'s send-command path from a floaty `ChatInterface` instance, not yet done. Filed as ISSUES.md #369. `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` (`Bind`, the `OnSubmit` wiring) | Building genuine cross-window shared-channel state (reading `ChatWindowController`'s private `_activeChannel` from four independent sibling controllers, or promoting it to a shared owner) is a real design decision outside this slice's explicit scope (task items 1-6 do not ask for cross-window channel sharing); `Say` is retail's own default channel and the safest fixed value absent confirmation | If retail's actual mechanism is "send on the currently-selected global channel," a user who selects e.g. Fellowship from the main window's talk-focus menu and then types into a floaty window would see it sent as Fellowship in retail but as Say in acdream — no data loss (the message still sends), only channel-selection mismatch | `gmMainChatUI::InitTalkFocusMenu @0x004CDC50`; `gmMainChatUI::UseTime @0x004CDB20`; `docs/research/2026-08-09-chat-retail-window-shell.md` §2.2 | ## 4. Temporary stopgap (TS) — 39 active rows (TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index 8c373a2c..eee96318 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -43,10 +43,13 @@ mounts flush to the viewport top and resolves a real (smaller) retail dat font instead of the unwired 15px debug fallback; bare `/help` and `/help ` (including `/help death`) now print retail's exact `DoHelp` shape — two scroll entries in the right order, not one -acdream-invented blob. Status stays CODE-COMPLETE pending the next user -gate round (still needed for CH6a's own visual confirmation, CH6b/CH6c, -round 3's fixes, and a final in-client visual pass on everything fixed so -far). +acdream-invented blob. **CH6b (floating chat windows 1-4) landed +CODE-COMPLETE 2026-08-10** under this session's hard constraints (no +subagents, no client launches) — see its ledger row and Slices bullet; +CH6c (opacity) remains not started. Status stays CODE-COMPLETE pending +the next user gate round (still needed for CH6a's own visual +confirmation, CH6b's keybind/mirror/filter behavior, CH6c, round 3's +fixes, and a final in-client visual pass on everything fixed so far). **Why now:** first track of the alpha-release program (chat is the most visible daily surface for the friend-alpha). User-directed 2026-08-09. @@ -179,6 +182,37 @@ implementer per slice against a pinned contract (per the toggle; per-window PostInit filter defaults (color research §4) with the `windowId == m_eWindowID OR (windowId==0 && TypeIsActive)` display rule. + **CODE-COMPLETE 2026-08-10 (this commit — hard constraint: no + subagents, no client launches).** `ChatWindowState` (Runtime, borrowed + from `RuntimeCommunicationState.ChatWindows`) owns the exact + PostInit-default filters + open flags for ids 0-4 and the full + `ShouldDisplay(windowId, targetWindowId, logTextType)` predicate; + `FloatingChatWindowController` (new sibling to `ChatWindowController`, + sharing the wrap/color algorithm via the new `ChatTranscriptRenderer`) + binds all four windows, each importing its own widget tree from one + shared `0x2100005B` `ElementInfo` parse. The keymap default confirmed + **Alt+1..4** (`MetaKeys` index 3 = `0x00000004`, cross-checked against + the file's own Alt+A/D strafe and Alt+Enter/Tab/F4 rows — `KeyBindings` + already carried this binding since Phase K.1c). A direct decomp read of + `gmMainChatUI::ListenToElementMessage @0x004CDA80` (the only function + in the binary that branches on a click message) settled the + button-mirror-vs-toggle question the research doc had left as a + hedge: **the four indicator buttons carry NO click handler in + retail** — `ChatWindowController.SetIndicatorOpen` ports this as a + pure one-directional mirror, no `OnClick`. `RetailUiRuntime. + OnWindowVisibilityChanged` is the single chokepoint that both syncs + `ChatWindowState.SetOpen` and calls the indicator mirror, regardless + of what changed a window's visibility (keybind, close button, or a + restored layout). Geometry + open/visible persist for free through + the existing `RetailWindowLayoutPersistence` path once each window + registers under its own `WindowNames` entry; the filter masks get a + dedicated local `ChatSettings` round-trip (register row AP-187 — no + retail `0x1000008C` wire yet). One approximation, register row + AP-188: a floaty window's entry field always sends on `Say` (no + talk-focus menu is authored on `0x2100005B`, and whether retail's + ACTUAL send path reads a per-window or a shared globally-current + channel is unconfirmed). Full Release suite 12,392 passed / 4 + skipped / 0 failed. - **CH6c — opacity.** Implement `UiRenderContext.AlphaMod` consumption (whole-composited-window alpha per `ChatInterface::SetOpacity @0x004F3120`); the two GLOBAL retail @@ -209,11 +243,13 @@ implementer per slice against a pinned contract (per | CH5 closeout | (this commit) | — (docs/memory only, no build) | — | pending (connected gate — see test script) | | User gate round 1 | (this commit) | 12,221 passed / 4 skipped / 0 failed (baseline; items A–G fixed this commit) | — | items A–G user-gate round 1 fixed; ten findings total, see "User gate — round 1" below | | CH6a main-window layout + 8-grip resize | (this commit) | 12,317 passed / 4 skipped / 0 failed | pending (no subagent review pass this session — implementer-only) | pending — needs the next in-client round (items H/I round 1, item 6 round 2) | -| CH6b/CH6c floating windows + opacity | not started | — | — | not started | +| CH6b/CH6c floating windows + opacity | superseded — split below | — | — | superseded | | User gate round 2 | (this commit) | 12,267 passed / 4 skipped / 0 failed | — | items 2/4/5 fixed this commit, item 3 confirmed-fixed, item 6 folded into CH6a's spec, item 1 NOT reproduced (see "User gate — round 2" below) | | CH6a main-window layout + 8-grip resize | `1fd51543` | 12,317 passed / 4 skipped / 0 failed | pending | pending — landed same day as round 2 | | Jump-in-air root cause (round-2 item 1, resolved) | `a5a7eb4f` | Runtime tests 1,323/0 | — | round-3 probe evidence pinpointed a missing `OnInterfaceText` wire on the production controller-commit path (`RuntimeLocalPlayerMovementState.CommitRuntimeOwnedController`); FIXED, regression test added | | User gate round 3 | (this commit) | Debug (all projects): 12,329 passed / 4 skipped / 1 failed (pre-existing #351 Debug-only flake — reproduces identically on the pristine pre-round-3 commit, not a regression); Release (every project reachable while a live `AcDream.App.exe` client — PID 15064, must not be killed per project policy — holds its own Release binaries locked, blocking `AcDream.App`/`AcDream.App.Tests`/`AcDream.Core.Tests` specifically): `AcDream.UI.Abstractions.Tests` (the layer this round's `/help` fix lives in) 867/867, plus `Core.Net.Tests` 823/823, `Runtime.Tests` 1,323/1,323, `Content.Tests` 130/130, `Headless.Tests` 89/89, `Bake.Tests` 15/15, `Cli.Tests` 4/4 — all 0 failed | — | findings (a)-(c) fixed this commit — SpewBox flush-top + retail dat font, `/help`/`/help death` exact retail print sequence (see "User gate — round 3" below) | +| CH6b floating windows 1–4 | (this commit) | 12,392 passed / 4 skipped / 0 failed | pending (no subagent review pass this session — implementer-only, per this session's HARD CONSTRAINT of no subagents) | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation | +| CH6c opacity | not started | — | — | not started | ### CH4 closeout (2026-08-09) diff --git a/docs/research/2026-08-09-chat-retail-window-shell.md b/docs/research/2026-08-09-chat-retail-window-shell.md index 6d4ec26f..2c9de333 100644 --- a/docs/research/2026-08-09-chat-retail-window-shell.md +++ b/docs/research/2026-08-09-chat-retail-window-shell.md @@ -151,16 +151,28 @@ State 6 = "on/depressed", state 1 = "normal". No handler anywhere in the binary switches on `0x10000522..0x10000525` as a *source* of a click — `grep` over the whole pseudo-C returns only `gmMainChatUI::RecvNotice_SetPanelVisibility`. -UNVERIFIED (and the one place I would not guess): whether clicking those four -buttons does anything in retail at all. Two readings are consistent with the -decomp — (a) they are pure indicators, and (b) they carry the same LayoutDesc -property `0x24` input-action value as the windows themselves, so a click routes -through the generic `UIElement` action path rather than through any chat code. -(b) is the more likely reading given §1.3's generic mechanism. Cheapest -resolution: the same LayoutDesc property dump as above, reading property `0x24` -on `0x10000522`–`0x10000525`. **For CH6 it is safe to wire both: keybind AND -button click both call the same toggle**, because retail's observable behaviour -(button lights up iff window is visible) is satisfied either way. +**RESOLVED 2026-08-10 at Campaign CH slice CH6b.** The prior UNVERIFIED +paragraph's hedge ("safe to wire both") is superseded by a direct read of +`gmMainChatUI::ListenToElementMessage @0x004CDA80` — the ONLY function in the +whole 2013 binary that branches on `idMessage == 1` ("clicked"). It handles +exactly two element ids: `0x1000046f` (max/min, dispatching +`HandleMaximizeButton`) and the talk-focus menu's selection message +(`idMessage == 7`, checked against `this->m_pCCS` / a `0x1000000b` attribute +read). There is no case, anywhere in that function or its base-class fallback +(`ChatInterface::ListenToElementMessage`, called unconditionally at the +function's tail), for `0x10000522`–`0x10000525`. **Clicking a chat-window +indicator button does NOTHING in retail — reading (a), pure indicator, is +correct; reading (b) is refuted.** The `0x24` input-action-property theory +in reading (b) does not even apply to a mouse click on the button itself: that +property only wires *keyboard* dispatch (`UIElementManager:: +DoVisibilityToggleAction` in §1.3), not a button's own `UIElement` click +message, which routes to its LISTENING PARENT — and that parent's handler has +no case for these four ids. acdream ports this exactly: the four indicator +buttons (`ChatWindowController._indicatorButtons`) carry no `OnClick` at all; +`ChatWindowController.SetIndicatorOpen` is their only writer, called only from +`RetailUiRuntime.OnWindowVisibilityChanged` in response to the floating +window's own visibility changing (keybind or otherwise) — a pure one-directional +mirror, matching retail exactly. ### 1.5 Closing a floaty window from its own title bar diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 9f8d3485..8e2fa9ec 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -596,7 +596,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory Host: host, Assets: assets, Vitals: new VitalsRuntimeBindings(vitals), - Chat: new ChatRuntimeBindings(chat, () => late.Session.Commands), + Chat: new ChatRuntimeBindings( + chat, + () => late.Session.Commands, + d.Communication.ChatWindows, + layoutStore), Radar: new RadarRuntimeBindings( late.Radar.Snapshot, d.Actions.Selection, diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs index 4f314f1d..453e569e 100644 --- a/src/AcDream.App/Input/GameplayInputCommandController.cs +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -10,6 +10,9 @@ namespace AcDream.App.Input; internal interface IRetainedGameplayWindowCommands { void ToggleInventory(); + + /// Toggle floating chat window (1-4). + void ToggleFloatingChatWindow(int windowId); } internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) @@ -19,6 +22,9 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) public void ToggleInventory() => _runtime?.ToggleWindow(WindowNames.Inventory); + + public void ToggleFloatingChatWindow(int windowId) => + _runtime?.ToggleFloatingChatWindow(windowId); } internal interface IDevToolsGameplayCommands @@ -188,6 +194,18 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg case InputAction.ToggleInventoryPanel: _retained.ToggleInventory(); return true; + case InputAction.ToggleFloatingChatWindow1: + _retained.ToggleFloatingChatWindow(1); + return true; + case InputAction.ToggleFloatingChatWindow2: + _retained.ToggleFloatingChatWindow(2); + return true; + case InputAction.ToggleFloatingChatWindow3: + _retained.ToggleFloatingChatWindow(3); + return true; + case InputAction.ToggleFloatingChatWindow4: + _retained.ToggleFloatingChatWindow(4); + return true; case InputAction.AcdreamToggleAudioMute: _toggleAudioMute?.Invoke(); return true; diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs new file mode 100644 index 00000000..93ef941d --- /dev/null +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI.Layout; + +/// +/// Shared word-wrap + retail color-carry-forward transcript line builder. +/// Factored out of +/// (Campaign CH slice CH6b) so +/// reuses the exact same algorithm instead of duplicating it — both the main +/// chat window and the four floating windows are views over the SAME +/// transcript (J4.1 pattern: one canonical log, many +/// filtered presentations). +/// +/// +/// Pure function — callers own their own per-controller layout cache +/// (revision/wrap-width/font keyed), matching the caching each controller +/// already had before this extraction. +/// +/// +internal static class ChatTranscriptRenderer +{ + /// Tail of the shared chat log, formatted with retail metadata. + /// Wrap width in pixels. + /// Glyph-width measurer for the active font. + /// + /// Optional per-line filter — retail's ChatInterface::TypeIsActive + /// (or the full ShouldDisplay predicate) for THIS window. Null + /// accepts every line (the main window has no user filter — color-table + /// research doc §4). A line that fails the filter is dropped from this + /// window's view WITHOUT advancing the carried-forward color, matching + /// retail's m_curFontColor only advancing for lines actually + /// appended to THIS window's own scroll (AppendStringInfoWithFont + /// only runs for displayed lines). + /// + public static List BuildLines( + IReadOnlyList detailed, + float maxW, + Func measure, + Func? accept) + { + var result = new List(detailed.Count); + if (detailed.Count == 0) + return result; + + // Retail's font-color state (m_curFontColor) persists across every + // line actually appended to this window — an out-of-range LogTextType + // leaves it unchanged rather than reverting to a default (color-table + // doc §3.2). Seed the carry with retail's own unfilled-slot default + // (colorGreen, index 0x00). + RetailChatColorTable.TryGetColor(0x00u, out Vector4 currentColor); + foreach (FormattedLine d in detailed) + { + if (accept is not null && !accept(d.LogTextType)) + continue; + if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) + currentColor = resolved; + foreach (string frag in ChatWindowController.WrapText(d.Text, maxW, measure)) + result.Add(new UiText.Line(frag, currentColor)); + } + return result; + } +} diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index cc60f264..6dfef4a8 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -62,9 +62,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // Chat-window 1-4 state-mirror indicator buttons // (gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80): the button lights // up iff the corresponding floating chat window is visible. The mechanism is - // one-directional (window visibility drives the button, not the reverse) and - // the floating windows themselves are CH6b's scope — these import generically - // (visible, inert) here and are left for CH6b to wire. + // one-directional (window visibility drives the button, never the reverse — + // see SetIndicatorOpen's doc for the decomp confirmation). Campaign CH slice + // CH6b resolves these to _indicatorButtons and wires them via SetIndicatorOpen, + // called by RetailUiRuntime whenever a floating chat window's visibility changes. private const uint Indicator1Id = 0x10000522u; private const uint Indicator2Id = 0x10000523u; private const uint Indicator3Id = 0x10000524u; @@ -176,6 +177,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private bool _maximized; private UiButton? _maxMinButton; + // Chat-window 1-4 indicator buttons, indexed [windowId - 1]. Resolved at + // Bind() time (see the Indicator1Id..Indicator4Id class doc above); + // CH6b wires SetIndicatorOpen as their only writer. + private readonly UiButton?[] _indicatorButtons = new UiButton?[4]; + // ── Factory ──────────────────────────────────────────────────────────── /// @@ -244,6 +250,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta if (layout.FindElement(id) is { } twin) twin.Visible = false; + // ── Chat-window 1-4 indicator buttons — resolve now, wired later by + // RetailUiRuntime via SetIndicatorOpen as each floating window's own + // visibility changes (gmMainChatUI::RecvNotice_SetPanelVisibility + // @0x004CCD80 — see this class's doc + Indicator1Id..Indicator4Id). ── + uint[] indicatorIds = { Indicator1Id, Indicator2Id, Indicator3Id, Indicator4Id }; + for (int i = 0; i < indicatorIds.Length; i++) + c._indicatorButtons[i] = layout.FindElement(indicatorIds[i]) as UiButton; + // ── Transcript ─────────────────────────────────────────────────── // The factory now builds the Type-12 transcript element (0x10000011) as a UiText. // Find it in the widget tree and bind the live providers — no remove/add needed. @@ -460,6 +474,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta handle.MoveTo(frame.Left, targetTop); } + /// + /// Mirror a floating chat window's ( 1-4) + /// open/closed state onto its main-window indicator button — the exact + /// state transition gmMainChatUI::RecvNotice_SetPanelVisibility + /// @0x004CCD80 performs: State 6 (Highlight/"lit") when the + /// floating window is visible, State 1 (Normal) when it is not. + /// + /// + /// One-directional by design — a decomp read of + /// gmMainChatUI::ListenToElementMessage @0x004CDA80 (the ONLY + /// function in the whole 2013 binary that branches on + /// idMessage == 1, i.e. "clicked") shows it handles exactly two + /// element ids: 0x1000046f (max/min) and the talk-focus menu's + /// selection message. There is no case for + /// 0x10000522-0x10000525 — clicking a chat-window + /// indicator button does NOTHING in retail. acdream ports this exactly: + /// these buttons have no OnClick (research doc §1.4 corrected — + /// its own hedge that "it is safe to wire both" is a weaker reading than + /// this direct decomp confirmation, and is now superseded by it). + /// + /// + public void SetIndicatorOpen(int windowId, bool open) + { + if (windowId < 1 || windowId > _indicatorButtons.Length) + throw new ArgumentOutOfRangeException(nameof(windowId)); + _indicatorButtons[windowId - 1]?.TrySetRetailState( + open ? UiButtonStateMachine.Highlight : UiButtonStateMachine.Normal); + } + public RetainedWindowState CaptureWindowState() => new( Maximized: _maximized, @@ -524,27 +567,13 @@ 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. This is an - // approximation of retail's LayoutDesc-initialized m_curFontColor: - // acdream re-seeds at 0x00 and restarts the fold every render - // window rather than carrying one persistent field across the - // window's whole lifetime. Unreachable in practice today — no - // producer emits a LogTextType >= 0x22 (RetailChatColorTable.Colors - // covers the full 0x00-0x21 retail index space), so the carry path - // below never actually fires outside tests. - RetailChatColorTable.TryGetColor(0x00u, out Vector4 currentColor); - var result = new List(detailed.Count); - foreach (var d in detailed) - { - 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, currentColor)); - } + // Campaign CH slice CH6b: the wrap + retail color-carry-forward + // algorithm is now shared with FloatingChatWindowController via + // ChatTranscriptRenderer (approximation note on the color carry + // moved there). The main window passes accept:null — it has no + // user filter (color-table research doc §4); this is behaviorally + // identical to the inline loop this replaced. + var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, accept: null); return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont); } diff --git a/src/AcDream.App/UI/Layout/FloatingChatWindowController.cs b/src/AcDream.App/UI/Layout/FloatingChatWindowController.cs new file mode 100644 index 00000000..858bdae0 --- /dev/null +++ b/src/AcDream.App/UI/Layout/FloatingChatWindowController.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.UI; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds one of retail's four floating chat windows — LayoutDesc +/// 0x2100005B, window elements 0x10000505/0x1000050E/ +/// 0x1000050F/0x10000510 — to live behavior (Campaign CH +/// slice CH6b). All four windows share the SAME LayoutDesc; only the +/// window-id attribute (0x1000007E, carried here as +/// rather than re-read from the dat, since acdream's +/// importer does not resolve LayoutDesc attributes into runtime state) and +/// the mounted screen position differ per instance. +/// +/// +/// Shares with +/// (the main window) rather than +/// duplicating the word-wrap/color-carry algorithm — both are views over the +/// SAME transcript (research doc §6.1's "one +/// ChatWindowController instance per window" recommendation, generalized to +/// two sibling classes because the main and floaty layouts diverge enough +/// in their own element sets — talk-focus menu, max/min button, four +/// indicator buttons on main; title bar and close button on floaty, +/// research doc §2.1 vs §2.2 — to make one shared Bind() unreadable). +/// +/// +/// +/// A floaty window has no talk-focus menu (research doc §2.2), so its chat +/// entry always sends on — there is no +/// authored control to pick another channel from this window. +/// +/// +public sealed class FloatingChatWindowController : IRetainedPanelController +{ + public const uint LayoutId = 0x2100005Bu; + + // Element ids from floating chat LayoutDesc 0x2100005B (research doc §2.2). + private const uint RootId = 0x100004F7u; // window root, 250x108 + private const uint TranscriptPanelId = 0x10000010u; + private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory + private const uint TrackId = 0x10000012u; + private const uint InputRowId = 0x10000509u; // NOTE: differs from the main window's 0x10000013 + private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 -> UiField + private const uint SendId = 0x10000019u; + private const uint TitleBarId = 0x100004D9u; // gmFloatyChatUI::SetWindowTitle target + private const uint CloseButtonId = 0x1000052Au; + + private bool _disposed; + + /// The retail window id this instance is bound to (1-4). + public int WindowId { get; } + + public UiElement Root { get; private set; } = null!; + public UiText Transcript { get; private set; } = null!; + public UiField Input { get; private set; } = null!; + public UiScrollbar? Scrollbar { get; private set; } + + /// Resolved window-root metadata, including DAT size constraints. + public ElementInfo DatWindowInfo { get; private set; } = null!; + + public RetailWindowHandle? WindowHandle { get; private set; } + + // Same idle-frame caching shape as ChatWindowController — an unchanged + // transcript/filter/wrap-width does not re-wrap or re-resolve colors. + private IReadOnlyList _cachedTranscriptLines = Array.Empty(); + private long _cachedTranscriptRevision = -1; + private ulong _cachedFilter; + private float _cachedTranscriptWrapWidth = float.NaN; + private UiDatFont? _cachedTranscriptDatFont; + private BitmapFont? _cachedTranscriptDebugFont; + internal int TranscriptLayoutBuildCount { get; private set; } + + private FloatingChatWindowController(int windowId) + { + WindowId = windowId; + } + + /// + /// Bind an imported floating-chat layout to live behavior. + /// + /// Retail chat window id, 1 through 4. + /// Full tree from an + /// call against + /// . + /// Widget tree from a matching + /// call — a FRESH call per window + /// instance, since four independent windows need four independent + /// widget trees even though they share one imported rootInfo. + /// The SAME chat view-model the main window binds — + /// one canonical transcript, filtered per window. + /// Factory for the live command bus at submit time. + /// + /// Runtime's canonical per-window filter/open state + /// (). Read live on + /// every transcript rebuild — never copied — so a filter change is + /// visible on the next frame without a separate notification. + /// + public static FloatingChatWindowController? Bind( + int windowId, + ElementInfo rootInfo, + ImportedLayout layout, + ChatVM vm, + Func busProvider, + ChatWindowState windowFilters, + UiDatFont? datFont, + BitmapFont? debugFont, + Func resolve) + { + if (windowId < ChatWindowState.MinFloatingWindowId || windowId > ChatWindowState.MaxFloatingWindowId) + throw new ArgumentOutOfRangeException(nameof(windowId)); + ArgumentNullException.ThrowIfNull(windowFilters); + + var transcriptPanel = layout.FindElement(TranscriptPanelId); + var inputRow = layout.FindElement(InputRowId); + var input = layout.FindElement(InputId) as UiField; + + if (input is null || transcriptPanel is null || inputRow is null) + { + Console.WriteLine( + $"[D.2b] FloatingChatWindowController.Bind(window {windowId}): missing required elements " + + $"(input={input is not null}, panel={transcriptPanel is not null}, row={inputRow is not null}) — " + + $"floating chat window will not be interactive."); + return null; + } + + var window = layout.FindElement(RootId) ?? layout.Root; + var c = new FloatingChatWindowController(windowId) + { + Root = window, + DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo, + }; + + // ── Transcript ─────────────────────────────────────────────────── + c.Transcript = layout.FindElement(TranscriptId) as UiText + ?? throw new InvalidOperationException("floating chat transcript 0x10000011 not built as UiText"); + c.Transcript.DatFont = datFont; + c.Transcript.Font = debugFont; + c.Transcript.Centered = false; + c.Transcript.RightAligned = false; + c.Transcript.OneLine = false; + c.Transcript.Selectable = true; + c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm, windowFilters); + + // ── Input — no talk-focus menu on the floaty layout, so the + // channel is always Say (class doc). ───────────────────────────── + c.Input = input; + c.Input.DatFont = datFont; + c.Input.Font = debugFont; + c.Input.SpriteResolve = resolve; + c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), ChatChannelKind.Say); + + // Same right-edge-tracks-resize fix as the main window + // (ChatWindowController.Bind) — see that method's comment for the + // full retail edge-mode citation. + if (c.Input.LayoutPolicy is { } inputPolicy) + { + c.Input.LayoutPolicy = new UiLayoutPolicy( + inputPolicy.LeftMode, + inputPolicy.TopMode, + rightMode: 1u, + inputPolicy.BottomMode, + inputPolicy.OriginalChild, + inputPolicy.OriginalParent); + } + else + { + c.Input.Anchors |= AnchorEdges.Right; + } + + // ── Scrollbar — bind the factory-built Type-11 track element ──── + if (layout.FindElement(TrackId) is UiScrollbar bar) + { + bar.Model = c.Transcript.Scroll; + bar.SpriteResolve ??= resolve; + c.Scrollbar = bar; + } + + // ── Send button ───────────────────────────────────────────────── + if (layout.FindElement(SendId) is UiButton sendEl) + { + sendEl.OnClick = () => c.Input.Submit(); + sendEl.Label = "Send"; + sendEl.LabelFont = datFont; + sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); + } + + // ── Title bar — gmFloatyChatUI::SetWindowTitle @0x004CEAA0 sets a + // localized "Chat N" string here; acdream has no LayoutDesc string + // table wired for it yet, so this is a hardcoded English label — + // the same stopgap the channel menu's item labels already use + // (ChatWindowController.ChannelItems). ────────────────────────── + if (layout.FindElement(TitleBarId) is UiText titleText) + { + titleText.DatFont = datFont; + titleText.Font = debugFont; + titleText.OneLine = true; + string title = $"Chat {windowId}"; + var titleColor = new Vector4(1f, 0.92f, 0.72f, 1f); + titleText.LinesProvider = () => new[] { new UiText.Line(title, titleColor) }; + } + + // ── Close button — gmFloatyChatUI::ListenToElementMessage + // @0x004CE330: idMessage==1 (clicked) on 0x1000052A -> SetVisible(false). ── + if (layout.FindElement(CloseButtonId) is UiButton closeEl) + { + closeEl.OnClick = () => c.WindowHandle?.Hide(); + } + + return c; + } + + /// + /// Attach the typed outer-frame handle after the controller's imported + /// content has been mounted. The close button needs this to hide the + /// window (retail's own SetVisible(false)). + /// + public void AttachWindow(RetailWindowHandle handle) + { + ArgumentNullException.ThrowIfNull(handle); + if (!ReferenceEquals(handle.ContentRoot, Root)) + throw new ArgumentException( + "Floating chat handle content root does not match the bound layout.", nameof(handle)); + if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle)) + throw new InvalidOperationException("Floating chat controller is already attached to another window."); + WindowHandle = handle; + } + + private static ElementInfo? FindInfo(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (ElementInfo child in node.Children) + { + ElementInfo? found = FindInfo(child, id); + if (found is not null) return found; + } + return null; + } + + /// + /// Convert the shared ChatVM's detailed lines to this window's filtered, + /// wrapped, colored transcript — + /// is retail's broadcast half of the display rule + /// (ChatInterface::RecvNotice_DisplayFinalStringInfo); the + /// explicit-address half is inert today because no production + /// ChatEntry carries a target window id yet (register row + /// AP-180 — see 's class doc). + /// + private IReadOnlyList GetTranscriptLines(ChatVM vm, ChatWindowState windowFilters) + { + float maxW = Transcript.Width - 2f * Transcript.Padding; + UiDatFont? datFont = Transcript.DatFont; + BitmapFont? debugFont = Transcript.Font; + long revision = vm.Revision; + ulong filter = windowFilters.GetFilter(WindowId); + + if (_cachedTranscriptRevision == revision + && _cachedFilter == filter + && _cachedTranscriptWrapWidth.Equals(maxW) + && ReferenceEquals(_cachedTranscriptDatFont, datFont) + && ReferenceEquals(_cachedTranscriptDebugFont, debugFont)) + { + return _cachedTranscriptLines; + } + + var detailed = vm.RecentLinesDetailed(); + Func measure = + datFont is { } df ? s => df.MeasureWidth(s) + : debugFont is { } bf ? s => bf.MeasureWidth(s) + : static s => s.Length * 7f; + + bool Accept(uint logTextType) => windowFilters.ShouldDisplay(WindowId, targetWindowId: 0u, logTextType); + var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept); + + _cachedTranscriptRevision = revision; + _cachedFilter = filter; + _cachedTranscriptWrapWidth = maxW; + _cachedTranscriptDatFont = datFont; + _cachedTranscriptDebugFont = debugFont; + _cachedTranscriptLines = result; + TranscriptLayoutBuildCount++; + return result; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 97692b2d..7feeeea9 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -6,6 +6,7 @@ using AcDream.App.Rendering; using AcDream.App.Spells; using AcDream.App.UI.Layout; using AcDream.App.UI.Testing; +using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; @@ -36,7 +37,29 @@ public sealed record RetailUiAssets( public sealed record VitalsRuntimeBindings(VitalsVM ViewModel); -public sealed record ChatRuntimeBindings(ChatVM ViewModel, Func CommandBus); +/// +/// Runtime's canonical per-window filter/open state +/// (). +/// Campaign CH slice CH6b: the four floating chat windows read/write this +/// exact instance — never a presentation-owned copy. +/// +/// +/// Optional local settings store used to persist the four floating +/// windows' text-type filters (open/visible + geometry already persist for +/// free through the generic +/// path once each window is registered under a distinct +/// entry). Null in hosts that don't have a +/// settings store wired (e.g. some tests) — filters then simply keep their +/// retail PostInit defaults for the session. The wire format +/// (retail's 0x1000008B/0x1000008C gameplay-options blob) is +/// explicitly deferred — see +/// docs/plans/2026-08-09-chat-parity-campaign.md's CH6f row. +/// +public sealed record ChatRuntimeBindings( + ChatVM ViewModel, + Func CommandBus, + ChatWindowState Windows, + SettingsStore? Store = null); public sealed record RadarRuntimeBindings( Func Snapshot, @@ -225,6 +248,8 @@ public sealed class RetailUiRuntime : IDisposable private RetailWindowLayoutPersistence? _persistence; private RetailUiAutomationScriptRunner? _automation; private readonly RetailPanelUiController _panelUi; + private ChatWindowController? _chatWindowController; + private readonly FloatingChatWindowController?[] _floatingChatControllers = new FloatingChatWindowController?[4]; private GameplayConfirmationController? _gameplayConfirmationController; private RetailItemConfirmationController? _itemConfirmationController; private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController; @@ -263,6 +288,7 @@ public sealed class RetailUiRuntime : IDisposable MountVitals(); MountRadar(); MountChat(); + MountFloatingChatWindows(); MountToolbar(); MountCombat(); MountSpellbook(); @@ -500,7 +526,11 @@ public sealed class RetailUiRuntime : IDisposable public void RestoreLayout() => _persistence?.RestoreAll(); - public void SaveLayout() => _persistence?.SaveAll(); + public void SaveLayout() + { + _persistence?.SaveAll(); + SaveChatWindowFilters(); + } public void SaveNamedLayout(string profileName) => _persistence?.SaveNamed(profileName); @@ -511,6 +541,55 @@ public sealed class RetailUiRuntime : IDisposable ? _panelUi.TogglePanel(panelId) : Host.ToggleWindow(name); + /// + /// The four floating chat window controllers, indexed [windowId - 1] + /// (null entries are windows whose required elements were missing — + /// see ). + /// + public IReadOnlyList FloatingChatWindows => _floatingChatControllers; + + /// + /// Toggle floating chat window (1-4) — the + /// ToggleFloatingChatWindow1..4 keybind's landing point + /// (research doc §1.3: the keybind mechanism, not a button click, is + /// what drives this in retail). Routes through the generic + /// , whose visibility-change event + /// drives both the persisted open state + /// ( syncing + /// ) and the main window's indicator-button + /// mirror — one chokepoint for every trigger. + /// + public bool ToggleFloatingChatWindow(int windowId) + => Host.ToggleWindow(FloatingChatWindowName(windowId)); + + private static string FloatingChatWindowName(int windowId) => windowId switch + { + 1 => WindowNames.ChatWindow1, + 2 => WindowNames.ChatWindow2, + 3 => WindowNames.ChatWindow3, + 4 => WindowNames.ChatWindow4, + _ => throw new ArgumentOutOfRangeException(nameof(windowId), windowId, "floating chat window id must be 1-4."), + }; + + /// + /// Capture the four floating windows' current text-type filters into + /// the local settings store (research doc §4.4/§6.1 — local-only until + /// CH6f's wire format). No-op when no store was wired. + /// + private void SaveChatWindowFilters() + { + if (_bindings.Chat.Store is not { } store) return; + ChatWindowState windows = _bindings.Chat.Windows; + ChatSettings current = store.LoadChat(); + store.SaveChat(current with + { + ChatWindow1Filter = windows.GetFilter(1), + ChatWindow2Filter = windows.GetFilter(2), + ChatWindow3Filter = windows.GetFilter(3), + ChatWindow4Filter = windows.GetFilter(4), + }); + } + public void CloseWindow(string name) { if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId)) @@ -543,6 +622,30 @@ public sealed class RetailUiRuntime : IDisposable _panelUi.ObserveWindowVisibility(windowName, visible); if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId)) ToolbarController?.SetPanelOpen(panelId, visible); + + if (TryGetFloatingChatWindowId(windowName, out int chatWindowId)) + { + // Keep Runtime's canonical open flag in sync regardless of what + // triggered the visibility change (keybind, restored layout, + // or the window's own close button) — one write path, matching + // gmFloatyChatUI::SetVisible's own single call site + // (persistence research doc §4.2). + _bindings.Chat.Windows.SetOpen(chatWindowId, visible); + _chatWindowController?.SetIndicatorOpen(chatWindowId, visible); + } + } + + private static bool TryGetFloatingChatWindowId(string windowName, out int windowId) + { + windowId = windowName switch + { + WindowNames.ChatWindow1 => 1, + WindowNames.ChatWindow2 => 2, + WindowNames.ChatWindow3 => 3, + WindowNames.ChatWindow4 => 4, + _ => 0, + }; + return windowId != 0; } private ImportedLayout? Import(uint layoutId) @@ -755,9 +858,133 @@ public sealed class RetailUiRuntime : IDisposable }); controller.AttachWindow(handle); Host.Root.DefaultTextInput = controller.Input; + _chatWindowController = controller; Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x2100006F)."); } + /// + /// Mount the four retail floating chat windows — always-resident + /// authored children per gmGamePlayUI::SetupChildren @0x004E9EC0 + /// (research doc §1.1), all four sharing LayoutDesc 0x2100005B + /// but each with its own widget tree, window-registry name, and + /// screen position; every one starts hidden + /// (gmFloatyChatUI instances are born closed and toggled by + /// keybind/indicator button — research doc §1.3-§1.4). Must run after + /// so is + /// available for the indicator-button mirror wired in + /// . + /// + private void MountFloatingChatWindows() + { + ElementInfo? info; + lock (_bindings.Assets.DatLock) + { + info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, FloatingChatWindowController.LayoutId); + } + if (info is null) + { + Console.WriteLine("[D.2b] floating chat: LayoutDesc 0x2100005B not found."); + return; + } + + ChatWindowState windowFilters = _bindings.Chat.Windows; + if (_bindings.Chat.Store is { } store) + { + // Local-only persistence (research doc §4.4/§6.1 — the retail + // 0x1000008B/0x1000008C gameplay-options wire is explicitly + // deferred to CH6f). Open/visible + geometry persist for free + // through the generic RetailWindowLayoutPersistence path once + // each window registers below; only the filter mask needs its + // own load/save leg. + ChatSettings chat = store.LoadChat(); + windowFilters.SetFilter(1, chat.ChatWindow1Filter); + windowFilters.SetFilter(2, chat.ChatWindow2Filter); + windowFilters.SetFilter(3, chat.ChatWindow3Filter); + windowFilters.SetFilter(4, chat.ChatWindow4Filter); + } + + // acdream-chosen default screen positions — retail's own authored + // rect (0,80 in the shared LayoutDesc's local space) is identical + // for all four windows, so a literal port would stack every window + // at the same spot on first open; stagger them the same way the + // main chat window's own (10,440) default is an acdream placement + // choice, not a retail-authored screen position (MountChat above). + (string windowName, float left, float top)[] slots = + { + (WindowNames.ChatWindow1, 440f, 40f), + (WindowNames.ChatWindow2, 440f, 170f), + (WindowNames.ChatWindow3, 440f, 300f), + (WindowNames.ChatWindow4, 440f, 430f), + }; + + var strings = new DatStringResolver(_bindings.Assets.Dats); + for (int windowId = 1; windowId <= 4; windowId++) + { + ImportedLayout layout; + lock (_bindings.Assets.DatLock) + { + layout = LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont, + strings.Resolve); + } + + FloatingChatWindowController? controller = FloatingChatWindowController.Bind( + windowId, + info, + layout, + _bindings.Chat.ViewModel, + _bindings.Chat.CommandBus, + windowFilters, + _bindings.Assets.DefaultFont, + _bindings.Assets.DebugFont, + _bindings.Assets.ResolveSprite); + if (controller is null) + { + Console.WriteLine($"[D.2b] floating chat window {windowId}: required role elements missing in 0x2100005B."); + continue; + } + + controller.Transcript.Keyboard = Host.Keyboard; + controller.Input.Keyboard = Host.Keyboard; + (string windowName, float left, float top) = slots[windowId - 1]; + UiElement root = controller.Root; + RetailWindowHandle handle = RetailWindowFrame.Mount( + Host.Root, + root, + _bindings.Assets.ResolveSprite, + new RetailWindowFrame.Options + { + WindowName = windowName, + // Same reasoning as MountChat: 0x2100005B's root is its + // own complete chrome (title bar, close button, and + // whichever mix of Resizebar/Dragbar grips the DAT + // authors for this layout — CH6a's importer/UiRoot + // priority logic is fully data-driven and needs no + // per-layout grip-count knowledge here). + Chrome = RetailWindowChrome.Imported, + Left = left, + Top = top, + DatConstraintSource = controller.DatWindowInfo, + AuthoredGeometryRevision = 1, + ResizeX = true, + ResizeY = true, + // Born hidden (research doc §1.1) — opened only via + // ToggleFloatingChatWindow (keybind) or a saved + // "visible" layout entry restored by + // RetailWindowLayoutPersistence. + Visible = false, + Controller = controller, + }); + controller.AttachWindow(handle); + _floatingChatControllers[windowId - 1] = controller; + } + + Console.WriteLine("[D.2b] retail floating chat windows 1-4 from LayoutDesc importer (0x2100005B)."); + } + private void MountToolbar() { ImportedLayout? layout = Import(0x21000016u); diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index 7030b0b4..198adc22 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -11,6 +11,10 @@ public static class WindowNames public const string Inventory = "inventory"; public const string ExternalContainer = "external-container"; public const string Chat = "chat"; + public const string ChatWindow1 = "chat-window-1"; + public const string ChatWindow2 = "chat-window-2"; + public const string ChatWindow3 = "chat-window-3"; + public const string ChatWindow4 = "chat-window-4"; public const string Radar = "radar"; public const string Combat = "combat"; public const string JumpPowerbar = "jump-powerbar"; diff --git a/src/AcDream.Core/Chat/ChatWindowState.cs b/src/AcDream.Core/Chat/ChatWindowState.cs new file mode 100644 index 00000000..39079535 --- /dev/null +++ b/src/AcDream.Core/Chat/ChatWindowState.cs @@ -0,0 +1,199 @@ +using System; +using System.Threading; + +namespace AcDream.Core.Chat; + +/// +/// Retail's per-chat-window text-type filter and open/visible state — window +/// id 0 is the main chat window, ids 1-4 are the four +/// floating chat windows (Campaign CH slice CH6b, +/// docs/research/2026-08-09-chat-retail-window-shell.md §1.2 and +/// docs/research/2026-08-09-chat-retail-color-table.md §4). +/// +/// +/// Ports two retail mechanisms exactly: +/// +/// ChatInterface::PostInit @0x004F3DD0's m_oldState +/// switch seeds each window's default 64-bit +/// m_llTextTypeFilter (color-table doc §4's table — the constants +/// below are byte-identical to that table). +/// ChatInterface::RecvNotice_DisplayFinalStringInfo +/// @0x004F4640's display predicate: a line shows in window +/// W when the message's target window id equals W +/// (explicit addressing) OR the message is broadcast (target id +/// 0) AND W's filter accepts the line's +/// (ChatInterface::TypeIsActive +/// @0x004F2F10). +/// +/// +/// +/// +/// UpdateFromPlayerModule @0x004F3920 early-returns for window id +/// 0 — the main window never has a user-settable filter. That +/// invariant needs no special case here: for windowId == 0, +/// 's first branch (targetWindowId == +/// windowId) is already true for every broadcast line (target id +/// 0), so the main window's filter is never actually consulted — +/// exactly matching retail's "no user filter" behavior without a guard. +/// and are still no-ops for +/// window 0 (it is always open and its seeded filter is inert), for +/// the same reason retail's setters gate on m_eWindowID != 0 +/// (persistence doc §4.2). +/// +/// +/// +/// No production carries an explicit target window +/// id yet (register row AP-180 — the windowId dual-destination echo +/// is deferred); every current line is effectively broadcast +/// (targetWindowId == 0). still accepts +/// the full retail shape so the routing predicate does not need to change +/// shape when AP-180 lands. +/// +/// +public sealed class ChatWindowState +{ + public const int MainWindowId = 0; + public const int MinFloatingWindowId = 1; + public const int MaxFloatingWindowId = 4; + + private const int WindowCount = MaxFloatingWindowId + 1; + + private readonly object _gate = new(); + private readonly ulong[] _filters = new ulong[WindowCount]; + private readonly bool[] _open = new bool[WindowCount]; + private long _revision; + + public ChatWindowState() => ResetToDefaults(); + + /// + /// Monotonic counter bumped on every filter or open-state change. + /// Lets presentation caches (per-window transcript layout) detect a + /// filter/visibility change without re-deriving it from the raw arrays. + /// + public long Revision => Interlocked.Read(ref _revision); + + /// + /// Reset every window to retail's PostInit defaults (color-table + /// doc §4). The high dword is always 0 for every window — Society + /// (0x20) and the reserved slot (0x21) are opt-in only, + /// matching retail. Windows 1-4 start closed; window 0 (main) is always + /// open. + /// + public void ResetToDefaults() + { + lock (_gate) + { + // "everything 0x00-0x1F except 0x1A" (m_oldState 1/8) — inert for + // routing (see class doc) but seeded for fidelity/inspection. + _filters[0] = 0xFBFFFFFFu; + // Speech, Tell, Speech_Direct_Send, Emote (m_oldState 2). + _filters[1] = 0x0000101Cu; + // Social, Social_Send, Allegiance (m_oldState 3). + _filters[2] = 0x00040C00u; + // Fellowship (m_oldState 4). + _filters[3] = 0x00080000u; + // Turbine General/Trade/LFG/Roleplay (m_oldState 5). + _filters[4] = 0x78000000u; + + _open[0] = true; + for (int i = MinFloatingWindowId; i <= MaxFloatingWindowId; i++) + _open[i] = false; + + Interlocked.Increment(ref _revision); + } + } + + public ulong GetFilter(int windowId) + { + ValidateWindowId(windowId); + lock (_gate) return _filters[windowId]; + } + + /// + /// Set window 's 64-bit type filter. No-op for + /// the main window (id 0) — see the class doc. + /// + public void SetFilter(int windowId, ulong filter) + { + ValidateWindowId(windowId); + if (windowId == MainWindowId) return; + lock (_gate) + { + if (_filters[windowId] == filter) return; + _filters[windowId] = filter; + Interlocked.Increment(ref _revision); + } + } + + /// Main window (id 0) is always open. + public bool IsOpen(int windowId) + { + ValidateWindowId(windowId); + if (windowId == MainWindowId) return true; + lock (_gate) return _open[windowId]; + } + + /// No-op for the main window — it cannot be closed. + public void SetOpen(int windowId, bool open) + { + ValidateWindowId(windowId); + if (windowId == MainWindowId) return; + lock (_gate) + { + if (_open[windowId] == open) return; + _open[windowId] = open; + Interlocked.Increment(ref _revision); + } + } + + /// + /// Flip window 's open state and return the + /// new value. Always returns for the main window + /// (it cannot be toggled closed). + /// + public bool Toggle(int windowId) + { + ValidateWindowId(windowId); + if (windowId == MainWindowId) return true; + lock (_gate) + { + bool next = !_open[windowId]; + _open[windowId] = next; + Interlocked.Increment(ref _revision); + return next; + } + } + + /// + /// ChatInterface::TypeIsActive @0x004F2F10: does window + /// 's filter accept retail + /// ? + /// Types >= 64 are never active (there is no such bit). + /// + public bool TypeIsActive(int windowId, uint logTextType) + { + ValidateWindowId(windowId); + if (logTextType >= 64u) return false; + ulong filter; + lock (_gate) filter = _filters[windowId]; + return ((1UL << (int)logTextType) & filter) != 0UL; + } + + /// + /// ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640's + /// exact display predicate — see the class doc for the two branches. + /// + public bool ShouldDisplay(int windowId, uint targetWindowId, uint logTextType) + { + ValidateWindowId(windowId); + if (targetWindowId == (uint)windowId) return true; + return targetWindowId == 0u && TypeIsActive(windowId, logTextType); + } + + private static void ValidateWindowId(int windowId) + { + if (windowId < MainWindowId || windowId > MaxFloatingWindowId) + throw new ArgumentOutOfRangeException( + nameof(windowId), windowId, "chat window id must be 0 (main) through 4 (floating)."); + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs index 188a33f6..460d300a 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs @@ -66,6 +66,7 @@ public sealed class RuntimeCommunicationState : IDisposable TurbineChat = new TurbineChatState(); Friends = new FriendsState(); Squelch = new SquelchState(); + ChatWindows = new ChatWindowState(); View = new CommunicationView(Chat); SocialView = new CommunicationSocialView( TurbineChat, @@ -75,6 +76,15 @@ public sealed class RuntimeCommunicationState : IDisposable public ChatLog Chat { get; } + /// + /// Campaign CH slice CH6b: retail's per-window text-type filter and + /// open/visible state for the main chat window (id 0) and the four + /// floating chat windows (ids 1-4). Graphical, headless, and plugin + /// hosts all borrow this exact instance — presentation never owns a + /// second copy of filter/open state. + /// + public ChatWindowState ChatWindows { get; } + /// /// Campaign CH slice CH2: retail's transient on-screen "interface text" /// queue (gmSpewBoxUI) — the SECOND sink @@ -206,6 +216,7 @@ public sealed class RuntimeCommunicationState : IDisposable Squelch.Clear(); Chat.ResetSessionIdentity(); SpewBox.Reset(); + ChatWindows.ResetToDefaults(); } private sealed class CommunicationView(ChatLog chat) : IRuntimeChatView diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs index 9591ec82..7f32cc10 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs @@ -46,7 +46,22 @@ public sealed record ChatSettings( bool ShowTimestamps, // 0x40 — TimeStamp prefix on chat lines bool FilterProfanity, // 0x20000 — FilterLanguage (Turbine's profanity filter) // Visual / UX (no retail bitfield). - float FontSize) // chat panel font, 10..20 pt + float FontSize, // chat panel font, 10..20 pt + // Campaign CH slice CH6b: local-only persistence of the four floating + // chat windows' 64-bit text-type filters + // (AcDream.Core.Chat.ChatWindowState — retail's per-window + // 0x1000007F option, docs/research/2026-08-09-chat-retail-color-table.md + // §4). Retail persists these server-side inside the opaque + // 0x1000008C GameplayOptions blob (window-shell research doc §4.4); + // acdream has no writer for that blob yet, so these fields are the + // interim local store, with default values matching retail's own + // ChatInterface::PostInit @0x004F3DD0 seed exactly. Trailing with + // defaults so no existing positional/named ChatSettings construction + // site needed to change. + ulong ChatWindow1Filter = 0x0000101Cu, // Speech, Tell, Speech_Direct_Send, Emote + ulong ChatWindow2Filter = 0x00040C00u, // Social, Social_Send, Allegiance + ulong ChatWindow3Filter = 0x00080000u, // Fellowship + ulong ChatWindow4Filter = 0x78000000u) // Turbine General/Trade/LFG/Roleplay { /// /// N4 (CH3 Opus review): matches ACE's ACTUAL diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 0656dd7a..67f2ed84 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -195,7 +195,11 @@ public sealed class SettingsStore AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline), ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps), FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity), - FontSize: ReadFloat(chat, "fontSize", d.FontSize)); + FontSize: ReadFloat(chat, "fontSize", d.FontSize), + ChatWindow1Filter: ReadULong(chat, "chatWindow1Filter", d.ChatWindow1Filter), + ChatWindow2Filter: ReadULong(chat, "chatWindow2Filter", d.ChatWindow2Filter), + ChatWindow3Filter: ReadULong(chat, "chatWindow3Filter", d.ChatWindow3Filter), + ChatWindow4Filter: ReadULong(chat, "chatWindow4Filter", d.ChatWindow4Filter)); } catch (Exception ex) { @@ -537,7 +541,11 @@ public sealed class SettingsStore private static SortedDictionary BuildChatObject(ChatSettings c) => new(StringComparer.Ordinal) { - ["appearOffline"] = c.AppearOffline, + ["appearOffline"] = c.AppearOffline, + ["chatWindow1Filter"] = c.ChatWindow1Filter, + ["chatWindow2Filter"] = c.ChatWindow2Filter, + ["chatWindow3Filter"] = c.ChatWindow3Filter, + ["chatWindow4Filter"] = c.ChatWindow4Filter, ["filterProfanity"] = c.FilterProfanity, ["fontSize"] = c.FontSize, ["hearGeneralChat"] = c.HearGeneralChat, @@ -655,6 +663,13 @@ public sealed class SettingsStore => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number ? el.GetSingle() : fallback; + private static ulong ReadULong(JsonElement obj, string name, ulong fallback) + { + if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) + return fallback; + return el.TryGetUInt64(out ulong value) ? value : fallback; + } + private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback) { if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index bc2d577d..ab96d012 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -17,6 +17,10 @@ public sealed class GameplayInputCommandControllerTests [InlineData(InputAction.ToggleChatEntry, "chat")] [InlineData(InputAction.ToggleOptionsPanel, "settings")] [InlineData(InputAction.CombatToggleCombat, "combat")] + [InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")] + [InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")] + [InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")] + [InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")] public void RecognizedCommand_RoutesToTypedOwner( InputAction action, string expected) @@ -119,6 +123,9 @@ public sealed class GameplayInputCommandControllerTests : IRetainedGameplayWindowCommands { public void ToggleInventory() => calls.Add("inventory"); + + public void ToggleFloatingChatWindow(int windowId) => + calls.Add($"chat-window-{windowId}"); } private sealed class FakeDevTools(List calls) diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 54d38a73..12696592 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -109,6 +109,24 @@ public class ChatWindowControllerTests Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16, }; + // Type 1 -> UiButton (DatWidgetFactory.Create) so SetIndicatorOpen tests + // have a real IUiDatStateful to assert against. Normal/Highlight state + // MEDIA (not just a States entry) must be authored or TrySetRetailState + // can't resolve either name to an ActiveState (UiButton.HasStateMedia) — + // same fixture shape ToolbarControllerTests uses for its own + // SetPanelOpen(Normal/Highlight) mirror test. + ElementInfo MakeIndicator(uint id, float y) + { + var info = new ElementInfo { Id = id, Type = 1, X = 5, Y = y, Width = 16, Height = 16 }; + info.StateMedia["Normal"] = (0x1u, 1); + info.StateMedia["Highlight"] = (0x2u, 1); + return info; + } + var indicator1 = MakeIndicator(0x10000522u, 5); + var indicator2 = MakeIndicator(0x10000523u, 22); + var indicator3 = MakeIndicator(0x10000524u, 39); + var indicator4 = MakeIndicator(0x10000525u, 56); + var root = new ElementInfo { Id = 0x10000600u, Type = 3, Width = 490, Height = 100, @@ -116,6 +134,10 @@ public class ChatWindowControllerTests root.Children.Add(transcriptPanel); root.Children.Add(inputBar); root.Children.Add(maxMinNode); + root.Children.Add(indicator1); + root.Children.Add(indicator2); + root.Children.Add(indicator3); + root.Children.Add(indicator4); var layout = LayoutImporter.Build(root, NoTex, null); var vm = new ChatVM(log ?? new ChatLog()); @@ -487,4 +509,65 @@ public class ChatWindowControllerTests Assert.Equal(new[] { "first", "", "third" }, lines); } + + // ── SetIndicatorOpen: Campaign CH slice CH6b — the button mirror ───────── + // gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80: State 6 + // (Highlight) when the floating window is visible, State 1 (Normal) + // when it is not. + + [Theory] + [InlineData(1, 0x10000522u)] + [InlineData(2, 0x10000523u)] + [InlineData(3, 0x10000524u)] + [InlineData(4, 0x10000525u)] + public void SetIndicatorOpen_Open_SetsHighlightState(int windowId, uint indicatorId) + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!; + var indicator = Assert.IsType(layout.FindElement(indicatorId)); + + ctrl.SetIndicatorOpen(windowId, open: true); + + Assert.Equal(UiButtonStateMachine.Highlight, indicator.ActiveRetailStateId); + } + + [Fact] + public void SetIndicatorOpen_Closed_SetsNormalState() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!; + var indicator = Assert.IsType(layout.FindElement(0x10000522u)); + + ctrl.SetIndicatorOpen(1, open: true); + ctrl.SetIndicatorOpen(1, open: false); + + Assert.Equal(UiButtonStateMachine.Normal, indicator.ActiveRetailStateId); + } + + [Fact] + public void SetIndicatorOpen_DoesNotAffectOtherWindowsIndicators() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!; + var indicator2 = Assert.IsType(layout.FindElement(0x10000523u)); + + ctrl.SetIndicatorOpen(1, open: true); + + Assert.Equal(UiButtonStateMachine.Normal, indicator2.ActiveRetailStateId); + } + + [Theory] + [InlineData(0)] + [InlineData(5)] + public void SetIndicatorOpen_OutOfRangeWindowId_Throws(int windowId) + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex)!; + + Assert.Throws(() => ctrl.SetIndicatorOpen(windowId, open: true)); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs new file mode 100644 index 00000000..6ffec932 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs @@ -0,0 +1,282 @@ +using System.Collections.Generic; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Smoke + filter-routing tests for +/// — no dats, no GL, +/// mirroring ChatWindowControllerTests's synthetic-tree approach but +/// against the floating layout's element topology +/// (0x2100005B, research doc §2.2): the input row id +/// (0x10000509) differs from the main window's +/// (0x10000013), and there is no talk-focus menu / max-min button / +/// indicator row. +/// +public class FloatingChatWindowControllerTests +{ + private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); + + private sealed class CaptureBus : ICommandBus + { + public readonly List Published = new(); + public void Publish(T cmd) where T : notnull => Published.Add(cmd!); + } + + /// + /// Synthetic tree mirroring the floating chat layout topology: + /// root (Type-3) [0x100004F7] + /// transcriptPanel (Type-3) [0x10000010] + /// transcript (Type-12, no media) [0x10000011] + /// track (Type-3) [0x10000012] + /// inputRow (Type-3) [0x10000509] ← the floaty-specific id + /// input (Type-12, Editable+Selectable) [0x10000016] + /// send (Type-3) [0x10000019] + /// titleBar (Type-3) [0x100004D9] + /// closeButton (Type-3) [0x1000052A] + /// + private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree( + ChatLog? log = null) + { + var transcriptNode = new ElementInfo + { + Id = 0x10000011u, Type = 12, + X = 5, Y = 20, Width = 224, Height = 60, + }; + var trackNode = new ElementInfo + { + Id = 0x10000012u, Type = 3, + X = 229, Y = 20, Width = 16, Height = 60, + }; + var transcriptPanel = new ElementInfo + { + Id = 0x10000010u, Type = 3, X = 0, Y = 20, Width = 250, Height = 60, + }; + transcriptPanel.Children.Add(transcriptNode); + transcriptPanel.Children.Add(trackNode); + + var inputNode = new ElementInfo + { + Id = 0x10000016u, Type = 12, + X = 0, Y = 80, Width = 202, Height = 18, + }; + var inputState = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + inputState.Properties.Values[0x16u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true }; + inputState.Properties.Values[0x20u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true }; + inputState.Properties.Values[0x27u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true }; + inputNode.States[UiStateInfo.DirectStateId] = inputState; + var sendNode = new ElementInfo + { + Id = 0x10000019u, Type = 3, X = 202, Y = 80, Width = 38, Height = 18, + }; + var inputRow = new ElementInfo + { + Id = 0x10000509u, Type = 3, X = 0, Y = 80, Width = 250, Height = 18, + }; + inputRow.Children.Add(inputNode); + inputRow.Children.Add(sendNode); + + var titleBar = new ElementInfo { Id = 0x100004D9u, Type = 3, X = 0, Y = 0, Width = 240, Height = 16 }; + var closeButton = new ElementInfo { Id = 0x1000052Au, Type = 3, X = 225, Y = 1, Width = 14, Height = 14 }; + + var root = new ElementInfo { Id = 0x100004F7u, Type = 3, Width = 250, Height = 108 }; + root.Children.Add(transcriptPanel); + root.Children.Add(inputRow); + root.Children.Add(titleBar); + root.Children.Add(closeButton); + + var layout = LayoutImporter.Build(root, NoTex, null); + var vm = new ChatVM(log ?? new ChatLog()); + return (root, layout, vm); + } + + // ── Bind smoke tests ────────────────────────────────────────────────── + + [Fact] + public void Bind_Returns_NonNull_OnValidTree() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); + + var ctrl = FloatingChatWindowController.Bind( + 1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex); + + Assert.NotNull(ctrl); + Assert.Equal(1, ctrl!.WindowId); + } + + [Theory] + [InlineData(0)] + [InlineData(5)] + public void Bind_InvalidWindowId_Throws(int windowId) + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); + + Assert.Throws(() => + FloatingChatWindowController.Bind( + windowId, rootInfo, layout, vm, () => bus, filters, null, null, NoTex)); + } + + [Fact] + public void Bind_Returns_Null_WhenTranscriptPanelMissing() + { + var root = new ElementInfo { Id = 0x100004F7u, Type = 3, Width = 250, Height = 108 }; + var layout = LayoutImporter.Build(root, NoTex, null); + var vm = new ChatVM(new ChatLog()); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); + + var ctrl = FloatingChatWindowController.Bind( + 1, root, layout, vm, () => bus, filters, null, null, NoTex); + + Assert.Null(ctrl); + } + + [Fact] + public void Bind_Transcript_IsChildOfTranscriptPanel() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); + + var ctrl = FloatingChatWindowController.Bind( + 2, rootInfo, layout, vm, () => bus, filters, null, null, NoTex); + + Assert.NotNull(ctrl); + var panel = layout.FindElement(0x10000010u); + Assert.NotNull(panel); + Assert.Contains(ctrl!.Transcript, panel!.Children); + } + + [Fact] + public void Bind_Input_IsChildOfFloatyInputRow_NotMainWindowInputBar() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); + + var ctrl = FloatingChatWindowController.Bind( + 3, rootInfo, layout, vm, () => bus, filters, null, null, NoTex); + + Assert.NotNull(ctrl); + var row = layout.FindElement(0x10000509u); + Assert.NotNull(row); + Assert.Contains(ctrl!.Input, row!.Children); + } + + // ── Chat entry always sends on Say (no talk-focus menu authored) ─────── + + [Fact] + public void Bind_InputSubmit_AlwaysSendsOnSayChannel() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); + + var ctrl = FloatingChatWindowController.Bind( + 4, rootInfo, layout, vm, () => bus, filters, null, null, NoTex); + + Assert.NotNull(ctrl); + ctrl!.Input.OnSubmit!.Invoke("hi everyone"); + + var cmd = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ChatChannelKind.Say, cmd.Channel); + Assert.Equal("hi everyone", cmd.Text); + } + + // ── Display rule matrix: this window's filter accepts/rejects a line ─── + + [Fact] + public void Transcript_ShowsOnlyLinesThisWindowsFilterAccepts() + { + var log = new ChatLog(); + var (rootInfo, layout, vm) = BuildTestTree(log); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); // window 1 default: Speech/Tell/SpeechDirectSend/Emote + + var ctrl = FloatingChatWindowController.Bind( + 1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex); + Assert.NotNull(ctrl); + + log.OnSystemMessage("visible speech", chatType: 0x02u); // Speech — in window 1's filter + log.OnSystemMessage("hidden social", chatType: 0x0Au); // Social — NOT in window 1's filter + + var lines = ctrl!.Transcript.LinesProvider(); + + Assert.Single(lines); + Assert.Equal("visible speech", lines[0].Text); + } + + [Fact] + public void Transcript_DifferentWindow_AcceptsADifferentSubsetOfTypes() + { + var log = new ChatLog(); + var (rootInfo, layout, vm) = BuildTestTree(log); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); // window 3 default: Fellowship only + + var ctrl = FloatingChatWindowController.Bind( + 3, rootInfo, layout, vm, () => bus, filters, null, null, NoTex); + Assert.NotNull(ctrl); + + log.OnSystemMessage("speech line", chatType: 0x02u); // Speech — not window 3's filter + log.OnSystemMessage("fellowship line", chatType: 0x13u); // Fellowship — window 3's filter + + var lines = ctrl!.Transcript.LinesProvider(); + + Assert.Single(lines); + Assert.Equal("fellowship line", lines[0].Text); + } + + [Fact] + public void Transcript_FilterChange_IsReflectedOnNextRebuild() + { + var log = new ChatLog(); + var (rootInfo, layout, vm) = BuildTestTree(log); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); + + var ctrl = FloatingChatWindowController.Bind( + 2, rootInfo, layout, vm, () => bus, filters, null, null, NoTex); + Assert.NotNull(ctrl); + + log.OnSystemMessage("speech line", chatType: 0x02u); // Speech — not window 2's default filter + Assert.Empty(ctrl!.Transcript.LinesProvider()); + + // Widen window 2's filter to include Speech (bit 0x02 = 0x4) without + // touching the chat log — the cached transcript must invalidate on + // the filter change alone. + filters.SetFilter(2, filters.GetFilter(2) | (1UL << 0x02)); + + var lines = ctrl.Transcript.LinesProvider(); + Assert.Single(lines); + Assert.Equal("speech line", lines[0].Text); + } + + [Fact] + public void Transcript_UnchangedFilterAndContent_ReusesCachedLayout() + { + var log = new ChatLog(); + var (rootInfo, layout, vm) = BuildTestTree(log); + var bus = new CaptureBus(); + var filters = new ChatWindowState(); + + var ctrl = FloatingChatWindowController.Bind( + 1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex); + Assert.NotNull(ctrl); + + log.OnSystemMessage("speech line", chatType: 0x02u); + var first = ctrl!.Transcript.LinesProvider(); + var unchanged = ctrl.Transcript.LinesProvider(); + + Assert.Same(first, unchanged); + Assert.Equal(1, ctrl.TranscriptLayoutBuildCount); + } +} diff --git a/tests/AcDream.Core.Tests/Chat/ChatWindowStateTests.cs b/tests/AcDream.Core.Tests/Chat/ChatWindowStateTests.cs new file mode 100644 index 00000000..fd8f8108 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatWindowStateTests.cs @@ -0,0 +1,253 @@ +using AcDream.Core.Chat; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Campaign CH slice CH6b: retail per-window text-type filter + open state +/// (ChatInterface::PostInit @0x004F3DD0 defaults, color-table research +/// doc §4, and RecvNotice_DisplayFinalStringInfo @0x004F4640's display +/// predicate). +/// +public sealed class ChatWindowStateTests +{ + // ── Defaults (research doc §4's table, verbatim) ──────────────────────── + + [Theory] + [InlineData(0, 0xFBFFFFFFu)] + [InlineData(1, 0x0000101Cu)] + [InlineData(2, 0x00040C00u)] + [InlineData(3, 0x00080000u)] + [InlineData(4, 0x78000000u)] + public void Defaults_MatchRetailPostInitTable(int windowId, ulong expectedFilter) + { + var state = new ChatWindowState(); + + Assert.Equal(expectedFilter, state.GetFilter(windowId)); + } + + [Fact] + public void Defaults_MainWindowIsAlwaysOpen_FloatingWindowsStartClosed() + { + var state = new ChatWindowState(); + + Assert.True(state.IsOpen(0)); + for (int windowId = 1; windowId <= 4; windowId++) + Assert.False(state.IsOpen(windowId)); + } + + [Fact] + public void Window1_DefaultFilter_MatchesSpeechTellDirectSendEmote() + { + var state = new ChatWindowState(); + Assert.True(state.TypeIsActive(1, 0x02u)); // Speech + Assert.True(state.TypeIsActive(1, 0x03u)); // Tell + Assert.True(state.TypeIsActive(1, 0x04u)); // Speech_Direct_Send + Assert.True(state.TypeIsActive(1, 0x0Cu)); // Emote + Assert.False(state.TypeIsActive(1, 0x0Au)); // Social — not in window 1's default + } + + [Fact] + public void Window2_DefaultFilter_MatchesSocialSocialSendAllegiance() + { + var state = new ChatWindowState(); + Assert.True(state.TypeIsActive(2, 0x0Au)); // Social + Assert.True(state.TypeIsActive(2, 0x0Bu)); // Social_Send + Assert.True(state.TypeIsActive(2, 0x12u)); // Allegiance + Assert.False(state.TypeIsActive(2, 0x13u)); // Fellowship + } + + [Fact] + public void Window3_DefaultFilter_MatchesFellowshipOnly() + { + var state = new ChatWindowState(); + Assert.True(state.TypeIsActive(3, 0x13u)); // Fellowship + Assert.False(state.TypeIsActive(3, 0x0Au)); // Social + Assert.False(state.TypeIsActive(3, 0x02u)); // Speech + } + + [Fact] + public void Window4_DefaultFilter_MatchesTurbineGeneralTradeLfgRoleplay() + { + var state = new ChatWindowState(); + Assert.True(state.TypeIsActive(4, 0x1Bu)); // TurbineGeneral + Assert.True(state.TypeIsActive(4, 0x1Cu)); // TurbineTrade + Assert.True(state.TypeIsActive(4, 0x1Du)); // TurbineLFG + Assert.True(state.TypeIsActive(4, 0x1Eu)); // TurbineRoleplay + Assert.False(state.TypeIsActive(4, 0x20u)); // TurbineSociety — opt-in only + Assert.False(state.TypeIsActive(4, 0x12u)); // Allegiance + } + + [Fact] + public void EveryWindow_NeverActivatesSocietyOrReservedByDefault() + { + var state = new ChatWindowState(); + for (int windowId = 0; windowId <= 4; windowId++) + { + Assert.False(state.TypeIsActive(windowId, 0x20u)); // Society + Assert.False(state.TypeIsActive(windowId, 0x21u)); // Reserved + } + } + + // ── TypeIsActive edge cases ────────────────────────────────────────────── + + [Fact] + public void TypeIsActive_TypeAtOrAbove64_IsNeverActive() + { + var state = new ChatWindowState(); + state.SetFilter(1, ulong.MaxValue); + + Assert.False(state.TypeIsActive(1, 64u)); + Assert.False(state.TypeIsActive(1, 1000u)); + } + + // ── Display rule matrix (windowId-addressed vs broadcast × filter hit/miss) ── + + [Fact] + public void ShouldDisplay_ExplicitlyAddressed_AlwaysShowsRegardlessOfFilter() + { + var state = new ChatWindowState(); + // Window 3's default filter has ONLY Fellowship (0x13) active — Speech + // (0x02) would fail the broadcast check, but explicit addressing wins. + Assert.True(state.ShouldDisplay(windowId: 3, targetWindowId: 3u, logTextType: 0x02u)); + } + + [Fact] + public void ShouldDisplay_ExplicitlyAddressedToAnotherWindow_NeverShowsHereEvenOnBroadcastFilterHit() + { + var state = new ChatWindowState(); + // Addressed to window 2, evaluated from window 1's perspective: not a + // broadcast (targetWindowId != 0) and not addressed to window 1. + Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 2u, logTextType: 0x02u)); + } + + [Fact] + public void ShouldDisplay_Broadcast_FilterHit_Shows() + { + var state = new ChatWindowState(); + Assert.True(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x02u)); // Speech + } + + [Fact] + public void ShouldDisplay_Broadcast_FilterMiss_DoesNotShow() + { + var state = new ChatWindowState(); + Assert.False(state.ShouldDisplay(windowId: 1, targetWindowId: 0u, logTextType: 0x0Au)); // Social + } + + [Fact] + public void ShouldDisplay_MainWindow_ShowsEveryBroadcastRegardlessOfSeededFilter() + { + var state = new ChatWindowState(); + // Window 0's own filter is 0xFBFFFFFF (excludes 0x1A) but that filter + // is never actually consulted for a broadcast message: targetWindowId + // (0) == windowId (0) is already true via the first branch. + Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x1Au)); + Assert.True(state.ShouldDisplay(windowId: 0, targetWindowId: 0u, logTextType: 0x21u)); + } + + // ── SetFilter / SetOpen / Toggle ───────────────────────────────────────── + + [Fact] + public void SetFilter_MainWindow_IsANoOp() + { + var state = new ChatWindowState(); + ulong before = state.GetFilter(0); + + state.SetFilter(0, 0u); + + Assert.Equal(before, state.GetFilter(0)); + } + + [Fact] + public void SetFilter_FloatingWindow_Persists() + { + var state = new ChatWindowState(); + + state.SetFilter(2, 0x1u); + + Assert.Equal(0x1u, state.GetFilter(2)); + Assert.True(state.TypeIsActive(2, 0x00u)); + } + + [Fact] + public void SetOpen_MainWindow_IsANoOp_AlwaysOpen() + { + var state = new ChatWindowState(); + + state.SetOpen(0, false); + + Assert.True(state.IsOpen(0)); + } + + [Fact] + public void Toggle_FloatingWindow_FlipsOpenState_AndReturnsNewValue() + { + var state = new ChatWindowState(); + Assert.False(state.IsOpen(1)); + + bool afterFirst = state.Toggle(1); + Assert.True(afterFirst); + Assert.True(state.IsOpen(1)); + + bool afterSecond = state.Toggle(1); + Assert.False(afterSecond); + Assert.False(state.IsOpen(1)); + } + + [Fact] + public void Toggle_MainWindow_AlwaysReturnsTrue_NeverCloses() + { + var state = new ChatWindowState(); + + bool result = state.Toggle(0); + + Assert.True(result); + Assert.True(state.IsOpen(0)); + } + + [Fact] + public void ResetToDefaults_RestoresSeededFiltersAndOpenState() + { + var state = new ChatWindowState(); + state.SetFilter(1, 0u); + state.SetOpen(1, true); + + state.ResetToDefaults(); + + Assert.Equal(0x0000101Cu, state.GetFilter(1)); + Assert.False(state.IsOpen(1)); + } + + // ── Argument validation ─────────────────────────────────────────────── + + [Theory] + [InlineData(-1)] + [InlineData(5)] + public void OutOfRangeWindowId_Throws(int windowId) + { + var state = new ChatWindowState(); + Assert.Throws(() => state.GetFilter(windowId)); + Assert.Throws(() => state.IsOpen(windowId)); + Assert.Throws(() => state.TypeIsActive(windowId, 0u)); + } + + // ── Revision counter ───────────────────────────────────────────────── + + [Fact] + public void Revision_AdvancesOnFilterAndOpenChange_NotOnNoOpWrites() + { + var state = new ChatWindowState(); + long baseline = state.Revision; + + state.SetFilter(1, 0x1u); + Assert.True(state.Revision > baseline); + long afterFilter = state.Revision; + + // No-op: same value. + state.SetFilter(1, 0x1u); + Assert.Equal(afterFilter, state.Revision); + + state.SetOpen(1, true); + Assert.True(state.Revision > afterFilter); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs index 273af9d0..e8c46338 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs @@ -254,6 +254,35 @@ public sealed class RuntimeCommunicationStateTests Assert.Equal(0, state.SpewBox.Count); } + // ── Campaign CH slice CH6b: ChatWindows is the canonical per-window + // filter/open owner every host borrows — no presentation-owned copy. ── + + [Fact] + public void ChatWindows_SeededWithRetailPostInitDefaults_OnConstruction() + { + using var state = new RuntimeCommunicationState(); + + Assert.True(state.ChatWindows.IsOpen(0)); + Assert.False(state.ChatWindows.IsOpen(1)); + Assert.Equal(0x0000101Cu, state.ChatWindows.GetFilter(1)); + Assert.Equal(0x00040C00u, state.ChatWindows.GetFilter(2)); + Assert.Equal(0x00080000u, state.ChatWindows.GetFilter(3)); + Assert.Equal(0x78000000u, state.ChatWindows.GetFilter(4)); + } + + [Fact] + public void Dispose_ResetsChatWindowsToRetailDefaults() + { + var state = new RuntimeCommunicationState(); + state.ChatWindows.SetOpen(1, true); + state.ChatWindows.SetFilter(2, 0u); + + state.Dispose(); + + Assert.False(state.ChatWindows.IsOpen(1)); + Assert.Equal(0x00040C00u, state.ChatWindows.GetFilter(2)); + } + private sealed class RecordingObserver : IRuntimeCommunicationObserver { public List Events { get; } = []; diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index c602927a..f5a2b569 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -295,6 +295,41 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Equal(original, store.LoadChat()); } + // -- Campaign CH slice CH6b: floating chat window filter round-trip --- + + [Fact] + public void LoadChat_returns_retail_PostInit_filter_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + ChatSettings loaded = store.LoadChat(); + + Assert.Equal(0x0000101Cu, loaded.ChatWindow1Filter); + Assert.Equal(0x00040C00u, loaded.ChatWindow2Filter); + Assert.Equal(0x00080000u, loaded.ChatWindow3Filter); + Assert.Equal(0x78000000u, loaded.ChatWindow4Filter); + } + + [Fact] + public void SaveChat_then_LoadChat_round_trips_floating_window_filters() + { + var store = new SettingsStore(_tempPath); + var original = ChatSettings.Default with + { + ChatWindow1Filter = 0x1u, + ChatWindow2Filter = 0xFFFFFFFFu, + ChatWindow3Filter = 0x8000000000000000u, // exercises the high dword (Society/reserved bits) + ChatWindow4Filter = 0ul, + }; + + store.SaveChat(original); + ChatSettings loaded = store.LoadChat(); + + Assert.Equal(original.ChatWindow1Filter, loaded.ChatWindow1Filter); + Assert.Equal(original.ChatWindow2Filter, loaded.ChatWindow2Filter); + Assert.Equal(original.ChatWindow3Filter, loaded.ChatWindow3Filter); + Assert.Equal(original.ChatWindow4Filter, loaded.ChatWindow4Filter); + } + [Fact] public void All_four_sections_coexist_in_one_settings_json() {