feat(a2b): first-pass texture render — full-screen event-CG layer

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) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-06 21:22:42 -04:00
parent a61c0c9abd
commit 10f6656c2d
15 changed files with 232 additions and 41 deletions

View File

@@ -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 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 **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`. via `ReadFile` (not mmap); the stable AGF decoder is `AGE.EXE+0x74f1f`.
3. **Wire the backend** (already designed — A2b-background plan Tasks 35): `ResourceMap` resolver + 3. **Wire the backend.** **✅ FIRST-PASS RENDER LANDED (2026-07-06).** `Age.Engine/Sys4/ResourceMap.cs`
Godot `TextureRect` compositing; render only resolved full-screen slots. Mechanical once (1)+(2) land. (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 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 Frida audio capture (hook `DATA3.ALF` reads or the audio-play fn); play via Godot. Reuses the
`tools/frida/` framework. `tools/frida/` framework.
@@ -98,9 +105,11 @@ rendering what the executed bytecode + the map produce (never a hardcoded image)
## Status ## Status
A2b-background: **machinery landed**; **steps 1 & 2 SOLVED (static, general).** Step 1 = A2b-background: **steps 13 landed.** Step 1 = `build/asset-index.json`. Step 2 = **`resId →
`build/asset-index.json`. Step 2 = **`resId → files[section_base(scene) + resId]`** via SYS4INI files[section_base(scene) + resId]`** via SYS4INI per-scene sections (`tools/resolve_asset.py` +
per-scene sections (`tools/resolve_asset.py` + `build/asset-sections.json`) — no runtime capture, works `build/asset-sections.json`) — no runtime capture, all archives/types + audio. Step 3 = **first-pass
across all archives/types and for audio too. Remaining for the render (step 3): wire a `ResourceMap` render** (ResourceMap + GodotAdvHost texture ops → TextureRect compositing): the full-screen event-CG
(scene → section_base; resId → asset via the index) + Godot `TextureRect` compositing (A2b plan Tasks 35, layer renders end-to-end from the bytecode. Remaining (next chunk): the **graphics geometry/blend
now purely mechanical). Audio (step 4) uses the *same* resolver (`play-bgm/play-voice id → files[base+id]`). 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]`).

View File

