diff --git a/docs/research/2026-08-10-options-panel-structure.md b/docs/research/2026-08-10-options-panel-structure.md
index 4d3d46b3..04bd5778 100644
--- a/docs/research/2026-08-10-options-panel-structure.md
+++ b/docs/research/2026-08-10-options-panel-structure.md
@@ -625,7 +625,7 @@ opt-in).
push low; call AddChild` sequences (`decode_addchild.py` over the raw section bytes; masks
are 64-bit, `high:low`):
-| Row | Mask (high:low) | Label global | Label literal (table `0x23000003`) |
+| Row | Mask (high:low) | Label global | Label literal (table `0x2300000D` — CORRECTED 2026-08-11 at #372: the TextFilter label/tooltip family lives in its OWN table, dat-verified by exhaustive sweep; the original 0x23000003 attribution was inferred) |
|---|---|---|---|
| **0** | `0x00000000_83912021` | `ID_ChatOption_TextFilter_Gameplay` (`0x0083E840`) | Gameplay |
| 1 | `0x00000000_00600040` | `ID_ChatOption_TextFilter_Combat` (`0x0083E830`) | Combat |
diff --git a/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs b/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs
index ed8cfebf..6037f363 100644
--- a/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs
+++ b/src/AcDream.App/UI/Layout/ChatOptionsPageController.cs
@@ -81,6 +81,17 @@ public static class ChatOptionsPageController
private const uint StringTableId = 0x23000003u;
+ /// #372 (2026-08-11): the ID_ChatOption_TextFilter_*
+ /// label/tooltip family lives in its OWN string table — an exhaustive
+ /// live-DAT sweep (OptionsPanelLiveMountProbeTests.ProbeFilterLabelHome)
+ /// found the keys resolve ONLY in 0x2300000D, not the
+ /// 0x23000003 options table the section headers and slider labels
+ /// use (the research doc's §8 table-attribution was inferred, not
+ /// dat-verified — the keys themselves were always right). All 13 filter
+ /// rows rendered blank-captioned at the first connected gate because of
+ /// this one wrong table id.
+ private const uint FilterStringTableId = 0x2300000Du;
+
/// The slider leaf inside either slider row template's subtree.
private const uint SliderElementId = 0x1000021Cu;
@@ -461,9 +472,9 @@ public static class ChatOptionsPageController
continue; // research doc §5.2: the main window has no Gameplay row.
FilterRowSpec rowSpec = FilterRows[i];
- string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(rowSpec.RetailLabelKey));
+ string? label = resolveString(FilterStringTableId, DatStringResolver.ComputeHash(rowSpec.RetailLabelKey));
string? tooltip = resolveString(
- StringTableId, DatStringResolver.ComputeHash(rowSpec.RetailLabelKey + "_Desc"));
+ FilterStringTableId, DatStringResolver.ComputeHash(rowSpec.RetailLabelKey + "_Desc"));
if (label is null)
Console.WriteLine(
$"[D.2b] ChatOptionsPageController: filter row label "
diff --git a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs
index 83f52668..2c5046ca 100644
--- a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs
@@ -87,6 +87,58 @@ public sealed class OptionsPanelLiveMountProbeTests
}
}
+ /// #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.
+ [Fact]
+ public void ProbeFilterLabelHome()
+ {
+ 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);
+
+ string[] keys =
+ {
+ "ID_ChatOption_TextFilter_Combat",
+ "ID_ChatOption_TextFilter_Gameplay",
+ "ID_ChatOptions_TextFilter_Combat", // plural-Options variant
+ "ID_Chat_TextFilter_Combat",
+ "ID_TextFilter_Combat",
+ "TextFilter_Combat",
+ };
+ for (uint table = 0x23000001u; table <= 0x2300000Au; table++)
+ {
+ foreach (string key in keys)
+ {
+ string? hit = strings.Resolve(table, DatStringResolver.ComputeHash(key));
+ if (hit is not null)
+ Console.WriteLine($"[probe] table 0x{table:X8} key '{key}' -> '{hit}'");
+ }
+ }
+ // Also: does the KNOWN-good Character-tab key family resolve where
+ // documented, as a control?
+ Console.WriteLine(
+ "[probe] control ID_PlayerOption_AutoTarget in 0x23000003 -> "
+ + $"'{strings.Resolve(0x23000003u, DatStringResolver.ComputeHash("ID_PlayerOption_AutoTarget"))}'");
+
+ // Exhaustive: sweep EVERY string table in the local dat for the key.
+ uint targetHash = DatStringResolver.ComputeHash("ID_ChatOption_TextFilter_Combat");
+ foreach (uint tableId in dats.Local.GetAllIdsOfType())
+ {
+ string? hit = strings.Resolve(tableId, targetHash);
+ if (hit is not null)
+ Console.WriteLine($"[probe] EXHAUSTIVE hit: table 0x{tableId:X8} -> '{hit}'");
+ }
+ Console.WriteLine("[probe] exhaustive sweep complete");
+ }
+
private static void DumpTree(ElementInfo node, int depth, int maxDepth)
{
if (depth > maxDepth) return;