Implement base SYS4 asset store
This commit is contained in:
@@ -10,6 +10,7 @@ var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
// call-script execution: resolves ids -> scripts. Product paths pass this so subroutines run;
|
||||
// `trace` stays provider-less on purpose (the base-ISA offset oracle).
|
||||
var provider = Sys4ScriptProvider.Load(table);
|
||||
Script ScriptByName(string name) => provider.RequireByName(name);
|
||||
|
||||
// Diagnostics flags (see the TraceSetup class below): --trace (text flow), --trace-steps (every op),
|
||||
// --trace-ops <csv> (only these mnemonics/hex, tagged with their script), --trace-histogram (op +
|
||||
@@ -43,7 +44,7 @@ if (args[0] == "audio")
|
||||
var sceneKey = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant();
|
||||
var res = ResourceMap.Load();
|
||||
var host = new AudioTraceHost(res, sceneKey);
|
||||
var vm = new VirtualMachine(Sys4Loader.Load(Paths.Scripts()[sceneName.ToUpperInvariant()], table), table, host);
|
||||
var vm = new VirtualMachine(ScriptByName(sceneName), table, host);
|
||||
// optional: seed globals, e.g. `audio SC0000.BIN 0xa57=1` to set Lily's form-A flag
|
||||
foreach (var s in args.Skip(2))
|
||||
{
|
||||
@@ -81,10 +82,10 @@ if (args[0] == "gfx")
|
||||
if (boot)
|
||||
foreach (var b in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" })
|
||||
{
|
||||
var bs = session.RunScene(Sys4Loader.Load(Paths.Scripts()[b], table), table, new CaptureHost(), null, provider);
|
||||
var bs = session.RunScene(ScriptByName(b), table, new CaptureHost(), null, provider);
|
||||
Console.WriteLine($"[boot] {b}: {bs.Steps} steps (halt: {bs.Halt})");
|
||||
}
|
||||
var target = Sys4Loader.Load(Paths.Scripts()[sceneName.ToUpperInvariant()], table);
|
||||
var target = ScriptByName(sceneName);
|
||||
// With --boot, run the target like the real engine (call-scripts on) so subroutine-driven setup runs.
|
||||
var vm = boot ? new VirtualMachine(target, table, host, new VmOptions(MaxSteps: 20_000_000), provider)
|
||||
: new VirtualMachine(target, table, host);
|
||||
@@ -111,7 +112,6 @@ if (args[0] == "play")
|
||||
// The *INIT boot set — all run clean (halt: exit) and populate the game's data tables into globals.
|
||||
string[] bootScripts = { "SKINIT.BIN", "ITINIT.BIN", "EBINIT.BIN", "CGINIT.BIN", "MPINIT.BIN",
|
||||
"AFINIT.BIN", "CCINIT.BIN", "STINIT.BIN", "STINIT2.BIN" };
|
||||
var scripts = Paths.Scripts();
|
||||
bool boot = args.Contains("--boot");
|
||||
var userScenes = args.Skip(1).Where(a => a.ToUpperInvariant().EndsWith(".BIN")).ToList();
|
||||
if (userScenes.Count == 0) { Console.WriteLine("usage: play [--boot] <SCENE.BIN...> [0xADDR=VAL ...]"); return 1; }
|
||||
@@ -134,7 +134,7 @@ if (args[0] == "play")
|
||||
var playOpts = new VmOptions(HaltAtWaitForInput: !args.Contains("--plow")); // faithful by default
|
||||
foreach (var name in scenes)
|
||||
{
|
||||
var script = Sys4Loader.Load(scripts[name.ToUpperInvariant()], table);
|
||||
var script = ScriptByName(name);
|
||||
var r = session.RunScene(script, table, new CaptureHost(), playOpts, provider, trace.Sink);
|
||||
totalLines += r.Emitted.Count;
|
||||
Console.WriteLine($" {name,-14} {r.Emitted.Count,4} lines, {r.Steps,7} steps (halt: {r.Halt})");
|
||||
@@ -151,8 +151,7 @@ if (args[0] == "sweep")
|
||||
// baseline) and report halt distribution + line counts. Validates the VM + state substrate at scale and
|
||||
// surfaces how booted real data affects the corpus. Headless.
|
||||
var sceneRe = new Regex(@"^S[CP]\d{4}\.BIN$");
|
||||
var scripts = Paths.Scripts();
|
||||
var names = scripts.Keys.Where(n => sceneRe.IsMatch(n)).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
var names = provider.ScriptNames.Where(n => sceneRe.IsMatch(n)).OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
bool boot = args.Contains("--boot");
|
||||
// Sweep DEFAULTS to plow (walk every page) — it's the dialogue-coverage oracle. --halt-at-wait opts into
|
||||
// the faithful "stop at the first prompt" semantics (VmOptions.HaltAtWaitForInput).
|
||||
@@ -163,7 +162,7 @@ if (args[0] == "sweep")
|
||||
var bootSession = new GameSession();
|
||||
foreach (var s in new[] { "SKINIT.BIN", "ITINIT.BIN", "EBINIT.BIN", "CGINIT.BIN", "MPINIT.BIN",
|
||||
"AFINIT.BIN", "CCINIT.BIN", "STINIT.BIN", "STINIT2.BIN" })
|
||||
bootSession.RunScene(Sys4Loader.Load(scripts[s], table), table, new CaptureHost(), null, provider);
|
||||
bootSession.RunScene(ScriptByName(s), table, new CaptureHost(), null, provider);
|
||||
baseline = bootSession.ToJson();
|
||||
Console.WriteLine($"[boot] baseline = {bootSession.Globals.Count} globals; running {names.Count} scenes from it.");
|
||||
}
|
||||
@@ -182,7 +181,7 @@ if (args[0] == "sweep")
|
||||
{
|
||||
var session = Fresh();
|
||||
if (seeded) foreach (var (k, v) in seeds) session.Seed(k, v);
|
||||
return session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost(), sweepOpts, provider).Emitted.Count;
|
||||
return session.RunScene(ScriptByName(name), table, new CaptureHost(), sweepOpts, provider).Emitted.Count;
|
||||
}
|
||||
|
||||
if (seeds.Count > 0)
|
||||
@@ -205,7 +204,7 @@ if (args[0] == "sweep")
|
||||
foreach (var name in names)
|
||||
{
|
||||
var session = Fresh();
|
||||
var r = session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost(), sweepOpts, provider, trace.Sink);
|
||||
var r = session.RunScene(ScriptByName(name), table, new CaptureHost(), sweepOpts, provider, trace.Sink);
|
||||
var halt = r.Halt ?? "null";
|
||||
haltDist[halt] = haltDist.GetValueOrDefault(halt) + 1;
|
||||
totalLines += r.Emitted.Count;
|
||||
@@ -233,8 +232,7 @@ if (args[0] == "trace")
|
||||
var outPath = args[tji + 1];
|
||||
var sceneName = args.First(a => a.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase));
|
||||
bool boot = args.Contains("--boot");
|
||||
var jscripts = Paths.Scripts();
|
||||
var target = Sys4Loader.Load(jscripts[sceneName.ToUpperInvariant()], table);
|
||||
var target = ScriptByName(sceneName);
|
||||
// --state <file>: start from a captured scene-entry snapshot (Frida global-write log →
|
||||
// capture_global_writes.py) — the real engine's full pre-scene state, superseding the partial
|
||||
// --boot. Otherwise fresh + optional --boot.
|
||||
@@ -252,7 +250,7 @@ if (args[0] == "trace")
|
||||
}
|
||||
if (boot && si < 0) // --state already carries boot state; don't re-run the *INIT prefix
|
||||
foreach (var b in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" })
|
||||
session.RunScene(Sys4Loader.Load(jscripts[b], table), table, new CaptureHost(), null, provider);
|
||||
session.RunScene(ScriptByName(b), table, new CaptureHost(), null, provider);
|
||||
var sink = new JsonOffsetTraceSink(target.Name);
|
||||
var vm = new VirtualMachine(target, table, new CaptureHost(),
|
||||
new VmOptions(HaltAtWaitForInput: true, MaxSteps: 20_000_000), provider, sink);
|
||||
@@ -269,11 +267,10 @@ if (args[0] == "trace")
|
||||
}
|
||||
|
||||
var scene = new Regex(@"^S[CP]\d{4}\.BIN$");
|
||||
var scripts = Paths.Scripts();
|
||||
var trace = new SortedDictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var name in scripts.Keys.Where(n => scene.IsMatch(n)).OrderBy(n => n, StringComparer.Ordinal))
|
||||
foreach (var name in provider.ScriptNames.Where(n => scene.IsMatch(n)).OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
var vm = new VirtualMachine(Sys4Loader.Load(scripts[name], table), table, new CaptureHost());
|
||||
var vm = new VirtualMachine(ScriptByName(name), table, new CaptureHost());
|
||||
vm.Run();
|
||||
trace[name] = new { offsets = vm.Emitted.Select(e => e.Offset).ToArray(), halt = vm.HaltReason, steps = vm.Steps };
|
||||
}
|
||||
|
||||
156
engine/Age.Engine.Tests/Sys4AssetStoreTests.cs
Normal file
156
engine/Age.Engine.Tests/Sys4AssetStoreTests.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using Age.Engine.Sys4;
|
||||
using Xunit;
|
||||
|
||||
public class Sys4AssetStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void RuntimeCatalogMatchesDiagnosticCatalogAndSceneViews()
|
||||
{
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
using var index = JsonDocument.Parse(File.ReadAllText(Paths.AssetIndexJson));
|
||||
var expected = index.RootElement;
|
||||
|
||||
Assert.Equal(expected.GetProperty("magic").GetString(), catalog.Magic);
|
||||
Assert.Equal(expected.GetProperty("file_count").GetInt32(), catalog.RawSlots.Count);
|
||||
Assert.Equal(expected.GetProperty("entry_count").GetInt32(), catalog.Files.Count);
|
||||
Assert.Equal(13208, catalog.RawSlots.Count);
|
||||
Assert.Equal(13206, catalog.Files.Count);
|
||||
Assert.Equal(2, catalog.RawSlots.Count(r => r.IsPlaceholder));
|
||||
Assert.Equal(expected.GetProperty("archives").EnumerateArray().Select(a => a.GetString()), catalog.Archives);
|
||||
|
||||
var jsonFiles = expected.GetProperty("files").EnumerateArray().ToArray();
|
||||
Assert.Equal(jsonFiles.Length, catalog.Files.Count);
|
||||
for (int i = 0; i < jsonFiles.Length; i++)
|
||||
{
|
||||
var j = jsonFiles[i];
|
||||
var actual = catalog.Files[i];
|
||||
Assert.Equal(j.GetProperty("raw_index").GetInt32(), actual.RawIndex);
|
||||
Assert.Equal(j.GetProperty("name").GetString(), actual.Name);
|
||||
Assert.Equal(j.GetProperty("archive").GetString(), actual.Archive);
|
||||
Assert.Equal(j.GetProperty("arc_id").GetInt32(), actual.ArchiveId);
|
||||
Assert.Equal(j.GetProperty("file_number").GetInt32(), actual.FileNumber);
|
||||
Assert.Equal(j.GetProperty("offset").GetInt64(), actual.Offset);
|
||||
Assert.Equal(j.GetProperty("size").GetInt64(), actual.Size);
|
||||
}
|
||||
|
||||
Assert.Equal("SO001.AGF", catalog.ResolveRaw(0x337e)?.Name);
|
||||
Assert.Equal("BGM005.OGG", catalog.ResolveName("bgm005.ogg")?.Name);
|
||||
Assert.Null(catalog.ResolveRaw(-1));
|
||||
Assert.Null(catalog.ResolveRaw(catalog.RawSlots.Count));
|
||||
|
||||
using var sections = JsonDocument.Parse(File.ReadAllText(Paths.AssetSectionsJson));
|
||||
foreach (var scene in sections.RootElement.GetProperty("scene_base").EnumerateObject())
|
||||
{
|
||||
int start = scene.Value.GetInt32();
|
||||
int end = start;
|
||||
while (end + 1 < catalog.Files.Count
|
||||
&& catalog.Files[end + 1].FileNumber > catalog.Files[end].FileNumber) end++;
|
||||
for (int i = start; i <= end; i++)
|
||||
Assert.Same(catalog.Files[i], catalog.ResolveScene(scene.Name, i - start));
|
||||
Assert.Null(catalog.ResolveScene(scene.Name, -1));
|
||||
Assert.Null(catalog.ResolveScene(scene.Name, end - start + 1));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryCatalogRangeFitsAndRepresentativePayloadsMatchExtractedData()
|
||||
{
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
foreach (var entry in catalog.Files)
|
||||
{
|
||||
long archiveLength = new FileInfo(Path.Combine(Paths.GameDir, entry.Archive)).Length;
|
||||
Assert.InRange(entry.Offset, 0, archiveLength);
|
||||
Assert.InRange(entry.Size, 0, archiveLength - entry.Offset);
|
||||
}
|
||||
|
||||
var store = new Sys4AssetStore(catalog, Paths.GameDir);
|
||||
var samples = catalog.Archives.SelectMany(archive =>
|
||||
{
|
||||
var entries = catalog.Files.Where(e => e.Archive.Equals(archive, StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
return new[] { entries[0], entries[entries.Length / 2], entries[^1] };
|
||||
}).Concat(new[]
|
||||
{
|
||||
catalog.ResolveName("MENU.BIN")!,
|
||||
catalog.ResolveName("SO001.AGF")!,
|
||||
catalog.ResolveName("BGM005.OGG")!,
|
||||
}).DistinctBy(e => e.RawIndex);
|
||||
|
||||
foreach (var entry in samples)
|
||||
{
|
||||
string folder = Path.GetFileNameWithoutExtension(entry.Archive);
|
||||
string extracted = Path.Combine(Paths.Extracted, folder, entry.Name);
|
||||
Assert.True(File.Exists(extracted), $"missing extracted oracle: {folder}/{entry.Name}");
|
||||
Assert.Equal(File.ReadAllBytes(extracted), store.ReadAll(entry));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllInstalledLooseScriptOverridesShadowArchiveCopies()
|
||||
{
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var archiveOnly = new Sys4AssetStore(catalog, Paths.GameDir);
|
||||
var looseFirst = new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir);
|
||||
var rootBins = Directory.EnumerateFiles(Paths.GameDir, "*.BIN")
|
||||
.Where(path => catalog.ResolveName(Path.GetFileName(path)) is { } entry
|
||||
&& entry.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
// This installed v1.03 tree currently has 49 archive-backed overrides plus the two root-only
|
||||
// engine catalogs SYS4AB.BIN/SYS4INI.BIN. Exercise every archive-backed override, not a sample.
|
||||
Assert.Equal(49, rootBins.Length);
|
||||
foreach (string path in rootBins)
|
||||
{
|
||||
var entry = catalog.ResolveName(Path.GetFileName(path))!;
|
||||
byte[] loose = File.ReadAllBytes(path);
|
||||
Assert.Equal(loose, looseFirst.ReadAll(entry));
|
||||
Assert.NotEqual(loose, archiveOnly.ReadAll(entry));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyntheticStoreIsBoundedLooseFirstThreadSafeAndRejectsTraversal()
|
||||
{
|
||||
string temp = Path.Combine(Path.GetTempPath(), "age-vfs-" + Guid.NewGuid().ToString("N"));
|
||||
string archives = Path.Combine(temp, "archives"), loose = Path.Combine(temp, "loose");
|
||||
Directory.CreateDirectory(archives);
|
||||
Directory.CreateDirectory(loose);
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(Path.Combine(archives, "DATA1.ALF"), new byte[] { 9, 8, 1, 2, 3, 7 });
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var entry = new AssetEntry("TEST.BIN", "DATA1.ALF", 2, 3);
|
||||
var store = new Sys4AssetStore(catalog, archives, loose);
|
||||
|
||||
using (var stream = store.Open(entry))
|
||||
{
|
||||
Assert.Equal(3, stream.Length);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, ReadToEnd(stream));
|
||||
Assert.Equal(-1, stream.ReadByte());
|
||||
Assert.Throws<IOException>(() => stream.Seek(1, SeekOrigin.End));
|
||||
}
|
||||
|
||||
File.WriteAllBytes(Path.Combine(loose, "TEST.BIN"), new byte[] { 4, 5 });
|
||||
Assert.Equal(new byte[] { 4, 5 }, store.ReadAll(entry));
|
||||
File.Delete(Path.Combine(loose, "TEST.BIN"));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, store.ReadAll(entry));
|
||||
|
||||
var reads = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => store.ReadAll(entry))));
|
||||
Assert.All(reads, bytes => Assert.Equal(new byte[] { 1, 2, 3 }, bytes));
|
||||
Assert.Throws<InvalidDataException>(() => store.Open(entry with { Name = "../TEST.BIN" }));
|
||||
Assert.Throws<InvalidDataException>(() => store.Open(entry with { Archive = "../DATA1.ALF" }));
|
||||
Assert.Throws<InvalidDataException>(() => store.Open(entry with { Offset = 5, Size = 2 }));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(temp, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ReadToEnd(Stream stream)
|
||||
{
|
||||
using var copy = new MemoryStream();
|
||||
stream.CopyTo(copy);
|
||||
return copy.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,18 @@ public class Sys4ScriptProviderTests
|
||||
Assert.Same(additem, provider.GetById(0x1ab)); // cached: same instance
|
||||
Assert.Null(provider.GetById(long.MaxValue)); // unknown id
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RootScriptLoadingUsesTheSameAssetStoreAndLoosePrecedence()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var provider = Sys4ScriptProvider.Load(table);
|
||||
var patched = provider.RequireByName("FIELD.BIN");
|
||||
var directLoose = Sys4Loader.Load(Path.Combine(Paths.GameDir, "FIELD.BIN"), table);
|
||||
|
||||
Assert.Equal(directLoose.Instructions.Count, patched.Instructions.Count);
|
||||
Assert.Same(patched, provider.RequireByName("field.bin"));
|
||||
Assert.Null(provider.GetByName("../FIELD.BIN"));
|
||||
Assert.Equal(481, provider.ScriptNames.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public static class Paths
|
||||
public static string AssetIndexJson => Path.Combine(Build, "asset-index.json");
|
||||
public static string CallscriptNamesJson => Path.Combine(Build, "callscript-names.json");
|
||||
public static string Textures => Path.Combine(Build, "textures");
|
||||
public static string Sys4Ini => Path.Combine(GameDir, "SYS4INI.BIN");
|
||||
|
||||
private static string FindRepo()
|
||||
{
|
||||
|
||||
@@ -1,56 +1,23 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Age.Engine.Sys4;
|
||||
|
||||
/// <summary>One SYS4INI asset entry.</summary>
|
||||
public sealed record AssetEntry(string Name, string Archive, long Offset, long Size);
|
||||
|
||||
/// <summary>
|
||||
/// Static asset resolver. SYS4INI's file list is sectioned (one per scene: SCxxxx.BIN + its
|
||||
/// Compatibility facade over the runtime SYS4 catalog. SYS4INI's file list is sectioned (one per scene: SCxxxx.BIN + its
|
||||
/// cross-archive asset manifest); file_number is the index within a section. So a bytecode
|
||||
/// resId resolves as files[section_base(scene) + resId] -- unified for graphics and audio.
|
||||
/// See docs/asset-resolution-re.md. Built from build/asset-index.json + build/asset-sections.json.
|
||||
/// See docs/asset-resolution-re.md. Extracted paths remain temporary graphics/audio backends only.
|
||||
/// </summary>
|
||||
public sealed class ResourceMap
|
||||
{
|
||||
private readonly IReadOnlyList<AssetEntry> _files;
|
||||
private readonly IReadOnlyDictionary<string, int> _sceneBase; // "SC0000" -> section base index
|
||||
private readonly Sys4AssetCatalog _catalog;
|
||||
|
||||
public ResourceMap(IReadOnlyList<AssetEntry> files, IReadOnlyDictionary<string, int> sceneBase)
|
||||
{
|
||||
_files = files;
|
||||
_sceneBase = sceneBase;
|
||||
}
|
||||
public ResourceMap(Sys4AssetCatalog catalog) => _catalog = catalog;
|
||||
|
||||
public static ResourceMap Load(string indexPath, string sectionsPath)
|
||||
{
|
||||
var files = new List<AssetEntry>();
|
||||
using (var idx = JsonDocument.Parse(File.ReadAllText(indexPath)))
|
||||
foreach (var f in idx.RootElement.GetProperty("files").EnumerateArray())
|
||||
files.Add(new AssetEntry(
|
||||
f.GetProperty("name").GetString()!,
|
||||
f.GetProperty("archive").GetString()!,
|
||||
f.GetProperty("offset").GetInt64(),
|
||||
f.GetProperty("size").GetInt64()));
|
||||
|
||||
var sceneBase = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var sec = JsonDocument.Parse(File.ReadAllText(sectionsPath)))
|
||||
foreach (var p in sec.RootElement.GetProperty("scene_base").EnumerateObject())
|
||||
sceneBase[p.Name] = p.Value.GetInt32();
|
||||
|
||||
return new ResourceMap(files, sceneBase);
|
||||
}
|
||||
|
||||
public static ResourceMap Load() => Load(Paths.AssetIndexJson, Paths.AssetSectionsJson);
|
||||
public static ResourceMap Load() => new(Sys4AssetCatalog.Load(Paths.Sys4Ini));
|
||||
|
||||
/// <summary>Resolve a scene-local resId to its asset, or null if out of range / unknown scene.</summary>
|
||||
public AssetEntry? Resolve(string scene, long resId)
|
||||
{
|
||||
var key = scene.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)
|
||||
? scene[..^4] : scene;
|
||||
if (!_sceneBase.TryGetValue(key, out var b)) return null;
|
||||
long p = b + resId;
|
||||
return p >= 0 && p < _files.Count ? _files[(int)p] : null;
|
||||
return _catalog.ResolveScene(scene, resId);
|
||||
}
|
||||
|
||||
/// <summary>Pre-converted BMP path for an AGF asset (see tools/convert_agf.py).</summary>
|
||||
@@ -70,10 +37,8 @@ public sealed class ResourceMap
|
||||
public string? BgmPathById(long id)
|
||||
{
|
||||
var name = $"BGM{id:D3}.OGG";
|
||||
foreach (var f in _files)
|
||||
if (f.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
return AudioPath(f);
|
||||
return null;
|
||||
var f = _catalog.ResolveName(name);
|
||||
return f == null ? null : AudioPath(f);
|
||||
}
|
||||
|
||||
/// <summary>Loose extracted OGG/WAV path for an audio asset (extracted/DATA{n}/{name}), or null.
|
||||
|
||||
185
engine/Age.Engine/Sys4/Sys4AssetCatalog.cs
Normal file
185
engine/Age.Engine/Sys4/Sys4AssetCatalog.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace Age.Engine.Sys4;
|
||||
|
||||
/// <summary>One raw SYS4INI file record. Placeholder records remain addressable by
|
||||
/// <see cref="RawIndex"/> but are excluded from scene and name views.</summary>
|
||||
public sealed record AssetEntry(
|
||||
string Name,
|
||||
string Archive,
|
||||
long Offset,
|
||||
long Size,
|
||||
int RawIndex = -1,
|
||||
int ArchiveId = -1,
|
||||
int FileNumber = -1,
|
||||
bool IsPlaceholder = false);
|
||||
|
||||
/// <summary>Runtime parser and lookup views for a base S4IC SYS4INI catalog.</summary>
|
||||
public sealed class Sys4AssetCatalog
|
||||
{
|
||||
private const int PackedSizeOffset = 0x134;
|
||||
private const int ExpandedSizeOffset = 0x12c;
|
||||
private const int ArchiveNameSize = 256;
|
||||
private const int RecordSize = 80;
|
||||
|
||||
private readonly Dictionary<string, AssetEntry> _byName;
|
||||
private readonly Dictionary<string, (int Start, int End)> _sceneRanges;
|
||||
|
||||
public string Magic { get; }
|
||||
public IReadOnlyList<string> Archives { get; }
|
||||
public IReadOnlyList<AssetEntry> RawSlots { get; }
|
||||
public IReadOnlyList<AssetEntry> Files { get; }
|
||||
|
||||
private Sys4AssetCatalog(string magic, List<string> archives, List<AssetEntry> rawSlots)
|
||||
{
|
||||
Magic = magic;
|
||||
Archives = archives;
|
||||
RawSlots = rawSlots;
|
||||
Files = rawSlots.Where(r => !r.IsPlaceholder).ToArray();
|
||||
_byName = Files.ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
|
||||
_sceneRanges = BuildSceneRanges(Files);
|
||||
}
|
||||
|
||||
public static Sys4AssetCatalog Load(string path) => Parse(File.ReadAllBytes(path), Path.GetFileName(path));
|
||||
|
||||
public static Sys4AssetCatalog Parse(byte[] data, string name = "SYS4INI.BIN")
|
||||
{
|
||||
if (data.Length < PackedSizeOffset + 4 || !data.AsSpan(0, 4).SequenceEqual("S4IC"u8))
|
||||
throw new InvalidDataException($"{name}: expected an S4IC catalog");
|
||||
|
||||
uint expandedSize = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(ExpandedSizeOffset, 4));
|
||||
uint packedSize = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(PackedSizeOffset, 4));
|
||||
if (packedSize > data.Length - (PackedSizeOffset + 4))
|
||||
throw new InvalidDataException($"{name}: packed directory is truncated");
|
||||
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);
|
||||
int p = 0;
|
||||
uint ReadU32()
|
||||
{
|
||||
if (p > blob.Length - 4) throw new InvalidDataException($"{name}: directory is truncated");
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(blob.AsSpan(p, 4));
|
||||
p += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
uint archiveCount = ReadU32();
|
||||
if (archiveCount is 0 or >= 0x1000 || archiveCount > (blob.Length - p) / ArchiveNameSize)
|
||||
throw new InvalidDataException($"{name}: invalid archive count {archiveCount}");
|
||||
var archives = new List<string>(checked((int)archiveCount));
|
||||
for (int i = 0; i < archiveCount; i++, p += ArchiveNameSize)
|
||||
archives.Add(ReadCString(blob.AsSpan(p, ArchiveNameSize)));
|
||||
|
||||
uint fileCount = ReadU32();
|
||||
if (fileCount is 0 or >= 0x400000 || fileCount > (blob.Length - p) / RecordSize)
|
||||
throw new InvalidDataException($"{name}: invalid file count {fileCount}");
|
||||
var slots = new List<AssetEntry>(checked((int)fileCount));
|
||||
for (int i = 0; i < fileCount; i++, p += RecordSize)
|
||||
{
|
||||
var row = blob.AsSpan(p, RecordSize);
|
||||
string fileName = ReadCString(row[..64]);
|
||||
int archiveId = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(64, 4)));
|
||||
int fileNumber = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(68, 4)));
|
||||
long offset = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(72, 4));
|
||||
long size = BinaryPrimitives.ReadUInt32LittleEndian(row.Slice(76, 4));
|
||||
string archive = archiveId >= 0 && archiveId < archives.Count ? archives[archiveId] : "";
|
||||
bool placeholder = fileName is "" or "@";
|
||||
slots.Add(new AssetEntry(fileName, archive, offset, size, i, archiveId, fileNumber, placeholder));
|
||||
}
|
||||
|
||||
string magic = ReadCString(data.AsSpan(0, Math.Min(8, data.Length)));
|
||||
return new Sys4AssetCatalog(magic, archives, slots);
|
||||
}
|
||||
|
||||
/// <summary>Universal raw-id lookup. Placeholder slots are returned, not collapsed.</summary>
|
||||
public AssetEntry? ResolveRaw(long rawId)
|
||||
=> rawId >= 0 && rawId < RawSlots.Count ? RawSlots[(int)rawId] : null;
|
||||
|
||||
/// <summary>Case-insensitive exact-name lookup over real records.</summary>
|
||||
public AssetEntry? ResolveName(string name)
|
||||
=> _byName.TryGetValue(Path.GetFileName(name), out var entry) && Path.GetFileName(name) == name
|
||||
? entry : null;
|
||||
|
||||
/// <summary>Resolve within the owning scene section; ids cannot spill into the next section.</summary>
|
||||
public AssetEntry? ResolveScene(string scene, long localId)
|
||||
{
|
||||
string key = Path.GetFileNameWithoutExtension(scene);
|
||||
if (!_sceneRanges.TryGetValue(key, out var range)) return null;
|
||||
long pos = range.Start + localId;
|
||||
return localId >= 0 && pos <= range.End ? Files[(int)pos] : null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ScriptNames => Files
|
||||
.Where(f => f.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(f => f.Name.ToUpperInvariant()).ToArray();
|
||||
|
||||
private static Dictionary<string, (int Start, int End)> BuildSceneRanges(IReadOnlyList<AssetEntry> files)
|
||||
{
|
||||
var ranges = new Dictionary<string, (int Start, int End)>(StringComparer.OrdinalIgnoreCase);
|
||||
int start = 0;
|
||||
for (int i = 1; i <= files.Count; i++)
|
||||
{
|
||||
bool end = i == files.Count || files[i].FileNumber <= files[i - 1].FileNumber;
|
||||
if (!end) continue;
|
||||
for (int k = start; k < i; k++)
|
||||
if (files[k].Name.Length == 10 && files[k].Name.StartsWith("SC", StringComparison.OrdinalIgnoreCase)
|
||||
&& files[k].Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)
|
||||
&& files[k].Name.AsSpan(2, 4).ToString().All(char.IsDigit))
|
||||
{
|
||||
ranges[Path.GetFileNameWithoutExtension(files[k].Name)] = (start, i - 1);
|
||||
break;
|
||||
}
|
||||
start = i;
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private static string ReadCString(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
int zero = bytes.IndexOf((byte)0);
|
||||
if (zero >= 0) bytes = bytes[..zero];
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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); }
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
|
||||
namespace Age.Engine.Sys4;
|
||||
|
||||
/// <summary>Resolves call-script ids (raw SYS4INI file indices) to loaded scripts, using
|
||||
/// build/callscript-names.json (id→name) + Paths.Scripts() (name→path). Cached per id.
|
||||
/// The native resolver prefers a loose override before the archive; Paths.Scripts() already
|
||||
/// shadows extracted/DATA1 with root overrides, so that behavior is preserved.</summary>
|
||||
/// <summary>Loads root and call-script bytecode through the native loose-first asset-store seam.</summary>
|
||||
public sealed class Sys4ScriptProvider : IScriptProvider
|
||||
{
|
||||
private readonly OpcodeTable _table;
|
||||
private readonly IReadOnlyDictionary<long, string> _idToName;
|
||||
private readonly Dictionary<string, string> _byName; // NAME(UPPER) -> path
|
||||
private readonly IAssetStore _store;
|
||||
private readonly Dictionary<long, Script?> _cache = new();
|
||||
private readonly Dictionary<string, Script?> _nameCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Sys4ScriptProvider(OpcodeTable table, IReadOnlyDictionary<long, string> idToName,
|
||||
Dictionary<string, string> byName)
|
||||
{ _table = table; _idToName = idToName; _byName = byName; }
|
||||
public Sys4AssetCatalog Catalog { get; }
|
||||
public IReadOnlyList<string> ScriptNames => Catalog.ScriptNames;
|
||||
|
||||
public Sys4ScriptProvider(OpcodeTable table, Sys4AssetCatalog catalog, IAssetStore store)
|
||||
{ _table = table; Catalog = catalog; _store = store; }
|
||||
|
||||
public static Sys4ScriptProvider Load(OpcodeTable table)
|
||||
{
|
||||
var raw = JsonSerializer.Deserialize<Dictionary<string, string>>(
|
||||
File.ReadAllText(Paths.CallscriptNamesJson)) ?? new();
|
||||
var idToName = raw.ToDictionary(kv => long.Parse(kv.Key), kv => kv.Value);
|
||||
return new Sys4ScriptProvider(table, idToName, Paths.Scripts());
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
return new Sys4ScriptProvider(table, catalog,
|
||||
new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir));
|
||||
}
|
||||
|
||||
public Script? GetById(long id)
|
||||
{
|
||||
if (_cache.TryGetValue(id, out var cached)) return cached;
|
||||
Script? s = null;
|
||||
if (_idToName.TryGetValue(id, out var name) &&
|
||||
_byName.TryGetValue(name.ToUpperInvariant(), out var path))
|
||||
s = Sys4Loader.Load(path, _table);
|
||||
_cache[id] = s;
|
||||
return s;
|
||||
var entry = Catalog.ResolveRaw(id);
|
||||
Script? script = entry is { IsPlaceholder: false }
|
||||
&& entry.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)
|
||||
? Parse(entry) : null;
|
||||
_cache[id] = script;
|
||||
return script;
|
||||
}
|
||||
|
||||
public Script? GetByName(string name)
|
||||
{
|
||||
string key = name;
|
||||
if (_nameCache.TryGetValue(key, out var cached)) return cached;
|
||||
var entry = Catalog.ResolveName(key);
|
||||
Script? script = entry != null && entry.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)
|
||||
? Parse(entry) : null;
|
||||
_nameCache[key] = script;
|
||||
return script;
|
||||
}
|
||||
|
||||
public Script RequireByName(string name)
|
||||
=> GetByName(name) ?? throw new FileNotFoundException($"script is not in SYS4INI: {name}", name);
|
||||
|
||||
private Script Parse(AssetEntry entry)
|
||||
=> Sys4Loader.Parse(_store.ReadAll(entry), _table, entry.Name);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user