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

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