From a1a28e45cc5a2e8e8152f8381aabd9b66bb3cdac Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:31:48 +0700 Subject: [PATCH] A far cinematic shot waits at black for its world, as 1.12.1's does (#2392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cinematic shot that moved the camera far faded in as soon as its path started, so its terrain and models popped in on screen. 1.12.1's shot arm (`0x48edf0`, shared by start and advance) reloads the world in place when the camera moves more than 30 yards (`0x48eed3` → `0x66fc80` → `0x694920`). That happens at full black. It sends `CMSG_NEXT_CINEMATIC_CAMERA` only afterwards (`0x48ef11`). Now a far shot holds its black, with its clock at 0, until the spot is resident or the ceiling is reached. Then it announces, starts its narration and fades in. The gate, the residency release and the ceiling are the teleport hold's, shared. The shot, the teleport and the end's far reload bump one reload mark that re-arms the stream's load. Tests: far and near shots, the ceiling, a skip, the announce and narration timing, and the re-arm. Fixes #2370 šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/cinematic.rs | 705 ++++++++++++++++++- crates/benilla-app/src/player.rs | 3 + crates/benilla-app/src/player/state.rs | 3 + crates/benilla-app/src/player/wire_in.rs | 10 +- crates/benilla-app/src/player/world_focus.rs | 74 +- crates/benilla-formats/src/cinematics.rs | 34 + crates/benilla-world/src/terrain_stream.rs | 104 ++- docs/DEVIATIONS.md | 10 +- 8 files changed, 907 insertions(+), 36 deletions(-) diff --git a/crates/benilla-app/src/cinematic.rs b/crates/benilla-app/src/cinematic.rs index 51018a1f4..cc4882495 100644 --- a/crates/benilla-app/src/cinematic.rs +++ b/crates/benilla-app/src/cinematic.rs @@ -9,7 +9,7 @@ //! - The path plays as an M2 animation (`0x7121a0`, sequence 0, rate 1.0), and a shot ends at its //! sequence band's end ([`CinematicPath::duration_ms`]); shipped fly-bys clamp and play once. //! - Every shot, the first included, is announced with `CMSG_NEXT_CINEMATIC_CAMERA` from the shot -//! arm (`0x48edf0`, sent at `0x48ef11`). A camera id of 0 ends the cinematic (`0x48efe0`). +//! arm (`0x48edf0`, sent at `0x48ef11`, after a far shot's load). A camera id of 0 ends the cinematic (`0x48efe0`). //! - ESC is no engine binding: `StopCinematic` has no native caller, and the only skip is //! `CinematicFrame.xml`'s `OnKeyDown`, whose Lua queues //! [`SessionRequest::StopCinematic`](benilla_ui::script::SessionRequest). @@ -19,6 +19,13 @@ //! - Every boundary (start, shot advance, end) cuts through black: a 0.25 s fade-out //! (`[0x804550]`, `0x4c0d10`) whose completion runs the step, then a fade-in (`0x4c1280`). There //! is no audio fade on this path. +//! - A shot that moves the camera more than 30 yards (`0x48eec1`; start and advance share the +//! arm `0x48edf0`) loads its destination in full before it fades in: `0x48eed3` calls +//! `0x66fc80`, which runs the synchronous map update `0x694920` at the black, and only then are +//! the clock restarted, the shot's sound played (`0x48ef2e`) and the shot announced. Here the +//! black is held instead, until the camera's spot is presentable or the teleport hold's +//! ceiling runs out ([`ShotHold`]). The end does the same for the body's spot when the camera +//! ends more than 30 yards from the followed object (`0x48f0fc` to `0x48f118`). //! //! Playback takes over, and releases, the camera pose, the streaming focus (which follows the //! camera, so the flight is over streamed terrain) and the UI's cinematic flag, which drives @@ -35,7 +42,7 @@ use bevy::prelude::*; use crate::client_state::ClientState; use crate::loading_screen::LoadingScreen; use crate::net::{CinematicTriggeredMessage, ClientCommand, NetCommands}; -use crate::player::PlayerControlSet; +use crate::player::{PlayerControlSet, SETTLE_TIMEOUT}; use crate::ui_script::SweepApp; /// Both cinematic DBCs, read once at startup. @@ -56,6 +63,26 @@ pub(crate) struct Cinematic { /// A skip not yet paid: `StopCinematic` runs EndCinematic as its fade-out's completion /// (`0x48f067`, `0x48f080`), so [`stop`] sets this and [`drive`] pays it at full black. skip: bool, + /// A far shot's black, held until the world at its first eye is in ([`ShotHold`]). + hold: Option, + /// How many far shots have asked for a reload so far: the stream's focus carries it, so its + /// load re-arms for each (a cinematic's focus is unpaced throughout, with no pacing edge). + reloads: u32, +} + +/// The black a shot that moved the camera past the reload gate holds before it fades in. +/// +/// The reference's arm (`0x48edf0`, and `0x48efe0` into it) loads that spot synchronously with the +/// screen at black and only then restarts the shot's clock and fades in (`0x48eed3` to `0x48ef43`). +/// The stream cannot block, so the black holds with the shot's clock stopped. It ends on the same +/// residency a teleport's hold does ([`crate::player::residency_reached`] and a presentable +/// world), or at [`SETTLE_TIMEOUT`] from the shot's arm, which nothing extends. +struct ShotHold { + /// `Time::elapsed_secs` at which the hold gives up. + ceiling: f32, + /// The arm's own frame: the stream has not yet seen the camera at the new spot, so the world + /// it reports is the last shot's. + fresh: bool, } /// One cinematic in flight. @@ -90,9 +117,23 @@ impl Cinematic { self.playing.is_some() } + /// The far reloads shots have asked for so far. + pub(crate) fn reloads(&self) -> u32 { + self.reloads + } + + /// A far shot asks the stream for a new load. + pub(crate) fn mark_reload(&mut self) { + self.reloads = self.reloads.wrapping_add(1); + } + /// The shot on screen: `(run, shot index, narration sound id)`, keyed on [`Playing::run`] so a - /// re-trigger counts as a new shot. + /// re-trigger counts as a new shot. None while a far shot's world loads (the black holds): its + /// sound plays after the load (`0x48ef2e`), and the shot left had its sound cut at the advance. pub(crate) fn playing_shot(&self) -> Option<(u64, usize, u32)> { + if self.hold.is_some() { + return None; + } let play = self.playing.as_ref()?; Some((play.run, play.index, play.shot().sound_id)) } @@ -122,8 +163,24 @@ impl Cinematic { }), settling: None, skip: false, + hold: None, + reloads: 0, } } + + /// A cinematic playing `shots`, for tests of the driver. + pub(crate) fn playing_shots_for_test(shots: Vec) -> Self { + let mut cine = Self::playing_for_test(); + if let Some(play) = cine.playing.as_mut() { + play.shots = shots; + } + cine + } + + /// Is a far shot's black being held for its world? + pub(crate) fn holds_black_for_shot(&self) -> bool { + self.hold.is_some() + } } /// One of the two letterbox bars, full-width black, top or bottom. @@ -330,6 +387,7 @@ fn take_trigger( fn relinquish(cine: &mut Cinematic, net: Option<&NetCommands>, why: &str) { if let Some(play) = cine.playing.take() { info!("cinematic: {} {why}", play.sequence_id); + cine.hold = None; ack(net); } if let Some(id) = cine.pending.take() { @@ -347,6 +405,8 @@ fn start_pending( net: Option>, state: Res>, mut fade: ResMut, + time: Res