namespace Age.Engine.Sys4;
/// Read-only byte seam after catalog resolution.
public interface IAssetStore
{
Stream Open(AssetEntry entry);
byte[] ReadAll(AssetEntry entry);
}
/// Reports only successful catalog opens, matching AGE's profile unlock marker timing.
public sealed class CatalogTrackingAssetStore : IAssetStore
{
private readonly IAssetStore _inner;
private readonly Action _opened;
public CatalogTrackingAssetStore(IAssetStore inner, Action opened)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_opened = opened ?? throw new ArgumentNullException(nameof(opened));
}
public Stream Open(AssetEntry entry)
{
Stream stream = _inner.Open(entry);
_opened(entry);
return stream;
}
public byte[] ReadAll(AssetEntry entry)
{
using Stream stream = Open(entry);
if (stream.Length > int.MaxValue)
throw new InvalidDataException($"{entry.Name}: payload is too large");
var bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);
return bytes;
}
}
/// Native base-game precedence: exact-basename loose roots first, indexed ALF range second.
public sealed class Sys4AssetStore : IAssetStore
{
private readonly string _archiveRoot;
private readonly string[] _looseRoots;
public Sys4AssetCatalog Catalog { get; }
public Sys4AssetStore(Sys4AssetCatalog catalog, string archiveRoot, params string[] looseRoots)
{
Catalog = catalog;
_archiveRoot = Path.GetFullPath(archiveRoot);
_looseRoots = looseRoots.Select(Path.GetFullPath).ToArray();
}
public Stream Open(AssetEntry entry)
{
ValidateBasename(entry.Name, "asset");
if (entry.IsPlaceholder) throw new FileNotFoundException("SYS4INI placeholder has no payload", entry.Name);
foreach (string root in _looseRoots)
{
string candidate = Path.GetFullPath(Path.Combine(root, entry.Name));
if (!IsDirectChild(root, candidate)) throw new InvalidDataException($"unsafe asset name: {entry.Name}");
try
{
return new FileStream(candidate, FileMode.Open, FileAccess.Read, FileShare.Read,
64 * 1024, FileOptions.RandomAccess);
}
catch (FileNotFoundException) { }
catch (DirectoryNotFoundException) { }
}
ValidateBasename(entry.Archive, "archive");
string archivePath = Path.GetFullPath(Path.Combine(_archiveRoot, entry.Archive));
if (!IsDirectChild(_archiveRoot, archivePath))
throw new InvalidDataException($"unsafe archive name: {entry.Archive}");
var file = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read,
64 * 1024, FileOptions.RandomAccess);
try
{
if (entry.Offset < 0 || entry.Size < 0 || entry.Offset > file.Length
|| entry.Size > file.Length - entry.Offset)
throw new InvalidDataException($"{entry.Name}: ALF range {entry.Offset}+{entry.Size} exceeds {entry.Archive} ({file.Length})");
return new BoundedReadStream(file, entry.Offset, entry.Size);
}
catch
{
file.Dispose();
throw;
}
}
public byte[] ReadAll(AssetEntry entry)
{
using Stream stream = Open(entry);
if (stream.Length > int.MaxValue) throw new InvalidDataException($"{entry.Name}: payload is too large");
var bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);
return bytes;
}
private static void ValidateBasename(string value, string kind)
{
if (string.IsNullOrWhiteSpace(value) || Path.IsPathRooted(value)
|| value.Contains('/') || value.Contains('\\') || value is "." or "..")
throw new InvalidDataException($"unsafe {kind} name: {value}");
}
private static bool IsDirectChild(string root, string child)
=> string.Equals(Path.GetDirectoryName(child)?.TrimEnd(Path.DirectorySeparatorChar),
root.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase);
private sealed class BoundedReadStream : Stream
{
private readonly FileStream _file;
private readonly long _start;
private readonly long _length;
private long _position;
public BoundedReadStream(FileStream file, long start, long length)
{
_file = file; _start = start; _length = length;
_file.Position = start;
}
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length => _length;
public override long Position { get => _position; set => Seek(value, SeekOrigin.Begin); }
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count)
=> Read(buffer.AsSpan(offset, count));
public override int Read(Span buffer)
{
int wanted = (int)Math.Min(buffer.Length, _length - _position);
if (wanted <= 0) return 0;
int read = _file.Read(buffer[..wanted]);
_position += read;
return read;
}
public override long Seek(long offset, SeekOrigin origin)
{
long target = origin switch
{
SeekOrigin.Begin => offset,
SeekOrigin.Current => checked(_position + offset),
SeekOrigin.End => checked(_length + offset),
_ => throw new ArgumentOutOfRangeException(nameof(origin)),
};
if (target < 0 || target > _length) throw new IOException("seek outside asset range");
_file.Position = _start + target;
return _position = target;
}
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing) { if (disposing) _file.Dispose(); base.Dispose(disposing); }
}
}