namespace Age.Engine.Sys4; /// /// Compatibility facade over the runtime SYS4 catalog. Scene-local graphics/voice/movie ids resolve /// through the executing script's manifest; BGM uses direct names; SFX/cursors use universal packed ids. /// See docs/asset-resolution-re.md. /// public sealed class ResourceMap { private readonly Sys4AssetCatalog _catalog; private readonly IAssetStore _store; public ResourceMap(Sys4AssetCatalog catalog, IAssetStore? store = null) { _catalog = catalog; _store = store ?? new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir); } public static ResourceMap Load() => new(Sys4AssetCatalog.Load(Paths.Sys4Ini)); /// Resolve a scene-local resId to its asset, or null if out of range / unknown scene. public AssetEntry? Resolve(string scene, long resId) { return _catalog.ResolveScene(scene, resId); } /// Resolve graphics normally through the scene manifest, with the universal raw-id /// fallback used by SYSTEM4-owned assets such as SO001. public AssetEntry? ResolveTexture(string scene, long resId) { var entry = _catalog.ResolveScene(scene, resId) ?? _catalog.ResolveRaw(resId); return entry is { IsPlaceholder: false } && entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } /// Resolve voice audio through the active SC section when one exists, then through the /// universal raw catalog used by non-SC frontend scripts such as ROOM. public AssetEntry? ResolveVoice(string scene, long resId) { var entry = _catalog.ResolveScene(scene, resId) ?? _catalog.ResolveRaw(resId); return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; } /// Resolve an already-normalized raw catalog id without applying a scene section base. public AssetEntry? ResolveRawTexture(long rawId) { var entry = _catalog.ResolveRaw(rawId); return entry is { IsPlaceholder: false } && entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } /// Resolve op 0x20f's universal raw-catalog movie id without applying the executing /// script's manifest base. AGE stores these MPEG program streams under .AGF names; ReadMovie /// validates the payload signature before playback. public AssetEntry? ResolveRawMovie(long rawId) { var entry = _catalog.ResolveRaw(rawId); return entry is { IsPlaceholder: false } && entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } /// 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; } public CursorImage DecodeCursor(AssetEntry entry) { if (!entry.Name.EndsWith(".CUR", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException($"not a CUR asset: {entry.Name}"); return CurDecoder.Decode(_store.ReadAll(entry), entry.Name); } public AssetEntry? ResolveName(string name) => _catalog.ResolveName(name); /// /// Resolve a BGM id to its catalog entry. BGM is addressed by DIRECT LITERAL NAME (BGM{id:D3}.OGG), NOT the /// per-scene section manifest that voices/textures use. Confirmed by ear (play-bgm 5->BGM005, 8->BGM008) /// and by the play-bgm 0x23->BGM035 case: BGM035 is a real standalone track (the BGM set skips 030-034), /// which the manifest mis-resolved to a graphics entry. See docs/asset-resolution-re.md. /// public AssetEntry? ResolveBgm(long id) { var name = $"BGM{id:D3}.OGG"; var entry = _catalog.ResolveName(name); return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; } /// 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; } /// Read a catalog-resolved OGG/WAV payload through the loose-first ALF/AAI byte store. public AudioPayload ReadAudio(AssetEntry entry) { if (entry.IsPlaceholder || !IsAudio(entry)) throw new InvalidDataException($"not an OGG/WAV asset: {entry.Name}"); return new AudioPayload(entry.Name, _store.ReadAll(entry)); } /// Read a catalog-resolved MPEG program-stream movie through the same loose-first VFS as /// scripts, graphics, and audio. AGE uses an .AGF basename for these payloads; the MPEG pack start /// code, rather than the extension, distinguishes them from still-image AGF. public MoviePayload ReadMovie(AssetEntry entry) { if (entry.IsPlaceholder) throw new InvalidDataException($"placeholder movie asset: {entry.Name}"); byte[] bytes = _store.ReadAll(entry); if (bytes.Length < 4 || bytes[0] != 0 || bytes[1] != 0 || bytes[2] != 1 || bytes[3] != 0xba) throw new InvalidDataException($"not an MPEG program stream: {entry.Name}"); return new MoviePayload(entry.Name, bytes); } private static bool IsAudio(AssetEntry entry) => entry.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase) || entry.Name.EndsWith(".WAV", StringComparison.OrdinalIgnoreCase); } public sealed record AudioPayload(string Name, byte[] Bytes); public sealed record MoviePayload(string Name, byte[] Bytes);