Match native asset fallback behavior

This commit is contained in:
gamer147
2026-08-15 09:49:04 -04:00
parent b27a5ff43d
commit d38511665a
8 changed files with 120 additions and 38 deletions

View File

@@ -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('=')))

View File

@@ -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<InvalidDataException>(() => 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<string>();
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();

View File

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

View File

@@ -11,49 +11,43 @@ public sealed class ResourceMap
{
private readonly Sys4AssetCatalog _catalog;
private readonly IAssetStore _store;
private readonly Action<string>? _diagnostic;
private readonly HashSet<string> _reportedDiagnostics = new(StringComparer.Ordinal);
private readonly object _diagnosticLock = new();
public ResourceMap(Sys4AssetCatalog catalog, IAssetStore? store = null)
public ResourceMap(Sys4AssetCatalog catalog, IAssetStore? store = null,
Action<string>? 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<string>? diagnostic = null)
=> new(Sys4AssetCatalog.Load(Paths.Sys4Ini), diagnostic: diagnostic);
/// <summary>Resolve a universal packed SYS4INI/AAI id to an AGF texture record.</summary>
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));
/// <summary>Resolve a universal packed SYS4INI/AAI id to a voice audio record.</summary>
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);
/// <summary>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.</summary>
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));
/// <summary>Decode an AGF directly from loose-first VFS bytes.</summary>
public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry);
/// <summary>Resolve a native packed raw id to one of AGE's Windows cursor resources.</summary>
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
/// <summary>Resolve opcode 0xb4's universal packed SYS4INI/AAI id to an audio entry.</summary>
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);
/// <summary>Read a catalog-resolved OGG/WAV payload through the loose-first ALF/AAI byte store.</summary>
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<AssetEntry, bool> 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);

View File

@@ -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