Implement modal startup movies

This commit is contained in:
gamer147
2026-07-20 20:23:15 -04:00
parent 831475217d
commit df17747f63
16 changed files with 293 additions and 49 deletions

View File

@@ -8,6 +8,46 @@ using Xunit;
public class MovieOpcodeTests
{
[Fact]
public void InitialRootFlagStartsSetAndClearsWhenExitScriptRuns()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var root = ScriptAssembler.Assemble(table, "ROOT", new List<(int, Operand[])>
{
(0x130, new[] { new Operand(3, 0x100) }),
(0x9, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(root, table, new RecordingHost());
vm.Run();
Assert.Equal(1, vm.Globals[0x100]);
vm.Globals[0x100] = -1;
vm.Run();
Assert.Equal(0, vm.Globals[0x100]);
}
[Fact]
public void ModalMovieDispatchesRawResourceSurfaceAndFlags()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = ScriptAssembler.Assemble(table, "MODAL", new List<(int, Operand[])>
{
(0x20f, new[] { new Operand(0, 0x335f), new Operand(0, 42), new Operand(0, 4) }),
(0x55, new[] { new Operand(3, 0x1234), new Operand(0, 0x5678) }),
(0x2, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var host = new RecordingHost();
var vm = new VirtualMachine(script, table, host);
vm.Run();
Assert.Equal(new[] { (0x335fL, 42, 4L) }, host.ModalMovies);
Assert.Equal(0x5678, vm.Globals[0x1234]);
vm.Gfx.BindDraw(1, 42, 0, 0, 1, 1, 0, 0);
Assert.Equal(0x335f, vm.Gfx.SnapshotVisibleObjects().Single().SurfaceResId);
}
[Fact]
public void Sc0000ResumesImmediatelyAfterMovieOpcodeAtBytecodeOffset13d1()
{
@@ -68,6 +108,20 @@ public class MovieOpcodeTests
Assert.Equal(8_194_052, movie.Bytes.Length);
}
[Theory]
[InlineData(0x335f, "LOGO.AGF")]
[InlineData(0x3364, "OP.AGF")]
public void ModalMoviePayloadResolvesFromUniversalRawCatalog(int rawId, string expectedName)
{
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir));
var entry = resources.ResolveRawMovie(rawId);
Assert.Equal(expectedName, entry?.Name);
var movie = resources.ReadMovie(entry!);
Assert.Equal(new byte[] { 0, 0, 1, 0xba }, movie.Bytes[..4]);
}
[Fact]
public void Sc0000MoviePayloadDecodesAn800By600FrameOnWindows()
{

View File

@@ -99,6 +99,13 @@ public class NaturalBootIntegrationTests
{
"SYSTEM4.BIN", "INITCONFIG.BIN", "INIT2.BIN",
}, sink.Entered.Take(3));
int logo = sink.Entered.IndexOf("LOGO.BIN");
int opening = sink.Entered.IndexOf("OP.BIN");
int init = sink.Entered.IndexOf("INIT.BIN");
int title = sink.Entered.IndexOf("TITLE.BIN");
Assert.True(logo >= 0 && logo < opening && opening < init && init < title,
$"entered={string.Join(",", sink.Entered)}");
Assert.Equal(new[] { (0x335fL, 42, 4L), (0x3364L, 42, 4L) }, host.ModalMovies);
Assert.Contains("TITLE.BIN", sink.Entered);
Assert.Contains("GAMESTART.BIN", sink.Entered);
Assert.Contains("UNITECH.BIN", sink.Entered);

View File

@@ -36,6 +36,7 @@ internal class RecordingHost : IHost
public readonly List<int> SfxReleases = new();
public readonly List<(int Target, long Duration)> BgmFades = new();
public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new();
public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new();
public readonly List<int> ClearedRenderTargets = new();
public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new();
public readonly List<bool> MessageSkipChanges = new();
@@ -128,6 +129,8 @@ internal class RecordingHost : IHost
public void FadeBgm(int targetPercent, long durationMs) => BgmFades.Add((targetPercent, durationMs));
public void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
=> Movies.Add((resourceId, surfaceSlot, movieFlags, syncMask));
public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags)
=> ModalMovies.Add((rawResourceId, surfaceSlot, movieFlags));
}
internal sealed class MapProvider : IScriptProvider

View File

@@ -103,4 +103,8 @@ public interface IHost
// Native op 0x236 binds a DirectShow movie decoder to an existing retained texture surface.
// Playback is non-modal: the VM advances to the following instruction while the host publishes frames.
void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) { }
// Native op 0x20f uses a universal raw-catalog 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) { }
}

View File

@@ -41,6 +41,16 @@ public sealed class ResourceMap
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);
return entry is { IsPlaceholder: false } &&
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>Decode an AGF directly from loose-first VFS bytes.</summary>
public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry);

View File

@@ -35,6 +35,7 @@ public sealed class VirtualMachine
private long _autoMessageTime0Ms = 500;
private long _autoMessageTime1Ms = 2000;
private bool _autoVoicePending;
private bool _initialRootRun = true;
private volatile bool _messageSkipEnabled;
private volatile bool _messageSkipServiceActive;
private AdvTextStyle _advTextStyle = AdvTextStyle.Default;
@@ -590,8 +591,13 @@ public sealed class VirtualMachine
Write(a[0], visits == 0 ? (terminal == 0 ? 1 : 0) : terminal);
return pc + 1;
}
case "exit":
case "exit-script": return FRAME_RETURN;
case "exit": return FRAME_RETURN;
case "exit-script":
// Native op 0x9 clears the process-initial root flag before returning control to
// the root-script loader. Root reload itself remains represented by the port's
// existing frame/session boundary; retaining the flag here prevents LOGO/OP replay.
_initialRootRun = false;
return FRAME_RETURN;
case "call-script":
{
long id = a.Count > 0 ? Read(a[0]) : 0;
@@ -1028,6 +1034,19 @@ public sealed class VirtualMachine
_host.FadeBgm((int)Read(a[0]), Read(a[1])); return pc + 1;
case "u00415880": // 0xd9 / semantics: clear-run-state-0x1000
return pc + 1;
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)
{
long rawResourceId = 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]));
return pc + 1;
}
case "u004221A0": // pre-reference compatibility
case "play-movie-to-surface": // 0x236 (resource)(surface)(movie flags)(sync mask)
{