diff --git a/src/AcDream.App/UI/UiChatInput.cs b/src/AcDream.App/UI/UiChatInput.cs
index 58c6e4a0..730a7175 100644
--- a/src/AcDream.App/UI/UiChatInput.cs
+++ b/src/AcDream.App/UI/UiChatInput.cs
@@ -101,6 +101,10 @@ public sealed class UiChatInput : UiElement
_historyIndex = -1;
}
+ /// Move the caret left (negative) or right (positive) by
+ /// glyph positions without extending a selection. Public for test access.
+ public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false);
+
private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift);
// ── Selection ────────────────────────────────────────────────────────
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
new file mode 100644
index 00000000..836adbdc
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
@@ -0,0 +1,46 @@
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Dat-free conformance tests for the committed chat_21000006.json golden fixture.
+/// Verifies that LayoutImporter.ImportInfos correctly resolves the BaseElement /
+/// BaseLayoutId inheritance chain for the chat window (LayoutDesc 0x21000006).
+///
+public class ChatLayoutConformanceTests
+{
+ private static ElementInfo? Find(ElementInfo n, uint id)
+ {
+ if (n.Id == id) return n;
+ foreach (var c in n.Children)
+ {
+ var f = Find(c, id);
+ if (f is not null) return f;
+ }
+ return null;
+ }
+
+ [Fact]
+ public void ChatFixture_ResolvesKnownElements()
+ {
+ var root = FixtureLoader.LoadChatInfos();
+ Assert.NotNull(Find(root, 0x10000011u)); // transcript
+ Assert.NotNull(Find(root, 0x10000016u)); // input
+ Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
+ Assert.NotNull(Find(root, 0x10000014u)); // channel menu
+ Assert.NotNull(Find(root, 0x10000019u)); // send button
+ Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
+ }
+
+ [Fact]
+ public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
+ {
+ var root = FixtureLoader.LoadChatInfos();
+ Assert.Equal(6u, Find(root, 0x10000014u)!.Type); // Menu
+ Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar
+ Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send)
+ Assert.Equal(1u, Find(root, 0x1000046Fu)!.Type); // Button (Max/Min)
+ Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript)
+ Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs
new file mode 100644
index 00000000..cdc89c5f
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs
@@ -0,0 +1,39 @@
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using AcDream.App.UI.Layout;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// One-off generator for the committed chat golden fixture. Skipped by default —
+/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
+/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
+///
+public class ChatLayoutFixtureGenerator
+{
+ [Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")]
+ public void GenerateChatFixture()
+ {
+ 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 info = LayoutImporter.ImportInfos(dats, 0x21000006u);
+ Assert.NotNull(info);
+
+ var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
+ {
+ IncludeFields = true,
+ WriteIndented = true,
+ });
+ File.WriteAllText(FixturePath(), json);
+ }
+
+ // Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
+ private static string FixturePath([CallerFilePath] string thisFile = "")
+ => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
index 724a0e89..c7338ba1 100644
--- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
@@ -5,9 +5,9 @@ using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
///
-/// Loads the committed vitals ElementInfo fixture and builds the widget tree —
-/// no dats required. The fixture was generated from layout 0x2100006C
-/// via the real portal.dat and serialized with .
+/// Loads the committed layout ElementInfo fixtures and builds widget trees —
+/// no dats required. Fixtures were generated from the real portal.dat and
+/// serialized with .
///
public static class FixtureLoader
{
@@ -37,18 +37,39 @@ public static class FixtureLoader
/// widget factory.
///
public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos()
- {
- var fixturePath = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", "vitals_2100006C.json");
- if (!File.Exists(fixturePath))
- throw new FileNotFoundException($"Vitals fixture not found at: {fixturePath}");
+ => LoadInfos("vitals_2100006C.json");
- var bytes = File.ReadAllBytes(fixturePath);
+ ///
+ /// Deserializes the committed chat_21000006.json fixture into a raw
+ /// tree and builds the
+ /// using a null-returning sprite resolver and no dat font — sufficient for
+ /// conformance checks on tree structure and resolved types.
+ ///
+ public static ImportedLayout LoadChat()
+ => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
+
+ ///
+ /// Deserializes the committed chat_21000006.json fixture into a raw
+ /// tree WITHOUT calling .
+ /// Use this when the test needs to inspect the resolved
+ /// tree directly (e.g. resolved Type values per element id).
+ ///
+ public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
+ => LoadInfos("chat_21000006.json");
+
+ // ── Shared loader ────────────────────────────────────────────────────────
+
+ private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName);
+ if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}");
+ var bytes = File.ReadAllBytes(path);
// Strip UTF-8 BOM (EF BB BF) if present so JsonSerializer.Deserialize(ReadOnlySpan)
// does not reject the first byte.
ReadOnlySpan span = bytes;
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF)
span = span[3..];
return JsonSerializer.Deserialize(span, _opts)
- ?? throw new InvalidOperationException($"fixture deserialized to null: {fixturePath}");
+ ?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
}
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json
new file mode 100644
index 00000000..37783bb7
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json
@@ -0,0 +1,542 @@
+{
+ "Id": 0,
+ "Type": 3,
+ "X": 0,
+ "Y": 0,
+ "Width": 0,
+ "Height": 0,
+ "Left": 0,
+ "Top": 0,
+ "Right": 0,
+ "Bottom": 0,
+ "ReadOrder": 0,
+ "FontDid": 0,
+ "StateMedia": {},
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268435484,
+ "Type": 3,
+ "X": 0,
+ "Y": 0,
+ "Width": 382,
+ "Height": 104,
+ "Left": 1,
+ "Top": 2,
+ "Right": 2,
+ "Bottom": 1,
+ "ReadOrder": 1,
+ "FontDid": 0,
+ "StateMedia": {
+ "": {
+ "Item1": 100667980,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268435485,
+ "Type": 5,
+ "X": 0,
+ "Y": 2,
+ "Width": 382,
+ "Height": 102,
+ "Left": 1,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 1,
+ "FontDid": 0,
+ "StateMedia": {},
+ "DefaultStateName": "",
+ "Children": []
+ }
+ ]
+ },
+ {
+ "Id": 268436774,
+ "Type": 1,
+ "X": 2,
+ "Y": 0,
+ "Width": 16,
+ "Height": 16,
+ "Left": 1,
+ "Top": 1,
+ "Right": 2,
+ "Bottom": 2,
+ "ReadOrder": 3,
+ "FontDid": 1073741861,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100688408,
+ "Item2": 1
+ },
+ "Highlight": {
+ "Item1": 100688409,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "Normal",
+ "Children": []
+ },
+ {
+ "Id": 268435486,
+ "Type": 12,
+ "X": 0,
+ "Y": 0,
+ "Width": 191,
+ "Height": 17,
+ "Left": 0,
+ "Top": 0,
+ "Right": 0,
+ "Bottom": 0,
+ "ReadOrder": 2,
+ "FontDid": 1073741825,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100667982,
+ "Item2": 1
+ },
+ "Ghosted": {
+ "Item1": 100667982,
+ "Item2": 1
+ },
+ "Talkfocus_highlight": {
+ "Item1": 100667981,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": []
+ },
+ {
+ "Id": 268435470,
+ "Type": 268435521,
+ "X": 0,
+ "Y": 0,
+ "Width": 800,
+ "Height": 100,
+ "Left": 1,
+ "Top": 2,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 0,
+ "FontDid": 0,
+ "StateMedia": {
+ "": {
+ "Item1": 100667725,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268436772,
+ "Type": 1,
+ "X": 0,
+ "Y": 46,
+ "Width": 16,
+ "Height": 16,
+ "Left": 1,
+ "Top": 1,
+ "Right": 2,
+ "Bottom": 2,
+ "ReadOrder": 6,
+ "FontDid": 1073741861,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100688408,
+ "Item2": 1
+ },
+ "Highlight": {
+ "Item1": 100688409,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "Normal",
+ "Children": []
+ },
+ {
+ "Id": 268436773,
+ "Type": 1,
+ "X": 0,
+ "Y": 64,
+ "Width": 16,
+ "Height": 16,
+ "Left": 1,
+ "Top": 1,
+ "Right": 2,
+ "Bottom": 2,
+ "ReadOrder": 7,
+ "FontDid": 1073741861,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100688408,
+ "Item2": 1
+ },
+ "Highlight": {
+ "Item1": 100688409,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "Normal",
+ "Children": []
+ },
+ {
+ "Id": 268436591,
+ "Type": 1,
+ "X": 474,
+ "Y": 0,
+ "Width": 16,
+ "Height": 16,
+ "Left": 2,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 2,
+ "ReadOrder": 3,
+ "FontDid": 0,
+ "StateMedia": {
+ "Maximized": {
+ "Item1": 100687460,
+ "Item2": 1
+ },
+ "Minimized": {
+ "Item1": 100687461,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "Minimized",
+ "Children": []
+ },
+ {
+ "Id": 268435471,
+ "Type": 9,
+ "X": 0,
+ "Y": 0,
+ "Width": 800,
+ "Height": 9,
+ "Left": 1,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 2,
+ "ReadOrder": 1,
+ "FontDid": 0,
+ "StateMedia": {
+ "": {
+ "Item1": 100667685,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": []
+ },
+ {
+ "Id": 268435472,
+ "Type": 3,
+ "X": 0,
+ "Y": 9,
+ "Width": 490,
+ "Height": 74,
+ "Left": 1,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 2,
+ "FontDid": 0,
+ "StateMedia": {
+ "": {
+ "Item1": 100667669,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268435473,
+ "Type": 12,
+ "X": 16,
+ "Y": 0,
+ "Width": 458,
+ "Height": 74,
+ "Left": 1,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 1,
+ "FontDid": 1073741824,
+ "StateMedia": {},
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268436620,
+ "Type": 1,
+ "X": 0,
+ "Y": 58,
+ "Width": 16,
+ "Height": 16,
+ "Left": 3,
+ "Top": 2,
+ "Right": 3,
+ "Bottom": 1,
+ "ReadOrder": 1,
+ "FontDid": 0,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100687630,
+ "Item2": 1
+ },
+ "Normal_pressed": {
+ "Item1": 100687630,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "Ghosted",
+ "Children": []
+ }
+ ]
+ },
+ {
+ "Id": 268435474,
+ "Type": 11,
+ "X": 474,
+ "Y": 6,
+ "Width": 16,
+ "Height": 68,
+ "Left": 2,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 2,
+ "FontDid": 0,
+ "StateMedia": {
+ "": {
+ "Item1": 100682847,
+ "Item2": 3
+ }
+ },
+ "DefaultStateName": "",
+ "Children": []
+ }
+ ]
+ },
+ {
+ "Id": 268435475,
+ "Type": 3,
+ "X": 0,
+ "Y": 83,
+ "Width": 490,
+ "Height": 17,
+ "Left": 1,
+ "Top": 2,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 8,
+ "FontDid": 0,
+ "StateMedia": {
+ "": {
+ "Item1": 100667706,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268435476,
+ "Type": 6,
+ "X": 0,
+ "Y": 0,
+ "Width": 46,
+ "Height": 17,
+ "Left": 1,
+ "Top": 1,
+ "Right": 2,
+ "Bottom": 1,
+ "ReadOrder": 1,
+ "FontDid": 0,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100683109,
+ "Item2": 3
+ },
+ "Normal_pressed": {
+ "Item1": 100683110,
+ "Item2": 3
+ }
+ },
+ "DefaultStateName": "Normal",
+ "Children": [
+ {
+ "Id": 268435477,
+ "Type": 12,
+ "X": 0,
+ "Y": 0,
+ "Width": 46,
+ "Height": 17,
+ "Left": 1,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 1,
+ "FontDid": 1073741826,
+ "StateMedia": {},
+ "DefaultStateName": "",
+ "Children": []
+ }
+ ]
+ },
+ {
+ "Id": 268435478,
+ "Type": 12,
+ "X": 46,
+ "Y": 0,
+ "Width": 398,
+ "Height": 17,
+ "Left": 1,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 2,
+ "FontDid": 1073741824,
+ "StateMedia": {
+ "Normal_focussed": {
+ "Item1": 100667819,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": [
+ {
+ "Id": 268435479,
+ "Type": 3,
+ "X": 0,
+ "Y": 0,
+ "Width": 1,
+ "Height": 17,
+ "Left": 1,
+ "Top": 1,
+ "Right": 2,
+ "Bottom": 1,
+ "ReadOrder": 1,
+ "FontDid": 0,
+ "StateMedia": {
+ "Normal_focussed": {
+ "Item1": 100683111,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": []
+ },
+ {
+ "Id": 268435480,
+ "Type": 3,
+ "X": 397,
+ "Y": 0,
+ "Width": 1,
+ "Height": 17,
+ "Left": 2,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 2,
+ "FontDid": 0,
+ "StateMedia": {
+ "Normal_focussed": {
+ "Item1": 100683111,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "",
+ "Children": []
+ }
+ ]
+ },
+ {
+ "Id": 268435481,
+ "Type": 1,
+ "X": 444,
+ "Y": 0,
+ "Width": 46,
+ "Height": 17,
+ "Left": 2,
+ "Top": 1,
+ "Right": 1,
+ "Bottom": 1,
+ "ReadOrder": 3,
+ "FontDid": 1073741826,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100669717,
+ "Item2": 1
+ },
+ "Normal_pressed": {
+ "Item1": 100669718,
+ "Item2": 1
+ },
+ "Ghosted": {
+ "Item1": 100669748,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "Normal",
+ "Children": []
+ }
+ ]
+ },
+ {
+ "Id": 268436770,
+ "Type": 1,
+ "X": 0,
+ "Y": 10,
+ "Width": 16,
+ "Height": 16,
+ "Left": 1,
+ "Top": 1,
+ "Right": 2,
+ "Bottom": 2,
+ "ReadOrder": 4,
+ "FontDid": 1073741861,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100688408,
+ "Item2": 1
+ },
+ "Highlight": {
+ "Item1": 100688409,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "Normal",
+ "Children": []
+ },
+ {
+ "Id": 268436771,
+ "Type": 1,
+ "X": 0,
+ "Y": 28,
+ "Width": 16,
+ "Height": 16,
+ "Left": 1,
+ "Top": 1,
+ "Right": 2,
+ "Bottom": 2,
+ "ReadOrder": 5,
+ "FontDid": 1073741861,
+ "StateMedia": {
+ "Normal": {
+ "Item1": 100688408,
+ "Item2": 1
+ },
+ "Highlight": {
+ "Item1": 100688409,
+ "Item2": 1
+ }
+ },
+ "DefaultStateName": "Normal",
+ "Children": []
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file