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(() => 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(() => store.Open(entry with { Name = "../TEST.BIN" })); Assert.Throws(() => store.Open(entry with { Archive = "../DATA1.ALF" })); Assert.Throws(() => 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(); } }