diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index b2a0581f..20661f2d 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) — 135 active rows (AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; 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) +## 3. Documented approximation (AP) — 135 active rows (AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships one shared 0.5/1.0 default rather than gmMainChatUI's per-class 1.0/1.0 override, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; 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 @@ -230,7 +230,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | | AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures | | AP-39 | Chat lines carry one solid color per line (retail's exact 34-value `LogTextType` table as of Campaign CH slice CH1, 2026-08-09 — see `RetailChatColorTable`, no longer the earlier synthetic per-`ChatKind` approximation); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs`; consumers `src/AcDream.App/UI/Layout/ChatWindowController.cs`, `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line coloring is now the exact retail tonal palette (`ChatInterface::BuildChatColorLookupTable @0x004F31C0`), not an approximation of it | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp); `docs/research/2026-08-09-chat-retail-color-table.md` | -| AP-40 | Chat uses one fixed `0.75` outer opacity and has no descendant-focus-driven active/default opacity transition | `src/AcDream.App/Rendering/GameWindow.cs` chat mount; `ChatWindowController.cs` | Font resolution is now live and per-element; only the opacity behavior remains deferred to the shared window/focus runtime | Focused chat remains too translucent and idle chat never restores the configured default alpha | `ChatInterface::SetOpacity @ 0x004F3120`; `SetDefaultOpacity @ 0x004F3BC0`; `SetActiveOpacity @ 0x004F3C40` | | AP-175 | PopUpString (`GameEvent 0x0004`) renders as an ordinary chat-log line (`ChatKind.Popup`) instead of retail's MODAL DIALOG. Filed 2026-08-09, Campaign CH slice CH1 (color table) — the color-table work routes this entry through the new 34-value `LogTextType` table (fixed at `0x00` Default/green, unchanged from the entry's pre-existing color) but does not change WHERE it renders; a modal-dialog port is out of this slice's scope | `src/AcDream.Core/Chat/ChatLog.cs` (`OnPopup`); `src/AcDream.Core.Net/GameEventWiring.cs:126` | Informational popup text still reaches the player via the chat transcript; a full modal-dialog port is deferred work, not a color-table concern | Any retail-specific PopUpString behavior contingent on being a blocking modal (e.g. must-acknowledge) is not reproduced; acdream's chat-log line can be missed or scrolled past instead | `ClientCommunicationSystem::Handle_Communication__PopUpString @0x0057FE80`; `docs/research/2026-08-09-chat-retail-color-table.md` §5.1 | | AP-177 | SpewBox line lifetime is an INVENTED 5-second placeholder. Retail's `gmSpewBoxUI` never raises the expiry element message (`0x10000003`) anywhere in its own compiled Sept 2013 EoR code — the real per-line timeout/fade curve is owned by keystone.dll's authored behaviour for layout `0x10000012` element `0x1000004A`, which this slice did not measure (a live cdb capture on `gmSpewBoxUI::ListenToElementMessage @0x004D57C0` against a real retail client would resolve it). Filed 2026-08-09, Campaign CH slice CH2 | `src/AcDream.Core/Chat/SpewBoxState.cs` (`DefaultLifetime`) | A round, conservative placeholder was chosen over guessing a retail-matching curve; no fade is modeled at all (the line pops on and off) | SpewBox lines may linger noticeably longer or shorter than retail's actual timing, and pop instead of fading | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.1 | | AP-178 | **NARROWED 2026-08-09 at the CH2 REJECT-review rework (NIT 3, `docs/research/2026-08-09-ch2-review-findings.md`), WORDING CORRECTED at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6):** the original filing's `dats.Portal` pass used an id source (`DatCollection`'s top-level AGGREGATE `GetAllIdsOfType()`) that is NOT `dats.Portal`'s own id space (`dats.Portal.GetAllIdsOfType()` reports a count of ZERO for this type), so querying those ids against `dats.Portal.TryGet` established nothing about Portal either way — the "swept only dats.Portal and found ZERO... all-invented" framing overclaimed a search that never meaningfully happened. Extending the sweep to `dats.Local` (`client_local_English.dat`), this time correctly paired, FOUND it: LayoutDesc `0x21000011`, element `0x10000048`, whose sole child (ListBox `0x10000049`, matching `gmSpewBoxUI::PostInit`'s `GetChildRecursive(0x10000049)` verbatim) carries ListBox property `0x10000028` = the integer `4`. Whether `dats.Portal` ALSO carries a copy remains UNESTABLISHED, not ruled out. Two sub-claims RETIRE: extent is now AUTHORED (`450×72`, not a placeholder size) and `MaxConcurrentItems` is now AUTHORED (`4`, not retail's code-default `1`). **CH USER-GATE ROUND 1 (2026-08-09):** colour PINS — the user tested live, side-by-side against retail, and confirmed the on-screen SpewBox text is the same bright yellow as an incoming Tell (`0x81C4C8`, `RetailChatColorTable.Yellow` = `(1, 1, 0.247, 1)`); `SpewBoxController.SpewBoxColor` now uses that exact value. The user's SAME live pass also reported that SIZE, POSITION, and FONT still visibly differ from retail — so despite extent's earlier AUTHORED status above, size is user-gate round 1: differs, iterating (re-opened pending a follow-up measurement pass, not yet root-caused). Three sub-claims therefore REMAIN open: (1) absolute screen position — the recovered position is `(0,0)` RELATIVE TO A PARENT this sweep could not identify (the element is presumably still mounted via the C++ `gmClient` HUD registration block the research doc's §1.1 describes, just parented under something dat-authored rather than the root view directly), so `TopOffset=60px` + a centered `Left` recomputed every frame (corrected from a one-time computation at nit 1 — see `SpewBoxController.Tick`) remain acdream's own placeholder, not a resolved retail value, and the user confirms this is visibly wrong; (2) size/font — the AUTHORED `450×72` extent and whatever font this renders with still do not match what the user sees live; unmeasured which of extent, the unresolved parent scale, or font metrics is the actual cause; (3) vertical content flow — the block now renders TOP-aligned (newest line at the top, via `UiText.VerticalJustify`/`HonorVerticalJustification`, nit 2) because that is the only placement consistent with "newest on top," but retail's own authored vertical justification for this element is unmeasured, so this is also acdream's invention pending measurement, not a resolved retail value. Retail's edge codes (`leftEdge=3`/`rightEdge=3`, "centered" per `ElementReader.ToAnchors`'s own doc comment; `topEdge=1`, top-anchored) confirm the box is a fixed-width centered block, not a full-viewport stretch — `SpewBoxController`'s anchor shape was corrected to match (`AnchorEdges.None` + a centered `Left` recomputed every frame against the current root width, `OneLine=false` since 4 concurrent lines can now actually be visible instead of collapsing to 1). **CH USER-GATE ROUND 3 (2026-08-10):** the user's finding (a) confirmed POSITION and FONT still read wrong live — "not aligned all the way to the top" and "not the correct font and size (retail's is SMALLER)." Both sub-claims close as best-available APPROXIMATIONS, not resolved retail values (a re-run of `SpewBoxLayoutDumpDiagnostic` this round still finds no `FontDid`/colour property on element `0x10000048` or its ListBox child `0x10000049`, confirming the true retail values remain genuinely unmeasurable statically): (1) position — `TopOffset` moves from the round-1 60px placeholder to `0` (flush to the viewport top), per the user's explicit direction; the true retail PARENT remains unidentified. (2) font — `SpewBoxController` now resolves retail dat Font `0x40000025` (`MaxCharHeight=11px`, `Baseline=9px`; confirmed via `AcDream.Cli dump-font-atlas` sweeping every populated font id `0x40000000`-`0x40000032` in the installed DAT) instead of silently falling through to the unwired 15px debug `BitmapFont` every prior round shipped with (no `DatFont`/`Font` was ever set on this element before). `0x40000025` is the SMALLEST font id confirmed in use by any of acdream's currently-imported retail LayoutDesc fixtures (cross-referenced across all `tests/AcDream.App.Tests/UI/Layout/fixtures/*.json` dumps) — it is ALSO the chat window's own smallest font (the `0x2100006F` floating-window 1/2/3/4 indicator badges), so both selection criteria the round-3 brief offered agree on the same id, with no tie to break. Vertical content flow remains OPEN, unchanged from round 1. | `src/AcDream.App/UI/SpewBoxController.cs`; `src/AcDream.Core/Chat/SpewBoxState.cs` (`MaxConcurrentItems`); `src/AcDream.App/UI/UiText.cs` (`HonorVerticalJustification`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Assets` accessor, round 3) | Colour is CONFIRMED, not a placeholder — CH user-gate round 1 (2026-08-09) pinned it against the user's own live side-by-side retail observation, not a recollection. A live cdb capture of `gmSpewBoxUI`'s runtime rect/state (or walking the `States` dictionary this pass skipped, or identifying the C++-assigned parent) remains the resolution path for the three still-open sub-claims (position, size/font, vertical flow) | SpewBox text may render in the wrong absolute screen location, size/font, or vertical flow versus retail — all three CONFIRMED wrong by the user's CH round-1 live pass, not merely suspected; colour is CLOSED and no longer a risk. The size/max-items risk this row originally recorded ("bursts of refusals collapse to one visible line where retail's authored ListBox may show more") is RETIRED — up to 4 now render, matching the authored value, though the box's overall size still visibly differs from retail per the user. Round 3 (2026-08-10) closes the position/font risks as best-available approximations (flush-top mount, smallest confirmed-used retail font) rather than resolved retail values — the box may still not sit at retail's true pixel position/size, and the exact retail font remains genuinely unmeasurable; only vertical content flow remains fully OPEN, unchanged from round 1 | `docs/research/2026-08-09-chat-retail-interface-text.md` §3.2.2-§3.2.4; `tests/AcDream.App.Tests/UI/SpewBoxLayoutDumpDiagnostic.cs`; `src/AcDream.App/UI/Layout/ElementReader.cs` (`ToAnchors`) | @@ -343,6 +342,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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 | | AP-189 | **Filed 2026-08-10 at the CH6a/b REJECT-review rework (SHOULD-FIX 5, `docs/research/2026-08-10-ch6ab-review-findings.md`).** Retail keeps a PER-`ChatInterface` `m_chatLog`, truncated at 10,000 lines (`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4711` → `TruncateChatLog`) — each of the five windows (main + 4 floaty) owns its OWN 10,000-line backlog, and a closed window keeps accumulating into its own log because `gmFloatyMainChatUI::SetVisible @0x004CE9B0` never unregisters the handler. acdream instead shares ONE canonical `ChatLog` capped at 500 entries (`RuntimeCommunicationState`'s ctor, `maximumChatEntries: 500`) with a 200-line display tail every window filters from (`InteractionRetainedUiComposition.cs:564`'s `displayLimit: 200` feeding `ChatVM.RecentLinesDetailed`; `ChatWindowState.ShouldDisplay` does the per-window filtering). The accumulate-while-closed and independent-per-window-scroll BEHAVIORS both fall out correctly from this shared-log shape, but the EFFECTIVE per-window scrollback DEPTH differs from retail's: a window whose filter accepts only a rare message type (e.g. a Fellowship-only floaty) sees only the fellowship lines that happen to still be inside the shared log's last 200-of-500 lines, not up to 10,000 like retail's own per-window log. `src/AcDream.Core/Chat/ChatLog.cs` (`_maxEntries`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:564` (`displayLimit: 200`); `src/AcDream.App/UI/Layout/ChatWindowController.cs`/`FloatingChatWindowController.cs` (`GetTranscriptLines`) | A single shared canonical log matches acdream's Slice-J "one canonical transcript, many filtered presentations" pattern and keeps memory bounded regardless of how many windows are open; 500 shared entries covers many minutes of typical mixed-channel play, and both retail-observable BEHAVIORS this row could have broken (closed-window accumulation, independent per-window scroll position) are reproduced correctly — only the numeric DEPTH ceiling differs | In a busy mixed-channel session (heavy General/Trade traffic), a rarely-used channel (Fellowship, a Turbine room) can scroll out of the shared 500-entry window long before a floaty window filtered to just that channel would have neared retail's 10,000-line depth — a user who opens that floaty window after a long session sees a much shorter backlog than retail would show for the same play session | `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640`/`TruncateChatLog @0x004F4711`; `gmFloatyMainChatUI::SetVisible @0x004CE9B0`; `docs/research/2026-08-09-chat-retail-window-shell.md` §1.2 | +| AP-190 | **Filed 2026-08-10 (Campaign CH slice CH6c — window opacity + transparency setting; retires AP-40).** Two divergences from retail's focus-driven window opacity, both decomp-verified (`docs/research/2026-08-09-chat-retail-window-shell.md` §3). (1) SCOPE: retail's `ChatInterface::SetOpacity`/`SetDefaultOpacity`/`SetActiveOpacity` only ever run on `ChatInterface`-derived windows (the main chat window + the four floaties) — every other retail window (vitals, toolbar, inventory, ...) has no opacity fade at all. acdream's `RetailWindowOpacityController` subscribes to `RetailWindowManager.WindowRegistered` and applies the SAME focus-driven fade to every window the manager ever registers, so the one Settings → Chat tab transparency slider pair affects the whole retained UI. (2) DEFAULT VALUE: retail's shipped defaults are PER WINDOW CLASS — the base `ChatInterface` ctor (`0x004F4550`) sets DefaultOpacity=0.5/ActiveOpacity=1.0, but `gmMainChatUI`'s own ctor (`0x004CD0F0`, called after the base ctor) overrides DefaultOpacity to 1.0 (the main window is ALWAYS fully opaque in both states); `gmFloatyChatUI::Create` (`0x004CE2C0`) calls the base ctor directly with no override, so only the four floating windows keep 0.5/1.0. acdream ships ONE shared global default (the base ChatInterface value, 0.5/1.0) applied uniformly including to the main chat window, rather than replicating the per-class 1.0/1.0 override. The linked active>=default invariant itself (`SetDefaultOpacity`/`SetActiveOpacity`'s mutual-correction bodies) IS ported exactly — `ChatOpacityLink` in `AcDream.UI.Abstractions`. | `src/AcDream.App/UI/RetailWindowOpacityController.cs`; `src/AcDream.App/UI/RetailWindowManager.cs` (`WindowRegistered`); `src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` (`DefaultOpacity`/`ActiveOpacity`) | Extending the fade to every window is the shape the user's requested "transparency setting" actually wants (a general UI preference, not a chat-only one); a single global default keeps the setting's starting behavior predictable across every window rather than silently varying the main chat window's fade floor from every other window's | A user who compares acdream's main chat window against retail side-by-side at the DEFAULT (never-touched) setting will see acdream's main window fade to 50% while idle, where retail's stays fully opaque always; every other window fading at all (vitals, toolbar, ...) has no retail analogue to compare against by construction | `ChatInterface::ChatInterface @0x004F4550`; `gmMainChatUI::gmMainChatUI @0x004CD0F0`; `gmFloatyChatUI::Create @0x004CE2C0`; `ChatInterface::SetDefaultOpacity @0x004F3BC0`/`SetActiveOpacity @0x004F3C40` | ## 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 55f6028c..7d9c3898 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -45,11 +45,14 @@ font instead of the unwired 15px debug fallback; bare `/help` and `DoHelp` shape — two scroll entries in the right order, not one 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). +subagents, no client launches) — see its ledger row and Slices bullet. +**CH6c (opacity) landed CODE-COMPLETE 2026-08-10**, same constraints — +see its ledger row and Slices bullet; the full three-sub-slice CH6 window +shell is now CODE-COMPLETE. 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's focus-driven fade and Settings +slider, 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. @@ -133,8 +136,9 @@ implementer per slice against a pinned contract (per commands implemented family-by-family. - **CH5 — closeout.** Register sweep, ledger flip, ISSUES updates, in-client test script for the user gate. -- **CH6 — chat-window shell parity (filed 2026-08-09 at user gate round - 1; research complete: +- **CH6 — chat-window shell parity — all three sub-slices CODE-COMPLETE + 2026-08-10, pending the connected user gate (filed 2026-08-09 at user + gate round 1; research complete: `docs/research/2026-08-09-chat-retail-window-shell.md`).** Three sub-slices: - **CH6a — correct main-window import + resize.** Swap the wrong @@ -222,6 +226,40 @@ implementer per slice against a pinned contract (per the retail per-window option array `0x1000008C` is stored by ACE as an opaque byte[] it never parses, so the wire format is its own deferred slice. + **CODE-COMPLETE 2026-08-10 (hard constraint: no subagents, no client + launches).** The sprite/rect chokepoint (`UiRenderContext.ApplyAlpha`) had + quietly existed since `1da697ec`, well before CH6 — the gap was narrower + than the plan assumed: `DrawStringDat`/`DrawString` still passed + `applyAlpha: false`, so text stayed sharp over a translucent window; + CH6c routes both through `ApplyAlpha` too, matching retail's + whole-surface `SetOpacity` fade. `RetailWindowOpacityController` + (new, `src/AcDream.App/UI/RetailWindowOpacityController.cs`) subscribes to + a new `RetailWindowManager.WindowRegistered` event and drives every + registered window's live `Opacity` from keyboard-focus state — deliberately + EVERY window (chat, floaties, vitals, toolbar, ...), not just retail's + `ChatInterface`-scoped mechanism (register row AP-190, retiring the stale + AP-40 "fixed 0.75, no focus transition" row in the same commit). Verified + retail defaults from the decomp (constructor-literal, no cdb needed): + the base `ChatInterface` ctor sets DefaultOpacity=0.5/ActiveOpacity=1.0, + which the four floating windows keep unmodified, but `gmMainChatUI`'s own + ctor overrides the main window to 1.0/1.0 (always fully opaque); acdream + ships ONE shared global default (the base 0.5/1.0) rather than + replicating the per-class override — also AP-190. The linking invariant + (raising default above active drags active UP; lowering active below + default drags default DOWN — never a clamp) is decomp-verified and ported + as `ChatOpacityLink` in `AcDream.UI.Abstractions`, shared by the live + controller and the Settings → Chat tab's two new linked sliders + (`SettingsPanel.RenderChatTab`). Persistence: `ChatSettings.DefaultOpacity`/ + `ActiveOpacity` round-trip through `SettingsStore`; Save pushes both + through `IRuntimeSettingsTargets.SetChatOpacity` into the live controller — + no restart. Rider (CH6a/b re-review): strengthened the grip-media + regression guard past a bare `SpriteFile != 0` check — + `ChatLayoutConformanceTests` now drives each live grip through a real + `UiRenderContext`/`TextRenderer` (backed by the in-memory + `RecordingGpuDevice` test double) and asserts the draw call chain actually + queued sprite geometry, via a new `TextRenderer.DebugSpriteSegments` + test-only accessor. Full Release suite 12,459 passed / 4 skipped / 0 + failed (baseline 12,420/4/0). ## Gates @@ -249,7 +287,7 @@ implementer per slice against a pinned contract (per | 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 | `98de4f5a` | 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 | `22020ef2`, reworked `1aa77099` | 12,420 passed / 4 skipped / 0 failed | REJECT (docs/research/2026-08-10-ch6ab-review-findings.md) → reworked `1aa77099` — SHOULD-FIXES 2/3/4/5 + NITs 1-5 applied | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation, plus the new 0x2100005B fixture's resolved-type assumptions | -| CH6c opacity | not started | — | — | not started | +| CH6c opacity | `PENDING-SHA` | 12,459 passed / 4 skipped / 0 failed | pending (no subagent review pass this session — implementer-only) | pending — needs the next connected round for visual confirmation (window fade on focus change, Settings slider live-apply) | ### CH4 closeout (2026-08-09) @@ -590,7 +628,7 @@ round's scope and filed as slice CH6. | G | The chat input line overflows the window's right edge when the window is resized. | **FIXED this SHA.** The input field's right edge no longer holds a fixed absolute pixel position across a resize (retail edge-mode 0's "frozen at current" fallback, or the `AnchorEdges` default with no `Right` bit) — `ChatWindowController.Bind` now upgrades it to retail edge-mode 1 (`UiLayoutPolicy`) or the equivalent `AnchorEdges.Right` stretch, so the right edge tracks every resize instead of only the bind-time/channel-change recompute. | | H | Extra/duplicate chat windows appear on number keys 1/2/3/4. | **STILL CH6b** (not this commit) — retail's real floating windows 1–4 (`0x2100005B` ×4) and their `ToggleFloatingChatWindow1..4` keybinds are a separate slice; CH6a only fixed the shell (import/resize) of the main window. | | I | Resizing the chat window only works from one corner, not every corner. | **FIXED at CH6a `1fd51543`.** Root cause: the main window imported the WRONG LayoutDesc (`0x21000006`, an unrelated layout whose root/resize-bar appear nowhere in the EoR gameplay UI) instead of retail's real `0x2100006F`; every symptom (crop hacks, the dropped resize bar, the one-corner-only resize) was downstream of that. The swap + a new `LayoutImporter` case for element type 9 (`UIElement_Resizebar`, `UiResizeGrip`) + `UiRoot` grip-priority hit-testing now resize from all 4 edges and all 4 corners, while the top strip (a Type-2 Dragbar, not a grip) correctly remains a move-only affordance. | -| J | The chat window has transparency issues / visual artifacts, and the user wants a transparency setting eventually. | **Artifacts FIXED at CH6a `1fd51543`** — the reported visual glitches were downstream of importing the wrong LayoutDesc (stray unparented siblings, the hand-cropped content width, the 9px patch); all retired with the correct import, and the two hard-coded translucent-black tints on the transcript/input are removed now that their parent panels draw their own authored background sprites. **Real opacity (the future transparency SETTING) remains CH6c** — `UiRenderContext.AlphaMod` still has no draw-path consumer. | +| J | The chat window has transparency issues / visual artifacts, and the user wants a transparency setting eventually. | **Artifacts FIXED at CH6a `1fd51543`** — the reported visual glitches were downstream of importing the wrong LayoutDesc (stray unparented siblings, the hand-cropped content width, the 9px patch); all retired with the correct import, and the two hard-coded translucent-black tints on the transcript/input are removed now that their parent panels draw their own authored background sprites. **Real opacity + the transparency SETTING landed at CH6c** — `UiRenderContext` now applies window alpha to sprite, rect, AND text draws; `RetailWindowOpacityController` drives every window's opacity from keyboard focus; the Settings → Chat tab carries two linked sliders. Pending the next connected round for visual confirmation. | Findings A–G's evidence: this commit's diff + the new/updated tests in `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`, 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 0c679d15..7573ecff 100644 --- a/docs/research/2026-08-09-chat-retail-window-shell.md +++ b/docs/research/2026-08-09-chat-retail-window-shell.md @@ -463,12 +463,54 @@ and application is one call on the window's own render surface — text — with one alpha, not a per-widget background tint.** This is the shape the future acdream user setting should take. -UNVERIFIED: retail's *shipped default values* for the two sliders. They are not -constants in code (the only in-code fallback is 1.0f); they arrive from the -server's `GameplayOptions` blob or from the options page's authored slider -defaults. Cheapest resolution: one cdb breakpoint on -`ChatInterface::SetDefaultOpacity` at login and read `st(0)` — or dump the -slider template defaults from the options LayoutDesc (`0x2100002B`). +**RESOLVED at Campaign CH slice CH6c (2026-08-10) by static decomp, not cdb — +the values are constructor-literal, so no live attach was needed.** Retail's +shipped defaults are PER WINDOW CLASS, not one constant: + +``` +004f4550 ChatInterface::ChatInterface(this, arg2, arg3) // BASE ctor +004f459f this->m_fDefaultOpacity = 0.5f; +004f45a5 this->m_fCurrentOpacity = 0.5f; +004f45ab this->m_fActiveOpacity = 1f; + +004cd0f0 gmMainChatUI::gmMainChatUI(this, arg2, arg3) // derived, calls base first +004cd0ff ChatInterface::ChatInterface(this, arg2, arg3); +004cd148 this->m_fDefaultOpacity = 1f; // OVERRIDES base +004cd14e this->m_fCurrentOpacity = 1f; + // m_fActiveOpacity left at base's 1f + +004ce2c0 gmFloatyChatUI::Create(arg1, arg2) // the 4 floating windows +004ce2e0 ChatInterface::ChatInterface(eax, arg1, arg2); // NO override — keeps base 0.5/1.0 +``` + +`gmFloatyMainChatUI` (element class `0x10000050`, the concrete class actually +instantiated for the retail main chat window — its `DynamicCast` accepts both +`0x10000050` and `0x10000041`) calls `gmMainChatUI::gmMainChatUI` as its own +base constructor (`0x004D22B0`) and adds no opacity override of its own, so it +inherits `gmMainChatUI`'s 1.0/1.0. + +**So: the main chat window is ALWAYS FULLY OPAQUE in both states (Default=1.0, +Active=1.0) unless a saved `GameplayOptions` value overrides it via +`UpdateFromPlayerModule`; the four floating windows default to +Default=0.5/Active=1.0 (translucent when idle, opaque once the chat entry has +focus).** These are IN-MEMORY CONSTRUCTED starting values for each window +INSTANCE's fields — they get overwritten the moment `UpdateFromPlayerModule` +successfully reads a persisted `0x10000080`/`0x10000081` value from +`PlayerModule::InqOption` (the SAME global option for every window instance), +which is why the two options are still correctly described as GLOBAL rather +than per-window: only a NEVER-SAVED option (a fresh character, nothing in the +`GameplayOptions` blob yet) lets the per-class constructed defaults show +through, and even then only until the user's first slider drag pushes one +shared value into every live window via `RecvNotice_GameplayOptionChanged`. + +acdream ships ONE shared global default — the base `ChatInterface` value, +0.5/1.0 — applied uniformly to every window including the main chat window +(register row AP-190 in `docs/architecture/retail-divergence-register.md`), +rather than replicating `gmMainChatUI`'s per-class 1.0/1.0 override. The +linking invariant (active >= default, restored by dragging the OTHER value — +verified from `SetDefaultOpacity`/`SetActiveOpacity`'s own bodies, matching +the summary already recorded above) is ported exactly regardless of which +default seeds it. --- @@ -635,7 +677,26 @@ per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D`. Additionally, for `Resizebar` in `src/AcDream.App/UI` returns nothing — so authored grips would be imported as inert sprites today. -**G3 — Window opacity is completely inert.** +**G3 — Window opacity is completely inert. CLOSED at Campaign CH slice CH6c +(2026-08-10).** `UiRenderContext.ApplyAlpha` already gated `DrawRect`/ +`DrawFill`/`DrawSprite` before this slice (added back at `1da697ec`, well +before CH6 — the "zero consumers" framing below described the PUBLIC +`AlphaMod` property specifically, not the private `_alpha`/`ApplyAlpha` pair +those three draws already used); the actual gap was narrower than originally +scoped: (a) `DrawStringDat`/`DrawString` still passed `applyAlpha: false`, so +TEXT stayed sharp over a translucent window against retail's whole-surface +`SetOpacity` semantics — CH6c fixed both; (b) nothing ever SET a window's +`Opacity` below its 1f default, since `RetailUiRuntime.MountChat` deliberately +left it at 1f pending this slice. CH6c added +`RetailWindowOpacityController` (`src/AcDream.App/UI/RetailWindowOpacityController.cs`), +which drives every `RetailWindowManager`-registered window's live `Opacity` +from keyboard-focus state and the two retail-linked Default/Active floats, +now exposed as a Settings → Chat tab transparency slider pair +(`SettingsPanel.RenderChatTab`). See the verified defaults + linking +behavior above (§3) and register row AP-190. The original paragraph below is +kept verbatim as the historical record of what CH6a/b actually shipped — +do not re-run this investigation. + `RetailWindowFrame.cs:157` sets `outerFrame.Opacity`, and `UiElement.DrawSelfAndChildren` (`src/AcDream.App/UI/UiElement.cs:465`) and `DrawOverlays` (`:513`) push it onto `UiRenderContext`'s alpha stack. But @@ -743,8 +804,16 @@ run alongside either. - **Retires** any row asserting "chat window is not resizable from the top" once CH6a lands. -- **New row** if CH6b keeps text out of the window alpha (retail's - `ChatInterface::SetOpacity @0x004F3120` fades the whole surface). +- **CLOSED at CH6c**: text now respects window alpha — `DrawStringDat`/ + `DrawString` route through `ApplyAlpha` exactly like `DrawSprite`/`DrawRect`/ + `DrawFill`, matching `ChatInterface::SetOpacity`'s whole-surface fade. No + divergence row needed for this part. +- **New row AP-190** (CH6c): acdream applies the two opacity options to EVERY + `RetailWindowManager` window (chat + floaties + vitals + toolbar + + everything else), where retail's mechanism only ever runs from + `ChatInterface`-derived windows; and ships ONE shared default (the base + `ChatInterface` ctor's 0.5/1.0) rather than `gmMainChatUI`'s per-class + 1.0/1.0 override for the main window specifically. - **New row** for local-only chat-window persistence until CH6f, since retail stores this server-side in `GameplayOptions`. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 8e41bb18..e11a412c 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -338,7 +338,10 @@ internal sealed class SessionPlayerCompositionPhase d.RenderRange, interaction.RetainedUi?.Host.Root, liveSessionCommands, - d.Log); + // CH6c: null when no retained UI exists (e.g. a no-window host) — the + // Chat tab's opacity sliders then apply through NullRuntimeChatOpacityTarget. + chatOpacity: interaction.RetainedUi?.Runtime.WindowOpacity, + log: d.Log); bindings.Adopt( "runtime settings targets", d.Settings.BindRuntimeTargetsOwned(settingsTargets)); diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 40a41f6d..b9ea2bb3 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -88,6 +88,33 @@ public sealed class TextRenderer : IDisposable /// internal long DynamicBufferCapacityBytes => 0; + /// + /// Test-only snapshot of the current frame's queued NORMAL-layer sprite segments, in + /// submission order: (textureId, vertexCount, alpha of the segment's first vertex — + /// color.W at float index 7 of the 8-float vertex layout). Lets a unit test assert + /// that a draw call actually EMITTED sprite geometry — and with what alpha — without + /// a live GPU, constructing this renderer over the in-memory + /// RecordingGpuDevice test double. Campaign CH slice CH6c rider (CH6a/b + /// re-review): strengthens the grip-media regression guard past a bare + /// SpriteFile != 0 check, which proves a sprite RESOLVED but not that + /// was ever called. AcDream.App.Tests-only via + /// InternalsVisibleTo. + /// + internal IReadOnlyList<(uint Texture, int VertexCount, float Alpha)> DebugSpriteSegments + { + get + { + var result = new List<(uint, int, float)>(_segUsed); + for (int i = 0; i < _segUsed; i++) + { + SpriteSeg seg = _spriteSegs[i]; + float alpha = seg.Verts.Count > 0 ? seg.Verts[7] : 0f; + result.Add((seg.Texture, seg.Verts.Count / FloatsPerVertex, alpha)); + } + return result; + } + } + // Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text // buckets, so open popups/menus composite on top of EVERYTHING, including translucent // rect panel backgrounds (which otherwise always win because rects flush after diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs index b3691e02..fc10812e 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsController.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs @@ -131,6 +131,15 @@ internal interface IRuntimeSettingsTargets /// CharacterOption id (e.g. ListenToGeneralChat = 0x23). /// void SetSingleCharacterOption(uint optionId, bool value); + + /// + /// Campaign CH slice CH6c (2026-08-10): pushes the Chat tab's two linked + /// transparency sliders into the live RetailWindowOpacityController — + /// local-only (no server round-trip, unlike ), + /// applies with no restart, matches retail's own + /// UpdateFromPlayerModule call order (default before active). + /// + void SetChatOpacity(float defaultOpacity, float activeOpacity); } internal interface IRuntimeSettingsPreviewSource @@ -541,6 +550,12 @@ internal sealed class RuntimeSettingsController : PublishHearOptionChange( previous.HearSocietyChat, chat.HearSocietyChat, (uint)CharacterOptionId.ListenToSocietyChat); + + // CH6c: local-only live apply — unlike the Hear* options above, this never + // touches the wire (retail's 0x1000008C blob remains unparsed, per the + // window-shell research doc §4.4/§6.1). Always pushed (not diffed) so the + // linking invariant self-heals even if only one field nominally changed. + _runtimeTargets?.SetChatOpacity(chat.DefaultOpacity, chat.ActiveOpacity); } private void PublishHearOptionChange(bool previous, bool current, uint optionId) diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index db46efc4..d79a3529 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -35,6 +35,15 @@ internal interface IRuntimeUiLockTarget void Apply(bool locked); } +/// +/// Campaign CH slice CH6c target seam for the Chat tab's transparency sliders, +/// mirroring 's shape. +/// +internal interface IRuntimeChatOpacityTarget +{ + void Apply(float defaultOpacity, float activeOpacity); +} + internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget { private readonly IWindow _window; @@ -196,6 +205,29 @@ internal sealed class NullRuntimeUiLockTarget : IRuntimeUiLockTarget } } +internal sealed class RuntimeChatOpacityTarget(RetailWindowOpacityController controller) + : IRuntimeChatOpacityTarget +{ + private readonly RetailWindowOpacityController _controller = + controller ?? throw new ArgumentNullException(nameof(controller)); + + public void Apply(float defaultOpacity, float activeOpacity) => + _controller.SetOpacity(defaultOpacity, activeOpacity); +} + +internal sealed class NullRuntimeChatOpacityTarget : IRuntimeChatOpacityTarget +{ + public static NullRuntimeChatOpacityTarget Instance { get; } = new(); + + private NullRuntimeChatOpacityTarget() + { + } + + public void Apply(float defaultOpacity, float activeOpacity) + { + } +} + /// /// Complete late-bound target for changes made after startup. Construction and /// binding are inert; only an explicit controller command mutates borrowers. @@ -205,6 +237,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets private readonly IRuntimeDisplayWindowTarget _displayWindow; private readonly IRuntimeQualityApplicationTarget _quality; private readonly IRuntimeUiLockTarget _uiLock; + private readonly IRuntimeChatOpacityTarget _chatOpacity; private readonly ICommandBus _commands; private readonly Action _log; @@ -216,6 +249,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets WorldRenderRangeState renderRange, UiRoot? uiRoot, ICommandBus commands, + RetailWindowOpacityController? chatOpacity = null, Action? log = null) : this( displayWindow, @@ -228,7 +262,10 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets ? NullRuntimeUiLockTarget.Instance : new RuntimeUiLockTarget(uiRoot), commands, - log) + log, + chatOpacity is null + ? NullRuntimeChatOpacityTarget.Instance + : new RuntimeChatOpacityTarget(chatOpacity)) { } @@ -237,12 +274,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets IRuntimeQualityApplicationTarget quality, IRuntimeUiLockTarget uiLock, ICommandBus commands, - Action? log = null) + Action? log = null, + IRuntimeChatOpacityTarget? chatOpacity = null) { _displayWindow = displayWindow ?? throw new ArgumentNullException(nameof(displayWindow)); _quality = quality ?? throw new ArgumentNullException(nameof(quality)); _uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock)); + _chatOpacity = chatOpacity ?? NullRuntimeChatOpacityTarget.Instance; _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _log = log ?? Console.WriteLine; } @@ -281,4 +320,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets /// public void SetSingleCharacterOption(uint optionId, bool value) => _commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value)); + + public void SetChatOpacity(float defaultOpacity, float activeOpacity) => + _chatOpacity.Apply(defaultOpacity, activeOpacity); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index edec8c2b..c9894c98 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -269,6 +269,20 @@ public sealed class RetailUiRuntime : IDisposable bindings.Host.IsWindowVisible, bindings.Host.ShowWindow, bindings.Host.HideWindow); + + // Constructed here (before Initialize's Mount* calls run) so its + // RetailWindowManager.WindowRegistered subscription is live from the very + // first window mount — every window this runtime ever registers picks up + // the live focus-driven opacity fade, matching retail's GLOBAL option scope + // (Campaign CH slice CH6c; register row AP-190). Seeded from the persisted + // Chat settings (defaults to ChatSettings.Default's 0.5/1.0 — retail's + // ChatInterface base-constructor values, 0x004F4550 — when no store is + // wired or nothing has been saved yet). + ChatSettings chatSettings = bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default; + WindowOpacity = new RetailWindowOpacityController( + bindings.Host.Root.WindowManager, + chatSettings.DefaultOpacity, + chatSettings.ActiveOpacity); } internal static RetailUiRuntime CreateUninitialized( @@ -348,6 +362,13 @@ public sealed class RetailUiRuntime : IDisposable public UiHost Host => _bindings.Host; + /// + /// The live focus-driven window-opacity mechanism (Campaign CH slice CH6c). + /// AcDream.App.Settings.RuntimeSettingsTargets calls into this from the + /// Chat tab's Save button so the transparency sliders apply with no restart. + /// + public RetailWindowOpacityController WindowOpacity { get; } + /// /// Shared dat/sprite/font resolvers this runtime was built with. /// Campaign CH user-gate round 3: lets a controller built OUTSIDE this @@ -861,10 +882,11 @@ public sealed class RetailUiRuntime : IDisposable // its own element is a Type-2 Dragbar, not a grip (research doc §2.1, // §2.3 — the top edge authors NO Resizebar of its own; only its two // corners do). - // Opacity: retail's whole-window alpha (ChatInterface::SetOpacity - // @0x004F3120) is CH6c's scope — UiRenderContext.AlphaMod currently has - // no draw-path consumer, so any value here is presentation-inert; leave - // the frame at the default 1f rather than assert a value with no effect. + // Opacity: leave Options.Opacity at its 1f default — WindowOpacity + // (constructed in the ctor, before this Mount call runs) overwrites it + // the instant this window registers, via RetailWindowManager.WindowRegistered + // (Campaign CH slice CH6c: retail's whole-window alpha, + // ChatInterface::SetOpacity @0x004F3120). Controller = controller, StateController = controller, }); @@ -2345,6 +2367,7 @@ public sealed class RetailUiRuntime : IDisposable { _characterSheetSubscription?.Dispose(); Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; + WindowOpacity.Dispose(); }, () => _itemConfirmationController?.Dispose(), () => _gameplayConfirmationController?.Dispose(), diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index 0b2d32ce..c037d055 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -33,6 +33,16 @@ public sealed class RetailWindowManager : IDisposable public IReadOnlyCollection Windows => _byName.Values; public event Action? WindowVisibilityChanged; + /// + /// Fires once a NEW window finishes registering (not on a same-name/same-args + /// re-registration, which returns the existing handle early). Campaign CH slice + /// CH6c: subscribes here so every + /// window this manager ever registers — chat, floaties, vitals, toolbar, + /// inventory, everything — picks up the live focus-driven opacity fade without + /// each individual Mount* call site needing to know about it. + /// + public event Action? WindowRegistered; + public RetailWindowHandle Register( string name, UiElement outerFrame, @@ -79,6 +89,7 @@ public sealed class RetailWindowManager : IDisposable _byName.Add(name, handle); _byFrame.Add(outerFrame, handle); handle.NotifyInitialState(); + WindowRegistered?.Invoke(handle); return handle; } diff --git a/src/AcDream.App/UI/RetailWindowOpacityController.cs b/src/AcDream.App/UI/RetailWindowOpacityController.cs new file mode 100644 index 00000000..2109b4b7 --- /dev/null +++ b/src/AcDream.App/UI/RetailWindowOpacityController.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.UI; + +/// +/// Live per-window opacity, ported from retail's focus-driven chat-window fade — +/// ChatInterface::SetOpacity/SetDefaultOpacity/SetActiveOpacity +/// (0x004F3120/0x004F3BC0/0x004F3C40) and the two GLOBAL +/// PlayerModule options Option_DefaultOpacity_Property +/// (0x10000080) / Option_ActiveOpacity_Property (0x10000081) +/// (docs/research/2026-08-09-chat-retail-window-shell.md §3). +/// +/// +/// Retail applies this ONLY to ChatInterface-derived windows (the main chat +/// window + the four floaties). acdream applies it to every window +/// registers — the single Settings transparency +/// slider therefore affects the whole retained UI, not just chat (register row +/// AP-190). The retail focus test is "does m_chatEntry specifically have +/// focus"; the generalization here is "does ANY descendant of this window have +/// keyboard focus", which already computes for +/// every window via . +/// +/// +/// +/// Retail's shipped defaults differ PER WINDOW CLASS: gmMainChatUI's own +/// constructor (0x004CD0F0) overrides its base ChatInterface ctor +/// (0x004F4550, DefaultOpacity=0.5/ActiveOpacity=1.0) to DefaultOpacity=1.0 +/// (always fully opaque); gmFloatyChatUI::Create (0x004CE2C0) calls +/// ChatInterface::ChatInterface directly with no override, so the four +/// floating windows keep the base 0.5/1.0. acdream ships ONE shared default (the +/// base ChatInterface value, 0.5/1.0) applied uniformly, including to the main +/// chat window — a simplification recorded alongside the scope extension above. +/// +/// +public sealed class RetailWindowOpacityController : IDisposable +{ + private readonly RetailWindowManager _manager; + private readonly HashSet _focused = new(); + private bool _disposed; + + public RetailWindowOpacityController( + RetailWindowManager manager, + float defaultOpacity, + float activeOpacity) + { + _manager = manager ?? throw new ArgumentNullException(nameof(manager)); + + // Seed via the SAME linking helper the live setters use, so a corrupt/ + // hand-edited settings.json (active < default) collapses through the + // identical retail-ported invariant rather than a separate ad-hoc clamp. + (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive( + System.Math.Clamp(defaultOpacity, 0f, 1f), + System.Math.Clamp(activeOpacity, 0f, 1f)); + + _manager.WindowRegistered += OnWindowRegistered; + foreach (RetailWindowHandle handle in _manager.Windows) + Attach(handle); + } + + public float DefaultOpacity { get; private set; } + + public float ActiveOpacity { get; private set; } + + /// + /// Port of ChatInterface::SetDefaultOpacity @0x004F3BC0. Raising the + /// default above the current active value drags active UP to match (the + /// invariant is restored by moving the OTHER value, never by clamping the one + /// being set). Reapplies to every registered window immediately — this is the + /// "no restart" live-apply seam the Settings Chat tab's Save button calls into. + /// + public void SetDefaultOpacity(float value) + { + (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, value); + ReapplyAll(); + } + + /// + /// Port of ChatInterface::SetActiveOpacity @0x004F3C40. Lowering the + /// active value below the current default drags default DOWN to match. + /// + public void SetActiveOpacity(float value) + { + (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, value); + ReapplyAll(); + } + + /// + /// Set both values in retail's own UpdateFromPlayerModule order + /// (0x004CE3F0: Default read/applied first, then Active) — the shape + /// used to push a freshly loaded/persisted pair in + /// one call instead of two separate reapply passes. + /// + public void SetOpacity(float defaultOpacity, float activeOpacity) + { + (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, defaultOpacity); + (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, activeOpacity); + ReapplyAll(); + } + + private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle); + + private void Attach(RetailWindowHandle handle) + { + handle.DescendantFocusChanged += OnDescendantFocusChanged; + Apply(handle, hasFocus: false); + } + + private void OnDescendantFocusChanged(RetailWindowHandle handle, UiElement? focusedDescendant) + { + bool hasFocus = focusedDescendant is not null; + if (hasFocus) + _focused.Add(handle); + else + _focused.Remove(handle); + Apply(handle, hasFocus); + } + + private void Apply(RetailWindowHandle handle, bool hasFocus) + => handle.SetOpacity(hasFocus ? ActiveOpacity : DefaultOpacity); + + private void ReapplyAll() + { + foreach (RetailWindowHandle handle in _manager.Windows) + Apply(handle, _focused.Contains(handle)); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _manager.WindowRegistered -= OnWindowRegistered; + foreach (RetailWindowHandle handle in _manager.Windows) + handle.DescendantFocusChanged -= OnDescendantFocusChanged; + } +} diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 9c191d40..8a8ffef1 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -45,9 +45,12 @@ public sealed class UiRenderContext private readonly System.Collections.Generic.List _clipStack = new(); private UiClipRect? _clip; - // Alpha (opacity) stack — a window pushes its Opacity so its background/sprite - // draws fade (retail's translucent-chat effect). Text draws bypass this (they go - // straight to TextRenderer), so text stays sharp over a translucent background. + // Alpha (opacity) stack — a window pushes its Opacity so EVERY draw under it + // (sprite, rect/fill, AND text) fades together. Retail's ChatInterface::SetOpacity + // (0x004F3120) sets one alpha on the window's whole composited render surface — + // chrome, background, and glyphs all fade as one unit, not text-stays-sharp over a + // translucent panel. Campaign CH slice CH6c ported this: DrawStringDat and + // DrawString both route through ApplyAlpha exactly like DrawSprite/DrawRect/DrawFill. private readonly System.Collections.Generic.List _alphaStack = new(); private float _alpha = 1f; @@ -188,14 +191,15 @@ public sealed class UiRenderContext if (f is null) return; float screenX = _current.X + x; float screenY = _current.Y + y; + Vector4 alphaColor = ApplyAlpha(color); if (_clip is { } clip) { TextRenderer.DrawStringClipped( - f, text, screenX, screenY, color, + f, text, screenX, screenY, alphaColor, clip.Left, clip.Top, clip.Right, clip.Bottom); return; } - TextRenderer.DrawString(f, text, screenX, screenY, color); + TextRenderer.DrawString(f, text, screenX, screenY, alphaColor); } /// @@ -262,6 +266,9 @@ public sealed class UiRenderContext // Background (outline) atlas pass, tinted black — drawn behind. Gated by // `outline` (retail's per-element m_bitField & 0x10); off by default so UI // text is crisp fill-only and free of the grey halo over solid panels. + // Both passes route through ApplyAlpha (applyAlpha: true) so a window's + // opacity fades glyphs exactly like its chrome/background sprites — retail's + // ChatInterface::SetOpacity (0x004F3120) fades the whole composited surface. if (outline && font.BackgroundTexture != 0) { var (bu0, bv0, bu1, bv1) = AtlasUv( @@ -269,7 +276,7 @@ public sealed class UiRenderContext font.BackgroundWidth, font.BackgroundHeight); DrawSpriteAbsolute( font.BackgroundTexture, gx, gy, gw, gh, - bu0, bv0, bu1, bv1, outlineTint, applyAlpha: false); + bu0, bv0, bu1, bv1, outlineTint, applyAlpha: true); } // Foreground (fill) atlas pass, tinted with the requested color. @@ -278,7 +285,7 @@ public sealed class UiRenderContext font.ForegroundWidth, font.ForegroundHeight); DrawSpriteAbsolute( font.ForegroundTexture, gx, gy, gw, gh, - fu0, fv0, fu1, fv1, color, applyAlpha: false); + fu0, fv0, fu1, fv1, color, applyAlpha: true); } pen += UiDatFont.GlyphAdvance(g); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs new file mode 100644 index 00000000..b6e9c58a --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs @@ -0,0 +1,52 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Retail's linked default/active window-opacity invariant: active >= default, +/// ALWAYS — enforced by dragging the OTHER value, never by clamping the one being +/// set. Verbatim port of ChatInterface::SetDefaultOpacity @0x004F3BC0 / +/// SetActiveOpacity @0x004F3C40 (docs/research/2026-08-09-chat-retail-window-shell.md +/// §3): raising the default ABOVE the current active value drags active UP to match; +/// lowering the active value BELOW the current default drags default DOWN to match. +/// Retail's options-page sliders share a DualHash link +/// (gmChatOptionsUI::InitOptions @0x0049FC60) — this is why they visibly track +/// each other while dragging. +/// +/// +/// Shared by two independent consumers so both apply the identical link math: +/// RetailWindowOpacityController (AcDream.App — the live per-window +/// mechanism, mutating its own two float fields) and 's +/// Chat tab (the draft slider UI, mutating a record). +/// Pure functions — no window/render/state dependency — so both layers can call +/// them without violating the App→Abstractions dependency direction. +/// +/// +public static class ChatOpacityLink +{ + /// + /// Port of ChatInterface::SetDefaultOpacity @0x004F3BC0: set the default + /// (unfocused) opacity to , dragging + /// UP if it would otherwise fall below the new + /// default. Never returns an (default, active) pair with active < default. + /// + public static (float DefaultOpacity, float ActiveOpacity) SetDefault( + float currentActive, float newDefault) + { + newDefault = System.Math.Clamp(newDefault, 0f, 1f); + float active = currentActive < newDefault ? newDefault : currentActive; + return (newDefault, active); + } + + /// + /// Port of ChatInterface::SetActiveOpacity @0x004F3C40: set the active + /// (focused) opacity to , dragging + /// DOWN if it would otherwise exceed the new + /// active value. Never returns an (default, active) pair with active < default. + /// + public static (float DefaultOpacity, float ActiveOpacity) SetActive( + float currentDefault, float newActive) + { + newActive = System.Math.Clamp(newActive, 0f, 1f); + float def = currentDefault > newActive ? newActive : currentDefault; + return (def, newActive); + } +} diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs index 7f32cc10..7e023c8c 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs @@ -61,7 +61,17 @@ public sealed record ChatSettings( 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 + ulong ChatWindow4Filter = 0x78000000u, // Turbine General/Trade/LFG/Roleplay + // Campaign CH slice CH6c: retail's two GLOBAL window-opacity options + // (Option_DefaultOpacity_Property 0x10000080 / Option_ActiveOpacity_Property + // 0x10000081, docs/research/2026-08-09-chat-retail-window-shell.md §3). + // DefaultOpacity applies while a window's descendant does NOT have keyboard + // focus; ActiveOpacity while it does. Always active >= default — enforced by + // ChatOpacityLink at every setter, not by clamping here. acdream applies this + // GLOBALLY to every RetailWindowManager-registered window (register row + // AP-190), where retail scopes it to ChatInterface-derived windows only. + float DefaultOpacity = 0.5f, + float ActiveOpacity = 1.0f) { /// /// N4 (CH3 Opus review): matches ACE's ACTUAL diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index fc928115..dcba4c88 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -428,11 +428,40 @@ public sealed class SettingsPanel : IPanel if (renderer.SliderFloat("Font size (pt)", ref fontSize, 10f, 20f)) _vm.SetChat(c with { FontSize = fontSize }); + renderer.Spacing(); + renderer.Text("Window transparency"); + renderer.Separator(); + + // Campaign CH slice CH6c: retail's two linked opacity sliders + // (gmChatOptionsUI::InitOptions @0x0049FC60's DualHash pair). Dragging + // Background above Active drags Active UP to match; dragging Active below + // Background drags Background DOWN — ChatOpacityLink is the shared port of + // ChatInterface::SetDefaultOpacity/SetActiveOpacity (0x004F3BC0/0x004F3C40) + // that both this draft and the live RetailWindowOpacityController use, so the + // sliders track each other exactly like retail's options page. + float defaultOpacity = c.DefaultOpacity; + if (renderer.SliderFloat("Background opacity (unfocused)", ref defaultOpacity, 0f, 1f)) + { + var (def, active) = ChatOpacityLink.SetDefault(c.ActiveOpacity, defaultOpacity); + _vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active }); + } + + float activeOpacity = c.ActiveOpacity; + if (renderer.SliderFloat("Active opacity (typing / focused)", ref activeOpacity, 0f, 1f)) + { + var (def, active) = ChatOpacityLink.SetActive(c.DefaultOpacity, activeOpacity); + _vm.SetChat(c with { DefaultOpacity = def, ActiveOpacity = active }); + } + renderer.Spacing(); renderer.TextWrapped( "Channel filters hide messages from the chat window without " - + "changing your server-side subscriptions. Save persists; " - + "Cancel reverts."); + + "changing your server-side subscriptions. Window transparency " + + "applies to every retained window (chat, floaties, vitals, " + + "toolbar, inventory...) and fades whichever window doesn't " + + "currently have keyboard focus; Active opacity can never be " + + "lower than Background — dragging one past the other drags " + + "the other along. Save persists; Cancel reverts."); } /// diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 67f2ed84..bfe58f21 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -199,7 +199,9 @@ public sealed class SettingsStore ChatWindow1Filter: ReadULong(chat, "chatWindow1Filter", d.ChatWindow1Filter), ChatWindow2Filter: ReadULong(chat, "chatWindow2Filter", d.ChatWindow2Filter), ChatWindow3Filter: ReadULong(chat, "chatWindow3Filter", d.ChatWindow3Filter), - ChatWindow4Filter: ReadULong(chat, "chatWindow4Filter", d.ChatWindow4Filter)); + ChatWindow4Filter: ReadULong(chat, "chatWindow4Filter", d.ChatWindow4Filter), + DefaultOpacity: ReadFloat(chat, "defaultOpacity", d.DefaultOpacity), + ActiveOpacity: ReadFloat(chat, "activeOpacity", d.ActiveOpacity)); } catch (Exception ex) { @@ -541,11 +543,13 @@ public sealed class SettingsStore private static SortedDictionary BuildChatObject(ChatSettings c) => new(StringComparer.Ordinal) { + ["activeOpacity"] = c.ActiveOpacity, ["appearOffline"] = c.AppearOffline, ["chatWindow1Filter"] = c.ChatWindow1Filter, ["chatWindow2Filter"] = c.ChatWindow2Filter, ["chatWindow3Filter"] = c.ChatWindow3Filter, ["chatWindow4Filter"] = c.ChatWindow4Filter, + ["defaultOpacity"] = c.DefaultOpacity, ["filterProfanity"] = c.FilterProfanity, ["fontSize"] = c.FontSize, ["hearGeneralChat"] = c.HearGeneralChat, diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs index 10c86607..4b187dbd 100644 --- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs +++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs @@ -312,6 +312,7 @@ public sealed class RuntimeSettingsControllerTests "save-gameplay", "target-ui-lock:True", "save-chat", + "target-chat-opacity:0.5:1", "save-character:default", ], events); @@ -389,6 +390,62 @@ public sealed class RuntimeSettingsControllerTests Assert.Empty(targets.SingleOptionCalls); } + [Fact] + public void SaveChat_PushesOpacityToRuntimeTargets_LiveApply_NoRestart() + { + // Campaign CH slice CH6c: unlike the Hear* options (local-only, no + // wire), opacity is ALWAYS pushed on Save (not diffed) so the linking + // invariant self-heals; the point of this test is that clicking Save + // is enough — no restart, no separate "apply" step. + var controller = CreateController(); + var targets = new FakeRuntimeTargets([]); + controller.BindRuntimeTargets(targets); + using InputDispatcher dispatcher = CreateDispatcher(); + SettingsVM viewModel = controller.CreateViewModel( + new KeyBindings(), + dispatcher, + static _ => { }); + + viewModel.SetChat(viewModel.ChatDraft with + { + DefaultOpacity = 0.3f, + ActiveOpacity = 0.6f, + }); + viewModel.Save(); + + Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls); + Assert.Equal(0.3f, controller.Chat.DefaultOpacity); + Assert.Equal(0.6f, controller.Chat.ActiveOpacity); + } + + [Fact] + public void ConcreteRuntimeTargetForwardsChatOpacityToTheLiveController() + { + // Mirrors ConcreteRuntimeTargetPublishesSetSingleCharacterOptionOntoTheBus + // below: proves the CONCRETE RuntimeSettingsTargets.SetChatOpacity wiring, + // not just the IRuntimeSettingsTargets interface via the fake. + var recording = new RecordingChatOpacityTarget(); + var target = new RuntimeSettingsTargets( + new InspectingDisplayWindowTarget(static _ => { }), + new RecordingQualityApplicationTarget([]), + new RecordingUiLockTarget([]), + NullCommandBus.Instance, + log: static _ => { }, + chatOpacity: recording); + + target.SetChatOpacity(0.25f, 0.75f); + + Assert.Equal((0.25f, 0.75f), Assert.Single(recording.Calls)); + } + + private sealed class RecordingChatOpacityTarget : IRuntimeChatOpacityTarget + { + public List<(float DefaultOpacity, float ActiveOpacity)> Calls { get; } = []; + + public void Apply(float defaultOpacity, float activeOpacity) => + Calls.Add((defaultOpacity, activeOpacity)); + } + [Fact] public void SyncChatFromServerOptionsReseedsPersistedAndDraft() { @@ -1099,6 +1156,14 @@ public sealed class RuntimeSettingsControllerTests SingleOptionCalls.Add((optionId, value)); events.Add($"target-single-option:0x{optionId:X}:{value}"); } + + public List<(float DefaultOpacity, float ActiveOpacity)> ChatOpacityCalls { get; } = []; + + public void SetChatOpacity(float defaultOpacity, float activeOpacity) + { + ChatOpacityCalls.Add((defaultOpacity, activeOpacity)); + events.Add($"target-chat-opacity:{defaultOpacity}:{activeOpacity}"); + } } // CH3 review S5(b): records every ICommandBus.Publish call so a test can diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index d1d50134..e9e87f1e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -1,3 +1,7 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Chat; @@ -360,6 +364,53 @@ public class ChatLayoutConformanceTests Assert.NotEqual(0u, grip.SpriteFile); } + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + [Theory] + [InlineData(0x1000069Bu)] // TL corner + [InlineData(0x1000069Du)] // TR corner + [InlineData(0x1000069Eu)] // left edge + [InlineData(0x1000069Fu)] // BL corner + [InlineData(0x100006A0u)] // bottom edge + [InlineData(0x100006A1u)] // BR corner + [InlineData(0x100006A2u)] // right edge + public void MountedChatWindow_LiveGrip_ActuallyEmitsASpriteDraw_NotJustResolvesSpriteFile(uint elementId) + { + // CH6a/b re-review rider: MountedChatWindow_LiveGrip_ResolvesNonZeroSprite + // (above) only proves the ElementInfo carries a non-zero DirectState + // sprite id — it never calls OnDraw, so a media-less regression (a grip + // constructed WITHOUT its resolve delegate, or one whose resolve always + // returns a zero handle/dimension — exactly the CH6a/b BLOCKER 1 bug) + // would still pass it. This drives the grip through a REAL + // UiRenderContext over a REAL TextRenderer (backed by the in-memory + // RecordingGpuDevice test double, no live GPU) and asserts the draw + // call chain actually queued sprite geometry for that grip's texture. + var infos = FixtureLoader.LoadChatInfos(); + // Distinct from FixtureLoader's own null-returning resolver: echoes the + // sprite id as a nonzero fake texture handle with nonzero dimensions, + // so UiResizeGrip.OnDraw's `tex == 0 || tw == 0 || th == 0` guard does + // not short-circuit before reaching ctx.DrawSprite. + var layout = LayoutImporter.Build(infos, id => (id, 8, 8), null); + var grip = Assert.IsType(layout.FindElement(elementId)); + Assert.NotEqual(0u, grip.SpriteFile); + Assert.True(grip.Visible); + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + grip.DrawSelfAndChildren(ctx); + + var seg = Assert.Single( + renderer.DebugSpriteSegments, + s => s.Texture == grip.SpriteFile); + Assert.True(seg.VertexCount > 0); + } + [Fact] public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks() { diff --git a/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs new file mode 100644 index 00000000..dc48d5e3 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs @@ -0,0 +1,188 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign CH slice CH6c: — the live +/// per-window opacity mechanism ported from ChatInterface::SetOpacity/ +/// SetDefaultOpacity/SetActiveOpacity (0x004F3120/0x004F3BC0/ +/// 0x004F3C40), extended to every -registered +/// window rather than retail's ChatInterface-only scope (register row AP-190). +/// +public sealed class RetailWindowOpacityControllerTests +{ + private static UiRoot NewRoot() => new() { Width = 800f, Height = 600f }; + + /// Registers a bare window ("outer frame" + one focusable child) and + /// returns both — mirrors how a real window has a content descendant the + /// chat entry / any input field could take focus on. + private static (RetailWindowHandle handle, UiElement child) RegisterWindow(UiRoot root, string name) + { + var frame = new UiPanel { Width = 100f, Height = 100f }; + var child = new UiPanel { Width = 10f, Height = 10f, AcceptsFocus = true }; + frame.AddChild(child); + root.AddChild(frame); + RetailWindowHandle handle = root.WindowManager.Register(name, frame); + return (handle, child); + } + + [Fact] + public void Construction_AttachesToAlreadyRegisteredWindows_AtDefaultOpacity() + { + UiRoot root = NewRoot(); + (RetailWindowHandle handle, _) = RegisterWindow(root, "Vitals"); + + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); + + // No descendant has focus yet — every window starts at the DEFAULT + // (unfocused) opacity, matching retail's unfocused ChatInterface state. + Assert.Equal(0.5f, handle.Opacity); + Assert.Equal(0.5f, controller.DefaultOpacity); + Assert.Equal(1.0f, controller.ActiveOpacity); + } + + [Fact] + public void WindowRegisteredAfterConstruction_PicksUpLiveOpacityImmediately() + { + UiRoot root = NewRoot(); + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.9f); + + // The window is mounted AFTER the controller exists — proves the + // RetailWindowManager.WindowRegistered subscription (not just the ctor's + // catch-up loop over already-registered windows) is what applies retail's + // GLOBAL opacity scope to every future Mount* call too. + (RetailWindowHandle handle, _) = RegisterWindow(root, "Toolbar"); + + Assert.Equal(0.3f, handle.Opacity); + } + + [Fact] + public void FocusEnteringAWindow_SwitchesToActiveOpacity_LeavingSwitchesBack() + { + UiRoot root = NewRoot(); + (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat"); + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); + Assert.Equal(0.5f, handle.Opacity); + + root.SetKeyboardFocus(child); + Assert.Equal(1.0f, handle.Opacity); + + root.SetKeyboardFocus(null); + Assert.Equal(0.5f, handle.Opacity); + GC.KeepAlive(controller); + } + + [Fact] + public void OpacityFade_AppliesToEveryRegisteredWindow_NotJustChat() + { + // The CH6c scope extension: retail's ChatInterface::SetOpacity only ever + // runs on chat-derived windows. acdream applies the SAME mechanism to + // every RetailWindowManager window — vitals, toolbar, whatever else is + // mounted — matching the task's GLOBAL-option framing. + UiRoot root = NewRoot(); + (RetailWindowHandle vitals, _) = RegisterWindow(root, "Vitals"); + (RetailWindowHandle toolbar, UiElement toolbarChild) = RegisterWindow(root, "Toolbar"); + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.4f, activeOpacity: 1.0f); + + Assert.Equal(0.4f, vitals.Opacity); + Assert.Equal(0.4f, toolbar.Opacity); + + root.SetKeyboardFocus(toolbarChild); + + Assert.Equal(0.4f, vitals.Opacity); // unrelated window: still unfocused + Assert.Equal(1.0f, toolbar.Opacity); // the focused one: active + } + + [Fact] + public void SetDefaultOpacity_AboveCurrentActive_DragsActiveUp_AndReappliesEverywhere() + { + // Decomp-verified linking (ChatInterface::SetDefaultOpacity @0x004F3BC0): + // raising DEFAULT above the current ACTIVE value drags active UP to + // match — it never clamps the default down instead. + UiRoot root = NewRoot(); + (RetailWindowHandle unfocused, _) = RegisterWindow(root, "A"); + (RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B"); + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.5f); + root.SetKeyboardFocus(focusedChild); + Assert.Equal(0.3f, unfocused.Opacity); + Assert.Equal(0.5f, focused.Opacity); + + controller.SetDefaultOpacity(0.9f); + + Assert.Equal(0.9f, controller.DefaultOpacity); + Assert.Equal(0.9f, controller.ActiveOpacity); + Assert.Equal(0.9f, unfocused.Opacity); + Assert.Equal(0.9f, focused.Opacity); + } + + [Fact] + public void SetActiveOpacity_BelowCurrentDefault_DragsDefaultDown_AndReappliesEverywhere() + { + // Symmetric case (ChatInterface::SetActiveOpacity @0x004F3C40). + UiRoot root = NewRoot(); + (RetailWindowHandle unfocused, _) = RegisterWindow(root, "A"); + (RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B"); + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 0.7f); + root.SetKeyboardFocus(focusedChild); + + controller.SetActiveOpacity(0.1f); + + Assert.Equal(0.1f, controller.DefaultOpacity); + Assert.Equal(0.1f, controller.ActiveOpacity); + Assert.Equal(0.1f, unfocused.Opacity); + Assert.Equal(0.1f, focused.Opacity); + } + + [Fact] + public void SetOpacity_AppliesBothInRetailsUpdateFromPlayerModuleOrder() + { + // UpdateFromPlayerModule (0x004CE3F0) reads/applies Default first, then + // Active — the shape used to push a freshly loaded ChatSettings pair. + UiRoot root = NewRoot(); + (RetailWindowHandle handle, _) = RegisterWindow(root, "Chat"); + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); + + controller.SetOpacity(defaultOpacity: 0.2f, activeOpacity: 0.6f); + + Assert.Equal(0.2f, controller.DefaultOpacity); + Assert.Equal(0.6f, controller.ActiveOpacity); + Assert.Equal(0.2f, handle.Opacity); + } + + [Fact] + public void ConstructorSeed_EnforcesTheActiveGreaterThanOrEqualDefaultInvariant() + { + // A corrupt/hand-edited settings.json could carry active < default. + // The seed collapses through the SAME link the live setters use. + UiRoot root = NewRoot(); + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.8f, activeOpacity: 0.2f); + + Assert.True(controller.ActiveOpacity >= controller.DefaultOpacity); + Assert.Equal(0.2f, controller.DefaultOpacity); + Assert.Equal(0.2f, controller.ActiveOpacity); + } + + [Fact] + public void Dispose_UnsubscribesFromFocusChanges() + { + UiRoot root = NewRoot(); + (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat"); + var controller = new RetailWindowOpacityController( + root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); + + controller.Dispose(); + root.SetKeyboardFocus(child); + + // Still at the last value the controller applied before disposal — a + // focus change after Dispose is not observed anymore. + Assert.Equal(0.5f, handle.Opacity); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs b/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs new file mode 100644 index 00000000..3d31cdc3 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs @@ -0,0 +1,175 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign CH slice CH6c: pins the window-opacity ALPHA CHOKEPOINT — +/// 's private ApplyAlpha, reached by every +/// public draw call (, +/// /, +/// and — new this slice — and +/// ). Retail's ChatInterface::SetOpacity +/// @0x004F3120 sets ONE alpha on the whole composited window surface, chrome +/// AND text together — before this slice, DrawStringDat passed +/// applyAlpha: false so glyphs stayed opaque over a translucent window. +/// +/// +/// Builds a real over the in-memory +/// test double (no live GPU, no shader +/// compile — RecordingGpuDevice.CreatePipeline just wraps the +/// description) so TextRenderer.DebugSpriteSegments can be read back +/// directly instead of asserting through a GPU flush. +/// +/// +public sealed class UiRenderContextAlphaTests +{ + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static (TextRenderer renderer, UiRenderContext ctx) Build() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + return (renderer, ctx); + } + + private static UiDatFont BuildFont() => new( + fgTex: 1, fgW: 64, fgH: 64, + bgTex: 0, bgW: 0, bgH: 0, + lineHeight: 16f, baselineOffset: 12f, + glyphs: new Dictionary + { + ['A'] = new FontCharDesc + { + Unicode = 'A', + Width = 8, + Height = 16, + OffsetX = 0, + OffsetY = 0, + HorizontalOffsetBefore = 0, + HorizontalOffsetAfter = 0, + VerticalOffsetBefore = 0, + }, + }); + + // -- DrawSprite: full-opacity identity --------------------------------- + + [Fact] + public void FullOpacity_DrawSprite_MatchesRequestedAlpha_Identity() + { + // Pin: with no window opacity pushed (AlphaMod == 1, matching every + // production window today), output is byte-identical to a tint's own + // alpha — this slice must not change ANY existing full-opacity render. + (TextRenderer renderer, UiRenderContext ctx) = Build(); + Assert.Equal(1f, ctx.AlphaMod); + + ctx.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, new Vector4(1f, 1f, 1f, 1f)); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(7u, seg.Texture); + Assert.Equal(1f, seg.Alpha); + } + + [Fact] + public void HalfOpacityWindow_MultipliesEverySpriteEmission() + { + (TextRenderer renderer, UiRenderContext ctx) = Build(); + + ctx.PushAlpha(0.5f); + Assert.Equal(0.5f, ctx.AlphaMod); + ctx.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, new Vector4(1f, 1f, 1f, 1f)); + ctx.PopAlpha(); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(0.5f, seg.Alpha); + + // Pop restores full opacity for whatever draws next. + Assert.Equal(1f, ctx.AlphaMod); + } + + [Fact] + public void NestedPushAlpha_ComposesMultiplicatively() + { + (TextRenderer renderer, UiRenderContext ctx) = Build(); + + ctx.PushAlpha(0.5f); + ctx.PushAlpha(0.4f); + Assert.Equal(0.2f, ctx.AlphaMod, 5); + ctx.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, new Vector4(1f, 1f, 1f, 1f)); + ctx.PopAlpha(); + ctx.PopAlpha(); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(0.2f, seg.Alpha, 5); + } + + [Fact] + public void NestedPushAlpha_MultipliesAgainstAnAlreadyTintedColor() + { + // A widget that already draws at partial alpha (e.g. a translucent + // background sprite, tint.W = 0.8) fades FURTHER when its window is + // also translucent — the two multipliers compose, they don't clobber. + (TextRenderer renderer, UiRenderContext ctx) = Build(); + + ctx.PushAlpha(0.5f); + ctx.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, new Vector4(1f, 1f, 1f, 0.8f)); + ctx.PopAlpha(); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(0.4f, seg.Alpha, 5); + } + + [Fact] + public void PopAlpha_WithoutMatchingPush_IsANoOp() + { + (TextRenderer renderer, UiRenderContext ctx) = Build(); + ctx.PopAlpha(); + Assert.Equal(1f, ctx.AlphaMod); + } + + // -- DrawStringDat: the CH6c fix (text now respects window alpha) ----- + + [Fact] + public void FullOpacity_DrawStringDat_MatchesRequestedAlpha_Identity() + { + (TextRenderer renderer, UiRenderContext ctx) = Build(); + UiDatFont font = BuildFont(); + + ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f)); + + // bgTex == 0, so only the foreground (fill) pass draws — one segment + // on the font's foreground texture (id 1). + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(1u, seg.Texture); + Assert.Equal(1f, seg.Alpha); + } + + [Fact] + public void HalfOpacityWindow_MultipliesDatFontGlyphAlpha() + { + // The retail-faithful fix: ChatInterface::SetOpacity (0x004F3120) fades + // the WHOLE composited window surface, glyphs included — before this + // slice, DrawStringDat's applyAlpha:false meant text stayed sharp over + // a translucent window (see the retired class-doc comment this test + // replaces the assumption of). + (TextRenderer renderer, UiRenderContext ctx) = Build(); + UiDatFont font = BuildFont(); + + ctx.PushAlpha(0.5f); + ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f)); + ctx.PopAlpha(); + + var seg = Assert.Single(renderer.DebugSpriteSegments); + Assert.Equal(0.5f, seg.Alpha); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatOpacityLinkTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatOpacityLinkTests.cs new file mode 100644 index 00000000..c266e203 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatOpacityLinkTests.cs @@ -0,0 +1,87 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.UI.Abstractions.Tests.Panels.Settings; + +/// +/// Campaign CH slice CH6c: pins 's port of retail's +/// linked default/active opacity invariant (ChatInterface::SetDefaultOpacity +/// @0x004F3BC0 / SetActiveOpacity @0x004F3C40) — active >= default, +/// ALWAYS, enforced by dragging the OTHER value rather than clamping the one +/// being set. +/// +public sealed class ChatOpacityLinkTests +{ + [Fact] + public void SetDefault_BelowCurrentActive_LeavesActiveUnchanged() + { + // The ordinary case: lowering the background opacity while it's already + // below the active value doesn't need to touch active at all. + var (def, active) = ChatOpacityLink.SetDefault(currentActive: 1.0f, newDefault: 0.3f); + Assert.Equal(0.3f, def); + Assert.Equal(1.0f, active); + } + + [Fact] + public void SetDefault_AboveCurrentActive_DragsActiveUp_DoesNotClampDefault() + { + // The decomp-verified answer to "does raising default above active drag + // active up, or clamp default?": ChatInterface::SetDefaultOpacity always + // WRITES this->m_fDefaultOpacity = arg2 first, THEN calls SetActiveOpacity + // when active < default. Default is never clamped down. + var (def, active) = ChatOpacityLink.SetDefault(currentActive: 0.4f, newDefault: 0.9f); + Assert.Equal(0.9f, def); + Assert.Equal(0.9f, active); + } + + [Fact] + public void SetActive_AboveCurrentDefault_LeavesDefaultUnchanged() + { + var (def, active) = ChatOpacityLink.SetActive(currentDefault: 0.2f, newActive: 0.9f); + Assert.Equal(0.2f, def); + Assert.Equal(0.9f, active); + } + + [Fact] + public void SetActive_BelowCurrentDefault_DragsDefaultDown() + { + // Symmetric case: ChatInterface::SetActiveOpacity writes m_fActiveOpacity + // first, then calls SetDefaultOpacity when default > active. + var (def, active) = ChatOpacityLink.SetActive(currentDefault: 0.7f, newActive: 0.3f); + Assert.Equal(0.3f, def); + Assert.Equal(0.3f, active); + } + + [Theory] + [InlineData(-1f, 0f)] + [InlineData(2f, 1f)] + public void SetDefault_ClampsInputToUnitRange(float rawInput, float expectedDefault) + { + var (def, _) = ChatOpacityLink.SetDefault(currentActive: 1f, newDefault: rawInput); + Assert.Equal(expectedDefault, def); + } + + [Theory] + [InlineData(-1f, 0f)] + [InlineData(2f, 1f)] + public void SetActive_ClampsInputToUnitRange(float rawInput, float expectedActive) + { + var (_, active) = ChatOpacityLink.SetActive(currentDefault: 0f, newActive: rawInput); + Assert.Equal(expectedActive, active); + } + + [Fact] + public void SetDefault_ThenSetActive_NeverProducesActiveBelowDefault() + { + // A short sequence exercising the invariant through several moves, the + // way a user dragging both sliders back and forth would. + (float def, float active) = (0.5f, 1.0f); + (def, active) = ChatOpacityLink.SetDefault(active, 0.9f); + Assert.True(active >= def); + (def, active) = ChatOpacityLink.SetActive(def, 0.1f); + Assert.True(active >= def); + (def, active) = ChatOpacityLink.SetDefault(active, 0.6f); + Assert.True(active >= def); + Assert.Equal(0.6f, def); + Assert.Equal(0.6f, active); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs index 2e9f6deb..2d825f9b 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs @@ -24,6 +24,14 @@ public sealed class ChatSettingsTests Assert.True(d.ShowTimestamps); Assert.True(d.FilterProfanity); Assert.Equal(12f, d.FontSize); + + // Campaign CH slice CH6c: retail's base ChatInterface constructor + // (0x004F4550) sets DefaultOpacity=0.5/ActiveOpacity=1.0 — the value + // every gmFloatyChatUI (the four floating windows) keeps unmodified. + // acdream ships that pair as ONE shared global default (register row + // AP-190), rather than gmMainChatUI's own 1.0/1.0 override. + Assert.Equal(0.5f, d.DefaultOpacity); + Assert.Equal(1.0f, d.ActiveOpacity); } [Fact] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 55229823..2ed12948 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -474,6 +474,70 @@ public sealed class SettingsPanelTests Assert.Equal(1f, (float)masterCall.Args[3]!); } + // -- Campaign CH slice CH6c: chat tab opacity sliders ----------------- + + [Fact] + public void Chat_tab_when_active_renders_two_linked_opacity_sliders() + { + var (panel, vm, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Chat" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var sliders = r.Calls.Where(c => c.Method == "SliderFloat") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.Contains("Background opacity (unfocused)", sliders); + Assert.Contains("Active opacity (typing / focused)", sliders); + + var bgCall = r.Calls.First( + c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Background opacity (unfocused)"); + Assert.Equal(vm.ChatDraft.DefaultOpacity, (float)bgCall.Args[1]!); + Assert.Equal(0f, (float)bgCall.Args[2]!); + Assert.Equal(1f, (float)bgCall.Args[3]!); + + var activeCall = r.Calls.First( + c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Active opacity (typing / focused)"); + Assert.Equal(vm.ChatDraft.ActiveOpacity, (float)activeCall.Args[1]!); + } + + [Fact] + public void Chat_tab_opacity_sliders_do_not_render_when_a_different_tab_is_active() + { + var (panel, _, _, _) = Build(); + var r = new FakePanelRenderer { ActiveTabLabel = "Display" }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + var sliders = r.Calls.Where(c => c.Method == "SliderFloat") + .Select(c => (string)c.Args[0]!).ToList(); + Assert.DoesNotContain("Background opacity (unfocused)", sliders); + Assert.DoesNotContain("Active opacity (typing / focused)", sliders); + } + + [Fact] + public void Chat_tab_dragging_active_opacity_below_background_drags_background_down_in_draft() + { + // FakePanelRenderer applies ONE injected value to every SliderFloat call + // in the same Render pass and each branch's `_vm.SetChat` starts from the + // ORIGINAL pre-render draft — so with SliderFloatNextReturn set, the + // LAST-rendered opacity slider ("Active", rendered after "Background") + // determines the final draft. That exercises ChatOpacityLink.SetActive's + // drag-background-down path end-to-end through the real panel code, + // starting from ChatSettings.Default (DefaultOpacity=0.5, ActiveOpacity=1.0). + var (panel, vm, _, _) = Build(); + var r = new FakePanelRenderer + { + ActiveTabLabel = "Chat", + SliderFloatNextReturn = true, + SliderFloatNextValue = 0.2f, + }; + + panel.Render(new PanelContext(0.016f, new NullBus()), r); + + Assert.Equal(0.2f, vm.ChatDraft.DefaultOpacity); + Assert.Equal(0.2f, vm.ChatDraft.ActiveOpacity); + } + [Fact] public void Save_Cancel_buttons_render_outside_the_tab_bar() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index f5a2b569..2667aca6 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -330,6 +330,35 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Equal(original.ChatWindow4Filter, loaded.ChatWindow4Filter); } + // -- Campaign CH slice CH6c: window opacity round-trip ----------------- + + [Fact] + public void LoadChat_returns_retail_ChatInterface_opacity_defaults_when_file_is_missing() + { + var store = new SettingsStore(_tempPath); + ChatSettings loaded = store.LoadChat(); + + Assert.Equal(0.5f, loaded.DefaultOpacity); + Assert.Equal(1.0f, loaded.ActiveOpacity); + } + + [Fact] + public void SaveChat_then_LoadChat_round_trips_opacity() + { + var store = new SettingsStore(_tempPath); + var original = ChatSettings.Default with + { + DefaultOpacity = 0.2f, + ActiveOpacity = 0.8f, + }; + + store.SaveChat(original); + ChatSettings loaded = store.LoadChat(); + + Assert.Equal(original.DefaultOpacity, loaded.DefaultOpacity); + Assert.Equal(original.ActiveOpacity, loaded.ActiveOpacity); + } + [Fact] public void All_four_sections_coexist_in_one_settings_json() {