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:
@@ -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
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -42,6 +42,28 @@ if (args[0] == "audio")
|
||||
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")
|
||||
{
|
||||
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<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) { }
|
||||
}
|
||||
|
||||
33
engine/Age.Engine.Tests/BmpHeaderTests.cs
Normal file
33
engine/Age.Engine.Tests/BmpHeaderTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
46
engine/Age.Engine.Tests/TextureGeometryTests.cs
Normal file
46
engine/Age.Engine.Tests/TextureGeometryTests.cs
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
25
engine/Age.Engine/Sys4/BmpHeader.cs
Normal file
25
engine/Age.Engine/Sys4/BmpHeader.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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);
|
||||
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) ----
|
||||
|
||||
@@ -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<int, TextureRect> _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 <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()
|
||||
{
|
||||
// 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 —";
|
||||
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user