Split Main movie playback into partial class
This commit is contained in:
@@ -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
|
||||
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
|
||||
`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
|
||||
runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports
|
||||
|
||||
@@ -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
|
||||
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
|
||||
surface into `godot/Main.Audio.cs`. The partial class retains the same node type, fields, signatures, and
|
||||
call sites; runtime validation, including all 590 engine tests and the Godot threaded self-test, remains
|
||||
green after each move.
|
||||
surface into `godot/Main.Audio.cs`, then decoder staging, movie frame/audio publication, completion, and
|
||||
teardown into `godot/Main.Movie.cs`. The partial class retains the same node type, fields, signatures,
|
||||
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
|
||||
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
|
||||
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
|
||||
isolated, move the `Main` movie playback surface next, then one existing domain at a time while preserving
|
||||
public types, commands, and generated output.
|
||||
by the tracked launcher and layered validation driver. With the embedded `Main` self-test, audio, and movie
|
||||
surfaces isolated, move the retained compositor surface next, then one existing domain at a time while
|
||||
preserving public types, commands, and generated output.
|
||||
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.
|
||||
|
||||
124
godot/Main.Movie.cs
Normal file
124
godot/Main.Movie.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
117
godot/Main.cs
117
godot/Main.cs
@@ -44,15 +44,6 @@ public partial class Main : Godot.Control
|
||||
private DebugSceneLauncher? _debugSceneLauncher;
|
||||
private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>();
|
||||
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 PageLocatorState _locator = null!;
|
||||
private bool _locatorHudVisible;
|
||||
@@ -1608,114 +1599,6 @@ public partial class Main : Godot.Control
|
||||
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()
|
||||
{
|
||||
_pageCount++;
|
||||
|
||||
Reference in New Issue
Block a user