Implement visible ADV History presentation

This commit is contained in:
gamer147
2026-07-18 23:59:26 -04:00
parent ac5437c5cf
commit 32a67b88de
11 changed files with 420 additions and 20 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<Operand>()),
}, Array.Empty<string>());
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<Operand>()),
}, Array.Empty<string>());
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<Operand>()),
}, Array.Empty<string>());
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));
}
}

View File

@@ -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<AdvTextHistoryRenderBatch> HistoryRenders = new();
public readonly List<int> ClearedTextLayouts = new();
public readonly List<SurfaceRectFill> SurfaceFills = new();
public readonly List<(long First, long Count)> PresentedRanges = new();
public readonly List<AdvWaitIndicatorConfig> WaitIndicators = new();
public readonly List<long> 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);

View File

@@ -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

View File

@@ -58,6 +58,15 @@ public sealed record AdvTextHistoryRecord(
/// <summary>AGE's logical 8-byte history index entry.</summary>
public readonly record struct AdvTextHistoryEntry(int LayoutSlot, int FirstRecordIndex);
/// <summary>The host-facing result of rendering one retained history group into a target ADV layout.</summary>
public sealed record AdvTextHistoryRenderBatch(
int LayoutSlot,
int FirstRecordIndex,
int Flags,
AdvTextLayoutSnapshot Layout,
string Text,
AdvTextStyle Style);
/// <summary>
/// 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;
}
/// <summary>
/// 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.
/// </summary>
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;

View File

@@ -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.

View File

@@ -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<int, SurfaceTextDraw> _surfaceText = new();
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _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<SurfaceTextDraw>();
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<SurfaceTextDraw> SnapshotSurfaceText(int surfaceSlot)
{
lock (_textLock)
return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>();
}
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<AdvTextHistoryRenderBatch> 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);

View File

@@ -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<Label> _surfaceTextLabels = new();
private readonly System.Collections.Generic.Dictionary<int, Label> _historyTextLabels = new();
private Font? _presentationRegularFont;
private FontVariation? _presentationBoldFont;
private Label _status = null!;
private Label _locatorHud = null!;
private AudioStreamPlayer _bgm = null!; // looping background music
@@ -100,6 +104,7 @@ public partial class Main : Godot.Control
_speaker = new Label { MouseFilter = MouseFilterEnum.Ignore, Visible = false };
_speaker.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
AddChild(_speaker);
_surfaceTextLabels.Add(_speaker);
_status = new Label();
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
_status.OffsetLeft = 40; _status.OffsetTop = -60;
@@ -285,6 +290,7 @@ public partial class Main : Godot.Control
Recomposite(); // native publishes retained mutations only at present/service boundaries
if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
@@ -445,7 +451,8 @@ public partial class Main : Godot.Control
private void Recomposite()
{
System.Array.Clear(_screenPixels);
_speaker.Visible = false;
foreach (var label in _surfaceTextLabels) label.Visible = false;
int surfaceTextLabelIndex = 0;
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
int z = 0;
var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // one synchronized sample for objects + ranges
@@ -506,13 +513,21 @@ public partial class Main : Godot.Control
}
decisions?.Add(v.Handle, $"z{z} {outcome}");
var rawObject = _vm.Gfx.TryGet(v.Handle);
if (rawObject != null && _host.TryGetSurfaceText(rawObject.SourceSlot, out var surfaceText))
if (rawObject != null)
{
var textPos = localToDest.Apply(surfaceText.X, surfaceText.Y);
_speaker.Position = new Vector2((float)textPos.X, (float)textPos.Y);
_speaker.Size = new Vector2(System.Math.Max(1, v.W - surfaceText.X), System.Math.Max(1, v.H - surfaceText.Y));
_speaker.Text = surfaceText.Text;
_speaker.Visible = true;
foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot))
{
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
var textPos = localToDest.Apply(surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY);
var label = GetSurfaceTextLabel(surfaceTextLabelIndex++);
label.Position = new Vector2((float)textPos.X, (float)textPos.Y);
label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)),
System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY)));
label.Text = surfaceText.Text;
ApplyAdvTextStyle(label, surfaceText.Style);
label.Visible = true;
}
}
z++;
}
@@ -532,6 +547,76 @@ public partial class Main : Godot.Control
_text.Text = count == 0 ? "" : t.Text[..count];
}
private void UpdateHistoryTextPresentation()
{
foreach (var label in _historyTextLabels.Values) label.Visible = false;
foreach (var batch in _host.SnapshotRenderedTextHistory())
{
if (batch.Text.Length == 0) continue;
if (!_historyTextLabels.TryGetValue(batch.LayoutSlot, out var label))
{
label = CreateAdvPresentationLabel();
_historyTextLabels.Add(batch.LayoutSlot, label);
}
var layout = batch.Layout;
label.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
label.Size = new Vector2(System.Math.Max(1, layout.Width - layout.CursorX),
System.Math.Max(1, layout.Height - layout.CursorY));
label.Text = batch.Text;
ApplyAdvTextStyle(label, batch.Style);
label.Visible = true;
}
}
private Label GetSurfaceTextLabel(int index)
{
while (_surfaceTextLabels.Count <= index) _surfaceTextLabels.Add(CreateAdvPresentationLabel());
return _surfaceTextLabels[index];
}
private Label CreateAdvPresentationLabel()
{
var label = new Label
{
MouseFilter = MouseFilterEnum.Ignore,
Visible = false,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
ClipText = true,
};
label.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
label.AddThemeFontOverride("font", _text.GetThemeFont("font"));
AddChild(label);
return label;
}
private void ApplyAdvTextStyle(Label label, AdvTextStyle style)
{
int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
_presentationRegularFont ??= _text.GetThemeFont("font");
if (style.Bold)
{
_presentationBoldFont ??= new FontVariation
{
BaseFont = _presentationRegularFont,
VariationEmbolden = 1.2f,
};
label.AddThemeFontOverride("font", _presentationBoldFont);
}
else label.AddThemeFontOverride("font", _presentationRegularFont);
label.AddThemeFontSizeOverride("font_size", fontSize);
label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White));
label.AddThemeColorOverride("font_outline_color", RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f)));
int outline = style.RenderMode == 0 ? 0 : System.Math.Max(1,
System.Math.Max(System.Math.Abs(style.EffectOffsetX), System.Math.Abs(style.EffectOffsetY)));
label.AddThemeConstantOverride("outline_size", outline);
}
private static Color RgbColor(long rgb, Color fallback)
{
if ((rgb & 0x00ff_ffff) == 0) return fallback;
return new Color(((rgb >> 16) & 0xff) / 255f, ((rgb >> 8) & 0xff) / 255f, (rgb & 0xff) / 255f, 1);
}
private void UpdateAdvWaitIndicatorPresentation()
{
var snapshot = _host.SnapshotAdvWaitIndicator();

View File

@@ -5070,7 +5070,7 @@ noop_headless = false
source = "investigation"
confidence = "high"
depends_on = []
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."
[[opcode.semantics.args]]
i = 1