diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 4d90953..1817b1a 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -81,6 +81,11 @@ - **grounding:** source=inference, confidence=med - **evidence:** confirm via frida +### 0x208 `get-texture-size` (get-texture-size, argc 3) +- **summary:** 0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649) +- **grounding:** source=inference, confidence=med +- **evidence:** SC0000 label_12649: set-texture(resId,slot) then 0x208(slot)->w,h feeds w/2 horizontal-center + foot-anchor subtraction into draw-texture dst; stubbing yields 0x0 sizes / off-center draws + ### 0x217 `gfx-geom?` (u004211E0, argc 4) - **summary:** 4 global-ints; part of a 0x217/0x218/0x21a geometry chain - **grounding:** source=inference, confidence=low @@ -840,10 +845,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x208 `u00420BF0` (u00420BF0, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x20a `u00420CE0` (u00420CE0, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 743f637..8bba758 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -245,6 +245,40 @@ of the above. **Watch-items:** BGM looping is whole-file for now (Eushully OGGs `LOOPLENGTH` Vorbis comments — refine later); `play-sound-effect`(0xb4, argc 2) left stubbed (arg roles unconfirmed). +### A2b-Geometry — `0x208` keystone + blit compositor (2026-07-06) + +Spec/plan: `docs/superpowers/{specs,plans}/2026-07-06-a2b-graphics-geometry*.md`. **Shipped & verified:** +the CG-load subroutine (`SC0000.asm` `label_12649`) computes all sprite/background geometry **in +bytecode** (`add`/`sub`/`div`/`lookup-array`); the only missing native primitive was **`0x208 = +get-texture-size(slot) → (out_w, out_h)`**. Implemented as a real VM op (`IHost.GetTextureSize`, writes +the two output globals); non-Godot hosts return `(0,0)` so trace/selftest parity holds (engine 11/11, +`--selftest` byte-identical). Replaced the TextureRect-per-slot approximation with a faithful **800×600 +immediate-mode blit compositor** (`Main.BlitSlot`: `_screen.BlitRect(src rect → dst)` in execution order, +one displayed `TextureRect`; source dims read from the pre-converted BMP header on the VM thread via +`BmpHeader.ReadDims`, so the bytecode's geometry math sees real sizes synchronously). New diagnostics: +`Age.Cli gfx ` (headless numeric oracle — dumps per-draw resolved file + computed geometry) and +`godot … -- --shot [--shot-page N]` (page-gated screenshot capture). **The opening event-CG sequence +renders correctly** — full-screen CG at `(0,0)` with dialogue over it (verified by screenshot, SC0000 +pages 1/3). + +**Slot-0 seed (bug found & fixed via the gfx oracle + user eyeball):** slot 0 is the **primary/screen +surface** (800×600), normally created by engine-boot init the single-scene harness skips. Cold, `0x208` +measured `0×0`, and the anchor-preserve math (`base' = center − (w_new/2, h_new)`) then wrote a corrupted +`(−400,−600)` into the **persistent base globals** — so the first CG was grey and CG2 inherited the +corruption. Fix: seed `_slotDims[0] = (800,600)` (and record `create-texture(w,h)` dims) so the first CG's +anchor stays an identity. This is the faithful stand-in for the skipped boot-time primary-surface creation. + +**DEFERRED (next chunk) — the sprite/background anchor-record subsystem.** Everything blits through slot 0 +as an immediate-mode canvas; the anchor-preserve base globals **accumulate drift** across textures of +*different* sizes (same-size 800×600 CGs stay put; the first `BG*` 800×500 / `AE*` 800×800 / sprite starts +a drift that accumulates — `BG030A→(300,500)`, next→`(450,100)`, →`(800,350)`…, marching bottom-right). +The real engine doesn't drift because it stores each element's geometry in a **per-object record** via +`0x217/0x218/0x21a` (currently no-op) and restores it (the `0x12683` if-branch reading the `0x3239` record +table). Implementing that store/restore (+ the record layout, likely Frida-confirmed) is the fix for +backgrounds **and** sprites together. Fades/alpha (`AE*`, `0x202/0x203`) and green chromakey remain +deferred as before (the compositor is built to accept alpha later). Also out: true multi-surface (dest +handle is collapsed onto the screen). The full-screen opening path is unaffected by any of these. + --- ## Risks / open questions for A0 diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 16d9e83..5c08fce 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -42,6 +42,28 @@ if (args[0] == "audio") return 0; } +if (args[0] == "gfx") +{ + // gfx [0xADDR=VAL ...] — run the scene and dump executed texture ops in order with + // resolved file + computed geometry (set-texture / get-texture-size / draw-texture). Diagnostic only. + var sceneName = args[1]; + var sceneKey = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant(); + var res = ResourceMap.Load(); + var host = new GfxTraceHost(res, sceneKey); + var vm = new VirtualMachine(Sys4Loader.Load(Paths.Scripts()[sceneName.ToUpperInvariant()], table), table, host); + foreach (var s in args.Skip(2)) + { + var kv = s.Split('='); + int k = kv[0].StartsWith("0x") ? Convert.ToInt32(kv[0], 16) : int.Parse(kv[0]); + long v = kv[1].StartsWith("0x") ? Convert.ToInt64(kv[1], 16) : long.Parse(kv[1]); + vm.Globals[k] = v; + } + vm.Run(); + Console.WriteLine($"{sceneName}: {host.Events.Count} texture ops (halt: {vm.HaltReason})"); + foreach (var line in host.Events) Console.WriteLine(" " + line); + return 0; +} + if (args[0] == "trace") { var scene = new Regex(@"^S[CP]\d{4}\.BIN$"); @@ -84,4 +106,53 @@ sealed class AudioTraceHost : IHost public void CreateTexture(int slot, int width, int height) { } public void SetTexture(long resourceId, int slot) { } public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); +} + +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) + { + 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) + { + 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]" : "")); + } + + public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) + { + _slotBmp.TryGetValue(slot, out var bmp); + Events.Add($"draw-texture slot={slot} src=({sx},{sy} {w}x{h}) dst=({dx},{dy}) " + + $"file={(bmp != null ? System.IO.Path.GetFileName(bmp) : "")}"); + } + + 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) { } + public void WaitForInput() { } + public void PlayBgm(long id) { } + public void PlayVoice(long id) { } } diff --git a/engine/Age.Engine.Tests/BmpHeaderTests.cs b/engine/Age.Engine.Tests/BmpHeaderTests.cs new file mode 100644 index 0000000..6fafd6c --- /dev/null +++ b/engine/Age.Engine.Tests/BmpHeaderTests.cs @@ -0,0 +1,33 @@ +using System.IO; +using Age.Engine.Sys4; +using Xunit; + +public class BmpHeaderTests +{ + [Fact] + public void ReadDimsReadsWidthAndHeightFromBmpHeader() + { + // Minimal 54-byte BMP header (BITMAPFILEHEADER 14 + BITMAPINFOHEADER 40); width=4, height=3. + var b = new byte[54]; + b[0] = (byte)'B'; b[1] = (byte)'M'; + System.BitConverter.GetBytes(40).CopyTo(b, 14); // header size + System.BitConverter.GetBytes(4).CopyTo(b, 18); // width + System.BitConverter.GetBytes(3).CopyTo(b, 22); // height + var tmp = Path.Combine(Path.GetTempPath(), "agehdr_test.bmp"); + File.WriteAllBytes(tmp, b); + try + { + var (w, h) = BmpHeader.ReadDims(tmp); + Assert.Equal(4, w); + Assert.Equal(3, h); + } + finally { File.Delete(tmp); } + } + + [Fact] + public void ReadDimsReturnsZeroForMissingFile() + { + var (w, h) = BmpHeader.ReadDims(Path.Combine(Path.GetTempPath(), "does_not_exist_agehdr.bmp")); + Assert.Equal((0, 0), (w, h)); + } +} diff --git a/engine/Age.Engine.Tests/TextureGeometryTests.cs b/engine/Age.Engine.Tests/TextureGeometryTests.cs new file mode 100644 index 0000000..9118fdc --- /dev/null +++ b/engine/Age.Engine.Tests/TextureGeometryTests.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class TextureGeometryTests +{ + private sealed class FakeSizeHost : IHost + { + public void ShowText(int o, string t) { } + public void CallScript(long id) { } + public void OnStub(int op) { } + public void WaitForInput() { } + public void CreateTexture(int slot, int w, int h) { } + public void SetTexture(long resId, int slot) { } + public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { } + public void PlayBgm(long id) { } + public void PlayVoice(long id) { } + public (int Width, int Height) GetTextureSize(int slot) => (0x140, 0xC8); + } + + [Fact] + public void GetTextureSizeWritesHostDimsIntoOutputGlobals() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + // 0x208 (global-int 50)(global-int 60)(global-int 61): slot=50, out_w=G[60], out_h=G[61] + const int T_GINT = 3; + var ins = new Instruction(0, 0x208, new[] + { + new Operand(T_GINT, 50), new Operand(T_GINT, 60), new Operand(T_GINT, 61), + }); + var script = new Script + { + Header = new ScriptHeader(0, 0, 0, 0, 0, 0), + Instructions = new[] { ins }, + IndexByOffset = new Dictionary { { 0, 0 } }, + Strings = new Dictionary(), + }; + var vm = new VirtualMachine(script, table, new FakeSizeHost()); + vm.Run(); + Assert.Equal(0x140, vm.Globals[60]); + Assert.Equal(0xC8, vm.Globals[61]); + } +} diff --git a/engine/Age.Engine.Tests/TextureOpsTests.cs b/engine/Age.Engine.Tests/TextureOpsTests.cs index 4dddd1d..1d31ad0 100644 --- a/engine/Age.Engine.Tests/TextureOpsTests.cs +++ b/engine/Age.Engine.Tests/TextureOpsTests.cs @@ -18,6 +18,7 @@ public class TextureOpsTests public void CreateTexture(int slot, int w, int h) => Creates++; public void SetTexture(long resId, int slot) => Sets.Add((resId, slot)); public void DrawTexture(int slot, int srcX, int srcY, int w, int h, int dstX, int dstY) => Draws.Add((slot, w, h)); + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); public void PlayBgm(long id) { } public void PlayVoice(long id) { } } diff --git a/engine/Age.Engine.Tests/WaitForInputTests.cs b/engine/Age.Engine.Tests/WaitForInputTests.cs index e423163..f176cb3 100644 --- a/engine/Age.Engine.Tests/WaitForInputTests.cs +++ b/engine/Age.Engine.Tests/WaitForInputTests.cs @@ -17,6 +17,7 @@ public class WaitForInputTests public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) { } public void DrawTexture(int slot, int srcX, int srcY, int w, int h, int dstX, int dstY) { } + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); public void PlayBgm(long id) { } public void PlayVoice(long id) { } } diff --git a/engine/Age.Engine/Hosting/CaptureHost.cs b/engine/Age.Engine/Hosting/CaptureHost.cs index a58d801..81619e6 100644 --- a/engine/Age.Engine/Hosting/CaptureHost.cs +++ b/engine/Age.Engine/Hosting/CaptureHost.cs @@ -11,6 +11,7 @@ public sealed class CaptureHost : IHost public void CreateTexture(int slot, int width, int height) { } public void SetTexture(long resourceId, int slot) { } public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); public void PlayBgm(long id) { } public void PlayVoice(long id) { } } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index ed85b41..f1d4ce9 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -8,6 +8,7 @@ public interface IHost void CreateTexture(int slot, int width, int height); void SetTexture(long resourceId, int slot); void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); + (int Width, int Height) GetTextureSize(int slot); void PlayBgm(long id); void PlayVoice(long id); } diff --git a/engine/Age.Engine/Sys4/BmpHeader.cs b/engine/Age.Engine/Sys4/BmpHeader.cs new file mode 100644 index 0000000..b541f44 --- /dev/null +++ b/engine/Age.Engine/Sys4/BmpHeader.cs @@ -0,0 +1,25 @@ +namespace Age.Engine.Sys4; + +/// +/// Reads pixel dimensions from a BMP file header (BITMAPINFOHEADER: width at byte 18, height at byte 22, +/// both little-endian int32; height may be negative for top-down bitmaps). Used to give the VM the +/// texture size that opcode 0x208 (get-texture-size) needs, without decoding pixels. Our textures are +/// pre-converted BMPs (tools/convert_agf.py). +/// +public static class BmpHeader +{ + public static (int Width, int Height) ReadDims(string? path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return (0, 0); + try + { + var b = new byte[26]; + using var fs = File.OpenRead(path); + if (fs.Read(b, 0, 26) < 26 || b[0] != (byte)'B' || b[1] != (byte)'M') return (0, 0); + int w = System.BitConverter.ToInt32(b, 18); + int h = System.BitConverter.ToInt32(b, 22); + return (System.Math.Abs(w), System.Math.Abs(h)); + } + catch { return (0, 0); } + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 617d604..9f3e3fd 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -175,6 +175,12 @@ public sealed class VirtualMachine case "draw-texture": // (handle, slot, srcX, srcY, w, h, dstX, dstY) _host.DrawTexture((int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7])); return pc + 1; + case "get-texture-size": // 0x208 (slot) (out_w) (out_h) + { + var (gw, gh) = _host.GetTextureSize((int)Read(a[0])); + Write(a[1], gw); Write(a[2], gh); + return pc + 1; + } case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1; case "play-voice": _host.PlayVoice(Read(a[0])); return pc + 1; default: diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index a42be03..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,18 +46,25 @@ 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); } // ---- audio ops (OGG plays natively in Godot) ---- 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 —"; diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 53ca7af..3ebe58b 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -4978,33 +4978,33 @@ observed_types = ["imm", "l-int"] [[opcode]] op = 0x208 -label = "u00420BF0" +label = "get-texture-size" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00420BF0" -category = "unknown" -summary = "" +name = "get-texture-size" +category = "draw" +summary = "0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649)" noop_headless = false -source = "kelebek" -confidence = "low" +source = "inference" +confidence = "med" depends_on = [] -evidence = "" +evidence = "SC0000 label_12649: set-texture(resId,slot) then 0x208(slot)->w,h feeds w/2 horizontal-center + foot-anchor subtraction into draw-texture dst; stubbing yields 0x0 sizes / off-center draws" [[opcode.semantics.args]] i = 1 -role = "" +role = "slot" observed_types = ["imm", "g-int", "l-int"] [[opcode.semantics.args]] i = 2 -role = "" +role = "out_width" observed_types = ["g-int", "l-int"] [[opcode.semantics.args]] i = 3 -role = "" +role = "out_height" observed_types = ["g-int", "l-int"] [[opcode]]