feat(chat): Campaign CH slice CH6c — window opacity + transparency setting

Retail's ChatInterface::SetOpacity (0x004F3120) fades the WHOLE composited
window surface with one alpha; UiRenderContext.ApplyAlpha already gated
DrawSprite/DrawRect/DrawFill (since 1da697ec, pre-CH6) but DrawStringDat and
DrawString still passed applyAlpha:false, so text stayed sharp over a
translucent window. Both now route through the same chokepoint.

RetailWindowOpacityController (new) subscribes to a new
RetailWindowManager.WindowRegistered event and drives every registered
window's live Opacity from keyboard-focus state, applied to EVERY window
(chat, floaties, vitals, toolbar, ...) rather than retail's ChatInterface-only
scope — register row AP-190, retiring the stale AP-40 "fixed 0.75, no focus
transition" row in the same commit.

Verified retail's shipped opacity defaults from the decomp (constructor
literals, no cdb needed): the base ChatInterface ctor sets
DefaultOpacity=0.5/ActiveOpacity=1.0, kept unmodified by the four floating
windows; gmMainChatUI's own ctor overrides the main window to 1.0/1.0
(always fully opaque). acdream ships one shared global default (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 ported verbatim as
ChatOpacityLink in AcDream.UI.Abstractions, shared by the live controller
and the new Settings -> Chat tab's two linked opacity sliders.

Persistence: ChatSettings.DefaultOpacity/ActiveOpacity round-trip through
SettingsStore; Save pushes both through IRuntimeSettingsTargets.SetChatOpacity
into the live controller, no restart required.

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). No subagents, no client launches (session hard constraints);
pending the next connected user gate for visual confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 14:00:55 +02:00
parent ccab53d9a1
commit a819687cf0
23 changed files with 1174 additions and 38 deletions

File diff suppressed because one or more lines are too long

View file

@ -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 14 | `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 14 (`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 AG's evidence: this commit's diff + the new/updated tests in
`tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs`,

View file

