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()
{