Implement positioned movie playback opcode

This commit is contained in:
gamer147
2026-07-29 15:41:01 -04:00
parent 71ae1b7c5b
commit 12af952b77
18 changed files with 523 additions and 46 deletions

View File

@@ -12,6 +12,7 @@ public class MovieOpcodeTests
private sealed class FakeMovieDecoder : IMovieDecoder
{
public long? StopTimeMs { get; init; }
public long InitialPositionMs { get; init; }
public bool IsCompleted { get; set; }
public string? Failure { get; set; }
public bool Disposed { get; private set; }
@@ -31,9 +32,11 @@ public class MovieOpcodeTests
private sealed class FakeMovieDecoderFactory(FakeMovieDecoder decoder) : IMovieDecoderFactory
{
public MoviePayload? OpenedPayload { get; private set; }
public IMovieDecoder Open(MoviePayload movie)
public long OpenedInitialPositionMs { get; private set; }
public IMovieDecoder Open(MoviePayload movie, long initialPositionMs = 0)
{
OpenedPayload = movie;
OpenedInitialPositionMs = initialPositionMs;
return decoder;
}
}
@@ -47,8 +50,11 @@ public class MovieOpcodeTests
public FfmpegMovieInfo Info { get; } = info;
public int FailOnDecodeCall { get; init; } = -1;
public int DecodeCalls => Volatile.Read(ref _decodeCalls);
public long? SeekPositionMs { get; private set; }
public bool Disposed { get; private set; }
public void Seek(long positionMs) => SeekPositionMs = positionMs;
public bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame)
{
int call = Interlocked.Increment(ref _decodeCalls) - 1;
@@ -112,8 +118,10 @@ public class MovieOpcodeTests
var runtime = MovieRuntime.Open("TEST.AGF", 7, 0x123, payload, factory);
Assert.Same(payload, factory.OpenedPayload);
Assert.Equal(0, factory.OpenedInitialPositionMs);
Assert.Same(decoder, runtime.Decoder);
Assert.Equal(0x123, runtime.ResourceId);
Assert.Equal(0, runtime.InitialPositionMs);
Assert.Equal(1876, runtime.Decoder.StopTimeMs);
Assert.Equal(5000, runtime.WatchdogMs);
Assert.True(runtime.Decoder.TryTakeFrame(out var frame));
@@ -122,6 +130,22 @@ public class MovieOpcodeTests
Assert.True(decoder.Disposed);
}
[Fact]
public void MovieRuntimePassesInitialPositionAndBasesWatchdogOnRemainingDuration()
{
var decoder = new FakeMovieDecoder { StopTimeMs = 10000, InitialPositionMs = 9000 };
var factory = new FakeMovieDecoderFactory(decoder);
var runtime = MovieRuntime.Open("TEST.AGF", 7, 0x123,
new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba }),
factory, initialPositionMs: 9000);
Assert.Equal(9000, factory.OpenedInitialPositionMs);
Assert.Equal(9000, runtime.InitialPositionMs);
Assert.Equal(5000, runtime.WatchdogMs);
runtime.Decoder.Dispose();
}
[Theory]
[InlineData(null, 30000)]
[InlineData(-1L, 30000L)]
@@ -237,6 +261,106 @@ public class MovieOpcodeTests
Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 1000));
}
[Fact]
public void FfmpegPositionedDecoderPublishesFrameActiveAtSeekThenContinuesPacing()
{
var source = new FakeFfmpegFrameSource(
new FfmpegMovieInfo(1, 1, 120, 25, 1, false),
SyntheticMovieFrame(1, 0),
SyntheticMovieFrame(2, 40),
SyntheticMovieFrame(3, 80));
using var clock = new ManualMoviePacingClock();
using var decoder = new FfmpegMovieDecoder(source, clock, initialPositionMs: 60);
Assert.Equal(60, source.SeekPositionMs);
Assert.Equal(60, decoder.InitialPositionMs);
Assert.True(SpinWait.SpinUntil(() => decoder.TryTakeFrame(out _), 1000));
Assert.Equal(40, decoder.FirstFramePresentationTimeMs);
Assert.True(clock.WaitForDeadline(40));
clock.AdvanceTo(39);
Assert.False(decoder.TryTakeFrame(out _));
clock.AdvanceTo(40);
RgbaImage? next = null;
Assert.True(SpinWait.SpinUntil(() =>
{
if (!decoder.TryTakeFrame(out var frame)) return false;
next = frame;
return true;
}, 1000));
Assert.Equal((byte)3, next!.Pixels[0]);
}
[Fact]
public void FfmpegPositionedDecoderRetainsTerminalFrameAtStopTimeMinusOne()
{
var source = new FakeFfmpegFrameSource(
new FfmpegMovieInfo(1, 1, 120, 25, 1, false),
SyntheticMovieFrame(1, 0),
SyntheticMovieFrame(2, 40),
SyntheticMovieFrame(3, 80));
using var clock = new ManualMoviePacingClock();
using var decoder = new FfmpegMovieDecoder(source, clock, initialPositionMs: 119);
RgbaImage? terminal = null;
Assert.True(SpinWait.SpinUntil(() =>
{
if (!decoder.TryTakeFrame(out var frame)) return false;
terminal = frame;
return true;
}, 1000));
Assert.Equal((byte)3, terminal!.Pixels[0]);
Assert.Equal(80, decoder.FirstFramePresentationTimeMs);
Assert.True(clock.WaitForDeadline(40));
}
[Fact]
public void FfmpegPositionedDecoderClampsInitialPositionToGraphBounds()
{
var belowStart = new FakeFfmpegFrameSource(
new FfmpegMovieInfo(1, 1, 120, 25, 1, false),
SyntheticMovieFrame(1, 0));
using (var decoder = new FfmpegMovieDecoder(belowStart, null, initialPositionMs: -50))
{
Assert.Equal(0, decoder.InitialPositionMs);
Assert.Null(belowStart.SeekPositionMs);
}
var beyondStop = new FakeFfmpegFrameSource(
new FfmpegMovieInfo(1, 1, 120, 25, 1, false),
SyntheticMovieFrame(1, 80));
using (var decoder = new FfmpegMovieDecoder(beyondStop, null, initialPositionMs: 500))
{
Assert.Equal(119, decoder.InitialPositionMs);
Assert.Equal(119, beyondStop.SeekPositionMs);
}
}
[Fact]
public void FfmpegPositionedDecoderTrimsAndRebasesAudioAtTheSamePosition()
{
var source = new FakeFfmpegFrameSource(
new FfmpegMovieInfo(1, 1, 150, 10, 1, true, 1000, 2, 50),
SyntheticMovieFrame(1, 0), SyntheticMovieFrame(2, 100));
source.EnqueueAudio(
new FfmpegAudioChunk(Enumerable.Range(0, 100).Select(value => (float)value).ToArray(),
50, 50),
new FfmpegAudioChunk(Enumerable.Repeat(0.25f, 100).ToArray(), 50, 100));
using var clock = new ManualMoviePacingClock();
using var decoder = new FfmpegMovieDecoder(source, clock, initialPositionMs: 75);
var chunks = new List<MovieAudioChunk>();
Assert.True(SpinWait.SpinUntil(() =>
{
while (decoder.TryTakeAudioChunk(out var chunk)) chunks.Add(chunk);
return decoder.AudioDecodingCompleted && chunks.Count == 2;
}, 1000));
Assert.Equal(new[] { 25, 50 }, chunks.Select(chunk => chunk.FrameCount));
Assert.Equal(new long[] { 0, 25 }, chunks.Select(chunk => chunk.PresentationTimeMs));
Assert.Equal(50f, chunks[0].InterleavedStereo[0]);
}
[Fact]
public void FfmpegDecoderDisposalInterruptsFutureFrameWait()
{
@@ -454,6 +578,33 @@ public class MovieOpcodeTests
Assert.Equal(-1, visible.ColorKey);
}
[Fact]
public void PositionedMovieDispatchesExactOperandsAndRetainsGraphStopTime()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = ScriptAssembler.Assemble(table, "POSITIONED-MOVIE",
[
(0x241,
[
new Operand(0, 0x33), new Operand(0, 5), new Operand(0, 2),
new Operand(0, 0), new Operand(0, 1875),
]),
(0x23f, [new Operand(3, 0x1234), new Operand(0, 5)]),
(0x55, [new Operand(3, 0x1235), new Operand(0, 0x5678)]),
(0x2, Array.Empty<Operand>()),
], []);
var host = new RecordingHost { MovieStopTimeMs = 1876 };
var vm = new VirtualMachine(script, table, host);
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(new[] { (0x33L, 5, 2L, 0L, 1875L) }, host.PositionedMovies);
Assert.Empty(host.Movies);
Assert.Equal(1876, vm.Globals[0x1234]);
Assert.Equal(0x5678, vm.Globals[0x1235]);
}
[Fact]
public void PlayMovieKeepsCreatedSurfaceBlankDuringSynchronousHostSetup()
{
@@ -683,6 +834,37 @@ public class MovieOpcodeTests
Assert.True(heardSignal, $"{expectedName} should contain non-silent MPEG audio");
}
[Fact]
public void FfmpegShimSeeksBothVideoAndAudioNearRequestedPosition()
{
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(0x33)!); // CHAPTER.AGF
const long targetMs = 11000;
using var movie = new FfmpegMovieSession(payload);
movie.Seek(targetMs);
long videoTimestamp = -1;
for (int frame = 0; frame < 120 && videoTimestamp < targetMs; frame++)
{
Assert.True(movie.TryDecodeNextVideoFrame(out var decoded));
videoTimestamp = decoded.PresentationTimeMs;
}
Assert.InRange(videoTimestamp, targetMs, movie.Info.StopTimeMs);
long audioEndTimestamp = -1;
for (int chunk = 0; chunk < 100 && audioEndTimestamp < targetMs; chunk++)
{
Assert.True(movie.TryDecodeNextAudioChunk(out var decoded));
audioEndTimestamp = decoded.PresentationTimeMs
+ decoded.FrameCount * 1000L / movie.Info.AudioSampleRate;
}
Assert.InRange(audioEndTimestamp, targetMs, movie.Info.StopTimeMs + 100);
}
[Fact]
public void FfmpegShimRejectsTruncatedMovieWithBoundedDiagnostic()
{

View File

@@ -49,6 +49,8 @@ internal class RecordingHost : IHost
public readonly List<(int Category, int BasisPoints)> AudioVolumeChanges = new();
public readonly List<(int Category, bool Enabled)> AudioRouteChanges = new();
public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new();
public readonly List<(long Resource, int Surface, long Flags, long SyncMask, long PositionMs)>
PositionedMovies = new();
public System.Action? OnPlayMovie;
public long? MovieStopTimeMs;
public readonly HashSet<int> ActiveMovieSurfaces = new();
@@ -216,6 +218,13 @@ internal class RecordingHost : IHost
OnPlayMovie?.Invoke();
return MovieStopTimeMs;
}
public long? PlayMovieToSurfaceAtPosition(
long resourceId, int surfaceSlot, long movieFlags, long syncMask, long positionMs)
{
PositionedMovies.Add((resourceId, surfaceSlot, movieFlags, syncMask, positionMs));
OnPlayMovie?.Invoke();
return MovieStopTimeMs;
}
public bool IsMovieSurfaceActive(int surfaceSlot) => ActiveMovieSurfaces.Contains(surfaceSlot);
public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags)
=> ModalMovies.Add((resourceId, surfaceSlot, movieFlags));

