Decode AGF textures through the asset store

This commit is contained in:
gamer147
2026-07-11 09:55:58 -04:00
parent 8e0f769a6e
commit ec07c76721
15 changed files with 468 additions and 158 deletions

View File

@@ -373,7 +373,7 @@ sealed class GfxTraceHost : IHost
{
private readonly ResourceMap _res;
private readonly string _scene;
private readonly Dictionary<int, string?> _slotBmp = new(); // slot -> resolved BMP path (or null)
private readonly Dictionary<int, string?> _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<int, (int W, int H)> _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 ?? "<unresolved>")}"
+ (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) : "<none>")}");
+ $"file={(asset ?? "<none>")}");
}
public void CreateTexture(int slot, int width, int height)

View File

@@ -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<byte>();
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);
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,132 @@
using System.Buffers.Binary;
namespace Age.Engine.Sys4;
/// <summary>A decoded, tightly packed, top-down RGBA8 image.</summary>
public sealed record RgbaImage(int Width, int Height, byte[] Pixels);
/// <summary>
/// Platform-neutral Eushully AGF decoder. Format algorithm ported from GARbro's MIT-licensed
/// ArcFormats/Eushully/ImageAGF.cs (Copyright (C) 2015 morkt).
/// </summary>
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<byte> 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<byte> palette = default;
if (sourceBpp <= 8)
palette = Slice(info, 0x38, checked((1 << sourceBpp) * 4), name, "palette");
int dataOffset = checked(OuterHeaderSize + infoPacked);
ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> source, int expanded, int packed,
string name, string section)
=> expanded == packed ? source.ToArray() : LzssDecoder.Decode(source, expanded, $"{name}: {section}");
private static int I32(ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> Slice(ReadOnlySpan<byte> 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);
}
}

View File

@@ -1,25 +0,0 @@
namespace Age.Engine.Sys4;
/// <summary>
/// Reads pixel dimensions from a BMP file header (BITMAPINFOHEADER: width at byte 18, height at byte 22,
/// both little-endian int32; height may be negative for top-down bitmaps). Used to give the VM the
/// texture size that opcode 0x208 (get-texture-size) needs, without decoding pixels. Our textures are
/// pre-converted BMPs (tools/convert_agf.py).
/// </summary>
public static class BmpHeader
{
public static (int Width, int Height) ReadDims(string? path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return (0, 0);
try
{
var b = new byte[26];
using var fs = File.OpenRead(path);
if (fs.Read(b, 0, 26) < 26 || b[0] != (byte)'B' || b[1] != (byte)'M') return (0, 0);
int w = System.BitConverter.ToInt32(b, 18);
int h = System.BitConverter.ToInt32(b, 22);
return (System.Math.Abs(w), System.Math.Abs(h));
}
catch { return (0, 0); }
}
}

View File

@@ -0,0 +1,44 @@
namespace Age.Engine.Sys4;
/// <summary>Eushully's 4 KiB-ring LZSS stream used by SYS4 catalogs and AGF sections.</summary>
public static class LzssDecoder
{
public static byte[] Decode(ReadOnlySpan<byte> 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;
}
}

View File

@@ -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);
}
/// <summary>Pre-converted BMP path for an AGF asset (see tools/convert_agf.py).</summary>
public static string? TexturePath(AssetEntry a)
/// <summary>Resolve graphics normally through the scene manifest, with the universal raw-id
/// fallback used by SYSTEM4-owned assets such as SO001.</summary>
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;
}
/// <summary>Decode an AGF directly from loose-first VFS bytes.</summary>
public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry);
public AssetEntry? ResolveName(string name) => _catalog.ResolveName(name);
/// <summary>
/// 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)

View File

@@ -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<byte> 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;
}
}