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

@ -0,0 +1,129 @@
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.UI.Layout;
/// <summary>
/// #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 <c>UIOption::InqGameplayOptionNameAndTooltip
/// @0x004ef750</c>'s own catalog lookup, the mechanism
/// <c>UIOption_Slider::SetGameplayOptionProperty @0x00485030</c> calls for
/// EVERY <c>UIOption_Slider</c> (both Chat opacity rows go through this path —
/// see <c>gmChatOptionsUI::InitOptions</c>'s own <c>SetGameplayOptionProperty</c>
/// calls at <c>0x0049fcc0</c>/<c>0x0049fd1a</c>). <c>PlayerOptionPage::AddSliderOption</c>
/// itself never sets a row caption (byte-verified — no <c>StringInfo</c> write in
/// its pseudo-C body), so the row's name-label child (element <c>0x1000021B</c>,
/// shared by BOTH the unlabelled and range-captioned slider templates) is never
/// populated by <c>AddSliderOption</c>, 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) — <see cref="ChatOptionsPageController"/>
/// never wired ANY source for it, which is #380's root cause.
///
/// <para>
/// <b>Byte-verified recipe</b> (a SECOND <c>DBCache::GetDIDFromEnumStatic</c>
/// sub-map lookup, sibling to <see cref="ChatOptionsDatDefaults"/>'s own
/// <c>(0x16, 2)</c> defaults catalog): <c>InqGameplayOptionNameAndTooltip</c>'s own
/// call site is <c>GetDIDFromEnumStatic(&amp;ret, 0x15, 2)</c> — SAME master-map
/// key (<c>2</c>) as the defaults lookup, DIFFERENT sub-map key (<c>0x15</c> vs
/// <c>0x16</c>) — so <see cref="RetailDataIdResolver.Resolve"/>'s own
/// <c>(enumValue, enumCategory)</c> order is <c>(0x15, 2)</c>. Empirically
/// confirmed against the installed DATs: resolves to DID <c>0x78000000</c> (a
/// DIFFERENT object than the defaults catalog's <c>0x78000001</c> — the two
/// enum values genuinely name separate DAT objects, not two views of one). That
/// DBProperties has ONE top-level entry, property <c>0xD2</c>, an
/// <c>ArrayBaseProperty</c> of <c>StructBaseProperty</c> entries — one per
/// registered <c>GameplayOptionProperty</c> in the whole game (not opacity-
/// specific). Each entry carries: <c>0xD4</c> = name <c>StringInfo</c>,
/// <c>0xD5</c> = tooltip <c>StringInfo</c>, <c>0xD6</c> = the
/// <c>GameplayOptionProperty</c> id this entry describes (an
/// <c>EnumBaseProperty</c>), <c>0xD7</c>/<c>0xD8</c> = unrelated floats. The
/// live array has exactly two entries, matching
/// <see cref="ChatOptionsDatDefaults.DefaultOpacityPropertyId"/> (<c>0xD6</c>
/// = <c>0x10000080</c>) and <c>ActiveOpacityPropertyId</c> (<c>0xD6</c> =
/// <c>0x10000081</c>) — resolving via table <c>0x2300000D</c> (the SAME
/// TextFilter-family table #372 already established as this campaign's
/// runtime-string home, not the compiled-symbol table <c>0x23000003</c> the
/// section headers use) to <b>"Inactive Opacity"</b> /
/// <b>"Active Opacity"</b> — 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").
/// </para>
/// </summary>
public static class ChatOptionsDatCaptions
{
/// <summary>DAT enum-category key for the FIRST (master-map) lookup — see
/// the class doc's byte-verified recipe (shared with
/// <see cref="ChatOptionsDatDefaults"/>'s own defaults lookup).</summary>
private const uint EnumCategory = 2u;
/// <summary>DAT enum-value key for the SECOND (sub-map) lookup — the
/// name/tooltip catalog, NOT <see cref="ChatOptionsDatDefaults"/>'s
/// <c>0x16</c> defaults catalog.</summary>
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;
/// <summary>One resolved name/tooltip pair. Either half may be
/// <see langword="null"/> if its own <c>StringInfo</c> failed to resolve —
/// the caller renders no text rather than inventing English (matching
/// this codebase's uniform degrade discipline).</summary>
public readonly record struct Caption(string? Name, string? Tooltip);
/// <summary>
/// Resolves the Default/Active opacity sliders' row captions + tooltips
/// from the live DAT. Returns <see langword="false"/> only when the
/// catalog DID itself does not resolve or is not a <c>DBProperties</c> —
/// a missing INDIVIDUAL entry still returns <see langword="true"/> with
/// that pair's <see cref="Caption"/> left at its default (both null).
/// </summary>
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<DBProperties>(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);
}
}

View file

@ -107,6 +107,25 @@ public static class ChatOptionsPageController
/// <summary>The slider leaf inside either slider row template's subtree.</summary>
private const uint SliderElementId = 0x1000021Cu;
/// <summary>
/// #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
/// <see cref="ConfigOptionsPageController"/> already cites for its own
/// slider rows sharing this template pool). Retail's
/// <c>PlayerOptionPage::AddSliderOption</c> never sets this element's
/// text itself (byte-verified — no <c>StringInfo</c> write in its
/// pseudo-C body); the caption comes from
/// <c>UIOption_Slider::SetGameplayOptionProperty</c>'s own
/// <c>InqGameplayOptionNameAndTooltip</c> catalog lookup instead — see
/// <see cref="ChatOptionsDatCaptions"/>'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").
/// </summary>
private const uint SliderLabelElementId = 0x1000021Bu;
/// <summary>Labelled slider template's low/high range-caption children
/// (research doc §1.5's template array; present only on template idx 6).</summary>
private const uint SliderRangeMinElementId = 0x1000021Eu;
@ -199,7 +218,17 @@ public static class ChatOptionsPageController
float DefaultOpacityDatDefault,
float ActiveOpacityDatDefault,
Func<int, ulong> CurrentFilter,
Action<int, ulong> SetFilter);
Action<int, ulong> 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);
/// <summary>
/// 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) };
}
/// <summary>
/// #380: sets one opacity slider row's own name-caption (element
/// <see cref="SliderLabelElementId"/>) + tooltip from a pre-resolved
/// <see cref="ChatOptionsDatCaptions.Caption"/> — the DAT-catalog
/// counterpart of <see cref="SetRangeLabel"/> (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
/// <see cref="ChatOptionsDatCaptions.Caption.Name"/> 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 <see cref="ConfigOptionsPageController"/>'s own slider
/// rows use), not the caption text.
/// </summary>
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;
}
/// <summary>
/// One per-window filter block: resolves the block template DIRECTLY through
/// <paramref name="templateResolver"/> (NOT via

View file

@ -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) =>
{