diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 36220375..4f8b7d92 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -51,14 +51,42 @@ we fail to draw (preferred: draw the authored one) before synthesizing.
## #380 — Chat tab: the two opacity sliders are missing their retail row captions
-**Status:** OPEN — filed 2026-08-11 at Campaign OP gate 4 (user report:
-"I also miss the text next to the bars for Inactive and Active Opacity").
-acdream's General Chat Options section renders the two sliders with only
-the Transparent/Opaque endpoint labels; retail also captions each slider
-row (inactive vs active opacity). Resolve the captions from the authored
-layout/DAT strings (never invented English) — check whether the authored
-elements exist and fail to resolve (the #375 resolver class) or are
-missing from our template handling.
+**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's
+re-gate. Filed 2026-08-11 at Campaign OP gate 4 (user report: "I also
+miss the text next to the bars for Inactive and Active Opacity").
+
+**ROOT CAUSE — a DAT-resident runtime catalog, not a compiled symbol,
+that nothing ever queried.** `PlayerOptionPage::AddSliderOption` never
+sets a row's name-label text (byte-verified — no `StringInfo` write in
+its pseudo-C body); the caption comes from a SEPARATE mechanism,
+`UIOption_Slider::SetGameplayOptionProperty @0x00485030`'s own
+`UIOption::InqGameplayOptionNameAndTooltip @0x004ef750` catalog lookup —
+a SECOND `DBCache::GetDIDFromEnumStatic` sub-map lookup
+(`(0x15, 2)`, sibling to the ALREADY-PORTED `(0x16, 2)` defaults lookup)
+resolving to DID `0x78000000` (confirmed a DIFFERENT object from the
+defaults catalog's `0x78000001`), a `DBProperties` with one `ArrayBase-
+Property` of per-`GameplayOptionProperty` entries (name/tooltip
+`StringInfo` + the owning property id). Live-DAT-read: the array has
+exactly two entries, resolving via string table `0x2300000D` (the SAME
+table #372 already established as this campaign's runtime-string home)
+to "Inactive Opacity" / "Active Opacity" — the user's own two words.
+**Fix:** `ChatOptionsDatCaptions.TryRead` ports the lookup (mirroring the
+existing `ChatOptionsDatDefaults` shape); `ChatOptionsPageController`
+wires the resolved captions onto element `0x1000021B` (the row's own
+name-label child, present on BOTH slider templates but never referenced
+by this controller before) and the tooltips onto each slider. Regressed
+by `tests/AcDream.App.Tests/UI/Layout/ChatOptionsPageControllerTests.cs`
+(`Bind_WiresEachSlidersOwnRowCaption_FromTheResolvedDatCatalog`,
+`Bind_MissingCaption_RendersNoText_NeverInventsEnglish`) and a new live-
+mount probe (`ProbeChatOpacityCaptions` in
+`OptionsPanelLiveMountProbeTests.cs`) that exercises the production
+`ChatOptionsDatCaptions.TryRead` against the real DAT and asserts the
+exact two strings.
+
+**Re-gate (§OP5, opacity sliders section): both slider rows should now
+show their own caption ("Inactive Opacity" / "Active Opacity") next to
+the bar, not just the Transparent/Opaque endpoint labels on the second
+slider.**
## #379 — Chat-window opacity applies to ALL retained windows/panels, not only the chat windows
diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md
index 9403b3a7..f15e24da 100644
--- a/docs/research/2026-08-11-campaign-op-test-script.md
+++ b/docs/research/2026-08-11-campaign-op-test-script.md
@@ -427,6 +427,12 @@ chat windows already read when deciding which lines to show.
### Opacity sliders — live drag, linked, never clamping
+**Gate-4 re-test note (#380):** both sliders previously showed ONLY the
+Transparent/Opaque endpoint labels on the second (Active) slider, with NO
+row caption identifying which slider was which. Each slider row now shows
+its own DAT-resolved caption — "Inactive Opacity" next to the first slider,
+"Active Opacity" next to the second — report if either caption is missing
+or shows the wrong text.
**Gate-4 re-test note (#379) — step 4 below is REWRITTEN:** the sliders
previously faded EVERY registered window (vitals, toolbar, inventory,
diff --git a/src/AcDream.App/UI/Layout/ChatOptionsDatCaptions.cs b/src/AcDream.App/UI/Layout/ChatOptionsDatCaptions.cs
new file mode 100644
index 00000000..a1ca5912
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/ChatOptionsDatCaptions.cs
@@ -0,0 +1,129 @@
+using AcDream.Content;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// #380 (Campaign OP gate 4, 2026-08-11): reads the Chat tab's two opacity-slider
+/// row CAPTIONS ("Inactive Opacity" / "Active Opacity") + tooltips from the
+/// installed DAT — retail UIOption::InqGameplayOptionNameAndTooltip
+/// @0x004ef750's own catalog lookup, the mechanism
+/// UIOption_Slider::SetGameplayOptionProperty @0x00485030 calls for
+/// EVERY UIOption_Slider (both Chat opacity rows go through this path —
+/// see gmChatOptionsUI::InitOptions's own SetGameplayOptionProperty
+/// calls at 0x0049fcc0/0x0049fd1a). PlayerOptionPage::AddSliderOption
+/// itself never sets a row caption (byte-verified — no StringInfo write in
+/// its pseudo-C body), so the row's name-label child (element 0x1000021B,
+/// shared by BOTH the unlabelled and range-captioned slider templates) is never
+/// populated by AddSliderOption, nor is it baked statically into the
+/// LayoutDesc (the SAME shared template element is reused for every slider on
+/// every tab, so it cannot carry a fixed caption) —
+/// never wired ANY source for it, which is #380's root cause.
+///
+///
+/// Byte-verified recipe (a SECOND DBCache::GetDIDFromEnumStatic
+/// sub-map lookup, sibling to 's own
+/// (0x16, 2) defaults catalog): InqGameplayOptionNameAndTooltip's own
+/// call site is GetDIDFromEnumStatic(&ret, 0x15, 2) — SAME master-map
+/// key (2) as the defaults lookup, DIFFERENT sub-map key (0x15 vs
+/// 0x16) — so 's own
+/// (enumValue, enumCategory) order is (0x15, 2). Empirically
+/// confirmed against the installed DATs: resolves to DID 0x78000000 (a
+/// DIFFERENT object than the defaults catalog's 0x78000001 — the two
+/// enum values genuinely name separate DAT objects, not two views of one). That
+/// DBProperties has ONE top-level entry, property 0xD2, an
+/// ArrayBaseProperty of StructBaseProperty entries — one per
+/// registered GameplayOptionProperty in the whole game (not opacity-
+/// specific). Each entry carries: 0xD4 = name StringInfo,
+/// 0xD5 = tooltip StringInfo, 0xD6 = the
+/// GameplayOptionProperty id this entry describes (an
+/// EnumBaseProperty), 0xD7/0xD8 = unrelated floats. The
+/// live array has exactly two entries, matching
+/// (0xD6
+/// = 0x10000080) and ActiveOpacityPropertyId (0xD6 =
+/// 0x10000081) — resolving via table 0x2300000D (the SAME
+/// TextFilter-family table #372 already established as this campaign's
+/// runtime-string home, not the compiled-symbol table 0x23000003 the
+/// section headers use) to "Inactive Opacity" /
+/// "Active Opacity" — exactly the two labels the user's gate-4 report
+/// named ("I also miss the text next to the bars for Inactive and Active
+/// Opacity").
+///
+///
+public static class ChatOptionsDatCaptions
+{
+ /// DAT enum-category key for the FIRST (master-map) lookup — see
+ /// the class doc's byte-verified recipe (shared with
+ /// 's own defaults lookup).
+ private const uint EnumCategory = 2u;
+
+ /// DAT enum-value key for the SECOND (sub-map) lookup — the
+ /// name/tooltip catalog, NOT 's
+ /// 0x16 defaults catalog.
+ private const uint EnumValue = 0x15u;
+
+ private const uint CatalogArrayPropertyId = 0xD2u;
+ private const uint EntryNamePropertyId = 0xD4u;
+ private const uint EntryTooltipPropertyId = 0xD5u;
+ private const uint EntryOwningPropertyId = 0xD6u;
+
+ /// One resolved name/tooltip pair. Either half may be
+ /// if its own StringInfo failed to resolve —
+ /// the caller renders no text rather than inventing English (matching
+ /// this codebase's uniform degrade discipline).
+ public readonly record struct Caption(string? Name, string? Tooltip);
+
+ ///
+ /// Resolves the Default/Active opacity sliders' row captions + tooltips
+ /// from the live DAT. Returns only when the
+ /// catalog DID itself does not resolve or is not a DBProperties —
+ /// a missing INDIVIDUAL entry still returns with
+ /// that pair's left at its default (both null).
+ ///
+ public static bool TryRead(
+ IDatReaderWriter dats,
+ DatStringResolver strings,
+ out Caption defaultOpacity,
+ out Caption activeOpacity)
+ {
+ defaultOpacity = default;
+ activeOpacity = default;
+
+ uint did = RetailDataIdResolver.Resolve(dats, EnumValue, EnumCategory);
+ if (did == 0u || !dats.Portal.TryGet(did, out DBProperties? props) || props is null)
+ return false;
+
+ if (!props.Properties.TryGetValue(CatalogArrayPropertyId, out BaseProperty? arrayProp)
+ || arrayProp is not ArrayBaseProperty array)
+ return false;
+
+ foreach (BaseProperty entry in array.Value)
+ {
+ if (entry is not StructBaseProperty entryStruct) continue;
+ if (!entryStruct.Value.TryGetValue(EntryOwningPropertyId, out BaseProperty? owningProp)
+ || owningProp is not EnumBaseProperty owningEnum)
+ continue;
+
+ if (owningEnum.Value == ChatOptionsDatDefaults.DefaultOpacityPropertyId)
+ defaultOpacity = ReadCaption(entryStruct, strings);
+ else if (owningEnum.Value == ChatOptionsDatDefaults.ActiveOpacityPropertyId)
+ activeOpacity = ReadCaption(entryStruct, strings);
+ }
+
+ return true;
+ }
+
+ private static Caption ReadCaption(StructBaseProperty entry, DatStringResolver strings)
+ {
+ string? name = entry.Value.TryGetValue(EntryNamePropertyId, out BaseProperty? nameProp)
+ && nameProp is StringInfoBaseProperty nameInfo
+ ? strings.Resolve(nameInfo.Value.TableId.DataId, nameInfo.Value.StringId, nameInfo.Value.Token)
+ : null;
+ string? tooltip = entry.Value.TryGetValue(EntryTooltipPropertyId, out BaseProperty? tooltipProp)
+ && tooltipProp is StringInfoBaseProperty tooltipInfo
+ ? strings.Resolve(tooltipInfo.Value.TableId.DataId, tooltipInfo.Value.StringId, tooltipInfo.Value.Token)
+ : null;
+ return new Caption(name, tooltip);
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs b/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs
index 81c09296..74ec5da2 100644
--- a/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs
+++ b/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs
@@ -107,6 +107,25 @@ public static class ChatOptionsPageController
/// The slider leaf inside either slider row template's subtree.
private const uint SliderElementId = 0x1000021Cu;
+ ///
+ /// #380 (2026-08-11, gate 4): the slider row's own NAME-CAPTION text
+ /// child ("Inactive Opacity" / "Active Opacity") — present on BOTH
+ /// template idx3 and idx6 (research doc §1.5's template array: "label
+ /// 0x1000021B + slider 0x1000021C", the SAME id
+ /// already cites for its own
+ /// slider rows sharing this template pool). Retail's
+ /// PlayerOptionPage::AddSliderOption never sets this element's
+ /// text itself (byte-verified — no StringInfo write in its
+ /// pseudo-C body); the caption comes from
+ /// UIOption_Slider::SetGameplayOptionProperty's own
+ /// InqGameplayOptionNameAndTooltip catalog lookup instead — see
+ /// 's own doc for the full byte
+ /// trace. Nothing wired this element at all before #380 — the user's
+ /// literal complaint ("I also miss the text next to the bars for
+ /// Inactive and Active Opacity").
+ ///
+ private const uint SliderLabelElementId = 0x1000021Bu;
+
/// Labelled slider template's low/high range-caption children
/// (research doc §1.5's template array; present only on template idx 6).
private const uint SliderRangeMinElementId = 0x1000021Eu;
@@ -199,7 +218,17 @@ public static class ChatOptionsPageController
float DefaultOpacityDatDefault,
float ActiveOpacityDatDefault,
Func CurrentFilter,
- Action SetFilter);
+ Action SetFilter,
+ // #380: the two sliders' row captions/tooltips, resolved ONCE by the
+ // caller (RetailUiRuntime) via ChatOptionsDatCaptions — the SAME
+ // "resolve once, thread the pre-resolved value through Bindings"
+ // shape DefaultOpacityDatDefault/ActiveOpacityDatDefault already
+ // established for the value half of this exact DAT lookup family.
+ // Null when the DAT read failed or that entry's own StringInfo did
+ // not resolve — the row then renders with NO caption rather than
+ // invented English, matching this codebase's uniform degrade rule.
+ ChatOptionsDatCaptions.Caption DefaultOpacityCaption,
+ ChatOptionsDatCaptions.Caption ActiveOpacityCaption);
///
/// Builds the General Options header + 2 sliders + 5 filter sections (header +
@@ -367,6 +396,16 @@ public static class ChatOptionsPageController
SetRangeLabel(row2, SliderRangeMaxElementId, "ID_UI_Value_Opaque", resolveString);
}
+ // #380: the row-name captions ("Inactive Opacity" / "Active
+ // Opacity") + tooltips — resolved once by the caller via
+ // ChatOptionsDatCaptions, threaded through Bindings exactly like the
+ // sibling DefaultOpacityDatDefault/ActiveOpacityDatDefault values.
+ // Element 0x1000021B is shared by BOTH templates (see its own doc).
+ if (row1 is not null)
+ SetOpacityCaption(row1, bindings.DefaultOpacityCaption, slider1);
+ if (row2 is not null)
+ SetOpacityCaption(row2, bindings.ActiveOpacityCaption, slider2);
+
if (slider1 is null || slider2 is null)
return;
@@ -431,6 +470,38 @@ public static class ChatOptionsPageController
text.LinesProvider = () => new[] { new UiText.Line(label, text.DefaultColor) };
}
+ ///
+ /// #380: sets one opacity slider row's own name-caption (element
+ /// ) + tooltip from a pre-resolved
+ /// — the DAT-catalog
+ /// counterpart of (which resolves a
+ /// COMPILED symbol key; this resolves a RUNTIME DAT entry, so it takes
+ /// the already-resolved string rather than a key to hash). A null
+ /// leaves the row with
+ /// no caption rather than inventing English, matching every other
+ /// resolve-miss path in this controller. The tooltip attaches to the
+ /// SLIDER itself (the interactive/hoverable widget for this row — same
+ /// convention 's own slider
+ /// rows use), not the caption text.
+ ///
+ private static void SetOpacityCaption(
+ UiElement row, ChatOptionsDatCaptions.Caption caption, UiScrollbar? slider)
+ {
+ if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText text)
+ {
+ if (caption.Name is { Length: > 0 } name)
+ text.LinesProvider = () => new[] { new UiText.Line(name, text.DefaultColor) };
+ else
+ Console.WriteLine(
+ "[D.2b] ChatOptionsPageController: opacity slider caption did not "
+ + "resolve from the DAT name/tooltip catalog — the row renders with no "
+ + "text rather than invented English.");
+ }
+
+ if (slider is not null && caption.Tooltip is { Length: > 0 } tooltip)
+ slider.TooltipText = tooltip;
+ }
+
///
/// One per-window filter block: resolves the block template DIRECTLY through
/// (NOT via
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index f7e37e15..c92bb05c 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -2126,6 +2126,22 @@ public sealed class RetailUiRuntime : IDisposable
+ "constructor values (0.5/1.0).");
}
+ // #380: the two sliders' row CAPTIONS ("Inactive Opacity" /
+ // "Active Opacity") + tooltips — a SECOND, sibling DAT catalog
+ // lookup (DID 0x78000000, not the 0x78000001 defaults catalog
+ // above) — see ChatOptionsDatCaptions' own doc for the byte
+ // trace. Resolved once, same shape as the defaults read above.
+ if (!Layout.ChatOptionsDatCaptions.TryRead(
+ _bindings.Assets.Dats, strings,
+ out Layout.ChatOptionsDatCaptions.Caption defaultOpacityCaption,
+ out Layout.ChatOptionsDatCaptions.Caption activeOpacityCaption))
+ {
+ Console.WriteLine(
+ "[UI] options panel: Chat tab opacity slider captions did not resolve "
+ + "(DID 0x78000000) — rows render with no caption rather than invented "
+ + "English.");
+ }
+
bool chatBound = Layout.ChatOptionsPageController.Bind(
layout,
controller.ChatPage,
@@ -2153,6 +2169,8 @@ public sealed class RetailUiRuntime : IDisposable
FlushOpacity: SaveChatOpacity,
DefaultOpacityDatDefault: datDefaultOpacity,
ActiveOpacityDatDefault: datActiveOpacity,
+ DefaultOpacityCaption: defaultOpacityCaption,
+ ActiveOpacityCaption: activeOpacityCaption,
CurrentFilter: windowId => _bindings.Chat.Windows.GetFilter(windowId),
SetFilter: (windowId, value) =>
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatOptionsPageControllerTests.cs
index 1ba5ca61..f1341cfd 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatOptionsPageControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatOptionsPageControllerTests.cs
@@ -152,6 +152,13 @@ public sealed class ChatOptionsPageControllerTests
public float ActiveOpacity = 1.0f;
public float DefaultOpacityDatDefault = 0.5f;
public float ActiveOpacityDatDefault = 1.0f;
+ // #380: realistic test defaults matching the live-DAT-verified
+ // strings (docs/research live-mount probe, 2026-08-11) so existing
+ // tests exercise the same shape production sees.
+ public ChatOptionsDatCaptions.Caption DefaultOpacityCaption =
+ new("Inactive Opacity", "Adjusts the opacity of the chat window when it is inactive");
+ public ChatOptionsDatCaptions.Caption ActiveOpacityCaption =
+ new("Active Opacity", "Adjusts the opacity of the chat window when it is active");
public List DefaultOpacitySets { get; } = new();
public List ActiveOpacitySets { get; } = new();
public int OpacityFlushes { get; private set; }
@@ -182,6 +189,8 @@ public sealed class ChatOptionsPageControllerTests
FlushOpacity: () => OpacityFlushes++,
DefaultOpacityDatDefault: DefaultOpacityDatDefault,
ActiveOpacityDatDefault: ActiveOpacityDatDefault,
+ DefaultOpacityCaption: DefaultOpacityCaption,
+ ActiveOpacityCaption: ActiveOpacityCaption,
CurrentFilter: windowId => Filters[windowId],
SetFilter: (windowId, value) =>
{
@@ -388,6 +397,111 @@ public sealed class ChatOptionsPageControllerTests
Assert.Empty(fakeBindings.ActiveOpacitySets);
}
+ ///
+ /// #380 regression (2026-08-11, gate 4): before this fix, NOTHING wired
+ /// element 0x1000021B on either slider row, so both rows rendered
+ /// with no caption at all — the user's literal complaint ("I also miss
+ /// the text next to the bars for Inactive and Active Opacity"). Drives
+ /// the row-caption text down from /
+ /// ActiveOpacityCaption the same way production resolves it
+ /// ( against the live DAT) and reads
+ /// back the ACTUAL built next to each slider — not
+ /// just that the field was set somewhere, but that IT rendered on the
+ /// correct row (Default → row 1's own caption, Active → row 2's own,
+ /// never swapped or bled onto the wrong slider).
+ ///
+ [Fact]
+ public void Bind_WiresEachSlidersOwnRowCaption_FromTheResolvedDatCatalog()
+ {
+ var fakeBindings = new FakeBindings
+ {
+ DefaultOpacityCaption = new ChatOptionsDatCaptions.Caption("Inactive Opacity", "inactive tip"),
+ ActiveOpacityCaption = new ChatOptionsDatCaptions.Caption("Active Opacity", "active tip"),
+ };
+ ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
+ OptionsPanelController controller = OptionsPanelController.Bind(
+ layout,
+ new OptionsPanelController.Callbacks(
+ Toggle: () => { },
+ RequestExitToCharacterSelection: () => { },
+ ExitGame: () => { },
+ UseMouseTurningSettings: () => { },
+ DisplaySystemMessage: _ => { }))!;
+ bool bound = ChatOptionsPageController.Bind(
+ layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null,
+ fakeBindings.ToBindings());
+ Assert.True(bound);
+
+ var listBox = Assert.IsType(
+ layout.FindElement(ChatOptionsPageController.ListBoxElementId));
+ List sliders = CollectScalarSliders(listBox);
+ Assert.Equal(2, sliders.Count); // build order: [0]=Default, [1]=Active
+
+ UiElement defaultRowRoot = FindRowRoot(sliders[0]);
+ UiElement activeRowRoot = FindRowRoot(sliders[1]);
+
+ var defaultCaption = Assert.IsType(
+ UiElement.FindDescendant(defaultRowRoot, 0x1000021Bu));
+ var activeCaption = Assert.IsType(
+ UiElement.FindDescendant(activeRowRoot, 0x1000021Bu));
+
+ Assert.Equal("Inactive Opacity", Assert.Single(defaultCaption.LinesProvider()).Text);
+ Assert.Equal("Active Opacity", Assert.Single(activeCaption.LinesProvider()).Text);
+
+ // The tooltip attaches to the interactive slider itself, per-row.
+ Assert.Equal("inactive tip", sliders[0].TooltipText);
+ Assert.Equal("active tip", sliders[1].TooltipText);
+ }
+
+ /// A DAT read failure (or a missing catalog entry) must never
+ /// invent English — the row renders with NO caption, exactly like every
+ /// other resolve-miss path already established in this controller
+ /// (filter labels, section headers, range captions).
+ [Fact]
+ public void Bind_MissingCaption_RendersNoText_NeverInventsEnglish()
+ {
+ var fakeBindings = new FakeBindings
+ {
+ DefaultOpacityCaption = default, // both Name/Tooltip null
+ ActiveOpacityCaption = default,
+ };
+ ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
+ OptionsPanelController controller = OptionsPanelController.Bind(
+ layout,
+ new OptionsPanelController.Callbacks(
+ Toggle: () => { },
+ RequestExitToCharacterSelection: () => { },
+ ExitGame: () => { },
+ UseMouseTurningSettings: () => { },
+ DisplaySystemMessage: _ => { }))!;
+ bool bound = ChatOptionsPageController.Bind(
+ layout, controller.ChatPage, MakeTemplateResolver(), (_, _) => null,
+ fakeBindings.ToBindings());
+ Assert.True(bound);
+
+ var listBox = Assert.IsType(
+ layout.FindElement(ChatOptionsPageController.ListBoxElementId));
+ List sliders = CollectScalarSliders(listBox);
+ UiElement defaultRowRoot = FindRowRoot(sliders[0]);
+
+ var defaultCaption = Assert.IsType(
+ UiElement.FindDescendant(defaultRowRoot, 0x1000021Bu));
+ Assert.Empty(defaultCaption.LinesProvider());
+ Assert.Null(sliders[0].TooltipText);
+ }
+
+ /// Walks up from a built slider leaf to the row root
+ /// stacked directly under
+ /// the viewport — the same subtree
+ /// itself scopes its FindDescendant caption lookup to.
+ private static UiElement FindRowRoot(UiElement leaf)
+ {
+ UiElement node = leaf;
+ while (node.Parent is { } parent && parent is not UiScrollablePanel)
+ node = parent;
+ return node;
+ }
+
[Fact]
public void DraggingDefaultSlider_AppliesLive_AndDragsActiveUp_NeverClamping()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs
index 501e7def..c6a6a68a 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs
@@ -930,7 +930,9 @@ public sealed class ConfigOptionsPageControllerTests
DefaultOpacityDatDefault: 0.5f,
ActiveOpacityDatDefault: 1.0f,
CurrentFilter: _ => 0xFBFFFFFFul,
- SetFilter: (_, _) => { });
+ SetFilter: (_, _) => { },
+ DefaultOpacityCaption: new ChatOptionsDatCaptions.Caption("Inactive Opacity", null),
+ ActiveOpacityCaption: new ChatOptionsDatCaptions.Caption("Active Opacity", null));
}
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs
index 94ded3f4..3b58c08f 100644
--- a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs
@@ -293,6 +293,43 @@ public sealed class OptionsPanelLiveMountProbeTests
return null;
}
+ /// #380 (gate 4): exercises the PRODUCTION
+ /// mechanism against the real
+ /// DAT and asserts the two resolved captions match the user's own
+ /// complaint ("Inactive Opacity" / "Active Opacity") — live-mount proof
+ /// that #380's fix reads the SAME two strings the fixture-driven
+ /// regression tests (ChatOptionsPageControllerTests) only assume
+ /// via a fake.
+ [Fact]
+ public void ProbeChatOpacityCaptions()
+ {
+ if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
+ return;
+
+ var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
+ ?? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Documents",
+ "Asheron's Call");
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var strings = new DatStringResolver(dats);
+
+ bool ok = ChatOptionsDatCaptions.TryRead(
+ dats, strings,
+ out ChatOptionsDatCaptions.Caption defaultOpacity,
+ out ChatOptionsDatCaptions.Caption activeOpacity);
+
+ Console.WriteLine($"[probe380] TryRead ok={ok}");
+ Console.WriteLine($"[probe380] Default: Name='{defaultOpacity.Name}' Tooltip='{defaultOpacity.Tooltip}'");
+ Console.WriteLine($"[probe380] Active: Name='{activeOpacity.Name}' Tooltip='{activeOpacity.Tooltip}'");
+
+ Assert.True(ok);
+ Assert.Equal("Inactive Opacity", defaultOpacity.Name);
+ Assert.Equal("Active Opacity", activeOpacity.Name);
+ Assert.False(string.IsNullOrEmpty(defaultOpacity.Tooltip));
+ Assert.False(string.IsNullOrEmpty(activeOpacity.Tooltip));
+ }
+
/// #372 minor half: the 13 ID_ChatOption_TextFilter_* labels fail
/// to resolve in table 0x23000003 — sweep the plausible tables and key
/// spellings against the live DAT to find their real home.