Implement movie mask transition opcode

This commit is contained in:
gamer147
2026-07-29 16:01:00 -04:00
parent 12af952b77
commit 3378abdeca
18 changed files with 573 additions and 45 deletions

View File

@@ -1761,12 +1761,29 @@ header identifies an MPEG program stream despite the extension. One call uses a
post-exit developer menu, although the port deliberately exposes DEBUG through its F4 diagnostic route. post-exit developer menu, although the port deliberately exposes DEBUG through its F4 diagnostic route.
The implementation boundary is consequently larger than ordinary movie playback or a scalar crossfade, The implementation boundary is consequently larger than ordinary movie playback or a scalar crossfade,
but no mask heuristic remains: the source is specifically the decoded green byte. The port needs a but no mask heuristic remains: the source is specifically the decoded green byte. It requires
movie-frame-to-mask publication path, old/new range capture, per-pixel alpha composition, and movie-frame-to-mask publication, retained-range capture, per-pixel alpha composition, and
movie-completion-driven blocking cleanup. The software compositor can serve as the correctness oracle; movie-completion-driven blocking cleanup. The handlers, sample worker, compositor, and exact helpers are
the GPU path can fall back while a type-1 command is active, as it already does for whole-screen named and commented in the saved `/v2` Ghidra image.
transitions. The handlers, sample worker, compositor, and exact helpers are named and commented in the
saved `/v2` Ghidra image. **Port implementation (2026-07-29):** `IHost.PlayMovieMaskTransition` carries the complete
twelve-operand request. At dispatch, Godot snapshots the old/source retained range through
`RetainedSurfaceRasterizer` into the script-created scratch surface; the immediately preceding
`draw-texture` command key therefore displays that captured range without a second compositor model.
Mask playback remains instance-keyed in the ordinary movie registry, but frame publication is redirected:
`MovieMaskSurface.ExtractGreen` copies logical top-down RGBA green bytes into the requested mask dimensions,
and `MovieMaskSurface.Apply` republishes an immutable dynamic-surface snapshot with the exact packed-ARGB
multiplication above. Signed rectangles clip mask and destination coordinates together, while pixels outside
the rectangle remain the captured source.
The initial mask is published before decoder adoption. `MovieRuntime` defers its first-frame take for the
requested start delay, so decoder pacing cannot run ahead during that interval. Its optional presentation
duration scales frame deadlines by `requested_duration/native_stop_time` and holds the terminal frame until
the requested endpoint; this is the managed equivalent of `IMediaPosition::put_Rate`. Completion publishes
the mode-specific terminal fill, marks the type-1 command complete in `GfxState`, and only then releases
opcode `0x21c`'s blocking lifetime. The existing click/skip completion path deliberately affects only type 0.
Because the result is an ordinary dynamic RGBA scratch surface, both the software oracle and accepted Godot
GPU renderer consume the same pixels; no backend-specific mask shader or approximation is involved.
**`0x248` port implementation (2026-07-29):** `GfxState` retains the complete signed dword with native **`0x248` port implementation (2026-07-29):** `GfxState` retains the complete signed dword with native
zero initialization. The setter neither publishes a retained mutation nor rebuilds surfaces that already zero initialization. The setter neither publishes a retained mutation nor rebuilds surfaces that already

View File

@@ -1138,12 +1138,12 @@ Port status (2026-07-29): implemented as retained signed-dword graphics configur
- **evidence:** Ghidra /v2: op_0x249_load_raw_texture_surface@0x424b20 is instruction-length 7 and is contract-identical to gfx_op_0x1f9_load_surface through release, unchanged packed operand, asset_open_indexed_entry, RGB colorkey conversion, load failure, and cleanup. Its only relevant distinction is mode-1 gfx_surface_mode1_ctor, a tiled large-image wrapper: gfx_tiled_surface_create@0x432ff0 splits logical dimensions into ordinary mode-0 child textures; gfx_tiled_surface_upload_agf@0x431a10 decodes/uploads regions; gfx_tiled_surface_blit@0x4316b0 subdivides logical source rectangles. Corpus literals include FIELD 0x32da..0x32dd -> SO005/SO007/SO008A/SO007A. The former claim that only 0x249 bypasses scene normalization was wrong because native never performs scene normalization for 0x1f9 either. - **evidence:** Ghidra /v2: op_0x249_load_raw_texture_surface@0x424b20 is instruction-length 7 and is contract-identical to gfx_op_0x1f9_load_surface through release, unchanged packed operand, asset_open_indexed_entry, RGB colorkey conversion, load failure, and cleanup. Its only relevant distinction is mode-1 gfx_surface_mode1_ctor, a tiled large-image wrapper: gfx_tiled_surface_create@0x432ff0 splits logical dimensions into ordinary mode-0 child textures; gfx_tiled_surface_upload_agf@0x431a10 decodes/uploads regions; gfx_tiled_surface_blit@0x4316b0 subdivides logical source rectangles. Corpus literals include FIELD 0x32da..0x32dd -> SO005/SO007/SO008A/SO007A. The former claim that only 0x249 bypasses scene normalization was wrong because native never performs scene normalization for 0x1f9 either.
### 0x24d `play-movie-mask-transition` (play-movie-mask-transition, argc 12) ### 0x24d `play-movie-mask-transition` (play-movie-mask-transition, argc 12)
- **summary:** Open a movie into a scratch surface, retime it to the requested duration, copy each decoded frame's green channel into a byte-per-pixel mask, and register a blocking retained-surface transition from an old object range to a newly drawn range within the supplied rectangle. - **summary:** Capture an old retained-object range into the newly drawn scratch surface, retime a movie to the requested duration, and use each decoded frame's green channel as a byte-per-pixel alpha mask within the supplied rectangle.
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **depends on:** 0x236, 0x223 - **depends on:** 0x236, 0x223
- **evidence:** Ghidra /v2: op_0x24d_play_movie_mask_transition@0x424db0 opens operand 10 through movie_to_texture_open_asset_graph, configures delay operand 11, sets IMediaPosition::put_Rate to native_stop_time_ms / operand12_ms, allocates a width*height byte mask through movie_texture_allocate_transition_mask@0x415d90, and calls gfx_movie_mask_transition_register@0x47f560 with operands 1-9/11. movie_texture_renderer_receive_sample@0x4628d0's mask-active branch copies byte +1 of every bottom-up RGB24 sample pixel, i.e. its green channel, directly into that mask. The retained compositor's type-1 branch applies the movie-updated mask between the old and new object ranges and holds its blocking dirty state until playback completes. Both DEBUG.BIN sites use TEST.AGF (an MPEG program stream), delay 0, and duration 1000 ms. - **evidence:** Ghidra /v2: op_0x24d_play_movie_mask_transition@0x424db0 opens operand 10 through movie_to_texture_open_asset_graph, configures delay operand 11, sets IMediaPosition::put_Rate to native_stop_time_ms / operand12_ms, allocates a width*height byte mask through movie_texture_allocate_transition_mask@0x415d90, and calls gfx_movie_mask_transition_register@0x47f560 with operands 1-9/11. movie_texture_renderer_receive_sample@0x4628d0's mask-active branch copies byte +1 of every bottom-up RGB24 sample pixel, i.e. its green channel, directly into that mask. The retained compositor's type-1 branch applies the movie-updated mask between the old and new object ranges and holds its blocking dirty state until playback completes. Both DEBUG.BIN sites use TEST.AGF (an MPEG program stream), delay 0, and duration 1000 ms.
The RGB24 sample callback copies the bottom-up green byte verbatim. In the native 32-bit mask compositor, captured RGB remains unchanged and the output alpha byte is the high byte of `((source_argb >> 8) * mask_byte)`; this records the exact packed-integer operation, including its low-color carry, rather than approximating it as a scalar luminance crossfade. Mode 1 initializes the mask to 0 and completes at 255; all other modes initialize at 255 and complete at 0. The RGB24 sample callback copies the bottom-up green byte verbatim. In the native 32-bit mask compositor, captured RGB remains unchanged and the output alpha byte is the high byte of `((source_argb >> 8) * mask_byte)`; this records the exact packed-integer operation, including its low-color carry, rather than approximating it as a scalar luminance crossfade. Mode 1 initializes the mask to 0 and completes at 255; all other modes initialize at 255 and complete at 0. Port status (2026-07-29): implemented through the existing retained-range software rasterizer and dynamic scratch-surface path. The decoder honors start delay and native stop/duration retiming, publishes logical top-down green masks, and holds opcode 0x21c until movie completion; click completion remains limited to type-0 transitions.
### 0x24e `set-gfx-animation-service-flags` (set-gfx-animation-service-flags, argc 1) ### 0x24e `set-gfx-animation-service-flags` (set-gfx-animation-service-flags, argc 1)
- **summary:** Replace the retained graphics animation-service flags with operand 1. BTL brackets combat presentation with values 1 and 0; GAMECLEAR uses 3 and 0. - **summary:** Replace the retained graphics animation-service flags with operand 1. BTL brackets combat presentation with values 1 and 0; GAMECLEAR uses 3 and 0.

View File

@@ -1116,6 +1116,30 @@ Himegari-targeted threaded `SELFTEST OK`.
**NEXT:** implement the DEBUG-only `0x24d` green-channel movie-mask compositor. **NEXT:** implement the DEBUG-only `0x24d` green-channel movie-mask compositor.
**MOVIE-MASK TRANSITION `0x24d` IMPLEMENTED (2026-07-29):** the final effectful opcode now dispatches
all twelve operands through a dedicated host request. The old/source retained range is captured into the
script-created scratch surface with the existing platform-neutral retained rasterizer. Each due TEST.AGF
frame publishes its logical green channel as a byte mask, and the scratch surface applies AGE's exact
packed-ARGB alpha multiplication inside the signed/clipped destination rectangle while preserving captured
RGB and every pixel outside the rectangle. Mode 1 runs from zero to 255; other modes run from 255 to zero.
Movie playback retains ordinary instance ownership and VFS/FFmpeg decode, while the mask path redirects
frame publication away from visible movie RGB. Start delay parks first-frame consumption, stop/duration
retiming scales decoder presentation deadlines, and the terminal frame is held to the requested endpoint.
The type-1 command remains blocking until decoder completion and cannot be click-completed through the
type-0 transition shortcut. Completion publishes the exact terminal fill before opcode `0x21c` resumes.
The resulting scratch surface is an ordinary dynamic RGBA image, so software and GPU presentation share
one correctness path.
Focused regressions cover exact VM dispatch, transition lifetime, click immunity, green extraction,
dimension padding, signed clipping, RGB/outside preservation, the native packed-color carry, requested
decoder retiming, and real VFS TEST.AGF metadata/green-mask decode. The effectful opcode inventory is now
empty. Validation passes 518/518 engine tests, the zero-warning Godot build, opcode/global/EngineCtx suites,
clean diff checking, and the Himegari-targeted threaded selftest.
**NEXT:** return to playthrough-led polish and save compatibility testing; no decoded effectful opcode gap
remains in the shipped 481-script corpus.
## Later Phase B breadth ## Later Phase B breadth
**INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static **INIT data-semantics side track started (2026-07-22).** Before naming more gameplay state, the static

View File

@@ -30,8 +30,8 @@ or replaced before claiming portable exports.
|---|---|---|---| |---|---|---|---|
| SYS4INI per-game startup profile | `Sys4AssetCatalog` parses and retains the bounded ordered startup trailer; `Sys4LogicalCanvas` applies `SCREENX`/`SCREENY` with AGE's independent `640x480` fallbacks. Native SYS4INI also carries text, ADV input/skip, save ABI/path, audio, legacy renderer, and Windows registration settings | Godot uses the selected canvas for content scaling, backbuffers/compositor bounds, primary surfaces, layout/input fallbacks, and the default windowed client. `--window-width`/`--window-height` vary only the physical client while preserving the canvas and letterbox policy. The port still does not source `CancelMesSkipOnClick`, `CoexistMesSkip`, cursor/redraw policy, or wheel action ids from the trailer | Validate the existing presentation policy on Linux/macOS window managers. Apply further semantic keys explicitly, translate save roots through the host, and classify DirectDraw/fullscreen-bit and registration/key settings as native compatibility metadata. Canonical inventory and consumers: `sys4-format-notes.md` and `engine-re.md` | | SYS4INI per-game startup profile | `Sys4AssetCatalog` parses and retains the bounded ordered startup trailer; `Sys4LogicalCanvas` applies `SCREENX`/`SCREENY` with AGE's independent `640x480` fallbacks. Native SYS4INI also carries text, ADV input/skip, save ABI/path, audio, legacy renderer, and Windows registration settings | Godot uses the selected canvas for content scaling, backbuffers/compositor bounds, primary surfaces, layout/input fallbacks, and the default windowed client. `--window-width`/`--window-height` vary only the physical client while preserving the canvas and letterbox policy. The port still does not source `CancelMesSkipOnClick`, `CoexistMesSkip`, cursor/redraw policy, or wheel action ids from the trailer | Validate the existing presentation policy on Linux/macOS window managers. Apply further semantic keys explicitly, translate save roots through the host, and classify DirectDraw/fullscreen-bit and registration/key settings as native compatibility metadata. Canonical inventory and consumers: `sys4-format-notes.md` and `engine-re.md` |
| Retained graphics presentation | Backend-neutral `GfxState`; accepted default Godot `Sprite2D` GPU stage plus the retained software pixel oracle, using runtime `ImageTexture`, canvas transforms/materials, and no native graphics API | GPU backend caches static/color-key variants, updates dynamic surfaces, handles retained range transitions, and falls back whole-frame for the legacy host screen-transition path | Godot owns D3D/Vulkan/Metal/OpenGL selection; validate shader/blend/filter behavior per target rather than adding a platform renderer | | Retained graphics presentation | Backend-neutral `GfxState`; accepted default Godot `Sprite2D` GPU stage plus the retained software pixel oracle, using runtime `ImageTexture`, canvas transforms/materials, and no native graphics API | GPU backend caches static/color-key variants, updates dynamic surfaces, handles retained range transitions, and falls back whole-frame for the legacy host screen-transition path | Godot owns D3D/Vulkan/Metal/OpenGL selection; validate shader/blend/filter behavior per target rather than adding a platform renderer |
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback | Add target-specific native builds and export packaging | | AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state; managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates | | Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug | | Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
| ADV font discovery/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows ` 明朝`/` ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback. AGE actually uses a display information DC, weight-700 `LOGFONTA`, `GGO_GRAY4_BITMAP`, and its own integer glyph/outline compositor; the current `Label` backend substitutes FreeType embolden and a Godot outline | On the reference Windows install, regular advances and direct SC0000 placement/bounds agree, but the behaviorally different provisional bold path has 45% fewer bright pixels and much softer edge coverage. Other platforms normally lack the proprietary faces, so substitute metrics remain profile-dependent | Deferred until gameplay settles: implement the Phase-E decoded mask/metrics/compositor backlog in `docs/remake-architecture-and-roadmap.md`, with a shipped GDI reference backend on Windows and an explicitly defined portable rasterizer/substitution policy | | ADV font discovery/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows ` 明朝`/` ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback. AGE actually uses a display information DC, weight-700 `LOGFONTA`, `GGO_GRAY4_BITMAP`, and its own integer glyph/outline compositor; the current `Label` backend substitutes FreeType embolden and a Godot outline | On the reference Windows install, regular advances and direct SC0000 placement/bounds agree, but the behaviorally different provisional bold path has 45% fewer bright pixels and much softer edge coverage. Other platforms normally lack the proprietary faces, so substitute metrics remain profile-dependent | Deferred until gameplay settles: implement the Phase-E decoded mask/metrics/compositor backlog in `docs/remake-architecture-and-roadmap.md`, with a shipped GDI reference backend on Windows and an explicitly defined portable rasterizer/substitution policy |
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules | | Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
@@ -51,13 +51,14 @@ dependency.
The live connection is now backend-neutral: The live connection is now backend-neutral:
``` ```
VM op 0x236 (non-modal) / op 0x20f (modal) VM op 0x236 (non-modal) / op 0x20f (modal) / op 0x24d (green-mask transition)
-> IHost.PlayMovieToSurface / PlayModalMovieToSurface -> IHost.PlayMovieToSurface / PlayModalMovieToSurface / PlayMovieMaskTransition
-> VFS-owned MoviePayload bytes -> VFS-owned MoviePayload bytes
-> IMovieDecoderFactory -> IMovieDecoderFactory
-> FfmpegMovieDecoder -> FfmpegMovieDecoder
-> FfmpegMovieSession -> age_movie C ABI -> FfmpegMovieSession -> age_movie C ABI
-> newest due RGBA frame -> retained movie surface -> Godot compositor -> newest due RGBA frame -> retained movie surface -> Godot compositor
or green byte -> exact captured-range alpha mask -> dynamic scratch surface
-> timestamped stereo float PCM -> per-playback AudioStreamGenerator -> timestamped stereo float PCM -> per-playback AudioStreamGenerator
``` ```

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Hosting;
using Age.Engine.Sys4; using Age.Engine.Sys4;
using Age.Engine.Vm; using Age.Engine.Vm;
using Xunit; using Xunit;
@@ -74,4 +75,22 @@ public class ForegroundTransitionTests
Assert.Equal(1, host.TransitionWaits); Assert.Equal(1, host.TransitionWaits);
Assert.Equal(1.0, vm.Gfx.SnapshotForegroundTransitions(100).Single().Progress); 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 MoviePayload? OpenedPayload { get; private set; }
public long OpenedInitialPositionMs { 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; OpenedPayload = movie;
OpenedInitialPositionMs = initialPositionMs; OpenedInitialPositionMs = initialPositionMs;
OpenedPresentationDurationMs = presentationDurationMs;
return decoder; return decoder;
} }
} }
@@ -202,6 +205,27 @@ public class MovieOpcodeTests
Assert.Null(decoder.Failure); 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] [Fact]
public void FfmpegDecoderPublishesAndRetainsFirstDecodedFrameBeforePacing() public void FfmpegDecoderPublishesAndRetainsFirstDecodedFrameBeforePacing()
{ {
@@ -605,6 +629,37 @@ public class MovieOpcodeTests
Assert.Equal(0x5678, vm.Globals[0x1235]); 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] [Fact]
public void PlayMovieKeepsCreatedSurfaceBlankDuringSynchronousHostSetup() public void PlayMovieKeepsCreatedSurfaceBlankDuringSynchronousHostSetup()
{ {
@@ -727,6 +782,28 @@ public class MovieOpcodeTests
Assert.Equal(new byte[] { 0, 0, 1, 0xba }, movie.Bytes[..4]); 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] [Theory]
[InlineData(0x335f, "LOGO.AGF")] [InlineData(0x335f, "LOGO.AGF")]
[InlineData(0x3364, "OP.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)> Movies = new();
public readonly List<(long Resource, int Surface, long Flags, long SyncMask, long PositionMs)> public readonly List<(long Resource, int Surface, long Flags, long SyncMask, long PositionMs)>
PositionedMovies = new(); PositionedMovies = new();
public readonly List<MovieMaskTransitionRequest> MovieMaskTransitions = new();
public System.Action? OnPlayMovie; public System.Action? OnPlayMovie;
public long? MovieStopTimeMs; public long? MovieStopTimeMs;
public readonly HashSet<int> ActiveMovieSurfaces = new(); public readonly HashSet<int> ActiveMovieSurfaces = new();
@@ -225,6 +226,12 @@ internal class RecordingHost : IHost
OnPlayMovie?.Invoke(); OnPlayMovie?.Invoke();
return MovieStopTimeMs; 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 bool IsMovieSurfaceActive(int surfaceSlot) => ActiveMovieSurfaces.Contains(surfaceSlot);
public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags) public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags)
=> ModalMovies.Add((resourceId, surfaceSlot, movieFlags)); => ModalMovies.Add((resourceId, surfaceSlot, movieFlags));

View File

@@ -28,6 +28,13 @@ public readonly record struct SurfaceRectCopy(
int SourceSurface, int DestinationSurface, int SourceX, int SourceY, int SourceSurface, int DestinationSurface, int SourceX, int SourceY,
int Width, int Height, int DestinationX, int DestinationY); int Width, int Height, int DestinationX, int DestinationY);
/// <summary>Opcode 0x24d's captured-range movie-mask transition. The movie's decoded green channel
/// becomes a byte-per-pixel alpha mask over SourceRange in the scratch surface.</summary>
public readonly record struct MovieMaskTransitionRequest(
long CommandKey, int SurfaceSlot, long SourceRangeStart, int SourceRangeCount,
int X, int Y, int Width, int Height, long Mode, long ResourceId,
long StartDelayMs, long DurationMs);
/// <summary>A synchronous AGE-owned diagnostic prompt after native body/context formatting.</summary> /// <summary>A synchronous AGE-owned diagnostic prompt after native body/context formatting.</summary>
public readonly record struct DiagnosticMessage(string Caption, string Text); public readonly record struct DiagnosticMessage(string Caption, string Text);
@@ -181,6 +188,14 @@ public interface IHost
long? PlayMovieToSurfaceAtPosition( long? PlayMovieToSurfaceAtPosition(
long resourceId, int surfaceSlot, long movieFlags, long syncMask, long positionMs) long resourceId, int surfaceSlot, long movieFlags, long syncMask, long positionMs)
=> PlayMovieToSurface(resourceId, surfaceSlot, movieFlags, syncMask); => PlayMovieToSurface(resourceId, surfaceSlot, movieFlags, syncMask);
// Native op 0x24d captures a retained range into the scratch surface, then publishes each
// decoded movie frame's green channel as an exact packed-alpha mask. A nonvisual host completes
// the lifecycle immediately so a following 0x21c can never strand script execution.
void PlayMovieMaskTransition(GfxState gfx, MovieMaskTransitionRequest request)
{
gfx.QueueMovieMaskTransition(request);
gfx.CompleteMovieMaskTransition(request.SurfaceSlot);
}
bool IsMovieSurfaceActive(int surfaceSlot) => false; bool IsMovieSurfaceActive(int surfaceSlot) => false;
// Native op 0x20f uses a universal packed 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 // reaches EOF or the player cancels it. The decoder remains asynchronous; the interactive host

View File

@@ -1,4 +1,5 @@
using System.Linq; using System.Linq;
using Age.Engine.Hosting;
namespace Age.Engine.Model; namespace Age.Engine.Model;
@@ -32,6 +33,9 @@ public readonly record struct SurfaceTransitionState(long CommandKey, int Target
long RangeAStart, int RangeACount, long RangeBStart, int RangeBCount, long RangeAStart, int RangeACount, long RangeBStart, int RangeBCount,
long DelayMs, long DurationMs, long StartMs, double Progress, bool Forced); long DelayMs, long DurationMs, long StartMs, double Progress, bool Forced);
public readonly record struct MovieMaskTransitionState(
MovieMaskTransitionRequest Request, bool Completed);
/// <summary>One synchronized sample of op 0x202's native one-shot packed-color channel.</summary> /// <summary>One synchronized sample of op 0x202's native one-shot packed-color channel.</summary>
public readonly record struct ColorTransitionState(long Current, long Target, public readonly record struct ColorTransitionState(long Current, long Target,
long DelayMs, long DurationMs, long StartMs, double Progress, bool Active); long DelayMs, long DurationMs, long StartMs, double Progress, bool Active);
@@ -112,6 +116,11 @@ public sealed class GfxState
public long StartMs = -1; public long StartMs = -1;
public bool Forced; public bool Forced;
} }
private sealed class MovieMaskTransition
{
public required MovieMaskTransitionRequest Request;
public bool Completed;
}
public sealed class GfxObject public sealed class GfxObject
{ {
// The native numbered-save record contains several full transform matrices and reserved fields // The native numbered-save record contains several full transform matrices and reserved fields
@@ -410,6 +419,7 @@ public sealed class GfxState
_reloadableSurfaces.Clear(); _reloadableSurfaces.Clear();
_movieStopTimesMs.Clear(); _movieStopTimesMs.Clear();
_surfaceTransitions.Clear(); _surfaceTransitions.Clear();
_movieMaskTransitions.Clear();
CurrentObject = 0; CurrentObject = 0;
CurrentRenderTargetSlot = -1; CurrentRenderTargetSlot = -1;
_rangeTransformFirst = 0; _rangeTransformFirst = 0;
@@ -525,6 +535,7 @@ public sealed class GfxState
// the movie object exists but its host decoder supplied no usable IMediaPosition stop time. // the movie object exists but its host decoder supplied no usable IMediaPosition stop time.
private readonly Dictionary<int, long?> _movieStopTimesMs = new(); private readonly Dictionary<int, long?> _movieStopTimesMs = new();
private readonly Dictionary<int, SurfaceTransition> _surfaceTransitions = new(); private readonly Dictionary<int, SurfaceTransition> _surfaceTransitions = new();
private readonly Dictionary<int, MovieMaskTransition> _movieMaskTransitions = new();
public void SetSurface(int slot, long resId, long colorKey) public void SetSurface(int slot, long resId, long colorKey)
{ {
lock (_lock) lock (_lock)
@@ -587,6 +598,7 @@ public sealed class GfxState
_createdSurfaces.Remove(slot); _createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot); _movieStopTimesMs.Remove(slot);
_surfaceTransitions.Remove(slot); _surfaceTransitions.Remove(slot);
_movieMaskTransitions.Remove(slot);
} }
if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end) if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end)
CurrentRenderTargetSlot = -1; CurrentRenderTargetSlot = -1;
@@ -707,6 +719,7 @@ public sealed class GfxState
_createdSurfaces.Remove(slot); _createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot); _movieStopTimesMs.Remove(slot);
_surfaceTransitions.Remove(slot); _surfaceTransitions.Remove(slot);
_movieMaskTransitions.Remove(slot);
MarkRetainedMutation(); MarkRetainedMutation();
} }
} }
@@ -729,6 +742,45 @@ public sealed class GfxState
} }
} }
public void QueueMovieMaskTransition(MovieMaskTransitionRequest request)
{
lock (_lock)
{
_movieMaskTransitions[request.SurfaceSlot] = new MovieMaskTransition
{
Request = request with
{
SourceRangeCount = System.Math.Max(0, request.SourceRangeCount),
Width = System.Math.Max(0, request.Width),
Height = System.Math.Max(0, request.Height),
StartDelayMs = System.Math.Max(0, request.StartDelayMs),
DurationMs = System.Math.Max(0, request.DurationMs),
},
};
MarkRetainedMutation();
}
}
public bool CompleteMovieMaskTransition(int surfaceSlot)
{
lock (_lock)
{
if (!_movieMaskTransitions.TryGetValue(surfaceSlot, out var transition)
|| transition.Completed)
return false;
transition.Completed = true;
MarkRetainedMutation();
return true;
}
}
public IReadOnlyList<MovieMaskTransitionState> SnapshotMovieMaskTransitions()
{
lock (_lock)
return _movieMaskTransitions.Values
.Select(t => new MovieMaskTransitionState(t.Request, t.Completed)).ToList();
}
/// <summary>Start every pending foreground transition at the native present boundary.</summary> /// <summary>Start every pending foreground transition at the native present boundary.</summary>
public int StartForegroundTransitions(long nowMs) public int StartForegroundTransitions(long nowMs)
{ {
@@ -744,7 +796,8 @@ public sealed class GfxState
public bool HasActiveForegroundTransitions(long nowMs) public bool HasActiveForegroundTransitions(long nowMs)
{ {
lock (_lock) lock (_lock)
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0); return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0)
|| _movieMaskTransitions.Values.Any(t => !t.Completed);
} }
/// <summary>Native op 0x21c keeps presenting until both queued surface commands and finite one-shot /// <summary>Native op 0x21c keeps presenting until both queued surface commands and finite one-shot
@@ -753,6 +806,7 @@ public sealed class GfxState
{ {
lock (_lock) lock (_lock)
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) || return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
_movieMaskTransitions.Values.Any(t => !t.Completed) ||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled || _rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
_rangeTransform.TranslationEnabled || _rangeTransform.TranslationEnabled ||
_objects.Values.Any(o => o.Visible && _objects.Values.Any(o => o.Visible &&
@@ -780,10 +834,12 @@ public sealed class GfxState
return new GfxDiagnosticSnapshot( return new GfxDiagnosticSnapshot(
nowMs, nowMs,
_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0)
|| _movieMaskTransitions.Values.Any(t => !t.Completed)
|| range != null || objects.Length != 0, || range != null || objects.Length != 0,
_objects.Count, _objects.Count,
_objects.Values.Count(o => o.Visible), _objects.Values.Count(o => o.Visible),
_surfaceTransitions.Values.Count(t => TransitionProgress(t, nowMs) < 1.0), _surfaceTransitions.Values.Count(t => TransitionProgress(t, nowMs) < 1.0)
+ _movieMaskTransitions.Values.Count(t => !t.Completed),
AnimationServiceFlags, AnimationServiceFlags,
AnimClockDurationTicks, AnimClockDurationTicks,
AnimClockGeneration, AnimClockGeneration,
@@ -866,6 +922,7 @@ public sealed class GfxState
{ {
lock (_lock) lock (_lock)
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) || return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
_movieMaskTransitions.Values.Any(t => !t.Completed) ||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled || _rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
_rangeTransform.TranslationEnabled || _rangeTransform.TranslationEnabled ||
_objects.Values.Any(o => o.Visible && _objects.Values.Any(o => o.Visible &&

View File

@@ -0,0 +1,66 @@
using Age.Engine.Model;
namespace Age.Engine.Sys4;
/// <summary>Platform-neutral pixel oracle for opcode 0x24d's type-1 movie transition.</summary>
public static class MovieMaskSurface
{
/// <summary>Extract logical top-down green samples into the requested native mask dimensions.
/// Missing source pixels retain <paramref name="fill"/>.</summary>
public static byte[] ExtractGreen(RgbaImage frame, int width, int height, byte fill)
{
int safeWidth = System.Math.Max(0, width);
int safeHeight = System.Math.Max(0, height);
var mask = new byte[checked(safeWidth * safeHeight)];
if (fill != 0) System.Array.Fill(mask, fill);
int copyWidth = System.Math.Min(safeWidth, frame.Width);
int copyHeight = System.Math.Min(safeHeight, frame.Height);
for (int row = 0; row < copyHeight; row++)
{
int source = checked(row * frame.Width * 4 + 1);
int destination = checked(row * safeWidth);
for (int column = 0; column < copyWidth; column++, source += 4)
mask[destination + column] = frame.Pixels[source];
}
return mask;
}
/// <summary>Apply AGE's exact packed-ARGB multiplication to a captured RGBA surface. RGB and
/// pixels outside the requested rectangle are preserved. The multiplication deliberately includes
/// carry from the packed red/green bytes; it is not conventional alpha*mask/255.</summary>
public static RgbaImage Apply(
RgbaImage captured, ReadOnlySpan<byte> mask, int maskWidth, int maskHeight, int x, int y)
{
if (maskWidth < 0 || maskHeight < 0
|| mask.Length != checked(maskWidth * maskHeight))
throw new System.ArgumentException("mask dimensions do not match its byte count", nameof(mask));
var output = new RgbaImage(
captured.Width, captured.Height, (byte[])captured.Pixels.Clone());
long left = System.Math.Max(0L, x);
long top = System.Math.Max(0L, y);
long right = System.Math.Min((long)captured.Width, (long)x + maskWidth);
long bottom = System.Math.Min((long)captured.Height, (long)y + maskHeight);
if (right <= left || bottom <= top) return output;
for (int destinationY = (int)top; destinationY < (int)bottom; destinationY++)
{
int maskY = destinationY - y;
for (int destinationX = (int)left; destinationX < (int)right; destinationX++)
{
int pixel = checked((destinationY * captured.Width + destinationX) * 4);
byte red = captured.Pixels[pixel];
byte green = captured.Pixels[pixel + 1];
byte blue = captured.Pixels[pixel + 2];
byte alpha = captured.Pixels[pixel + 3];
uint argb = (uint)alpha << 24
| (uint)red << 16
| (uint)green << 8
| blue;
uint product = unchecked((argb >> 8)
* mask[checked(maskY * maskWidth + destinationX - x)]);
output.Pixels[pixel + 3] = (byte)(product >> 24);
}
}
return output;
}
}

View File

@@ -2846,6 +2846,14 @@ public sealed class VirtualMachine
Gfx.ResetAnimClock(); return pc + 1; Gfx.ResetAnimClock(); return pc + 1;
case "set-gfx-animation-service-flags": // 0x24e: bit 1 suppresses op 0x243 case "set-gfx-animation-service-flags": // 0x24e: bit 1 suppresses op 0x243
Gfx.SetAnimationServiceFlags(Read(a[0])); return pc + 1; Gfx.SetAnimationServiceFlags(Read(a[0])); return pc + 1;
case "play-movie-mask-transition": // 0x24d: captured retained range + movie green-channel mask
_host.PlayMovieMaskTransition(Gfx, new MovieMaskTransitionRequest(
Read(a[0]), unchecked((int)Read(a[1])),
Read(a[2]), unchecked((int)Read(a[3])),
unchecked((int)Read(a[4])), unchecked((int)Read(a[5])),
unchecked((int)Read(a[6])), unchecked((int)Read(a[7])),
Read(a[8]), Read(a[9]), Read(a[10]), Read(a[11])));
return pc + 1;
case "queue-surface-alpha-transition": // 0x223: target surface crossfade over two object ranges case "queue-surface-alpha-transition": // 0x223: target surface crossfade over two object ranges
Gfx.QueueSurfaceAlphaTransition(Read(a[0]), (int)Read(a[1]), Read(a[2]), (int)Read(a[3]), Gfx.QueueSurfaceAlphaTransition(Read(a[0]), (int)Read(a[1]), Read(a[2]), (int)Read(a[3]),
Read(a[4]), (int)Read(a[5]), Read(a[6]), Read(a[7])); return pc + 1; Read(a[4]), (int)Read(a[5]), Read(a[6]), Read(a[7])); return pc + 1;

View File

@@ -89,6 +89,7 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
private readonly AutoResetEvent _audioSpace = new(false); private readonly AutoResetEvent _audioSpace = new(false);
private readonly int _maximumQueuedAudioFrames; private readonly int _maximumQueuedAudioFrames;
private readonly long _initialPositionMs; private readonly long _initialPositionMs;
private readonly long? _presentationDurationMs;
private RgbaImage? _latestFrame; private RgbaImage? _latestFrame;
private volatile bool _completed; private volatile bool _completed;
private volatile bool _videoTimelineCompleted; private volatile bool _videoTimelineCompleted;
@@ -116,17 +117,21 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
public MovieAudioInfo? AudioInfo { get; } public MovieAudioInfo? AudioInfo { get; }
public bool AudioDecodingCompleted => _audioDecodingCompleted; public bool AudioDecodingCompleted => _audioDecodingCompleted;
public FfmpegMovieDecoder(MoviePayload movie, long initialPositionMs = 0) public FfmpegMovieDecoder(
: this(new FfmpegMovieSession(movie), null, initialPositionMs) { } MoviePayload movie, long initialPositionMs = 0, long? presentationDurationMs = null)
: this(new FfmpegMovieSession(movie), null, initialPositionMs, presentationDurationMs) { }
internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock? clock, internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock? clock,
long initialPositionMs = 0) long initialPositionMs = 0, long? presentationDurationMs = null)
{ {
_source = source ?? throw new ArgumentNullException(nameof(source)); _source = source ?? throw new ArgumentNullException(nameof(source));
_initialPositionMs = Math.Clamp( _initialPositionMs = Math.Clamp(
initialPositionMs, initialPositionMs,
0, 0,
Math.Max(0, source.Info.StopTimeMs - 1)); Math.Max(0, source.Info.StopTimeMs - 1));
_presentationDurationMs = presentationDurationMs is >= 0
? Math.Max(0, presentationDurationMs.Value)
: null;
try try
{ {
if (_initialPositionMs > 0) if (_initialPositionMs > 0)
@@ -254,7 +259,8 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
{ {
if (decodedFrames == 0) if (decodedFrames == 0)
throw new InvalidDataException("FFmpeg stream ended before producing a video frame"); throw new InvalidDataException("FFmpeg stream ended before producing a video frame");
long completionTime = PresentationDeadline( long completionTime = _presentationDurationMs
?? PresentationDeadline(
Math.Max(_source.Info.StopTimeMs, Math.Max(_source.Info.StopTimeMs,
lastTimestamp + FrameIntervalMilliseconds(_source.Info)), lastTimestamp + FrameIntervalMilliseconds(_source.Info)),
firstTimestamp); firstTimestamp);
@@ -432,7 +438,11 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
// stream's mux timestamp origin. DirectShow presents the video's first sample as video time zero; // stream's mux timestamp origin. DirectShow presents the video's first sample as video time zero;
// retaining the absolute mux offset here would freeze that sample until the audio clock caught up. // retaining the absolute mux offset here would freeze that sample until the audio clock caught up.
// Preserve every decoded frame and its cadence, but rebase the video stream to its first sample. // Preserve every decoded frame and its cadence, but rebase the video stream to its first sample.
return Math.Max(0, sourceTimestamp - firstVideoTimestamp); long sourceElapsed = Math.Max(0, sourceTimestamp - firstVideoTimestamp);
if (_presentationDurationMs is not { } duration
|| _source.Info.StopTimeMs <= 0)
return sourceElapsed;
return checked(sourceElapsed * duration / _source.Info.StopTimeMs);
} }
public void Dispose() public void Dispose()
@@ -454,6 +464,7 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
internal sealed class FfmpegMovieDecoderFactory : IMovieDecoderFactory internal sealed class FfmpegMovieDecoderFactory : IMovieDecoderFactory
{ {
public IMovieDecoder Open(MoviePayload movie, long initialPositionMs = 0) public IMovieDecoder Open(
=> new FfmpegMovieDecoder(movie, initialPositionMs); MoviePayload movie, long initialPositionMs = 0, long? presentationDurationMs = null)
=> new FfmpegMovieDecoder(movie, initialPositionMs, presentationDurationMs);
} }

View File

@@ -41,6 +41,11 @@ public sealed class GodotAdvHost : IHost
private readonly Dictionary<int, long> _surfaceColorKeys = new(); private readonly Dictionary<int, long> _surfaceColorKeys = new();
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id
private readonly MovieSurfaceRegistry _movieSurfaces = new(); private readonly MovieSurfaceRegistry _movieSurfaces = new();
private sealed record MovieMaskPlayback(
GfxState Gfx, MovieMaskTransitionRequest Request, RgbaImage Captured);
private readonly object _movieMaskLock = new();
private readonly Dictionary<long, MovieMaskPlayback> _movieMasksByPlayback = new();
private readonly Dictionary<int, long> _movieMaskPlaybackBySurface = new();
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the selected game's // slot -> dimensions of the currently allocated surface. Slot 0 begins as the selected game's
// logical canvas, but op 0x1fa releases it like any other slot; subsequent queries return 0x0. // logical canvas, but op 0x1fa releases it like any other slot; subsequent queries return 0x0.
@@ -1256,6 +1261,12 @@ public sealed class GodotAdvHost : IHost
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture( public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture(
int surfaceSlot, long fallbackResourceId) int surfaceSlot, long fallbackResourceId)
{ {
lock (_movieMaskLock)
if (_movieMaskPlaybackBySurface.ContainsKey(surfaceSlot))
lock (_imageLock)
if (_surfaceImages.TryGetValue(surfaceSlot, out var masked))
return (masked, $"<movie-mask:{surfaceSlot}>",
int.MinValue + surfaceSlot, true);
if (_movieSurfaces.TryResolveSurface(surfaceSlot, out var movie) && movie != null) if (_movieSurfaces.TryResolveSurface(surfaceSlot, out var movie) && movie != null)
return (movie.Image, movie.Name, movie.AssetId, true); return (movie.Image, movie.Name, movie.AssetId, true);
if (_movieSurfaces.IsBound(surfaceSlot)) return null; if (_movieSurfaces.IsBound(surfaceSlot)) return null;
@@ -1273,7 +1284,8 @@ public sealed class GodotAdvHost : IHost
var asset = _res.ResolveMovie(resourceId); var asset = _res.ResolveMovie(resourceId);
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; } if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; }
StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false, StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false,
initialPositionMs: 0, out long? stopTimeMs, out _); initialPositionMs: 0, startDelayMs: 0, presentationDurationMs: null,
movieMask: null, out long? stopTimeMs, out _);
return stopTimeMs ?? 0; return stopTimeMs ?? 0;
} }
@@ -1284,10 +1296,40 @@ public sealed class GodotAdvHost : IHost
var asset = _res.ResolveMovie(resourceId); var asset = _res.ResolveMovie(resourceId);
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; } if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; }
StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false, StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false,
initialPositionMs: positionMs, out long? stopTimeMs, out _); initialPositionMs: positionMs, startDelayMs: 0, presentationDurationMs: null,
movieMask: null, out long? stopTimeMs, out _);
return stopTimeMs ?? 0; return stopTimeMs ?? 0;
} }
public void PlayMovieMaskTransition(GfxState gfx, MovieMaskTransitionRequest request)
{
gfx.QueueMovieMaskTransition(request);
RgbaImage captured = CaptureRetainedRange(
gfx, request.SurfaceSlot, request.SourceRangeStart, request.SourceRangeCount);
byte initialFill = request.Mode == 1 ? (byte)0 : (byte)255;
PublishMaskedCapture(
request.SurfaceSlot, captured,
CreateFilledMask(request.Width, request.Height, initialFill), request);
var asset = _res.ResolveMovie(request.ResourceId);
if (asset == null)
{
Godot.GD.Print($"movie mask unresolved {CurrentScene}:0x{request.ResourceId:x}");
PublishMovieMaskTerminal(new MovieMaskPlayback(gfx, request, captured));
return;
}
var mask = new MovieMaskPlayback(gfx, request, captured);
bool started = StartMovie(
asset, request.ResourceId, request.SurfaceSlot, movieFlags: 6, syncMask: 0,
modal: false, initialPositionMs: 0,
startDelayMs: request.StartDelayMs,
presentationDurationMs: request.DurationMs,
movieMask: mask, out long? stopTimeMs, out _);
gfx.SetMovieStopTime(request.SurfaceSlot, stopTimeMs ?? 0);
if (!started) PublishMovieMaskTerminal(mask);
}
public bool IsMovieSurfaceActive(int surfaceSlot) public bool IsMovieSurfaceActive(int surfaceSlot)
=> _movieSurfaces.IsActive(surfaceSlot); => _movieSurfaces.IsActive(surfaceSlot);
@@ -1327,7 +1369,8 @@ public sealed class GodotAdvHost : IHost
try try
{ {
if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true, if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true,
initialPositionMs: 0, initialPositionMs: 0, startDelayMs: 0,
presentationDurationMs: null, movieMask: null,
out _, out long playbackId)) return; out _, out long playbackId)) return;
_timeline?.State("modal-movie-wait", new() _timeline?.State("modal-movie-wait", new()
{ {
@@ -1361,7 +1404,8 @@ public sealed class GodotAdvHost : IHost
} }
private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags, private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags,
long syncMask, bool modal, long initialPositionMs, long syncMask, bool modal, long initialPositionMs, long startDelayMs,
long? presentationDurationMs, MovieMaskPlayback? movieMask,
out long? stopTimeMs, out long playbackId) out long? stopTimeMs, out long playbackId)
{ {
stopTimeMs = null; stopTimeMs = null;
@@ -1369,14 +1413,27 @@ public sealed class GodotAdvHost : IHost
// asset on multiple surfaces; replacing one binding must not erase another binding's completion. // asset on multiple surfaces; replacing one binding must not erase another binding's completion.
MovieSurfaceBinding binding = _movieSurfaces.Begin(surfaceSlot, resourceId, out var replaced); MovieSurfaceBinding binding = _movieSurfaces.Begin(surfaceSlot, resourceId, out var replaced);
playbackId = binding.PlaybackId; playbackId = binding.PlaybackId;
if (replaced is { } prior) _main.CallDeferred("StopMovie", prior.PlaybackId); if (replaced is { } prior)
{
AbandonMovieMaskPlayback(prior.PlaybackId);
ForgetMovieMaskSurface(prior.SurfaceSlot, prior.PlaybackId);
_main.CallDeferred("StopMovie", prior.PlaybackId);
}
if (movieMask != null)
lock (_movieMaskLock)
{
_movieMasksByPlayback[playbackId] = movieMask;
_movieMaskPlaybackBySurface[surfaceSlot] = playbackId;
}
lock (_imageLock) lock (_imageLock)
{ {
_surfaceImages.Remove(surfaceSlot); if (movieMask == null) _surfaceImages.Remove(surfaceSlot);
_surfaceColorKeys.Remove(surfaceSlot); _surfaceColorKeys.Remove(surfaceSlot);
} }
// Movie surfaces inherit the selected game's primary size until a decoded frame supplies content. // Movie surfaces inherit the selected game's primary size until a decoded frame supplies content.
_slotDims[surfaceSlot] = (_screenWidth, _screenHeight); _slotDims[surfaceSlot] = movieMask == null
? (_screenWidth, _screenHeight)
: (movieMask.Captured.Width, movieMask.Captured.Height);
try try
{ {
var movie = _res.ReadMovie(asset); var movie = _res.ReadMovie(asset);
@@ -1386,10 +1443,13 @@ public sealed class GodotAdvHost : IHost
["surface"] = surfaceSlot, ["file"] = movie.Name, ["surface"] = surfaceSlot, ["file"] = movie.Name,
["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal, ["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal,
["initial_position_ms"] = Math.Max(0, initialPositionMs), ["initial_position_ms"] = Math.Max(0, initialPositionMs),
["start_delay_ms"] = Math.Max(0, startDelayMs),
["presentation_duration_ms"] = presentationDurationMs,
["movie_mask"] = movieMask != null,
}); });
bool started = _main.TryPlayMovie( bool started = _main.TryPlayMovie(
movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId, movieFlags, movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId, movieFlags,
initialPositionMs, out stopTimeMs); initialPositionMs, startDelayMs, presentationDurationMs, out stopTimeMs);
if (!started) if (!started)
{ {
stopTimeMs = 0; stopTimeMs = 0;
@@ -1399,6 +1459,7 @@ public sealed class GodotAdvHost : IHost
} }
catch (System.Exception e) catch (System.Exception e)
{ {
AbandonMovieMaskPlayback(playbackId);
_movieSurfaces.Abandon(playbackId, out _); _movieSurfaces.Abandon(playbackId, out _);
_slotDims.Remove(surfaceSlot); _slotDims.Remove(surfaceSlot);
stopTimeMs = 0; stopTimeMs = 0;
@@ -1427,6 +1488,8 @@ public sealed class GodotAdvHost : IHost
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released) if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
{ {
var binding = movieRelease.Binding; var binding = movieRelease.Binding;
AbandonMovieMaskPlayback(binding.PlaybackId);
ForgetMovieMaskSurface(binding.SurfaceSlot, binding.PlaybackId);
_timeline?.Event("movie-stop", new() _timeline?.Event("movie-stop", new()
{ {
["resource"] = binding.ResourceId, ["resource"] = binding.ResourceId,
@@ -1577,6 +1640,8 @@ public sealed class GodotAdvHost : IHost
} }
foreach (MovieSurfaceBinding binding in stoppedMovies) foreach (MovieSurfaceBinding binding in stoppedMovies)
{ {
AbandonMovieMaskPlayback(binding.PlaybackId);
ForgetMovieMaskSurface(binding.SurfaceSlot, binding.PlaybackId);
_timeline?.Event("movie-stop", new() _timeline?.Event("movie-stop", new()
{ {
["resource"] = binding.ResourceId, ["resource"] = binding.ResourceId,
@@ -1592,12 +1657,25 @@ public sealed class GodotAdvHost : IHost
// sample callback: the retained object keeps its surface binding while only the surface pixels change. // sample callback: the retained object keeps its surface binding while only the surface pixels change.
public void PublishMovieFrame(long playbackId, string name, int assetId, RgbaImage frame) public void PublishMovieFrame(long playbackId, string name, int assetId, RgbaImage frame)
{ {
MovieMaskPlayback? maskPlayback;
lock (_movieMaskLock) _movieMasksByPlayback.TryGetValue(playbackId, out maskPlayback);
if (maskPlayback != null)
{
var request = maskPlayback.Request;
byte initialFill = request.Mode == 1 ? (byte)0 : (byte)255;
byte[] mask = MovieMaskSurface.ExtractGreen(
frame, request.Width, request.Height, initialFill);
PublishMaskedCapture(request.SurfaceSlot, maskPlayback.Captured, mask, request);
return;
}
if (_movieSurfaces.PublishFrame(playbackId, frame, name, assetId)) if (_movieSurfaces.PublishFrame(playbackId, frame, name, assetId))
System.Threading.Interlocked.Exchange(ref _presentRequested, 1); System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
} }
public void NotifyMovieCompleted(long playbackId) public void NotifyMovieCompleted(long playbackId)
{ {
MovieMaskPlayback? maskPlayback = RemoveMovieMaskPlayback(playbackId);
if (maskPlayback != null) PublishMovieMaskTerminal(maskPlayback);
if (!_movieSurfaces.Complete(playbackId)) return; if (!_movieSurfaces.Complete(playbackId)) return;
_timeline?.Event("movie-complete", new() { ["playback"] = playbackId }); _timeline?.Event("movie-complete", new() { ["playback"] = playbackId });
_frameSignal.Set(); _frameSignal.Set();
@@ -1606,6 +1684,85 @@ public sealed class GodotAdvHost : IHost
private bool HasActiveMoviePresentation() private bool HasActiveMoviePresentation()
=> _movieSurfaces.HasActivePlayback; => _movieSurfaces.HasActivePlayback;
private RgbaImage CaptureRetainedRange(
GfxState gfx, int targetSlot, long firstHandle, int count)
{
var dimensions = _slotDims.GetValueOrDefault(
targetSlot, (W: _screenWidth, H: _screenHeight));
int width = System.Math.Max(0, dimensions.W);
int height = System.Math.Max(0, dimensions.H);
var captured = new RgbaImage(width, height, new byte[checked(width * height * 4)]);
IReadOnlyList<RenderObject> visible = gfx.SnapshotVisibleObjects(_clock.NowMs);
RetainedSurfaceRasterizer.CompositeRange(
captured, visible, firstHandle, System.Math.Max(0, count),
item =>
{
var raw = gfx.TryGet(item.Handle);
var resolved = raw != null
? ResolveSurfaceTexture(raw.SourceSlot, item.SurfaceResId)
: ResolveResIdTexture(item.SurfaceResId);
return resolved == null
? null
: RgbaSurfaceOps.WithColorKey(resolved.Value.Image, item.ColorKey);
});
return captured;
}
private static byte[] CreateFilledMask(int width, int height, byte fill)
{
var mask = new byte[checked(System.Math.Max(0, width) * System.Math.Max(0, height))];
if (fill != 0) System.Array.Fill(mask, fill);
return mask;
}
private void PublishMaskedCapture(
int surfaceSlot, RgbaImage captured, byte[] mask, MovieMaskTransitionRequest request)
{
RgbaImage image = MovieMaskSurface.Apply(
captured, mask, System.Math.Max(0, request.Width),
System.Math.Max(0, request.Height), request.X, request.Y);
lock (_imageLock) _surfaceImages[surfaceSlot] = image;
_slotDims[surfaceSlot] = (image.Width, image.Height);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
_frameSignal.Set();
}
private void PublishMovieMaskTerminal(MovieMaskPlayback playback)
{
byte terminalFill = playback.Request.Mode == 1 ? (byte)255 : (byte)0;
PublishMaskedCapture(
playback.Request.SurfaceSlot, playback.Captured,
CreateFilledMask(playback.Request.Width, playback.Request.Height, terminalFill),
playback.Request);
playback.Gfx.CompleteMovieMaskTransition(playback.Request.SurfaceSlot);
}
private MovieMaskPlayback? RemoveMovieMaskPlayback(long playbackId)
{
lock (_movieMaskLock)
{
if (!_movieMasksByPlayback.Remove(playbackId, out var playback)) return null;
return playback;
}
}
private void AbandonMovieMaskPlayback(long playbackId)
{
MovieMaskPlayback? playback = RemoveMovieMaskPlayback(playbackId);
if (playback != null)
{
ForgetMovieMaskSurface(playback.Request.SurfaceSlot, playbackId);
playback.Gfx.CompleteMovieMaskTransition(playback.Request.SurfaceSlot);
}
}
private void ForgetMovieMaskSurface(int surfaceSlot, long playbackId)
{
lock (_movieMaskLock)
if (_movieMaskPlaybackBySurface.GetValueOrDefault(surfaceSlot) == playbackId)
_movieMaskPlaybackBySurface.Remove(surfaceSlot);
}
private RgbaImage? Decode(AssetEntry asset) private RgbaImage? Decode(AssetEntry asset)
{ {
lock (_imageLock) lock (_imageLock)

View File

@@ -29,5 +29,6 @@ internal interface IMovieDecoder : IDisposable
internal interface IMovieDecoderFactory internal interface IMovieDecoderFactory
{ {
IMovieDecoder Open(MoviePayload movie, long initialPositionMs = 0); IMovieDecoder Open(
MoviePayload movie, long initialPositionMs = 0, long? presentationDurationMs = null);
} }

View File

@@ -2129,7 +2129,8 @@ public partial class Main : Godot.Control
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId, public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId, long movieFlags, long resourceId, int assetId, long movieFlags,
long initialPositionMs, long initialPositionMs, long startDelayMs,
long? presentationDurationMs,
out long? stopTimeMs) out long? stopTimeMs)
{ {
stopTimeMs = null; stopTimeMs = null;
@@ -2138,7 +2139,7 @@ public partial class Main : Godot.Control
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes); var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = MovieRuntime.Open( var runtime = MovieRuntime.Open(
assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags, assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags,
initialPositionMs); initialPositionMs, startDelayMs, presentationDurationMs);
stopTimeMs = runtime.Decoder.StopTimeMs; stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(playbackId, runtime)) while (!_pendingMovies.TryAdd(playbackId, runtime))
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose(); if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
@@ -2180,6 +2181,9 @@ public partial class Main : Godot.Control
if (_host == null) return; if (_host == null) return;
foreach (var (playbackId, movie) in _movies) foreach (var (playbackId, movie) in _movies)
{ {
long elapsedMs = (long)Stopwatch.GetElapsedTime(
movie.StartedAtTimestamp).TotalMilliseconds;
if (elapsedMs < movie.StartDelayMs) continue;
bool frameWasAlreadySeen = _movieFrameSeen.Contains(playbackId); bool frameWasAlreadySeen = _movieFrameSeen.Contains(playbackId);
_movieAudio.TryGetValue(playbackId, out var audio); _movieAudio.TryGetValue(playbackId, out var audio);
if (frameWasAlreadySeen) audio?.Update(); if (frameWasAlreadySeen) audio?.Update();
@@ -2196,8 +2200,7 @@ public partial class Main : Godot.Control
audio?.Update(); audio?.Update();
} }
} }
bool watchdogExpired = Stopwatch.GetElapsedTime(movie.StartedAtTimestamp).TotalMilliseconds bool watchdogExpired = elapsedMs >= movie.WatchdogMs;
>= movie.WatchdogMs;
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId)) if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId))
{ {
if (movie.Decoder.Failure is { } failure) if (movie.Decoder.Failure is { } failure)

View File

@@ -5,21 +5,31 @@ using Age.Engine.Sys4;
/// <summary>Presentation-side ownership for one decoder plus its fail-safe completion deadline.</summary> /// <summary>Presentation-side ownership for one decoder plus its fail-safe completion deadline.</summary>
internal sealed record MovieRuntime(string Name, int AssetId, long ResourceId, IMovieDecoder Decoder, internal sealed record MovieRuntime(string Name, int AssetId, long ResourceId, IMovieDecoder Decoder,
long MovieFlags, long InitialPositionMs, long MovieFlags, long InitialPositionMs,
long StartedAtTimestamp, long WatchdogMs) long StartedAtTimestamp, long StartDelayMs,
long? PresentationDurationMs, long WatchdogMs)
{ {
public static MovieRuntime Open(string name, int assetId, long resourceId, MoviePayload payload, public static MovieRuntime Open(string name, int assetId, long resourceId, MoviePayload payload,
IMovieDecoderFactory factory, long movieFlags = 0, IMovieDecoderFactory factory, long movieFlags = 0,
long initialPositionMs = 0) long initialPositionMs = 0, long startDelayMs = 0,
long? presentationDurationMs = null)
{ {
ArgumentNullException.ThrowIfNull(factory); ArgumentNullException.ThrowIfNull(factory);
IMovieDecoder decoder = factory.Open(payload, Math.Max(0, initialPositionMs)); long safeDelayMs = Math.Max(0, startDelayMs);
long? safeDurationMs = presentationDurationMs is >= 0
? Math.Max(0, presentationDurationMs.Value)
: null;
IMovieDecoder decoder = factory.Open(
payload, Math.Max(0, initialPositionMs), safeDurationMs);
long remainingMs = decoder.StopTimeMs is >= 0 and var stopTime long remainingMs = decoder.StopTimeMs is >= 0 and var stopTime
? Math.Max(0, stopTime - decoder.InitialPositionMs) ? Math.Max(0, stopTime - decoder.InitialPositionMs)
: -1; : -1;
long watchdogBasis = safeDurationMs ?? remainingMs;
if (watchdogBasis >= 0) watchdogBasis = checked(watchdogBasis + safeDelayMs);
return new MovieRuntime(name, assetId, resourceId, decoder, movieFlags, return new MovieRuntime(name, assetId, resourceId, decoder, movieFlags,
decoder.InitialPositionMs, Stopwatch.GetTimestamp(), decoder.InitialPositionMs, Stopwatch.GetTimestamp(),
remainingMs >= 0 safeDelayMs, safeDurationMs,
? Math.Clamp(remainingMs + 2000, 5000, 300000) watchdogBasis >= 0
? Math.Clamp(watchdogBasis + 2000, 5000, 300000)
: 30000); : 30000);
} }
} }

View File

@@ -6654,8 +6654,8 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "play-movie-mask-transition" name = "play-movie-mask-transition"
category = "draw" category = "draw"
summary = "Open a movie into a scratch surface, retime it to the requested duration, copy each decoded frame's green channel into a byte-per-pixel mask, and register a blocking retained-surface transition from an old object range to a newly drawn range within the supplied rectangle." summary = "Capture an old retained-object range into the newly drawn scratch surface, retime a movie to the requested duration, and use each decoded frame's green channel as a byte-per-pixel alpha mask within the supplied rectangle."
details = "The RGB24 sample callback copies the bottom-up green byte verbatim. In the native 32-bit mask compositor, captured RGB remains unchanged and the output alpha byte is the high byte of `((source_argb >> 8) * mask_byte)`; this records the exact packed-integer operation, including its low-color carry, rather than approximating it as a scalar luminance crossfade. Mode 1 initializes the mask to 0 and completes at 255; all other modes initialize at 255 and complete at 0." details = "The RGB24 sample callback copies the bottom-up green byte verbatim. In the native 32-bit mask compositor, captured RGB remains unchanged and the output alpha byte is the high byte of `((source_argb >> 8) * mask_byte)`; this records the exact packed-integer operation, including its low-color carry, rather than approximating it as a scalar luminance crossfade. Mode 1 initializes the mask to 0 and completes at 255; all other modes initialize at 255 and complete at 0. Port status (2026-07-29): implemented through the existing retained-range software rasterizer and dynamic scratch-surface path. The decoder honors start delay and native stop/duration retiming, publishes logical top-down green masks, and holds opcode 0x21c until movie completion; click completion remains limited to type-0 transitions."
noop_headless = false noop_headless = false
source = "investigation" source = "investigation"
confidence = "high" confidence = "high"