From de5eb6562e069152ed6a0d6edbc51915c434b970 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 6 Jul 2026 22:50:25 -0400 Subject: [PATCH] feat(a2b): BmpHeader.ReadDims + gfx diagnostic (headless geometry oracle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BmpHeader.ReadDims reads texture dims from the pre-converted BMP header (VM- thread-safe, no pixel decode) — the data source for GetTextureSize. `Age.Cli gfx ` runs a scene and dumps set-texture/get-texture-size/draw-texture in order with resolved file + computed geometry, mirroring the `audio` diagnostic. Confirms 0x208 now yields real dims (800x600/800x800/800x227) and sane UI geometry; surfaces the first-load anchor edge (unseeded state, Phase B). Co-Authored-By: Claude Opus 4.8 (1M context) --- engine/Age.Cli/Program.cs | 63 +++++++++++++++++++++++ engine/Age.Engine.Tests/BmpHeaderTests.cs | 33 ++++++++++++ engine/Age.Engine/Sys4/BmpHeader.cs | 25 +++++++++ 3 files changed, 121 insertions(+) create mode 100644 engine/Age.Engine.Tests/BmpHeaderTests.cs create mode 100644 engine/Age.Engine/Sys4/BmpHeader.cs diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index aa9f1a0..5366abf 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$"); @@ -86,3 +108,44 @@ sealed class AudioTraceHost : IHost 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) + 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); + } + + public void SetTexture(long resId, int slot) + { + var e = _res.Resolve(_scene, resId); + var bmp = e != null ? ResourceMap.TexturePath(e) : null; + _slotBmp[slot] = 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) => 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/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); } + } +}