Split Main movie playback into partial class

This commit is contained in:
gamer147
2026-08-02 18:17:25 -04:00
parent 123794cace
commit 2f7e53ebde
4 changed files with 134 additions and 125 deletions

View File

@@ -138,7 +138,8 @@ complete disposable artifact under `build/export/linux-x64/`.
`godot/Main.cs` retains the front-end's startup and runtime coordination. Behavior-neutral partial-class `godot/Main.cs` retains the front-end's startup and runtime coordination. Behavior-neutral partial-class
companions keep cohesive surfaces independently navigable without changing the Godot node type or invocation companions keep cohesive surfaces independently navigable without changing the Godot node type or invocation
paths: `godot/Main.SelfTest.cs` owns the synthetic threaded/headless regression harness, while paths: `godot/Main.SelfTest.cs` owns the synthetic threaded/headless regression harness, while
`godot/Main.Audio.cs` owns BGM, voice, sound-effect, mixer-routing/persistence, and audio-bus control. `godot/Main.Audio.cs` owns BGM, voice, sound-effect, mixer-routing/persistence, and audio-bus control, and
`godot/Main.Movie.cs` owns decoder staging, movie frame/audio publication, completion, and teardown.
The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map
runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports

View File

@@ -559,11 +559,12 @@ do not mix mechanical moves with semantic changes.
stage, battle, card, routine, gallery, and general table implementations plus tests into importable stage, battle, card, routine, gallery, and general table implementations plus tests into importable
modules. modules.
**Progress (2026-08-02):** the first two bounded splits moved `Main`'s synthetic scene builder/threaded **Progress (2026-08-02):** the first three bounded splits moved `Main`'s synthetic scene builder/threaded
self-test harness into `godot/Main.SelfTest.cs`, then its BGM, voice, sound-effect, mixer, and bus-control self-test harness into `godot/Main.SelfTest.cs`, then its BGM, voice, sound-effect, mixer, and bus-control
surface into `godot/Main.Audio.cs`. The partial class retains the same node type, fields, signatures, and surface into `godot/Main.Audio.cs`, then decoder staging, movie frame/audio publication, completion, and
call sites; runtime validation, including all 590 engine tests and the Godot threaded self-test, remains teardown into `godot/Main.Movie.cs`. The partial class retains the same node type, fields, signatures,
green after each move. execution order, and call sites; runtime validation, including all 590 engine tests and the Godot threaded
self-test, remains green after each move.
**Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where **Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where
deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after
@@ -935,8 +936,8 @@ layer's rendering diverges from ADV; save layout.
## 8. Immediate next step ## 8. Immediate next step
Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed
by the tracked launcher and layered validation driver. With the embedded `Main` self-test and audio surfaces by the tracked launcher and layered validation driver. With the embedded `Main` self-test, audio, and movie
isolated, move the `Main` movie playback surface next, then one existing domain at a time while preserving surfaces isolated, move the retained compositor surface next, then one existing domain at a time while
public types, commands, and generated output. preserving public types, commands, and generated output.
Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does
not replace Phase B gameplay validation or the open cross-platform gates. not replace Phase B gameplay validation or the open cross-platform gates.

124
godot/Main.Movie.cs Normal file
View File

@@ -0,0 +1,124 @@
using System.Diagnostics;
using Godot;
public partial class Main
{
private readonly System.Collections.Generic.Dictionary<long, MovieRuntime> _movies = new();
private readonly System.Collections.Generic.Dictionary<long, MovieAudioOutput> _movieAudio = new();
// 0x236 opens its decoder synchronously on the VM thread so 0x23f can query timing immediately.
// Presentation ownership transfers here; _Process adopts staged decoders before sampling frames.
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, MovieRuntime> _pendingMovies = new();
private IMovieDecoderFactory _movieDecoderFactory = new FfmpegMovieDecoderFactory();
private double _audioOutputLatencySeconds;
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private readonly System.Collections.Generic.HashSet<long> _movieCompletionNotified = new();
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId, long movieFlags,
long initialPositionMs, long startDelayMs,
long? presentationDurationMs,
out long? stopTimeMs)
{
stopTimeMs = null;
try
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = MovieRuntime.Open(
assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags,
initialPositionMs, startDelayMs, presentationDurationMs);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(playbackId, runtime))
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
return true;
}
catch (System.Exception e)
{
GD.Print($"movie decode failed {assetName}: {e.Message}");
return false;
}
}
private void AdoptPendingMovies()
{
foreach (var (playbackId, _) in _pendingMovies)
{
if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue;
if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose();
_movies[playbackId] = movie;
if (movie.Decoder.AudioInfo != null)
{
if (_movieAudio.Remove(playbackId, out var priorAudio)) priorAudio.Dispose();
_movieAudio[playbackId] = new MovieAudioOutput(
this, movie.Decoder, MovieAudioRouteFromFlags(movie.MovieFlags),
_audioOutputLatencySeconds);
}
_movieCompletionNotified.Remove(playbackId);
GD.Print($"movie started {movie.Name} playback={playbackId} " +
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS" +
(movie.InitialPositionMs > 0 ? $", start={movie.InitialPositionMs}ms" : "") +
(movie.Decoder.AudioInfo is { } audio
? $", audio={audio.SampleRate}Hz stereo route={MovieAudioRouteFromFlags(movie.MovieFlags)}"
: "") + ")");
}
}
private void UpdateMovieFrames()
{
if (_host == null) return;
foreach (var (playbackId, movie) in _movies)
{
long elapsedMs = (long)Stopwatch.GetElapsedTime(
movie.StartedAtTimestamp).TotalMilliseconds;
if (elapsedMs < movie.StartDelayMs) continue;
bool frameWasAlreadySeen = _movieFrameSeen.Contains(playbackId);
_movieAudio.TryGetValue(playbackId, out var audio);
if (frameWasAlreadySeen) audio?.Update();
if (movie.Decoder.TryTakeFrame(out var frame))
{
_host.PublishMovieFrame(playbackId, movie.Name, movie.AssetId, frame);
if (_movieFrameSeen.Add(playbackId))
{
GD.Print($"movie first frame {movie.Name} playback={playbackId}: " +
$"{frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame} " +
$"(source PTS {movie.Decoder.FirstFramePresentationTimeMs?.ToString() ?? "unknown"} ms)");
// Do not let decode startup consume the opening audio timeline. The first PCM push
// begins only after the first decoded image has reached the retained movie surface.
audio?.Update();
}
}
bool watchdogExpired = elapsedMs >= movie.WatchdogMs;
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId))
{
if (movie.Decoder.Failure is { } failure)
GD.Print($"movie decode failed {movie.Name}: {failure}");
if (watchdogExpired && !movie.Decoder.IsCompleted)
GD.Print($"movie completion watchdog {movie.Name}: forcing completion after {movie.WatchdogMs} ms");
_host.NotifyMovieCompleted(playbackId);
}
}
}
public void StopMovie(long playbackId)
{
if (_movieAudio.Remove(playbackId, out var audio)) audio.Dispose();
if (_pendingMovies.TryRemove(playbackId, out var pending)) pending.Decoder.Dispose();
if (_movies.Remove(playbackId, out var movie))
{
movie.Decoder.Dispose();
GD.Print($"movie stopped {movie.Name} playback={playbackId} at render frame {_timelineFrame}");
}
_movieFrameSeen.Remove(playbackId);
_movieCompletionNotified.Remove(playbackId);
}
private static MovieAudioRoute MovieAudioRouteFromFlags(long flags)
{
ulong value = unchecked((ulong)flags);
if ((value & 0x10000) != 0) return MovieAudioRoute.Muted;
if ((value & 0x20000) != 0) return MovieAudioRoute.Music;
if ((value & 0x40000) != 0) return MovieAudioRoute.SoundEffect;
if ((value & 0x80000) != 0) return MovieAudioRoute.Voice;
return MovieAudioRoute.Movie;
}
}

View File

@@ -44,15 +44,6 @@ public partial class Main : Godot.Control
private DebugSceneLauncher? _debugSceneLauncher; private DebugSceneLauncher? _debugSceneLauncher;
private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>(); private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>();
private readonly Age.Engine.Hosting.FrameClock _clock = new(); private readonly Age.Engine.Hosting.FrameClock _clock = new();
private readonly System.Collections.Generic.Dictionary<long, MovieRuntime> _movies = new();
private readonly System.Collections.Generic.Dictionary<long, MovieAudioOutput> _movieAudio = new();
// 0x236 opens its decoder synchronously on the VM thread so 0x23f can query timing immediately.
// Presentation ownership transfers here; _Process adopts staged decoders before sampling frames.
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, MovieRuntime> _pendingMovies = new();
private IMovieDecoderFactory _movieDecoderFactory = new FfmpegMovieDecoderFactory();
private double _audioOutputLatencySeconds;
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private readonly System.Collections.Generic.HashSet<long> _movieCompletionNotified = new();
private GodotTraceSink _trace = null!; private GodotTraceSink _trace = null!;
private PageLocatorState _locator = null!; private PageLocatorState _locator = null!;
private bool _locatorHudVisible; private bool _locatorHudVisible;
@@ -1608,114 +1599,6 @@ public partial class Main : Godot.Control
px[i + 3] = 0; px[i + 3] = 0;
} }
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId, long movieFlags,
long initialPositionMs, long startDelayMs,
long? presentationDurationMs,
out long? stopTimeMs)
{
stopTimeMs = null;
try
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = MovieRuntime.Open(
assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags,
initialPositionMs, startDelayMs, presentationDurationMs);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(playbackId, runtime))
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
return true;
}
catch (System.Exception e)
{
GD.Print($"movie decode failed {assetName}: {e.Message}");
return false;
}
}
private void AdoptPendingMovies()
{
foreach (var (playbackId, _) in _pendingMovies)
{
if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue;
if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose();
_movies[playbackId] = movie;
if (movie.Decoder.AudioInfo != null)
{
if (_movieAudio.Remove(playbackId, out var priorAudio)) priorAudio.Dispose();
_movieAudio[playbackId] = new MovieAudioOutput(
this, movie.Decoder, MovieAudioRouteFromFlags(movie.MovieFlags),
_audioOutputLatencySeconds);
}
_movieCompletionNotified.Remove(playbackId);
GD.Print($"movie started {movie.Name} playback={playbackId} " +
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS" +
(movie.InitialPositionMs > 0 ? $", start={movie.InitialPositionMs}ms" : "") +
(movie.Decoder.AudioInfo is { } audio
? $", audio={audio.SampleRate}Hz stereo route={MovieAudioRouteFromFlags(movie.MovieFlags)}"
: "") + ")");
}
}
private void UpdateMovieFrames()
{
if (_host == null) return;
foreach (var (playbackId, movie) in _movies)
{
long elapsedMs = (long)Stopwatch.GetElapsedTime(
movie.StartedAtTimestamp).TotalMilliseconds;
if (elapsedMs < movie.StartDelayMs) continue;
bool frameWasAlreadySeen = _movieFrameSeen.Contains(playbackId);
_movieAudio.TryGetValue(playbackId, out var audio);
if (frameWasAlreadySeen) audio?.Update();
if (movie.Decoder.TryTakeFrame(out var frame))
{
_host.PublishMovieFrame(playbackId, movie.Name, movie.AssetId, frame);
if (_movieFrameSeen.Add(playbackId))
{
GD.Print($"movie first frame {movie.Name} playback={playbackId}: " +
$"{frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame} " +
$"(source PTS {movie.Decoder.FirstFramePresentationTimeMs?.ToString() ?? "unknown"} ms)");
// Do not let decode startup consume the opening audio timeline. The first PCM push
// begins only after the first decoded image has reached the retained movie surface.
audio?.Update();
}
}
bool watchdogExpired = elapsedMs >= movie.WatchdogMs;
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId))
{
if (movie.Decoder.Failure is { } failure)
GD.Print($"movie decode failed {movie.Name}: {failure}");
if (watchdogExpired && !movie.Decoder.IsCompleted)
GD.Print($"movie completion watchdog {movie.Name}: forcing completion after {movie.WatchdogMs} ms");
_host.NotifyMovieCompleted(playbackId);
}
}
}
public void StopMovie(long playbackId)
{
if (_movieAudio.Remove(playbackId, out var audio)) audio.Dispose();
if (_pendingMovies.TryRemove(playbackId, out var pending)) pending.Decoder.Dispose();
if (_movies.Remove(playbackId, out var movie))
{
movie.Decoder.Dispose();
GD.Print($"movie stopped {movie.Name} playback={playbackId} at render frame {_timelineFrame}");
}
_movieFrameSeen.Remove(playbackId);
_movieCompletionNotified.Remove(playbackId);
}
private static MovieAudioRoute MovieAudioRouteFromFlags(long flags)
{
ulong value = unchecked((ulong)flags);
if ((value & 0x10000) != 0) return MovieAudioRoute.Muted;
if ((value & 0x20000) != 0) return MovieAudioRoute.Music;
if ((value & 0x40000) != 0) return MovieAudioRoute.SoundEffect;
if ((value & 0x80000) != 0) return MovieAudioRoute.Voice;
return MovieAudioRoute.Movie;
}
public void PageBreak() public void PageBreak()
{ {
_pageCount++; _pageCount++;