Implement movie mask transition opcode

This commit is contained in:
gamer147
2026-07-29 16:01:00 -04:00
parent cfd8809339
commit 835b0e28cd
18 changed files with 573 additions and 45 deletions

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using Age.Engine.Model;
using Age.Engine.Hosting;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
@@ -74,4 +75,22 @@ public class ForegroundTransitionTests
Assert.Equal(1, host.TransitionWaits);
Assert.Equal(1.0, vm.Gfx.SnapshotForegroundTransitions(100).Single().Progress);
}
[Fact]
public void MovieMaskTransitionBlocksUntilMovieCompletionAndIgnoresClickCompletion()
{
var gfx = new GfxState();
var request = new MovieMaskTransitionRequest(
11, 45, 10, 1, -184, 0, 800, 600, 0, 0x325e, 0, 1000);
gfx.QueueMovieMaskTransition(request);
Assert.True(gfx.HasActiveForegroundTransitions(0));
Assert.True(gfx.HasActiveTimedPresentation(0));
Assert.Equal(0, gfx.CompleteForegroundTransitions(100));
Assert.False(gfx.SnapshotMovieMaskTransitions().Single().Completed);
Assert.True(gfx.CompleteMovieMaskTransition(45));
Assert.False(gfx.HasActiveForegroundTransitions(100));
Assert.True(gfx.SnapshotMovieMaskTransitions().Single().Completed);
}
}

View File

@@ -0,0 +1,55 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
using Xunit;
public class MovieMaskSurfaceTests
{
[Fact]
public void ExtractGreenUsesLogicalRowsAndRequestedMaskDimensions()
{
var frame = new RgbaImage(2, 2,
[
1, 10, 2, 255, 3, 20, 4, 255,
5, 30, 6, 255, 7, 40, 8, 255,
]);
Assert.Equal(new byte[] { 10, 20, 99, 30, 40, 99 },
MovieMaskSurface.ExtractGreen(frame, 3, 2, 99));
}
[Fact]
public void ApplyPreservesRgbAndUsesExactPackedCarryAlpha()
{
var captured = new RgbaImage(2, 1,
[
0, 0, 0, 128,
255, 0, 0, 128,
]);
RgbaImage result = MovieMaskSurface.Apply(captured, [255, 255], 2, 1, 0, 0);
Assert.Equal(new byte[]
{
0, 0, 0, 127, // packed 0x80000000: native differs from alpha*255/255
255, 0, 0, 128, // packed red carry reaches the output alpha byte
}, result.Pixels);
Assert.Equal(new byte[] { 0, 0, 0, 128, 255, 0, 0, 128 }, captured.Pixels);
}
[Fact]
public void ApplyClipsSignedRectangleAndLeavesOutsidePixelsUnchanged()
{
var captured = new RgbaImage(3, 1,
[
1, 2, 3, 255,
4, 5, 6, 255,
7, 8, 9, 255,
]);
RgbaImage result = MovieMaskSurface.Apply(captured, [0, 64, 128], 3, 1, -1, 0);
Assert.Equal((byte)63, result.Pixels[3]); // mask column 1
Assert.Equal((byte)127, result.Pixels[7]); // mask column 2
Assert.Equal((byte)255, result.Pixels[11]);
}
}

View File

