diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 20661f2d..394ec1c7 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -342,7 +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` |
+| AP-190 | **Filed 2026-08-10 (Campaign CH slice CH6c — window opacity + transparency setting; retires AP-40). AMENDED 2026-08-10 at the CH6c review-fix round: reworded (2), added (3)/(4).** Four divergences from retail's focus-driven window opacity, all 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 — REWORDED at the review-fix round: 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 originally shipped the base ChatInterface value (0.5/1.0) as ONE shared global default applied to EVERY registered window — combined with (1)'s scope extension this faded the WHOLE registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity out of the box, including several windows that can never take keyboard focus at all and so were PERMANENTLY stuck at 0.5. Fixed at the review round to `gmMainChatUI`'s 1.0/1.0 override as the shared default instead: this reduces the remaining divergence to acdream's four floating chat windows shipping OPAQUE where retail's floaties ship 0.5-while-idle — user-settable via the same Settings → Chat opacity slider pair, so it is now a default-VALUE divergence only, not a missing mechanism. (3) EASING (new, filed at the review-fix round): retail's `ChatInterface::ListenToGlobalMessage @0x004F3840` — armed on the focus element-messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E` at `0x004F5275` via `UIListener::RegisterForGlobalMessage(this, 3)` — eases the live opacity toward its target by 5% of the target-delta per tick, unregistering from the global tick once within FP-epsilon of the target. acdream's `RetailWindowOpacityController.Apply` snaps to the target opacity immediately on every focus-change event; porting the per-tick lerp needs a UI frame-tick hook the controller does not have today, so it is deferred rather than implemented this round. (4) FOCUS PREDICATE (new, filed at the review-fix round): retail's `ChatInterface::IsTextEntryFocused @0x004F30A0` tests specifically whether `GetFocusDescendant(rootElement) == this->m_chatEntry` — the chat ENTRY FIELD, not the window generally. acdream's `RetailWindowHandle.DescendantFocusChanged` fires whenever ANY focusable descendant of the window gains focus, a strictly broader predicate for any window with more than one focusable child. 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); shipping the shared default at 1.0 keeps the out-of-box render retail-identical for the 11 non-chat windows AND the main chat window (the windows retail keeps opaque, several of which can never take focus at all), while the Settings → Chat transparency slider remains fully user-settable for anyone who wants the four floaties' retail translucence back. (3) and (4) are both presentation-only refinements — the fade direction and the linked-invariant math stay retail-exact, only the transition curve (snap vs. 5%-per-tick ease) and the focus predicate's granularity (any descendant vs. the text-entry specifically) diverge — so recording them without implementing the frame-tick hook (3) or narrowing the focus event (4) is the correct scope for a review-fix round rather than opening new implementation work | A user who compares acdream's default install against retail side-by-side now sees the 11 non-chat windows AND the main chat window matching (opaque); only the four floating chat windows still diverge (opaque vs. retail's 50%-while-idle) until the slider is dragged. (3) is visible as the opacity change happening in a single frame instead of retail's ~20-tick fade — low severity, since the START and END states are both retail-exact, only the transition is instant instead of eased. (4) is visible on any window with more than one distinct focusable descendant (e.g. a settings panel with several controls): acdream stays at ActiveOpacity while ANY of them holds focus, where retail would already have faded back to DefaultOpacity once focus left the specific text-entry element — for single-focusable-child windows (most of the retained UI today) the two predicates coincide and there is no observable difference | `ChatInterface::ChatInterface @0x004F4550`; `gmMainChatUI::gmMainChatUI @0x004CD0F0`; `gmFloatyChatUI::Create @0x004CE2C0`; `ChatInterface::SetDefaultOpacity @0x004F3BC0`/`SetActiveOpacity @0x004F3C40`; `ChatInterface::ListenToGlobalMessage @0x004F3840`; `ChatInterface::IsTextEntryFocused @0x004F30A0`; global-message arming switch @0x004F5275 (`UIListener::RegisterForGlobalMessage(this, 3)` on element messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E`) |
## 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 0132665a..e4dca9d0 100644
--- a/docs/plans/2026-08-09-chat-parity-campaign.md
+++ b/docs/plans/2026-08-09-chat-parity-campaign.md
@@ -287,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 | `a819687c` | 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) |
+| CH6c opacity | `a819687c` | 12,459 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed this commit — BLOCKER (out-of-box `DefaultOpacity` 0.5→1.0), AP-190 reworded + two new decomp-verified clauses (retail's per-tick ease, retail's entry-field-specific focus predicate), NITs (`UiElement.cs` stale comment, `WindowUnregistered` detach, post-Dispose `Set*` guards, `DrawString`/outline-pass alpha tests) | pending — needs the next connected round for visual confirmation (window fade on focus change, Settings slider live-apply) |
### CH4 closeout (2026-08-09)
diff --git a/docs/research/2026-08-09-chat-retail-window-shell.md b/docs/research/2026-08-09-chat-retail-window-shell.md
index 7573ecff..67e1388b 100644
--- a/docs/research/2026-08-09-chat-retail-window-shell.md
+++ b/docs/research/2026-08-09-chat-retail-window-shell.md
@@ -503,15 +503,63 @@ than per-window: only a NEVER-SAVED option (a fresh character, nothing in the
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
+**CH6c review-fix round (2026-08-10): the shared default above was WRONG.**
+Shipping the base `ChatInterface` value (0.5/1.0) as ONE shared global
+default, combined with the scope extension to every registered window, faded
+the WHOLE registered UI (radar, vitals, toolbar, main chat, ...) to 50%
+opacity out of the box — including several windows that can never take
+keyboard focus at all, so they were stuck at 0.5 permanently. acdream now
+ships `gmMainChatUI`'s per-class 1.0/1.0 override (`0x004CD0F0`) as the
+shared global default instead (register row AP-190 in
+`docs/architecture/retail-divergence-register.md`), which is retail-identical
+for the 11 non-chat windows and the main chat window and leaves only the four
+floating chat windows diverging from retail's 0.5-while-idle fade — a
+default-VALUE divergence the transparency slider still fully covers. 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.
+### 3.1 Two more residuals found at the CH6c review (not yet ported)
+
+Both are decomp-verified and both are recorded as new AP-190 clauses; neither
+is implemented this round.
+
+**(a) Retail eases opacity between endpoints; acdream snaps.**
+`ChatInterface::ListenToGlobalMessage @0x004F3840` is the handler for global
+message id `3`, armed (`UIListener::RegisterForGlobalMessage(this, 3)`) from
+the element-focus messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E` inside the
+window's `ListenToElementMessage` switch at `0x004F5275`. Once armed, every
+tick nudges the live opacity toward whichever endpoint
+`IsTextEntryFocused` currently selects by 5% of the endpoint delta
+(`fabsl(target - current) * 0.05f`), and unregisters from the global message
+once the value lands within FP-epsilon of the target. acdream's
+`RetailWindowOpacityController.Apply` sets the target opacity directly on the
+focus-change event — the START and END states are retail-exact, but the
+transition is an instant snap instead of a roughly 20-tick fade. Porting the
+lerp needs a UI frame-tick hook `RetailWindowOpacityController` does not have
+today (it only reacts to `DescendantFocusChanged`); deferred.
+
+**(b) Retail's focus predicate is the chat entry field specifically; acdream's
+is any focusable descendant.** `ChatInterface::IsTextEntryFocused @0x004F30A0`
+tests `GetFocusDescendant(rootElement) == this->m_chatEntry` — literally the
+window's text-entry element, not "some descendant of this window has focus."
+acdream's `RetailWindowHandle.DescendantFocusChanged` (the event
+`RetailWindowOpacityController` subscribes to) fires whenever ANY focusable
+descendant of the window gains focus. For a window with exactly one
+focusable child the two predicates coincide; for a window with several (a
+settings panel's multiple controls, for example) acdream's broader predicate
+holds ActiveOpacity while retail would already have faded back to
+DefaultOpacity once focus left the specific text-entry widget.
+
+**Pre-existing, unrelated: `UiMenu.cs:293`'s opacity bypass.** Popup menus
+call `ctx.PushAlphaAbsolute(1f)` before drawing so a menu always reads solid
+even when it is opened from a translucent (faded) window — this is a
+deliberate acdream-only presentation choice (menus must stay legible
+regardless of the host window's current fade state), not a divergence from
+either of the two opacity mechanisms documented in this section, and it
+predates the CH6c slice.
+
---
## 4. Persistence — how filters, geometry, visibility and title survive
diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs
index b9ea2bb3..7c714bb4 100644
--- a/src/AcDream.App/Rendering/TextRenderer.cs
+++ b/src/AcDream.App/Rendering/TextRenderer.cs
@@ -115,6 +115,21 @@ public sealed class TextRenderer : IDisposable
}
}
+ ///
+ /// Test-only snapshot of the current frame's queued NORMAL-layer BITMAP FONT
+ /// text buffer (/,
+ /// the path used for
+ /// D.6 world-space HUD text): (vertex count, alpha of the first emitted
+ /// vertex — color.W at float index 7 of the 8-float vertex layout). Unlike
+ /// this buffer is not split per-texture —
+ /// bitmap-font glyphs all sample the one atlas — so there is exactly one
+ /// (count, alpha) pair to check. CH6c review NIT: pins that
+ /// 's alpha chokepoint is guarded
+ /// the same way 's already was.
+ ///
+ internal (int VertexCount, float Alpha) DebugTextBuffer
+ => (_textVerts, _textBuf.Count > 0 ? _textBuf[7] : 0f);
+
// 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/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs
index c037d055..51cab64c 100644
--- a/src/AcDream.App/UI/RetailWindowManager.cs
+++ b/src/AcDream.App/UI/RetailWindowManager.cs
@@ -43,6 +43,17 @@ public sealed class RetailWindowManager : IDisposable
///
public event Action? WindowRegistered;
+ ///
+ /// Fires when a window is REMOVED from the registry (),
+ /// after teardown (NotifyClosed/DisposeController) but before the
+ /// handle is discarded. CH6c review NIT:
+ /// subscribes here so it can drop its per-handle DescendantFocusChanged
+ /// subscription and forget the handle from its focused-window set — without this,
+ /// a window unregistered while it held keyboard focus stayed referenced by the
+ /// controller for the rest of the session.
+ ///
+ public event Action? WindowUnregistered;
+
public RetailWindowHandle Register(
string name,
UiElement outerFrame,
@@ -215,6 +226,7 @@ public sealed class RetailWindowManager : IDisposable
_defaultInputs.Remove(handle);
handle.NotifyClosed();
handle.DisposeController();
+ WindowUnregistered?.Invoke(handle);
return true;
}
diff --git a/src/AcDream.App/UI/RetailWindowOpacityController.cs b/src/AcDream.App/UI/RetailWindowOpacityController.cs
index 2109b4b7..46251781 100644
--- a/src/AcDream.App/UI/RetailWindowOpacityController.cs
+++ b/src/AcDream.App/UI/RetailWindowOpacityController.cs
@@ -29,9 +29,24 @@ namespace AcDream.App.UI;
/// (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.
+/// floating windows keep the base 0.5/1.0. CH6c review fix: acdream ships ONE
+/// shared default — gmMainChatUI's 1.0/1.0 override, not the base
+/// ChatInterface value — applied uniformly to every window including the four
+/// floating chat windows. Shipping the base 0.5/1.0 pair globally (the original
+/// CH6c behavior) faded the WHOLE registered UI to 50% opacity out of the box,
+/// including windows that can never take keyboard focus and so were stuck at 0.5
+/// permanently; 1.0/1.0 is retail-identical for the 11 non-chat windows and the
+/// main chat window, and only the four floaties diverge from retail's
+/// 0.5-while-idle default now — user-settable via the same slider (register row
+/// AP-190).
+///
+///
+///
+/// Retail also EASES the live opacity toward its target by 5% of the delta per
+/// tick (ChatInterface::ListenToGlobalMessage @0x004F3840) rather than
+/// snapping, and its focus predicate is the chat ENTRY FIELD specifically
+/// (ChatInterface::IsTextEntryFocused @0x004F30A0), not "any descendant has
+/// focus". Both are recorded as AP-190 residuals — CH6c review, not yet ported.
///
///
public sealed class RetailWindowOpacityController : IDisposable
@@ -55,6 +70,7 @@ public sealed class RetailWindowOpacityController : IDisposable
System.Math.Clamp(activeOpacity, 0f, 1f));
_manager.WindowRegistered += OnWindowRegistered;
+ _manager.WindowUnregistered += OnWindowUnregistered;
foreach (RetailWindowHandle handle in _manager.Windows)
Attach(handle);
}
@@ -72,6 +88,7 @@ public sealed class RetailWindowOpacityController : IDisposable
///
public void SetDefaultOpacity(float value)
{
+ if (_disposed) return;
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, value);
ReapplyAll();
}
@@ -82,6 +99,7 @@ public sealed class RetailWindowOpacityController : IDisposable
///
public void SetActiveOpacity(float value)
{
+ if (_disposed) return;
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, value);
ReapplyAll();
}
@@ -94,6 +112,7 @@ public sealed class RetailWindowOpacityController : IDisposable
///
public void SetOpacity(float defaultOpacity, float activeOpacity)
{
+ if (_disposed) return;
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, defaultOpacity);
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, activeOpacity);
ReapplyAll();
@@ -101,6 +120,19 @@ public sealed class RetailWindowOpacityController : IDisposable
private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle);
+ ///
+ /// CH6c review NIT: without this, a window unregistered while it held
+ /// keyboard focus stayed in — and its
+ /// subscription
+ /// stayed live — for the rest of the session, because the only prior
+ /// detach point was .
+ ///
+ private void OnWindowUnregistered(RetailWindowHandle handle)
+ {
+ handle.DescendantFocusChanged -= OnDescendantFocusChanged;
+ _focused.Remove(handle);
+ }
+
private void Attach(RetailWindowHandle handle)
{
handle.DescendantFocusChanged += OnDescendantFocusChanged;
@@ -133,7 +165,9 @@ public sealed class RetailWindowOpacityController : IDisposable
_disposed = true;
_manager.WindowRegistered -= OnWindowRegistered;
+ _manager.WindowUnregistered -= OnWindowUnregistered;
foreach (RetailWindowHandle handle in _manager.Windows)
handle.DescendantFocusChanged -= OnDescendantFocusChanged;
+ _focused.Clear();
}
}
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index f2fe46d1..4af90e90 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -460,7 +460,10 @@ public abstract class UiElement
if (!Visible) return;
// Translate into our local space + push this window's opacity (multiplies into
- // descendants' sprite/rect draws; text bypasses the alpha so it stays sharp).
+ // descendants' sprite, rect, AND text draws — CH6c ported DrawStringDat/DrawString
+ // through the same ApplyAlpha chokepoint as sprites/rects, matching retail's
+ // ChatInterface::SetOpacity (0x004F3120), which fades the whole composited window
+ // surface, chrome and glyphs together, not text-stays-sharp over a translucent panel).
ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
index 7e023c8c..41b8d373 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs
@@ -70,7 +70,20 @@ public sealed record ChatSettings(
// 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,
+ //
+ // CH6c review fix (2026-08-10): the base ChatInterface ctor's 0.5/1.0 pair
+ // is retail-correct ONLY for the four floating chat windows —
+ // gmMainChatUI overrides to 1.0/1.0 (0x004CD0F0), and every other
+ // RetailWindowManager-registered window (radar, vitals, toolbar, ...) has
+ // no retail opacity fade at all, so applying 0.5 to them out of the box
+ // rendered the whole registered UI half-transparent forever, including
+ // several windows that can never take keyboard focus and so were
+ // PERMANENTLY stuck at 0.5. DefaultOpacity now ships 1.0, matching
+ // retail-identical opaque presentation for the 11 non-chat windows and
+ // the main chat window; the four floating chat windows lose their
+ // retail 0.5-while-idle fade by default, but the Settings → Chat
+ // transparency slider remains fully user-settable (AP-190).
+ float DefaultOpacity = 1.0f,
float ActiveOpacity = 1.0f)
{
///
diff --git a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs
index 4b187dbd..1ebebb20 100644
--- a/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs
+++ b/tests/AcDream.App.Tests/Settings/RuntimeSettingsControllerTests.cs
@@ -312,7 +312,7 @@ public sealed class RuntimeSettingsControllerTests
"save-gameplay",
"target-ui-lock:True",
"save-chat",
- "target-chat-opacity:0.5:1",
+ "target-chat-opacity:1:1",
"save-character:default",
],
events);
diff --git a/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs
index dc48d5e3..c85a72bc 100644
--- a/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs
@@ -185,4 +185,63 @@ public sealed class RetailWindowOpacityControllerTests
// focus change after Dispose is not observed anymore.
Assert.Equal(0.5f, handle.Opacity);
}
+
+ [Fact]
+ public void WindowUnregistered_DetachesSubscription_AndForgetsFocusedState()
+ {
+ // CH6c review NIT: before this fix, the only detach point for a
+ // handle's DescendantFocusChanged subscription (and its membership in
+ // _focused) was the CONTROLLER's own Dispose — a window unregistered
+ // while it held focus stayed subscribed and pinned in _focused for
+ // the rest of the session. Prove the RetailWindowManager.WindowUnregistered
+ // wiring actually detaches: a stray post-unregister notification (the
+ // kind a lingering external reference to the handle could still fire)
+ // must not reach the controller anymore.
+ UiRoot root = NewRoot();
+ (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
+ var controller = new RetailWindowOpacityController(
+ root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
+
+ root.SetKeyboardFocus(child);
+ Assert.Equal(1.0f, handle.Opacity);
+
+ root.WindowManager.Unregister("Chat");
+
+ // Unregister hides the outer frame, which drops keyboard focus off
+ // the now-invisible child — the manager's (still-live at that point)
+ // focus-change plumbing reapplies DefaultOpacity naturally. Expected
+ // either way; not itself the thing this test pins.
+ Assert.Equal(0.5f, handle.Opacity);
+
+ controller.SetActiveOpacity(0.7f);
+
+ // A stray post-unregister focus-gain notification (the kind a
+ // lingering external reference to the handle could still fire). If
+ // the controller were STILL subscribed, this would re-add the stale
+ // handle to _focused and apply the NEW active opacity (0.7).
+ handle.NotifyDescendantFocusChanged(child);
+
+ Assert.Equal(0.5f, handle.Opacity);
+ }
+
+ [Fact]
+ public void SetMutators_AfterDispose_AreNoOps()
+ {
+ UiRoot root = NewRoot();
+ (RetailWindowHandle handle, _) = RegisterWindow(root, "Chat");
+ var controller = new RetailWindowOpacityController(
+ root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
+
+ controller.Dispose();
+ controller.SetDefaultOpacity(0.9f);
+ controller.SetActiveOpacity(0.9f);
+ controller.SetOpacity(0.2f, 0.3f);
+
+ // None of the three post-Dispose calls changed anything — no
+ // ObjectDisposedException either, matching Dispose's own idempotent
+ // shape.
+ Assert.Equal(0.5f, controller.DefaultOpacity);
+ Assert.Equal(1.0f, controller.ActiveOpacity);
+ Assert.Equal(0.5f, handle.Opacity);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs b/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs
index 3d31cdc3..f152b8da 100644
--- a/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs
@@ -5,6 +5,7 @@ using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using DatReaderWriter.Types;
+using Xunit.Sdk;
namespace AcDream.App.Tests.UI;
@@ -62,6 +63,43 @@ public sealed class UiRenderContextAlphaTests
},
});
+ /// Same shape as but with a non-zero
+ /// background (outline) atlas, so 's
+ /// outline: true branch actually emits its background sprite pass.
+ private static UiDatFont BuildOutlinedFont() => new(
+ fgTex: 1, fgW: 64, fgH: 64,
+ bgTex: 2, bgW: 64, bgH: 64,
+ 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,
+ },
+ });
+
+ /// Bakes a real from a system TTF so
+ /// (the BitmapFont path, distinct
+ /// from the dat-font above) can
+ /// be exercised end-to-end. Skips rather than fails on a machine with none
+ /// of 's well-known
+ /// paths — matches the skip pattern other environment-dependent tests in
+ /// this suite already use (e.g. RetailSelectionAssetTests).
+ private static BitmapFont BuildBitmapFontOrSkip(IGpuDevice device)
+ {
+ byte[]? ttf = BitmapFont.TryLoadSystemMonospaceFont();
+ if (ttf is null)
+ throw SkipException.ForSkip("No system TTF font found for BitmapFont construction.");
+ return new BitmapFont(device, ttf, pixelHeight: 16f);
+ }
+
// -- DrawSprite: full-opacity identity ---------------------------------
[Fact]
@@ -172,4 +210,68 @@ public sealed class UiRenderContextAlphaTests
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(0.5f, seg.Alpha);
}
+
+ [Fact]
+ public void HalfOpacityWindow_MultipliesDatFontOutlineAndForegroundPassAlpha()
+ {
+ // CH6c review NIT: BuildFont() above has bgTex == 0, so every existing
+ // DrawStringDat alpha test only ever exercised the foreground (fill)
+ // sprite pass. The background (outline) pass is a SEPARATE
+ // DrawSpriteAbsolute(applyAlpha: true) call site — this pins it too,
+ // with a font whose background atlas is actually present (bgTex != 0)
+ // and outline: true so both passes fire.
+ (TextRenderer renderer, UiRenderContext ctx) = Build();
+ UiDatFont font = BuildOutlinedFont();
+
+ ctx.PushAlpha(0.5f);
+ ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f), outline: true);
+ ctx.PopAlpha();
+
+ // Background pass (texture 2) submitted first, then foreground (texture 1) —
+ // both routes guarded by the same ApplyAlpha chokepoint.
+ Assert.Equal(2, renderer.DebugSpriteSegments.Count);
+ foreach (var seg in renderer.DebugSpriteSegments)
+ Assert.Equal(0.5f, seg.Alpha);
+ }
+
+ // -- DrawString (BitmapFont path): the same alpha chokepoint, guarded ---
+
+ [Fact]
+ public void FullOpacity_DrawString_BitmapFontPath_MatchesRequestedAlpha_Identity()
+ {
+ // CH6c review NIT: DrawStringDat (retail dat-font glyphs) had its own
+ // alpha regression tests above; UiRenderContext.DrawString — the
+ // BitmapFont path used for D.6 world-space HUD text — had none. Both
+ // route through the SAME private ApplyAlpha, but nothing pinned it for
+ // this path specifically.
+ var device = new RecordingGpuDevice();
+ using BitmapFont font = BuildBitmapFontOrSkip(device);
+ var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
+ renderer.Begin(new Vector2(800f, 600f));
+ var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
+
+ ctx.DrawString("A", 0, 0, new Vector4(1f, 1f, 1f, 1f), font);
+
+ (int vertexCount, float alpha) = renderer.DebugTextBuffer;
+ Assert.True(vertexCount > 0);
+ Assert.Equal(1f, alpha);
+ }
+
+ [Fact]
+ public void HalfOpacityWindow_MultipliesBitmapFontGlyphAlpha()
+ {
+ var device = new RecordingGpuDevice();
+ using BitmapFont font = BuildBitmapFontOrSkip(device);
+ var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
+ renderer.Begin(new Vector2(800f, 600f));
+ var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
+
+ ctx.PushAlpha(0.5f);
+ ctx.DrawString("A", 0, 0, new Vector4(1f, 1f, 1f, 1f), font);
+ ctx.PopAlpha();
+
+ (int vertexCount, float alpha) = renderer.DebugTextBuffer;
+ Assert.True(vertexCount > 0);
+ Assert.Equal(0.5f, alpha);
+ }
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs
index 2d825f9b..53ab26b8 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/ChatSettingsTests.cs
@@ -28,9 +28,18 @@ public sealed class ChatSettingsTests
// 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);
+ // CH6c review fix (2026-08-10): shipping the base pair as acdream's
+ // ONE shared global default faded every registered window (radar,
+ // vitals, toolbar, main chat, ...) to 50% opacity out of the box,
+ // including several that can never take keyboard focus and so were
+ // permanently half-transparent. acdream now ships gmMainChatUI's own
+ // 1.0/1.0 override (0x004CD0F0) as the shared global default instead
+ // — retail-identical opaque presentation for the 11 non-chat windows
+ // and the main chat window; only the four floating chat windows
+ // diverge from retail's 0.5-while-idle fade, and the Settings → Chat
+ // transparency slider remains fully user-settable (register row
+ // AP-190).
+ Assert.Equal(1.0f, d.DefaultOpacity);
Assert.Equal(1.0f, d.ActiveOpacity);
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
index 2ed12948..60243e3a 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
@@ -523,7 +523,8 @@ public sealed class SettingsPanelTests
// 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).
+ // starting from ChatSettings.Default (DefaultOpacity=1.0, ActiveOpacity=1.0
+ // as of the CH6c review fix).
var (panel, vm, _, _) = Build();
var r = new FakePanelRenderer
{
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
index 2667aca6..8fba1869 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
@@ -333,12 +333,17 @@ public sealed class SettingsStoreTests : System.IDisposable
// -- Campaign CH slice CH6c: window opacity round-trip -----------------
[Fact]
- public void LoadChat_returns_retail_ChatInterface_opacity_defaults_when_file_is_missing()
+ public void LoadChat_returns_acdream_opaque_opacity_defaults_when_file_is_missing()
{
+ // CH6c review fix (2026-08-10): the pre-fix default was retail's base
+ // ChatInterface value (0.5/1.0), which faded every registered window
+ // to 50% opacity out of the box. acdream now ships gmMainChatUI's own
+ // 1.0/1.0 override (0x004CD0F0) as the shared global default — see
+ // ChatSettings.DefaultOpacity and register row AP-190.
var store = new SettingsStore(_tempPath);
ChatSettings loaded = store.LoadChat();
- Assert.Equal(0.5f, loaded.DefaultOpacity);
+ Assert.Equal(1.0f, loaded.DefaultOpacity);
Assert.Equal(1.0f, loaded.ActiveOpacity);
}