Decode AGF textures through the asset store
This commit is contained in:
132
engine/Age.Engine/Sys4/AgfDecoder.cs
Normal file
132
engine/Age.Engine/Sys4/AgfDecoder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
44
engine/Age.Engine/Sys4/LzssDecoder.cs
Normal file
44
engine/Age.Engine/Sys4/LzssDecoder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user