fix(chat): CH6c review fixes — opaque default, opacity-transition register clauses

BLOCKER: ChatSettings.DefaultOpacity shipped retail's base ChatInterface
value (0.5) as ONE shared global default applied to every
RetailWindowManager-registered window, not just the four floating chat
windows retail itself fades. That faded the whole out-of-box registered
UI (radar, vitals, toolbar, main chat, ...) to 50% opacity, including
several windows that can never take keyboard focus and so were stuck at
0.5 permanently. Fixed to gmMainChatUI's 1.0/1.0 override
(0x004CD0F0) instead — retail-identical opaque presentation for the 11
non-chat windows and the main chat window; only the four floating chat
windows now diverge from retail's 0.5-while-idle default, and the
Settings -> Chat transparency slider remains fully user-settable.

AP-190 reworded and gains two new decomp-verified clauses: (3) retail
eases opacity toward its target by 5% of the delta per tick
(ChatInterface::ListenToGlobalMessage @0x004F3840, armed from the focus
element-messages at @0x004F5275) where acdream snaps -- deferred, needs
a UI frame-tick hook the opacity controller doesn't have; (4) retail's
focus predicate is the chat ENTRY FIELD specifically
(ChatInterface::IsTextEntryFocused @0x004F30A0) where acdream uses
any-focusable-descendant. Both findings + the pre-existing UiMenu.cs
PushAlphaAbsolute(1f) popup bypass are folded into the window-shell
research doc's opacity section.

NITs: fixed the stale "text bypasses the alpha" comment in
UiElement.DrawSelfAndChildren (CH6c already routed DrawStringDat/
DrawString through the same ApplyAlpha chokepoint as sprites/rects);
added RetailWindowManager.WindowUnregistered + wired
RetailWindowOpacityController to detach and forget a window unregistered
while it held focus (previously only Dispose detached, leaking any
window unregistered mid-focus for the rest of the session); added
post-Dispose no-op guards to the three Set* opacity mutators; added a
DrawString (BitmapFont path) alpha regression test and a DrawStringDat
outline/background-pass alpha test (the existing tests only ever
exercised the foreground/fill pass).

Also fixes RuntimeSettingsControllerTests.SettingsViewModelSavePreserves
SectionAndTargetOrder's now-stale "target-chat-opacity:0.5:1" expectation
(caught by the full-suite run this fix requires) to match the new 1.0
default.

Campaign ledger CH6c row updated to APPROVE-WITH-FIXES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 14:45:14 +02:00
parent 964af62e25
commit cc58289967
14 changed files with 319 additions and 18 deletions

File diff suppressed because one or more lines are too long

View file

