From 1a2ea008c69e832bb6df725b7e27ccdabcd4dcf1 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 20:23:32 -0400 Subject: [PATCH] Use universal packed resource resolution --- docs/asset-resolution-re.md | 30 ++++++---- docs/engine-re.md | 8 ++- docs/opcode-reference.md | 4 +- docs/phase-b-framework.md | 17 +++--- engine/Age.Cli/Program.cs | 18 +++--- engine/Age.Engine.Tests/AgfDecoderTests.cs | 4 +- engine/Age.Engine.Tests/CallScriptTests.cs | 8 +-- .../Age.Engine.Tests/GfxCommandBufferTests.cs | 4 +- .../HistoryInteractionOpsTests.cs | 2 +- engine/Age.Engine.Tests/MovieOpcodeTests.cs | 8 +-- .../Age.Engine.Tests/Sys4AssetStoreTests.cs | 35 +++++++++-- engine/Age.Engine.Tests/TestSupport.cs | 6 +- engine/Age.Engine/Hosting/IHost.cs | 8 +-- engine/Age.Engine/Sys4/ResourceMap.cs | 43 ++++--------- engine/Age.Engine/Sys4/Sys4AssetCatalog.cs | 6 +- engine/Age.Engine/Vm/VirtualMachine.cs | 34 +++++------ godot/GodotAdvHost.cs | 60 +++++++++---------- godot/Main.cs | 16 ++--- vm-map/opcodes.toml | 4 +- 19 files changed, 161 insertions(+), 154 deletions(-) diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 5f92ffb..df21047 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -54,8 +54,9 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b all 13206 `offset+size` fit inside their real `.ALF`; 837 name-matched files → 0 size mismatches. `files[]` preserves directory order (feeds step 2's order-correlation). Re-run: `py -3.11 -X utf8 tools/parse_sys4ini.py --check`. (Ref: asmodean's `exs4alf` / GARbro Eushully `ArcALF.cs`.) -2. **Resolve `resource_id → asset file`.** **NATIVE RULE CONFIRMED IN GHIDRA (2026-07-21): resource - operands are universal packed SYS4INI/AAI ids. There is no scene-relative path or fallback.** +2. **Resolve `resource_id → asset file`.** **NATIVE RULE CONFIRMED IN GHIDRA AND IMPLEMENTED + (2026-07-21): resource operands are universal packed SYS4INI/AAI ids. There is no scene-relative path + or fallback.** `asset_catalog_parse_base_tables@0x44e7e0` creates one flat base entry array in serialized SYS4INI order. `asset_open_indexed_entry@0x44f390` receives the operand unchanged. If its high byte is zero, @@ -80,7 +81,7 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b correlation and the strong `file_number == position - group_start` pattern remain useful evidence about catalog construction/order, but they do not describe runtime resolution. SC0000 starting at zero hid the mistake, while later large ids often fell outside the invented scene range and happened to reach the - port's raw fallback. Low raw ids used from later scripts can instead be silently misresolved today. + port's former raw fallback. Low raw ids used from later scripts could instead be silently misresolved. `tools/resolve_asset.py` and `build/asset-sections.json` are therefore correlation/manifest-inventory diagnostics only; they must not drive runtime lookup. @@ -144,9 +145,10 @@ rendering what the executed bytecode + the map produce (never a hardcoded image) ## Status -A2b-background: **steps 1–3 landed, but step 2's resolver must be corrected.** Step 1 = -`build/asset-index.json`. Step 2 originally normalized through inferred per-scene sections; native RE now -proves runtime operands are already universal packed ids. `tools/resolve_asset.py` and +A2b-background: **steps 1–3 landed; step 2's packed resolver was corrected 2026-07-21.** Step 1 = +`build/asset-index.json`. Step 2 originally normalized through inferred per-scene sections; native RE proved +runtime operands are already universal packed ids, and every typed runtime facade now uses that contract. +`tools/resolve_asset.py` and `build/asset-sections.json` remain grouping/correlation diagnostics, not runtime inputs. Step 3 = **first-pass render** (ResourceMap + GodotAdvHost texture ops → TextureRect compositing): the full-screen event-CG layer renders end-to-end from the bytecode. Remaining (next chunk): the **graphics geometry/blend @@ -214,12 +216,14 @@ scene-local numeric addressing mode. on-disk `BinExtractALF.exe` are validation references; the Kelebek repository exposes no clear license, so its code should not be copied without clarification. The focused `LzssDecoder` is shared with `Sys4AssetCatalog`; raw and compressed information/pixel/ACIF sections use the same bounded primitive. -4. **Runtime consumers (packed-id correction pending).** Script loading and SFX already use - `ResolvePacked`. Texture/voice/non-modal movie facades still contain the disproven scene-first/raw-fallback - compatibility layer and must be switched to the same typed packed lookup. Godot caches decoded RGBA - surfaces by catalog identity and supplies synchronous dimensions to opcode `0x208`; it no longer reads - `build/textures/*.BMP`. BGM remains direct-name. Extraction, grouping, and conversion tools remain - diagnostics. +4. **Runtime consumers (packed-id correction complete 2026-07-21).** Script loading, textures, voice, + SFX, cursors, and modal/non-modal movies all select through `ResolvePacked` before type filtering. The VM + retains bytecode operands unchanged instead of asking the host for scene normalization. Godot caches + decoded RGBA and movie identities by the full packed id, preserving AAI selectors rather than colliding + with equal low indexes in the base catalog. It supplies synchronous dimensions to opcode `0x208` and no + longer reads `build/textures/*.BMP`. BGM remains direct-name. Focused regressions cover SC0010's low + `0x21` texture and `0x120..0x122` voices plus append-pack identity. Extraction, grouping, and conversion + tools remain diagnostics. ### Acceptance gates @@ -352,7 +356,7 @@ corpus is `LOGO.BIN (0x335f,42,4)`, `OP.BIN (0x3364,42,4)`, and Native `0x20f` also arms modal run-state `0x2000`; unlike `0x236`, these six-instruction wrapper scripts depend on the movie service itself to park until EOF/input cancellation before they release surface 42. -`ResourceMap.ResolveRawMovie` currently supplies that typed universal lookup, while `ReadMovie` remains the MPEG +`ResourceMap.ResolveMovie` supplies that typed universal lookup, while `ReadMovie` remains the MPEG signature gate. `IHost.PlayModalMovieToSurface` is distinct from the non-modal call for lifecycle only: Godot reuses the asynchronous DirectShow frame decoder and retained compositor but parks the VM thread until EOF or mouse/Accept/Cancel input. The wrapper's following release then tears down the completed/cancelled movie. diff --git a/docs/engine-re.md b/docs/engine-re.md index 5ace62a..c96c85d 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -2119,9 +2119,11 @@ open raw entry `0x21` (`SO013A.AGF`); adding SC0010's catalog position `0x11e` i `COL0023.OGG`. Its `play-voice 0x120/0x121/0x122` operands directly select `LILA1414/LILB0053/LILC0054.OGG`, whose SC0010-local file numbers are 2/3/4. The catalog grouping relation is therefore `absolute id = group start + file_number`; the compiler has already performed that addition. -The port's scene-first compatibility resolver inverted this relationship and must be replaced by typed -`ResolvePacked` lookup for texture, voice, and movie consumers. Ghidra `/v2` renames the catalog parser and -entry-name helper, corrects the opener/caller comments, and is saved. +The port's former scene-first compatibility resolver inverted this relationship. On 2026-07-21 it was +replaced by typed `ResolvePacked` lookup for texture, voice, and both movie consumers; the VM now preserves +the operand unchanged and Godot cache/movie identities retain the complete packed selector. SC0010 low-id +and installed append-pack regressions cover the two failure modes. Ghidra `/v2` renames the catalog parser +and entry-name helper, corrects the opener/caller comments, and is saved. --- diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 173e732..a839a48 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -619,7 +619,7 @@ This is a target-pixel operation, not retained-object teardown. It invokes IDire - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x20f_play_modal_movie_to_surface@0x422e50 shares the movie-object allocation, DirectShow graph open, audio-route, and volume setup used by 0x236, then calls movie_start_modal_playback@0x463280, ORs EngineCtx+0xa0ce4 with 0x2000, and marks movie presentation dirty. The main loop and window procedure special-case run-state 0x2000. Corpus has exactly three sites: LOGO (0x335f,42,4), OP (0x3364,42,4), and ED (0x3324,42,dynamic flags). Those ids are universal raw SYS4INI indexes for MPEG-pack LOGO.AGF, OP.AGF, and ED.AGF; each script releases its surface only after 0x20f resumes. -Implemented through IHost.PlayModalMovieToSurface. Its operand uses the same native universal packed-id catalog contract as 0x236; the separate host call exists for modal wait/cancel lifecycle, not a different resolver. ResourceMap must retain MPEG signature validation in ReadMovie. Godot reuses the asynchronous decoder/retained-surface compositor, parks only the VM thread until EOF, and treats mouse click or Accept/Cancel input as completion before wrapper cleanup releases the decoder. MPEG audio remains a separate backend/audio-clock contract. +Implemented through IHost.PlayModalMovieToSurface. Its operand uses the same native universal packed-id catalog contract as 0x236; the separate host call exists for modal wait/cancel lifecycle, not a different resolver. ResourceMap.ResolveMovie selects through ResolvePacked and retains MPEG signature validation in ReadMovie. Godot reuses the asynchronous decoder/retained-surface compositor, parks only the VM thread until EOF, and treats mouse click or Accept/Cancel input as completion before wrapper cleanup releases the decoder. MPEG audio remains a separate backend/audio-clock contract. ### 0x212 `set-gfx-field64` (set-gfx-field64, argc 2) - **summary:** 0x212 (obj_idx)(val) — handler gfx_op_0x212_set_field64 @0x4230c0: obj=[ctx+0x14d54 + obj_idx*4]; if obj: *(obj+0x64)=val. The generic instruction length is 5 dwords. See docs/engine-re.md gfx op-contract table. @@ -749,7 +749,7 @@ Implemented through IHost.PlayModalMovieToSurface. Its operand uses the same nat - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2 op_0x236_play_movie_to_surface@0x423ee0 fetches operand 1 and passes it unchanged to movie_to_texture_open_asset_graph@0x463e20, which passes it unchanged to asset_open_indexed_entry@0x44f390. The opener directly indexes the flat base table or selected AAI table and has no scene input. SC0000 native operand capture and exact 0x13c8->0x13d1 trace prove nonblocking behavior. BTL's live 0x2b21 site supplies 0x2af1/0x2af5/0x2bca/0x2bd8/0x2bde, the exact base entries MVB001/MVB004/MVB914/MVB958/MVB955. -The handler requires an existing destination texture, allocates/reuses a 0x478-byte movie-to-texture object for the surface, opens operand 1 through the native universal packed-id reader, builds FilterGraph/IGraphBuilder/IMediaControl/IMediaPosition/IMediaEvent/IBasicAudio, and presents bottom-up RGB samples through the movie texture renderer. Operand 3 selects movie/sound routing policy: bits 0x10000/0x20000/0x40000/0x80000 force sound route 0/1/2/3, otherwise set:DependMovieSound is used; SC0000's low value 2 is retained as native movie mode state. Operand 4 is the movie sync/device mask. Static layer preparation after 0x236 does not terminate the retained movie; 0x21c services it through EOF and subsequent surface cleanup stops/detaches it. The port should type-check the selected packed record as MPEG but must not add a scene base or scene-first fallback. +The handler requires an existing destination texture, allocates/reuses a 0x478-byte movie-to-texture object for the surface, opens operand 1 through the native universal packed-id reader, builds FilterGraph/IGraphBuilder/IMediaControl/IMediaPosition/IMediaEvent/IBasicAudio, and presents bottom-up RGB samples through the movie texture renderer. Operand 3 selects movie/sound routing policy: bits 0x10000/0x20000/0x40000/0x80000 force sound route 0/1/2/3, otherwise set:DependMovieSound is used; SC0000's low value 2 is retained as native movie mode state. Operand 4 is the movie sync/device mask. Static layer preparation after 0x236 does not terminate the retained movie; 0x21c services it through EOF and subsequent surface cleanup stops/detaches it. The port type-checks the selected ResolvePacked record as MPEG without adding a scene base or fallback. ### 0x238 `set-anim-clock` (set-anim-clock, argc 1) - **summary:** (duration) — set the GLOBAL animation clock: native ctx+0x51b78=0 (elapsed), +0x51b7c=duration. The generic instruction length is 3 dwords. NON-BLOCKING: only configures; the render loop advances it and interpolates all animating objects. SC0000 opening @0x123bd/@0x13858. Handler 0x4240e0; Kelebek VA 0x422390 is drift. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 1eae464..e711e5e 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -561,20 +561,23 @@ Focused regressions cover exact dispatch, signed edge cases, string aliasing, cl movie polling, animation reset suppression, delayed voice operands, paired clipping, overlap, and colorkey transparency. Manual DEBUGMAP acceptance reached player combat and exposed the next concrete frontier: combat-effect movies do not play. The resolver/decoder diagnosis is canonical in -`docs/asset-resolution-re.md`; fix that bounded movie path before proceeding to enemy-turn/end-turn breadth. +`docs/asset-resolution-re.md`; packed resolution is now corrected, with manual BTL validation and the +separate decoder decision remaining before enemy-turn/end-turn breadth. -**Combat-effect movie gap diagnosed; native resolver generalized; implementation pending.** BTL's +**Universal packed resource resolver implemented; combat movie validation pending.** BTL's `0x236@0x2b21` consumes universal packed MVB ids, exactly as texture, voice, script-load, and modal-movie consumers do. Native Ghidra analysis shows that none of those paths applies an SC section base or fallback; -the port's scene-first compatibility resolver is therefore generally wrong, not merely incomplete for BTL. +the port's former scene-first compatibility resolver was therefore generally wrong, not merely incomplete for BTL. That explains each `movie unresolved BTL:...` warning and the secondary attempt to decode MPEG-backed `MVB914.AGF` as a still image. A packed-catalog decoder probe also found a separate backend wall: the current DirectShow graph handles `MVB914` (400x400) but rejects the reached 280x352 MVB001/MVB004/MVB955/MVB958 assets with `0x80040217`. Broader samples tie current compatibility to 16-aligned display widths, while 125 -installed MVB assets use 280x352. The next bounded slice is to replace typed texture, voice, and movie -consumers with universal packed resolution and regress SC0010's low texture/voice ids plus BTL effects. -The existing portable-decoder seam and a software fallback for 280x352 effects remain the following, -separate step, with destination dimensions and failed-movie identity preserved correctly. +installed MVB assets use 280x352. Texture, voice, and both movie consumers now use universal packed +resolution; VM surface state and Godot caches retain the full selector. Focused tests prove SC0010's low +texture/voice ids and append-pack identity; 302 engine tests, a zero-warning Godot build, and threaded +selftest pass. **NEXT:** manually re-enter combat and confirm the BTL ids now resolve into the movie backend. +The expected remaining failures are the known 280x352 DirectShow graphs; defer decoder replacement until +that resolver-only acceptance check, preserving destination dimensions and failed-movie identity afterward. **Mutable-surface fill/blend regression corrected.** The first visual recheck exposed BUNKI's menu interior as transparent. SYSTEM4 creates 800x600 surface 3 and fills it opaque white through `0x20b`; the metadata-only diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index c2c7c37..eb83020 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -43,7 +43,7 @@ if (args[0] == "audio") var sceneName = args[1]; var sceneKey = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant(); var res = ResourceMap.Load(); - var host = new AudioTraceHost(res, sceneKey); + 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 foreach (var s in args.Skip(2)) @@ -70,7 +70,7 @@ if (args[0] == "gfx") var sceneName = args.First(a => a.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)); var sceneKey = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant(); var res = ResourceMap.Load(); - var host = new GfxTraceHost(res, sceneKey); + var host = new GfxTraceHost(res); var session = new GameSession(); foreach (var s in args.Where(a => a.Contains('='))) { @@ -100,7 +100,7 @@ if (args[0] == "gfx") var vis = vm.Gfx.SnapshotVisibleObjects(); Console.WriteLine($" visible objects ({vis.Count}, ascending-handle = z-order):"); foreach (var v in vis) - Console.WriteLine($" h=0x{v.Handle:x} surf=0x{v.SurfaceResId:x} ({res.Resolve(sceneKey, v.SurfaceResId)?.Name ?? "?"}) src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) dst=({v.DstX},{v.DstY})"); + Console.WriteLine($" h=0x{v.Handle:x} surf=0x{v.SurfaceResId:x} ({res.ResolveTexture(v.SurfaceResId)?.Name ?? "?"}) src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) dst=({v.DstX},{v.DstY})"); return 0; } @@ -345,17 +345,16 @@ sealed class TraceSetup : IDisposable sealed class AudioTraceHost : IHost { private readonly ResourceMap _res; - private readonly string _scene; public List<(string Kind, long Id, string Resolved)> Events { get; } = new(); - public AudioTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } + public AudioTraceHost(ResourceMap res) { _res = res; } public void PlayBgm(long id) // BGM: direct name, not the manifest { var entry = _res.ResolveBgm(id); Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG ")); } - public void PlayVoice(long id) // voice: SC section, then frontend raw id + public void PlayVoice(long id) // voice: universal packed catalog id { - var e = _res.ResolveVoice(_scene, id); + var e = _res.ResolveVoice(id); Events.Add(("play-voice", id, e == null ? "" : $"{e.Archive} {e.Name}")); } public void ShowText(int offset, string text) { } @@ -371,12 +370,11 @@ sealed class AudioTraceHost : IHost sealed class GfxTraceHost : IHost { private readonly ResourceMap _res; - private readonly string _scene; private readonly Dictionary _slotAsset = new(); // slot -> resolved AGF name (or null) // slot -> dimensions of the currently allocated surface. Slot 0 starts as the engine's primary surface. private readonly Dictionary _slotDims = new() { { 0, (800, 600) } }; public List Events { get; } = new(); - public GfxTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } + public GfxTraceHost(ResourceMap res) { _res = res; } public (int Width, int Height) GetTextureSize(int slot) { @@ -387,7 +385,7 @@ sealed class GfxTraceHost : IHost public void SetTexture(long resId, int slot) { - var e = _res.ResolveTexture(_scene, resId); + var e = _res.ResolveTexture(resId); RgbaImage? image = e != null ? _res.DecodeTexture(e) : null; _slotAsset[slot] = e?.Name; _slotDims[slot] = image != null ? (image.Width, image.Height) : (0, 0); diff --git a/engine/Age.Engine.Tests/AgfDecoderTests.cs b/engine/Age.Engine.Tests/AgfDecoderTests.cs index 266a317..af1b6de 100644 --- a/engine/Age.Engine.Tests/AgfDecoderTests.cs +++ b/engine/Age.Engine.Tests/AgfDecoderTests.cs @@ -75,7 +75,7 @@ public class AgfDecoderTests var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var store = new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir); var resources = new ResourceMap(catalog, store); - Assert.Equal("SO001.AGF", resources.ResolveTexture("SC0000", 0x337e)?.Name); + Assert.Equal("SO001.AGF", resources.ResolveTexture(0x337e)?.Name); var image = AgfDecoder.Decode(store, catalog.ResolveRaw(0x337e)!); Assert.Equal((800, 300), (image.Width, image.Height)); Assert.Contains(image.Pixels.Where((_, i) => (i & 3) == 3), a => a is > 0 and < 255); @@ -89,7 +89,7 @@ public class AgfDecoderTests public void InstalledFieldMapSheetsResolveAndDecodeByRawCatalogIndex(int rawId, string name) { var resources = ResourceMap.Load(); - var asset = resources.ResolveRawTexture(rawId); + var asset = resources.ResolveTexture(rawId); Assert.NotNull(asset); Assert.Equal(name, asset.Name); var image = resources.DecodeTexture(asset); diff --git a/engine/Age.Engine.Tests/CallScriptTests.cs b/engine/Age.Engine.Tests/CallScriptTests.cs index cb0e918..2625360 100644 --- a/engine/Age.Engine.Tests/CallScriptTests.cs +++ b/engine/Age.Engine.Tests/CallScriptTests.cs @@ -15,7 +15,6 @@ public class CallScriptTests { public virtual void EnterScriptContext(string scriptName) { } public virtual void ExitScriptContext() { } - public virtual long ResolveTextureResourceId(long resourceId) => resourceId; public void ShowText(int o, string t) { } public void WaitForInput() { } public void Sleep(long duration) { } @@ -52,9 +51,6 @@ public class CallScriptTests Events.Add($"exit:{_contexts.Pop()}"); } - public override long ResolveTextureResourceId(long resourceId) - => resourceId + (_contexts.Peek() == "CALLEE" ? 700 : 70); - public override void SetTexture(long resourceId, int slot) => Textures.Add((resourceId, slot)); } @@ -133,7 +129,7 @@ public class CallScriptTests } [Fact] - public void ScriptLocalTextureIdsFollowTheActiveNestedFrame() + public void PackedTextureIdsRemainUnchangedAcrossNestedFrames() { var t = Table(); var callee = Asm(t, "CALLEE", @@ -149,7 +145,7 @@ public class CallScriptTests vm.Run(); - Assert.Equal(new[] { (77L, 1), (707L, 2), (77L, 3) }, host.Textures); + Assert.Equal(new[] { (7L, 1), (7L, 2), (7L, 3) }, host.Textures); Assert.Equal(new[] { "enter:CALLER", "enter:CALLEE", "exit:CALLEE", "exit:CALLER" }, host.Events); } } diff --git a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs index 3daf219..b7ee68f 100644 --- a/engine/Age.Engine.Tests/GfxCommandBufferTests.cs +++ b/engine/Age.Engine.Tests/GfxCommandBufferTests.cs @@ -126,7 +126,7 @@ public class GfxCommandBufferTests } [Fact] - public void RawTextureLoadBypassesSceneResourceNormalizationAndFeedsRetainedDraws() + public void ModeOneTextureLoadPreservesPackedIdAndFeedsRetainedDraws() { var t = T(); var scene = ScriptAssembler.Assemble(t, "RAW-GFX", new List<(int, Operand[])> @@ -135,7 +135,7 @@ public class GfxCommandBufferTests (0x1fb, new[] { I(0x100), I(0x3e), I(0), I(0), I(100), I(100), I(20), I(30) }), Exit(), }, System.Array.Empty()); - var host = new RecordingHost { TextureResourceIdOffset = 0x1000 }; + var host = new RecordingHost(); var vm = new VirtualMachine(scene, t, host); vm.Run(); diff --git a/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs b/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs index 58cc6af..9de2226 100644 --- a/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs +++ b/engine/Age.Engine.Tests/HistoryInteractionOpsTests.cs @@ -342,7 +342,7 @@ public class HistoryInteractionOpsTests Assert.Equal(new[] { (expectedVoice, checked((int)expectedVariant)) }, host.VoiceRequests); var resources = ResourceMap.Load(); - var voice = Assert.IsType(resources.Resolve("SC0000", expectedVoice)); + var voice = Assert.IsType(resources.ResolveVoice(expectedVoice)); Assert.Equal("MAN999.OGG", voice.Name); Assert.NotEmpty(resources.ReadAudio(voice).Bytes); } diff --git a/engine/Age.Engine.Tests/MovieOpcodeTests.cs b/engine/Age.Engine.Tests/MovieOpcodeTests.cs index f8344c9..6eda5fe 100644 --- a/engine/Age.Engine.Tests/MovieOpcodeTests.cs +++ b/engine/Age.Engine.Tests/MovieOpcodeTests.cs @@ -196,7 +196,7 @@ public class MovieOpcodeTests { var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); - var entry = resources.Resolve("SC0000", 0x33); + var entry = resources.ResolveMovie(0x33); Assert.Equal("CHAPTER.AGF", entry?.Name); var movie = resources.ReadMovie(entry!); @@ -207,11 +207,11 @@ public class MovieOpcodeTests [Theory] [InlineData(0x335f, "LOGO.AGF")] [InlineData(0x3364, "OP.AGF")] - public void ModalMoviePayloadResolvesFromUniversalRawCatalog(int rawId, string expectedName) + public void ModalMoviePayloadResolvesFromUniversalPackedCatalog(int resourceId, string expectedName) { var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); - var entry = resources.ResolveRawMovie(rawId); + var entry = resources.ResolveMovie(resourceId); Assert.Equal(expectedName, entry?.Name); var movie = resources.ReadMovie(entry!); @@ -224,7 +224,7 @@ public class MovieOpcodeTests if (!OperatingSystem.IsWindows()) return; var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); - var entry = resources.Resolve("SC0000", 0x33)!; + var entry = resources.ResolveMovie(0x33)!; using var decoder = new DirectShowMovieDecoder(resources.ReadMovie(entry)); Assert.True(decoder.StopTimeMs > 0, "DirectShow should expose a positive IMediaPosition stop time"); diff --git a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs index da8c0b4..7fa3b04 100644 --- a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs +++ b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs @@ -22,6 +22,7 @@ public class Sys4AssetStoreTests Assert.All(append.Files, entry => { Assert.Equal(1, entry.PackId); + Assert.Equal(0x01000000 | entry.RawIndex, entry.PackedId); Assert.StartsWith("$1$", entry.Name); Assert.Equal("APPEND01.ALF", entry.Archive); }); @@ -178,14 +179,14 @@ public class Sys4AssetStoreTests Assert.Equal("BGM005.OGG", bgm?.Name); AssertOgg(resources.ReadAudio(bgm!)); - var voice = resources.ResolveVoice("SC0000", 0x24); + var voice = resources.ResolveVoice(0x24); Assert.Equal("MAN999.OGG", voice?.Name); AssertOgg(resources.ReadAudio(voice!)); - var roomVoice = resources.ResolveVoice("ROOM", 0x3365); + var roomVoice = resources.ResolveVoice(0x3365); Assert.Equal("EUA0016.OGG", roomVoice?.Name); AssertOgg(resources.ReadAudio(roomVoice!)); - Assert.Null(resources.ResolveVoice("ROOM", 0x337e)); // SO001.AGF is not voice audio. + Assert.Null(resources.ResolveVoice(0x337e)); // SO001.AGF is not voice audio. var sfx = resources.ResolveSoundEffect(0x28); Assert.Equal("E0808.WAV", sfx?.Name); @@ -194,7 +195,6 @@ public class Sys4AssetStoreTests Assert.Equal("RIFF", Encoding.ASCII.GetString(wav.Bytes, 0, 4)); Assert.Equal("WAVE", Encoding.ASCII.GetString(wav.Bytes, 8, 4)); - Assert.Null(resources.Resolve("TITLE", 0x2aea)); Assert.Equal("SE020.WAV", resources.ResolveSoundEffect(0x2aea)?.Name); Assert.Equal("SE013.WAV", resources.ResolveSoundEffect(0x2aeb)?.Name); Assert.Equal("SE015.WAV", resources.ResolveSoundEffect(0x3321)?.Name); @@ -204,6 +204,33 @@ public class Sys4AssetStoreTests Assert.Throws(() => resources.ReadAudio(catalog.ResolveName("SO001.AGF")!)); } + [Fact] + public void Sc0010LowOperandsAreAlreadyUniversalPackedIds() + { + var resources = ResourceMap.Load(); + + Assert.Equal("SO013A.AGF", resources.ResolveTexture(0x21)?.Name); + Assert.Equal("LILA1414.OGG", resources.ResolveVoice(0x120)?.Name); + Assert.Equal("LILB0053.OGG", resources.ResolveVoice(0x121)?.Name); + Assert.Equal("LILC0054.OGG", resources.ResolveVoice(0x122)?.Name); + + var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + Assert.Equal("COL0023.OGG", catalog.ResolveRaw(0x11e + 0x21)?.Name); + } + + [Fact] + public void TypedResourceResolutionPreservesAppendPackSelector() + { + var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + var resources = new ResourceMap(catalog); + var append = Assert.Single(catalog.AppendPacks).Value; + var texture = Assert.Single(append.Files.Where(entry => + entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase)).Take(1)); + + Assert.Same(texture, resources.ResolveTexture(texture.PackedId)); + Assert.NotEqual(texture, resources.ResolveTexture(texture.RawIndex)); + } + [Fact] public void AllInstalledLooseScriptOverridesShadowArchiveCopies() { diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 97ea115..fac364e 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -55,9 +55,7 @@ internal class RecordingHost : IHost public readonly List AdvPagePresentationSuspended = new(); public int CursorClearCount; public int SceneContextResets; - public long TextureResourceIdOffset; public void ReportWarning(string message) => Warnings.Add(message); - public long ResolveTextureResourceId(long resourceId) => resourceId + TextureResourceIdOffset; public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y)); public void DrawStringToSurface(int surfaceSlot, int x, int y, string text) @@ -162,8 +160,8 @@ internal class RecordingHost : IHost return MovieStopTimeMs; } public bool IsMovieSurfaceActive(int surfaceSlot) => ActiveMovieSurfaces.Contains(surfaceSlot); - public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) - => ModalMovies.Add((rawResourceId, surfaceSlot, movieFlags)); + public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags) + => ModalMovies.Add((resourceId, surfaceSlot, movieFlags)); } internal sealed class MapProvider : IScriptProvider diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 09e6618..60fbd83 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -21,11 +21,9 @@ public interface IHost { /// Report a recoverable runtime discrepancy while allowing script execution to continue. void ReportWarning(string message) => System.Console.Error.WriteLine(message); - // Script-local resource ids resolve against the currently executing frame's SYS4INI section. - // Interactive hosts track this stack; headless hosts may keep the no-op/default identity behavior. + // Script context is retained for diagnostics/page location; resource operands are universal packed ids. void EnterScriptContext(string scriptName) { } void ExitScriptContext() { } - long ResolveTextureResourceId(long resourceId) => resourceId; void ShowText(int offset, string text); // Native ADV text subsystem: op 0x7a updates the selected layout's last 20-byte cursor record; // op 0x204 rasterizes a string into a numbered surface before 0x1fb binds that surface. @@ -125,8 +123,8 @@ public interface IHost /// immediately after 0x236 returns. long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) => null; bool IsMovieSurfaceActive(int surfaceSlot) => false; - // Native op 0x20f uses a universal raw-catalog id and parks script execution until the movie + // Native op 0x20f uses a universal packed id and parks script execution until the movie // reaches EOF or the player cancels it. The decoder remains asynchronous; the interactive host // owns the modal wait so its render loop can continue publishing frames. - void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) { } + void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags) { } } diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 6c5d5df..ebb622a 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -1,8 +1,8 @@ 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. +/// Typed facade over the runtime SYS4 catalog. Graphics, voice, movie, SFX, and cursor operands use +/// universal packed ids; BGM uses direct names. /// See docs/asset-resolution-re.md. /// public sealed class ResourceMap @@ -18,43 +18,26 @@ public sealed class ResourceMap 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) + /// Resolve a universal packed SYS4INI/AAI id to an AGF texture record. + public AssetEntry? ResolveTexture(long resourceId) { - 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); + var entry = _catalog.ResolvePacked(resourceId); 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) + /// Resolve a universal packed SYS4INI/AAI id to a voice audio record. + public AssetEntry? ResolveVoice(long resourceId) { - var entry = _catalog.ResolveScene(scene, resId) ?? _catalog.ResolveRaw(resId); + var entry = _catalog.ResolvePacked(resourceId); return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null; } - /// Resolve an already-normalized packed catalog id without applying a scene section base. - public AssetEntry? ResolveRawTexture(long rawId) + /// 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(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); + var entry = _catalog.ResolvePacked(resourceId); return entry is { IsPlaceholder: false } && entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } @@ -81,7 +64,7 @@ public sealed class ResourceMap /// /// 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) + /// universal packed resource table used by voices/textures. 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. /// diff --git a/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs b/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs index f4eb446..c5dfda3 100644 --- a/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs +++ b/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs @@ -14,7 +14,11 @@ public sealed record AssetEntry( int ArchiveId = -1, int FileNumber = -1, bool IsPlaceholder = false, - int PackId = 0); + int PackId = 0) +{ + /// The exact packed SYS4INI/AAI id AGE uses to address this record. + public int PackedId => checked((PackId << 24) | RawIndex); +} /// A real catalog entry paired with the packed resource id AGE uses at runtime. public sealed record PackedAssetEntry(long PackedId, AssetEntry Asset); diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 5bcba84..5ea3272 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1539,29 +1539,27 @@ public sealed class VirtualMachine _host.CreateTexture((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1; case "set-texture": // 0x1f9 (resId)(slot)(colorkey) — load a file into the slot's surface { - long requestedResourceId = Read(a[0]); - long resolvedResourceId = _host.ResolveTextureResourceId(requestedResourceId); + long resourceId = Read(a[0]); if (_diagSetTexture) // AGE_DIAG_SETTEX: log the SLOT operand source (literal vs which global) — grey-BG slot dig - System.Console.Error.WriteLine($"[settex] resId=0x{requestedResourceId:x}->0x{resolvedResourceId:x} slot={(int)Read(a[1])} " + + System.Console.Error.WriteLine($"[settex] resId=0x{resourceId:x} slot={(int)Read(a[1])} " + $"slotOp=(type={a[1].Type} val=0x{a[1].Value:x}){(a[1].Type == 3 ? $" G[0x{a[1].Value:x}]" : "")}"); _host.ReleaseSurface((int)Read(a[1])); long colorKey = a.Count > 2 ? Read(a[2]) : -1; - Gfx.SetSurface((int)Read(a[1]), resolvedResourceId, colorKey); - _host.SetTexture(resolvedResourceId, (int)Read(a[1]), colorKey); + Gfx.SetSurface((int)Read(a[1]), resourceId, colorKey); + _host.SetTexture(resourceId, (int)Read(a[1]), colorKey); return pc + 1; // host still tracks dims for get-texture-size } case "u00422EB0": // pre-reference compatibility - case "load-raw-texture-surface": // 0x249 (raw catalog id)(slot)(colorkey) + case "load-raw-texture-surface": // 0x249 (packed resource id)(slot)(colorkey) { // Native shares 0x1f9's release/load/colorkey path, but constructs its mode-1 - // surface subclass and receives an already-global SYS4INI catalog index. The - // CPU compositor does not need the D3D subclass distinction; it does need the - // resource id to bypass the executing script's scene-section normalization. - long rawResourceId = Read(a[0]); + // surface subclass. Both texture opcodes receive the same universal packed id; + // the CPU compositor does not need the D3D subclass distinction. + long resourceId = Read(a[0]); int surfaceSlot = (int)Read(a[1]); _host.ReleaseSurface(surfaceSlot); - Gfx.SetSurface(surfaceSlot, rawResourceId, Read(a[2])); - _host.SetTexture(rawResourceId, surfaceSlot, Read(a[2])); + Gfx.SetSurface(surfaceSlot, resourceId, Read(a[2])); + _host.SetTexture(resourceId, surfaceSlot, Read(a[2])); return pc + 1; } case "draw-texture": // 0x1fb (handle)(slot)(srcX)(srcY)(w)(h)(dstX)(dstY) — bind object -> surface + rect + pos @@ -1652,14 +1650,14 @@ public sealed class VirtualMachine case "get-initial-root-run": // 0x130 (out) Write(a[0], _initialRootRun ? 1 : 0); return pc + 1; - case "play-modal-movie-to-surface": // 0x20f (raw resource)(surface)(movie flags) + case "play-modal-movie-to-surface": // 0x20f (packed resource)(surface)(movie flags) { - long rawResourceId = Read(a[0]); + long resourceId = Read(a[0]); int surfaceSlot = (int)Read(a[1]); - // The modal and scene-local paths share retained-surface composition. The host's - // distinct entry point preserves 0x20f's raw-id resolver and blocking lifecycle. - Gfx.SetSurface(surfaceSlot, rawResourceId, 0); - _host.PlayModalMovieToSurface(rawResourceId, surfaceSlot, Read(a[2])); + // Modal and non-modal paths share packed resolution and retained-surface composition. + // The distinct host entry point owns only 0x20f's blocking lifecycle. + Gfx.SetSurface(surfaceSlot, resourceId, 0); + _host.PlayModalMovieToSurface(resourceId, surfaceSlot, Read(a[2])); return pc + 1; } case "u004221A0": // pre-reference compatibility diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 8b8f791..23d3d98 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -16,13 +16,13 @@ public sealed class GodotAdvHost : IHost private readonly object _scriptContextLock = new(); private readonly Stack _scriptContexts = new(); private readonly object _imageLock = new(); - private readonly Dictionary _images = new(); // raw catalog id -> decoded pixels + private readonly Dictionary _images = new(); // packed catalog id -> decoded pixels // Mutable AGE surfaces are published by replacing immutable RgbaImage snapshots, so the compositor // can safely finish reading an old frame while the VM prepares a copied-rectangle update. private readonly Dictionary _surfaceImages = new(); private readonly Dictionary _surfaceColorKeys = new(); - private readonly Dictionary _surfaceResources = new(); // surface slot -> normalized raw catalog id - private readonly Dictionary _movieFrames = new(); + private readonly Dictionary _surfaceResources = new(); // surface slot -> packed catalog id + private readonly Dictionary _movieFrames = new(); private readonly Dictionary _movieBySurface = new(); private readonly HashSet _completedMovies = new(); private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset @@ -102,9 +102,6 @@ public sealed class GodotAdvHost : IHost if (scene != null) _timeline?.Event("script-context-exit", new() { ["scene"] = scene }); } - public long ResolveTextureResourceId(long resourceId) - => _res.ResolveTexture(CurrentScene, resourceId)?.RawIndex ?? resourceId; - public void ShowText(int offset, string text) { Captured.Add((offset, text)); @@ -301,13 +298,13 @@ public sealed class GodotAdvHost : IHost if (!_waitIndicators.TryGetValue(_activeWaitLayout, out config)) return null; if (!_surfaceResources.TryGetValue(config.SurfaceSlot, out resourceId)) return null; } - var asset = _res.ResolveRawTexture(resourceId); + var asset = _res.ResolveTexture(resourceId); var image = asset != null ? Decode(asset) : null; if (asset == null || image == null || config.CellWidth <= 0 || config.CellHeight <= 0) return null; int frames = System.Math.Max(1, config.TerminalFrame + 1); long period = System.Math.Max(1, config.FramePeriodMs); int frame = (int)((_clock.NowMs - _waitIndicatorStartedMs) / period % frames); - return new AdvWaitIndicatorSnapshot(image, asset.Name, asset.RawIndex, config, frame); + return new AdvWaitIndicatorSnapshot(image, asset.Name, asset.PackedId, config, frame); } public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race) @@ -811,7 +808,7 @@ public sealed class GodotAdvHost : IHost _surfaceText.Remove(slot); _surfaceResources[slot] = resourceId; } - var asset = _res.ResolveRawTexture(resourceId); + var asset = _res.ResolveTexture(resourceId); var image = asset != null ? Decode(asset) : null; _slotDims[slot] = image != null ? (image.Width, image.Height) : (0, 0); if (TraceOps) Godot.GD.Print($"[op] set-texture slot={slot} resId=0x{resourceId:x} -> {(asset?.Name ?? "")}"); @@ -868,22 +865,22 @@ public sealed class GodotAdvHost : IHost return RgbaSurfaceOps.WithColorKey(resolved.Value.Image, colorKey); } - /// Resolve a gfx surface through scene-local or universal raw-id addressing and decode it - /// from the loose-first asset store. + /// Resolve a gfx surface through universal packed addressing and decode it from the + /// loose-first asset store. public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId) { lock (_imageLock) { if (_movieFrames.TryGetValue(resId, out var movie)) - return (movie.Image, movie.Name, movie.RawIndex, true); + return (movie.Image, movie.Name, movie.AssetId, true); // Movie payloads use the same .AGF extension as still images. While DirectShow is opening // the graph (or before its first sample arrives), keep the already-created surface blank // instead of falling through to AgfDecoder and misclassifying the MPEG program stream. if (_movieBySurface.Values.Contains(resId)) return null; } - var asset = _res.ResolveRawTexture(resId); + var asset = _res.ResolveTexture(resId); var image = asset != null ? Decode(asset) : null; - return asset != null && image != null ? (image, asset.Name, asset.RawIndex, false) : null; + return asset != null && image != null ? (image, asset.Name, asset.PackedId, false) : null; } public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture( @@ -898,7 +895,7 @@ public sealed class GodotAdvHost : IHost public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) { string scene = CurrentScene; - var asset = _res.Resolve(scene, resourceId); + var asset = _res.ResolveMovie(resourceId); if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; } return StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false, out long? stopTimeMs) ? stopTimeMs : null; @@ -911,12 +908,12 @@ public sealed class GodotAdvHost : IHost && !_completedMovies.Contains(resourceId); } - public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) + public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags) { - var asset = _res.ResolveRawMovie(rawResourceId); + var asset = _res.ResolveMovie(resourceId); if (asset == null) { - Godot.GD.Print($"modal movie unresolved raw:0x{rawResourceId:x}"); + Godot.GD.Print($"modal movie unresolved packed:0x{resourceId:x}"); return; } @@ -924,23 +921,23 @@ public sealed class GodotAdvHost : IHost _modalMovieWaiting = true; try { - if (!StartMovie(asset, rawResourceId, surfaceSlot, movieFlags, 0, modal: true, + if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true, out _)) return; _timeline?.State("modal-movie-wait", new() { - ["resource"] = rawResourceId, ["surface"] = surfaceSlot, ["file"] = asset.Name, + ["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = asset.Name, }); while (!_stopping && !_modalMovieCancelled) { lock (_imageLock) - if (_completedMovies.Contains(rawResourceId)) break; + if (_completedMovies.Contains(resourceId)) break; _frameSignal.WaitOne(50); } // Cancellation is a completed modal presentation from the script's perspective. The // wrapper's following surface-release opcode performs the ordinary decoder teardown. if (_modalMovieCancelled) - lock (_imageLock) _completedMovies.Add(rawResourceId); + lock (_imageLock) _completedMovies.Add(resourceId); _timeline?.State("running", new() { ["modal_movie_complete"] = !_modalMovieCancelled, @@ -975,7 +972,7 @@ public sealed class GodotAdvHost : IHost ["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = movie.Name, ["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal, }); - return _main.TryPlayMovie(movie.Bytes, movie.Name, resourceId, asset.RawIndex, out stopTimeMs); + return _main.TryPlayMovie(movie.Bytes, movie.Name, resourceId, asset.PackedId, out stopTimeMs); } catch (System.Exception e) { @@ -1079,9 +1076,9 @@ public sealed class GodotAdvHost : IHost // Main-thread decoder handoff. Replacing the newest frame mirrors the native texture renderer's // sample callback: the retained object keeps its surface binding while only the surface pixels change. - public void PublishMovieFrame(long resourceId, string name, int rawIndex, RgbaImage frame) + public void PublishMovieFrame(long resourceId, string name, int assetId, RgbaImage frame) { - lock (_imageLock) _movieFrames[resourceId] = (frame, name, rawIndex); + lock (_imageLock) _movieFrames[resourceId] = (frame, name, assetId); System.Threading.Interlocked.Exchange(ref _presentRequested, 1); } @@ -1105,20 +1102,19 @@ public sealed class GodotAdvHost : IHost { lock (_imageLock) { - if (_images.TryGetValue(asset.RawIndex, out var cached)) return cached; - try { return _images[asset.RawIndex] = _res.DecodeTexture(asset); } + if (_images.TryGetValue(asset.PackedId, out var cached)) return cached; + try { return _images[asset.PackedId] = _res.DecodeTexture(asset); } catch (System.Exception e) { Godot.GD.Print($"AGF decode failed {asset.Name}: {e.Message}"); - _images[asset.RawIndex] = null; + _images[asset.PackedId] = null; return null; } } } // ---- audio ops (OGG plays natively in Godot) ---- - // BGM: addressed by direct name (BGM{id:D3}.OGG), NOT the manifest. Voice: SC-section first, - // then universal raw id for frontend scripts such as ROOM that do not own an SC section. + // BGM is addressed by direct name (BGM{id:D3}.OGG); voice uses the universal packed catalog. public void PlayBgm(long id) { var asset = _res.ResolveBgm(id); @@ -1131,7 +1127,7 @@ public sealed class GodotAdvHost : IHost public void PlayVoice(long id, int playbackVariant) { - var asset = _res.ResolveVoice(CurrentScene, id); + var asset = _res.ResolveVoice(id); var audio = asset != null ? LoadAudio(asset) : null; _timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant }); @@ -1172,7 +1168,7 @@ public sealed class GodotAdvHost : IHost public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs) { - var asset = _res.ResolveVoice(CurrentScene, id); + var asset = _res.ResolveVoice(id); var audio = asset != null ? LoadAudio(asset) : null; _timeline?.Event("voice-scheduled", new() { diff --git a/godot/Main.cs b/godot/Main.cs index 45177b9..3288b36 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -252,16 +252,16 @@ public partial class Main : Godot.Control // Seed that inherited retained-surface state without replaying the entrypoint's unrelated UI flow. if (directSceneHarness && resources.ResolveName("SO001.AGF") is { } systemChrome) { - _host.SetTexture(systemChrome.RawIndex, 0x11); - _vm.Gfx.SetSurface(0x11, systemChrome.RawIndex, 0); + _host.SetTexture(systemChrome.PackedId, 0x11); + _vm.Gfx.SetSurface(0x11, systemChrome.PackedId, 0); } // SYSTEM4 also loads SO000 and configures op 0x73 before entering scene code. The Phase-A // single-scene harness does not replay those graphics side effects, so inject their exact state // alongside the existing SO001 bootstrap until Phase B runs the complete SYSTEM4 entrypoint. if (directSceneHarness && resources.ResolveName("SO000.AGF") is { } waitIndicator) { - _host.SetTexture(waitIndicator.RawIndex, 0x0c); - _vm.Gfx.SetSurface(0x0c, waitIndicator.RawIndex, 0xff00); + _host.SetTexture(waitIndicator.PackedId, 0x0c); + _vm.Gfx.SetSurface(0x0c, waitIndicator.PackedId, 0xff00); _host.ConfigureAdvWaitIndicator(new AdvWaitIndicatorConfig( 1, 385, 140, 0x0c, 0, 0, 30, 27, 12, 48)); } @@ -1174,14 +1174,14 @@ public partial class Main : Godot.Control CreateTween().TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds); } - public bool TryPlayMovie(byte[] mpegBytes, string assetName, long resourceId, int rawIndex, + public bool TryPlayMovie(byte[] mpegBytes, string assetName, long resourceId, int assetId, out long? stopTimeMs) { stopTimeMs = null; try { var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes); - var runtime = new MovieRuntime(assetName, rawIndex, new DirectShowMovieDecoder(payload)); + var runtime = new MovieRuntime(assetName, assetId, new DirectShowMovieDecoder(payload)); stopTimeMs = runtime.Decoder.StopTimeMs; while (!_pendingMovies.TryAdd(resourceId, runtime)) if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose(); @@ -1213,7 +1213,7 @@ public partial class Main : Godot.Control { if (movie.Decoder.TryTakeFrame(out var frame)) { - _host.PublishMovieFrame(resourceId, movie.Name, movie.RawIndex, frame); + _host.PublishMovieFrame(resourceId, movie.Name, movie.AssetId, frame); if (_movieFrameSeen.Add(resourceId)) GD.Print($"movie first frame {movie.Name}: {frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}"); } @@ -1232,7 +1232,7 @@ public partial class Main : Godot.Control _movieFrameSeen.Remove(resourceId); } - private sealed record MovieRuntime(string Name, int RawIndex, DirectShowMovieDecoder Decoder); + private sealed record MovieRuntime(string Name, int AssetId, DirectShowMovieDecoder Decoder); public void AppendLine(string text) => _text.Text += text + "\n"; public void PageBreak() diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 7884480..e322e39 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -5196,7 +5196,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra /v2: op_0x20f_play_modal_movie_to_surface@0x422e50 shares the movie-object allocation, DirectShow graph open, audio-route, and volume setup used by 0x236, then calls movie_start_modal_playback@0x463280, ORs EngineCtx+0xa0ce4 with 0x2000, and marks movie presentation dirty. The main loop and window procedure special-case run-state 0x2000. Corpus has exactly three sites: LOGO (0x335f,42,4), OP (0x3364,42,4), and ED (0x3324,42,dynamic flags). Those ids are universal raw SYS4INI indexes for MPEG-pack LOGO.AGF, OP.AGF, and ED.AGF; each script releases its surface only after 0x20f resumes." -details = "Implemented through IHost.PlayModalMovieToSurface. Its operand uses the same native universal packed-id catalog contract as 0x236; the separate host call exists for modal wait/cancel lifecycle, not a different resolver. ResourceMap must retain MPEG signature validation in ReadMovie. Godot reuses the asynchronous decoder/retained-surface compositor, parks only the VM thread until EOF, and treats mouse click or Accept/Cancel input as completion before wrapper cleanup releases the decoder. MPEG audio remains a separate backend/audio-clock contract." +details = "Implemented through IHost.PlayModalMovieToSurface. Its operand uses the same native universal packed-id catalog contract as 0x236; the separate host call exists for modal wait/cancel lifecycle, not a different resolver. ResourceMap.ResolveMovie selects through ResolvePacked and retains MPEG signature validation in ReadMovie. Godot reuses the asynchronous decoder/retained-surface compositor, parks only the VM thread until EOF, and treats mouse click or Accept/Cancel input as completion before wrapper cleanup releases the decoder. MPEG audio remains a separate backend/audio-clock contract." [[opcode.semantics.args]] i = 1 @@ -6187,7 +6187,7 @@ source = "investigation" confidence = "high" depends_on = [] evidence = "Ghidra /v2 op_0x236_play_movie_to_surface@0x423ee0 fetches operand 1 and passes it unchanged to movie_to_texture_open_asset_graph@0x463e20, which passes it unchanged to asset_open_indexed_entry@0x44f390. The opener directly indexes the flat base table or selected AAI table and has no scene input. SC0000 native operand capture and exact 0x13c8->0x13d1 trace prove nonblocking behavior. BTL's live 0x2b21 site supplies 0x2af1/0x2af5/0x2bca/0x2bd8/0x2bde, the exact base entries MVB001/MVB004/MVB914/MVB958/MVB955." -details = "The handler requires an existing destination texture, allocates/reuses a 0x478-byte movie-to-texture object for the surface, opens operand 1 through the native universal packed-id reader, builds FilterGraph/IGraphBuilder/IMediaControl/IMediaPosition/IMediaEvent/IBasicAudio, and presents bottom-up RGB samples through the movie texture renderer. Operand 3 selects movie/sound routing policy: bits 0x10000/0x20000/0x40000/0x80000 force sound route 0/1/2/3, otherwise set:DependMovieSound is used; SC0000's low value 2 is retained as native movie mode state. Operand 4 is the movie sync/device mask. Static layer preparation after 0x236 does not terminate the retained movie; 0x21c services it through EOF and subsequent surface cleanup stops/detaches it. The port should type-check the selected packed record as MPEG but must not add a scene base or scene-first fallback." +details = "The handler requires an existing destination texture, allocates/reuses a 0x478-byte movie-to-texture object for the surface, opens operand 1 through the native universal packed-id reader, builds FilterGraph/IGraphBuilder/IMediaControl/IMediaPosition/IMediaEvent/IBasicAudio, and presents bottom-up RGB samples through the movie texture renderer. Operand 3 selects movie/sound routing policy: bits 0x10000/0x20000/0x40000/0x80000 force sound route 0/1/2/3, otherwise set:DependMovieSound is used; SC0000's low value 2 is retained as native movie mode state. Operand 4 is the movie sync/device mask. Static layer preparation after 0x236 does not terminate the retained movie; 0x21c services it through EOF and subsequent surface cleanup stops/detaches it. The port type-checks the selected ResolvePacked record as MPEG without adding a scene base or fallback." [[opcode.semantics.args]] i = 1