From b7ea16b54c49b266426f4f821f3b0af08584dac3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 18 Jul 2026 23:28:26 -0400 Subject: [PATCH] Implement retained ADV History writers --- docs/engine-re.md | 15 +- docs/phase-a-slice-plan.md | 24 +++ .../Age.Engine.Tests/AdvTextHistoryTests.cs | 155 +++++++++++++++ engine/Age.Engine/Model/AdvTextHistory.cs | 177 ++++++++++++++++++ engine/Age.Engine/Vm/GameSession.cs | 9 +- engine/Age.Engine/Vm/VirtualMachine.cs | 44 ++++- 6 files changed, 415 insertions(+), 9 deletions(-) create mode 100644 engine/Age.Engine.Tests/AdvTextHistoryTests.cs create mode 100644 engine/Age.Engine/Model/AdvTextHistory.cs diff --git a/docs/engine-re.md b/docs/engine-re.md index 8cd81b6..91a4606 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1316,10 +1316,17 @@ rectangle fill (`0x20b`), message-window alpha (`0x131`), and retained-object pr `0xd3/0xd4/0xd5` callback-sequence family drives the smooth scrollbar interpolation and is the main remaining scheduler detail; omitting it would affect motion fidelity, not the backlog data model. -The port currently retains only the current `_advText` plus a diagnostic `(offset,text)` capture list. -History should therefore begin with an engine/session-owned `AdvTextHistory` service—records, logical index, -suppression, and clear lifetime—fed by the native write opcodes. The host should render a requested group; -it should not own the canonical backlog. No persistence format is required for that first slice. +The first port slice now implements an engine/session-owned `AdvTextHistory` service. `GameSession` carries +one instance across VM scene runs, while a standalone VM owns its instance for the full top-level and nested +script lifetime. The model retains the logical index, typed text/metadata/voice records, group-start flags, +layout/cursor snapshots, and font/color/effect state. Ops `0x70`, `0x71`, `0x6e`, `0xc4`, `0x1d2`, `0x1bb`, +and `0x85` feed it directly; Godot remains only a presentation host and does not own the canonical backlog. + +The model intentionally has no JSON or disk serialization. `GameSession.ToJson()` continues to snapshot +only the pre-existing global banks, so landing the live backlog does not silently choose a save/profile +backend. Native numbered-save restoration remains the explicit future integration seam described above. +The next History slice can now implement generic inline arrays/value dispatch and ops `0x1d0..0x1d4` over +real retained data before adding host rendering. The original dependency order was **Hide Window first** to establish reusable callback/coroutine input, then Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index f5a2c34..89a2e6a 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1680,3 +1680,27 @@ backend decision in this slice. Full layout, lifecycle, and opcode evidence live path first, with focused synthetic tests for grouping, metadata, voice pairs, suppression, and clear. Then add generic literal-array/switch support and the HISTORY read/render path. This orders the work around the data that must exist before clicking History can display anything. + +### ADV History retained writer model implemented (2026-07-18) + +The recommended first slice is complete. `AdvTextHistory` is an engine model rather than a Godot widget: +standalone VMs own it for their complete nested-script lifetime, and `GameSession` supplies one shared +instance to successive scene VMs. It stores logical `{layout_slot,first_record_index}` entries and semantic +text, typed-metadata, and voice-pair records with native flags, layout/cursor snapshots, and the active +font/color/effect style. + +The ordinary native write path is live. Ops `0x70/0x71` configure/reset layouts and establish group +boundaries; `0x6e`, `0xc4`, and `0x1d2` append text, voice, and metadata records; `0x1bb` suppresses all of +those writers while HISTORY renders itself; and `0x85` clears the retained vectors at ordinary ADV block +boundaries. The already-reversed font, color, mode, effect, bold, and cursor setters update the snapshots +future history rendering will consume. + +Five focused regressions cover grouping and first-record flags, metadata and voice fields, style/layout +snapshots, suppression/re-enable, clear lifetime, session sharing, and the real SC0000 first page reaching +its wait with string `0x14963` retained. Validation: engine 182/182, zero-warning Godot build, threaded +`SELFTEST OK`. Static HISTORY coverage is now 56/78 distinct opcodes and 798/854 instructions handled or +safe-noop. + +**Next:** implement generic inline integer arrays (`0x64`) and value-switch dispatch (`0xa1/0xa2/0xa3`), +then wire History navigation/query ops `0x1d0`, `0x1d3`, and `0x1d4` against this model. Rendering op `0x1d1` +and its presentation operations can follow once those data-side script paths execute correctly. diff --git a/engine/Age.Engine.Tests/AdvTextHistoryTests.cs b/engine/Age.Engine.Tests/AdvTextHistoryTests.cs new file mode 100644 index 0000000..fbf663a --- /dev/null +++ b/engine/Age.Engine.Tests/AdvTextHistoryTests.cs @@ -0,0 +1,155 @@ +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class AdvTextHistoryTests +{ + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand I(long value) => new(0, value); + private static Operand S(int index) => new(2, index); + + [Fact] + public void OrdinaryAdvOpsBuildGroupedStyledMetadataVoiceAndTextRecords() + { + var script = ScriptAssembler.Assemble(Table, "HISTORY_WRITE", + new List<(int, Operand[])> + { + (0x70, new[] { I(1), I(640), I(160), I(80), I(430) }), + (0x75, new[] { I(24) }), + (0x76, new[] { I(0xf0e0d0) }), + (0x7a, new[] { I(0), I(12), I(34) }), + (0x1d2, new[] { I(123), I(1) }), + (0xc4, new[] { I(77) }), + (0x6e, new[] { I(0), S(0) }), + (0x71, new[] { I(1) }), + (0x6e, new[] { I(0), S(1) }), + (0x2, Array.Empty()), + }, new[] { "first", "second" }); + + var vm = new VirtualMachine(script, Table, new RecordingHost()); + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(new[] + { + new AdvTextHistoryEntry(1, 0), + new AdvTextHistoryEntry(1, 3), + }, vm.TextHistory.Entries); + + Assert.Collection(vm.TextHistory.Records, + metadata => + { + Assert.Equal(AdvTextHistoryRecordKind.Metadata, metadata.Kind); + Assert.Equal(AdvTextHistoryRecordFlags.TypedMetadata | AdvTextHistoryRecordFlags.GroupStart, + metadata.Flags); + Assert.Equal(123, metadata.Value); + Assert.Equal(1, metadata.AuxValue); + Assert.Equal((24, 0xf0e0d0L), (metadata.Style.PrimaryFontSize, metadata.Style.TextColor)); + Assert.Equal(new AdvTextLayoutSnapshot(1, 640, 160, 80, 430, 12, 34), metadata.Layout); + }, + voice => + { + Assert.Equal(AdvTextHistoryRecordKind.Voice, voice.Kind); + Assert.Equal(AdvTextHistoryRecordFlags.VoicePair, voice.Flags); + Assert.Equal((77L, 0L), (voice.Value, voice.AuxValue)); + }, + text => + { + Assert.Equal(AdvTextHistoryRecordKind.Text, text.Kind); + Assert.Equal("first", text.Text); + Assert.Equal(AdvTextHistoryRecordFlags.None, text.Flags); + }, + text => + { + Assert.Equal(AdvTextHistoryRecordKind.Text, text.Kind); + Assert.Equal("second", text.Text); + Assert.Equal(AdvTextHistoryRecordFlags.GroupStart, text.Flags); + Assert.Equal((0, 0), (text.Layout.CursorX, text.Layout.CursorY)); + }); + } + + [Fact] + public void RecordingSuppressionBlocksEveryWriterUntilReenabled() + { + var script = ScriptAssembler.Assemble(Table, "HISTORY_SUPPRESS", + new List<(int, Operand[])> + { + (0x1bb, new[] { I(0) }), + (0x70, new[] { I(2), I(500), I(100), I(10), I(20) }), + (0x1d2, new[] { I(9), I(2) }), + (0xc4, new[] { I(88) }), + (0x6e, new[] { I(0), S(0) }), + (0x1bb, new[] { I(1) }), + (0x71, new[] { I(2) }), + (0x6e, new[] { I(0), S(1) }), + (0x2, Array.Empty()), + }, new[] { "hidden", "retained" }); + + var vm = new VirtualMachine(script, Table, new RecordingHost()); + vm.Run(); + + Assert.False(vm.TextHistory.RecordingSuppressed); + Assert.Equal(new AdvTextHistoryEntry(2, 0), Assert.Single(vm.TextHistory.Entries)); + var record = Assert.Single(vm.TextHistory.Records); + Assert.Equal("retained", record.Text); + Assert.True(record.Flags.HasFlag(AdvTextHistoryRecordFlags.GroupStart)); + } + + [Fact] + public void ClearDropsRecordsIndexAndPendingGroupStartButRetainsLayoutDefinition() + { + var history = new AdvTextHistory(); + history.DefineLayout(3, 320, 90, 20, 400); + history.AppendText(0, 10, "old", AdvTextStyle.Default); + + history.Clear(); + history.AppendText(0, 11, "new", AdvTextStyle.Default); + + Assert.Empty(history.Entries); + var record = Assert.Single(history.Records); + Assert.Equal("new", record.Text); + Assert.False(record.Flags.HasFlag(AdvTextHistoryRecordFlags.GroupStart)); + Assert.Equal(new AdvTextLayoutSnapshot(3, 320, 90, 20, 400, 0, 0), record.Layout); + } + + [Fact] + public void GameSessionSharesOneLiveHistoryAcrossSceneVms() + { + Script Scene(string name, string text) => ScriptAssembler.Assemble(Table, name, + new List<(int, Operand[])> + { + (0x71, new[] { I(1) }), + (0x6e, new[] { I(0), S(0) }), + (0x2, Array.Empty()), + }, new[] { text }); + + var session = new GameSession(); + session.RunScene(Scene("FIRST", "one"), Table, new RecordingHost()); + session.RunScene(Scene("SECOND", "two"), Table, new RecordingHost()); + + Assert.Equal(new[] { "one", "two" }, session.TextHistory.Records.Select(r => r.Text)); + Assert.Equal(new[] + { + new AdvTextHistoryEntry(1, 0), + new AdvTextHistoryEntry(1, 1), + }, session.TextHistory.Entries); + } + + [Fact] + public void RealSc0000FirstPagePopulatesTheRetainedBacklogBeforeItsWait() + { + var scripts = Sys4ScriptProvider.Load(Table); + var scene = scripts.RequireByName("SC0000.BIN"); + var vm = new VirtualMachine(scene, Table, new RecordingHost(), + new VmOptions(MaxSteps: 1_000_000, HaltAtWaitForInput: true), scripts); + + vm.Run(); + + Assert.Equal("wait-for-input", vm.HaltReason); + Assert.NotEmpty(vm.TextHistory.Entries); + var text = Assert.Single(vm.TextHistory.Records.Where(r => r.Kind == AdvTextHistoryRecordKind.Text)); + Assert.Equal(0x14963, text.SourceOffset); + Assert.NotEmpty(text.Text); + Assert.True(text.Flags.HasFlag(AdvTextHistoryRecordFlags.GroupStart)); + } +} diff --git a/engine/Age.Engine/Model/AdvTextHistory.cs b/engine/Age.Engine/Model/AdvTextHistory.cs new file mode 100644 index 0000000..9053993 --- /dev/null +++ b/engine/Age.Engine/Model/AdvTextHistory.cs @@ -0,0 +1,177 @@ +namespace Age.Engine.Model; + +[Flags] +public enum AdvTextHistoryRecordFlags : uint +{ + None = 0, + GroupStart = 0x00000001, + NavigationFiltered = 0x00000002, + TypedMetadata = 0x20000000, + VoicePair = 0x40000000, +} + +public enum AdvTextHistoryRecordKind +{ + Text, + Metadata, + Voice, +} + +/// The text raster state copied into each native retained text record. +public readonly record struct AdvTextStyle( + int PrimaryFontSize, + int RubyFontSize, + bool Bold, + long TextColor, + long EffectColor, + int RenderMode, + int EffectOffsetX, + int EffectOffsetY) +{ + public static AdvTextStyle Default => new(0, 0, false, 0, 0, 0, 0, 0); +} + +/// A stable snapshot of the layout state associated with a retained record. +public readonly record struct AdvTextLayoutSnapshot( + int Slot, + int Width, + int Height, + int OriginX, + int OriginY, + int CursorX, + int CursorY); + +/// +/// One semantic counterpart of AGE's 0x48-byte retained text record. Metadata uses +/// plus as value/type; voice uses them as id/argument. +/// +public sealed record AdvTextHistoryRecord( + AdvTextHistoryRecordKind Kind, + AdvTextHistoryRecordFlags Flags, + AdvTextLayoutSnapshot Layout, + AdvTextStyle Style, + string Text, + long Value, + long AuxValue, + int SourceOffset); + +/// AGE's logical 8-byte history index entry. +public readonly record struct AdvTextHistoryEntry(int LayoutSlot, int FirstRecordIndex); + +/// +/// Engine-owned retained ADV backlog. It deliberately has no persistence behavior: native numbered-save +/// restoration belongs to the future unified save architecture, while live HISTORY.BIN reads this model. +/// +public sealed class AdvTextHistory +{ + private sealed class LayoutState + { + public int Width; + public int Height; + public int OriginX; + public int OriginY; + public int CursorX; + public int CursorY; + } + + private readonly List _records = new(); + private readonly List _entries = new(); + private readonly Dictionary _layouts = new(); + private readonly HashSet _pendingGroupStarts = new(); + + public IReadOnlyList Records => _records; + public IReadOnlyList Entries => _entries; + public bool RecordingSuppressed { get; private set; } + public int CurrentLayoutSlot { get; private set; } + + public void SetRecordingEnabled(bool enabled) => RecordingSuppressed = !enabled; + + public void DefineLayout(int requestedSlot, int width, int height, int originX, int originY) + { + int slot = SelectLayout(requestedSlot); + var layout = GetOrCreateLayout(slot); + layout.Width = width; + layout.Height = height; + layout.OriginX = originX; + layout.OriginY = originY; + AppendBoundary(slot); + } + + public void ResetLayout(int requestedSlot) + { + int slot = SelectLayout(requestedSlot); + var layout = GetOrCreateLayout(slot); + layout.CursorX = 0; + layout.CursorY = 0; + AppendBoundary(slot); + } + + public void SetCursor(int requestedSlot, int x, int y) + { + int slot = ResolveLayout(requestedSlot); + var layout = GetOrCreateLayout(slot); + layout.CursorX = x; + layout.CursorY = y; + } + + public void AppendText(int requestedSlot, int sourceOffset, string text, AdvTextStyle style) + { + int slot = ResolveLayout(requestedSlot); + AppendRecord(slot, AdvTextHistoryRecordKind.Text, AdvTextHistoryRecordFlags.None, + style, text, 0, 0, sourceOffset); + } + + public void AppendMetadata(long value, long metadataType, AdvTextStyle style) + => AppendRecord(CurrentLayoutSlot, AdvTextHistoryRecordKind.Metadata, + AdvTextHistoryRecordFlags.TypedMetadata, style, "", value, metadataType, -1); + + public void AppendVoice(long voiceId, long voiceArgument, AdvTextStyle style) + => AppendRecord(CurrentLayoutSlot, AdvTextHistoryRecordKind.Voice, + AdvTextHistoryRecordFlags.VoicePair, style, "", voiceId, voiceArgument, -1); + + /// Clear the retained records and logical index while keeping reusable layout definitions. + public void Clear() + { + _records.Clear(); + _entries.Clear(); + _pendingGroupStarts.Clear(); + } + + private int SelectLayout(int requestedSlot) + { + int slot = requestedSlot == 0 ? CurrentLayoutSlot : requestedSlot; + CurrentLayoutSlot = slot; + return slot; + } + + private int ResolveLayout(int requestedSlot) => requestedSlot == 0 ? CurrentLayoutSlot : requestedSlot; + + private LayoutState GetOrCreateLayout(int slot) + { + if (!_layouts.TryGetValue(slot, out var layout)) + { + layout = new LayoutState(); + _layouts.Add(slot, layout); + } + return layout; + } + + private void AppendBoundary(int slot) + { + if (RecordingSuppressed) return; + _entries.Add(new AdvTextHistoryEntry(slot, _records.Count)); + _pendingGroupStarts.Add(slot); + } + + private void AppendRecord(int slot, AdvTextHistoryRecordKind kind, AdvTextHistoryRecordFlags flags, + AdvTextStyle style, string text, long value, long auxValue, int sourceOffset) + { + if (RecordingSuppressed) return; + if (_pendingGroupStarts.Remove(slot)) flags |= AdvTextHistoryRecordFlags.GroupStart; + + var layout = GetOrCreateLayout(slot); + var snapshot = new AdvTextLayoutSnapshot(slot, layout.Width, layout.Height, + layout.OriginX, layout.OriginY, layout.CursorX, layout.CursorY); + _records.Add(new AdvTextHistoryRecord(kind, flags, snapshot, style, text, value, auxValue, sourceOffset)); + } +} diff --git a/engine/Age.Engine/Vm/GameSession.cs b/engine/Age.Engine/Vm/GameSession.cs index c909eee..2c10eda 100644 --- a/engine/Age.Engine/Vm/GameSession.cs +++ b/engine/Age.Engine/Vm/GameSession.cs @@ -20,6 +20,8 @@ public sealed class GameSession { public Dictionary Globals { get; } = new(); public Dictionary GlobalStrings { get; } = new(); + /// The live retained ADV backlog shared by every VM run in this session. + public AdvTextHistory TextHistory { get; } = new(); public void Seed(int addr, long value) => Globals[addr] = value; public void SeedString(int addr, string value) => GlobalStrings[addr] = value; @@ -29,7 +31,7 @@ public sealed class GameSession VmOptions? options = null, IScriptProvider? provider = null, ITraceSink? sink = null) { - var vm = new VirtualMachine(script, table, host, options, provider, sink); + var vm = new VirtualMachine(script, table, host, options, provider, sink, TextHistory); foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value; foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; @@ -42,8 +44,9 @@ public sealed class GameSession return new SceneResult(vm.Emitted.ToList(), vm.HaltReason, vm.Steps); } - /// Serialize the persistent state to JSON (globals + string globals, keyed by decimal address). - /// Lets an expensive booted state be snapshotted and reused; foundation for save-file work. + /// Serialize the persistent global banks to JSON, keyed by decimal address. + /// Lets an expensive booted state be snapshotted and reused. Live is + /// intentionally excluded until the unified save/profile architecture defines its lifecycle. public string ToJson() { var snap = new StateSnapshot( diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 2426011..fa28fe2 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -34,6 +34,7 @@ public sealed class VirtualMachine private long _autoMessageTime1Ms = 2000; private bool _autoVoicePending; private volatile bool _messageSkipEnabled; + private AdvTextStyle _advTextStyle = AdvTextStyle.Default; public long CallScriptDispatches { get; private set; } public Dictionary Globals { get; } = new(); @@ -46,11 +47,13 @@ public sealed class VirtualMachine public long Steps { get; private set; } public bool AutoMessageEnabled => _autoMessageEnabled; public bool MessageSkipEnabled => _messageSkipEnabled; + public AdvTextHistory TextHistory { get; } public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, - IScriptProvider? provider = null, ITraceSink? sink = null) + IScriptProvider? provider = null, ITraceSink? sink = null, + AdvTextHistory? textHistory = null) { _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; - _sink = sink ?? NullTraceSink.Instance; } + _sink = sink ?? NullTraceSink.Instance; TextHistory = textHistory ?? new AdvTextHistory(); } /// Update the native 800x600 cursor coordinate without advancing the current ADV page. public void UpdatePointer(int x, int y) @@ -436,10 +439,20 @@ public sealed class VirtualMachine if (c > _o.EmitCap) { HaltReason = $"LOOP:line@0x{off:x}×{c}"; return HALT; } string text = _cur.Script.GetString(off); Emitted.Add((off, text, _cur.Script.Name)); + int layoutSlot = a.Count > 0 ? (int)Read(a[0]) : 0; + TextHistory.AppendText(layoutSlot, off, text, _advTextStyle); _host.ShowText(off, text); } return pc + 1; + case "define-adv-text-layout": // 0x70: configure layout and begin a logical retained group + TextHistory.DefineLayout((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), + (int)Read(a[3]), (int)Read(a[4])); + return pc + 1; + case "reset-adv-text-layout": // 0x71: reset layout and begin the next logical retained group + TextHistory.ResetLayout((int)Read(a[0])); + return pc + 1; case "set-adv-text-cursor": // 0x7a (layout slot, x, y); slot 0 means current natively + TextHistory.SetCursor((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); _host.SetAdvTextCursor((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1; case "configure-adv-wait-indicator": // 0x73: per-layout animated input-wait marker _host.ConfigureAdvWaitIndicator(new AdvWaitIndicatorConfig( @@ -601,6 +614,32 @@ public sealed class VirtualMachine case "block-mark": case "reset-message-voice-state": // 0x1bc resets native per-message voice/queued-voice state _autoVoicePending = false; return pc + 1; + case "set-text-history-recording": // 0x1bb: HISTORY.BIN suppresses recording its own UI + if (Read(a[0]) is 0 or 1) TextHistory.SetRecordingEnabled(Read(a[0]) == 1); + return pc + 1; + case "append-text-history-metadata": // 0x1d2: typed value attached to the current group + TextHistory.AppendMetadata(Read(a[0]), Read(a[1]), _advTextStyle); return pc + 1; + case "clear-text-history": // 0x85: bound the backlog to the current ordinary ADV block + TextHistory.Clear(); return pc + 1; + case "set-font-size": + _advTextStyle = _advTextStyle with { PrimaryFontSize = (int)Read(a[0]) }; return pc + 1; + case "set-ruby-font-size": + _advTextStyle = _advTextStyle with { RubyFontSize = (int)Read(a[0]) }; return pc + 1; + case "set-font-bold": + _advTextStyle = _advTextStyle with { Bold = Read(a[0]) != 0 }; return pc + 1; + case "set-text-color": + _advTextStyle = _advTextStyle with { TextColor = Read(a[0]) }; return pc + 1; + case "set-text-effect-color": + _advTextStyle = _advTextStyle with { EffectColor = Read(a[0]) }; return pc + 1; + case "set-text-render-mode": + _advTextStyle = _advTextStyle with { RenderMode = (int)Read(a[0]) }; return pc + 1; + case "set-text-effect-offset": + _advTextStyle = _advTextStyle with + { + EffectOffsetX = (int)Read(a[0]), + EffectOffsetY = (int)Read(a[1]) + }; + return pc + 1; case "u00415BF0": case "reset-message-skip-input": // 0x101 clears transient input/run bits, not op 0x88 state return pc + 1; @@ -632,6 +671,7 @@ public sealed class VirtualMachine case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1; case "play-voice": _autoVoicePending = true; + TextHistory.AppendVoice(Read(a[0]), 0, _advTextStyle); _host.PlayVoice(Read(a[0])); return pc + 1; case "play-sound-effect": // 0xb4 / semantics: sfx-load _host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1;