feat(a2b): screen-backbuffer blit compositor + real GetTextureSize

Godot host: source images kept per slot with dims read from the BMP header on
the VM thread (synchronous, so the bytecode's geometry math sees real sizes);
draw-texture blits src rect -> dst into one 800x600 canvas in execution order,
shown by a single TextureRect. Selftest byte-parity preserved.

Slot 0 = primary/screen surface: seed its dims to 800x600 (the boot-time
create-texture the single-scene harness skips) and record create-texture(w,h),
so the first CG's anchor-preserve math stays an identity instead of corrupting
the persistent base globals. Fixes the grey-first-CG opening. Verified: SC0000
pages 1/3 composite the opening event CG at (0,0) with dialogue over it.

Also adds a --shot <png> [--shot-page N] dev capture to Main (page-gated on a
VM-thread counter) for headless visual verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-06 23:02:58 -04:00
parent d1d470920c
commit 4eae9515fd
3 changed files with 90 additions and 42 deletions

View File

@@ -114,15 +114,17 @@ sealed class GfxTraceHost : IHost
private readonly ResourceMap _res; private readonly ResourceMap _res;
private readonly string _scene; private readonly string _scene;
private readonly Dictionary<int, string?> _slotBmp = new(); // slot -> resolved BMP path (or null) private readonly Dictionary<int, string?> _slotBmp = new(); // slot -> resolved BMP path (or null)
// slot -> dims. Slot 0 is the primary/screen surface (800x600), normally created at engine boot which
// the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0).
private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } };
public List<string> Events { get; } = new(); public List<string> Events { get; } = new();
public GfxTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } public GfxTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; }
public (int Width, int Height) GetTextureSize(int slot) public (int Width, int Height) GetTextureSize(int slot)
{ {
_slotBmp.TryGetValue(slot, out var bmp); var d = _slotDims.TryGetValue(slot, out var v) ? v : (0, 0);
var (w, h) = BmpHeader.ReadDims(bmp); Events.Add($"get-tex-size slot={slot} -> {d.Item1}x{d.Item2}");
Events.Add($"get-tex-size slot={slot} -> {w}x{h}"); return d;
return (w, h);
} }
public void SetTexture(long resId, int slot) public void SetTexture(long resId, int slot)
@@ -130,6 +132,7 @@ sealed class GfxTraceHost : IHost
var e = _res.Resolve(_scene, resId); var e = _res.Resolve(_scene, resId);
var bmp = e != null ? ResourceMap.TexturePath(e) : null; var bmp = e != null ? ResourceMap.TexturePath(e) : null;
_slotBmp[slot] = bmp; _slotBmp[slot] = bmp;
_slotDims[slot] = BmpHeader.ReadDims(bmp);
Events.Add($"set-texture slot={slot} res=0x{resId:x} -> {(e?.Name ?? "<unresolved>")}" Events.Add($"set-texture slot={slot} res=0x{resId:x} -> {(e?.Name ?? "<unresolved>")}"
+ (bmp == null ? " [NO BMP]" : "")); + (bmp == null ? " [NO BMP]" : ""));
} }
@@ -141,7 +144,11 @@ sealed class GfxTraceHost : IHost
+ $"file={(bmp != null ? System.IO.Path.GetFileName(bmp) : "<none>")}"); + $"file={(bmp != null ? System.IO.Path.GetFileName(bmp) : "<none>")}");
} }
public void CreateTexture(int slot, int width, int height) => Events.Add($"create-texture slot={slot} {width}x{height}"); public void CreateTexture(int slot, int width, int height)
{
_slotDims[slot] = (width, height);
Events.Add($"create-texture slot={slot} {width}x{height}");
}
public void ShowText(int offset, string text) { } public void ShowText(int offset, string text) { }
public void CallScript(long id) { } public void CallScript(long id) { }
public void OnStub(int opcode) { } public void OnStub(int opcode) { }

View File

