From a1f6d0ab513faefc1c2b7b8a961a5dbf36a648c9 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 11 Jul 2026 09:55:58 -0400 Subject: [PATCH] Decode AGF textures through the asset store --- docs/PROJECT-STRUCTURE.md | 4 +- docs/asset-resolution-re.md | 29 +++- docs/phase-a-slice-plan.md | 22 ++- docs/remake-architecture-and-roadmap.md | 4 + docs/tools-reference.md | 2 +- engine/Age.Cli/Program.cs | 16 +- engine/Age.Engine.Tests/AgfDecoderTests.cs | 164 +++++++++++++++++++++ engine/Age.Engine.Tests/BmpHeaderTests.cs | 33 ----- engine/Age.Engine/Sys4/AgfDecoder.cs | 132 +++++++++++++++++ engine/Age.Engine/Sys4/BmpHeader.cs | 25 ---- engine/Age.Engine/Sys4/LzssDecoder.cs | 44 ++++++ engine/Age.Engine/Sys4/ResourceMap.cs | 23 ++- engine/Age.Engine/Sys4/Sys4AssetCatalog.cs | 41 +----- godot/GodotAdvHost.cs | 43 ++++-- godot/Main.cs | 44 +++--- 15 files changed, 468 insertions(+), 158 deletions(-) create mode 100644 engine/Age.Engine.Tests/AgfDecoderTests.cs delete mode 100644 engine/Age.Engine.Tests/BmpHeaderTests.cs create mode 100644 engine/Age.Engine/Sys4/AgfDecoder.cs delete mode 100644 engine/Age.Engine/Sys4/BmpHeader.cs create mode 100644 engine/Age.Engine/Sys4/LzssDecoder.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index b854b2d..34ae5c8 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -85,7 +85,7 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ │ └── strings.jsonl every string, tagged by source opcode │ ├── data/ parsed data tables (*INIT → JSON) │ ├── scripts-json/ machine-readable full dumps (on demand via --json) - │ ├── textures/ AGF→BMP stills (convert_agf.py) — feeds the Godot render + │ ├── textures/ AGF-to-BMP stills (convert_agf.py) - diagnostic pixel oracle only │ ├── engine-dump/ UNPACKED engine dump (frida/dump_engine.py): range_.bin + manifest.json │ ├── asset-index.json, asset-sections.json asset resolver data (parse_sys4ini / resolve_asset) │ ├── global-var-map.{json,md} partial global-variable name map (auto shape inference; feeds globals.toml merge) @@ -97,7 +97,7 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ ├── engine/ DELIVERABLE — the .NET VM core (AgeEngine.sln: Age.Engine / Age.Cli / tests) │ └── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store, - │ script provider, and temporary resource facade + │ script provider, AGF-to-RGBA8/LZSS decoder, and resource facade ├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md) └── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine) ``` diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index d92be9d..a96754a 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -166,7 +166,7 @@ The current Phase-A backend deliberately continues through the extracted-file bo accepts both OGG and WAV and Godot loads the WAV bytes into its fixed SC0000 channel pool. This does not change the scoped VFS plan below: ALF/AAI mounting and in-process asset reads remain a separate foundation track. -## Runtime asset-VFS track (VFS-A complete 2026-07-11) +## Runtime asset-VFS track (VFS-A and VFS-C complete 2026-07-11) The pre-extracted tree and `build/textures/*.BMP` pipeline were a Phase-A bootstrap, not the desired final runtime. The native-compatible target is a read-only virtual filesystem that preserves AGE's translation/mod @@ -199,16 +199,20 @@ store. by name replacement, or by another table selected by the native high-byte-id path; do not invent mount precedence. Validate every parsed append entry against `BinExtractALF.exe` output before exposing it to the runtime. -3. **AGF decoder.** Decode an opened AGF stream directly to width/height + RGBA8. The MIT-licensed GARbro +3. **AGF decoder (VFS-C DONE).** `Age.Engine/Sys4/AgfDecoder.cs` decodes an opened AGF payload directly + to a tightly packed, top-down width/height + RGBA8 surface. The MIT-licensed GARbro `ArcFormats/Eushully/ImageAGF.cs` provides a compact reference: `ACGF` (or zero) signature, type 1/2, LZSS-or-raw header section, 4/8/truecolor source pixels, LZSS-or-raw pixel section, bottom-up row/stride conversion, and optional `ACIF` LZSS alpha plane. Port only the algorithm and attribution into platform-neutral .NET code; do not carry GARbro's WPF/GameRes dependencies. Kelebek's extractor and the on-disk `BinExtractALF.exe` are validation references; the Kelebek repository exposes no clear license, - so its code should not be copied without clarification. -4. **Runtime consumers.** Script loading is complete. Next make texture surfaces own - decoded RGBA pixels rather than BMP paths, and load OGG/WAV from store bytes. Migrate one consumer at a - time; retain extraction/conversion tools as diagnostics until parity is established. + so its code should not be copied without clarification. The focused `LzssDecoder` is shared with + `Sys4AssetCatalog`; raw and compressed information/pixel/ACIF sections use the same bounded primitive. +4. **Runtime consumers.** Script and texture loading are complete. `ResourceMap.ResolveTexture` preserves + scene-local resolution and falls back to universal raw ids for SYSTEM4 assets; `GodotAdvHost` caches + decoded RGBA surfaces by catalog identity and supplies synchronous dimensions to opcode `0x208`. + Godot no longer reads `build/textures/*.BMP`. OGG/WAV byte migration remains a separate follow-up; + retain extraction/conversion tools as diagnostics until parity is established. ### Acceptance gates @@ -233,8 +237,17 @@ are byte-identical to `extracted/`, and synthetic removal of a loose override re Traversal, past-range seek/read, and concurrent reads are covered. Installed override enumeration corrected an older inventory error: this tree contains 51 loose root BINs, comprising **49 archive-backed v1.03 script overrides** (all byte-proven to win and differ from DATA1) plus root-only `SYS4INI.BIN` and `SYS4AB.BIN`. -There are not 52 archive copies available to shadow. APPEND01/AAI, AGF decode, audio consumers, and movie -`0x236` remain unimplemented by design. +There are not 52 archive copies available to shadow. + +VFS-C passes its bounded gates in `AgfDecoderTests`: synthesized fixtures cover raw/compressed sections, +4/8-bit palettes, 24/32-bit truecolor expansion, padded bottom-up rows, type 1/2, and ACIF/no-ACIF alpha. +Five installed assets spanning raw/compressed metadata and pixel/alpha combinations match the existing +`AGF2BMP2AGF` BMP oracle pixel-for-pixel. SO001 resolves through universal raw id `0x337e`, decodes to +800×300 with intermediate alpha values, and is inherited in surface slot 17 before SC0000. A windowed +page-1 capture with `build/textures/` moved aside showed the translucent textbox edge and bottom-right +controls. Texture runtime no longer consults `extracted/` or `build/textures/`; current audio consumers +still use extracted OGG/WAV paths by design. APPEND01/AAI, audio migration, and movie `0x236` remain +unimplemented by this slice. ### Deliberate non-goals diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 1084995..edd5b6b 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1050,7 +1050,7 @@ opcode/ctx lint, 481-script decode, RECOVER, and `git diff --check` clean. SC000 **85/129 to 87/129 handled (67.4%)**, with 42 GAP ops / 188 GAP instructions. Movie `0x236` and SFX remain separate slices. No implementation commit was made. -### Candidate foundation track — native asset VFS + ALF/AAI/AGF readers (SCOPED 2026-07-10; NOT STARTED) +### Foundation track — native asset VFS + ALF/AAI/AGF readers (VFS-A/C DONE; VFS-B PENDING) This is an optional high-leverage detour before movie `0x236` or SFX. It replaces the Phase-A pre-extracted/pre-converted asset bootstrap with the native loose-override/archive-fallback model and removes @@ -1105,6 +1105,26 @@ archive-backed script overrides and two root-only BIN engine files, not the hist shadowing scripts; all 49 were byte-proven to win and differ from their archive payload. VFS-B APPEND01/AAI, VFS-C AGF, audio migration, and movie `0x236` remain separate. +### VFS-C — in-process AGF to RGBA8 + SC0000 system chrome DONE (2026-07-11) + +`AgfDecoder` now consumes `IAssetStore` bytes and emits a platform-neutral, tightly packed top-down RGBA8 +surface. Its shared 4 KiB-ring `LzssDecoder` covers raw/compressed information, pixel, and optional ACIF +alpha sections; expansion covers 4/8-bit palettes, 24/32-bit truecolor, DIB row padding, and bottom-up +orientation. Synthetic fixtures exercise the whole format matrix. Installed `AE000A`, `AE001A`, +`BG030A`, `EV052CA`, and `SO001` match the existing converter's pixels exactly; SO001 is 800×300 +with non-binary per-pixel alpha. + +`ResourceMap` now resolves texture ids through the scene manifest with a universal raw-id fallback, +then decodes through the loose-first VFS. Godot and the CLI no longer use `BmpHeader`, `TexturePath`, or +pre-converted BMPs. SYSTEM4's inherited SO001 surface is seeded into slot `0x11` before SC0000, allowing +the existing callback-window crops to render the textbox and control icons. Windowed page-1 validation +passed with `build/textures/` temporarily unavailable; source alpha produced the translucent upper edge +and the bottom-right control row was visible. + +Validation: 124/124 engine tests, CLI/Godot builds, Godot threaded selftest, pixel parity for five installed +assets, and the windowed no-BMP capture. VFS-B APPEND01/AAI, OGG/WAV migration, movie `0x236`, and unrelated +opcode work remain separate. + ### Phase A — native SC0000 SFX family (`0xb4`/`0xb5`/`0xb6`/`0xc2`/`0xd9`) DONE (2026-07-11) Native RE and the matching trace resolve the bounded family. `0xb4(resource,channel)` synchronously loads diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 3842e32..a7be15c 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -69,6 +69,10 @@ Three layers, cleanly separated: the offline analysis/authoring chain; they were the reference implementation and stay useful for modders. They share the *format spec* (documented), not code — acceptable for a small, stable container format. +- **Native content foundation status (2026-07-11).** VFS-A runtime-parses the base SYS4 catalog and + applies loose-first bounded ALF reads; VFS-C decodes AGF directly to platform-neutral RGBA8 and feeds + Godot without pre-extracted or pre-converted texture files. APPEND01/AAI mounting and audio-byte consumers + remain separate follow-ups. - **Profile = manifest.** Adding a game = a new profile + its maps. Adding an engine version = a new front-end plugin + profiles that reference it. See §5. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 52c693e..02b0b6a 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -160,7 +160,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after | `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the diagnostic JSON asset-index mirror — name ↔ archive ↔ offset ↔ size for all DATA*.ALF. Each real entry carries universal `raw_index`; the runtime parses SYS4INI itself, while these generated files remain tooling/test oracles. Also emits the `call-script → name` annotation map. | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN` → `build/asset-index.json` + `build/callscript-names.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 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` | +| `convert_agf.py` | Convert AGF stills to BMP via `AGF2BMP2AGF.exe` (searches all `extracted/DATA*`). `--scene` batch-converts a scene's whole SYS4INI manifest. Since VFS-C, output is a diagnostic pixel-parity oracle; the runtime decodes AGF bytes directly. | `convert_agf.py EV052CA.AGF …` · `convert_agf.py --scene SC0000` | `extracted/DATA*/*.AGF` → `build/textures/*.BMP` | ## Runtime capture (Frida) diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 7385f6d..3bedf86 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -373,7 +373,7 @@ sealed class GfxTraceHost : IHost { private readonly ResourceMap _res; private readonly string _scene; - private readonly Dictionary _slotBmp = new(); // slot -> resolved BMP path (or null) + private readonly Dictionary _slotAsset = new(); // slot -> resolved AGF name (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 _slotDims = new() { { 0, (800, 600) } }; @@ -389,19 +389,19 @@ sealed class GfxTraceHost : IHost 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); + var e = _res.ResolveTexture(_scene, resId); + RgbaImage? image = e != null ? _res.DecodeTexture(e) : null; + _slotAsset[slot] = e?.Name; + _slotDims[slot] = image != null ? (image.Width, image.Height) : (0, 0); Events.Add($"set-texture slot={slot} res=0x{resId:x} -> {(e?.Name ?? "")}" - + (bmp == null ? " [NO BMP]" : "")); + + (image == null ? " [NO AGF]" : "")); } public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { - _slotBmp.TryGetValue(slot, out var bmp); + _slotAsset.TryGetValue(slot, out var asset); Events.Add($"draw-texture slot={slot} src=({sx},{sy} {w}x{h}) dst=({dx},{dy}) " - + $"file={(bmp != null ? System.IO.Path.GetFileName(bmp) : "")}"); + + $"file={(asset ?? "")}"); } public void CreateTexture(int slot, int width, int height) diff --git a/engine/Age.Engine.Tests/AgfDecoderTests.cs b/engine/Age.Engine.Tests/AgfDecoderTests.cs new file mode 100644 index 0000000..f62ec4e --- /dev/null +++ b/engine/Age.Engine.Tests/AgfDecoderTests.cs @@ -0,0 +1,164 @@ +using System.Buffers.Binary; +using Age.Engine.Sys4; + +namespace Age.Engine.Tests; + +public class AgfDecoderTests +{ + [Fact] + public void DecodesRawFourBitPaletteWithBottomUpStride() + { + byte[] palette = Palette((0x10, 0x20, 0x30), (0x40, 0x50, 0x60), (0x70, 0x80, 0x90)); + byte[] bottomUp = { 0x12, 0x10, 0, 0, 0x01, 0x20, 0, 0 }; + var image = AgfDecoder.Decode(BuildAgf(1, 3, 2, 4, palette, bottomUp)); + + Assert.Equal((3, 2), (image.Width, image.Height)); + Assert.Equal(new byte[] { + 0x10,0x20,0x30,255, 0x40,0x50,0x60,255, 0x70,0x80,0x90,255, + 0x40,0x50,0x60,255, 0x70,0x80,0x90,255, 0x40,0x50,0x60,255, + }, image.Pixels); + } + + [Fact] + public void DecodesCompressedEightBitPaletteAndAcifAlpha() + { + byte[] palette = Palette(256, (1, 2, 3), (4, 5, 6), (7, 8, 9)); + byte[] bottomUp = { 2, 1, 0, 0, 0, 1, 2, 0 }; + byte[] alpha = { 10, 20, 30, 40, 50, 60 }; + var image = AgfDecoder.Decode(BuildAgf(2, 3, 2, 8, palette, bottomUp, alpha, + compressInfo: true, compressPixels: true, compressAlpha: true)); + + Assert.Equal(new byte[] { + 1,2,3,10, 4,5,6,20, 7,8,9,30, + 7,8,9,40, 4,5,6,50, 1,2,3,60, + }, image.Pixels); + } + + [Fact] + public void DecodesTruecolorAndDefaultsMissingAlphaToOpaque() + { + byte[] bottomUp = { 30,20,10,0, 60,50,40,0 }; + var image = AgfDecoder.Decode(BuildAgf(2, 2, 1, 32, null, bottomUp)); + Assert.Equal(new byte[] { 10,20,30,255, 40,50,60,255 }, image.Pixels); + } + + [Fact] + public void DecodesThroughAssetStoreByteSeam() + { + byte[] agf = BuildAgf(1, 1, 1, 24, null, new byte[] { 3, 2, 1, 0 }); + var entry = new AssetEntry("TEST.AGF", "DATA.ALF", 0, agf.Length); + var image = AgfDecoder.Decode(new MemoryStore(agf), entry); + Assert.Equal(new byte[] { 1, 2, 3, 255 }, image.Pixels); + } + + [Theory] + [InlineData("AE000A.AGF")] + [InlineData("AE001A.AGF")] + [InlineData("BG030A.AGF")] + [InlineData("EV052CA.AGF")] + [InlineData("SO001.AGF")] + public void InstalledAssetMatchesExistingConverterPixels(string name) + { + string bmpPath = Path.Combine(Paths.Textures, Path.ChangeExtension(name, ".BMP")); + if (!File.Exists(bmpPath)) return; + var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + var store = new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir); + var image = AgfDecoder.Decode(store, catalog.ResolveName(name)!); + var oracle = ReadBmp32(bmpPath); + Assert.Equal((oracle.Width, oracle.Height), (image.Width, image.Height)); + Assert.Equal(oracle.Pixels, image.Pixels); + } + + [Fact] + public void InstalledSo001HasExpectedAlphaBearingDimensions() + { + var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + var store = new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir); + var resources = new ResourceMap(catalog, store); + Assert.Equal("SO001.AGF", resources.ResolveTexture("SC0000", 0x337e)?.Name); + var image = AgfDecoder.Decode(store, catalog.ResolveRaw(0x337e)!); + Assert.Equal((800, 300), (image.Width, image.Height)); + Assert.Contains(image.Pixels.Where((_, i) => (i & 3) == 3), a => a is > 0 and < 255); + } + + private sealed class MemoryStore(byte[] bytes) : IAssetStore + { + public Stream Open(AssetEntry entry) => new MemoryStream(bytes, writable: false); + public byte[] ReadAll(AssetEntry entry) => bytes; + } + + private static byte[] Palette(params (byte R, byte G, byte B)[] colors) => Palette(16, colors); + private static byte[] Palette(int count, params (byte R, byte G, byte B)[] colors) + { + var result = new byte[count * 4]; + for (int i = 0; i < colors.Length; i++) + { result[i * 4] = colors[i].B; result[i * 4 + 1] = colors[i].G; result[i * 4 + 2] = colors[i].R; } + return result; + } + + private static byte[] BuildAgf(int type, int width, int height, int bpp, byte[]? palette, byte[] pixels, + byte[]? alpha = null, bool compressInfo = false, + bool compressPixels = false, bool compressAlpha = false) + { + var info = new byte[0x38 + (palette?.Length ?? 0)]; + Put32(info, 0x14, width); Put32(info, 0x18, height); Put16(info, 0x1c, 1); Put16(info, 0x1e, bpp); + palette?.CopyTo(info, 0x38); + byte[] packedInfo = compressInfo ? LiteralLzss(info) : info; + byte[] packedPixels = compressPixels ? LiteralLzss(pixels) : pixels; + byte[]? packedAlpha = alpha == null ? null : compressAlpha ? LiteralLzss(alpha) : alpha; + int length = 0x18 + packedInfo.Length + 12 + packedPixels.Length + + (alpha == null ? 0 : 0x24 + packedAlpha!.Length); + var file = new byte[length]; + "ACGF"u8.CopyTo(file); Put32(file, 4, type); + Put32(file, 0x0c, info.Length); Put32(file, 0x14, packedInfo.Length); + packedInfo.CopyTo(file, 0x18); + int p = 0x18 + packedInfo.Length; + Put32(file, p + 4, pixels.Length); Put32(file, p + 8, packedPixels.Length); + packedPixels.CopyTo(file, p + 12); p += 12 + packedPixels.Length; + if (alpha != null) + { + "ACIF"u8.CopyTo(file.AsSpan(p)); Put32(file, p + 0x1c, alpha.Length); + Put32(file, p + 0x20, packedAlpha!.Length); packedAlpha.CopyTo(file, p + 0x24); + } + return file; + } + + private static byte[] LiteralLzss(byte[] source) + { + var output = new List(); + for (int p = 0; p < source.Length;) + { + int count = Math.Min(8, source.Length - p); + output.Add((byte)((1 << count) - 1)); + for (int i = 0; i < count; i++) output.Add(source[p++]); + } + return output.ToArray(); + } + + private static RgbaImage ReadBmp32(string path) + { + byte[] b = File.ReadAllBytes(path); + int offset = BinaryPrimitives.ReadInt32LittleEndian(b.AsSpan(10)); + int width = BinaryPrimitives.ReadInt32LittleEndian(b.AsSpan(18)); + int signedHeight = BinaryPrimitives.ReadInt32LittleEndian(b.AsSpan(22)); + int bpp = BinaryPrimitives.ReadInt16LittleEndian(b.AsSpan(28)); + Assert.True(bpp is 24 or 32); + int height = Math.Abs(signedHeight); + int stride = ((width * bpp / 8) + 3) & ~3; + var rgba = new byte[width * height * 4]; + for (int y = 0; y < height; y++) + { + int sy = signedHeight > 0 ? height - 1 - y : y; + for (int x = 0; x < width; x++) + { + int s = offset + sy * stride + x * (bpp / 8), d = (y * width + x) * 4; + rgba[d] = b[s + 2]; rgba[d + 1] = b[s + 1]; rgba[d + 2] = b[s]; + rgba[d + 3] = bpp == 32 ? b[s + 3] : (byte)255; + } + } + return new RgbaImage(width, height, rgba); + } + + private static void Put32(byte[] b, int p, int value) => BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(p), value); + private static void Put16(byte[] b, int p, int value) => BinaryPrimitives.WriteInt16LittleEndian(b.AsSpan(p), (short)value); +} diff --git a/engine/Age.Engine.Tests/BmpHeaderTests.cs b/engine/Age.Engine.Tests/BmpHeaderTests.cs deleted file mode 100644 index 6fafd6c..0000000 --- a/engine/Age.Engine.Tests/BmpHeaderTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -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/AgfDecoder.cs b/engine/Age.Engine/Sys4/AgfDecoder.cs new file mode 100644 index 0000000..093aab8 --- /dev/null +++ b/engine/Age.Engine/Sys4/AgfDecoder.cs @@ -0,0 +1,132 @@ +using System.Buffers.Binary; + +namespace Age.Engine.Sys4; + +/// A decoded, tightly packed, top-down RGBA8 image. +public sealed record RgbaImage(int Width, int Height, byte[] Pixels); + +/// +/// Platform-neutral Eushully AGF decoder. Format algorithm ported from GARbro's MIT-licensed +/// ArcFormats/Eushully/ImageAGF.cs (Copyright (C) 2015 morkt). +/// +public static class AgfDecoder +{ + private const int OuterHeaderSize = 0x18; + + public static RgbaImage Decode(IAssetStore store, AssetEntry entry) + => Decode(store.ReadAll(entry), entry.Name); + + public static RgbaImage Decode(ReadOnlySpan file, string name = "AGF") + { + if (file.Length < OuterHeaderSize || + !(file[..4].SequenceEqual("ACGF"u8) || BinaryPrimitives.ReadUInt32LittleEndian(file) == 0)) + throw new InvalidDataException($"{name}: expected an ACGF image"); + + int type = I32(file, 4, name); + if (type is not (1 or 2)) throw new InvalidDataException($"{name}: unsupported AGF type {type}"); + int infoSize = PositiveSize(file, 0x0c, name, "information expanded size"); + int infoPacked = PositiveSize(file, 0x14, name, "information packed size"); + byte[] info = OpenSection(Slice(file, OuterHeaderSize, infoPacked, name, "information section"), + infoSize, infoPacked, name, "information"); + if (info.Length < 0x20) throw new InvalidDataException($"{name}: information header is truncated"); + + int width = PositiveSize(info, 0x14, name, "width"); + int height = PositiveSize(info, 0x18, name, "height"); + int sourceBpp = I16(info, 0x1e, name); + if (sourceBpp is not (4 or 8 or 24 or 32)) + throw new InvalidDataException($"{name}: unsupported source depth {sourceBpp}"); + long pixelCount = (long)width * height; + if (pixelCount > int.MaxValue / 4) throw new InvalidDataException($"{name}: dimensions are too large"); + + ReadOnlySpan palette = default; + if (sourceBpp <= 8) + palette = Slice(info, 0x38, checked((1 << sourceBpp) * 4), name, "palette"); + + int dataOffset = checked(OuterHeaderSize + infoPacked); + ReadOnlySpan dataHeader = Slice(file, dataOffset, 12, name, "pixel section header"); + int dataSize = PositiveSize(dataHeader, 4, name, "pixel expanded size"); + int dataPacked = PositiveSize(dataHeader, 8, name, "pixel packed size"); + int dataPos = checked(dataOffset + 12); + byte[] pixels = OpenSection(Slice(file, dataPos, dataPacked, name, "pixel section"), + dataSize, dataPacked, name, "pixels"); + + byte[]? alpha = null; + if (type == 2) + { + int alphaOffset = checked(dataPos + dataPacked); + if (alphaOffset <= file.Length - 0x24 && file.Slice(alphaOffset, 4).SequenceEqual("ACIF"u8)) + { + ReadOnlySpan alphaHeader = file.Slice(alphaOffset, 0x24); + int alphaSize = PositiveSize(alphaHeader, 0x1c, name, "alpha expanded size"); + int alphaPacked = PositiveSize(alphaHeader, 0x20, name, "alpha packed size"); + if (alphaSize != pixelCount) throw new InvalidDataException($"{name}: alpha dimensions do not match image"); + alpha = OpenSection(Slice(file, alphaOffset + 0x24, alphaPacked, name, "alpha section"), + alphaSize, alphaPacked, name, "alpha"); + } + } + + int sourceRowBytes = checked((checked(width * sourceBpp) + 7) / 8); + int sourceStride = checked((sourceRowBytes + 3) & ~3); + if ((long)sourceStride * height > pixels.Length) + throw new InvalidDataException($"{name}: pixel section is shorter than its bitmap stride"); + var rgba = new byte[checked((int)pixelCount * 4)]; + for (int y = 0; y < height; y++) + { + int src = checked((height - 1 - y) * sourceStride); + int dst = checked(y * width * 4); + int alphaAt = y * width; + for (int x = 0; x < width; x++, dst += 4) + { + if (sourceBpp == 4) + { + int index = (pixels[src + (x >> 1)] >> ((x & 1) == 0 ? 4 : 0)) & 0x0f; + CopyPalette(palette, index, rgba, dst); + } + else if (sourceBpp == 8) + CopyPalette(palette, pixels[src + x], rgba, dst); + else + { + int at = src + x * (sourceBpp / 8); + rgba[dst] = pixels[at + 2]; + rgba[dst + 1] = pixels[at + 1]; + rgba[dst + 2] = pixels[at]; + } + rgba[dst + 3] = alpha?[alphaAt + x] ?? (byte)255; + } + } + return new RgbaImage(width, height, rgba); + } + + private static void CopyPalette(ReadOnlySpan palette, int index, byte[] rgba, int dst) + { + int p = index * 4; + rgba[dst] = palette[p + 2]; rgba[dst + 1] = palette[p + 1]; rgba[dst + 2] = palette[p]; + } + + private static byte[] OpenSection(ReadOnlySpan source, int expanded, int packed, + string name, string section) + => expanded == packed ? source.ToArray() : LzssDecoder.Decode(source, expanded, $"{name}: {section}"); + + private static int I32(ReadOnlySpan data, int offset, string name) + { + if (offset < 0 || offset > data.Length - 4) throw new InvalidDataException($"{name}: header is truncated"); + return BinaryPrimitives.ReadInt32LittleEndian(data.Slice(offset, 4)); + } + private static int I16(ReadOnlySpan data, int offset, string name) + { + if (offset < 0 || offset > data.Length - 2) throw new InvalidDataException($"{name}: header is truncated"); + return BinaryPrimitives.ReadInt16LittleEndian(data.Slice(offset, 2)); + } + private static int PositiveSize(ReadOnlySpan data, int offset, string name, string field) + { + int value = I32(data, offset, name); + if (value <= 0) throw new InvalidDataException($"{name}: invalid {field} {value}"); + return value; + } + private static ReadOnlySpan Slice(ReadOnlySpan data, int offset, int count, string name, string field) + { + if (offset < 0 || count < 0 || offset > data.Length - count) + throw new InvalidDataException($"{name}: {field} is truncated"); + return data.Slice(offset, count); + } +} diff --git a/engine/Age.Engine/Sys4/BmpHeader.cs b/engine/Age.Engine/Sys4/BmpHeader.cs deleted file mode 100644 index b541f44..0000000 --- a/engine/Age.Engine/Sys4/BmpHeader.cs +++ /dev/null @@ -1,25 +0,0 @@ -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); } - } -} diff --git a/engine/Age.Engine/Sys4/LzssDecoder.cs b/engine/Age.Engine/Sys4/LzssDecoder.cs new file mode 100644 index 0000000..8bb87aa --- /dev/null +++ b/engine/Age.Engine/Sys4/LzssDecoder.cs @@ -0,0 +1,44 @@ +namespace Age.Engine.Sys4; + +/// Eushully's 4 KiB-ring LZSS stream used by SYS4 catalogs and AGF sections. +public static class LzssDecoder +{ + public static byte[] Decode(ReadOnlySpan source, int expectedSize, string name = "LZSS") + { + if (expectedSize < 0) throw new InvalidDataException($"{name}: negative expanded size"); + var frame = new byte[0x1000]; + var result = new byte[expectedSize]; + int framePos = 0xfee, input = 0, output = 0; + while (output < expectedSize) + { + if (input >= source.Length) throw new InvalidDataException($"{name}: stream ended early"); + int control = source[input++]; + for (int bit = 1; bit <= 0x80 && output < expectedSize; bit <<= 1) + { + if ((control & bit) != 0) + { + if (input >= source.Length) throw new InvalidDataException($"{name}: truncated literal"); + byte value = source[input++]; + result[output++] = value; + frame[framePos] = value; + framePos = (framePos + 1) & 0xfff; + } + else + { + if (input > source.Length - 2) throw new InvalidDataException($"{name}: truncated back-reference"); + int lo = source[input++], hi = source[input++]; + int readPos = ((hi & 0xf0) << 4) | lo; + int length = 3 + (hi & 0x0f); + for (int j = 0; j < length && output < expectedSize; j++) + { + byte value = frame[readPos++ & 0xfff]; + result[output++] = value; + frame[framePos] = value; + framePos = (framePos + 1) & 0xfff; + } + } + } + } + return result; + } +} diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index c2e2430..7285f7a 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -9,8 +9,13 @@ namespace Age.Engine.Sys4; public sealed class ResourceMap { private readonly Sys4AssetCatalog _catalog; + private readonly IAssetStore _store; - public ResourceMap(Sys4AssetCatalog catalog) => _catalog = catalog; + public ResourceMap(Sys4AssetCatalog catalog, IAssetStore? store = null) + { + _catalog = catalog; + _store = store ?? new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir); + } public static ResourceMap Load() => new(Sys4AssetCatalog.Load(Paths.Sys4Ini)); @@ -20,14 +25,20 @@ public sealed class ResourceMap return _catalog.ResolveScene(scene, resId); } - /// Pre-converted BMP path for an AGF asset (see tools/convert_agf.py). - public static string? TexturePath(AssetEntry a) + /// Resolve graphics normally through the scene manifest, with the universal raw-id + /// fallback used by SYSTEM4-owned assets such as SO001. + public AssetEntry? ResolveTexture(string scene, long resId) { - 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; + var entry = _catalog.ResolveScene(scene, resId) ?? _catalog.ResolveRaw(resId); + return entry is { IsPlaceholder: false } && + entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } + /// Decode an AGF directly from loose-first VFS bytes. + public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry); + + public AssetEntry? ResolveName(string name) => _catalog.ResolveName(name); + /// /// Resolve a BGM id to its OGG path. BGM is addressed by DIRECT LITERAL NAME (BGM{id:D3}.OGG), NOT the /// per-scene section manifest that voices/textures use. Confirmed by ear (play-bgm 5->BGM005, 8->BGM008) diff --git a/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs b/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs index 06a535e..d18e7d9 100644 --- a/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs +++ b/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs @@ -55,8 +55,8 @@ public sealed class Sys4AssetCatalog if (expandedSize == 0 || expandedSize > int.MaxValue) throw new InvalidDataException($"{name}: invalid expanded size {expandedSize}"); - var blob = DecompressLzss(data.AsSpan(PackedSizeOffset + 4, checked((int)packedSize)), - checked((int)expandedSize), name); + var blob = LzssDecoder.Decode(data.AsSpan(PackedSizeOffset + 4, checked((int)packedSize)), + checked((int)expandedSize), name); int p = 0; uint ReadU32() { @@ -145,41 +145,4 @@ public sealed class Sys4AssetCatalog return Encoding.GetEncoding(932).GetString(bytes); } - private static byte[] DecompressLzss(ReadOnlySpan source, int expectedSize, string name) - { - var frame = new byte[0x1000]; - int framePos = 0xfee, input = 0, output = 0; - var result = new byte[expectedSize]; - while (output < expectedSize) - { - if (input >= source.Length) throw new InvalidDataException($"{name}: LZSS stream ended early"); - int control = source[input++]; - for (int bit = 1; bit <= 0x80 && output < expectedSize; bit <<= 1) - { - if ((control & bit) != 0) - { - if (input >= source.Length) throw new InvalidDataException($"{name}: truncated LZSS literal"); - byte value = source[input++]; - result[output++] = value; - frame[framePos] = value; - framePos = (framePos + 1) & 0xfff; - } - else - { - if (input > source.Length - 2) throw new InvalidDataException($"{name}: truncated LZSS back-reference"); - int lo = source[input++], hi = source[input++]; - int readPos = ((hi & 0xf0) << 4) | lo; - int length = 3 + (hi & 0x0f); - for (int j = 0; j < length && output < expectedSize; j++) - { - byte value = frame[readPos++ & 0xfff]; - result[output++] = value; - frame[framePos] = value; - framePos = (framePos + 1) & 0xfff; - } - } - } - } - return result; - } } diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index db731b6..32d0db1 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -9,7 +9,8 @@ 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 object _imageLock = new(); + private readonly Dictionary _images = new(); // raw catalog id -> decoded pixels private readonly string?[] _sfxPaths = new string?[10]; // SC0000 native channel subset // 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). @@ -230,22 +231,20 @@ public sealed class GodotAdvHost : IHost public void CreateTexture(int slot, int width, int height) { lock (_textLock) _surfaceText.Remove(slot); - _slotBmp[slot] = null; _slotDims[slot] = (width, height); + _slotDims[slot] = (width, height); if (TraceOps) Godot.GD.Print($"[op] create-texture slot={slot} {width}x{height}"); } public void SetTexture(long resourceId, int slot) { lock (_textLock) _surfaceText.Remove(slot); - var asset = _res.Resolve(_scene, resourceId); - var bmp = asset != null ? ResourceMap.TexturePath(asset) : null; - _slotBmp[slot] = bmp; - _slotDims[slot] = BmpHeader.ReadDims(bmp); // synchronous: dims from the header, no Godot Image - if (TraceOps) Godot.GD.Print($"[op] set-texture slot={slot} resId=0x{resourceId:x} -> {(bmp != null ? System.IO.Path.GetFileName(bmp) : "")}"); + var asset = _res.ResolveTexture(_scene, resourceId); + var image = asset != null ? Decode(asset) : null; + _slotDims[slot] = image != null ? (image.Width, image.Height) : (0, 0); + if (TraceOps) Godot.GD.Print($"[op] set-texture slot={slot} resId=0x{resourceId:x} -> {(asset?.Name ?? "")}"); } - // 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. + // AGF is decoded synchronously on the VM thread so geometry queried immediately afterward sees real dims. public (int Width, int Height) GetTextureSize(int slot) => _slotDims.TryGetValue(slot, out var d) ? (d.W, d.H) : (0, 0); @@ -253,12 +252,28 @@ public sealed class GodotAdvHost : IHost // the visible objects each frame in ascending-handle order. No immediate blit here. public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } - /// Resolve a gfx surface's resId to its pre-converted BMP path (Main's per-frame compositor - /// resolves each visible object's surface through this). - public string? ResolveResIdTexture(long resId) + /// Resolve a gfx surface through scene-local or universal raw-id addressing and decode it + /// from the loose-first asset store. + public (RgbaImage Image, string Name, int AssetId)? ResolveResIdTexture(long resId) { - var asset = _res.Resolve(_scene, resId); - return asset != null ? ResourceMap.TexturePath(asset) : null; + var asset = _res.ResolveTexture(_scene, resId); + var image = asset != null ? Decode(asset) : null; + return asset != null && image != null ? (image, asset.Name, asset.RawIndex) : null; + } + + private RgbaImage? Decode(AssetEntry asset) + { + lock (_imageLock) + { + if (_images.TryGetValue(asset.RawIndex, out var cached)) return cached; + try { return _images[asset.RawIndex] = _res.DecodeTexture(asset); } + catch (System.Exception e) + { + Godot.GD.Print($"AGF decode failed {asset.Name}: {e.Message}"); + _images[asset.RawIndex] = null; + return null; + } + } } // ---- audio ops (OGG plays natively in Godot) ---- diff --git a/godot/Main.cs b/godot/Main.cs index 2c4b7a3..922d6d3 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -154,7 +154,8 @@ public partial class Main : Godot.Control if (_selftest) (script, provider) = BuildSelfTestScene(table); else { scripts = Sys4ScriptProvider.Load(table); script = scripts.RequireByName(scene + ".BIN"); provider = scripts; } if (_timelineLogPath != null) _timeline = new GodotTimelineLog(_timelineLogPath); - _host = new GodotAdvHost(this, scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load(), scene, _clock, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null }; + var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load(); + _host = new GodotAdvHost(this, resources, scene, _clock, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null }; _trace = new GodotTraceSink(_timeline); // --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow // diverges — wait-for-input is a no-op there — so this is the only way to profile the live path). @@ -164,6 +165,13 @@ public partial class Main : Godot.Control if (histFile != null) { _hist = new Age.Engine.Diagnostics.HistogramTraceSink(); sink = new Age.Engine.Diagnostics.CompositeTraceSink(_trace, _hist); } _vm = new VirtualMachine(script, table, _host, new VmOptions(MaxSteps: 20_000_000), provider, sink); + // SYSTEM4 loads the shared SO001 chrome sheet into surface slot 17 before any scene runs. + // Seed that inherited retained-surface state without replaying the entrypoint's unrelated UI flow. + if (!_selftest && resources.ResolveName("SO001.AGF") is { } systemChrome) + { + _host.SetTexture(systemChrome.RawIndex, 0x11); + _vm.Gfx.SetSurface(0x11, systemChrome.RawIndex, 0); + } // --boot: run SYSTEM4's state prefix (INITCONFIG/INIT2/INIT) so the scene sees boot state — chiefly // INIT2's gfx handle array 0x62455.. (skips the UI scripts LOGO/OP/TITLE). State carries via globals. if (boot && !_selftest) @@ -279,10 +287,10 @@ public partial class Main : Godot.Control // ---- retained per-frame compositor (main thread, from _Process) ---- // Clear the screen and composite the VM's current VISIBLE gfx objects in ascending-handle order (= the - // engine's z-order), each blitting its live surface's rect at its position. Surfaces are cached by BMP - // path (this runs every frame). Native scale/translation matrix channels are sampled independently by + // engine's z-order), each blitting its live surface's rect at its position. Decoded AGF surfaces are cached + // by catalog identity (this runs every frame). Native scale/translation matrix channels are sampled independently by // GfxState and applied here; object opacity comes only from the actual blend/color path. - private readonly System.Collections.Generic.Dictionary<(string Path, long Key), Image?> _imgCache = new(); + private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), Image?> _imgCache = new(); private void Recomposite() { @@ -328,14 +336,14 @@ public partial class Main : Godot.Control } else { - var bmp = _host.ResolveResIdTexture(v.SurfaceResId); - if (bmp == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)"; + var texture = _host.ResolveResIdTexture(v.SurfaceResId); + if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)"; else { - BlitLayer(bmp, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H, + BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H, localToDest, opacity, v.MultiplyTint); var raw = _vm.Gfx.TryGet(v.Handle); - outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {System.IO.Path.GetFileName(bmp)} " + + outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " + $"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " + $"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " + @@ -395,9 +403,9 @@ public partial class Main : Godot.Control } else { - var bmp = _host.ResolveResIdTexture(source.SurfaceResId); - if (bmp == null) continue; - BlitLayer(bmp, source.ColorKey, source.Tint, source.TintStrength / 255f, + var texture = _host.ResolveResIdTexture(source.SurfaceResId); + if (texture == null) continue; + BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f, source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint); } drawn++; @@ -439,20 +447,14 @@ public partial class Main : Godot.Control // matching the surface colorkey are made transparent (native bakes the key at load — engine-re.md §Blend). // Mode 0 uses tintStrength to LERP texel RGB toward tint. Mode 1 sets multiplyTint and uses packed RGB as // multiplicative modulation while alpha is object opacity. - private void BlitLayer(string bmpPath, long colorKey, long tint, float tintStrength, int srcX, int srcY, int w, int h, + private void BlitLayer(RgbaImage decoded, int assetId, long colorKey, long tint, float tintStrength, int srcX, int srcY, int w, int h, Age.Engine.Model.Affine2D localToDest, float alpha = 1f, bool multiplyTint = false) { - var cacheKey = (bmpPath, colorKey); + var cacheKey = (assetId, colorKey); if (!_imgCache.TryGetValue(cacheKey, out var src)) { - src = new Image(); - if (src.LoadBmpFromBuffer(System.IO.File.ReadAllBytes(bmpPath)) != Error.Ok) - { GD.Print($"BMP load failed {bmpPath}"); src = null; } - else - { - if (src.GetFormat() != Image.Format.Rgba8) src.Convert(Image.Format.Rgba8); - if (Age.Engine.Model.BlendMath.HasColorKey(colorKey)) BakeColorKey(src, colorKey); - } + src = Image.CreateFromData(decoded.Width, decoded.Height, false, Image.Format.Rgba8, decoded.Pixels); + if (Age.Engine.Model.BlendMath.HasColorKey(colorKey)) BakeColorKey(src, colorKey); _imgCache[cacheKey] = src; } if (src == null) return;