From 32a67b88dedcb2180f2cdd3bb363a81122602c68 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 18 Jul 2026 23:59:26 -0400 Subject: [PATCH] Implement visible ADV History presentation --- docs/engine-re.md | 30 +++++- docs/opcode-reference.md | 2 +- docs/phase-a-slice-plan.md | 24 +++++ .../HistoryPresentationOpsTests.cs | 102 ++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 12 +++ engine/Age.Engine/Hosting/IHost.cs | 10 ++ engine/Age.Engine/Model/AdvTextHistory.cs | 60 +++++++++++ engine/Age.Engine/Vm/VirtualMachine.cs | 29 ++++- godot/GodotAdvHost.cs | 70 +++++++++++- godot/Main.cs | 99 +++++++++++++++-- vm-map/opcodes.toml | 2 +- 11 files changed, 420 insertions(+), 20 deletions(-) create mode 100644 engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs diff --git a/docs/engine-re.md b/docs/engine-re.md index 6791b87..09a7149 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1310,11 +1310,11 @@ future numbered-save integration seam, not a reason to choose a shared-profile b History button can be complete first, while save/load restoration stays deferred with the wider storage architecture decision. -The remaining supporting gaps are ordinary presentation operations: primary/ruby font sizes (`0x75/0x197`), -font weight (`0x2bd`), colors/effect mode/offset (`0x76/0x77/0x78/0x1a4`), layout origin (`0x198`), surface -rectangle fill (`0x20b`), message-window alpha (`0x131`), and retained-object presentation (`0x222`). The -`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. +History's display support consists of the ordinary presentation operations: primary/ruby font sizes +(`0x75/0x197`), font weight (`0x2bd`), colors/effect mode/offset (`0x76/0x77/0x78/0x1a4`), layout origin +(`0x198`), surface rectangle fill (`0x20b`), message-window alpha (`0x131`), and retained-object presentation +(`0x222`). The `0xd3/0xd4/0xd5` callback-sequence family drives the smooth scrollbar interpolation; omitting +it affects motion fidelity, not the backlog data model. 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 @@ -1334,6 +1334,26 @@ and does not mutate it. Query scans stop at the next group-start record; their t the native helpers. History's remaining work is therefore presentation and interaction rather than backlog data access. +The third port slice implements visible History presentation without moving canonical records into Godot. +Op `0x1d1` asks `AdvTextHistory` to select one retained group, skip metadata/voice records, and build a +host-facing render batch using the target layout configured by `0x70/0x71`, positioned by `0x198`, and +seeded by `0x7a`. `HISTORY.BIN` passes zero for flags and both color overrides, so this is its exact ordinary +binding path; the native flag-4 raster-without-binding and flag-8 side-effect modes remain outside the live +game call site. Godot owns only replaceable labels keyed by target layout, applies the retained font/color/ +effect snapshot, and clears stale labels whenever `0x71` resets that layout. + +The same slice implements the support calls at their natural seams. Op `0x131` reads a host configuration +property (currently defaulting to zero; choosing a profile/config persistence backend remains deferred), +`0x20b` clears the addressed region of the mutable name-strip text surface, and `0x222` requests one retained +recomposition boundary. Surface text is now a list rather than one draw per slot: HISTORY's five 600x30 +source rows therefore retain five independent speaker names, and compositor source-rectangle clipping maps +each one into its bound object. A real-script regression retains SC0000 page one, runs unmodified +`HISTORY.BIN`, observes a non-empty rendered row, and reaches its `(0,60000)` presentation call. + +History's remaining work is interaction fidelity: `0x12e` rectangle hover/hit selection, stored voice replay +through `0x1bd`, and the `0xd3/0xd4/0xd5` smooth-scroll callback scheduler. None changes backlog ownership or +requires choosing a save/profile backend. + 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 now complete. Read-message Skip's semantics remain understood, but its implementation is deferred until the diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index b725f5f..192a177 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -321,7 +321,7 @@ SC0000 label_1235a reaches this when 0x1c7/0x1cc are zero. Native run-state bit ### 0x20b `fill-surface-rect` (fill-surface-rect, argc 7) - **summary:** (surface_slot)(x)(y)(width)(height)(alpha)(rgb) - fill a clipped rectangle on a graphics surface with the supplied color and alpha. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0x20b_handler@0x422d50 converts x/y/width/height to an exclusive rectangle, clamps alpha to 255, repacks RGB to native ARGB, and calls the surface manager's rectangle-fill path at 0x4790e0. HISTORY.BIN clears each 400x30 name strip on temporary surface 0xc1 before draw-string. +- **evidence:** Ghidra /v2: op_0x20b_handler@0x422d50 converts x/y/width/height to an exclusive rectangle, clamps alpha to 255, repacks RGB to native ARGB, and calls the surface manager's rectangle-fill path at 0x4790e0. HISTORY.BIN clears each 600x30 name strip on temporary surface 0xc1 before draw-string. ### 0x20c `present-frame` (present-frame, argc 0) - **summary:** Present the composited frame; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately. diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 7a19dd6..f008b29 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1728,3 +1728,27 @@ remaining effectful gaps total 21 instructions. **Next:** implement History's display slice around `0x1d1`, layout origin `0x198`, message-window alpha `0x131`, surface fill `0x20b`, and retained-object presentation `0x222`. After visible backlog rows render, finish rectangle hover/interaction, voice replay, and the `0xd3/0xd4/0xd5` smooth-scroll scheduler details. + +### ADV History visible display implemented (2026-07-18) + +The third bounded slice is complete. Op `0x1d1` now selects one retained group and produces an engine-owned +render batch for the requested target text layout. `0x198` updates that layout's origin, `0x71` clears stale +host presentation, and the existing cursor/style setters provide the target baseline and retained font, +color, outline, and effect state. The live `HISTORY.BIN` call passes flags/colors as zero, so the port renders +its ordinary bound-text path without inventing behavior for unused native special modes. + +Godot maintains per-layout History labels while the engine remains the canonical backlog owner. The surface- +text path was generalized from one string per surface to clipped lists, allowing all five speaker-name rows +drawn into surface `0xc1` to coexist. Op `0x20b` clears each reused 600x30 name region, op `0x131` reads a +host configuration seam with a backend-neutral default, and op `0x222` explicitly publishes the retained +rebuild. No persistence, RT.DAT, or port-owned profile format was introduced. + +Four focused regressions cover target origin/cursor/style, alpha/fill/present operand shaping, layout reset, +and an end-to-end real-script path: real SC0000 page-one records are shared into unmodified `HISTORY.BIN`, +which produces a non-empty visible row and reaches `(0,60000)` presentation without a step-cap halt. +Validation: engine 194/194, zero-warning Godot build, threaded `SELFTEST OK`. HISTORY is now 68/78 distinct +opcodes and 840/854 instructions handled or safe-noop; its ten remaining effectful gaps total 14 instructions. + +**Next:** implement the basic History interaction slice around rectangle hit/hover (`0x12e`) and clean exit +using the existing callback layer. Then add stored voice replay (`0x1bd`); leave `0xd3/0xd4/0xd5` smooth +scroll interpolation as a separate fidelity slice. diff --git a/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs b/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs new file mode 100644 index 0000000..e53c235 --- /dev/null +++ b/engine/Age.Engine.Tests/HistoryPresentationOpsTests.cs @@ -0,0 +1,102 @@ +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; + +public class HistoryPresentationOpsTests +{ + private const int T_IMM = 0, T_GINT = 3; + private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand I(long value) => new(T_IMM, value); + private static Operand G(int address) => new(T_GINT, address); + + [Fact] + public void RenderHistoryUsesTheSelectedTargetLayoutAndOneRetainedGroup() + { + var history = new AdvTextHistory(); + history.DefineLayout(1, 640, 160, 80, 430); + history.AppendMetadata(9, 2, AdvTextStyle.Default); + history.AppendText(0, 10, "retained dialogue", new AdvTextStyle(24, 8, false, + 0xffffff, 0x606060, 3, 1, 1)); + history.ResetLayout(1); + history.AppendText(0, 11, "next group", AdvTextStyle.Default); + history.SetRecordingEnabled(false); + history.DefineLayout(4, 600, 150, 0, 0); + history.SetRecordingEnabled(true); + + var script = ScriptAssembler.Assemble(Table, "HISTORY_RENDER", + new List<(int, Operand[])> + { + (0x198, new[] { I(4), I(65), I(150) }), + (0x7a, new[] { I(4), I(45), I(42) }), + (0x1d1, new[] { I(4), I(0), I(0), I(0), I(0) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, Table, host, textHistory: history); + + vm.Run(); + + var render = Assert.Single(host.HistoryRenders); + Assert.Equal((4, 0, "retained dialogue"), + (render.LayoutSlot, render.FirstRecordIndex, render.Text)); + Assert.Equal(new AdvTextLayoutSnapshot(4, 600, 150, 65, 150, 45, 42), render.Layout); + Assert.Equal((24, 0xffffffL, 0x606060L), + (render.Style.PrimaryFontSize, render.Style.TextColor, render.Style.EffectColor)); + } + + [Fact] + public void HistorySupportingPresentationOpsReachTheHostWithNativeShaping() + { + var script = ScriptAssembler.Assemble(Table, "HISTORY_PRESENT_SUPPORT", + new List<(int, Operand[])> + { + (0x131, new[] { G(0x100) }), + (0x20b, new[] { I(0xc1), I(0), I(30), I(600), I(30), I(999), I(0x123456) }), + (0x222, new[] { I(0), I(60000) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost { MessageWindowAlphaSetting = 6 }; + var vm = new VirtualMachine(script, Table, host); + + vm.Run(); + + Assert.Equal(6, vm.Globals[0x100]); + Assert.Equal(new SurfaceRectFill(0xc1, 0, 30, 600, 30, 255, 0x123456), + Assert.Single(host.SurfaceFills)); + Assert.Equal((0L, 60000L), Assert.Single(host.PresentedRanges)); + } + + [Fact] + public void ResetLayoutClearsItsPreviouslyBoundHostPresentation() + { + var script = ScriptAssembler.Assemble(Table, "HISTORY_CLEAR_RENDER", + new List<(int, Operand[])> + { + (0x71, new[] { I(5) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var host = new RecordingHost(); + + new VirtualMachine(script, Table, host).Run(); + + Assert.Equal(5, Assert.Single(host.ClearedTextLayouts)); + } + + [Fact] + public void RealHistoryScriptBuildsVisibleRowsFromARealSc0000Page() + { + var scripts = Sys4ScriptProvider.Load(Table); + var session = new GameSession(); + session.RunScene(scripts.RequireByName("SC0000.BIN"), Table, new RecordingHost(), + new VmOptions(MaxSteps: 1_000_000, HaltAtWaitForInput: true), scripts); + var host = new RecordingHost(); + + var result = session.RunScene(scripts.RequireByName("HISTORY.BIN"), Table, host, + new VmOptions(MaxSteps: 2_000_000, HaltAtWaitForInput: true), scripts); + + Assert.NotEqual("step-cap", result.Halt); + Assert.Contains(host.HistoryRenders, render => render.Text.Length > 0); + Assert.Contains(host.PresentedRanges, range => range == (0L, 60000L)); + } +} diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 2ac44cc..cd50526 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -17,6 +17,10 @@ internal class RecordingHost : IHost public readonly List<(int Offset, string Text)> Lines = new(); public readonly List<(int Slot, int X, int Y)> TextCursors = new(); public readonly List<(int Surface, int X, int Y, string Text)> SurfaceStrings = new(); + public readonly List HistoryRenders = new(); + public readonly List ClearedTextLayouts = new(); + public readonly List SurfaceFills = new(); + public readonly List<(long First, long Count)> PresentedRanges = new(); public readonly List WaitIndicators = new(); public readonly List SleptDurations = new(); public readonly List<(long Resource, int Channel)> SfxLoads = new(); @@ -32,6 +36,14 @@ internal class RecordingHost : IHost public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y)); public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) => SurfaceStrings.Add((surfaceSlot, x, y, text)); + public void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style) + => SurfaceStrings.Add((surfaceSlot, x, y, text)); + public void ClearRenderedAdvTextLayout(int layoutSlot) => ClearedTextLayouts.Add(layoutSlot); + public void RenderTextHistory(AdvTextHistoryRenderBatch batch) => HistoryRenders.Add(batch); + public int MessageWindowAlphaSetting { get; set; } + public void FillSurfaceRect(SurfaceRectFill fill) => SurfaceFills.Add(fill); + public void PresentObjectRange(GfxState gfx, long firstHandle, long count) + => PresentedRanges.Add((firstHandle, count)); public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) => WaitIndicators.Add(config); public void SetAdvPagePresentationSuspended(bool suspended) => AdvPagePresentationSuspended.Add(suspended); diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index a05efa1..3d07bc5 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -10,6 +10,9 @@ public readonly record struct AdvWaitIndicatorConfig( public readonly record struct AdvAutoWaitState( bool Enabled, bool VoicePending, long PostVoiceDelayMs, long UnvoicedDelayMs); +public readonly record struct SurfaceRectFill( + int SurfaceSlot, int X, int Y, int Width, int Height, int Alpha, long Rgb); + public interface IHost { void ShowText(int offset, string text); @@ -17,6 +20,13 @@ public interface IHost // op 0x204 rasterizes a string into a numbered surface before 0x1fb binds that surface. void SetAdvTextCursor(int layoutSlot, int x, int y) { } void DrawStringToSurface(int surfaceSlot, int x, int y, string text) { } + void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style) + => DrawStringToSurface(surfaceSlot, x, y, text); + void ClearRenderedAdvTextLayout(int layoutSlot) { } + void RenderTextHistory(AdvTextHistoryRenderBatch batch) { } + int MessageWindowAlphaSetting => 0; + void FillSurfaceRect(SurfaceRectFill fill) { } + void PresentObjectRange(GfxState gfx, long firstHandle, long count) { } void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { } // Op 0x199 temporarily yields the active ADV page into its registered hide-window coroutine. // The retained scene continues to render, but the text layout and its wait marker are suspended diff --git a/engine/Age.Engine/Model/AdvTextHistory.cs b/engine/Age.Engine/Model/AdvTextHistory.cs index f5173bb..3bc1a24 100644 --- a/engine/Age.Engine/Model/AdvTextHistory.cs +++ b/engine/Age.Engine/Model/AdvTextHistory.cs @@ -58,6 +58,15 @@ public sealed record AdvTextHistoryRecord( /// AGE's logical 8-byte history index entry. public readonly record struct AdvTextHistoryEntry(int LayoutSlot, int FirstRecordIndex); +/// The host-facing result of rendering one retained history group into a target ADV layout. +public sealed record AdvTextHistoryRenderBatch( + int LayoutSlot, + int FirstRecordIndex, + int Flags, + AdvTextLayoutSnapshot Layout, + string Text, + AdvTextStyle Style); + /// /// 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. @@ -115,6 +124,14 @@ public sealed class AdvTextHistory layout.CursorY = y; } + public void SetLayoutOrigin(int requestedSlot, int x, int y) + { + int slot = ResolveLayout(requestedSlot); + var layout = GetOrCreateLayout(slot); + layout.OriginX = x; + layout.OriginY = y; + } + public void AppendText(int requestedSlot, int sourceOffset, string text, AdvTextStyle style, AdvTextHistoryRecordFlags flags = AdvTextHistoryRecordFlags.None) { @@ -214,6 +231,42 @@ public sealed class AdvTextHistory return found; } + /// + /// Build the ordinary bound-text result of native op 0x1d1. The game-facing HISTORY path passes flags + /// and colors as zero: metadata and voice records are skipped, while adjacent text records in the same + /// group are emitted continuously into the selected target layout. + /// + public bool TryBuildRenderBatch(int requestedLayoutSlot, int firstRecordIndex, int flags, + long overrideTextColor, long overrideEffectColor, + out AdvTextHistoryRenderBatch batch) + { + batch = null!; + if ((uint)firstRecordIndex >= (uint)_records.Count) return false; + + int slot = ResolveLayout(requestedLayoutSlot); + var target = SnapshotLayout(slot); + var text = new System.Text.StringBuilder(); + AdvTextStyle style = AdvTextStyle.Default; + bool haveStyle = false; + foreach (var record in EnumerateGroup(firstRecordIndex)) + { + if (record.Flags.HasFlag(AdvTextHistoryRecordFlags.NavigationFiltered) && (flags & 1) == 0) + break; + if (record.Kind != AdvTextHistoryRecordKind.Text) continue; + if (!haveStyle) + { + style = record.Style; + haveStyle = true; + } + text.Append(record.Text); + } + + if ((flags & 2) != 0) + style = style with { TextColor = overrideTextColor, EffectColor = overrideEffectColor }; + batch = new AdvTextHistoryRenderBatch(slot, firstRecordIndex, flags, target, text.ToString(), style); + return true; + } + private int SelectLayout(int requestedSlot) { int slot = requestedSlot == 0 ? CurrentLayoutSlot : requestedSlot; @@ -233,6 +286,13 @@ public sealed class AdvTextHistory return layout; } + private AdvTextLayoutSnapshot SnapshotLayout(int slot) + { + var layout = GetOrCreateLayout(slot); + return new AdvTextLayoutSnapshot(slot, layout.Width, layout.Height, + layout.OriginX, layout.OriginY, layout.CursorX, layout.CursorY); + } + private void AppendBoundary(int slot) { if (RecordingSuppressed) return; diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 698c869..d30111d 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -499,6 +499,7 @@ public sealed class VirtualMachine return pc + 1; case "reset-adv-text-layout": // 0x71: reset layout and begin the next logical retained group TextHistory.ResetLayout((int)Read(a[0])); + _host.ClearRenderedAdvTextLayout((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])); @@ -510,7 +511,8 @@ public sealed class VirtualMachine (int)Read(a[8]), Read(a[9]))); return pc + 1; case "draw-string": // 0x204 (surface slot, x, y, string) - _host.DrawStringToSurface((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), ReadStr(a[3])); + _host.DrawStringToSurface((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), ReadStr(a[3]), + _advTextStyle); return pc + 1; case "wait-for-input": // Faithful headless: no player => halt here rather than plow past every prompt (see VmOptions). @@ -680,6 +682,15 @@ public sealed class VirtualMachine Write(a[1], -1); } return pc + 1; + case "render-text-history": // 0x1d1: rasterize/bind one retained group to a target layout + case "u0041BAE0": + { + int flags = (int)Read(a[2]); + if ((flags & 4) == 0 && TextHistory.TryBuildRenderBatch( + (int)Read(a[0]), (int)Read(a[1]), flags, Read(a[3]), Read(a[4]), out var batch)) + _host.RenderTextHistory(batch); + return pc + 1; + } case "u0041BB90": case "find-text-history-value": // 0x1d3: operand 3 is accepted but ignored natively { @@ -715,6 +726,13 @@ public sealed class VirtualMachine EffectOffsetY = (int)Read(a[1]) }; return pc + 1; + case "set-adv-text-layout-origin": // 0x198; slot 0 selects the current layout + case "u0041B540": + TextHistory.SetLayoutOrigin((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); + return pc + 1; + case "get-message-window-alpha": // 0x131: host profile/config seam; default host currently supplies 0 + case "u00415F70": + Write(a[0], _host.MessageWindowAlphaSetting); return pc + 1; case "u00415BF0": case "reset-message-skip-input": // 0x101 clears transient input/run bits, not op 0x88 state return pc + 1; @@ -743,6 +761,12 @@ public sealed class VirtualMachine Write(a[1], gw); Write(a[2], gh); return pc + 1; } + case "fill-surface-rect": // 0x20b: clipped alpha/RGB fill of a mutable surface + case "u00420D50": + _host.FillSurfaceRect(new SurfaceRectFill( + (int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), + (int)System.Math.Min(Read(a[5]), 255), Read(a[6]) & 0x00ff_ffff)); + return pc + 1; case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1; case "play-voice": _autoVoicePending = true; @@ -879,6 +903,9 @@ public sealed class VirtualMachine _host.WaitForForegroundTransition(Gfx); return pc + 1; case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue return pc + 1; + case "present-gfx-object-range": // 0x222: publish pending retained changes in the selected range + case "u004216C0": + _host.PresentObjectRange(Gfx, Read(a[0]), Read(a[1])); return pc + 1; default: // Stub is per-instruction frequency (the VM handles ~30 ops; the rest hit here, e.g. // 0x258/0x259 stmt markers appear en masse), so gate it with Step — else --trace floods. diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index d0de92d..f003574 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.Versioning; using System.Threading; using Age.Engine.Hosting; @@ -30,7 +31,8 @@ public sealed class GodotAdvHost : IHost private readonly System.Threading.AutoResetEvent _frameSignal = new(false); private volatile bool _stopping; private readonly object _textLock = new(); - private readonly Dictionary _surfaceText = new(); + private readonly Dictionary> _surfaceText = new(); + private readonly Dictionary _historyText = new(); private string _advText = ""; private int _advTextX = 100, _advTextY = 47; private int _currentAdvLayout = 1; // SYSTEM4's ordinary SC0000 ADV layout @@ -99,8 +101,17 @@ public sealed class GodotAdvHost : IHost } public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) + => DrawStringToSurface(surfaceSlot, x, y, text, AdvTextStyle.Default); + + public void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style) { - lock (_textLock) _surfaceText[surfaceSlot] = new SurfaceTextDraw(x, y, text); + lock (_textLock) + { + if (!_surfaceText.TryGetValue(surfaceSlot, out var draws)) + _surfaceText[surfaceSlot] = draws = new List(); + draws.RemoveAll(draw => draw.X == x && draw.Y == y); + draws.Add(new SurfaceTextDraw(x, y, text, style)); + } _timeline?.Event("draw-string", new() { ["surface"] = surfaceSlot, ["x"] = x, ["y"] = y, ["text"] = text }); } @@ -115,8 +126,57 @@ public sealed class GodotAdvHost : IHost } } - public bool TryGetSurfaceText(int surfaceSlot, out SurfaceTextDraw draw) - { lock (_textLock) return _surfaceText.TryGetValue(surfaceSlot, out draw); } + public IReadOnlyList SnapshotSurfaceText(int surfaceSlot) + { + lock (_textLock) + return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty(); + } + + public void ClearRenderedAdvTextLayout(int layoutSlot) + { + lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot); + } + + public void RenderTextHistory(AdvTextHistoryRenderBatch batch) + { + lock (_textLock) _historyText[batch.LayoutSlot] = batch; + _timeline?.Event("history-render", new() + { + ["layout"] = batch.LayoutSlot, ["record"] = batch.FirstRecordIndex, + ["x"] = batch.Layout.OriginX + batch.Layout.CursorX, + ["y"] = batch.Layout.OriginY + batch.Layout.CursorY, + ["text"] = batch.Text, + }); + } + + public IReadOnlyList SnapshotRenderedTextHistory() + { + lock (_textLock) return _historyText.Values.OrderBy(batch => batch.LayoutSlot).ToArray(); + } + + public int MessageWindowAlphaSetting => 0; + + public void FillSurfaceRect(SurfaceRectFill fill) + { + lock (_textLock) + { + if (!_surfaceText.TryGetValue(fill.SurfaceSlot, out var draws)) return; + int right = fill.X + System.Math.Max(0, fill.Width); + int bottom = fill.Y + System.Math.Max(0, fill.Height); + draws.RemoveAll(draw => draw.X >= fill.X && draw.X < right && draw.Y >= fill.Y && draw.Y < bottom); + } + _timeline?.Event("surface-fill", new() + { + ["surface"] = fill.SurfaceSlot, ["x"] = fill.X, ["y"] = fill.Y, + ["w"] = fill.Width, ["h"] = fill.Height, ["alpha"] = fill.Alpha, ["rgb"] = fill.Rgb, + }); + } + + public void PresentObjectRange(GfxState gfx, long firstHandle, long count) + { + System.Threading.Interlocked.Exchange(ref _presentRequested, 1); + _timeline?.Event("present-object-range", new() { ["first"] = firstHandle, ["count"] = count }); + } public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { @@ -608,6 +668,6 @@ public sealed class GodotAdvHost : IHost } } -public readonly record struct SurfaceTextDraw(int X, int Y, string Text); +public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style); public readonly record struct AdvWaitIndicatorSnapshot( RgbaImage Image, string Name, int AssetId, AdvWaitIndicatorConfig Config, int Frame); diff --git a/godot/Main.cs b/godot/Main.cs index 542dfb9..d8ddfa0 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -27,6 +27,10 @@ public partial class Main : Godot.Control private readonly byte[] _screenPixels = new byte[ScreenWidth * ScreenHeight * 4]; private Label _text = null!; private Label _speaker = null!; + private readonly System.Collections.Generic.List