@@ -9,6 +9,9 @@ public sealed class GodotAdvHost : IHost
private readonly ResourceMap _res; private readonly ResourceMap _res;
private readonly string _scene; // e.g. "SC0000" — for section_base private readonly string _scene; // e.g. "SC0000" — for section_base
private readonly Dictionary<int, string?> _slotBmp = new(); // slot -> pre-converted BMP path private readonly Dictionary<int, string?> _slotBmp = new(); // slot -> pre-converted BMP path
// slot -> dims. Slot 0 is the primary/screen surface (800x600), normally created at engine boot which
// the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0).
private readonly Dictionary<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } };
private readonly SemaphoreSlim _gate = new(0, 1); private readonly SemaphoreSlim _gate = new(0, 1);
public volatile bool IsWaiting; public volatile bool IsWaiting;
public readonly List<(int Offset, string Text)> Captured = new(); public readonly List<(int Offset, string Text)> Captured = new();
@@ -24,8 +27,11 @@ public sealed class GodotAdvHost : IHost
_main.CallDeferred("AppendLine", text); _main.CallDeferred("AppendLine", text);
} }
public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race)
public void WaitForInput() public void WaitForInput()
{ {
Pages++;
_main.CallDeferred("PageBreak"); _main.CallDeferred("PageBreak");
IsWaiting = true; IsWaiting = true;
_gate.Wait(); _gate.Wait();
@@ -40,23 +46,27 @@ public sealed class GodotAdvHost : IHost
public void OnStub(int opcode) { } public void OnStub(int opcode) { }
// ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ---- // ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ----
public void CreateTexture(int slot, int width, int height) => _slotBmp[slot] = null; public void CreateTexture(int slot, int width, int height) { _slotBmp[slot] = null; _slotDims[slot] = (width, height); }
public void SetTexture(long resourceId, int slot) public void SetTexture(long resourceId, int slot)
{ {
var asset = _res.Resolve(_scene, resourceId); var asset = _res.Resolve(_scene, resourceId);
_slotBmp[slot] = asset != null ? ResourceMap.TexturePath(asset) : null; var bmp = asset != null ? ResourceMap.TexturePath(asset) : null;
_slotBmp[slot] = bmp;
_slotDims[slot] = BmpHeader.ReadDims(bmp); // synchronous: dims from the header, no Godot Image
} }
// Dims are read from the BMP header on the VM thread so the bytecode's geometry math (which calls this
// synchronously right after set-texture) sees the real size. Pixels are blitted later on the main thread.
public (int Width, int Height) GetTextureSize(int slot)
=> _slotDims.TryGetValue(slot, out var d) ? (d.W, d.H) : (0, 0);
public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY)
{ {
if (_slotBmp.TryGetValue(slot, out var bmp) && bmp != null) if (_slotBmp.TryGetValue(slot, out var bmp) && bmp != null)
_main.CallDeferred("DrawSlot", slot, bmp, dstX, dstY, width, height); _main.CallDeferred("BlitSlot", bmp, srcX, srcY, width, height, dstX, dstY);
} }
// Temporary stub — replaced by the real BMP-header-backed impl in Task 3 (blit compositor).
public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
// ---- audio ops (OGG plays natively in Godot) ---- // ---- audio ops (OGG plays natively in Godot) ----
// BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: via the per-scene manifest. // BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: via the per-scene manifest.
public void PlayBgm(long id) public void PlayBgm(long id)

View File

@@ -7,8 +7,9 @@ using Age.Engine.Vm;
public partial class Main : Godot.Control public partial class Main : Godot.Control
{ {
private Control _stage = null!; // texture layer (behind the text) private TextureRect _screenView = null!; // shows the composited screen backbuffer
private readonly Dictionary<int, TextureRect> _slots = new(); private Image _screen = null!; // 800x600 immediate-mode canvas
private ImageTexture _screenTex = null!;
private Label _text = null!; private Label _text = null!;
private Label _status = null!; private Label _status = null!;
private AudioStreamPlayer _bgm = null!; // looping background music private AudioStreamPlayer _bgm = null!; // looping background music
@@ -18,14 +19,26 @@ public partial class Main : Godot.Control
private volatile bool _done; private volatile bool _done;
private bool _ended; private bool _ended;
private bool _selftest; private bool _selftest;
private string? _shotPath; // --shot <png>: capture a page then quit (dev tool)
private int _shotPage = 1; // --shot-page <n>: which page to capture (default 1)
private volatile int _pageCount;
private int _shotSettle;
private bool _shotDone;
public override void _Ready() public override void _Ready()
{ {
// texture stage, added first so it draws BEHIND the dialogue text // Screen backbuffer: one 800x600 canvas that draw-texture blits into, shown behind the dialogue.
_stage = new Control(); _screen = Image.CreateEmpty(800, 600, false, Image.Format.Rgba8);
_stage.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _screenTex = ImageTexture.CreateFromImage(_screen);
_stage.MouseFilter = MouseFilterEnum.Ignore; _screenView = new TextureRect
AddChild(_stage); {
Texture = _screenTex,
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.Scale,
MouseFilter = MouseFilterEnum.Ignore,
};
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
AddChild(_screenView); // added first -> draws behind the text/status labels
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart }; _text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart };
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
@@ -57,7 +70,13 @@ public partial class Main : Godot.Control
AddChild(_bgm); AddChild(_bgm);
AddChild(_voice); AddChild(_voice);
_selftest = System.Array.IndexOf(OS.GetCmdlineUserArgs(), "--selftest") >= 0; var userArgs = OS.GetCmdlineUserArgs();
_selftest = System.Array.IndexOf(userArgs, "--selftest") >= 0;
for (int i = 0; i < userArgs.Length; i++)
{
if (userArgs[i] == "--shot" && i + 1 < userArgs.Length) _shotPath = userArgs[i + 1];
if (userArgs[i] == "--shot-page" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _shotPage);
}
var table = OpcodeTableJson.Load(Paths.OpcodesJson); var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table);
@@ -67,10 +86,26 @@ public partial class Main : Godot.Control
if (_selftest) if (_selftest)
_ = Task.Run(async () => { while (!_done) { if (_host.IsWaiting) _host.SignalInput(); await Task.Delay(1); } }); _ = Task.Run(async () => { while (!_done) { if (_host.IsWaiting) _host.SignalInput(); await Task.Delay(1); } });
// --shot: auto-advance up to (but not past) the target page, then _Process captures + quits.
if (_shotPath != null)
_ = Task.Run(async () => { while (!_done) { if (_host.IsWaiting && _host.Pages < _shotPage) _host.SignalInput(); await Task.Delay(1); } });
} }
public override void _Process(double delta) public override void _Process(double delta)
{ {
// --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it.
if (_shotPath != null && !_shotDone && (_host.Pages >= _shotPage && _host.IsWaiting || _done))
{
if (++_shotSettle >= 3)
{
_shotDone = true;
var img = GetViewport().GetTexture().GetImage();
img.SavePng(_shotPath);
GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}");
GetTree().Quit(0);
}
return;
}
if (_done && !_ended) if (_done && !_ended)
{ {
_ended = true; _ended = true;
@@ -92,28 +127,24 @@ public partial class Main : Godot.Control
public override void _ExitTree() { _host?.SignalInput(); } public override void _ExitTree() { _host?.SignalInput(); }
// ---- UI methods invoked on the main thread via CallDeferred ---- // ---- UI methods invoked on the main thread via CallDeferred ----
// Composite a resolved texture into a slot at (x,y) sized (w,h). One TextureRect per slot, // Blit a source BMP (src rect) onto the screen backbuffer at (dstX,dstY), then refresh the display
// layered in draw order (backgrounds are drawn before sprites, so they sit behind). // texture. Execution order == paint order, so later draws (sprites) land over earlier ones (bg).
public void DrawSlot(int slot, string bmpPath, int x, int y, int w, int h) public void BlitSlot(string bmpPath, int srcX, int srcY, int w, int h, int dstX, int dstY)
{ {
var img = new Image(); var src = new Image();
var err = img.LoadBmpFromBuffer(System.IO.File.ReadAllBytes(bmpPath)); if (src.LoadBmpFromBuffer(System.IO.File.ReadAllBytes(bmpPath)) != Error.Ok)
if (err != Error.Ok) { GD.Print($"BMP load failed {bmpPath}: {err}"); return; } { GD.Print($"BMP load failed {bmpPath}"); return; }
if (!_slots.TryGetValue(slot, out var tr)) if (src.GetFormat() != Image.Format.Rgba8) src.Convert(Image.Format.Rgba8);
{
tr = new TextureRect // Clamp the source rect to the image; a zero/negative size falls back to the full image.
{ int sw = w > 0 ? w : src.GetWidth();
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, int sh = h > 0 ? h : src.GetHeight();
StretchMode = TextureRect.StretchModeEnum.Scale, sw = System.Math.Min(sw, src.GetWidth() - srcX);
MouseFilter = MouseFilterEnum.Ignore, sh = System.Math.Min(sh, src.GetHeight() - srcY);
}; if (sw <= 0 || sh <= 0) return;
_stage.AddChild(tr);
_slots[slot] = tr; _screen.BlitRect(src, new Rect2I(srcX, srcY, sw, sh), new Vector2I(dstX, dstY));
} _screenTex.Update(_screen);
tr.Texture = ImageTexture.CreateFromImage(img);
tr.Position = new Vector2(x, y);
tr.Size = new Vector2(w > 0 ? w : img.GetWidth(), h > 0 ? h : img.GetHeight());
tr.Visible = true;
} }
// Load an OGG off disk and play it. BGM loops; voice plays once, cutting off any prior line. // Load an OGG off disk and play it. BGM loops; voice plays once, cutting off any prior line.
@@ -136,7 +167,7 @@ public partial class Main : Godot.Control
} }
public void AppendLine(string text) => _text.Text += text + "\n"; public void AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak() => _status.Text = "▼ click / Enter"; public void PageBreak() { _pageCount++; _status.Text = "▼ click / Enter"; }
public void ClearPage() { _text.Text = ""; _status.Text = ""; } public void ClearPage() { _text.Text = ""; _status.Text = ""; }
public void ShowEnd() => _status.Text = "— end —"; public void ShowEnd() => _status.Text = "— end —";