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:
parent
ccab53d9a1
commit
a819687cf0
23 changed files with 1174 additions and 38 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
139
src/AcDream.App/UI/RetailWindowOpacityController.cs
Normal file
139
src/AcDream.App/UI/RetailWindowOpacityController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's linked default/active window-opacity invariant: active >= default,
|
||||
/// ALWAYS — enforced by dragging the OTHER value, never by clamping the one being
|
||||
/// set. Verbatim port of <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 < 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 < 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue