Merge: A2b graphics geometry (0x208 keystone + blit compositor)

Opening event-CG sequence now renders correctly. 0x208 get-texture-size
implemented as a real VM op; faithful 800x600 immediate-mode blit compositor;
gfx + --shot diagnostics. Post-opening background/sprite anchor drift is
characterized and deferred to the anchor-record subsystem (next chunk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-06 23:06:39 -04:00
14 changed files with 312 additions and 48 deletions

View File

@@ -81,6 +81,11 @@
- **grounding:** source=inference, confidence=med - **grounding:** source=inference, confidence=med
- **evidence:** confirm via frida - **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) ### 0x217 `gfx-geom?` (u004211E0, argc 4)
- **summary:** 4 global-ints; part of a 0x217/0x218/0x21a geometry chain - **summary:** 4 global-ints; part of a 0x217/0x218/0x21a geometry chain
- **grounding:** source=inference, confidence=low - **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:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low
### 0x208 `u00420BF0` (u00420BF0, argc 3)
- **summary:** —
- **grounding:** source=kelebek, confidence=low
### 0x20a `u00420CE0` (u00420CE0, argc 1) ### 0x20a `u00420CE0` (u00420CE0, argc 1)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low

View File

@@ -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 `LOOPLENGTH` Vorbis comments — refine later); `play-sound-effect`(0xb4, argc 2) left stubbed (arg roles
unconfirmed). 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 <SCENE>` (headless numeric oracle — dumps per-draw resolved file + computed geometry) and
`godot … -- --shot <png> [--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 ## Risks / open questions for A0

View File

@@ -42,6 +42,28 @@ if (args[0] == "audio")
return 0; return 0;
} }
if (args[0] == "gfx")
{
// gfx <SCENE.BIN> [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") if (args[0] == "trace")
{ {
var scene = new Regex(@"^S[CP]\d{4}\.BIN$"); 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 CreateTexture(int slot, int width, int height) { }
public void SetTexture(long resourceId, int slot) { } 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 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<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 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 ?? "<unresolved>")}"
+ (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) : "<none>")}");
}
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) { }
} }

View File

@@ -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));
}
}

View File

@@ -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<int, int> { { 0, 0 } },
Strings = new Dictionary<int, string>(),
};
var vm = new VirtualMachine(script, table, new FakeSizeHost());
vm.Run();
Assert.Equal(0x140, vm.Globals[60]);
Assert.Equal(0xC8, vm.Globals[61]);
}
}

View File

@@ -18,6 +18,7 @@ public class TextureOpsTests
public void CreateTexture(int slot, int w, int h) => Creates++; public void CreateTexture(int slot, int w, int h) => Creates++;
public void SetTexture(long resId, int slot) => Sets.Add((resId, slot)); 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 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 PlayBgm(long id) { }
public void PlayVoice(long id) { } public void PlayVoice(long id) { }
} }

View File

@@ -17,6 +17,7 @@ public class WaitForInputTests
public void CreateTexture(int slot, int w, int h) { } public void CreateTexture(int slot, int w, int h) { }
public void SetTexture(long resId, int slot) { } 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 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 PlayBgm(long id) { }
public void PlayVoice(long id) { } public void PlayVoice(long id) { }
} }

View File

@@ -11,6 +11,7 @@ public sealed class CaptureHost : IHost
public void CreateTexture(int slot, int width, int height) { } public void CreateTexture(int slot, int width, int height) { }
public void SetTexture(long resourceId, int slot) { } 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 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 PlayBgm(long id) { }
public void PlayVoice(long id) { } public void PlayVoice(long id) { }
} }

View File

@@ -8,6 +8,7 @@ public interface IHost
void CreateTexture(int slot, int width, int height); void CreateTexture(int slot, int width, int height);
void SetTexture(long resourceId, int slot); void SetTexture(long resourceId, int slot);
void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); 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 PlayBgm(long id);
void PlayVoice(long id); void PlayVoice(long id);
} }

View File

@@ -0,0 +1,25 @@
namespace Age.Engine.Sys4;
/// <summary>
/// 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).
/// </summary>
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); }
}
}

View File

@@ -175,6 +175,12 @@ public sealed class VirtualMachine
case "draw-texture": // (handle, slot, srcX, srcY, w, h, dstX, dstY) 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]), _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; (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-bgm": _host.PlayBgm(Read(a[0])); return pc + 1;
case "play-voice": _host.PlayVoice(Read(a[0])); return pc + 1; case "play-voice": _host.PlayVoice(Read(a[0])); return pc + 1;
default: default:

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,18 +46,25 @@ 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);
} }
// ---- audio ops (OGG plays natively in Godot) ---- // ---- audio ops (OGG plays natively in Godot) ----

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 —";

View File

@@ -4978,33 +4978,33 @@ observed_types = ["imm", "l-int"]
[[opcode]] [[opcode]]
op = 0x208 op = 0x208
label = "u00420BF0" label = "get-texture-size"
argc = 3 argc = 3
abi_source = "kelebek+decode-validated" abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "u00420BF0" name = "get-texture-size"
category = "unknown" category = "draw"
summary = "" 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 noop_headless = false
source = "kelebek" source = "inference"
confidence = "low" confidence = "med"
depends_on = [] 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]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "slot"
observed_types = ["imm", "g-int", "l-int"] observed_types = ["imm", "g-int", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 2 i = 2
role = "" role = "out_width"
observed_types = ["g-int", "l-int"] observed_types = ["g-int", "l-int"]
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 3 i = 3
role = "" role = "out_height"
observed_types = ["g-int", "l-int"] observed_types = ["g-int", "l-int"]
[[opcode]] [[opcode]]