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