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:
parent
964af62e25
commit
cc58289967
14 changed files with 319 additions and 18 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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 1–4 | `22020ef2`, reworked `1aa77099` | 12,420 passed / 4 skipped / 0 failed | REJECT (docs/research/2026-08-10-ch6ab-review-findings.md) → reworked `1aa77099` — SHOULD-FIXES 2/3/4/5 + NITs 1-5 applied | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation, plus the new 0x2100005B fixture's resolved-type assumptions |
|
| CH6b floating windows 1–4 | `22020ef2`, reworked `1aa77099` | 12,420 passed / 4 skipped / 0 failed | REJECT (docs/research/2026-08-10-ch6ab-review-findings.md) → reworked `1aa77099` — SHOULD-FIXES 2/3/4/5 + NITs 1-5 applied | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation, plus the new 0x2100005B fixture's resolved-type assumptions |
|
||||||
| CH6c opacity | `a819687c` | 12,459 passed / 4 skipped / 0 failed | pending (no subagent review pass this session — implementer-only) | pending — needs the next connected round for visual confirmation (window fade on focus change, Settings slider live-apply) |
|
| CH6c opacity | `a819687c` | 12,459 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed this commit — BLOCKER (out-of-box `DefaultOpacity` 0.5→1.0), AP-190 reworded + two new decomp-verified clauses (retail's per-tick ease, retail's entry-field-specific focus predicate), NITs (`UiElement.cs` stale comment, `WindowUnregistered` detach, post-Dispose `Set*` guards, `DrawString`/outline-pass alpha tests) | pending — needs the next connected round for visual confirmation (window fade on focus change, Settings slider live-apply) |
|
||||||
|
|
||||||
### CH4 closeout (2026-08-09)
|
### CH4 closeout (2026-08-09)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue