using System.Collections.Generic; using System.Diagnostics; using Age.Engine.Diagnostics; using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; using Age.Engine.Vm; using Xunit; public class MovieOpcodeTests { private sealed class FakeMovieDecoder : IMovieDecoder { public long? StopTimeMs { get; init; } public bool IsCompleted { get; set; } public string? Failure { get; set; } public bool Disposed { get; private set; } public RgbaImage? Frame { get; set; } public bool TryTakeFrame(out RgbaImage frame) { if (Frame == null) { frame = default!; return false; } frame = Frame; Frame = null; return true; } public void Dispose() => Disposed = true; } private sealed class FakeMovieDecoderFactory(FakeMovieDecoder decoder) : IMovieDecoderFactory { public MoviePayload? OpenedPayload { get; private set; } public IMovieDecoder Open(MoviePayload movie) { OpenedPayload = movie; return decoder; } } private sealed class FakeFfmpegFrameSource(FfmpegMovieInfo info, params FfmpegVideoFrame[] frames) : IFfmpegFrameSource { private readonly Queue _frames = new(frames); private readonly Queue _audio = new(); private int _decodeCalls; public FfmpegMovieInfo Info { get; } = info; public int FailOnDecodeCall { get; init; } = -1; public bool Disposed { get; private set; } public bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame) { if (_decodeCalls++ == FailOnDecodeCall) throw new InvalidDataException("synthetic decode failure"); return _frames.TryDequeue(out frame!); } public void EnqueueAudio(params FfmpegAudioChunk[] chunks) { foreach (FfmpegAudioChunk chunk in chunks) _audio.Enqueue(chunk); } public bool TryDecodeNextAudioChunk(out FfmpegAudioChunk chunk) => _audio.TryDequeue(out chunk!); public void Dispose() => Disposed = true; } private sealed class ManualMoviePacingClock : IMoviePacingClock, IDisposable { private readonly AutoResetEvent _advanced = new(false); private long _now; private long _waitingFor = -1; public bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation) { Interlocked.Exchange(ref _waitingFor, elapsedMilliseconds); while (Interlocked.Read(ref _now) < elapsedMilliseconds) { int signalled = WaitHandle.WaitAny(new[] { cancellation, _advanced }); if (signalled == 0) return false; } return true; } public void AdvanceTo(long elapsedMilliseconds) { Interlocked.Exchange(ref _now, elapsedMilliseconds); _advanced.Set(); } public bool WaitForDeadline(long elapsedMilliseconds) => SpinWait.SpinUntil(() => Interlocked.Read(ref _waitingFor) == elapsedMilliseconds, 1000); public void Dispose() => _advanced.Dispose(); } private static FfmpegVideoFrame SyntheticMovieFrame(byte value, long timestamp) => new(new RgbaImage(1, 1, new[] { value, value, value, (byte)255 }), timestamp); [Fact] public void MovieRuntimeUsesInjectedDecoderAndRetainsSynchronousMetadata() { var decoder = new FakeMovieDecoder { StopTimeMs = 1876, Frame = new RgbaImage(1, 1, new byte[] { 1, 2, 3, 4 }), }; var factory = new FakeMovieDecoderFactory(decoder); var payload = new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba }); var runtime = MovieRuntime.Open("TEST.AGF", 7, 0x123, payload, factory); Assert.Same(payload, factory.OpenedPayload); Assert.Same(decoder, runtime.Decoder); Assert.Equal(0x123, runtime.ResourceId); Assert.Equal(1876, runtime.Decoder.StopTimeMs); Assert.Equal(5000, runtime.WatchdogMs); Assert.True(runtime.Decoder.TryTakeFrame(out var frame)); Assert.Equal(new byte[] { 1, 2, 3, 4 }, frame.Pixels); runtime.Decoder.Dispose(); Assert.True(decoder.Disposed); } [Theory] [InlineData(null, 30000)] [InlineData(-1L, 30000L)] [InlineData(10000L, 12000L)] [InlineData(500000L, 300000L)] public void MovieRuntimeComputesBoundedWatchdogFromDecoderMetadata(long? stopTimeMs, long expected) { var decoder = new FakeMovieDecoder { StopTimeMs = stopTimeMs }; var runtime = MovieRuntime.Open("TEST.AGF", 7, 0x123, new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba }), new FakeMovieDecoderFactory(decoder)); Assert.Equal(expected, runtime.WatchdogMs); runtime.Decoder.Dispose(); } [Fact] public void FfmpegDecoderPublishesOnlyDueFramesAndCompletesAfterFinalInterval() { var source = new FakeFfmpegFrameSource( new FfmpegMovieInfo(1, 1, 100, 20, 1, false), SyntheticMovieFrame(1, 0), SyntheticMovieFrame(2, 50)); using var clock = new ManualMoviePacingClock(); using var decoder = new FfmpegMovieDecoder(source, clock); RgbaImage? first = null; Assert.True(SpinWait.SpinUntil(() => { if (!decoder.TryTakeFrame(out var frame)) return false; first = frame; return true; }, 1000)); Assert.Equal((byte)1, first!.Pixels[0]); Assert.True(clock.WaitForDeadline(50)); Assert.False(decoder.TryTakeFrame(out _)); clock.AdvanceTo(49); Assert.False(decoder.TryTakeFrame(out _)); clock.AdvanceTo(50); RgbaImage? second = null; Assert.True(SpinWait.SpinUntil(() => { if (!decoder.TryTakeFrame(out var frame)) return false; second = frame; return true; }, 1000)); Assert.Equal((byte)2, second!.Pixels[0]); Assert.True(clock.WaitForDeadline(100)); clock.AdvanceTo(99); Assert.False(decoder.IsCompleted); clock.AdvanceTo(100); Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 1000)); Assert.Null(decoder.Failure); } [Fact] public void FfmpegDecoderDisposalInterruptsFutureFrameWait() { var source = new FakeFfmpegFrameSource( new FfmpegMovieInfo(1, 1, 60040, 25, 1, false), SyntheticMovieFrame(1, 0), SyntheticMovieFrame(2, 60000)); using var clock = new ManualMoviePacingClock(); var decoder = new FfmpegMovieDecoder(source, clock); Assert.True(clock.WaitForDeadline(60000)); var elapsed = Stopwatch.StartNew(); decoder.Dispose(); Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(1)); Assert.True(source.Disposed); } [Fact] public void FfmpegDecoderFailureCompletesInsteadOfStrandingMovieWait() { var source = new FakeFfmpegFrameSource( new FfmpegMovieInfo(1, 1, 100, 20, 1, false), SyntheticMovieFrame(1, 0)) { FailOnDecodeCall = 1, }; using var clock = new ManualMoviePacingClock(); using var decoder = new FfmpegMovieDecoder(source, clock); Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 1000)); Assert.Equal("synthetic decode failure", decoder.Failure); } [Fact] public void FfmpegAudioDecoderQueuesTimestampedPcmAndCompletesAfterSubmissionAndClock() { var source = new FakeFfmpegFrameSource( new FfmpegMovieInfo(1, 1, 100, 20, 1, true, 1000, 2, 50), SyntheticMovieFrame(1, 0), SyntheticMovieFrame(2, 50)); source.EnqueueAudio( new FfmpegAudioChunk(new float[100], 50, 0), new FfmpegAudioChunk(Enumerable.Repeat(0.25f, 100).ToArray(), 50, 50)); using var clock = new ManualMoviePacingClock(); using var decoder = new FfmpegMovieDecoder(source, clock); var chunks = new List(); Assert.True(SpinWait.SpinUntil(() => { while (decoder.TryTakeAudioChunk(out var chunk)) chunks.Add(chunk); return decoder.AudioDecodingCompleted && chunks.Count == 2; }, 1000)); Assert.Equal(new long[] { 0, 50 }, chunks.Select(chunk => chunk.PresentationTimeMs)); Assert.Equal(100, chunks.Sum(chunk => chunk.FrameCount)); decoder.MarkAudioSubmitted(); clock.AdvanceTo(99); Assert.False(decoder.IsCompleted); clock.AdvanceTo(100); Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 1000)); Assert.Null(decoder.Failure); } [Fact] public void MovieAudioTimelineDoesNotSpliceContinuousMpegBlocksAtRoundedMillisecondTimestamps() { const int sampleRate = 44100; const int blockFrames = 1152; const int blockCount = 4093; // OP.AGF's installed MPEG audio block count long submittedFrames = 0; bool anchored = false; for (int block = 0; block < blockCount; block++) { long exactStartFrame = block * (long)blockFrames; long roundedDownTimestampMs = exactStartFrame * 1000 / sampleRate; long targetFrame = MovieAudioTimeline.PresentationFrame( roundedDownTimestampMs, sampleRate); MovieAudioAdjustment adjustment = MovieAudioTimeline.Align( submittedFrames, targetFrame, blockFrames, sampleRate, anchored); Assert.Equal(default, adjustment); submittedFrames += blockFrames; anchored = true; } Assert.Equal(blockCount * (long)blockFrames, submittedFrames); } [Fact] public void MovieAudioTimelineStillPreservesMaterialGapsAndOverlaps() { const int sampleRate = 44100; var initialGap = MovieAudioTimeline.Align( submittedFrames: 0, targetFrame: MovieAudioTimeline.PresentationFrame(100, sampleRate), chunkFrames: 1152, sampleRate: sampleRate, timelineAnchored: false); Assert.Equal(new MovieAudioAdjustment(4410, 0), initialGap); var laterGap = MovieAudioTimeline.Align( submittedFrames: 2304, targetFrame: MovieAudioTimeline.PresentationFrame(100, sampleRate), chunkFrames: 1152, sampleRate: sampleRate, timelineAnchored: true); Assert.Equal(new MovieAudioAdjustment(2106, 0), laterGap); var overlap = MovieAudioTimeline.Align( submittedFrames: 2304, targetFrame: MovieAudioTimeline.PresentationFrame(40, sampleRate), chunkFrames: 1152, sampleRate: sampleRate, timelineAnchored: true); Assert.Equal(new MovieAudioAdjustment(0, 540), overlap); } [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()), }, System.Array.Empty()); var reloadedRoot = ScriptAssembler.Assemble(table, "SYSTEM4", new List<(int, Operand[])> { (0x130, new[] { new Operand(3, 0x101) }), (0x2, System.Array.Empty()), }, System.Array.Empty()); var host = new RecordingHost(); var vm = new VirtualMachine(root, table, host, provider: new MapProvider(new Dictionary { [0] = reloadedRoot })); vm.Run(); Assert.Equal(1, vm.Globals[0x100]); Assert.Equal(0, vm.Globals[0x101]); Assert.Equal(1, host.SceneContextResets); } [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()), }, System.Array.Empty()); 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() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var provider = Sys4ScriptProvider.Load(table); var session = new GameSession(); foreach (var name in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" }) session.RunScene(Sys4Loader.Load(Paths.Scripts()[name], table), table, new CaptureHost(), provider: provider); var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); var host = new RecordingHost(); var trace = new RecordingTraceSink { TracingSteps = true }; var vm = new VirtualMachine(script, table, host, new VmOptions(MaxSteps: 20_000_000), provider, trace); foreach (var kv in session.Globals) vm.Globals[kv.Key] = kv.Value; foreach (var kv in session.GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; vm.Run(); int movieStep = trace.Events.FindIndex(e => e.Kind == TraceEventKind.Step && e.Opcode == 0x236); Assert.True(movieStep >= 0); var nextStep = trace.Events.Skip(movieStep + 1).First(e => e.Kind == TraceEventKind.Step); Assert.Equal(0x13c8, trace.Events[movieStep].Ins!.Offset); Assert.Equal(0x13d1, nextStep.Ins!.Offset); Assert.Contains((0x33L, 0, 2L, 0L), host.Movies); } [Fact] public void PlayMovieDispatchesExactOperandsAndResumesAtNextInstruction() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "MOVIE", new List<(int, Operand[])> { (0x236, new[] { new Operand(0, 0x33), new Operand(0, 5), new Operand(0, 2), new Operand(0, 0) }), (0x55, new[] { new Operand(3, 0x1234), new Operand(0, 0x5678) }), (0x2, System.Array.Empty()), }, System.Array.Empty()); var host = new RecordingHost(); var vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal("exit", vm.HaltReason); Assert.Equal(0x5678, vm.Globals[0x1234]); Assert.Equal(new[] { (0x33L, 5, 2L, 0L) }, host.Movies); vm.Gfx.BindDraw(1, 5, 0, 0, 1, 1, 0, 0); Assert.Equal(0x33, vm.Gfx.SnapshotVisibleObjects().Single().SurfaceResId); } [Fact] public void PlayMovieKeepsCreatedSurfaceBlankDuringSynchronousHostSetup() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "MOVIE-PREROLL", new List<(int, Operand[])> { (0x1f8, new[] { new Operand(0, 5), new Operand(0, 800), new Operand(0, 600), new Operand(0, 0) }), (0x1fb, new[] { new Operand(0, 1), new Operand(0, 5), new Operand(0, 0), new Operand(0, 0), new Operand(0, 800), new Operand(0, 600), new Operand(0, 0), new Operand(0, 0), }), (0x236, new[] { new Operand(0, 0x33), new Operand(0, 5), new Operand(0, 2), new Operand(0, 0) }), (0x2, System.Array.Empty()), }, System.Array.Empty()); var host = new RecordingHost(); VirtualMachine? vm = null; long resourceDuringSetup = -1; host.OnPlayMovie = () => resourceDuringSetup = vm!.Gfx.SnapshotVisibleObjects().Single().SurfaceResId; vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal(0, resourceDuringSetup); Assert.Equal(0x33, vm.Gfx.SnapshotVisibleObjects().Single().SurfaceResId); } [Fact] public void QueryMovieStopTimeReturnsTheValueRetainedByPlayMovie() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "MOVIE-TIME", new List<(int, Operand[])> { (0x236, new[] { new Operand(0, 0x33), new Operand(0, 5), new Operand(0, 2), new Operand(0, 0) }), (0x23f, new[] { new Operand(3, 0x1234), new Operand(0, 5) }), (0x2, System.Array.Empty()), }, System.Array.Empty()); var host = new RecordingHost { MovieStopTimeMs = 1876 }; var vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal(1876, vm.Globals[0x1234]); Assert.True(vm.Gfx.TryGetMovieStopTime(5, out long? retained)); Assert.Equal(1876, retained); Assert.Empty(host.Warnings); vm.Gfx.SetSurface(5, 0x34, 0); // replacing the native surface tears down its movie metadata Assert.False(vm.Gfx.TryGetMovieStopTime(5, out _)); } [Fact] public void QueryMovieStopTimeReturnsMinusOneWithoutWarningForAnEmptyMovieSlot() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "EMPTY-MOVIE-TIME", new List<(int, Operand[])> { (0x23f, new[] { new Operand(3, 0x1234), new Operand(0, 5) }), (0x2, System.Array.Empty()), }, System.Array.Empty()); var host = new RecordingHost(); var vm = new VirtualMachine(script, table, host); vm.Gfx.GetOrCreate(5); // retained-object existence is unrelated to the native movie-object slot vm.Run(); Assert.Equal(-1, vm.Globals[0x1234]); Assert.Empty(host.Warnings); } [Fact] public void LoadedMovieWithoutBackendMetadataUsesImmediateZeroDuration() { var table = OpcodeTableJson.Load(Paths.OpcodesJson); var script = ScriptAssembler.Assemble(table, "MISSING-MOVIE-TIME", new List<(int, Operand[])> { (0x236, new[] { new Operand(0, 0x33), new Operand(0, 5), new Operand(0, 2), new Operand(0, 0) }), (0x23f, new[] { new Operand(3, 0x1234), new Operand(0, 5) }), (0x23a, new[] { new Operand(3, 0x1235), new Operand(0, 5) }), (0x2, System.Array.Empty()), }, System.Array.Empty()); var host = new RecordingHost(); var vm = new VirtualMachine(script, table, host); vm.Run(); Assert.Equal(0, vm.Globals[0x1234]); Assert.True(vm.Gfx.TryGetMovieStopTime(5, out long? retained)); Assert.Equal(0, retained); Assert.Equal(0, vm.Globals[0x1235]); Assert.Empty(host.Warnings); } [Fact] public void Sc0000MoviePayloadReadsFromArchiveVfsAndIsMpegProgramStream() { var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); var entry = resources.ResolveMovie(0x33); Assert.Equal("CHAPTER.AGF", entry?.Name); var movie = resources.ReadMovie(entry!); Assert.Equal(new byte[] { 0, 0, 1, 0xba }, movie.Bytes[..4]); Assert.Equal(8_194_052, movie.Bytes.Length); } [Theory] [InlineData(0x335f, "LOGO.AGF")] [InlineData(0x3364, "OP.AGF")] 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.ResolveMovie(resourceId); Assert.Equal(expectedName, entry?.Name); var movie = resources.ReadMovie(entry!); Assert.Equal(new byte[] { 0, 0, 1, 0xba }, movie.Bytes[..4]); } [Theory] [InlineData(0x2be3, "MVB961.AGF", 280, 352, 500)] [InlineData(0x2b94, "MVB238.AGF", 280, 352, 866)] [InlineData(0x2bc2, "MVB908.AGF", 400, 400, 333)] [InlineData(0x33, "CHAPTER.AGF", 800, 600, 12016)] public void FfmpegShimDecodesRepresentativeVfsMovie(int resourceId, string expectedName, int expectedWidth, int expectedHeight, long expectedStopTimeMs) { if (!OperatingSystem.IsWindows()) return; ConfigureFfmpegNativeProbe(); var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); var payload = resources.ReadMovie(resources.ResolveMovie(resourceId)!); using var movie = new FfmpegMovieSession(payload); Assert.Equal(expectedName, payload.Name); Assert.Equal(expectedWidth, movie.Info.Width); Assert.Equal(expectedHeight, movie.Info.Height); Assert.Equal(expectedStopTimeMs, movie.Info.StopTimeMs); Assert.True(movie.TryDecodeNextVideoFrame(out var first)); Assert.Equal(expectedWidth * expectedHeight * 4, first.Image.Pixels.Length); Assert.True(first.PresentationTimeMs >= 0); bool changed = false; long priorTimestamp = first.PresentationTimeMs; for (int frameIndex = 0; frameIndex < 30 && movie.TryDecodeNextVideoFrame(out var later); frameIndex++) { Assert.True(later.PresentationTimeMs >= priorTimestamp); priorTimestamp = later.PresentationTimeMs; if (!first.Image.Pixels.AsSpan().SequenceEqual(later.Image.Pixels)) { changed = true; break; } } Assert.True(changed, $"{expectedName} should deliver changing decoded frames"); } [Theory] [InlineData(0x33, "CHAPTER.AGF")] [InlineData(0x2bf1, "MVS001.AGF")] [InlineData(0x335f, "LOGO.AGF")] public void FfmpegShimDecodesRepresentativeMpegAudio(int resourceId, string expectedName) { if (!OperatingSystem.IsWindows()) return; ConfigureFfmpegNativeProbe(); var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); var payload = resources.ReadMovie(resources.ResolveMovie(resourceId)!); using var movie = new FfmpegMovieSession(payload); Assert.Equal(expectedName, payload.Name); Assert.True(movie.Info.HasAudio); Assert.Equal(44100, movie.Info.AudioSampleRate); Assert.Equal(2, movie.Info.AudioChannels); Assert.True(movie.TryDecodeNextAudioChunk(out var first)); Assert.True(first.FrameCount > 0); Assert.Equal(first.FrameCount * 2, first.InterleavedStereo.Length); Assert.True(first.PresentationTimeMs >= 0); Assert.All(first.InterleavedStereo, sample => Assert.True(float.IsFinite(sample))); bool heardSignal = first.InterleavedStereo.Any(sample => Math.Abs(sample) > 0.00001f); long priorTimestamp = first.PresentationTimeMs; for (int block = 0; block < 500 && !heardSignal && movie.TryDecodeNextAudioChunk(out var later); block++) { Assert.True(later.PresentationTimeMs >= priorTimestamp); Assert.All(later.InterleavedStereo, sample => Assert.True(float.IsFinite(sample))); priorTimestamp = later.PresentationTimeMs; heardSignal = later.InterleavedStereo.Any(sample => Math.Abs(sample) > 0.00001f); } Assert.True(heardSignal, $"{expectedName} should contain non-silent MPEG audio"); } [Fact] public void FfmpegShimRejectsTruncatedMovieWithBoundedDiagnostic() { if (!OperatingSystem.IsWindows()) return; ConfigureFfmpegNativeProbe(); var payload = new MoviePayload("TRUNCATED.AGF", new byte[] { 0, 0, 1, 0xba, 0x21, 0, 1, 0 }); var error = Assert.Throws(() => new FfmpegMovieSession(payload)); Assert.Contains("TRUNCATED.AGF", error.Message); Assert.Contains("FFmpeg", error.Message); } [Fact] public void FfmpegShimSupportsRepeatedOpenAndClose() { if (!OperatingSystem.IsWindows()) return; ConfigureFfmpegNativeProbe(); var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); var payload = resources.ReadMovie(resources.ResolveMovie(0x2bc2)!); for (int iteration = 0; iteration < 10; iteration++) { using var movie = new FfmpegMovieSession(payload); Assert.True(movie.TryDecodeNextVideoFrame(out _)); } } [Theory] [InlineData(0x2be3, 280, 500, 450)] [InlineData(0x2bc2, 400, 333, 300)] public void FfmpegPacedDecoderKeepsRealMovieAliveThroughItsStopTime( int resourceId, int expectedWidth, long expectedStopTimeMs, long minimumElapsedMs) { if (!OperatingSystem.IsWindows()) return; ConfigureFfmpegNativeProbe(); var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); var payload = resources.ReadMovie(resources.ResolveMovie(resourceId)!); var elapsed = Stopwatch.StartNew(); using var decoder = new FfmpegMovieDecoder(payload); RgbaImage? first = null; Assert.True(SpinWait.SpinUntil(() => { if (!decoder.TryTakeFrame(out var frame)) return false; first = frame; return true; }, 2000)); Assert.Equal(expectedWidth, first!.Width); Assert.Equal(expectedStopTimeMs, decoder.StopTimeMs); Assert.False(decoder.IsCompleted); Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 2000)); Assert.True(elapsed.ElapsedMilliseconds >= minimumElapsedMs, $"{expectedStopTimeMs} ms movie completed after only {elapsed.ElapsedMilliseconds} ms"); Assert.Null(decoder.Failure); Assert.True(decoder.TryTakeFrame(out var final)); Assert.Equal(expectedWidth, final.Width); } private static void ConfigureFfmpegNativeProbe() { string nativeDirectory = Environment.GetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR") ?? Path.Combine(Paths.Build, "native", "win-x64"); Assert.True(File.Exists(Path.Combine(nativeDirectory, "age_movie_ffmpeg.dll")), $"Build the FFmpeg shim first: native/age_movie_ffmpeg/build-win64.ps1 (expected {nativeDirectory})"); Environment.SetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR", nativeDirectory); } }