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
|
`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
|
||||||
|
|||||||
@@ -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
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 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++;
|
||||||
|
|||||||
Reference in New Issue
Block a user