From d38511665a34d0517a807cee21f0e4e7b9e3ee75 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 15 Aug 2026 09:49:04 -0400 Subject: [PATCH] Match native asset fallback behavior --- docs/asset-resolution-re.md | 18 +++++ docs/engine-re.md | 10 ++- engine/Age.Cli/Program.cs | 4 +- .../Age.Engine.Tests/Sys4AssetStoreTests.cs | 26 +++++++ .../Sys4StartupSettingsTests.cs | 8 +- engine/Age.Engine/Sys4/ResourceMap.cs | 78 +++++++++++++------ engine/Age.Engine/Sys4/Sys4AssetStore.cs | 10 ++- godot/Main.cs | 4 +- 8 files changed, 120 insertions(+), 38 deletions(-) diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 7892484..ab5a44a 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -214,6 +214,24 @@ graphics, voice/SFX, and movie bytes. Resolution and opening remain separate onl packed id selects one record, then the store applies loose-first/archive-second opening. There is no scene-local numeric addressing mode. +**Native/port fallback fidelity recheck and correction (2026-08-15).** A fresh `/v2` decompile and disassembly +comparison confirmed the normal installed-asset contract above, including base/append packed selection, exact loose +basename, archive offset/size, and successful-open catalog tracking. `Sys4AssetStore` now also matches the two +previously divergent open details: any ordinary loose-file open failure (`IOException` or +`UnauthorizedAccessException`) continues through later override roots and then the archive, and both loose and +archive handles request `FileOptions.SequentialScan`. A loose file that opens but fails later decoding remains +authoritative in both implementations; decode failure never reveals the archive copy. + +Native still throws for a base id outside the table, a missing signed append selector, an append id outside its +table, or a selected entry that cannot be opened loose or archived. The typed port intentionally retains its safer +`null` result for invalid, unmounted, placeholder, or wrong-type packed records. `ResourceMap` now emits a +deduplicated diagnostic for each `(resource kind, packed id, failure)` through an optional host callback; Godot sends +these to `GD.PushWarning`, and the CLI audio/gfx diagnostics send them to standard error. Thus malformed scripts or +mods remain nonfatal without becoming silent. + +The corresponding Ghidra helper is named `asset_register_open_handle_range@0x44edf0`; it stores the selected +handle, current position, and readable length in the FileDB stream table. + ### Proposed layers 1. **Catalog + read-only ALF store (VFS-A DONE).** `Sys4AssetCatalog` parses SYS4INI at runtime while preserving all 13208 raw records diff --git a/docs/engine-re.md b/docs/engine-re.md index 8d8eb13..08c7464 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -307,11 +307,19 @@ the dispatch table (op `0x03` → `ctx[0x26c93+3]` = **`FUN_0041bc90`**), then t **SYS4INI 80-byte layout** `{name[64], arc_id@0x40, file_number@0x44, offset@0x48, size@0x4c}` (count = `[FileDB+0x40c]`, archive-name table = `[FileDB+0x410]`; absolute EngineCtx fields `+0x9c658/+0x9c65c/+0x9c660`). It tries a **loose override first** - (`CreateFileA` on `record.name` → the mod/patch hook point), else opens archive + (`CreateFileA` on `record.name` → the mod/patch hook point). Any failed loose `CreateFileA` result—not only + file-not-found—takes the archive branch; a successfully opened but malformed loose payload remains authoritative. + Both loose and archive opens use read access, read sharing, `OPEN_EXISTING`, and the sequential-scan hint. The + archive branch opens `[record.arc_id*0x100 + FileDB+0x410]`, `SetFilePointer` to `record.offset`, size = `record.size`. High-byte-tagged ids select `[FileDB+0x3028 + signed_selector*4]` and use the low 24 bits as the selected AAI record. The base corpus has no explicit high-byte call-script operand; INIT2 op `0x143` supplies mounted record-zero ids dynamically. +- **`asset_register_open_handle_range@0x44edf0`** registers the selected handle in FileDB's 256-slot stream + table, retaining its current file position and the loose-file length or catalog-record length supplied by the + opener. A full table closes the new handle and throws. Base out-of-range ids, missing signed append selectors, + append out-of-range ids, and entries for which neither loose nor archive open succeeds likewise throw rather + than returning an unresolved sentinel. **So `call-script ` = a direct RAW index into the SYS4INI global file table** — the same table `parse_sys4ini.py` reads, but indexed *without* skipping `@` placeholders (13208 records, 2 diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 07145e2..8cdaa1c 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -42,7 +42,7 @@ if (args[0] == "audio") // each resolved via ResourceMap (same rule as the Godot host). Diagnostic only. var sceneName = args[1]; var sceneKey = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant(); - var res = ResourceMap.Load(); + var res = ResourceMap.Load(Console.Error.WriteLine); var host = new AudioTraceHost(res); 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 @@ -69,7 +69,7 @@ if (args[0] == "gfx") bool boot = args.Contains("--boot"); var sceneName = args.First(a => a.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)); var sceneKey = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant(); - var res = ResourceMap.Load(); + var res = ResourceMap.Load(Console.Error.WriteLine); var host = new GfxTraceHost(res); var session = new GameSession(); foreach (var s in args.Where(a => a.Contains('='))) diff --git a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs index f75e0a1..3356190 100644 --- a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs +++ b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs @@ -307,6 +307,10 @@ public class Sys4AssetStoreTests File.Delete(Path.Combine(loose, "TEST.BIN")); Assert.Equal(new byte[] { 1, 2, 3 }, store.ReadAll(entry)); + Directory.CreateDirectory(Path.Combine(loose, "TEST.BIN")); + Assert.Equal(new byte[] { 1, 2, 3 }, store.ReadAll(entry)); + Directory.Delete(Path.Combine(loose, "TEST.BIN")); + 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" })); @@ -319,6 +323,28 @@ public class Sys4AssetStoreTests } } + [Fact] + public void TypedResolutionReportsInvalidAndMismatchedPackedIdsOnce() + { + var catalog = Sys4AssetCatalog.Parse(Sys4StartupSettingsTests.BuildCatalog( + includeTrailer: true, fileName: "TEST.BIN")); + var diagnostics = new List(); + var resources = new ResourceMap(catalog, diagnostic: diagnostics.Add); + + Assert.Null(resources.ResolveTexture(0x02000000)); + Assert.Null(resources.ResolveTexture(0x02000000)); + Assert.Null(resources.ResolveTexture(0)); + Assert.Null(resources.ResolveTexture(0)); + + Assert.Collection(diagnostics, + message => Assert.Equal( + "[asset-resolution] texture resource 0x2000000 does not select a mounted SYS4INI/AAI record", + message), + message => Assert.Equal( + "[asset-resolution] texture resource 0x0 selects TEST.BIN, expected an AGF record", + message)); + } + private static byte[] ReadToEnd(Stream stream) { using var copy = new MemoryStream(); diff --git a/engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs b/engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs index 571a49a..3b13483 100644 --- a/engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs +++ b/engine/Age.Engine.Tests/Sys4StartupSettingsTests.cs @@ -101,11 +101,11 @@ public class Sys4StartupSettingsTests params (string Key, string Value)[] pairs) => WrapCatalog(BuildExpanded(pairs)); - internal static byte[] BuildCatalog(bool includeTrailer) - => WrapCatalog(BuildExpanded(Array.Empty<(string, string)>(), includeTrailer)); + internal static byte[] BuildCatalog(bool includeTrailer, string fileName = "@") + => WrapCatalog(BuildExpanded(Array.Empty<(string, string)>(), includeTrailer, fileName)); private static byte[] BuildExpanded( - (string Key, string Value)[] pairs, bool includeTrailer = true) + (string Key, string Value)[] pairs, bool includeTrailer = true, string fileName = "@") { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding cp932 = Encoding.GetEncoding(932); @@ -113,7 +113,7 @@ public class Sys4StartupSettingsTests AddU32(blob, 1); AddFixedString(blob, "DATA.ALF", 256, cp932); AddU32(blob, 1); - AddFixedString(blob, "@", 64, cp932); + AddFixedString(blob, fileName, 64, cp932); AddU32(blob, 0); AddU32(blob, 0); AddU32(blob, 0); diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 13f73a2..50eae0b 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -11,49 +11,43 @@ public sealed class ResourceMap { private readonly Sys4AssetCatalog _catalog; private readonly IAssetStore _store; + private readonly Action? _diagnostic; + private readonly HashSet _reportedDiagnostics = new(StringComparer.Ordinal); + private readonly object _diagnosticLock = new(); - public ResourceMap(Sys4AssetCatalog catalog, IAssetStore? store = null) + public ResourceMap(Sys4AssetCatalog catalog, IAssetStore? store = null, + Action? diagnostic = null) { _catalog = catalog; _store = store ?? new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir); + _diagnostic = diagnostic; } - public static ResourceMap Load() => new(Sys4AssetCatalog.Load(Paths.Sys4Ini)); + public static ResourceMap Load(Action? diagnostic = null) + => new(Sys4AssetCatalog.Load(Paths.Sys4Ini), diagnostic: diagnostic); /// Resolve a universal packed SYS4INI/AAI id to an AGF texture record. public AssetEntry? ResolveTexture(long resourceId) - { - var entry = _catalog.ResolvePacked(resourceId); - return entry is { IsPlaceholder: false } && - entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; - } + => ResolveTypedPacked(resourceId, "texture", "an AGF record", + entry => entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase)); /// Resolve a universal packed SYS4INI/AAI id to a voice audio record. public AssetEntry? ResolveVoice(long resourceId) - { - var entry = _catalog.ResolvePacked(resourceId); - return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; - } + => ResolveTypedPacked(resourceId, "voice", "an OGG/WAV record", IsAudio); /// Resolve a universal packed SYS4INI/AAI id to an AGF-named movie record. ReadMovie /// validates the MPEG signature because still images use the same extension. public AssetEntry? ResolveMovie(long resourceId) - { - var entry = _catalog.ResolvePacked(resourceId); - return entry is { IsPlaceholder: false } && - entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; - } + => ResolveTypedPacked(resourceId, "movie", "an AGF record", + entry => entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase)); /// Decode an AGF directly from loose-first VFS bytes. public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry); /// Resolve a native packed raw id to one of AGE's Windows cursor resources. public AssetEntry? ResolveCursor(long resourceId) - { - var entry = _catalog.ResolvePacked(resourceId); - return entry is { IsPlaceholder: false } - && entry.Name.EndsWith(".CUR", StringComparison.OrdinalIgnoreCase) ? entry : null; - } + => ResolveTypedPacked(resourceId, "cursor", "a CUR record", + entry => entry.Name.EndsWith(".CUR", StringComparison.OrdinalIgnoreCase)); public CursorImage DecodeCursor(AssetEntry entry) { @@ -79,10 +73,7 @@ public sealed class ResourceMap /// Resolve opcode 0xb4's universal packed SYS4INI/AAI id to an audio entry. public AssetEntry? ResolveSoundEffect(long packedRawId) - { - var entry = _catalog.ResolvePacked(packedRawId); - return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; - } + => ResolveTypedPacked(packedRawId, "sound effect", "an OGG/WAV record", IsAudio); /// Read a catalog-resolved OGG/WAV payload through the loose-first ALF/AAI byte store. public AudioPayload ReadAudio(AssetEntry entry) @@ -108,6 +99,43 @@ public sealed class ResourceMap private static bool IsAudio(AssetEntry entry) => entry.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase) || entry.Name.EndsWith(".WAV", StringComparison.OrdinalIgnoreCase); + + private AssetEntry? ResolveTypedPacked(long resourceId, string kind, string expected, + Func matches) + { + AssetEntry? entry = _catalog.ResolvePacked(resourceId); + if (entry == null) + { + ReportOnce($"[asset-resolution] {kind} resource {FormatResourceId(resourceId)} " + + "does not select a mounted SYS4INI/AAI record"); + return null; + } + if (entry.IsPlaceholder) + { + ReportOnce($"[asset-resolution] {kind} resource {FormatResourceId(resourceId)} " + + "selects a placeholder catalog record"); + return null; + } + if (!matches(entry)) + { + ReportOnce($"[asset-resolution] {kind} resource {FormatResourceId(resourceId)} " + + $"selects {entry.Name}, expected {expected}"); + return null; + } + return entry; + } + + private void ReportOnce(string message) + { + if (_diagnostic == null) return; + bool report; + lock (_diagnosticLock) + report = _reportedDiagnostics.Add(message); + if (report) _diagnostic(message); + } + + private static string FormatResourceId(long resourceId) + => resourceId < 0 ? resourceId.ToString() : $"0x{resourceId:x}"; } public sealed record AudioPayload(string Name, byte[] Bytes); diff --git a/engine/Age.Engine/Sys4/Sys4AssetStore.cs b/engine/Age.Engine/Sys4/Sys4AssetStore.cs index 95f7254..e924862 100644 --- a/engine/Age.Engine/Sys4/Sys4AssetStore.cs +++ b/engine/Age.Engine/Sys4/Sys4AssetStore.cs @@ -64,10 +64,12 @@ public sealed class Sys4AssetStore : IAssetStore try { return new FileStream(candidate, FileMode.Open, FileAccess.Read, FileShare.Read, - 64 * 1024, FileOptions.RandomAccess); + 64 * 1024, FileOptions.SequentialScan); } - catch (FileNotFoundException) { } - catch (DirectoryNotFoundException) { } + // Native falls through on any INVALID_HANDLE_VALUE result from the loose CreateFileA. + // Keep malformed payloads authoritative once open; only open failures reach the archive. + catch (IOException) { } + catch (UnauthorizedAccessException) { } } ValidateBasename(entry.Archive, "archive"); @@ -75,7 +77,7 @@ public sealed class Sys4AssetStore : IAssetStore 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); + 64 * 1024, FileOptions.SequentialScan); try { if (entry.Offset < 0 || entry.Size < 0 || entry.Offset > file.Length diff --git a/godot/Main.cs b/godot/Main.cs index 625c0a5..b2ff6bb 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -301,8 +301,8 @@ public partial class Main : Godot.Control _locator = new PageLocatorState(scene, _selftest ? null : pageMapPath); _locatorHud.Visible = _locatorHudVisible; var resources = scripts != null - ? new ResourceMap(scripts.Catalog, trackedAssetStore) - : new ResourceMap(catalog, _assetStore); + ? new ResourceMap(scripts.Catalog, trackedAssetStore, GD.PushWarning) + : new ResourceMap(catalog, _assetStore, GD.PushWarning); IGlyphMaskRasterizer? surfaceTextRasterizer = null; PortableTextRenderingPolicy? portableTextPolicy = null; string? exactUnavailable = null;