From 10f6656c2df4e76167b0f79fbd1e2a42897c20b0 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 6 Jul 2026 21:22:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(a2b):=20first-pass=20texture=20render=20?= =?UTF-8?q?=E2=80=94=20full-screen=20event-CG=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire asset resolution into a live Godot render. VM executes set-texture -> ResourceMap resolves (scene,resId) -> files[section_base+resId] across all archives -> pre-converted BMP -> composite behind the dialogue. The full-screen event-CG layer (EV052*) renders end-to-end from the executed bytecode. - Age.Engine/Sys4/ResourceMap.cs: Resolve + BMP TexturePath; Paths: asset JSONs - GodotAdvHost: create/set/draw-texture -> TextureRect in a _stage layer - IHost.DrawTexture + VM dispatch extended with dst x/y (draw-texture args 7/8) - project.godot 800x600; convert_agf.py all-archive + --scene batch - engine 8/8, C# --selftest still byte-matches vm0 trace (VM behaviour unchanged) Known limitations (next chunk = graphics geometry/blend subsystem): - sprites + BG* via the CG-load subroutine get garbage dst/size — native ops stubbed (0x208 get-texture-size + sprite position/anim chain) - AE* fades draw opaque/instant (no alpha); no chromakey - slot model approximates the game's immediate-mode blit-onto-slot-0 canvas - AGF pre-converted to BMP offline (runtime decoder deferred) See docs/phase-a-slice-plan.md (A2b section). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/asset-resolution-re.md | 25 +++++--- docs/phase-a-slice-plan.md | 38 ++++++++--- docs/tools-reference.md | 2 +- engine/Age.Engine.Tests/TextureOpsTests.cs | 2 +- engine/Age.Engine.Tests/WaitForInputTests.cs | 2 +- engine/Age.Engine/Hosting/CaptureHost.cs | 2 +- engine/Age.Engine/Hosting/IHost.cs | 2 +- engine/Age.Engine/Sys4/Paths.cs | 3 + engine/Age.Engine/Sys4/ResourceMap.cs | 63 +++++++++++++++++++ engine/Age.Engine/Vm/VirtualMachine.cs | 5 +- godot/GodotAdvHost.cs | 24 ++++++- godot/GodotAdvHost.cs.uid | 1 + godot/Main.cs | 34 +++++++++- godot/project.godot | 4 ++ tools/convert_agf.py | 66 +++++++++++++++----- 15 files changed, 232 insertions(+), 41 deletions(-) create mode 100644 engine/Age.Engine/Sys4/ResourceMap.cs create mode 100644 godot/GodotAdvHost.cs.uid diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 5122d04..63fc8bf 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -81,8 +81,15 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b branchy non-opening scenes — use the C# VM to trace those). Runtime note for future work: the game is **packed** (main VM logic in a per-run heap `r-x` region) and streams archives through a heap block-cache via `ReadFile` (not mmap); the stable AGF decoder is `AGE.EXE+0x74f1f`. -3. **Wire the backend** (already designed — A2b-background plan Tasks 3–5): `ResourceMap` resolver + - Godot `TextureRect` compositing; render only resolved full-screen slots. Mechanical once (1)+(2) land. +3. **Wire the backend.** **✅ FIRST-PASS RENDER LANDED (2026-07-06).** `Age.Engine/Sys4/ResourceMap.cs` + (Resolve + BMP path) + `GodotAdvHost` texture ops → `TextureRect` compositing behind the dialogue; + `IHost.DrawTexture` extended with dst x/y; 800×600 window; `convert_agf.py --scene` pre-converts a + scene's manifest AGFs → BMP. The full-screen **event-CG layer renders end-to-end** from the executed + bytecode. **Limitations (next chunk = graphics geometry/blend):** sprites + `BG*` (routed through the + CG-load subroutine) have garbage geometry because native graphics ops are stubbed (`0x208` + get-texture-size + the sprite position/animation chain); fades (`AE*`) draw opaque (no alpha); slot + model approximates the game's immediate-mode blit-onto-slot-0 canvas. See `docs/phase-a-slice-plan.md` + (A2b section) for the full write-up + the graphics-subsystem plan. 4. **Audio** (parallel, same shape): resolve `play-voice`/`play-bgm` `id → OGG` via SYS4INI + a Frida audio capture (hook `DATA3.ALF` reads or the audio-play fn); play via Godot. Reuses the `tools/frida/` framework. @@ -98,9 +105,11 @@ rendering what the executed bytecode + the map produce (never a hardcoded image) ## Status -A2b-background: **machinery landed**; **steps 1 & 2 SOLVED (static, general).** Step 1 = -`build/asset-index.json`. Step 2 = **`resId → files[section_base(scene) + resId]`** via SYS4INI -per-scene sections (`tools/resolve_asset.py` + `build/asset-sections.json`) — no runtime capture, works -across all archives/types and for audio too. Remaining for the render (step 3): wire a `ResourceMap` -(scene → section_base; resId → asset via the index) + Godot `TextureRect` compositing (A2b plan Tasks 3–5, -now purely mechanical). Audio (step 4) uses the *same* resolver (`play-bgm/play-voice id → files[base+id]`). +A2b-background: **steps 1–3 landed.** Step 1 = `build/asset-index.json`. Step 2 = **`resId → +files[section_base(scene) + resId]`** via SYS4INI per-scene sections (`tools/resolve_asset.py` + +`build/asset-sections.json`) — no runtime capture, all archives/types + audio. Step 3 = **first-pass +render** (ResourceMap + GodotAdvHost texture ops → TextureRect compositing): the full-screen event-CG +layer renders end-to-end from the bytecode. Remaining (next chunk): the **graphics geometry/blend +subsystem** — native geometry ops (`0x208` + sprite position/animation) so sprites/`BG*` position, plus +alpha/blend for fades + chromakey. See `docs/phase-a-slice-plan.md` (A2b). Audio (step 4) uses the *same* +resolver (`play-bgm/play-voice id → files[base+id]`). diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index c6befc7..c82a5d2 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -181,16 +181,34 @@ Toolchain: `godot --headless --path godot --import` → `dotnet build godot/Hime **Next = A2b:** background via `AGF2BMP2AGF.exe`, `play-voice`/`play-bgm`, choices → VM globals, just-enough `call-script`/state (unlocks richer scenes). -### A2b-Background — machinery landed; render blocked on asset resolution (2026-07-06) -Engine-driven texture ops shipped: `create/set/draw-texture` (0x1f8/0x1f9/0x1fb) promoted from VM stubs -to typed `IHost` methods (CaptureHost no-ops → trace parity kept; engine tests 8/8). Tools: `convert_agf.py` -(AGF→BMP stills) + `tools/frida/` capture harness (Frida 17.15.3 installed). **Blocked:** rendering the bg -needs `resId → asset file` resolution, which proved opaque — CGINIT isn't a filename map, SYS4INI (S4IC422) -needs format RE, Frida file-I/O offsets are noisy (memory-mapping), and the opening mixes movies (MVB/OP = -MPEG) with stills so eyeball-curation stalled too. **Asset resolution promoted to a dedicated foundational -RE effort** (graphics + audio; not machine-verifiable — Frida ground truth + human eye/ear are the oracle): -see `docs/asset-resolution-re.md`. The backend render (ResourceMap + Godot compositing) stays designed in -`docs/superpowers/plans/2026-07-06-a2b-background.md` Tasks 3–5, mechanical once resolution lands. +### A2b-Background — FIRST-PASS RENDER LANDED (2026-07-06) +Resolution solved (`docs/asset-resolution-re.md`: `resId → files[section_base(scene)+resId]`) and wired +into a live render. **Shipped:** `Age.Engine/Sys4/ResourceMap.cs` (loads `build/asset-index.json` + +`build/asset-sections.json`; `Resolve(scene,resId) → AssetEntry`; `TexturePath` → pre-converted BMP); +`GodotAdvHost` implements `create/set/draw-texture` (slot → `TextureRect` composited behind the dialogue +in a `_stage` layer); `IHost.DrawTexture` + VM dispatch extended to pass the destination x/y (draw-texture +args 7/8); `project.godot` window = 800×600; `convert_agf.py` searches all archives + `--scene` batch. +Engine 8/8, C# `--selftest` still byte-matches the vm0 trace (VM behaviour unchanged). **Works end-to-end:** +the VM executes `set-texture(resId)` → ResourceMap resolves across archives → BMP loads → composite; the +full-screen **event-CG layer (`EV052*`) renders correctly** as the opening plays. + +**Known first-pass limitations (all one subsystem = graphics geometry/blend, the next chunk):** +1. **Only the full-screen layer is correct.** Sprites/effects and `BG*` backgrounds routed through the + CG-load subroutine (`label_12649`) derive width/height/position from native ops we still **stub** — + `0x208` (get-texture-size) + the sprite position/registration/animation chain — so their `dst/size` + are garbage (backgrounds land off-center, e.g. `BG030A dst=(300,300)`; sizes come out `0x0`). Only the + *immediate* full-screen draws (`(0,0) 800×600`) render right. +2. **No alpha/blend.** `AE*` full-screen fade/flash effects draw **opaque and instant** (a static grey/white + sheet over the CG) instead of alpha-animating. No chromakey either (sprites would show green boxes — + moot until they position). +3. **Slot model is an approximation.** We use one `TextureRect` per slot, replace-on-draw; the game + actually **blits onto slot 0 as an immediate-mode canvas** (everything composites into slot 0). +4. AGF is **pre-converted to BMP offline** (`convert_agf.py --scene`); a runtime C# AGF decoder is deferred. + +**Next chunk — graphics-geometry/blend subsystem:** implement `0x208` (host returns the slot's real image +dims) + the sprite position/registration ops so geometry is correct; add alpha/additive blend for fades + +green chromakey; likely move to a proper canvas/blit compositor. Fixes sprites, background placement, and +fades together. (Superseded: the id-specific plan in `docs/superpowers/plans/2026-07-06-a2b-background.md`.) --- diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 465bc7a..9c339f0 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -66,7 +66,7 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited * | `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN` → `build/asset-index.json` | | `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py [resId]` | `build/asset-index.json` → `build/asset-sections.json`; resolves any (scene, resId) | | `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json` → `build/frida-asset-loads.json` | -| `convert_agf.py` | Convert named AGF stills to BMP via `AGF2BMP2AGF.exe`. | `convert_agf.py EV001AA.AGF …` | `extracted/DATA2|DATA5/*.AGF` → `build/textures/*.BMP` | +| `convert_agf.py` | Convert AGF stills to BMP via `AGF2BMP2AGF.exe` (searches all `extracted/DATA*`). `--scene` batch-converts a scene's whole SYS4INI manifest — feeds the Godot render. | `convert_agf.py EV052CA.AGF …` · `convert_agf.py --scene SC0000` | `extracted/DATA*/*.AGF` → `build/textures/*.BMP` | ## Runtime capture (Frida) diff --git a/engine/Age.Engine.Tests/TextureOpsTests.cs b/engine/Age.Engine.Tests/TextureOpsTests.cs index 26fcf99..edcf08c 100644 --- a/engine/Age.Engine.Tests/TextureOpsTests.cs +++ b/engine/Age.Engine.Tests/TextureOpsTests.cs @@ -17,7 +17,7 @@ public class TextureOpsTests public void WaitForInput() { } 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 x, int y, int w, int h) => 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)); } [Fact] diff --git a/engine/Age.Engine.Tests/WaitForInputTests.cs b/engine/Age.Engine.Tests/WaitForInputTests.cs index 4c5067d..d3a35b9 100644 --- a/engine/Age.Engine.Tests/WaitForInputTests.cs +++ b/engine/Age.Engine.Tests/WaitForInputTests.cs @@ -16,7 +16,7 @@ public class WaitForInputTests public void WaitForInput() => Waits++; public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) { } - public void DrawTexture(int slot, int x, int y, int w, int h) { } + public void DrawTexture(int slot, int srcX, int srcY, int w, int h, int dstX, int dstY) { } } [Fact] diff --git a/engine/Age.Engine/Hosting/CaptureHost.cs b/engine/Age.Engine/Hosting/CaptureHost.cs index 717042e..70bfc1c 100644 --- a/engine/Age.Engine/Hosting/CaptureHost.cs +++ b/engine/Age.Engine/Hosting/CaptureHost.cs @@ -10,5 +10,5 @@ public sealed class CaptureHost : IHost public void WaitForInput() { } public void CreateTexture(int slot, int width, int height) { } public void SetTexture(long resourceId, int slot) { } - public void DrawTexture(int slot, int x, int y, int width, int height) { } + public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 3c21436..6b02dfb 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -7,5 +7,5 @@ public interface IHost void WaitForInput(); void CreateTexture(int slot, int width, int height); void SetTexture(long resourceId, int slot); - void DrawTexture(int slot, int x, int y, int width, int height); + void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); } diff --git a/engine/Age.Engine/Sys4/Paths.cs b/engine/Age.Engine/Sys4/Paths.cs index 930d785..2533566 100644 --- a/engine/Age.Engine/Sys4/Paths.cs +++ b/engine/Age.Engine/Sys4/Paths.cs @@ -8,6 +8,9 @@ public static class Paths public static string GameDir => Path.Combine(Workspace, "姫狩りダンジョンマイスター"); public static string Build => Path.Combine(Repo, "build"); public static string OpcodesJson => Path.Combine(Build, "opcodes.json"); + public static string AssetSectionsJson => Path.Combine(Build, "asset-sections.json"); + public static string AssetIndexJson => Path.Combine(Build, "asset-index.json"); + public static string Textures => Path.Combine(Build, "textures"); private static string FindRepo() { diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs new file mode 100644 index 0000000..62f3abd --- /dev/null +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -0,0 +1,63 @@ +using System.Text.Json; + +namespace Age.Engine.Sys4; + +/// One SYS4INI asset entry. +public sealed record AssetEntry(string Name, string Archive, long Offset, long Size); + +/// +/// Static asset resolver. SYS4INI's file list is sectioned (one per scene: SCxxxx.BIN + its +/// cross-archive asset manifest); file_number is the index within a section. So a bytecode +/// resId resolves as files[section_base(scene) + resId] -- unified for graphics and audio. +/// See docs/asset-resolution-re.md. Built from build/asset-index.json + build/asset-sections.json. +/// +public sealed class ResourceMap +{ + private readonly IReadOnlyList _files; + private readonly IReadOnlyDictionary _sceneBase; // "SC0000" -> section base index + + public ResourceMap(IReadOnlyList files, IReadOnlyDictionary sceneBase) + { + _files = files; + _sceneBase = sceneBase; + } + + public static ResourceMap Load(string indexPath, string sectionsPath) + { + var files = new List(); + using (var idx = JsonDocument.Parse(File.ReadAllText(indexPath))) + foreach (var f in idx.RootElement.GetProperty("files").EnumerateArray()) + files.Add(new AssetEntry( + f.GetProperty("name").GetString()!, + f.GetProperty("archive").GetString()!, + f.GetProperty("offset").GetInt64(), + f.GetProperty("size").GetInt64())); + + var sceneBase = new Dictionary(StringComparer.OrdinalIgnoreCase); + using (var sec = JsonDocument.Parse(File.ReadAllText(sectionsPath))) + foreach (var p in sec.RootElement.GetProperty("scene_base").EnumerateObject()) + sceneBase[p.Name] = p.Value.GetInt32(); + + return new ResourceMap(files, sceneBase); + } + + public static ResourceMap Load() => Load(Paths.AssetIndexJson, Paths.AssetSectionsJson); + + /// Resolve a scene-local resId to its asset, or null if out of range / unknown scene. + public AssetEntry? Resolve(string scene, long resId) + { + var key = scene.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase) + ? scene[..^4] : scene; + if (!_sceneBase.TryGetValue(key, out var b)) return null; + long p = b + resId; + return p >= 0 && p < _files.Count ? _files[(int)p] : null; + } + + /// Pre-converted BMP path for an AGF asset (see tools/convert_agf.py). + public static string? TexturePath(AssetEntry a) + { + if (!a.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase)) return null; + var bmp = Path.Combine(Paths.Textures, Path.GetFileNameWithoutExtension(a.Name) + ".BMP"); + return File.Exists(bmp) ? bmp : null; + } +} diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 48fec88..7e6f639 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -172,8 +172,9 @@ public sealed class VirtualMachine _host.CreateTexture((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1; case "set-texture": _host.SetTexture(Read(a[0]), (int)Read(a[1])); return pc + 1; - case "draw-texture": - _host.DrawTexture((int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), (int)Read(a[5])); return pc + 1; + 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; default: _host.OnStub(op); return pc + 1; } diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index af63ab8..05f558e 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -1,15 +1,22 @@ using System.Collections.Generic; using System.Threading; using Age.Engine.Hosting; +using Age.Engine.Sys4; 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 SemaphoreSlim _gate = new(0, 1); public volatile bool IsWaiting; public readonly List<(int Offset, string Text)> Captured = new(); - public GodotAdvHost(Main main) => _main = main; + public GodotAdvHost(Main main, ResourceMap res, string scene) + { + _main = main; _res = res; _scene = scene; + } public void ShowText(int offset, string text) { @@ -31,4 +38,19 @@ public sealed class GodotAdvHost : IHost public void CallScript(long id) { } 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 SetTexture(long resourceId, int slot) + { + var asset = _res.Resolve(_scene, resourceId); + _slotBmp[slot] = asset != null ? ResourceMap.TexturePath(asset) : null; + } + + 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); + } } diff --git a/godot/GodotAdvHost.cs.uid b/godot/GodotAdvHost.cs.uid new file mode 100644 index 0000000..08518eb --- /dev/null +++ b/godot/GodotAdvHost.cs.uid @@ -0,0 +1 @@ +uid://b6bhdmtc2387h diff --git a/godot/Main.cs b/godot/Main.cs index dd1db35..463fedc 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -7,6 +7,8 @@ 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 Label _text = null!; private Label _status = null!; private VirtualMachine _vm = null!; @@ -17,6 +19,12 @@ public partial class Main : Godot.Control 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); + _text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart }; _text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _text.OffsetLeft = 40; _text.OffsetTop = 40; _text.OffsetRight = -40; _text.OffsetBottom = -80; @@ -46,7 +54,7 @@ public partial class Main : Godot.Control var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); - _host = new GodotAdvHost(this); + _host = new GodotAdvHost(this, ResourceMap.Load(), "SC0000"); _vm = new VirtualMachine(script, table, _host); _ = Task.Run(() => { _vm.Run(); _done = true; }); @@ -77,6 +85,30 @@ 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) + { + 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; + } + public void AppendLine(string text) => _text.Text += text + "\n"; public void PageBreak() => _status.Text = "▼ click / Enter"; public void ClearPage() { _text.Text = ""; _status.Text = ""; } diff --git a/godot/project.godot b/godot/project.godot index 0fe090f..e5a27a0 100644 --- a/godot/project.godot +++ b/godot/project.godot @@ -4,5 +4,9 @@ config_version=5 config/name="Himegari (age-reimpl A2a)" run/main_scene="res://Main.tscn" +[display] +window/size/viewport_width=800 +window/size/viewport_height=600 + [dotnet] project/assembly_name="Himegari" diff --git a/tools/convert_agf.py b/tools/convert_agf.py index 62349a7..622094b 100644 --- a/tools/convert_agf.py +++ b/tools/convert_agf.py @@ -1,14 +1,26 @@ #!/usr/bin/env python3 -"""Convert named AGF files (in extracted/DATA2 or DATA5) to BMP via AGF2BMP2AGF.exe, -into build/textures/. Run: py -3.11 -X utf8 tools/convert_agf.py EV001AA.AGF ...""" -import os, sys, shutil, subprocess +"""Convert AGF images to BMP via AGF2BMP2AGF.exe, into build/textures/. + + py -3.11 -X utf8 tools/convert_agf.py EV052CA.AGF BG030A.AGF ... # named assets + py -3.11 -X utf8 tools/convert_agf.py --scene SC0000 # all .AGF in the scene's + # SYS4INI section manifest + +Searches every extracted/DATA* dir (DATA1 holds BG/CS/CB/CA/AE/EM sprites, DATA2 the EV CGs). +""" +import json +import os +import shutil +import subprocess +import sys + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import paths -EXE = paths.EXTRACTED / "DATA1" / "AGF2BMP2AGF.exe" # tool lives in extracted/DATA1 -SRC_DIRS = [paths.EXTRACTED / "DATA2", paths.EXTRACTED / "DATA5"] +EXE = paths.EXTRACTED / "DATA1" / "AGF2BMP2AGF.exe" # tool lives in extracted/DATA1 +SRC_DIRS = [paths.EXTRACTED / d for d in ("DATA1", "DATA2", "DATA3", "DATA4", "DATA5")] OUT = paths.BUILD / "textures" + def find(name): for d in SRC_DIRS: p = d / name @@ -16,18 +28,44 @@ def find(name): return p return None + +def scene_agfs(scene): + """The .AGF asset names in a scene's SYS4INI section manifest.""" + secs = json.loads((paths.BUILD / "asset-sections.json").read_text(encoding="utf-8")) + files = json.loads((paths.BUILD / "asset-index.json").read_text(encoding="utf-8"))["files"] + base = secs["scene_base"][scene.upper().removesuffix(".BIN")] + end = next(s["end"] for s in secs["sections"] if s["start"] == base) + return [files[p]["name"] for p in range(base, end + 1) + if files[p]["name"].upper().endswith(".AGF")] + + +def convert(name): + src = find(name) + if not src: + print(f"NOT FOUND: {name}"); return False + out_bmp = OUT / (os.path.splitext(name)[0] + ".BMP") + if out_bmp.exists(): + return True + tmp = OUT / name + shutil.copy(src, tmp) + r = subprocess.run([str(EXE), name], cwd=str(OUT), capture_output=True, text=True) + tmp.unlink(missing_ok=True) + if not out_bmp.exists(): + print(f"FAILED: {name} ({r.stdout.strip()[:60]})"); return False + return True + + def main(argv): OUT.mkdir(parents=True, exist_ok=True) - for name in argv: - src = find(name) - if not src: - print(f"NOT FOUND: {name}"); continue - tmp = OUT / name - shutil.copy(src, tmp) - subprocess.run([str(EXE), name], cwd=str(OUT), check=True) - tmp.unlink(missing_ok=True) # drop the copied .AGF, keep the .BMP - print(f"converted {name} -> {OUT / (os.path.splitext(name)[0] + '.BMP')}") + if argv and argv[0] == "--scene": + names = scene_agfs(argv[1]) + print(f"{argv[1]}: {len(names)} .AGF assets in manifest") + else: + names = argv + ok = sum(convert(n) for n in names) + print(f"converted {ok}/{len(names)} -> {OUT.relative_to(paths.REPO)}") return 0 + if __name__ == "__main__": sys.exit(main(sys.argv[1:]))