fix #380: Chat tab opacity sliders were missing their retail row captions

Root cause: PlayerOptionPage::AddSliderOption never sets a row's own
name-label text — the "Inactive Opacity"/"Active Opacity" caption comes
from a SEPARATE DAT-resident runtime catalog (DID 0x78000000, resolved
via the same two-level DBCache::GetDIDFromEnumStatic master-map/submap
lookup ChatOptionsDatDefaults already uses for enum 0x16/category 2,
here for enum 0x15/category 2) that nothing in the codebase ever
queried, so both slider rows rendered with no caption at all.

Fix: new ChatOptionsDatCaptions.TryRead resolves the DID-0x78000000
catalog's per-property name/tooltip entries (matched by the same
owning-property enum ChatOptionsDatDefaults already keys its defaults
by) and ChatOptionsPageController.BuildOpacitySliders stamps each
slider's own row caption/tooltip from it — falling back to no text
(never invented English) if resolution fails. Regressed by
ChatOptionsPageControllerTests.
Bind_WiresEachSlidersOwnRowCaption_FromTheResolvedDatCatalog and the
companion Bind_MissingCaption_RendersNoText_NeverInventsEnglish case,
plus a live-mount probe (OptionsPanelLiveMountProbeTests.
ProbeChatOpacityCaptions) confirming the production TryRead call
resolves "Inactive Opacity"/"Active Opacity" against the real DAT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 23:01:22 +02:00
parent c0b3d8f233
commit 2a248c0d48
8 changed files with 415 additions and 10 deletions

View file

@ -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<float> DefaultOpacitySets { get; } = new();
public List<float> 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);
}
/// <summary>
/// #380 regression (2026-08-11, gate 4): before this fix, NOTHING wired
/// element <c>0x1000021B</c> 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 <see cref="ChatOptionsPageController.Bindings.DefaultOpacityCaption"/>/
/// <c>ActiveOpacityCaption</c> the same way production resolves it
/// (<see cref="ChatOptionsDatCaptions"/> against the live DAT) and reads
/// back the ACTUAL built <see cref="UiText"/> 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).
/// </summary>
[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<UiTemplateListBox>(
layout.FindElement(ChatOptionsPageController.ListBoxElementId));
List<UiScrollbar> 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<UiText>(
UiElement.FindDescendant(defaultRowRoot, 0x1000021Bu));
var activeCaption = Assert.IsType<UiText>(
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);
}
/// <summary>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).</summary>
[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<UiTemplateListBox>(
layout.FindElement(ChatOptionsPageController.ListBoxElementId));
List<UiScrollbar> sliders = CollectScalarSliders(listBox);
UiElement defaultRowRoot = FindRowRoot(sliders[0]);
var defaultCaption = Assert.IsType<UiText>(
UiElement.FindDescendant(defaultRowRoot, 0x1000021Bu));
Assert.Empty(defaultCaption.LinesProvider());
Assert.Null(sliders[0].TooltipText);
}
/// <summary>Walks up from a built slider leaf to the row root
/// <see cref="UiTemplateListBox.AddPrebuiltRow"/> stacked directly under
/// the viewport — the same subtree <see cref="ChatOptionsPageController.BuildOpacitySliders"/>
/// itself scopes its <c>FindDescendant</c> caption lookup to.</summary>
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()
{

View file

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

View file

@ -293,6 +293,43 @@ public sealed class OptionsPanelLiveMountProbeTests
return null;
}
/// <summary>#380 (gate 4): exercises the PRODUCTION
/// <see cref="ChatOptionsDatCaptions.TryRead"/> 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 (<c>ChatOptionsPageControllerTests</c>) only assume
/// via a fake.</summary>
[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));
}
/// <summary>#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.</summary>