A far cinematic shot waits at black for its world, as 1.12.1's does (#2392)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ShotHold>,
|
||||
/// 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<CinematicPath>) -> 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<Res<NetCommands>>,
|
||||
state: Res<State<ClientState>>,
|
||||
mut fade: ResMut<crate::screen_fade::ScreenFade>,
|
||||
time: Res<Time>,
|
||||
camera: Query<&Transform, With<WorldCamera>>,
|
||||
) {
|
||||
let Some(id) = cine.pending else { return };
|
||||
// The world-load gate: in the world, where `CinematicFrame` (the only ESC route) exists, and
|
||||
@@ -397,6 +457,7 @@ fn start_pending(
|
||||
);
|
||||
ack(net.as_deref());
|
||||
}
|
||||
cine.hold = None;
|
||||
static RUN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
cine.playing = Some(Playing {
|
||||
run: RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
@@ -405,12 +466,72 @@ fn start_pending(
|
||||
index: 0,
|
||||
elapsed: Duration::ZERO,
|
||||
});
|
||||
announce_shot(net.as_deref());
|
||||
// `0x48ef43`, the tail of the shot arm: the shot fades in from the black it was armed under.
|
||||
fade.fade_in(crate::screen_fade::CINEMATIC);
|
||||
let from = camera.single().ok().map(|t| t.translation);
|
||||
open_shot(
|
||||
&mut cine,
|
||||
&mut fade,
|
||||
net.as_deref(),
|
||||
from,
|
||||
time.elapsed_secs(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `CMSG_NEXT_CINEMATIC_CAMERA`, sent as each shot is armed, the first included (`0x48ef11`).
|
||||
/// The tail of the shot arm (`0x48edf0`), for the first shot and every advance alike: the shot
|
||||
/// comes in from the black it was armed under (`0x48ef43`), unless its first eye is more than 30
|
||||
/// yards from the camera (`0x48eec1`), where the arm first loads that spot at the black
|
||||
/// (`0x48eed3`) and restarts the shot's clock. So a far shot keeps the black with its clock at
|
||||
/// zero ([`ShotHold`]); `from` is the camera as it stands, none with no camera to move. The shot's
|
||||
/// `CMSG_NEXT_CINEMATIC_CAMERA` (`0x48ef11`) follows that load, so a far shot sends it when the
|
||||
/// hold ends.
|
||||
fn open_shot(
|
||||
cine: &mut Cinematic,
|
||||
fade: &mut crate::screen_fade::ScreenFade,
|
||||
net: Option<&NetCommands>,
|
||||
from: Option<Vec3>,
|
||||
now: f32,
|
||||
) {
|
||||
let Some(play) = cine.playing.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let eye = benilla_assets::coords::wow_to_bevy(play.shot().sample(0).eye);
|
||||
if from.is_some_and(|from| crate::player::is_far_reload(from, eye)) {
|
||||
play.elapsed = Duration::ZERO;
|
||||
cine.mark_reload();
|
||||
cine.hold = Some(ShotHold {
|
||||
ceiling: now + SETTLE_TIMEOUT,
|
||||
fresh: true,
|
||||
});
|
||||
info!("cinematic: far shot — the black holds while its world loads");
|
||||
} else {
|
||||
announce_shot(net);
|
||||
fade.fade_in(crate::screen_fade::CINEMATIC);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a held far shot's world in? `Some(true)` once the camera's spot is resident and presentable,
|
||||
/// `Some(false)` when the ceiling ran out first, `None` while it waits. With no world progress to
|
||||
/// read, or no camera, there is nothing to wait on.
|
||||
fn shot_hold_over(
|
||||
hold: &mut ShotHold,
|
||||
progress: Option<&benilla_world::terrain_stream::WorldLoadProgress>,
|
||||
at: Option<Vec3>,
|
||||
now: f32,
|
||||
) -> Option<bool> {
|
||||
// The arm's own frame reads a world that has not seen the new spot.
|
||||
if std::mem::take(&mut hold.fresh) {
|
||||
return None;
|
||||
}
|
||||
if now >= hold.ceiling {
|
||||
return Some(false);
|
||||
}
|
||||
let (Some(p), Some(at)) = (progress, at) else {
|
||||
return Some(true);
|
||||
};
|
||||
(crate::player::residency_reached(p, at) && p.presentable()).then_some(true)
|
||||
}
|
||||
|
||||
/// `CMSG_NEXT_CINEMATIC_CAMERA`, sent as each shot is shown, the first included (`0x48ef11`): at
|
||||
/// its arm, or for a far shot once its world is in ([`open_shot`]).
|
||||
/// vmangos ignores it (`MiscHandler.cpp:919`) and flies its camera copy on its own clock.
|
||||
fn announce_shot(net: Option<&NetCommands>) {
|
||||
if let Some(net) = net {
|
||||
@@ -434,6 +555,7 @@ fn drive(
|
||||
mut fade: ResMut<crate::screen_fade::ScreenFade>,
|
||||
progress: Option<Res<benilla_world::terrain_stream::WorldLoadProgress>>,
|
||||
screen: Option<Res<LoadingScreen>>,
|
||||
player: Option<Res<crate::player::Player>>,
|
||||
mut camera: Query<&mut Transform, With<WorldCamera>>,
|
||||
) {
|
||||
// --- The end boundary's tail: hold the black while the world comes back around the body.
|
||||
@@ -462,15 +584,21 @@ fn drive(
|
||||
|
||||
// A skip with only a latch in flight is settled by [`stop`], so here a skip has a shot to fade.
|
||||
let skip = cine.skip;
|
||||
// A far shot's clock restarts once its world is in (`0x50ca10` after the load), so it stands
|
||||
// at zero under the black.
|
||||
let holding = cine.hold.is_some();
|
||||
let camera_at = camera.single().ok().map(|t| t.translation);
|
||||
let Some(play) = cine.playing.as_mut() else {
|
||||
return;
|
||||
};
|
||||
play.elapsed += time.delta();
|
||||
if !holding {
|
||||
play.elapsed += time.delta();
|
||||
}
|
||||
|
||||
// --- Every boundary cuts through black: a run-out shot or a skip fades out and steps at full
|
||||
// black, as the fade's completion (`0x48efd7` to `0x48efe0` to advance, `0x48f067` to
|
||||
// `0x48f080` to stop), which keeps the cut invisible under a hitch.
|
||||
if skip || play.elapsed.as_millis() as u32 >= play.shot().duration_ms {
|
||||
if skip || (!holding && play.elapsed.as_millis() as u32 >= play.shot().duration_ms) {
|
||||
if !fade.is_black() {
|
||||
fade.fade_out(crate::screen_fade::CINEMATIC);
|
||||
// Fall through to seat the camera: what darkens is the shot's own last pose.
|
||||
@@ -478,6 +606,7 @@ fn drive(
|
||||
// Walk past every shot this frame's delta ran through, each owed its
|
||||
// `CMSG_NEXT_CINEMATIC_CAMERA`, inside one black.
|
||||
let mut ended = skip;
|
||||
let mut passed = 0usize;
|
||||
while !ended {
|
||||
let play = cine.playing.as_mut().expect("checked above");
|
||||
if (play.elapsed.as_millis() as u32) < play.shot().duration_ms {
|
||||
@@ -490,10 +619,20 @@ fn drive(
|
||||
}
|
||||
play.index += 1;
|
||||
play.elapsed = over;
|
||||
passed += 1;
|
||||
}
|
||||
// The shot left up announces itself from [`open_shot`], after its load; the ones
|
||||
// passed through are owed theirs now.
|
||||
for _ in 0..(if ended {
|
||||
passed
|
||||
} else {
|
||||
passed.saturating_sub(1)
|
||||
}) {
|
||||
announce_shot(net.as_deref());
|
||||
}
|
||||
if ended {
|
||||
cine.skip = false;
|
||||
cine.hold = None;
|
||||
if skip {
|
||||
// A skip takes a latched trigger with it, acked all the same.
|
||||
relinquish(&mut cine, net.as_deref(), "stopped");
|
||||
@@ -506,11 +645,45 @@ fn drive(
|
||||
}
|
||||
if ended {
|
||||
// Not faded in yet: the world around the body streamed away during the flight, so
|
||||
// hold the black the reference shows while its own load blocks.
|
||||
// hold the black the reference shows while its own load blocks. EndCinematic
|
||||
// reloads the body's spot only when the camera ended more than 30 yards from the
|
||||
// followed object (`0x48f0fc` to `0x48f118`); counts latched under the camera say
|
||||
// nothing of it, so the stream is asked for a new load behind the same gate.
|
||||
let body = player.as_deref().filter(|p| p.active).map(|p| p.pos);
|
||||
if camera_at
|
||||
.zip(body)
|
||||
.is_some_and(|(cam, body)| crate::player::is_far_reload(cam, body))
|
||||
{
|
||||
cine.mark_reload();
|
||||
}
|
||||
cine.settling = Some(SETTLE_BUDGET);
|
||||
return;
|
||||
}
|
||||
// `0x48ef43`: the next shot comes in from the black it was armed under.
|
||||
// `0x48ef43`: the next shot comes in from the black it was armed under, after its
|
||||
// world if it is far.
|
||||
open_shot(
|
||||
&mut cine,
|
||||
&mut fade,
|
||||
net.as_deref(),
|
||||
camera_at,
|
||||
time.elapsed_secs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- A far shot's black ends with its world, or at the ceiling.
|
||||
if let Some(hold) = cine.hold.as_mut().filter(|_| !skip) {
|
||||
let over = shot_hold_over(hold, progress.as_deref(), camera_at, time.elapsed_secs());
|
||||
if let Some(resident) = over {
|
||||
if !resident {
|
||||
warn!(
|
||||
"cinematic: the shot's world did not load inside the settle budget — \
|
||||
revealing anyway"
|
||||
);
|
||||
}
|
||||
cine.hold = None;
|
||||
// `0x48ef11`: the shot is announced after its load, then it fades in.
|
||||
announce_shot(net.as_deref());
|
||||
fade.fade_in(crate::screen_fade::CINEMATIC);
|
||||
}
|
||||
}
|
||||
@@ -595,6 +768,7 @@ fn abandon(cine: &mut Cinematic, fade: &mut crate::screen_fade::ScreenFade) {
|
||||
cine.pending = None;
|
||||
cine.skip = false;
|
||||
cine.settling = None;
|
||||
cine.hold = None;
|
||||
// Clear any fade: the glue comes up next and must not sit behind our black.
|
||||
fade.clear();
|
||||
}
|
||||
@@ -923,3 +1097,512 @@ mod settling_under_black {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A shot that moves the camera past the reload gate keeps the black until its world is in
|
||||
/// (`0x48eec1`, then the synchronous load `0x66fc80` at the black, `0x48eed3`).
|
||||
#[cfg(test)]
|
||||
mod far_shots {
|
||||
use super::*;
|
||||
use crate::net::ClientCommand;
|
||||
use benilla_formats::{CinematicCameraRow, CinematicPath};
|
||||
use benilla_world::terrain_stream::WorldLoadProgress;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// The receiving end of the cinematic's outgoing packets.
|
||||
#[derive(Resource)]
|
||||
struct Wire(crossbeam_channel::Receiver<ClientCommand>);
|
||||
|
||||
/// Where the camera stands before the shot, wow coordinates.
|
||||
const HERE: [f32; 3] = [-8_949.0, -132.0, 83.0];
|
||||
/// Ten minutes of flight from `HERE`, on another tile.
|
||||
const FAR: [f32; 3] = [-3_000.0, 2_500.0, 90.0];
|
||||
|
||||
fn tile_of(wow: [f32; 3]) -> (i32, i32) {
|
||||
let (tx, ty) = benilla_formats::world_to_tile(wow[0], wow[1]);
|
||||
(tx as i32, ty as i32)
|
||||
}
|
||||
|
||||
fn shot(eye: [f32; 3], ms: u32) -> CinematicPath {
|
||||
let row = CinematicCameraRow {
|
||||
id: 1,
|
||||
model: String::new(),
|
||||
sound_id: 0,
|
||||
origin: [0.0; 3],
|
||||
origin_facing: 0.0,
|
||||
};
|
||||
CinematicPath::held_pose(&row, eye, [eye[0] + 10.0, eye[1], eye[2]], ms)
|
||||
}
|
||||
|
||||
/// `HERE`, moved `yards` along x.
|
||||
fn from_here(yards: f32) -> [f32; 3] {
|
||||
[HERE[0] + yards, HERE[1], HERE[2]]
|
||||
}
|
||||
|
||||
/// The world's report about the tile at `wow`: in, or still out.
|
||||
fn world_at(wow: [f32; 3], resident: bool) -> WorldLoadProgress {
|
||||
WorldLoadProgress {
|
||||
ready: usize::from(resident),
|
||||
total: 1,
|
||||
focus_resident: resident,
|
||||
scene_ready: resident,
|
||||
focus_tile: Some(tile_of(wow)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn black() -> crate::screen_fade::ScreenFade {
|
||||
let mut fade = crate::screen_fade::ScreenFade::default();
|
||||
fade.fade_out(0.0);
|
||||
assert!(fade.is_black());
|
||||
fade
|
||||
}
|
||||
|
||||
/// The driver over a camera at `HERE`, at full black, the clock stepping 100 ms a frame.
|
||||
fn app_with(cine: Cinematic, world: WorldLoadProgress) -> App {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.insert_resource(cine)
|
||||
.insert_resource(NetCommands(tx))
|
||||
.insert_resource(Wire(rx))
|
||||
.insert_resource(black())
|
||||
.insert_resource(world)
|
||||
.init_resource::<Time>();
|
||||
app.world_mut().spawn((
|
||||
WorldCamera,
|
||||
Transform::from_translation(benilla_assets::coords::wow_to_bevy(HERE)),
|
||||
));
|
||||
app.add_systems(Update, drive);
|
||||
app
|
||||
}
|
||||
|
||||
/// One frame: the clock steps, then the driver runs.
|
||||
fn frame(app: &mut App) {
|
||||
app.world_mut()
|
||||
.resource_mut::<Time>()
|
||||
.advance_by(Duration::from_millis(100));
|
||||
app.update();
|
||||
}
|
||||
|
||||
fn black_now(app: &App) -> bool {
|
||||
app.world()
|
||||
.resource::<crate::screen_fade::ScreenFade>()
|
||||
.is_black()
|
||||
}
|
||||
|
||||
fn holding(app: &App) -> bool {
|
||||
app.world().resource::<Cinematic>().holds_black_for_shot()
|
||||
}
|
||||
|
||||
/// How many `CMSG_NEXT_CINEMATIC_CAMERA` went out since the last look.
|
||||
fn announced(app: &App) -> usize {
|
||||
app.world()
|
||||
.resource::<Wire>()
|
||||
.0
|
||||
.try_iter()
|
||||
.filter(|c| matches!(c, ClientCommand::NextCinematicCamera))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn set_world(app: &mut App, world: WorldLoadProgress) {
|
||||
app.insert_resource(world);
|
||||
}
|
||||
|
||||
/// What `start_pending` does with a started cinematic: the shot arm's tail, from the camera.
|
||||
fn open_first_shot(app: &mut App) {
|
||||
app.world_mut()
|
||||
.run_system_once(
|
||||
|mut cine: ResMut<Cinematic>,
|
||||
mut fade: ResMut<crate::screen_fade::ScreenFade>,
|
||||
time: Res<Time>,
|
||||
net: Res<NetCommands>,
|
||||
camera: Query<&Transform, With<WorldCamera>>| {
|
||||
let from = camera.single().ok().map(|t| t.translation);
|
||||
open_shot(&mut cine, &mut fade, Some(&net), from, time.elapsed_secs());
|
||||
},
|
||||
)
|
||||
.expect("runs");
|
||||
}
|
||||
|
||||
/// The first shot, far: the black holds through stale and half-loaded worlds, and the fade-in
|
||||
/// comes with the destination.
|
||||
#[test]
|
||||
fn a_far_shot_start_holds_the_black_until_its_world_is_in() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(FAR, 60_000)]),
|
||||
// The world as the body's spot left it, resident there.
|
||||
world_at(HERE, true),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
assert!(holding(&app), "past 30 yards the black is held");
|
||||
assert_eq!(
|
||||
app.world().resource::<Cinematic>().reloads(),
|
||||
1,
|
||||
"and the stream is asked for a new load"
|
||||
);
|
||||
assert!(black_now(&app), "and there is no fade-in");
|
||||
|
||||
// The old spot's residency is not the destination's, whatever the frame count.
|
||||
for _ in 0..5 {
|
||||
frame(&mut app);
|
||||
}
|
||||
assert!(black_now(&app) && holding(&app), "the old world is not it");
|
||||
set_world(&mut app, world_at(FAR, false));
|
||||
frame(&mut app);
|
||||
assert!(black_now(&app) && holding(&app), "its tile still loading");
|
||||
|
||||
set_world(&mut app, world_at(FAR, true));
|
||||
frame(&mut app);
|
||||
assert!(!holding(&app), "the destination is in");
|
||||
assert!(!black_now(&app), "and the shot fades in");
|
||||
}
|
||||
|
||||
/// The arm's own frame reads a world that has not seen the new spot, even when it reports the
|
||||
/// destination's tile (a shot within one tile of the camera).
|
||||
#[test]
|
||||
fn the_arms_own_frame_does_not_release_the_hold() {
|
||||
// 100 yards along x is far, and the same tile.
|
||||
let there = from_here(100.0);
|
||||
assert_eq!(tile_of(there), tile_of(HERE));
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(there, 60_000)]),
|
||||
world_at(there, true),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
frame(&mut app);
|
||||
assert!(
|
||||
holding(&app),
|
||||
"the stream has not looked from the new spot yet"
|
||||
);
|
||||
frame(&mut app);
|
||||
assert!(!holding(&app), "the next frame's report is about it");
|
||||
assert!(!black_now(&app));
|
||||
}
|
||||
|
||||
/// The gate is `d² > 900` (`0x48eec1`: the compare falls through for a strictly larger one).
|
||||
#[test]
|
||||
fn a_shot_within_30_yards_fades_in_at_once() {
|
||||
for yards in [0.0, 12.0, 29.5, 30.0] {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(from_here(yards), 60_000)]),
|
||||
world_at(HERE, false),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
assert!(!holding(&app), "{yards} yd holds nothing");
|
||||
assert_eq!(
|
||||
app.world().resource::<Cinematic>().reloads(),
|
||||
0,
|
||||
"{yards} yd asks for no load"
|
||||
);
|
||||
assert!(!black_now(&app), "{yards} yd fades in as it always has");
|
||||
}
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(from_here(30.5), 60_000)]),
|
||||
world_at(HERE, false),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
assert!(holding(&app) && black_now(&app), "30.5 yd holds");
|
||||
}
|
||||
|
||||
/// The shot's clock stands at zero under the hold and runs from release.
|
||||
#[test]
|
||||
fn the_shots_clock_restarts_when_the_world_is_in() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(FAR, 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
for _ in 0..8 {
|
||||
frame(&mut app);
|
||||
}
|
||||
let elapsed = |app: &App| {
|
||||
app.world()
|
||||
.resource::<Cinematic>()
|
||||
.playing
|
||||
.as_ref()
|
||||
.expect("playing")
|
||||
.elapsed
|
||||
};
|
||||
assert_eq!(
|
||||
elapsed(&app),
|
||||
Duration::ZERO,
|
||||
"no time passes under the black"
|
||||
);
|
||||
set_world(&mut app, world_at(FAR, true));
|
||||
frame(&mut app);
|
||||
assert!(!holding(&app));
|
||||
frame(&mut app);
|
||||
assert_eq!(elapsed(&app), Duration::from_millis(100), "then it runs");
|
||||
}
|
||||
|
||||
/// A world that never loads is let through at the teleport hold's budget from the arm.
|
||||
#[test]
|
||||
fn the_ceiling_releases_a_stalled_load_and_is_never_extended() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(FAR, 60_000)]),
|
||||
world_at(FAR, false),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
let frames = |secs: f32| (secs * 10.0).round() as usize;
|
||||
for i in 0..frames(SETTLE_TIMEOUT - 0.5) {
|
||||
// The stream keeps advancing, which a stall budget would count as progress.
|
||||
let mut world = world_at(FAR, false);
|
||||
world.ready = i;
|
||||
world.total = i + 1;
|
||||
set_world(&mut app, world);
|
||||
frame(&mut app);
|
||||
}
|
||||
assert!(
|
||||
holding(&app) && black_now(&app),
|
||||
"inside the budget it holds"
|
||||
);
|
||||
for _ in 0..frames(1.0) {
|
||||
frame(&mut app);
|
||||
}
|
||||
assert!(!holding(&app), "past it the hold is over");
|
||||
assert!(
|
||||
!black_now(&app),
|
||||
"and the shot fades in on the world as it is"
|
||||
);
|
||||
}
|
||||
|
||||
/// A shot's advance is the same arm: the next shot, far from where the last one ended, keeps
|
||||
/// the black; a near one does not.
|
||||
#[test]
|
||||
fn a_far_shot_advance_holds_like_a_far_start() {
|
||||
let two = |next: [f32; 3]| {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(HERE, 1_000), shot(next, 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
// The first shot has run out, and the fade-out has reached black.
|
||||
app.world_mut()
|
||||
.resource_mut::<Cinematic>()
|
||||
.playing
|
||||
.as_mut()
|
||||
.expect("playing")
|
||||
.elapsed = Duration::from_millis(1_000);
|
||||
frame(&mut app);
|
||||
app
|
||||
};
|
||||
|
||||
let mut app = two(FAR);
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<Cinematic>()
|
||||
.playing
|
||||
.as_ref()
|
||||
.map(|p| p.index),
|
||||
Some(1),
|
||||
"the next shot is up"
|
||||
);
|
||||
assert!(holding(&app) && black_now(&app), "its world is not in");
|
||||
for _ in 0..5 {
|
||||
frame(&mut app);
|
||||
}
|
||||
assert!(
|
||||
holding(&app) && black_now(&app),
|
||||
"the last shot's world is not it"
|
||||
);
|
||||
set_world(&mut app, world_at(FAR, true));
|
||||
frame(&mut app);
|
||||
assert!(
|
||||
!holding(&app) && !black_now(&app),
|
||||
"its world is in, and it fades in"
|
||||
);
|
||||
|
||||
let app = two(from_here(10.0));
|
||||
assert!(
|
||||
!holding(&app) && !black_now(&app),
|
||||
"a near shot fades in at once"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference sends the shot's `CMSG_NEXT_CINEMATIC_CAMERA` after the synchronous load
|
||||
/// (`0x48ef11` follows `0x48eed3`), so a far start sends it with the world, a near one at once.
|
||||
#[test]
|
||||
fn a_far_start_announces_its_shot_when_the_world_is_in() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(FAR, 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
for _ in 0..5 {
|
||||
frame(&mut app);
|
||||
}
|
||||
assert_eq!(announced(&app), 0, "nothing goes out while it loads");
|
||||
set_world(&mut app, world_at(FAR, true));
|
||||
frame(&mut app);
|
||||
assert!(!holding(&app));
|
||||
assert_eq!(announced(&app), 1, "it goes out with the fade-in");
|
||||
frame(&mut app);
|
||||
assert_eq!(announced(&app), 0, "and only once");
|
||||
|
||||
let mut near = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(from_here(10.0), 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
open_first_shot(&mut near);
|
||||
assert_eq!(announced(&near), 1, "a near shot announces at the arm");
|
||||
}
|
||||
|
||||
/// The ceiling's release shows the shot, so it announces it too.
|
||||
#[test]
|
||||
fn a_stalled_far_start_announces_its_shot_at_the_ceiling() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(FAR, 60_000)]),
|
||||
world_at(FAR, false),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
for _ in 0..((SETTLE_TIMEOUT * 10.0) as usize + 2) {
|
||||
frame(&mut app);
|
||||
}
|
||||
assert!(!holding(&app));
|
||||
assert_eq!(announced(&app), 1);
|
||||
}
|
||||
|
||||
/// An advance is the same arm: one announce for the shot, with its world if it is far.
|
||||
#[test]
|
||||
fn a_far_advance_announces_its_shot_when_the_world_is_in() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(HERE, 1_000), shot(FAR, 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
app.world_mut()
|
||||
.resource_mut::<Cinematic>()
|
||||
.playing
|
||||
.as_mut()
|
||||
.expect("playing")
|
||||
.elapsed = Duration::from_millis(1_000);
|
||||
frame(&mut app);
|
||||
for _ in 0..3 {
|
||||
frame(&mut app);
|
||||
}
|
||||
assert!(holding(&app));
|
||||
assert_eq!(announced(&app), 0, "not while it loads");
|
||||
set_world(&mut app, world_at(FAR, true));
|
||||
frame(&mut app);
|
||||
assert_eq!(announced(&app), 1, "once, with the world");
|
||||
}
|
||||
|
||||
/// A hitch that runs through two shots owes each its announce: the one passed through at the
|
||||
/// black, and the one shown.
|
||||
#[test]
|
||||
fn a_hitch_through_two_shots_announces_both() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![
|
||||
shot(HERE, 1_000),
|
||||
shot(HERE, 1_000),
|
||||
shot(from_here(5.0), 60_000),
|
||||
]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
app.world_mut()
|
||||
.resource_mut::<Cinematic>()
|
||||
.playing
|
||||
.as_mut()
|
||||
.expect("playing")
|
||||
.elapsed = Duration::from_millis(2_000);
|
||||
frame(&mut app);
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<Cinematic>()
|
||||
.playing_shot()
|
||||
.map(|s| s.1),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(announced(&app), 2);
|
||||
}
|
||||
|
||||
/// The shot's narration starts after the load (`0x48ef2e` follows `0x48eed3`): the shot is not
|
||||
/// the one on screen, as the voice follows it, until its hold ends. A far first shot, as the
|
||||
/// Tauren intro's, and a far advance alike; the voice of the shot left is cut at the black.
|
||||
#[test]
|
||||
fn a_held_shot_is_not_narrated_until_its_world_is_in() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(FAR, 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
let on_screen = |app: &App| app.world().resource::<Cinematic>().playing_shot();
|
||||
assert!(on_screen(&app).is_some(), "before the arm it is the shot");
|
||||
open_first_shot(&mut app);
|
||||
for _ in 0..4 {
|
||||
frame(&mut app);
|
||||
}
|
||||
assert!(holding(&app));
|
||||
assert_eq!(on_screen(&app), None, "no voice under the load");
|
||||
set_world(&mut app, world_at(FAR, true));
|
||||
frame(&mut app);
|
||||
assert_eq!(
|
||||
on_screen(&app).map(|s| s.1),
|
||||
Some(0),
|
||||
"the voice starts with it"
|
||||
);
|
||||
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(HERE, 1_000), shot(FAR, 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
app.world_mut()
|
||||
.resource_mut::<Cinematic>()
|
||||
.playing
|
||||
.as_mut()
|
||||
.expect("playing")
|
||||
.elapsed = Duration::from_millis(1_000);
|
||||
frame(&mut app);
|
||||
assert!(holding(&app));
|
||||
assert_eq!(
|
||||
on_screen(&app),
|
||||
None,
|
||||
"the last shot's voice is cut, the next waits"
|
||||
);
|
||||
set_world(&mut app, world_at(FAR, true));
|
||||
frame(&mut app);
|
||||
assert_eq!(on_screen(&app).map(|s| s.1), Some(1));
|
||||
}
|
||||
|
||||
/// EndCinematic (`0x48f080`) reloads the body's spot when the camera ended more than 30 yards
|
||||
/// from the followed object (`0x48f0fc` to `0x48f118`), so the end asks the stream for a new
|
||||
/// load behind the same gate; counts latched under the camera say nothing of the body's.
|
||||
#[test]
|
||||
fn the_end_asks_for_a_load_only_when_the_camera_is_far_from_the_body() {
|
||||
let ended_with_body_at = |body: [f32; 3]| {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(HERE, 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
let mut player = crate::player::Player::default();
|
||||
player.active = true;
|
||||
player.pos = benilla_assets::coords::wow_to_bevy(body);
|
||||
app.insert_resource(player);
|
||||
app.world_mut().resource_mut::<Cinematic>().skip = true;
|
||||
frame(&mut app);
|
||||
let cine = app.world().resource::<Cinematic>();
|
||||
assert!(!cine.is_playing() && cine.settling.is_some(), "it ended");
|
||||
cine.reloads()
|
||||
};
|
||||
assert_eq!(
|
||||
ended_with_body_at(FAR),
|
||||
1,
|
||||
"the camera is far from the body"
|
||||
);
|
||||
assert_eq!(ended_with_body_at(from_here(29.5)), 0, "29.5 yd");
|
||||
assert_eq!(ended_with_body_at(from_here(30.5)), 1, "30.5 yd");
|
||||
}
|
||||
|
||||
/// ESC under a far shot's black ends the cinematic, and the hold goes with it.
|
||||
#[test]
|
||||
fn a_skip_under_the_hold_ends_it() {
|
||||
let mut app = app_with(
|
||||
Cinematic::playing_shots_for_test(vec![shot(FAR, 60_000)]),
|
||||
world_at(HERE, true),
|
||||
);
|
||||
open_first_shot(&mut app);
|
||||
frame(&mut app);
|
||||
assert!(holding(&app));
|
||||
app.world_mut().resource_mut::<Cinematic>().skip = true;
|
||||
frame(&mut app);
|
||||
let cine = app.world().resource::<Cinematic>();
|
||||
assert!(!cine.is_playing() && !cine.holds_black_for_shot());
|
||||
assert!(cine.settling.is_some(), "and the end holds its own black");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ mod controller;
|
||||
mod world_focus;
|
||||
#[cfg(test)]
|
||||
pub(crate) use world_focus::insert_test_room;
|
||||
/// The reload gate and the destination's residency, which a cinematic's far shot waits on as a
|
||||
/// teleport does.
|
||||
pub(crate) use world_focus::{is_far_reload, residency_reached};
|
||||
pub(crate) use world_focus::{publish_caster_outdoors, BarUnitOutdoors, PlayerOutdoors};
|
||||
// The remembered camera pose, inside `player/` to read the rig's `pub(super)` fields.
|
||||
mod camera_saved;
|
||||
|
||||
@@ -379,6 +379,9 @@ pub(crate) struct Player {
|
||||
/// by reloading the destination before the frame goes on (`0x617e90` calls `0x630bd0` for
|
||||
/// `d² > 900`): the frame is held until the settle hold ends ([`Self::holds_the_frame`]).
|
||||
pub(crate) world_reload: bool,
|
||||
/// How many far reloads the body's snaps have asked for ([`Self::world_reload`]'s marks), which
|
||||
/// the stream's focus carries so it re-arms its load on each.
|
||||
pub(crate) reloads: u32,
|
||||
/// When the reload hold gives up (`Time::elapsed_secs`): the stall budget from its snap, never
|
||||
/// extended, as the reference's wait-all ends once every read has, failed or not (`0x443d30`).
|
||||
pub(crate) reload_ceiling: f32,
|
||||
|
||||
@@ -464,9 +464,10 @@ fn snap_near_teleport(player: &mut Player, cam_yaw: &mut f32, t: &TeleportMessag
|
||||
/// `MovementHandler.cpp:252,255`) leaves a hold up, which only its release ends; a far one restarts
|
||||
/// the hold's ceiling.
|
||||
fn mark_world_reload(player: &mut Player, from: Vec3, to: Vec3, now: f32) -> bool {
|
||||
let far = !player.detached && from.distance_squared(to) > 30.0 * 30.0;
|
||||
let far = !player.detached && super::world_focus::is_far_reload(from, to);
|
||||
if far {
|
||||
player.world_reload = true;
|
||||
player.reloads = player.reloads.wrapping_add(1);
|
||||
player.reload_ceiling = now + SETTLE_TIMEOUT;
|
||||
}
|
||||
far
|
||||
@@ -966,6 +967,13 @@ mod teleport_tests {
|
||||
assert!(!holds([16_222.0, 16_281.5, 12.5], false), "29.5 yards");
|
||||
assert!(!holds(south_seas, true), "the eye does not follow");
|
||||
|
||||
assert_eq!(
|
||||
snapped(from, south_seas, false).reloads,
|
||||
1,
|
||||
"a far snap marks"
|
||||
);
|
||||
assert_eq!(snapped(from, [16_222.0, 16_281.5, 12.5], false).reloads, 0);
|
||||
|
||||
let mut player = snapped(from, south_seas, false);
|
||||
player.end_settle(true, 11.0);
|
||||
assert!(
|
||||
|
||||
@@ -77,6 +77,12 @@ pub(super) fn publish_view_focus(
|
||||
}
|
||||
}
|
||||
*was_flying = flying;
|
||||
// Far reloads, a teleport's snap or a cinematic's far cut, are one mark to the stream: a change
|
||||
// re-arms its load, which a pacing edge does only for a focus that was settled.
|
||||
let reloads = player
|
||||
.as_deref()
|
||||
.map_or(0, |p| p.reloads)
|
||||
.wrapping_add(cinematic.as_deref().map_or(0, |c| c.reloads()));
|
||||
// Spawn caps pace only a live avatar in a settled world; through entry, a teleport or a world
|
||||
// swap a cap would only lengthen the load.
|
||||
*focus = match player.as_deref() {
|
||||
@@ -97,7 +103,33 @@ pub(super) fn publish_view_focus(
|
||||
Some((map, pos)) => ViewFocus::entry(map, pos),
|
||||
None => ViewFocus::camera(),
|
||||
},
|
||||
};
|
||||
}
|
||||
.with_reload(reloads);
|
||||
}
|
||||
|
||||
/// The reference's reload gate: a forced move of more than 30 yards (`d² > 900`, `0x80c730` is
|
||||
/// 30.0 and the compare is strict, `0x617ee5`; the cinematic shot arm's gate `0x48eec1` compares
|
||||
/// the 30.0 of `0x804554`, and EndCinematic's `0x48f0fc`, camera against the followed object, that
|
||||
/// of `0x804558`) reloads the destination before the frame goes on. `from` and `to` are in one frame.
|
||||
pub(crate) fn is_far_reload(from: Vec3, to: Vec3) -> bool {
|
||||
from.distance_squared(to) > 30.0 * 30.0
|
||||
}
|
||||
|
||||
/// Is the load's focus the tile under `at` (a Bevy position)? Residency counts only for the tile
|
||||
/// the held thing stands on: on a mismatch (the snap frame, a free-fly eye) a release is refused.
|
||||
pub(crate) fn focus_is_under(p: &WorldLoadProgress, at: Vec3) -> bool {
|
||||
p.focus_tile.is_some_and(|t| {
|
||||
let wow = benilla_assets::coords::bevy_to_wow(at);
|
||||
let (tx, ty) = benilla_formats::world_to_tile(wow[0], wow[1]);
|
||||
t == (tx as i32, ty as i32)
|
||||
})
|
||||
}
|
||||
|
||||
/// The tile under `at` is in: this map's own tile (a WMO-only map's one building) is spawned or
|
||||
/// its read failed, which a swap reaches only after draining every tile of the map we left. A
|
||||
/// destination is presentable ([`WorldLoadProgress::presentable`]) only once this holds too.
|
||||
pub(crate) fn residency_reached(p: &WorldLoadProgress, at: Vec3) -> bool {
|
||||
p.focus_resident && p.total > 0 && focus_is_under(p, at)
|
||||
}
|
||||
|
||||
/// Ends the post-snap hold when the destination's world has arrived. Runs after the stream stage,
|
||||
@@ -112,14 +144,8 @@ pub(super) fn release_post_snap_hold(
|
||||
let Some(p) = progress else { return };
|
||||
// Residency counts only for the tile under the body. On a mismatch (the teleport's snap frame,
|
||||
// a free-fly eye) the release and the stale-clear are refused and only the backstop runs.
|
||||
let focus_matches = p.focus_tile.is_some_and(|t| {
|
||||
let wow = benilla_assets::coords::bevy_to_wow(player.pos);
|
||||
let (tx, ty) = benilla_formats::world_to_tile(wow[0], wow[1]);
|
||||
t == (tx as i32, ty as i32)
|
||||
});
|
||||
// Residency means this map's own tile (a WMO-only map's one building) is spawned, which a
|
||||
// swap reaches only after draining every tile of the map we left.
|
||||
if p.focus_resident && p.total > 0 && focus_matches {
|
||||
let focus_matches = focus_is_under(&p, player.pos);
|
||||
if residency_reached(&p, player.pos) {
|
||||
player.world_stale = false;
|
||||
}
|
||||
// Any counter moving is the destination still arriving; tracked every frame, so the first
|
||||
@@ -365,6 +391,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A teleport's far snap and a cinematic's far cut reach the stream as the same mark, so its
|
||||
/// load re-arms for either, with the focus unpaced or not.
|
||||
#[test]
|
||||
fn far_reloads_of_the_body_and_of_a_shot_both_reach_the_stream() {
|
||||
let mut app = App::new();
|
||||
app.insert_resource(ViewFocus::default())
|
||||
.insert_resource(Player {
|
||||
active: true,
|
||||
..Player::default()
|
||||
})
|
||||
.insert_resource(crate::cinematic::Cinematic::playing_for_test())
|
||||
.add_systems(Update, publish_view_focus);
|
||||
app.update();
|
||||
let mark = |app: &App| app.world().resource::<ViewFocus>().reload();
|
||||
let steady = mark(&app);
|
||||
app.update();
|
||||
assert_eq!(mark(&app), steady, "a frame with no reload keeps the mark");
|
||||
|
||||
app.world_mut().resource_mut::<Player>().reloads += 1;
|
||||
app.update();
|
||||
let after_snap = mark(&app);
|
||||
assert_ne!(after_snap, steady, "a far snap");
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::cinematic::Cinematic>()
|
||||
.mark_reload();
|
||||
app.update();
|
||||
assert_ne!(mark(&app), after_snap, "a far shot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_frame_arms_nothing() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -278,6 +278,40 @@ impl CinematicPath {
|
||||
})
|
||||
}
|
||||
|
||||
/// A shot that holds one eye and target (raw WoW coordinates) for `duration_ms`, built from no
|
||||
/// model: the seam for tests of whatever plays shots, which would otherwise need a shipped
|
||||
/// `Cameras\*.m2`.
|
||||
pub fn held_pose(
|
||||
row: &CinematicCameraRow,
|
||||
eye: [f32; 3],
|
||||
target: [f32; 3],
|
||||
duration_ms: u32,
|
||||
) -> Self {
|
||||
let camera = M2Camera {
|
||||
camera_type: -1,
|
||||
fov: 45f32.to_radians(),
|
||||
far_clip: 1000.0 / 36.0,
|
||||
near_clip: 8.0 / 36.0,
|
||||
positions: Default::default(),
|
||||
position_base: eye,
|
||||
target: Default::default(),
|
||||
target_base: target,
|
||||
roll: Default::default(),
|
||||
};
|
||||
Self {
|
||||
camera_id: row.id,
|
||||
sound_id: row.sound_id,
|
||||
fov: camera.fov,
|
||||
near_clip: camera.near_clip,
|
||||
far_clip: camera.far_clip,
|
||||
duration_ms,
|
||||
band_start: 0,
|
||||
origin: [0.0; 3],
|
||||
facing_sin_cos: 0f32.sin_cos(),
|
||||
camera,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample the shot `ms` after its start, clamped at both ends: each track composed against its
|
||||
/// base, then planted in the world by the row's origin and facing, with no scale or flip.
|
||||
pub fn sample(&self, ms: u32) -> CinematicView {
|
||||
|
||||
@@ -89,6 +89,8 @@ pub struct TerrainStreamer {
|
||||
/// [`ViewFocus::paced`] last frame: the residency latch re-arms on the edge into a load, since
|
||||
/// a viewer with no avatar is unpaced forever and the level would re-arm it every frame.
|
||||
was_paced: bool,
|
||||
/// [`ViewFocus::reload`] as last seen.
|
||||
reload_seen: u32,
|
||||
/// Last frame had nothing to request, drop or spawn and every tile stood furnished, so an
|
||||
/// unchanged frame skips the walk.
|
||||
settled: bool,
|
||||
@@ -116,6 +118,11 @@ struct StreamPlan {
|
||||
const GLOBAL_WMO_UID: u32 = u32::MAX;
|
||||
|
||||
impl TerrainStreamer {
|
||||
/// Whether a far reload was asked for since the last look ([`ViewFocus::reload`]).
|
||||
fn take_reload(&mut self, focus: &ViewFocus) -> bool {
|
||||
std::mem::replace(&mut self.reload_seen, focus.reload) != focus.reload
|
||||
}
|
||||
|
||||
/// Whether the loaded map, whose `Map.dbc` directory is `dir`, has its WDT's one-object flag
|
||||
/// set; false until that WDT has loaded.
|
||||
pub(crate) fn is_one_object_map(&self, dir: &str) -> bool {
|
||||
@@ -163,6 +170,17 @@ struct TileState {
|
||||
gave_up: bool,
|
||||
}
|
||||
|
||||
/// The counting of the load latches off once fully ready (`complete`); a new load re-arms it. A
|
||||
/// focus on unfurnished ground is one. So is a snap onto furnished ground, whose buildings and
|
||||
/// neighbours may still be arriving: on the edge into the load, not the level, and on a far
|
||||
/// reload's mark ([`ViewFocus::reload`]), which a focus already unpaced (a cinematic's) shows no
|
||||
/// edge for.
|
||||
fn rearm_load(p: &mut WorldLoadProgress, was_paced: bool, focus: &ViewFocus, reloaded: bool) {
|
||||
if !p.focus_resident || (was_paced && !focus.paced) || reloaded {
|
||||
p.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the load counts the tile as in: furnished, or its ADT read failed, as the reference's
|
||||
/// wait-all completes on a failed read (`0x443d30`) and loads on without the tile.
|
||||
fn tile_done(t: &TileState, server: &AssetServer) -> bool {
|
||||
@@ -311,6 +329,10 @@ pub struct ViewFocus {
|
||||
pub(crate) paced: bool,
|
||||
/// The eye is a cinematic's camera, which the tile pass follows as it follows a body.
|
||||
cinematic: bool,
|
||||
/// How many far reloads the owners of the view have asked for so far (a teleport's snap past
|
||||
/// 30 yards, a cinematic shot's far cut, `0x66fc80`). A change is a new load, whatever the
|
||||
/// pacing was: the stream re-arms its latch.
|
||||
reload: u32,
|
||||
}
|
||||
|
||||
impl ViewFocus {
|
||||
@@ -327,9 +349,20 @@ impl ViewFocus {
|
||||
entry: None,
|
||||
paced,
|
||||
cinematic: false,
|
||||
reload: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The far reloads asked for so far; only a change from the last frame's matters.
|
||||
pub fn with_reload(self, reload: u32) -> Self {
|
||||
Self { reload, ..self }
|
||||
}
|
||||
|
||||
/// The far reloads asked for so far.
|
||||
pub fn reload(&self) -> u32 {
|
||||
self.reload
|
||||
}
|
||||
|
||||
/// The stream follows the body's own ground, so residency is a fact about the player; a
|
||||
/// detached eye streams wherever the camera went.
|
||||
pub fn follows_body(&self) -> bool {
|
||||
@@ -678,6 +711,7 @@ fn stream_terrain(
|
||||
&& state.settled
|
||||
&& focus.paced
|
||||
&& state.was_paced == focus.paced
|
||||
&& state.reload_seen == focus.reload
|
||||
&& (wdt_index.is_some() || state.wdt_ungated)
|
||||
&& load_progress
|
||||
.as_ref()
|
||||
@@ -1060,6 +1094,7 @@ fn spawn_streamed_tiles(
|
||||
.values()
|
||||
.all(|t| t.entity.is_some() && t.furnished);
|
||||
let was_paced = std::mem::replace(&mut state.was_paced, focus.paced);
|
||||
let reloaded = state.take_reload(&focus);
|
||||
if let Some(p) = load_progress.as_mut() {
|
||||
p.focus_tile = Some((cx, cy));
|
||||
// Judged from the desired set, so a desired tile not yet requested is not there; ground
|
||||
@@ -1083,16 +1118,7 @@ fn spawn_streamed_tiles(
|
||||
.get(&GLOBAL_WMO_UID)
|
||||
.is_some_and(|p| p.spawned);
|
||||
}
|
||||
// The counting below latches off once fully ready (`complete`); a focus on unfurnished
|
||||
// ground is a new load and re-arms it.
|
||||
if !p.focus_resident {
|
||||
p.complete = false;
|
||||
}
|
||||
// So does a snap onto furnished ground, whose buildings may still be arriving; on the edge
|
||||
// into the load, not the level.
|
||||
if was_paced && !focus.paced {
|
||||
p.complete = false;
|
||||
}
|
||||
rearm_load(p, was_paced, &focus, reloaded);
|
||||
if !p.complete {
|
||||
// Spawned means furnished, root and every cell, so a reveal never shows bare ground (or
|
||||
// read and failed, which has nothing to show);
|
||||
@@ -1537,6 +1563,64 @@ fn terrain_base_material() -> StandardMaterial {
|
||||
mod focus_tests {
|
||||
use super::*;
|
||||
|
||||
/// A far reload (a teleport's snap, a cinematic's far shot) is a new load whatever the pacing:
|
||||
/// the latch that froze the last load's counts re-arms, so the new spot's neighbours that are
|
||||
/// not requested yet are waited for. A focus that just stands there re-arms nothing.
|
||||
#[test]
|
||||
fn a_reload_mark_re_arms_a_latched_load_once() {
|
||||
let at = [SPAWN_XY.0, SPAWN_XY.1, 100.0];
|
||||
let mut state = TerrainStreamer::default();
|
||||
// A cinematic's focus is unpaced throughout, so no pacing edge ever shows between shots,
|
||||
// and the focus tile stays furnished.
|
||||
let steady = ViewFocus::cinematic(at, false);
|
||||
let latched = || WorldLoadProgress {
|
||||
complete: true,
|
||||
focus_resident: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut p = latched();
|
||||
let reloaded = state.take_reload(&steady);
|
||||
rearm_load(&mut p, false, &steady, reloaded);
|
||||
assert!(p.complete, "nothing asked for a new load");
|
||||
|
||||
let marked = steady.with_reload(1);
|
||||
let mut p = latched();
|
||||
let reloaded = state.take_reload(&marked);
|
||||
assert!(reloaded, "the mark is seen");
|
||||
rearm_load(&mut p, false, &marked, reloaded);
|
||||
assert!(
|
||||
!p.complete,
|
||||
"a furnished focus tile does not keep the old counts"
|
||||
);
|
||||
|
||||
let mut p = latched();
|
||||
let reloaded = state.take_reload(&marked);
|
||||
assert!(!reloaded, "and it is seen once");
|
||||
rearm_load(&mut p, false, &marked, reloaded);
|
||||
assert!(p.complete);
|
||||
}
|
||||
|
||||
/// The edge into a load and a focus on unfurnished ground still re-arm, as before.
|
||||
#[test]
|
||||
fn the_pacing_edge_and_bare_ground_still_re_arm() {
|
||||
let at = [SPAWN_XY.0, SPAWN_XY.1, 100.0];
|
||||
let latched = |resident| WorldLoadProgress {
|
||||
complete: true,
|
||||
focus_resident: resident,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = latched(true);
|
||||
rearm_load(&mut p, true, &ViewFocus::body(at, false), false);
|
||||
assert!(!p.complete, "paced to unpaced");
|
||||
let mut p = latched(true);
|
||||
rearm_load(&mut p, false, &ViewFocus::body(at, true), false);
|
||||
assert!(p.complete, "a settled body stays complete");
|
||||
let mut p = latched(false);
|
||||
rearm_load(&mut p, false, &ViewFocus::body(at, true), false);
|
||||
assert!(!p.complete, "unfurnished ground");
|
||||
}
|
||||
|
||||
/// Stratholme's map and position.
|
||||
const PICK: (u32, [f32; 3]) = (329, [3398.9, -3381.8, 142.7]);
|
||||
fn cam_at_northshire() -> Option<Vec3> {
|
||||
|
||||
+5
-5
@@ -97,7 +97,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
|
||||
#### cinematic
|
||||
|
||||
- Bevy UI nodes computed per frame, not `CinematicFrame.xml`'s textures, so a window resized mid-cinematic stays letterboxed. `CinematicFrame` still shows and owns ESC. ([`src/cinematic.rs:130`](../crates/benilla-app/src/cinematic.rs#L130))
|
||||
- Bevy UI nodes computed per frame, not `CinematicFrame.xml`'s textures, so a window resized mid-cinematic stays letterboxed. `CinematicFrame` still shows and owns ESC. ([`src/cinematic.rs:187`](../crates/benilla-app/src/cinematic.rs#L187))
|
||||
|
||||
#### combat_text
|
||||
|
||||
@@ -205,7 +205,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
- A settle hold is summed and sent once on release, without the push, because it stands in for the reference's blocking load (`0x694920`), not a refused substep, and lasts seconds. ([`src/player/movement_net.rs:357`](../crates/benilla-app/src/player/movement_net.rs#L357))
|
||||
- The reference's blocking load (`0x694920`) waits out its reads before the frame goes on and has no such release; without one, a model that never loads would freeze our body for good. ([`src/player/state.rs:120`](../crates/benilla-app/src/player/state.rs#L120))
|
||||
- Root is not merged, because a bare pose unrooting us while the server still roots us streams motion into `CHEAT_TYPE_ROOT_MOVE`; vmangos keeps a rooted mover's root bit (`MovementHandler.cpp:1064`), so the reference's merge of it is a no-op anyway. ([`src/player/state.rs:202`](../crates/benilla-app/src/player/state.rs#L202))
|
||||
- Applied at arrival, because our own avatar has no motion to de-jitter; the reference routes it through the replay chain remotes use, which paces to the sender's cadence and can hold an early packet (up to 1000 ms in `net::motion`). ([`src/player/wire_in.rs:496`](../crates/benilla-app/src/player/wire_in.rs#L496))
|
||||
- Applied at arrival, because our own avatar has no motion to de-jitter; the reference routes it through the replay chain remotes use, which paces to the sender's cadence and can hold an early packet (up to 1000 ms in `net::motion`). ([`src/player/wire_in.rs:497`](../crates/benilla-app/src/player/wire_in.rs#L497))
|
||||
|
||||
#### portrait
|
||||
|
||||
@@ -906,9 +906,9 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it
|
||||
|
||||
#### terrain_stream
|
||||
|
||||
- The debug free-fly eye holds nothing: it is benilla's own tool with no reference counterpart, and a stall there only slows the developer. ([`src/terrain_stream.rs:401`](../crates/benilla-world/src/terrain_stream.rs#L401))
|
||||
- A tile is released only one chunk past the outer window (`StreamWindow::keeps`), where the reference releases by window membership (`0x6984f0`), because our reload is a re-decode and re-spawn, not a cheap async read. ([`src/terrain_stream.rs:741`](../crates/benilla-world/src/terrain_stream.rs#L741))
|
||||
- A read still loading after `INNER_WAIT_CAP` is given up on with a warning, once per request, and its tile then streams on unwaited like the outer ring, where the reference waits for ever: a hung read costs one capped stall, not one every frame. ([`src/terrain_stream.rs:836`](../crates/benilla-world/src/terrain_stream.rs#L836))
|
||||
- The debug free-fly eye holds nothing: it is benilla's own tool with no reference counterpart, and a stall there only slows the developer. ([`src/terrain_stream.rs:434`](../crates/benilla-world/src/terrain_stream.rs#L434))
|
||||
- A tile is released only one chunk past the outer window (`StreamWindow::keeps`), where the reference releases by window membership (`0x6984f0`), because our reload is a re-decode and re-spawn, not a cheap async read. ([`src/terrain_stream.rs:775`](../crates/benilla-world/src/terrain_stream.rs#L775))
|
||||
- A read still loading after `INNER_WAIT_CAP` is given up on with a warning, once per request, and its tile then streams on unwaited like the outer ring, where the reference waits for ever: a hung read costs one capped stall, not one every frame. ([`src/terrain_stream.rs:870`](../crates/benilla-world/src/terrain_stream.rs#L870))
|
||||
- Benilla gates on terrain alone, which is more permissive; the settle hold covers a building or doodad arriving after a teleport. ([`src/terrain_stream/residency.rs:9`](../crates/benilla-world/src/terrain_stream/residency.rs#L9))
|
||||
- Tagged per submesh, where the reference tests the whole object, because a placement has no root entity to carry the bound. ([`src/terrain_stream/spawn/mod.rs:229`](../crates/benilla-world/src/terrain_stream/spawn/mod.rs#L229))
|
||||
- The box, where the reference tests its circumsphere (`0x683700` → `0x682ef0` → `0x686b80` on the `[rec+0x5c]` centre and `[rec+0x68]` radius × scale), because Bevy culls by box and the box is tighter. ([`src/terrain_stream/spawn/mod.rs:889`](../crates/benilla-world/src/terrain_stream/spawn/mod.rs#L889))
|
||||
|
||||
Reference in New Issue
Block a user