diff --git a/README.en.md b/README.en.md index 490ee77..f01fa5d 100644 --- a/README.en.md +++ b/README.en.md @@ -123,6 +123,9 @@ See [capture architecture and testing](docs/capture-architecture.md). - Full recordings and death replays use independent encoding/editing sessions sharing one pixel/FMOD source. Death replays retain the latest 30 seconds by default, configurable from 10 to 60 seconds, save after death, and resume automatically after respawn. +- Death replays begin on the first frame of a fade-in (or according to the configured replay + length) and end on the first frame of the next segment's fade-in (respawn or level entry); + consecutive replays join on that frame. - Continuous H.264 death replays reuse the live encoder's packets rather than encoding video twice; adjacent room/music metadata splits keep this fast path. MP4 edit lists hide decoder preroll at non-keyframe cuts while retaining SFX, reconstructed BGM and room-specific music policy. diff --git a/README.md b/README.md index c3c32be..8bc70cb 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ Windows 下,关注的玩家换房间且 Celeste 不在前台时,会发送系 可选的键盘或手柄按键,默认均不绑定。 - 完整录像和死亡回放使用独立的编码/剪辑会话,共享唯一的像素和 FMOD 采集源;死亡回放默认保留最近 30 秒, 可设置为 10–60 秒,并在死亡后自动保存、复活后继续录制。 +- 死亡回放从渐入的第一帧(或根据预设回放时长)开始,到下一段(复活/进入关卡)渐入的第一帧结束; + 相邻回放在同一帧衔接。 - 连续的 H.264 死亡回放会直接复用采集时已编码的视频,不再二次编码;普通房间/BGM 元数据切分不影响快速保存。 非关键帧开头通过 MP4 edit list 隐藏解码预滚,保留音效、BGM 重构和敏感房间策略。 暂停造成的剪切、开启“剪辑冻结帧”、不兼容的码流会自动走原精确剪辑流程。 diff --git a/Source/AutoRecorder.cs b/Source/AutoRecorder.cs index 026d9af..23de507 100644 --- a/Source/AutoRecorder.cs +++ b/Source/AutoRecorder.cs @@ -48,7 +48,9 @@ public static class AutoRecorder { private static bool deathReplayBranchActive; private static bool deathReplayWaitingForStablePlayer; private static bool deathReplayPauseSuspended; - private static bool deathReplayFinalizeRequested; + private static ulong deathReplayQueuedEpoch; + private static double deathReplayClipStartSeconds; + private static string deathReplayDeathRoom = ""; private static bool fullRecordingEnabled; private static bool reconstructBgm; private static readonly RecordingSessionState SessionState = new(); @@ -171,7 +173,7 @@ public static void Update(Level level) { } ObserveRoom(level); - bool carryingGolden = !player.Dead && CarriesGolden(player); + bool carryingGolden = PlayerLifecycle.IsAlive(player) && CarriesGolden(player); SessionState.Observe(settings.AutomaticRecording, carryingGolden); // Mode changes are explicit user actions; save an existing auto session rather // than discarding it or silently changing its ownership. @@ -188,10 +190,11 @@ public static void AfterEngineUpdate() { current?.CheckpointRecovery(); deathReplayCurrent?.CheckpointRecovery(); FlushPendingRecordingStop(); - if (deathReplayFinalizeRequested && DeathAnimationFinished()) { - deathReplayFinalizeRequested = false; - // Keep capturing through the death animation and its final black wipe. + // The lifecycle ends a death replay on the first frame of the next segment + // (respawn wipe / level entry), then the next replay starts on that same frame. + if (PlayerLifecycle.AdvanceOnce(Engine.Scene as Level).LeftDying) { FinalizeDeathReplayCapture(); + if (MicroblocksQolUtilsModule.Settings.DeathReplayEnabled) StartDeathReplayRecording(); } if (manualSlSuspended && MicroblocksQolUtilsModule.Settings.DeathReplayEnabled && deathReplayCurrent is null) { StartDeathReplayRecording(); @@ -219,13 +222,13 @@ private static void UpdateFullRecording(Level level, Player player, QolSettings SuspendForPause(); return; } - if (current is null && PlayerIsRecordable(level, player)) StartRunRecording(level); + if (current is null && PlayerLifecycle.IsControllable(level, player)) StartRunRecording(level); NativeRoomRecording? recording = current; if (recording is null) return; - if (pauseSuspended && PlayerIsRecordable(level, player) && PauseOverlayCleared(level)) { + if (pauseSuspended && PlayerLifecycle.IsControllable(level, player) && PauseOverlayCleared(level)) { ResumeFullRecordingAfterPause(recording); - } else if (waitingForStablePlayer && PlayerIsRecordable(level, player)) { + } else if (waitingForStablePlayer && PlayerLifecycle.IsControllable(level, player)) { StartBranchAtCurrentTime(seamlessFromPrevious: resumeFromSavedState); } @@ -249,24 +252,26 @@ private static void UpdateFullRecording(Level level, Player player, QolSettings private static void UpdateDeathReplayRecording(Level level, Player player, QolSettings settings) { if (!settings.DeathReplayEnabled) { - if (deathReplayCurrent is not null || deathReplayFinalizeRequested) + if (deathReplayCurrent is not null) DiscardDeathReplayRecording(); return; } if (manualSlSuspended) return; - if (deathReplayFinalizeRequested) return; + if (PlayerLifecycle.InDeathSequence) return; if (level.Paused && !MicroblocksQolUtilsModule.Settings.RecordingKeepPausedFrames) { SuspendDeathReplayForPause(); return; } - if (deathReplayCurrent is null && PlayerIsRecordable(level, player)) + // Begin on the black frame where the next scene starts fading in. + if (deathReplayCurrent is null + && (PlayerLifecycle.IsControllable(level, player) || level.Wipe is ScreenWipe { WipeIn: true })) StartDeathReplayRecording(); NativeRoomRecording? recording = deathReplayCurrent; if (recording is null) return; - if (deathReplayPauseSuspended && PlayerIsRecordable(level, player) && PauseOverlayCleared(level)) { + if (deathReplayPauseSuspended && PlayerLifecycle.IsControllable(level, player) && PauseOverlayCleared(level)) { ResumeDeathReplayAfterPause(recording); - } else if (deathReplayWaitingForStablePlayer && PlayerIsRecordable(level, player)) { + } else if (deathReplayWaitingForStablePlayer && PlayerLifecycle.IsControllable(level, player)) { StartDeathReplayBranchAtCurrentTime(); } @@ -410,16 +415,17 @@ bool registerDeathInStats PlayerDeadBody? body = orig(self, direction, evenIfInvincible, registerDeathInStats); if (body is null) return body; RecordingTransitionAutoSave.Cancel(); - - if (deathReplayCurrent is not null) { - QueueDeathReplay(self, deathReplayCurrent); - deathReplayBranchActive = false; - deathReplayBranchSeamlessFromPrevious = false; - deathReplayWaitingForStablePlayer = true; + // Every real death is registered; the lifecycle owns the phase from here. + PlayerLifecycle.NotifyDeath(self.Scene as Level); + + if (deathReplayCurrent is { } deathReplay) { + // ESC -> retry kills the player while paused, so reopen the branch here, + // or the death cutscene is never captured. + if (!deathReplayBranchActive) StartDeathReplayBranchAtCurrentTime(seamlessFromPrevious: false); + double bufferSeconds = Math.Clamp(MicroblocksQolUtilsModule.Settings.DeathReplayBufferSeconds, 10, 60); + deathReplayClipStartSeconds = Math.Max(0, deathReplay.TimelineTimeSeconds - bufferSeconds); + deathReplayDeathRoom = (self.Scene as Level)?.Session.Level ?? recordingRoomName ?? "room"; deathReplayPauseSuspended = false; - // Player.Die runs from inside EntityList.Update; the capture is stopped later by - // AfterEngineUpdate rather than doing synchronous FMOD teardown in this hook. - deathReplayFinalizeRequested = true; } if (current is null || pendingRecordingStop is not null) return body; @@ -510,6 +516,7 @@ ref bool shouldDissociateEntities private static void BeginRun(Level level) { SessionState.ResetRun(keepManualRequest: true); + PlayerLifecycle.ResetRun(); runKey = RunKey(level); areaSid = level.Session.Area.SID; observedRespawnPoint = level.Session.RespawnPoint; @@ -620,13 +627,21 @@ internal static void SuspendForManualSl(bool loading = false) { DeathReplayPrefix.Add(deathClip); branchActive = deathReplayBranchActive = false; manualSlSuspended = true; + // Loading rewinds the death, so end the replay on this frame and start the next one here. + if (loading && PlayerLifecycle.Rewind().LeftDying) { + FinalizeDeathReplayCapture(); + if (MicroblocksQolUtilsModule.Settings.DeathReplayEnabled) { + StartDeathReplayRecording(); + deathReplayBranchActive = false; + } + } } internal static void ManualSlPresented(ulong timestamp) { if (!CapturePresentationGate.AcceptGameplay || !manualSlSuspended || SpeedrunToolAutoSave.ManualOperationActive || Engine.Scene is not Level level || level.Paused || level.Wipe is not null || !PauseOverlayCleared(level) - || level.Tracker.GetEntity() is not { } player || !PlayerIsRecordable(level, player)) return; + || level.Tracker.GetEntity() is not { } player || !PlayerLifecycle.IsControllable(level, player)) return; // Observe SRT, never unfreeze it. Resume video at its first clean presented // frame, without waiting for a QolHud update or pausing the game ourselves. ObserveRoom(level); @@ -663,11 +678,6 @@ internal static void StageInternalRecoveryResume() { saveSuspendedFull = current; if (MicroblocksQolUtilsModule.Settings.DeathReplayEnabled && deathReplayCurrent is null) StartDeathReplayRecording(); - if (deathReplayCurrent is { } death) { - saveSuspendedDeath = death; - deathReplayBranchActive = false; - deathReplayWaitingForStablePlayer = false; - } } internal static bool TryResumeAfterInternalSave() { @@ -812,25 +822,29 @@ private static void FinalizeCurrent(Level level) { ResetFullRecordingState(); } - private static void QueueDeathReplay(Player player, NativeRoomRecording recording) { - QolSettings settings = MicroblocksQolUtilsModule.Settings; - if (!settings.DeathReplayEnabled) return; + private static void QueueDeathReplay(NativeRoomRecording recording) { + if (PlayerLifecycle.DeathEpoch == deathReplayQueuedEpoch + || !MicroblocksQolUtilsModule.Settings.DeathReplayEnabled) return; - double bufferSeconds = Math.Clamp(settings.DeathReplayBufferSeconds, 10, 60); - List clips = CaptureRecentDeathReplayClips(recording, bufferSeconds); + QolSettings settings = MicroblocksQolUtilsModule.Settings; + double end = recording.TimelineTimeSeconds; + double start = Math.Min(deathReplayClipStartSeconds, end); + double windowSeconds = end - start; + if (windowSeconds <= 0) return; + List clips = CaptureRecentDeathReplayClips(recording, windowSeconds); bool unedited = !settings.RecordingEditingEnabled; if (unedited) { - double end = recording.TimelineTimeSeconds; - clips = [new(recording.Path, Math.Max(0, end - bufferSeconds), Math.Min(end, bufferSeconds), "", 0, BgmFollowsVideo: true)]; + clips = [new(recording.Path, start, windowSeconds, "", 0, BgmFollowsVideo: true)]; } if (clips.Count == 0) return; + // Mark the death consumed only once it actually produced an export. + deathReplayQueuedEpoch = PlayerLifecycle.DeathEpoch; - Level? level = player.Scene as Level; PendingDeathReplays.Add(new PendingDeathReplay( clips, DateTime.Now, - level?.Session.Area.SID ?? areaSid, - level?.Session.Level ?? "room", + areaSid, + deathReplayDeathRoom.Length > 0 ? deathReplayDeathRoom : "room", !unedited && reconstructBgm, !unedited && settings.RecordingRemoveFreezeFrames )); @@ -913,6 +927,7 @@ double seconds private static void FinalizeDeathReplayCapture() { NativeRoomRecording? recording = deathReplayCurrent; + if (recording is not null) QueueDeathReplay(recording); deathReplayCurrent = null; if (recording is not null) { FinishStoppedRecording(recording, recording.StopAsync(), TakeDeathReplayJobs()); @@ -931,22 +946,6 @@ private static bool HasUncollectedGolden(Level level) { && !collectible.IsCollected(level.Session)); } - private static bool DeathAnimationFinished() { - if (Engine.Scene is not Level level) return true; - // A death replay must include the complete death cutscene and the wipe to black. - // Once the wipe/cutscene is gone, the next update can safely finalize it. - return !level.InCutscene && !level.SkippingCutscene && !level.Transitioning && level.Wipe is null; - } - - private static bool PlayerIsRecordable(Level level, Player player) { - // A freshly respawned Player is already non-dead while the respawn wipe/animation is - // still running. Starting a retained branch there puts the tail of the death sequence - // back into manually and automatically saved videos. - return !player.Dead - && !level.Transitioning - && player.StateMachine.State != Player.StIntroRespawn; - } - private static bool PauseOverlayCleared(Level level) { return !level.Paused && QolSettingsOverlay.ActivePage is null @@ -1006,7 +1005,6 @@ private static void DiscardCurrentRecording() { } private static void DiscardDeathReplayRecording() { - deathReplayFinalizeRequested = false; NativeRoomRecording? recording = deathReplayCurrent; deathReplayCurrent = null; if (recording is not null) FinishStoppedRecording(recording, recording.StopAsync(), []); @@ -1017,6 +1015,7 @@ private static void DiscardDeathReplayRecording() { private static void StopAndReset(bool deleteSource) { NativeRoomRecording? recording = current; NativeRoomRecording? deathRecording = deathReplayCurrent; + if (deleteSource && deathRecording is not null) QueueDeathReplay(deathRecording); current = null; deathReplayCurrent = null; if (recording is not null) { @@ -1181,6 +1180,7 @@ private static void DeleteTemporaryFiles(IEnumerable files) { private static void ResetTimelineState() { manualSlSuspended = false; + PlayerLifecycle.ResetRun(); ResetFullRecordingState(); runKey = ""; areaSid = ""; @@ -1216,7 +1216,9 @@ private static void ResetDeathReplayState(bool waitForStablePlayer, bool keepRec deathReplayWaitingForStablePlayer = waitForStablePlayer; deathReplayPauseSuspended = false; deathReplayPauseResumeAfterMediaSeconds = null; - deathReplayFinalizeRequested = false; + deathReplayClipStartSeconds = 0; + deathReplayDeathRoom = ""; + deathReplayQueuedEpoch = PlayerLifecycle.DeathEpoch; if (!keepRecording) deathReplayCurrent = null; } diff --git a/Source/InstantDeaths.cs b/Source/InstantDeaths.cs index 3178f7f..988ff13 100644 --- a/Source/InstantDeaths.cs +++ b/Source/InstantDeaths.cs @@ -29,11 +29,7 @@ public static void AfterEngineUpdate() { return; } - // PlayerDeadBody is not tracked by vanilla Monocle. Ask Everest to register the - // type on demand instead of indexing Tracker.Entities through GetEntity(). - PlayerDeadBody? body = level.Tracker - .GetEntitiesTrackIfNeeded() - .FirstOrDefault() as PlayerDeadBody; + PlayerDeadBody? body = PlayerLifecycle.Latest.DeadBody; if (body is null) { reloadedBody = null; return; diff --git a/Source/MiniMapRenderer.cs b/Source/MiniMapRenderer.cs index 6f50595..fe3c25e 100644 --- a/Source/MiniMapRenderer.cs +++ b/Source/MiniMapRenderer.cs @@ -125,7 +125,7 @@ private static bool TryResolvePlayerCenter(Level level, Player? player, out Vect return true; } - PlayerDeadBody? deadBody = level.Entities.FindFirst(); + PlayerDeadBody? deadBody = PlayerLifecycle.Latest.DeadBody; if (deadBody is null) { center = default; return false; diff --git a/Source/PlayerLifecycle.cs b/Source/PlayerLifecycle.cs new file mode 100644 index 0000000..b9d8120 --- /dev/null +++ b/Source/PlayerLifecycle.cs @@ -0,0 +1,111 @@ +using Monocle; + +namespace Celeste.Mod.MicroblocksQolUtils; + +internal enum PlayerLiveState { + Alive, // player present and playable + Dying, // death registered; dead body animating / death cutscene + Respawning, // post-death reload / load: respawn wipe-in / StIntroRespawn, until playable + EnteringLevel, // entering a level / room (chapter start, transition, rebuild), until playable +} + +internal readonly record struct PlayerLiveSnapshot( + PlayerLiveState State, + bool EnteredDying, + bool LeftDying, + bool EnteredRespawning, + bool EnteredAlive, + bool PlayerAlive, + bool PlayerDead, + bool PlayerControllable, + bool InCutscene, + PlayerDeadBody? DeadBody); + +internal static class PlayerLifecycle { + private static PlayerLiveState state = PlayerLiveState.Alive; + + private static bool playerAlive, playerDead, playerControllable, inCutscene; + private static PlayerDeadBody? deadBody; + + internal static PlayerLiveState State => state; + internal static bool InDeathSequence => state == PlayerLiveState.Dying; + internal static PlayerLiveSnapshot Latest { get; private set; } + internal static ulong DeathEpoch { get; private set; } + + // Re-reads the live fields from the scene; the phase machine is untouched. + // NotifyDeath / Rewind call this too, since they run outside the frame cadence. + private static void Sample(Level? level) { + Player? playerNow = level?.Tracker.GetEntity(); + deadBody = level?.Entities.FindFirst(); + playerAlive = playerNow is { Dead: false }; + playerDead = playerNow is { Dead: true }; + playerControllable = level is not null && IsControllable(level, playerNow); + inCutscene = level is not null && (level.InCutscene || level.SkippingCutscene); + } + + internal static PlayerLiveSnapshot NotifyDeath(Level? level = null) { + DeathEpoch++; + Sample(level ?? Engine.Scene as Level); + return Enter(PlayerLiveState.Dying); + } + + internal static PlayerLiveSnapshot Rewind(Level? level = null) { + Sample(level ?? Engine.Scene as Level); + return Enter(state == PlayerLiveState.Dying ? PlayerLiveState.Alive : state); + } + + internal static PlayerLiveSnapshot ResetRun() { + playerAlive = playerDead = playerControllable = inCutscene = false; + deadBody = null; + return Enter(PlayerLiveState.EnteringLevel); + } + + private static PlayerLiveSnapshot Enter(PlayerLiveState to) { + PlayerLiveState before = state; + state = to; + return Build(before); + } + + // The single per-frame sample: call it once per frame (AutoRecorder.AfterEngineUpdate), + // everyone else reads Latest; a second call would consume this frame's LeftDying. + internal static PlayerLiveSnapshot AdvanceOnce(Level? level) { + Sample(level); + PlayerLiveState before = state; + if (state == PlayerLiveState.Dying && (level is null || DeathSequenceSettled(level, deadBody))) + state = PlayerLiveState.Respawning; + else if (state is PlayerLiveState.Respawning or PlayerLiveState.EnteringLevel && playerControllable) + state = PlayerLiveState.Alive; + return Build(before); + } + + private static PlayerLiveSnapshot Build(PlayerLiveState before) { + PlayerLiveSnapshot snapshot = new( + State: state, + EnteredDying: before != PlayerLiveState.Dying && state == PlayerLiveState.Dying, + LeftDying: before == PlayerLiveState.Dying && state != PlayerLiveState.Dying, + EnteredRespawning: before != PlayerLiveState.Respawning && state == PlayerLiveState.Respawning, + EnteredAlive: before != PlayerLiveState.Alive && state == PlayerLiveState.Alive, + PlayerAlive: playerAlive, + PlayerDead: playerDead, + PlayerControllable: playerControllable, + InCutscene: inCutscene, + DeadBody: deadBody); + Latest = snapshot; + return snapshot; + } + + internal static bool IsControllable(Level level, Player? candidate) => + candidate is { Dead: false } + && !level.Transitioning + && candidate.StateMachine.State != Player.StIntroRespawn; + + internal static bool IsAlive(Player? candidate) => candidate is { Dead: false }; + + private static bool DeathSequenceSettled(Level level, PlayerDeadBody? deadBody) { + if (deadBody is null && level.Tracker.GetEntity() is { Dead: false }) return true; + if (level.InCutscene || level.SkippingCutscene) return false; + if (deadBody is not null || level.Transitioning) return false; + if (level.Wipe is ScreenWipe wipe) return wipe.WipeIn; + return false; + } +} diff --git a/Source/RecordingDeathRecovery.cs b/Source/RecordingDeathRecovery.cs index 971759d..1af0840 100644 --- a/Source/RecordingDeathRecovery.cs +++ b/Source/RecordingDeathRecovery.cs @@ -40,7 +40,7 @@ private static void End(On.Celeste.PlayerDeadBody.orig_End orig, PlayerDeadBody internal static void AfterEngineUpdate() { if (pendingBody is not { } body) return; if (body.Scene is not Level level || !ReferenceEquals(Engine.Scene, level) - || level.Tracker.GetEntity() is { Dead: false }) { + || PlayerLifecycle.Latest.PlayerAlive) { pendingBody = null; fallback = null; return; diff --git a/Source/RecordingTransitionAutoSave.cs b/Source/RecordingTransitionAutoSave.cs index 3f4132f..cd2edb9 100644 --- a/Source/RecordingTransitionAutoSave.cs +++ b/Source/RecordingTransitionAutoSave.cs @@ -98,10 +98,15 @@ internal static void AfterEngineUpdate() { // recording-start, room-transition or respawn-point-change requests. if (pendingLevel is not { } level) return; QolSettings settings = MicroblocksQolUtilsModule.Settings; - Player? player = level.Tracker.GetEntity(); if (!settings.Enabled || !settings.RecordingAutoSaveOnTransition || !AutoRecorder.IsRecording || !ReferenceEquals(Engine.Scene, level) || !ReferenceEquals(level.Session, pendingSession) - || level.Completed || player?.Dead == true) { + || level.Completed) { + Cancel(); + return; + } + // AutoRecorder.AfterEngineUpdate already sampled this frame (same Engine.Scene). + PlayerLiveSnapshot life = PlayerLifecycle.Latest; + if (life.PlayerDead) { Cancel(); return; } @@ -110,8 +115,8 @@ internal static void AfterEngineUpdate() { Cancel(); return; } - if (player is null || player.StateMachine.State == Player.StIntroRespawn - || level.Paused || level.InCutscene || level.SkippingCutscene || Engine.FreezeTimer > 0f + if (!life.PlayerControllable + || level.Paused || life.InCutscene || Engine.FreezeTimer > 0f || !AutoRecorder.CanSaveTransitionTimeline) return; // Deep-cloning may mutate EntityList. Never call SL from QolHud.Update or diff --git a/Tests/Recorder/Program.cs b/Tests/Recorder/Program.cs index 2c9217e..e0c9580 100644 --- a/Tests/Recorder/Program.cs +++ b/Tests/Recorder/Program.cs @@ -106,8 +106,9 @@ Tick(l); Check(AutoRecorder.IsRecording==(death==GoldenRecordingDeath.Continue),"golden death choice"); Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="自动录像")== (death==GoldenRecordingDeath.Save?1:0),"save failed attempt only when requested"); - Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==1,"death replay independently saves"); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0,"death replay waits for the whole death sequence"); var exit=new LevelExit(); Everest.Events.Level.Exit(l,exit,LevelExit.Mode.GoldenBerryRestart); Everest.Events.Level.End(l,exit); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==1,"death replay independently saves"); var resumed=new Level(); var respawned=new Player{Scene=resumed}; resumed.Tracker.Player=respawned; Tick(resumed);Advance(3);Tick(resumed); Check(AutoRecorder.IsRecording==(death==GoldenRecordingDeath.Continue),"continue survives golden LevelExit without pickup"); @@ -205,35 +206,37 @@ Check(clips.Count==2 && clips[1].SeamlessFromPrevious,"validated saved-state recovery still crossfades"); Check(clips.Sum(c=>c.DurationSeconds)==5,"seamless recovery kept failed gameplay"); } -// Real orchestrator: an internal recovery opens from the accepted source frame, -// not the later wall clock / first QolHud.Update. Full and death sinks may lag -// independently; both must be ready before opening either branch. +// An internal recovery reopens the full recording from its accepted source frame +// (not the later wall clock / first QolHud.Update). +// A death replay keeps recording until the death sequence settles, +// so it is never closed mid-cutscene. { var(l,p,b)=Begin(AutoRecordingMode.Chapter,replay:true); Tick(l); Advance(3); AutoRecorder.SuspendForInternalSave(100); var saved=AutoRecorder.CaptureTimeline(l)!; Advance(1); AutoRecorder.ResumeAfterInternalSave(200); Advance(2); On.Celeste.Player.Raise(p); AutoRecorder.AfterEngineUpdate(); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0, + "death replay closed inside the death sequence"); var full=NativeRoomRecording.Started.First(); p.Dead=false; AutoRecorder.RestoreTimeline(l,saved); AutoRecorder.StageInternalRecoveryResume(); var death=NativeRoomRecording.Started.Last(); - Check(!ReferenceEquals(full,death) && !death.Stopped,"recovery did not create a fresh death sink while frozen"); - full.ResumeFrameTimestamp=death.ResumeFrameTimestamp=0; - full.MediaTimeSeconds=10; death.MediaTimeSeconds=1; + Check(!ReferenceEquals(full,death) && !death.Stopped,"recovery disturbed the death sink mid-cutscene"); + full.ResumeFrameTimestamp=0; full.MediaTimeSeconds=10; death.MediaTimeSeconds=1; AutoRecorder.PrepareInternalSaveResume(1000); - Check(full.RequestedFrame==1000 && death.RequestedFrame==1000,"recovery did not request a GOP boundary from both sinks"); - full.ResumeFrameTimestamp=1001; full.AcceptedFrameTime=10; + Check(full.RequestedFrame==1000,"recovery did not request a GOP boundary from the full sink"); Advance(0.5); - Check(!AutoRecorder.TryResumeAfterInternalSave(),"full sink released recovery before death sink accepted the frame"); + Check(!AutoRecorder.TryResumeAfterInternalSave(),"full sink reopened without an accepted frame"); Check(AutoRecorder.CaptureTimeline(l)!.Clips.SequenceEqual(saved.Clips),"GPU wait entered the saved prefix"); - death.ResumeFrameTimestamp=1002; death.AcceptedFrameTime=1.2; - Check(AutoRecorder.TryResumeAfterInternalSave(),"both accepted frames did not resume recovery"); - Advance(2); On.Celeste.Player.Raise(p); AutoRecorder.AfterEngineUpdate(); + full.ResumeFrameTimestamp=1001; full.AcceptedFrameTime=10; + Check(AutoRecorder.TryResumeAfterInternalSave(),"accepted full frame did not resume recovery"); + Advance(2); + Check(AutoRecorder.IsDeathReplayRecording,"death replay ended before the scene settled"); + AutoRecorder.AfterEngineUpdate(); var replay=NativeRecordingFinalizer.Jobs.Last(j=>j.Description=="死亡回放"); Check(replay.PreferVideoCopy,"recovery regressed death replay to mandatory full video transcode"); - Check(replay.Clips.Count==1 && replay.Clips[0].StartSeconds==1.2 && replay.Clips[0].SeamlessFromPrevious, - "death replay did not start at its accepted clean keyframe"); + Check(replay.Clips.Count>0 && replay.Clips.Sum(c=>c.DurationSeconds)>0,"recovery dropped the death replay"); p.Dead=false; AutoRecorder.RestoreTimeline(l,saved); AutoRecorder.StageInternalRecoveryResume(); full.AcceptedFrameTime=20; full.MediaTimeSeconds=20.3; AutoRecorder.PrepareInternalSaveResume(2000); @@ -243,6 +246,54 @@ Check(clips.Count==2 && clips[0].DurationSeconds==3 && clips[1].StartSeconds==20 && clips[1].SeamlessFromPrevious,"repeated recovery retained failed gameplay or used resume wall time/crossfade"); } +// A death replay carries the whole death cutscene and ends on the black frame +// where the respawn wipe appears, and the next replay begins on that same frame. +{ + var(l,p,b)=Begin(AutoRecordingMode.Chapter,replay:true); Tick(l); Advance(4); + On.Celeste.Player.Raise(p); Tick(l); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0,"death replay closed at the death moment"); + l.Entities.DeadBody=new PlayerDeadBody(); + for(int i=0;i<3;i++){Advance(0.2);Tick(l);} + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0,"death replay closed during the death cutscene"); + l.Wipe=new(){WipeIn=false,Percent=0.5f}; Advance(0.3); Tick(l); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0,"death replay closed on the wipe-out"); + l.Wipe.Percent=1f; Advance(0.2); Tick(l); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0,"death replay closed before the respawn wipe"); + // The reload replaces the room and the respawn wipe appears while the screen is black. + l.Entities.DeadBody=null; p.Dead=false; l.Wipe=new(){WipeIn=true,Percent=0.0f}; Advance(0.1); Tick(l); + var replay=NativeRecordingFinalizer.Jobs.Last(j=>j.Description=="死亡回放"); + Check(replay.Clips[0].StartSeconds==0 && Math.Abs(replay.Clips.Sum(c=>c.DurationSeconds)-5.2)<0.01, + "death replay did not run from the pre-death buffer to the black frame"); + Check(AutoRecorder.IsDeathReplayRecording,"next death replay did not begin on the black frame"); +} +// ESC -> retry kills the player while still paused, +// and pausing had closed the retained death branch. +// The death cutscene and its wipe must still be recorded. +{ + var(l,p,b)=Begin(AutoRecordingMode.Chapter,replay:true); Tick(l); Advance(3); + l.Paused=true; Tick(l); Advance(5); + On.Celeste.Player.Raise(p); Tick(l); + l.Paused=false; + l.Entities.DeadBody=new PlayerDeadBody(); Advance(0.4); Tick(l); + l.Wipe=new(){WipeIn=false,Percent=0.5f}; Advance(0.3); Tick(l); + l.Entities.DeadBody=null; p.Dead=false; l.Wipe=new(){WipeIn=true,Percent=0f}; Advance(0.1); Tick(l); + var replay=NativeRecordingFinalizer.Jobs.Last(j=>j.Description=="死亡回放"); + Check(replay.Clips.Any(c=>c.StartSeconds>7.9 && c.DurationSeconds>0.5), + "pause→retry death cutscene and wipe were not recorded"); +} +// A golden death replaces the whole Level. +// The next replay must still begin on the restart's fade-in. +{ + var(l,p,b)=Begin(AutoRecordingMode.Golden,GoldenRecordingDeath.Discard,replay:true); Pickup(p,b); Tick(l); Advance(4); + On.Celeste.Player.Raise(p); Tick(l); + var exit=new LevelExit(); Everest.Events.Level.Exit(l,exit,LevelExit.Mode.GoldenBerryRestart); Everest.Events.Level.End(l,exit); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==1,"golden death replay not saved"); + var resumed=new Level(); var respawned=new Player{Scene=resumed}; + respawned.StateMachine.State=Player.StIntroRespawn; + resumed.Tracker.Player=respawned; resumed.Wipe=new(){WipeIn=true,Percent=0.0f}; + Tick(resumed); + Check(AutoRecorder.IsDeathReplayRecording,"next replay missed the golden restart fade-in"); +} // Saved branches contain immutable edit references to one append-only source. // Loading/clearing game slots must not recycle the source backing older routes. { @@ -337,5 +388,58 @@ Check(RecordingLibrary.KindOf(root,Path.Combine(root,"autonomous","a.mp4"))==RecordingLibraryKind.Full,"directory boundary must be exact"); Check(RecordingLibrary.KindOf(root,Path.Combine(root,"deaths","map","a.mp4"))==RecordingLibraryKind.DeathReplay,"classify deaths"); Check(RecordingLibrary.KindOf(root,Path.Combine(root,"legacy-map","a.mp4"))==RecordingLibraryKind.Full,"legacy output preserved"); +// A manual load during the death cutscene +// ends the replay on the load frame and starts the next one there. +// It must never splice the cutscene into the restored state, +// even when the loaded level still reports a cutscene/transition. +foreach(bool deepCutscene in new[]{false,true}) { + var(l,p,b)=Begin(AutoRecordingMode.Chapter,replay:true); Tick(l); Advance(4); + On.Celeste.Player.Raise(p); Tick(l); + l.Entities.DeadBody=new PlayerDeadBody(); Advance(0.6); Tick(l); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0,"load replay ended before the load"); + AutoRecorder.SuspendForManualSl(loading:true); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==1,"manual load did not end the death replay"); + var loaded=NativeRecordingFinalizer.Jobs.Last(j=>j.Description=="死亡回放"); + Check(loaded.Clips.Count==1 && loaded.Clips[0].StartSeconds==0 + && Math.Abs(loaded.Clips.Sum(c=>c.DurationSeconds)-4.6)<0.01,"manual load did not stop the replay on the load frame"); + Check(AutoRecorder.IsDeathReplayRecording,"next replay did not start at the load"); + // The restored save fades in; ManualSlPresented reopens the new recording. + l.Entities.DeadBody=null; p.Dead=false; l.InCutscene=deepCutscene; l.Wipe=new(){WipeIn=true,Percent=0f}; + Advance(0.5); Tick(l); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==1,"loaded level produced a spliced replay"); + l.Wipe=null; + AutoRecorder.ManualSlPresented(1000); Advance(1); Tick(l); + l.InCutscene=false; Advance(0.2); Tick(l); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==1,"manual-sl resume reproduced the spliced replay"); + Check(AutoRecorder.IsDeathReplayRecording,"death replay stopped after the load"); +} +// PlayerLifecycle owns the player live-state phases. +{ + var l=new Level(); var p=new Player{Scene=l}; l.Tracker.Player=p; + PlayerLifecycle.ResetRun(); + Check(PlayerLifecycle.State==PlayerLiveState.EnteringLevel,"run starts in EnteringLevel"); + Check(PlayerLifecycle.AdvanceOnce(l).EnteredAlive,"level entry settles to Alive"); + Check(!PlayerLifecycle.AdvanceOnce(l).EnteredAlive && PlayerLifecycle.State==PlayerLiveState.Alive,"Alive is stable"); + Check(PlayerLifecycle.NotifyDeath().EnteredDying && PlayerLifecycle.InDeathSequence,"death latches Dying"); + l.Entities.DeadBody=new PlayerDeadBody(); + Check(!PlayerLifecycle.AdvanceOnce(l).LeftDying,"dead body keeps Dying"); + l.Entities.DeadBody=null; p.Dead=false; l.Wipe=new(){WipeIn=true,Percent=0f}; + var respawn=PlayerLifecycle.AdvanceOnce(l); + Check(respawn.LeftDying && respawn.EnteredRespawning,"respawn wipe starts the next segment"); + l.Wipe=null; + Check(PlayerLifecycle.AdvanceOnce(l).EnteredAlive,"respawn settles to Alive"); + PlayerLifecycle.NotifyDeath(); + Check(PlayerLifecycle.Rewind().LeftDying && PlayerLifecycle.State==PlayerLiveState.Alive,"manual load rewinds the death"); +} +// A death that happened with no replay open must never be exported by a later recording. +{ + var(l,p,b)=Begin(AutoRecordingMode.Chapter,replay:false); Tick(l); Advance(3); + On.Celeste.Player.Raise(p); Tick(l); + MicroblocksQolUtilsModule.Settings.DeathReplayEnabled=true; + l.Entities.DeadBody=null; p.Dead=false; l.Wipe=new(){WipeIn=true,Percent=0f}; Advance(0.1); Tick(l); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0,"a death with no open replay was exported"); + Advance(3); Everest.Events.Level.End(l,new Scene()); + Check(NativeRecordingFinalizer.Jobs.Count(j=>j.Description=="死亡回放")==0,"a later recording exported the earlier death"); +} AutoRecorder.Unload(); Console.WriteLine($"Recorder regression checks passed: {checks}"); diff --git a/Tests/Recorder/Recorder.csproj b/Tests/Recorder/Recorder.csproj index 8c13a0e..fac7d3b 100644 --- a/Tests/Recorder/Recorder.csproj +++ b/Tests/Recorder/Recorder.csproj @@ -3,6 +3,7 @@ + diff --git a/Tests/Recorder/Stubs.cs b/Tests/Recorder/Stubs.cs index 923616d..1f48ffb 100644 --- a/Tests/Recorder/Stubs.cs +++ b/Tests/Recorder/Stubs.cs @@ -9,15 +9,16 @@ public static class Engine { public static Scene? Scene; } public class Scene { } public class Entity { public Scene? Scene { get; set; } } public class Tracker { public Celeste.Player? Player; public T? GetEntity() where T:class => Player as T; } - public class EntityList { public T? FindFirst() where T:class => null; } + public class EntityList { public object? DeadBody; public T? FindFirst() where T:class => DeadBody as T; } } namespace Celeste { public class Level : Scene { public Tracker Tracker = new(); public EntityList Entities = new(); public Session Session = new(); - public bool Paused, Transitioning, Completed; + public bool Paused, Transitioning, Completed, InCutscene, SkippingCutscene; public ScreenWipe? Wipe; } public class Session { public AreaKey Area = new(); public Vector2? RespawnPoint; public object MapData = new(); public string Level = "room"; } + public class MapData { } public class AreaKey { public string SID = "test/chapter"; public int Mode; } public class Player : Entity { public const int StIntroRespawn=14; public bool Dead; public Leader Leader = new(); public StateMachine StateMachine = new(); } public class StateMachine { public int State; } @@ -27,7 +28,7 @@ public class Follower { public Entity Entity = null!; public Leader? Leader; } public class PlayerDeadBody { } public class LevelData { public string Name="next"; } public class TextMenu { } - public class ScreenWipe { } + public class ScreenWipe { public bool WipeIn; public float Percent; } public class HiresSnow { } public class LevelExit : Scene { public enum Mode { GoldenBerryRestart, GiveUp } } } @@ -91,13 +92,16 @@ public static class RecordingTransitionAutoSave { public static string? CurrentV public static class SpeedrunToolBridge { public static void Load(){} public static void Unload(){} } public static class SpeedrunToolAutoSave { public static bool ManualOperationActive; } public static class RhythmMapDetector { public static bool IsRhythmSensitive(object map, string room) => false; } + public enum MiniMapCollectibleKind { GoldenBerry } + public class MiniMapCollectible { public MiniMapCollectibleKind Kind; public string Room=""; public bool IsCollected(Celeste.Session s)=>false; } + public static class MapCollectibleCache { public static List Get(Celeste.MapData map)=>[]; } public class MaterialModOptions { } public static class QolSettingsOverlay { public static object? ActivePage => null; } public readonly record struct MusicPosition(string Event,int TimelineMilliseconds) { public static MusicPosition Read()=>new("",0); } public class NativeRoomRecording { public static List Started=[]; public static bool FailNextStart; - public string Path=""; public string AudioPath=>Path+".audio"; public string BgmPath=>Path+".bgm"; public string MusicEventsPath=>Path+".music"; public string CaptureReportPath=>Path+".capture.json"; public string RecoveryManifestPath=>Path+".recovery.json"; + public string Path=""; public string AudioPath=>Path+".audio"; public string BgmPath=>Path+".bgm"; public string MusicEventsPath=>Path+".music"; public string SfxEventsPath=>Path+".sfxevents"; public string CaptureReportPath=>Path+".capture.json"; public string RecoveryManifestPath=>Path+".recovery.json"; public void CheckpointRecovery() { } public double MediaTimeSeconds; public bool HasAudioTap=>true; public double TimelineTimeSeconds => MediaTimeSeconds; diff --git a/Tests/Recording/Recording.csproj b/Tests/Recording/Recording.csproj index e664b42..bbcfade 100644 --- a/Tests/Recording/Recording.csproj +++ b/Tests/Recording/Recording.csproj @@ -6,6 +6,7 @@ + diff --git a/Tests/Recording/Stubs.cs b/Tests/Recording/Stubs.cs index 3f3ad30..416f063 100644 --- a/Tests/Recording/Stubs.cs +++ b/Tests/Recording/Stubs.cs @@ -45,6 +45,7 @@ public class Level : Monocle.Scene { public Tracker Tracker = new(); public Monocle.EntityList Entities = []; public bool Completed, Transitioning, Paused, InCutscene, SkippingCutscene; + public ScreenWipe? Wipe; public bool GoldenMarked; public bool TimerMarked => Session.GetFlag("SpeedrunTool_SavedSate"); public int Position, CameraPosition, SceneFrame, Reloads; @@ -55,6 +56,7 @@ public class Tracker { public T? GetEntity() where T : class => Player as T; } public class Player { public const int StIntroRespawn = 14; public bool Dead; public StateMachine StateMachine = new(); } + public class ScreenWipe { public bool WipeIn; public float Percent; } public class PlayerSeeker : Monocle.Entity; public class StateMachine { public int State; } public class PlayerDeadBody : Monocle.Entity { diff --git a/docs/capture-architecture.md b/docs/capture-architecture.md index c395311..14cd294 100644 --- a/docs/capture-architecture.md +++ b/docs/capture-architecture.md @@ -166,7 +166,8 @@ SFX 按源视频片段裁切,music 按输出时间线连续重放,再交给 - 保存按 `Boundary → Indicator → Save/Cloning → Clean → ResumeFrame` 推进,整个过程跳过游戏更新。 Boundary 是保存姿态第一次干净呈现的 end-exclusive 边界;不把保存 UI 的首帧当作姿态边界。 - 内部死亡恢复同样冻结更新;恢复实体后不等待 QolHud.Update 重新开启分支,而是在冻结状态下 - 请求两路 sink 的恢复关键帧,按各自实际接受的源帧时间戳开启分支,再放行物理更新。 + 请求完整录像 sink 的恢复关键帧,按其实际接受的源帧时间戳开启分支,再放行物理更新。 + 死亡回放不参与这个恢复门:它保持死亡期间已打开的分支,继续录到复活渐入的黑帧才最终化。 捕获尚未结束的死亡音效在新分支之前停止。没有有效存档的死亡仍使用普通复活流程。 - 队列延迟不会选择旧帧;恢复首个物理步不使用保存/读档耗时,首次恢复绘制前最多放行一个物理步, 避免 fixed-step catch-up 连跑多步。普通绘制也可解除此补步门,停止采集不会造成更新死锁。 @@ -222,6 +223,24 @@ SFX 按源视频片段裁切,music 按输出时间线连续重放,再交给 不改视频时长或加入画面 crossfade。正常连续波形、纯 metadata 分段不动,event-rendered BGM 在此步骤后混入。 旧的混合 BGM sidecar 无法分离时不做这项 SFX 去爆音,避免误伤音乐。 +### 死亡回放采集窗口 + +玩家生命阶段由 `PlayerLifecycle` 统一:4 个线性状态(`Alive`/`Dying`/`Respawning`/`EnteringLevel`)带各自的 +上升/下降沿,另加正交信号(`PlayerAlive`/`PlayerDead`/`PlayerControllable`/`InCutscene`)和本帧的 `DeadBody` 引用。 +`AutoRecorder` 每帧只调用一次 `AdvanceOnce`(上升/下降沿只由跨越它的那一次采样产生,调用点必须唯一), +`RecordingDeathRecovery`、`RecordingTransitionAutoSave`、`InstantDeaths`、`MiniMapRenderer` 都读只读快照 `Latest`, +不再各自判定 `player.Dead`/`PlayerDeadBody`/`StIntroRespawn`/wipe/过场;`NotifyDeath`/`Rewind` 这两个帧外入口各自重采样一次。死亡回放在“离开 `Dying`”的第一帧收尾,下一轮从同一帧开始。 + +- 起点:`死亡时刻 − 缓冲`(`DeathReplayBufferSeconds`,10–60s,录不到则夹到 0),即裁剪区间从死前缓冲开始。 +- 终点:穿过整段死亡过场(`InCutscene`/`SkippingCutscene`),到**下一段渐入的第一帧**(复活 wipe 的 `WipeIn` + 黑帧,或场景已可玩)才结束;没有 wipe 时,还需 `PlayerDeadBody` 消失、不在 `Transitioning` 且玩家已复活。 + 该帧即离开 `Dying` 的第一帧(`EnteredRespawning`,不是可玩帧),也是下一轮录制的起点; + 裁剪区间为「死亡时刻 − 缓冲」到该帧,包含死前缓冲与完整死亡演出,而不只是死亡前最近的一段。 +- `Player.Die` 只登记死亡并保持死亡分支打开(暂停中 ESC→retry 会先重开分支); + 帧末在同一黑帧立即开始下一段回放,使连续回放无缝衔接。 +- 死亡过场中途手动 load 时,在 load 帧就收尾并立即开下一轮:SRT 的 load 是原地换状态 + 渐入 wipe(没有渐出), + 等载入后的关卡再收尾会与 `ManualSlPresented` 抢跑,把死亡过场拼接到恢复后的状态。载入等待照例排除。 + ### 死亡回放快速最终化 - 只有死亡回放任务设置 `prefer_video_copy`;旧 JSON 默认为 false,完整录像仍走原剪辑器。 diff --git a/docs/recording-transition-save.md b/docs/recording-transition-save.md index d265cfc..be677a8 100644 --- a/docs/recording-transition-save.md +++ b/docs/recording-transition-save.md @@ -48,8 +48,13 @@ 其他未选中的手动槽不会全局禁用自动恢复;手动 SL 只分段录像,不使用内部冻结门。 - 金草莓重开章节、自定义 `DeathAction`、PlayerSeeker、切面中、跨房间或旧录制的状态不接管。 `DeathAction == null` 或显式的 `level.Reload` 属于正常复活。 -- hook 只取消死亡协程并排队;帧末先结束死亡回放,再读内部槽并恢复录像时间线。 +- hook 只取消死亡协程并排队;死亡回放不再在帧末提前结束,而是保持死亡期间的分支继续录完整段死亡演出, + 到复活渐入的黑帧(`level.Wipe` 的 `WipeIn`)才最终化,随后读内部槽并恢复录像时间线。 失败时只回退一次原死亡流程,不把玩家留在死锁状态;离开场景后不会调用旧场景的回调。 +- 死亡过场中途手动 load 时,死亡回放按 load 帧截断保存并在同处开始下一轮,不再等载入后的关卡: + 否则会与 `ManualSlPresented` 抢跑,把死亡过场拼进恢复后的状态。载入等待照例排除,下一轮接在恢复帧之后。 +- 生命阶段(活着 / 死亡中 / 重生 / 进入关卡)由 `PlayerLifecycle` 统一;死亡接管与切面自动保存都读它的每帧快照 + (含 `PlayerAlive`/`PlayerDead`/`PlayerControllable`/`InCutscene` 等正交信号),不再各自判定 `player.Dead`/`StIntroRespawn`/过场。 - 内部恢复不依赖用户的 `AutoLoadStateAfterDeath` 设置。正常死亡的会话/总计/章节时间和死亡数 在恢复后保留,即使用户手动 SL 设置启用了 `SaveTimeAndDeaths`。