@ -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`.

View file

@ -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));

View file

@ -88,6 +88,33 @@ public sealed class TextRenderer : IDisposable
/// </summary>
internal long DynamicBufferCapacityBytes => 0;
/// <summary>
/// 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
/// <c>RecordingGpuDevice</c> test double. Campaign CH slice CH6c rider (CH6a/b
/// re-review): strengthens the grip-media regression guard past a bare
/// <c>SpriteFile != 0</c> check, which proves a sprite RESOLVED but not that
/// <see cref="DrawSprite"/> was ever called. <c>AcDream.App.Tests</c>-only via
/// <c>InternalsVisibleTo</c>.
/// </summary>
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

View file

@ -131,6 +131,15 @@ internal interface IRuntimeSettingsTargets
/// <c>CharacterOption</c> id (e.g. <c>ListenToGeneralChat = 0x23</c>).
/// </summary>
void SetSingleCharacterOption(uint optionId, bool value);
/// <summary>
/// Campaign CH slice CH6c (2026-08-10): pushes the Chat tab's two linked
/// transparency sliders into the live <c>RetailWindowOpacityController</c> —
/// local-only (no server round-trip, unlike <see cref="SetSingleCharacterOption"/>),
/// applies with no restart, matches retail's own
/// <c>UpdateFromPlayerModule</c> call order (default before active).
/// </summary>
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)

View file

@ -35,6 +35,15 @@ internal interface IRuntimeUiLockTarget
void Apply(bool locked);
}
/// <summary>
/// Campaign CH slice CH6c target seam for the Chat tab's transparency sliders,
/// mirroring <see cref="IRuntimeUiLockTarget"/>'s shape.
/// </summary>
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)
{
}
}
/// <summary>
/// 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<string> _log;
@ -216,6 +249,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
WorldRenderRangeState renderRange,
UiRoot? uiRoot,
ICommandBus commands,
RetailWindowOpacityController? chatOpacity = null,
Action<string>? 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<string>? log = null)
Action<string>? 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
/// </summary>
public void SetSingleCharacterOption(uint optionId, bool value) =>
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
public void SetChatOpacity(float defaultOpacity, float activeOpacity) =>
_chatOpacity.Apply(defaultOpacity, activeOpacity);
}

View file

@ -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;
/// <summary>
/// The live focus-driven window-opacity mechanism (Campaign CH slice CH6c).
/// <c>AcDream.App.Settings.RuntimeSettingsTargets</c> calls into this from the
/// Chat tab's Save button so the transparency sliders apply with no restart.
/// </summary>
public RetailWindowOpacityController WindowOpacity { get; }
/// <summary>
/// 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(),

View file

@ -33,6 +33,16 @@ public sealed class RetailWindowManager : IDisposable
public IReadOnlyCollection<RetailWindowHandle> Windows => _byName.Values;
public event Action<string, bool>? WindowVisibilityChanged;
/// <summary>
/// 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: <see cref="RetailWindowOpacityController"/> 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 <c>Mount*</c> call site needing to know about it.
/// </summary>
public event Action<RetailWindowHandle>? 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;
}

View file

@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI;
/// <summary>
/// Live per-window opacity, ported from retail's focus-driven chat-window fade —
/// <c>ChatInterface::SetOpacity/SetDefaultOpacity/SetActiveOpacity</c>
/// (<c>0x004F3120</c>/<c>0x004F3BC0</c>/<c>0x004F3C40</c>) and the two GLOBAL
/// <c>PlayerModule</c> options <c>Option_DefaultOpacity_Property</c>
/// (<c>0x10000080</c>) / <c>Option_ActiveOpacity_Property</c> (<c>0x10000081</c>)
/// (<c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §3).
///
/// <para>
/// Retail applies this ONLY to <c>ChatInterface</c>-derived windows (the main chat
/// window + the four floaties). acdream applies it to every window
/// <see cref="RetailWindowManager"/> 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 <c>m_chatEntry</c> specifically have
/// focus"; the generalization here is "does ANY descendant of this window have
/// keyboard focus", which <see cref="RetailWindowManager"/> already computes for
/// every window via <see cref="RetailWindowHandle.DescendantFocusChanged"/>.
/// </para>
///
/// <para>
/// Retail's shipped defaults differ PER WINDOW CLASS: <c>gmMainChatUI</c>'s own
/// constructor (<c>0x004CD0F0</c>) overrides its base <c>ChatInterface</c> ctor
/// (<c>0x004F4550</c>, DefaultOpacity=0.5/ActiveOpacity=1.0) to DefaultOpacity=1.0
/// (always fully opaque); <c>gmFloatyChatUI::Create</c> (<c>0x004CE2C0</c>) calls
/// <c>ChatInterface::ChatInterface</c> 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.
/// </para>
/// </summary>
public sealed class RetailWindowOpacityController : IDisposable
{
private readonly RetailWindowManager _manager;
private readonly HashSet<RetailWindowHandle> _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; }
/// <summary>
/// Port of <c>ChatInterface::SetDefaultOpacity @0x004F3BC0</c>. 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.
/// </summary>
public void SetDefaultOpacity(float value)
{
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, value);
ReapplyAll();
}
/// <summary>
/// Port of <c>ChatInterface::SetActiveOpacity @0x004F3C40</c>. Lowering the
/// active value below the current default drags default DOWN to match.
/// </summary>
public void SetActiveOpacity(float value)
{
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, value);
ReapplyAll();
}
/// <summary>
/// Set both values in retail's own <c>UpdateFromPlayerModule</c> order
/// (<c>0x004CE3F0</c>: Default read/applied first, then Active) — the shape
/// used to push a freshly loaded/persisted <see cref="ChatSettings"/> pair in
/// one call instead of two separate reapply passes.
/// </summary>
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;
}
}

View file

@ -45,9 +45,12 @@ public sealed class UiRenderContext
private readonly System.Collections.Generic.List<UiClipRect?> _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<float> _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);
}
/// <summary>
@ -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);

View file

@ -0,0 +1,52 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Retail's linked default/active window-opacity invariant: active &gt;= default,
/// ALWAYS — enforced by dragging the OTHER value, never by clamping the one being
/// set. Verbatim port of <c>ChatInterface::SetDefaultOpacity @0x004F3BC0</c> /
/// <c>SetActiveOpacity @0x004F3C40</c> (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 <c>DualHash</c> link
/// (<c>gmChatOptionsUI::InitOptions @0x0049FC60</c>) — this is why they visibly track
/// each other while dragging.
///
/// <para>
/// Shared by two independent consumers so both apply the identical link math:
/// <c>RetailWindowOpacityController</c> (AcDream.App — the live per-window
/// mechanism, mutating its own two float fields) and <see cref="SettingsPanel"/>'s
/// Chat tab (the draft slider UI, mutating a <see cref="ChatSettings"/> record).
/// Pure functions — no window/render/state dependency — so both layers can call
/// them without violating the App→Abstractions dependency direction.
/// </para>
/// </summary>
public static class ChatOpacityLink
{
/// <summary>
/// Port of <c>ChatInterface::SetDefaultOpacity @0x004F3BC0</c>: set the default
/// (unfocused) opacity to <paramref name="newDefault"/>, dragging
/// <paramref name="currentActive"/> UP if it would otherwise fall below the new
/// default. Never returns an (default, active) pair with active &lt; default.
/// </summary>
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);
}
/// <summary>
/// Port of <c>ChatInterface::SetActiveOpacity @0x004F3C40</c>: set the active
/// (focused) opacity to <paramref name="newActive"/>, dragging
/// <paramref name="currentDefault"/> DOWN if it would otherwise exceed the new
/// active value. Never returns an (default, active) pair with active &lt; default.
/// </summary>
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);
}
}

View file

@ -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)
{
/// <summary>
/// N4 (CH3 Opus review): matches ACE's ACTUAL

View file

@ -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.");
}
/// <summary>

View file

@ -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<string, object> 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,

View file

@ -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

View file

@ -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<UiResizeGrip>(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()
{

View file

@ -0,0 +1,188 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign CH slice CH6c: <see cref="RetailWindowOpacityController"/> — the live
/// per-window opacity mechanism ported from <c>ChatInterface::SetOpacity/
/// SetDefaultOpacity/SetActiveOpacity</c> (<c>0x004F3120</c>/<c>0x004F3BC0</c>/
/// <c>0x004F3C40</c>), extended to every <see cref="RetailWindowManager"/>-registered
/// window rather than retail's ChatInterface-only scope (register row AP-190).
/// </summary>
public sealed class RetailWindowOpacityControllerTests
{
private static UiRoot NewRoot() => new() { Width = 800f, Height = 600f };
/// <summary>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.</summary>
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);
}
}

View file

@ -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;
/// <summary>
/// Campaign CH slice CH6c: pins the window-opacity ALPHA CHOKEPOINT —
/// <see cref="UiRenderContext"/>'s private <c>ApplyAlpha</c>, reached by every
/// public draw call (<see cref="UiRenderContext.DrawSprite"/>,
/// <see cref="UiRenderContext.DrawRect"/>/<see cref="UiRenderContext.DrawFill"/>,
/// and — new this slice — <see cref="UiRenderContext.DrawStringDat"/> and
/// <see cref="UiRenderContext.DrawString"/>). Retail's <c>ChatInterface::SetOpacity
/// @0x004F3120</c> sets ONE alpha on the whole composited window surface, chrome
/// AND text together — before this slice, <c>DrawStringDat</c> passed
/// <c>applyAlpha: false</c> so glyphs stayed opaque over a translucent window.
///
/// <para>
/// Builds a real <see cref="TextRenderer"/> over the in-memory
/// <see cref="RecordingGpuDevice"/> test double (no live GPU, no shader
/// compile — <c>RecordingGpuDevice.CreatePipeline</c> just wraps the
/// description) so <c>TextRenderer.DebugSpriteSegments</c> can be read back
/// directly instead of asserting through a GPU flush.
/// </para>
/// </summary>
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<char, FontCharDesc>
{
['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);
}
}

View file

@ -0,0 +1,87 @@
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// Campaign CH slice CH6c: pins <see cref="ChatOpacityLink"/>'s port of retail's
/// linked default/active opacity invariant (<c>ChatInterface::SetDefaultOpacity
/// @0x004F3BC0</c> / <c>SetActiveOpacity @0x004F3C40</c>) — active &gt;= default,
/// ALWAYS, enforced by dragging the OTHER value rather than clamping the one
/// being set.
/// </summary>
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);
}
}

View file

@ -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]

View file

@ -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()
{

View file

@ -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()
{