View File

@@ -176,6 +176,11 @@ public interface IHost
/// when the host could not obtain usable timing metadata. Native op 0x23f queries this state
/// immediately after 0x236 returns.</returns>
long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) => null;
// Native op 0x241 uses the same graph/surface lifecycle as 0x236, but seeks the graph before
// playback configuration. Hosts without positioned decoding may fall back to ordinary playback.
long? PlayMovieToSurfaceAtPosition(
long resourceId, int surfaceSlot, long movieFlags, long syncMask, long positionMs)
=> PlayMovieToSurface(resourceId, surfaceSlot, movieFlags, syncMask);
bool IsMovieSurfaceActive(int surfaceSlot) => false;
// 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

View File

@@ -2688,6 +2688,17 @@ public sealed class VirtualMachine
Gfx.SetMovieStopTime(surfaceSlot, stopTimeMs ?? 0);
return pc + 1; // native cmd size 9 resumes at the next instruction; playback is asynchronous
}
case "u00422B80": // pre-reference compatibility
case "play-movie-to-surface-at-position": // 0x241 (+ initial position ms)
{
long resourceId = Read(a[0]);
int surfaceSlot = unchecked((int)Read(a[1]));
long? stopTimeMs = _host.PlayMovieToSurfaceAtPosition(
resourceId, surfaceSlot, Read(a[2]), Read(a[3]), Read(a[4]));
// As with 0x236, seeking changes decoder position but not the graph's stop metadata.
Gfx.SetMovieStopTime(surfaceSlot, stopTimeMs ?? 0);
return pc + 1;
}
// ---- gfx command-buffer ops (VM-internal GfxState; docs/engine-re.md op-contract table) ----
case "query-gfx-object?": // 0x215 (out)(handle) -> slot | -1
if (_diagSetTexture) // reuse the flag: show what the slot query returns (grey-BG slot dig)