@@ -33,10 +33,13 @@ public class MovieOpcodeTests
{
public MoviePayload? OpenedPayload { get; private set; }
public long OpenedInitialPositionMs { get; private set; }
public IMovieDecoder Open(MoviePayload movie, long initialPositionMs = 0)
public long? OpenedPresentationDurationMs { get; private set; }
public IMovieDecoder Open(
MoviePayload movie, long initialPositionMs = 0, long? presentationDurationMs = null)
{
OpenedPayload = movie;
OpenedInitialPositionMs = initialPositionMs;
OpenedPresentationDurationMs = presentationDurationMs;
return decoder;
}
}
@@ -202,6 +205,27 @@ public class MovieOpcodeTests
Assert.Null(decoder.Failure);
}
[Fact]
public void FfmpegDecoderRetimeUsesRequestedDurationAndHoldsTerminalFrameToEndpoint()
{
var source = new FakeFfmpegFrameSource(
new FfmpegMovieInfo(1, 1, 1000, 30, 1, false),
SyntheticMovieFrame(1, 600), SyntheticMovieFrame(2, 900));
using var clock = new ManualMoviePacingClock();
using var decoder = new FfmpegMovieDecoder(
source, clock, presentationDurationMs: 500);
Assert.True(SpinWait.SpinUntil(() => decoder.TryTakeFrame(out _), 1000));
Assert.True(clock.WaitForDeadline(150)); // (900 - 600) * 500 / 1000
clock.AdvanceTo(150);
Assert.True(SpinWait.SpinUntil(() => decoder.TryTakeFrame(out _), 1000));
Assert.True(clock.WaitForDeadline(500));
clock.AdvanceTo(499);
Assert.False(decoder.IsCompleted);
clock.AdvanceTo(500);
Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 1000));
}
[Fact]
public void FfmpegDecoderPublishesAndRetainsFirstDecodedFrameBeforePacing()
{
@@ -605,6 +629,37 @@ public class MovieOpcodeTests
Assert.Equal(0x5678, vm.Globals[0x1235]);
}
[Fact]
public void MovieMaskTransitionDispatchesAllTwelveOperandsAndResumes()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = ScriptAssembler.Assemble(table, "MOVIE-MASK",
[
(0x24d,
[
new Operand(0, 11), new Operand(0, 45),
new Operand(0, 10), new Operand(0, 1),
new Operand(0, unchecked((uint)-184)), new Operand(0, 0),
new Operand(0, 800), new Operand(0, 600),
new Operand(0, 0), new Operand(0, 0x325e),
new Operand(0, 0), new Operand(0, 1000),
]),
(0x55, [new Operand(3, 0x1234), new Operand(0, 0x5678)]),
(0x2, Array.Empty<Operand>()),
], []);
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 MovieMaskTransitionRequest(
11, 45, 10, 1, -184, 0, 800, 600, 0, 0x325e, 0, 1000),
host.MovieMaskTransitions.Single());
Assert.True(vm.Gfx.SnapshotMovieMaskTransitions().Single().Completed);
}
[Fact]
public void PlayMovieKeepsCreatedSurfaceBlankDuringSynchronousHostSetup()
{
@@ -727,6 +782,28 @@ public class MovieOpcodeTests
Assert.Equal(new byte[] { 0, 0, 1, 0xba }, movie.Bytes[..4]);
}
[Fact]
public void DebugTestMovieDecodesToExactGreenMaskDimensions()
{
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(0x325e)!);
using var movie = new FfmpegMovieSession(payload);
Assert.Equal("TEST.AGF", payload.Name);
Assert.Equal(800, movie.Info.Width);
Assert.Equal(600, movie.Info.Height);
Assert.Equal(1000, movie.Info.StopTimeMs);
Assert.False(movie.Info.HasAudio);
Assert.True(movie.TryDecodeNextVideoFrame(out var frame));
byte[] mask = MovieMaskSurface.ExtractGreen(frame.Image, 800, 600, 255);
Assert.Equal(800 * 600, mask.Length);
Assert.True(mask.Distinct().Skip(1).Any(), "TEST.AGF should publish a nonuniform green mask");
}
[Theory]
[InlineData(0x335f, "LOGO.AGF")]
[InlineData(0x3364, "OP.AGF")]

View File

@@ -51,6 +51,7 @@ internal class RecordingHost : IHost
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 readonly List<MovieMaskTransitionRequest> MovieMaskTransitions = new();
public System.Action? OnPlayMovie;
public long? MovieStopTimeMs;
public readonly HashSet<int> ActiveMovieSurfaces = new();
@@ -225,6 +226,12 @@ internal class RecordingHost : IHost
OnPlayMovie?.Invoke();
return MovieStopTimeMs;
}
public void PlayMovieMaskTransition(GfxState gfx, MovieMaskTransitionRequest request)
{
MovieMaskTransitions.Add(request);
gfx.QueueMovieMaskTransition(request);
gfx.CompleteMovieMaskTransition(request.SurfaceSlot);
}
public bool IsMovieSurfaceActive(int surfaceSlot) => ActiveMovieSurfaces.Contains(surfaceSlot);
public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags)
=> ModalMovies.Add((resourceId, surfaceSlot, movieFlags));