@ -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 | | 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) | | 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 | | 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 | `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) ### CH4 closeout (2026-08-09)

View file

@ -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 through, and even then only until the user's first slider drag pushes one
shared value into every live window via `RecvNotice_GameplayOptionChanged`. shared value into every live window via `RecvNotice_GameplayOptionChanged`.
acdream ships ONE shared global default — the base `ChatInterface` value, **CH6c review-fix round (2026-08-10): the shared default above was WRONG.**
0.5/1.0 — applied uniformly to every window including the main chat window Shipping the base `ChatInterface` value (0.5/1.0) as ONE shared global
(register row AP-190 in `docs/architecture/retail-divergence-register.md`), default, combined with the scope extension to every registered window, faded
rather than replicating `gmMainChatUI`'s per-class 1.0/1.0 override. The 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 — linking invariant (active >= default, restored by dragging the OTHER value —
verified from `SetDefaultOpacity`/`SetActiveOpacity`'s own bodies, matching verified from `SetDefaultOpacity`/`SetActiveOpacity`'s own bodies, matching
the summary already recorded above) is ported exactly regardless of which the summary already recorded above) is ported exactly regardless of which
default seeds it. 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 ## 4. Persistence — how filters, geometry, visibility and title survive

View file

@ -115,6 +115,21 @@ public sealed class TextRenderer : IDisposable
} }
} }
/// <summary>
/// Test-only snapshot of the current frame's queued NORMAL-layer BITMAP FONT
/// text buffer (<see cref="DrawString"/>/<see cref="DrawStringClipped"/>,
/// the <see cref="AcDream.App.UI.UiRenderContext.DrawString"/> 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
/// <see cref="DebugSpriteSegments"/> 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
/// <see cref="UiRenderContext.DrawString"/>'s alpha chokepoint is guarded
/// the same way <see cref="DrawStringDat"/>'s already was.
/// </summary>
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 // 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 // buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after // rect panel backgrounds (which otherwise always win because rects flush after

View file

@ -43,6 +43,17 @@ public sealed class RetailWindowManager : IDisposable
/// </summary> /// </summary>
public event Action<RetailWindowHandle>? WindowRegistered; public event Action<RetailWindowHandle>? WindowRegistered;
/// <summary>
/// Fires when a window is REMOVED from the registry (<see cref="Unregister"/>),
/// after teardown (<c>NotifyClosed</c>/<c>DisposeController</c>) but before the
/// handle is discarded. CH6c review NIT: <see cref="RetailWindowOpacityController"/>
/// subscribes here so it can drop its per-handle <c>DescendantFocusChanged</c>
/// 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.
/// </summary>
public event Action<RetailWindowHandle>? WindowUnregistered;
public RetailWindowHandle Register( public RetailWindowHandle Register(
string name, string name,
UiElement outerFrame, UiElement outerFrame,
@ -215,6 +226,7 @@ public sealed class RetailWindowManager : IDisposable
_defaultInputs.Remove(handle); _defaultInputs.Remove(handle);
handle.NotifyClosed(); handle.NotifyClosed();
handle.DisposeController(); handle.DisposeController();
WindowUnregistered?.Invoke(handle);
return true; return true;
} }

View file

@ -29,9 +29,24 @@ namespace AcDream.App.UI;
/// (<c>0x004F4550</c>, DefaultOpacity=0.5/ActiveOpacity=1.0) to DefaultOpacity=1.0 /// (<c>0x004F4550</c>, DefaultOpacity=0.5/ActiveOpacity=1.0) to DefaultOpacity=1.0
/// (always fully opaque); <c>gmFloatyChatUI::Create</c> (<c>0x004CE2C0</c>) calls /// (always fully opaque); <c>gmFloatyChatUI::Create</c> (<c>0x004CE2C0</c>) calls
/// <c>ChatInterface::ChatInterface</c> directly with no override, so the four /// <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 /// floating windows keep the base 0.5/1.0. <b>CH6c review fix:</b> acdream ships ONE
/// base ChatInterface value, 0.5/1.0) applied uniformly, including to the main /// shared default — <c>gmMainChatUI</c>'s 1.0/1.0 override, not the base
/// chat window — a simplification recorded alongside the scope extension above. /// 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).
/// </para>
///
/// <para>
/// Retail also EASES the live opacity toward its target by 5% of the delta per
/// tick (<c>ChatInterface::ListenToGlobalMessage @0x004F3840</c>) rather than
/// snapping, and its focus predicate is the chat ENTRY FIELD specifically
/// (<c>ChatInterface::IsTextEntryFocused @0x004F30A0</c>), not "any descendant has
/// focus". Both are recorded as AP-190 residuals — CH6c review, not yet ported.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class RetailWindowOpacityController : IDisposable public sealed class RetailWindowOpacityController : IDisposable
@ -55,6 +70,7 @@ public sealed class RetailWindowOpacityController : IDisposable
System.Math.Clamp(activeOpacity, 0f, 1f)); System.Math.Clamp(activeOpacity, 0f, 1f));
_manager.WindowRegistered += OnWindowRegistered; _manager.WindowRegistered += OnWindowRegistered;
_manager.WindowUnregistered += OnWindowUnregistered;
foreach (RetailWindowHandle handle in _manager.Windows) foreach (RetailWindowHandle handle in _manager.Windows)
Attach(handle); Attach(handle);
} }
@ -72,6 +88,7 @@ public sealed class RetailWindowOpacityController : IDisposable
/// </summary> /// </summary>
public void SetDefaultOpacity(float value) public void SetDefaultOpacity(float value)
{ {
if (_disposed) return;
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, value); (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, value);
ReapplyAll(); ReapplyAll();
} }
@ -82,6 +99,7 @@ public sealed class RetailWindowOpacityController : IDisposable
/// </summary> /// </summary>
public void SetActiveOpacity(float value) public void SetActiveOpacity(float value)
{ {
if (_disposed) return;
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, value); (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, value);
ReapplyAll(); ReapplyAll();
} }
@ -94,6 +112,7 @@ public sealed class RetailWindowOpacityController : IDisposable
/// </summary> /// </summary>
public void SetOpacity(float defaultOpacity, float activeOpacity) public void SetOpacity(float defaultOpacity, float activeOpacity)
{ {
if (_disposed) return;
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, defaultOpacity); (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetDefault(ActiveOpacity, defaultOpacity);
(DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, activeOpacity); (DefaultOpacity, ActiveOpacity) = ChatOpacityLink.SetActive(DefaultOpacity, activeOpacity);
ReapplyAll(); ReapplyAll();
@ -101,6 +120,19 @@ public sealed class RetailWindowOpacityController : IDisposable
private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle); private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle);
/// <summary>
/// CH6c review NIT: without this, a window unregistered while it held
/// keyboard focus stayed in <see cref="_focused"/> — and its
/// <see cref="RetailWindowHandle.DescendantFocusChanged"/> subscription
/// stayed live — for the rest of the session, because the only prior
/// detach point was <see cref="Dispose"/>.
/// </summary>
private void OnWindowUnregistered(RetailWindowHandle handle)
{
handle.DescendantFocusChanged -= OnDescendantFocusChanged;
_focused.Remove(handle);
}
private void Attach(RetailWindowHandle handle) private void Attach(RetailWindowHandle handle)
{ {
handle.DescendantFocusChanged += OnDescendantFocusChanged; handle.DescendantFocusChanged += OnDescendantFocusChanged;
@ -133,7 +165,9 @@ public sealed class RetailWindowOpacityController : IDisposable
_disposed = true; _disposed = true;
_manager.WindowRegistered -= OnWindowRegistered; _manager.WindowRegistered -= OnWindowRegistered;
_manager.WindowUnregistered -= OnWindowUnregistered;
foreach (RetailWindowHandle handle in _manager.Windows) foreach (RetailWindowHandle handle in _manager.Windows)
handle.DescendantFocusChanged -= OnDescendantFocusChanged; handle.DescendantFocusChanged -= OnDescendantFocusChanged;
_focused.Clear();
} }
} }

View file

@ -460,7 +460,10 @@ public abstract class UiElement
if (!Visible) return; if (!Visible) return;
// Translate into our local space + push this window's opacity (multiplies into // 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.PushTransform(Left, Top);
ctx.PushAlpha(Opacity); ctx.PushAlpha(Opacity);
try try

View file

@ -70,7 +70,20 @@ public sealed record ChatSettings(
// ChatOpacityLink at every setter, not by clamping here. acdream applies this // ChatOpacityLink at every setter, not by clamping here. acdream applies this
// GLOBALLY to every RetailWindowManager-registered window (register row // GLOBALLY to every RetailWindowManager-registered window (register row
// AP-190), where retail scopes it to ChatInterface-derived windows only. // 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) float ActiveOpacity = 1.0f)
{ {
/// <summary> /// <summary>

View file

@ -312,7 +312,7 @@ public sealed class RuntimeSettingsControllerTests
"save-gameplay", "save-gameplay",
"target-ui-lock:True", "target-ui-lock:True",
"save-chat", "save-chat",
"target-chat-opacity:0.5:1", "target-chat-opacity:1:1",
"save-character:default", "save-character:default",
], ],
events); events);

View file

@ -185,4 +185,63 @@ public sealed class RetailWindowOpacityControllerTests
// focus change after Dispose is not observed anymore. // focus change after Dispose is not observed anymore.
Assert.Equal(0.5f, handle.Opacity); 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);
}
} }

View file

@ -5,6 +5,7 @@ using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI; using AcDream.App.UI;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using Xunit.Sdk;
namespace AcDream.App.Tests.UI; namespace AcDream.App.Tests.UI;
@ -62,6 +63,43 @@ public sealed class UiRenderContextAlphaTests
}, },
}); });
/// <summary>Same shape as <see cref="BuildFont"/> but with a non-zero
/// background (outline) atlas, so <see cref="UiRenderContext.DrawStringDat"/>'s
/// <c>outline: true</c> branch actually emits its background sprite pass.</summary>
private static UiDatFont BuildOutlinedFont() => new(
fgTex: 1, fgW: 64, fgH: 64,
bgTex: 2, bgW: 64, bgH: 64,
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,
},
});
/// <summary>Bakes a real <see cref="BitmapFont"/> from a system TTF so
/// <see cref="UiRenderContext.DrawString"/> (the BitmapFont path, distinct
/// from the dat-font <see cref="UiRenderContext.DrawStringDat"/> above) can
/// be exercised end-to-end. Skips rather than fails on a machine with none
/// of <see cref="BitmapFont.TryLoadSystemMonospaceFont"/>'s well-known
/// paths — matches the skip pattern other environment-dependent tests in
/// this suite already use (e.g. <c>RetailSelectionAssetTests</c>).</summary>
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 --------------------------------- // -- DrawSprite: full-opacity identity ---------------------------------
[Fact] [Fact]
@ -172,4 +210,68 @@ public sealed class UiRenderContextAlphaTests
var seg = Assert.Single(renderer.DebugSpriteSegments); var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(0.5f, seg.Alpha); 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);
}
} }

View file

@ -28,9 +28,18 @@ public sealed class ChatSettingsTests
// Campaign CH slice CH6c: retail's base ChatInterface constructor // Campaign CH slice CH6c: retail's base ChatInterface constructor
// (0x004F4550) sets DefaultOpacity=0.5/ActiveOpacity=1.0 — the value // (0x004F4550) sets DefaultOpacity=0.5/ActiveOpacity=1.0 — the value
// every gmFloatyChatUI (the four floating windows) keeps unmodified. // every gmFloatyChatUI (the four floating windows) keeps unmodified.
// acdream ships that pair as ONE shared global default (register row // CH6c review fix (2026-08-10): shipping the base pair as acdream's
// AP-190), rather than gmMainChatUI's own 1.0/1.0 override. // ONE shared global default faded every registered window (radar,
Assert.Equal(0.5f, d.DefaultOpacity); // 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); Assert.Equal(1.0f, d.ActiveOpacity);
} }

View file

@ -523,7 +523,8 @@ public sealed class SettingsPanelTests
// LAST-rendered opacity slider ("Active", rendered after "Background") // LAST-rendered opacity slider ("Active", rendered after "Background")
// determines the final draft. That exercises ChatOpacityLink.SetActive's // determines the final draft. That exercises ChatOpacityLink.SetActive's
// drag-background-down path end-to-end through the real panel code, // 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 (panel, vm, _, _) = Build();
var r = new FakePanelRenderer var r = new FakePanelRenderer
{ {

View file

@ -333,12 +333,17 @@ public sealed class SettingsStoreTests : System.IDisposable
// -- Campaign CH slice CH6c: window opacity round-trip ----------------- // -- Campaign CH slice CH6c: window opacity round-trip -----------------
[Fact] [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); var store = new SettingsStore(_tempPath);
ChatSettings loaded = store.LoadChat(); ChatSettings loaded = store.LoadChat();
Assert.Equal(0.5f, loaded.DefaultOpacity); Assert.Equal(1.0f, loaded.DefaultOpacity);
Assert.Equal(1.0f, loaded.ActiveOpacity); Assert.Equal(1.0f, loaded.ActiveOpacity);
} }