Use universal packed resource resolution

This commit is contained in:
gamer147
2026-07-21 20:23:32 -04:00
parent 625c95a2d4
commit 88f2f65bda
19 changed files with 161 additions and 154 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <missing>"));
}
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 ? "<unresolved>" : $"{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<int, string?> _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<int, (int W, int H)> _slotDims = new() { { 0, (800, 600) } };
public List<string> 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);

View File

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

View File

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

View File

@@ -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<string>());
var host = new RecordingHost { TextureResourceIdOffset = 0x1000 };
var host = new RecordingHost();
var vm = new VirtualMachine(scene, t, host);
vm.Run();

View File

@@ -342,7 +342,7 @@ public class HistoryInteractionOpsTests
Assert.Equal(new[] { (expectedVoice, checked((int)expectedVariant)) }, host.VoiceRequests);
var resources = ResourceMap.Load();
var voice = Assert.IsType<AssetEntry>(resources.Resolve("SC0000", expectedVoice));
var voice = Assert.IsType<AssetEntry>(resources.ResolveVoice(expectedVoice));
Assert.Equal("MAN999.OGG", voice.Name);
Assert.NotEmpty(resources.ReadAudio(voice).Bytes);
}

View File

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

View File

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

View File

@@ -55,9 +55,7 @@ internal class RecordingHost : IHost
public readonly List<bool> 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

View File

@@ -21,11 +21,9 @@ public interface IHost
{
/// <summary>Report a recoverable runtime discrepancy while allowing script execution to continue.</summary>
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.</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) { }
}

View File

@@ -1,8 +1,8 @@
namespace Age.Engine.Sys4;
/// <summary>
/// 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.
/// </summary>
public sealed class ResourceMap
@@ -18,43 +18,26 @@ public sealed class ResourceMap
public static ResourceMap Load() => new(Sys4AssetCatalog.Load(Paths.Sys4Ini));
/// <summary>Resolve a scene-local resId to its asset, or null if out of range / unknown scene.</summary>
public AssetEntry? Resolve(string scene, long resId)
/// <summary>Resolve a universal packed SYS4INI/AAI id to an AGF texture record.</summary>
public AssetEntry? ResolveTexture(long resourceId)
{
return _catalog.ResolveScene(scene, resId);
}
/// <summary>Resolve graphics normally through the scene manifest, with the universal raw-id
/// fallback used by SYSTEM4-owned assets such as SO001.</summary>
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;
}
/// <summary>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.</summary>
public AssetEntry? ResolveVoice(string scene, long resId)
/// <summary>Resolve a universal packed SYS4INI/AAI id to a voice audio record.</summary>
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;
}
/// <summary>Resolve an already-normalized packed catalog id without applying a scene section base.</summary>
public AssetEntry? ResolveRawTexture(long rawId)
/// <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(rawId);
return entry is { IsPlaceholder: false } &&
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>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.</summary>
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
/// <summary>
/// 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.
/// </summary>

View File

@@ -14,7 +14,11 @@ public sealed record AssetEntry(
int ArchiveId = -1,
int FileNumber = -1,
bool IsPlaceholder = false,
int PackId = 0);
int PackId = 0)
{
/// <summary>The exact packed SYS4INI/AAI id AGE uses to address this record.</summary>
public int PackedId => checked((PackId << 24) | RawIndex);
}
/// <summary>A real catalog entry paired with the packed resource id AGE uses at runtime.</summary>
public sealed record PackedAssetEntry(long PackedId, AssetEntry Asset);

View File

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

View File

@@ -16,13 +16,13 @@ public sealed class GodotAdvHost : IHost
private readonly object _scriptContextLock = new();
private readonly Stack<string> _scriptContexts = new();
private readonly object _imageLock = new();
private readonly Dictionary<int, RgbaImage?> _images = new(); // raw catalog id -> decoded pixels
private readonly Dictionary<int, RgbaImage?> _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<int, RgbaImage> _surfaceImages = new();
private readonly Dictionary<int, long> _surfaceColorKeys = new();
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> normalized raw catalog id
private readonly Dictionary<long, (RgbaImage Image, string Name, int RawIndex)> _movieFrames = new();
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id
private readonly Dictionary<long, (RgbaImage Image, string Name, int AssetId)> _movieFrames = new();
private readonly Dictionary<int, long> _movieBySurface = new();
private readonly HashSet<long> _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 ?? "<none>")}");
@@ -868,22 +865,22 @@ public sealed class GodotAdvHost : IHost
return RgbaSurfaceOps.WithColorKey(resolved.Value.Image, colorKey);
}
/// <summary>Resolve a gfx surface through scene-local or universal raw-id addressing and decode it
/// from the loose-first asset store.</summary>
/// <summary>Resolve a gfx surface through universal packed addressing and decode it from the
/// loose-first asset store.</summary>
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()
{

View File

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

View File

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