@@ -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, **Next = A2b:** background via `AGF2BMP2AGF.exe`, `play-voice`/`play-bgm`, choices → VM globals,
just-enough `call-script`/state (unlocks richer scenes). just-enough `call-script`/state (unlocks richer scenes).
### A2b-Background — machinery landed; render blocked on asset resolution (2026-07-06) ### A2b-Background — FIRST-PASS RENDER LANDED (2026-07-06)
Engine-driven texture ops shipped: `create/set/draw-texture` (0x1f8/0x1f9/0x1fb) promoted from VM stubs Resolution solved (`docs/asset-resolution-re.md`: `resId → files[section_base(scene)+resId]`) and wired
to typed `IHost` methods (CaptureHost no-ops → trace parity kept; engine tests 8/8). Tools: `convert_agf.py` into a live render. **Shipped:** `Age.Engine/Sys4/ResourceMap.cs` (loads `build/asset-index.json` +
(AGF→BMP stills) + `tools/frida/` capture harness (Frida 17.15.3 installed). **Blocked:** rendering the bg `build/asset-sections.json`; `Resolve(scene,resId) → AssetEntry`; `TexturePath` → pre-converted BMP);
needs `resId → asset file` resolution, which proved opaque — CGINIT isn't a filename map, SYS4INI (S4IC422) `GodotAdvHost` implements `create/set/draw-texture` (slot → `TextureRect` composited behind the dialogue
needs format RE, Frida file-I/O offsets are noisy (memory-mapping), and the opening mixes movies (MVB/OP = in a `_stage` layer); `IHost.DrawTexture` + VM dispatch extended to pass the destination x/y (draw-texture
MPEG) with stills so eyeball-curation stalled too. **Asset resolution promoted to a dedicated foundational args 7/8); `project.godot` window = 800×600; `convert_agf.py` searches all archives + `--scene` batch.
RE effort** (graphics + audio; not machine-verifiable — Frida ground truth + human eye/ear are the oracle): Engine 8/8, C# `--selftest` still byte-matches the vm0 trace (VM behaviour unchanged). **Works end-to-end:**
see `docs/asset-resolution-re.md`. The backend render (ResourceMap + Godot compositing) stays designed in the VM executes `set-texture(resId)` → ResourceMap resolves across archives → BMP loads → composite; the
`docs/superpowers/plans/2026-07-06-a2b-background.md` Tasks 35, mechanical once resolution lands. 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`.)
--- ---

View File

@@ -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` | | `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 <SCENE> [resId]` | `build/asset-index.json``build/asset-sections.json`; resolves any (scene, resId) | | `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 <SCENE> [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` | | `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) ## Runtime capture (Frida)

View File

@@ -17,7 +17,7 @@ public class TextureOpsTests
public void WaitForInput() { } public void WaitForInput() { }
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 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] [Fact]

View File

@@ -16,7 +16,7 @@ public class WaitForInputTests
public void WaitForInput() => Waits++; public void WaitForInput() => Waits++;
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 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] [Fact]

View File

@@ -10,5 +10,5 @@ public sealed class CaptureHost : IHost
public void WaitForInput() { } public void WaitForInput() { }
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 x, int y, int width, int height) { } public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { }
} }

View File

@@ -7,5 +7,5 @@ public interface IHost
void WaitForInput(); void WaitForInput();
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 x, int y, int width, int height); void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY);
} }

View File

@@ -8,6 +8,9 @@ public static class Paths
public static string GameDir => Path.Combine(Workspace, "姫狩りダンジョンマイスター"); public static string GameDir => Path.Combine(Workspace, "姫狩りダンジョンマイスター");
public static string Build => Path.Combine(Repo, "build"); public static string Build => Path.Combine(Repo, "build");
public static string OpcodesJson => Path.Combine(Build, "opcodes.json"); 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() private static string FindRepo()
{ {

View File

@@ -0,0 +1,63 @@
using System.Text.Json;
namespace Age.Engine.Sys4;
/// <summary>One SYS4INI asset entry.</summary>
public sealed record AssetEntry(string Name, string Archive, long Offset, long Size);
/// <summary>
/// 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.
/// </summary>
public sealed class ResourceMap
{
private readonly IReadOnlyList<AssetEntry> _files;
private readonly IReadOnlyDictionary<string, int> _sceneBase; // "SC0000" -> section base index
public ResourceMap(IReadOnlyList<AssetEntry> files, IReadOnlyDictionary<string, int> sceneBase)
{
_files = files;
_sceneBase = sceneBase;
}
public static ResourceMap Load(string indexPath, string sectionsPath)
{
var files = new List<AssetEntry>();
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<string, int>(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);
/// <summary>Resolve a scene-local resId to its asset, or null if out of range / unknown scene.</summary>
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;
}
/// <summary>Pre-converted BMP path for an AGF asset (see tools/convert_agf.py).</summary>
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;
}
}

View File

@@ -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; _host.CreateTexture((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1;
case "set-texture": case "set-texture":
_host.SetTexture(Read(a[0]), (int)Read(a[1])); return pc + 1; _host.SetTexture(Read(a[0]), (int)Read(a[1])); return pc + 1;
case "draw-texture": 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])); return pc + 1; _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: default:
_host.OnStub(op); return pc + 1; _host.OnStub(op); return pc + 1;
} }

View File

@@ -1,15 +1,22 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
using Age.Engine.Hosting; using Age.Engine.Hosting;
using Age.Engine.Sys4;
public sealed class GodotAdvHost : IHost public sealed class GodotAdvHost : IHost
{ {
private readonly Main _main; 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 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();
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) public void ShowText(int offset, string text)
{ {
@@ -31,4 +38,19 @@ public sealed class GodotAdvHost : IHost
public void CallScript(long id) { } public void CallScript(long id) { }
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) ----
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);
}
} }

View File

@@ -0,0 +1 @@
uid://b6bhdmtc2387h

View File

@@ -7,6 +7,8 @@ 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 readonly Dictionary<int, TextureRect> _slots = new();
private Label _text = null!; private Label _text = null!;
private Label _status = null!; private Label _status = null!;
private VirtualMachine _vm = null!; private VirtualMachine _vm = null!;
@@ -17,6 +19,12 @@ public partial class Main : Godot.Control
public override void _Ready() 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 = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart };
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); _text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_text.OffsetLeft = 40; _text.OffsetTop = 40; _text.OffsetRight = -40; _text.OffsetBottom = -80; _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 table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); 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); _vm = new VirtualMachine(script, table, _host);
_ = Task.Run(() => { _vm.Run(); _done = true; }); _ = Task.Run(() => { _vm.Run(); _done = true; });
@@ -77,6 +85,30 @@ 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,
// 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 AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak() => _status.Text = "▼ click / Enter"; public void PageBreak() => _status.Text = "▼ click / Enter";
public void ClearPage() { _text.Text = ""; _status.Text = ""; } public void ClearPage() { _text.Text = ""; _status.Text = ""; }

View File

@@ -4,5 +4,9 @@ config_version=5
config/name="Himegari (age-reimpl A2a)" config/name="Himegari (age-reimpl A2a)"
run/main_scene="res://Main.tscn" run/main_scene="res://Main.tscn"
[display]
window/size/viewport_width=800
window/size/viewport_height=600
[dotnet] [dotnet]
project/assembly_name="Himegari" project/assembly_name="Himegari"

View File

@@ -1,14 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Convert named AGF files (in extracted/DATA2 or DATA5) to BMP via AGF2BMP2AGF.exe, """Convert AGF images to BMP via AGF2BMP2AGF.exe, into build/textures/.
into build/textures/. Run: py -3.11 -X utf8 tools/convert_agf.py EV001AA.AGF ..."""
import os, sys, shutil, subprocess 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__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import paths import paths
EXE = paths.EXTRACTED / "DATA1" / "AGF2BMP2AGF.exe" # tool lives in extracted/DATA1 EXE = paths.EXTRACTED / "DATA1" / "AGF2BMP2AGF.exe" # tool lives in extracted/DATA1
SRC_DIRS = [paths.EXTRACTED / "DATA2", paths.EXTRACTED / "DATA5"] SRC_DIRS = [paths.EXTRACTED / d for d in ("DATA1", "DATA2", "DATA3", "DATA4", "DATA5")]
OUT = paths.BUILD / "textures" OUT = paths.BUILD / "textures"
def find(name): def find(name):
for d in SRC_DIRS: for d in SRC_DIRS:
p = d / name p = d / name
@@ -16,18 +28,44 @@ def find(name):
return p return p
return None 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): def main(argv):
OUT.mkdir(parents=True, exist_ok=True) OUT.mkdir(parents=True, exist_ok=True)
for name in argv: if argv and argv[0] == "--scene":
src = find(name) names = scene_agfs(argv[1])
if not src: print(f"{argv[1]}: {len(names)} .AGF assets in manifest")
print(f"NOT FOUND: {name}"); continue else:
tmp = OUT / name names = argv
shutil.copy(src, tmp) ok = sum(convert(n) for n in names)
subprocess.run([str(EXE), name], cwd=str(OUT), check=True) print(f"converted {ok}/{len(names)} -> {OUT.relative_to(paths.REPO)}")
tmp.unlink(missing_ok=True) # drop the copied .AGF, keep the .BMP
print(f"converted {name} -> {OUT / (os.path.splitext(name)[0] + '.BMP')}")
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main(sys.argv[1:])) sys.exit(main(sys.argv[1:]))