Implement base SYS4 asset store
This commit is contained in:
129
engine/Age.Engine/Sys4/Sys4AssetStore.cs
Normal file
129
engine/Age.Engine/Sys4/Sys4AssetStore.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
namespace Age.Engine.Sys4;
|
||||
|
||||
/// <summary>Read-only byte seam after catalog resolution.</summary>
|
||||
public interface IAssetStore
|
||||
{
|
||||
Stream Open(AssetEntry entry);
|
||||
byte[] ReadAll(AssetEntry entry);
|
||||
}
|
||||
|
||||
/// <summary>Native base-game precedence: exact-basename loose roots first, indexed ALF range second.</summary>
|
||||
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<byte> 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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user