From 4eae9515fd655c562f92801b5e3b534ffecb4ea7 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 6 Jul 2026 23:02:58 -0400 Subject: [PATCH] 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 [--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) --- engine/Age.Cli/Program.cs | 17 +++++--- godot/GodotAdvHost.cs | 24 ++++++++--- godot/Main.cs | 91 ++++++++++++++++++++++++++------------- 3 files changed, 90 insertions(+), 42 deletions(-) diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 5366abf..5c08fce 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -114,15 +114,17 @@ sealed class GfxTraceHost : IHost private readonly ResourceMap _res; private readonly string _scene; private readonly Dictionary _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 _slotDims = new() { { 0, (800, 600) } }; public List Events { get; } = new(); public GfxTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } public (int Width, int Height) GetTextureSize(int slot) { - _slotBmp.TryGetValue(slot, out var bmp); - var (w, h) = BmpHeader.ReadDims(bmp); - Events.Add($"get-tex-size slot={slot} -> {w}x{h}"); - return (w, h); + var d = _slotDims.TryGetValue(slot, out var v) ? v : (0, 0); + Events.Add($"get-tex-size slot={slot} -> {d.Item1}x{d.Item2}"); + return d; } public void SetTexture(long resId, int slot) @@ -130,6 +132,7 @@ sealed class GfxTraceHost : IHost var e = _res.Resolve(_scene, resId); var bmp = e != null ? ResourceMap.TexturePath(e) : null; _slotBmp[slot] = bmp; + _slotDims[slot] = BmpHeader.ReadDims(bmp); Events.Add($"set-texture slot={slot} res=0x{resId:x} -> {(e?.Name ?? "")}" + (bmp == null ? " [NO BMP]" : "")); } @@ -141,7 +144,11 @@ sealed class GfxTraceHost : IHost + $"file={(bmp != null ? System.IO.Path.GetFileName(bmp) : "")}"); } - 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 CallScript(long id) { } public void OnStub(int opcode) { } diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index fdc4feb..ed6e708 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -8,7 +8,10 @@ public sealed class GodotAdvHost : IHost private readonly Main _main; private readonly ResourceMap _res; private readonly string _scene; // e.g. "SC0000" — for section_base - private readonly Dictionary _slotBmp = new(); // slot -> pre-converted BMP path + private readonly Dictionary _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 _slotDims = new() { { 0, (800, 600) } }; private readonly SemaphoreSlim _gate = new(0, 1); public volatile bool IsWaiting; public readonly List<(int Offset, string Text)> Captured = new(); @@ -24,8 +27,11 @@ public sealed class GodotAdvHost : IHost _main.CallDeferred("AppendLine", text); } + public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race) + public void WaitForInput() { + Pages++; _main.CallDeferred("PageBreak"); IsWaiting = true; _gate.Wait(); @@ -40,23 +46,27 @@ public sealed class GodotAdvHost : IHost public void OnStub(int opcode) { } // ---- 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) { 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) { 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) ---- // BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: via the per-scene manifest. public void PlayBgm(long id) diff --git a/godot/Main.cs b/godot/Main.cs index 99cd416..ac591e0 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -7,8 +7,9 @@ using Age.Engine.Vm; public partial class Main : Godot.Control { - private Control _stage = null!; // texture layer (behind the text) - private readonly Dictionary _slots = new(); + private TextureRect _screenView = null!; // shows the composited screen backbuffer + private Image _screen = null!; // 800x600 immediate-mode canvas + private ImageTexture _screenTex = null!; private Label _text = null!; private Label _status = null!; private AudioStreamPlayer _bgm = null!; // looping background music @@ -18,14 +19,26 @@ public partial class Main : Godot.Control private volatile bool _done; private bool _ended; private bool _selftest; + private string? _shotPath; // --shot : capture a page then quit (dev tool) + private int _shotPage = 1; // --shot-page : which page to capture (default 1) + private volatile int _pageCount; + private int _shotSettle; + private bool _shotDone; public override void _Ready() { - // texture stage, added first so it draws BEHIND the dialogue text - _stage = new Control(); - _stage.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); - _stage.MouseFilter = MouseFilterEnum.Ignore; - AddChild(_stage); + // Screen backbuffer: one 800x600 canvas that draw-texture blits into, shown behind the dialogue. + _screen = Image.CreateEmpty(800, 600, false, Image.Format.Rgba8); + _screenTex = ImageTexture.CreateFromImage(_screen); + _screenView = new TextureRect + { + 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.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); @@ -57,7 +70,13 @@ public partial class Main : Godot.Control AddChild(_bgm); 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 script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); @@ -67,10 +86,26 @@ public partial class Main : Godot.Control if (_selftest) _ = 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) { + // --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) { _ended = true; @@ -92,28 +127,24 @@ public partial class Main : Godot.Control public override void _ExitTree() { _host?.SignalInput(); } // ---- 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, - // layered in draw order (backgrounds are drawn before sprites, so they sit behind). - public void DrawSlot(int slot, string bmpPath, int x, int y, int w, int h) + // Blit a source BMP (src rect) onto the screen backbuffer at (dstX,dstY), then refresh the display + // texture. Execution order == paint order, so later draws (sprites) land over earlier ones (bg). + public void BlitSlot(string bmpPath, int srcX, int srcY, int w, int h, int dstX, int dstY) { - var img = new Image(); - var err = img.LoadBmpFromBuffer(System.IO.File.ReadAllBytes(bmpPath)); - if (err != Error.Ok) { GD.Print($"BMP load failed {bmpPath}: {err}"); return; } - if (!_slots.TryGetValue(slot, out var tr)) - { - tr = new TextureRect - { - ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, - StretchMode = TextureRect.StretchModeEnum.Scale, - MouseFilter = MouseFilterEnum.Ignore, - }; - _stage.AddChild(tr); - _slots[slot] = tr; - } - 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; + var src = new Image(); + if (src.LoadBmpFromBuffer(System.IO.File.ReadAllBytes(bmpPath)) != Error.Ok) + { GD.Print($"BMP load failed {bmpPath}"); return; } + if (src.GetFormat() != Image.Format.Rgba8) src.Convert(Image.Format.Rgba8); + + // Clamp the source rect to the image; a zero/negative size falls back to the full image. + int sw = w > 0 ? w : src.GetWidth(); + int sh = h > 0 ? h : src.GetHeight(); + sw = System.Math.Min(sw, src.GetWidth() - srcX); + sh = System.Math.Min(sh, src.GetHeight() - srcY); + if (sw <= 0 || sh <= 0) return; + + _screen.BlitRect(src, new Rect2I(srcX, srcY, sw, sh), new Vector2I(dstX, dstY)); + _screenTex.Update(_screen); } // 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 PageBreak() => _status.Text = "▼ click / Enter"; + public void PageBreak() { _pageCount++; _status.Text = "▼ click / Enter"; } public void ClearPage() { _text.Text = ""; _status.Text = ""; } public void ShowEnd() => _status.Text = "— end —";