the loading screen gets a lifecycle, and stops lying at both ends
- Entering the world used to show an uncovered, movable world before the screen
came up — seconds of it on a slower machine ("like the Stormwind intro
video", as one report with video put it). The screen also cleared onto bare
terrain with the buildings still streaming in, and could hang forever if you
arrived swimming or flying. It has a real lifecycle now: it rises at the
entry edges (character pick, transfer, teleport), clears only when the scene
is actually presentable — terrain, buildings, props and their collision all
resident — and its physics hold releases in every movement mode, not just
walking. The progress bar counts buildings too.
- A far teleport flashed a frame of the destination before the screen rose.
The "swap never flashes" guarantee existed only in comments — a streamer
rework had silently dropped the scheduling constraint that enforced it; it
is structural now. And logout got its missing cover: the original's plain
black cut, not a frame of torn-down world.
- Animation parking now governs everything with a rig. ~350 animated objects
(braziers, doors, chests) were sampling their loops off-screen forever, and
a patrolling parked mob kept writing pose data nothing could read — ~620 rig
refreshes a frame became 7, and creatures in rooms the camera can't reach
through any portal park too.
- The engine's own tax got measured: bare Bevy spends ~3.9 CPU ms a frame on
this machine drawing nothing at all, which resets what "fast enough"
honestly means for everything built on top of it. A shipping build profile
(fat LTO) landed alongside — worth about −1 ms at the pin, kept out of
everyday builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+10
@@ -38,3 +38,13 @@ opt-level = 1
|
||||
# No debuginfo trimming: `debug = "line-tables-only"` was A/B-measured (decision 0192) at zero
|
||||
# recurring win on this stack (macOS unpacked split-debuginfo; sccache; fast ld) — not worth
|
||||
# invalidating every warm target and the sccache cache for.
|
||||
|
||||
# Shipping-grade optimization, A/B-graded at the pin (decision 0736): −1.0 cpu_ms (−5 %) vs
|
||||
# default release, flat at the empty-map and bare-Bevy floors. The campaign keeps grading on
|
||||
# default `release` — same-day A/B is the law, and fat LTO's link time would tax every
|
||||
# iteration for a constant offset. Build with `--profile ship` for actual releases (add
|
||||
# `RUSTFLAGS=-Ctarget-cpu=native` for a machine-local binary; not set here — it isn't portable).
|
||||
[profile.ship]
|
||||
inherits = "release"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -48,7 +48,8 @@ pub struct WmoGroupNav {
|
||||
|
||||
/// A loaded WMO building: the render batches across all its groups, flattened, plus the flattened
|
||||
/// collidable triangles (the app bakes a collider from these per placement).
|
||||
#[derive(Asset, TypePath, Clone)]
|
||||
/// `Default` = the empty building (every field vacuous) — a test scaffold, never a loader product.
|
||||
#[derive(Asset, TypePath, Clone, Default)]
|
||||
pub struct WmoModel {
|
||||
/// The building's `WMOAreaTable.WMOID` key (root `MOHD.wmoID`; NSabbey → 59).
|
||||
pub wmo_id: u32,
|
||||
|
||||
@@ -141,6 +141,15 @@ impl Roster {
|
||||
pub(super) fn selected_char(&self) -> Option<&Character> {
|
||||
self.selected.and_then(|i| self.chars.get(i))
|
||||
}
|
||||
|
||||
/// The pending pick's map — the loading screen resolves the *destination's* art from the
|
||||
/// roster at the entry edge, before the server's `SMSG_LOGIN_VERIFY_WORLD` snap lands
|
||||
/// (decision 0737).
|
||||
pub(crate) fn pending_map(&self) -> Option<u32> {
|
||||
self.pending_pick
|
||||
.and_then(|g| self.chars.iter().find(|c| c.guid == g))
|
||||
.map(|c| c.map)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the parked IO thread to log in as `guid` (the pick channel) and remember it as pending.
|
||||
|
||||
@@ -133,14 +133,21 @@ pub(crate) fn finalize_rig_worlds(
|
||||
.single()
|
||||
.ok()
|
||||
.map(|t| (*t.forward(), *t.right(), *t.up()));
|
||||
// Which rigs refresh this frame: pose-dirty, model frame moved, or camera-faced live.
|
||||
// Which rigs refresh this frame: pose-dirty, model frame moved, or camera-faced — and never
|
||||
// a parked one (decision 0739). The LOD gate ruled a parked rig un-viewable, so neither an
|
||||
// idle's pose_dirty, a patrol's root motion, nor a billboard face can matter until the wake
|
||||
// — which drops the marker before the same frame's pose evaluation, re-raising `pose_dirty`
|
||||
// before this pass runs, so a woken rig's rows are current before anything draws them. (The
|
||||
// world-space rows of a moving parked rig go stale on purpose — nothing reads them; at the
|
||||
// LBRS pin the parked-but-patrolling re-writes were ~260 of the ~620 per-frame refreshes.)
|
||||
let refresh: Vec<Entity> = {
|
||||
let roots_changed = worlds_params.p0();
|
||||
rigs.iter()
|
||||
.filter(|(_, rig, _, parked)| {
|
||||
rig.pose_dirty
|
||||
|| roots_changed.contains(rig.joints_root)
|
||||
|| (rig.has_billboard && !parked)
|
||||
!parked
|
||||
&& (rig.pose_dirty
|
||||
|| roots_changed.contains(rig.joints_root)
|
||||
|| rig.has_billboard)
|
||||
})
|
||||
.map(|(holder, ..)| holder)
|
||||
.collect()
|
||||
@@ -148,6 +155,9 @@ pub(crate) fn finalize_rig_worlds(
|
||||
if refresh.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The `WOW_RIG_COST` compose meter (decision 0736): how many rigs refresh and what the
|
||||
// whole finalize pass costs, beside the palette's copy/write and upload lines.
|
||||
let cost_t0 = crate::rig_palette::rig_cost_enabled().then(std::time::Instant::now);
|
||||
let mut globals = worlds_params.p1();
|
||||
// The rigid-child re-walk's do-not-enter set, exactly the entity pass's: a nested rig with
|
||||
// its own billboard output owns its interior (and its root keeps the propagated frame).
|
||||
@@ -230,6 +240,13 @@ pub(crate) fn finalize_rig_worlds(
|
||||
rig.pose_dirty = false;
|
||||
}
|
||||
}
|
||||
if let Some(t0) = cost_t0 {
|
||||
eprintln!(
|
||||
"[rig-finalize] refreshed={} ms={:.3}",
|
||||
refresh.len(),
|
||||
t0.elapsed().as_secs_f32() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the model pass; the world pass is chained by [`crate::billboard::BillboardPlugin`]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! The unit animation-LOD gate (decision 0448): park an off-frustum rig's **per-bone pose
|
||||
//! evaluation**, keep **every clock** running.
|
||||
//! The animation-LOD gate (decision 0448): park an off-frustum — or, since decision 0739,
|
||||
//! portal-invisible — rig's **per-bone pose evaluation**, keep **every clock** running. Since
|
||||
//! 0739 it governs the whole [`RigPose`] population — units, players, AND GameObject rigs (see
|
||||
//! the query's comment for why the old `With<AnimDriver>` filter left every animated GO
|
||||
//! sampling off-view).
|
||||
//!
|
||||
//! The reference has NO view cull on unit skeletons — every in-range, non-hidden unit is fully
|
||||
//! animated every frame (wow-re `unit-anim-visibility-gate.md`: worklist admission is the CGUnit
|
||||
@@ -23,18 +26,39 @@
|
||||
//! gate on the same marker. Contrast the DOODAD gate ([`crate::doodad_anim`]), which faithfully
|
||||
//! stops the player and re-arms on the shared clock — correct there (no one consumes a doodad's
|
||||
//! events off-screen), the exact 0075 trap here.
|
||||
//!
|
||||
//! **The room leg (decision 0739).** The frustum is the wrong instrument indoors: a dungeon
|
||||
//! camera's view cone passes through walls, so most of an instance's population stays "in
|
||||
//! frustum" while only the current room chain is drawable. The portal PVS
|
||||
//! ([`crate::wmo_portal`], the faithful WMO group cull) already knows which rooms the camera can
|
||||
//! reach, and [`UnitWmoRoom`] already names each unit's room — so a unit whose room (and every
|
||||
//! room one portal hop from it) is outside this frame's PVS parks too. Soundness: a unit is
|
||||
//! room-parked exactly when the geometry *around* it — its room's walls, floor, furniture — is
|
||||
//! portal-culled by the same bits, and a body can poke only through a portal, never a wall, so
|
||||
//! the one-hop guard bounds the doorway straddle. Reveals carry no artifact by construction: a
|
||||
//! mob can only become visible through a portal, and the moment that portal's screen window is
|
||||
//! non-zero the flood marks its room visible (`compute_wmo_pvs` runs in `Update`, this gate in
|
||||
//! `PostUpdate` before the evaluator — same-frame wake, absolute-clock pose). Fail-open at every
|
||||
//! seam: no claim (outdoors, EXTERIOR groups), a despawned placement, a still-loading model, an
|
||||
//! out-of-range index — all read visible, the [`crate::wmo_portal::WmoGroupVis::drawn_by`]
|
||||
//! convention.
|
||||
//! `WOW_NO_ROOM_LOD=1` disables just this leg (the A/B lever); `WOW_RIG_COST=1` prints the
|
||||
//! per-frame `[rig-gate]` counters.
|
||||
|
||||
use bevy::app::AnimationSystems;
|
||||
use bevy::camera::primitives::{Frustum, Sphere as CullSphere};
|
||||
use bevy::ecs::entity::EntityHashMap;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::{WmoGroupNav, WmoModel, WmoPortalRef};
|
||||
|
||||
use crate::entities::mount::MountBody;
|
||||
use crate::net::SelfPlayer;
|
||||
use crate::player::WorldCamera;
|
||||
use crate::target::SelectionRadius;
|
||||
use crate::wmo_portal::{UnitWmoRoom, WmoPortalInstance, WmoRoom};
|
||||
|
||||
use super::AnimDriver;
|
||||
use super::RigPose;
|
||||
|
||||
/// This rig's per-bone pose evaluation is parked (decision 0448): the pose evaluator and the pose
|
||||
/// post-passes skip it. The sequence clocks, the driver state machine, and the event scanner all
|
||||
@@ -55,12 +79,13 @@ const RADIUS_SCALE: f32 = 2.0;
|
||||
const RADIUS_PAD: f32 = 4.0;
|
||||
const FALLBACK_RADIUS: f32 = 6.0;
|
||||
|
||||
/// Park/wake streamed rigs by the padded sphere-vs-frustum test (decision 0448). PostUpdate,
|
||||
/// Park/wake streamed rigs by the padded sphere-vs-frustum test (decision 0448) ANDed with the
|
||||
/// portal-PVS room test (decision 0739 — see the module doc's "room leg"). PostUpdate,
|
||||
/// before [`AnimationSystems`] — a wake drops the marker in time for the same frame's pose
|
||||
/// evaluation, so the re-appearing unit samples the absolute-clock pose with no stale
|
||||
/// frame. Exempt: the self-avatar (the camera rides its attachment-17 pivot, and it must keep
|
||||
/// animating faded-out in first person) and its mount child. `WOW_NO_ANIM_LOD=1` disables
|
||||
/// parking — the live-probe A/B lever.
|
||||
/// parking — the live-probe A/B lever; `WOW_NO_ROOM_LOD=1` disables only the room leg.
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)] // one Bevy system's full input set
|
||||
pub(super) fn gate_rig_animation(
|
||||
time: Res<Time>,
|
||||
@@ -73,13 +98,25 @@ pub(super) fn gate_rig_animation(
|
||||
Has<AnimParked>,
|
||||
Has<SelfPlayer>,
|
||||
Option<&MountBody>,
|
||||
Option<&UnitWmoRoom>,
|
||||
),
|
||||
With<AnimDriver>,
|
||||
// The whole collapsed-rig population (decision 0739), not `With<AnimDriver>`: GameObject
|
||||
// rigs deliberately carry no driver (their looping player IS the animation — attach's GO
|
||||
// arm), and gating on the driver left every animated GO sampling and refreshing off-view
|
||||
// — at the LBRS pin, ~350 of the ~620 per-frame refreshes. Parking preserves their whole
|
||||
// observable surface: the event scanner requires `AnimDriver` (GOs never fired anim
|
||||
// events), `go_anim`'s state machine arms the player regardless of the marker, and the
|
||||
// wake samples the absolute clock. Booth/studio rigs are entity-joint-lane (no
|
||||
// `RigPose`), so this query can never park the character pane.
|
||||
With<RigPose>,
|
||||
>,
|
||||
self_hosts: Query<Has<SelfPlayer>>,
|
||||
instances: Query<&WmoPortalInstance>,
|
||||
wmos: Res<Assets<WmoModel>>,
|
||||
mut out_since: Local<EntityHashMap<f32>>,
|
||||
mut disabled: Local<Option<bool>>,
|
||||
mut park_all: Local<Option<bool>>,
|
||||
mut no_room: Local<Option<bool>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let disabled = *disabled.get_or_insert_with(|| std::env::var_os("WOW_NO_ANIM_LOD").is_some());
|
||||
@@ -89,25 +126,45 @@ pub(super) fn gate_rig_animation(
|
||||
// profiler build (the chrome-trace feature costs a 5-minute rebuild and a GB-scale file).
|
||||
// Not a shipping mode: it freezes visible rigs on purpose.
|
||||
let park_all = *park_all.get_or_insert_with(|| std::env::var_os("WOW_ANIM_PARK_ALL").is_some());
|
||||
let no_room = *no_room.get_or_insert_with(|| std::env::var_os("WOW_NO_ROOM_LOD").is_some());
|
||||
// `[rig-gate]` counters (`WOW_RIG_COST`): `room_out` is the room leg's *marginal* park set —
|
||||
// rigs the frustum keeps that the PVS rejects — so a `WOW_NO_ROOM_LOD=1` leg reads the
|
||||
// premise number without applying the leg (counted either way, applied only when the lever
|
||||
// is off).
|
||||
let cost_on = crate::rig_palette::rig_cost_enabled();
|
||||
let (mut n_rigs, mut n_parked, mut room_out) = (0u32, 0u32, 0u32);
|
||||
let Ok(frustum) = cam.single() else {
|
||||
return;
|
||||
};
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, tf, radius, parked, is_self, mount) in &rigs {
|
||||
for (entity, tf, radius, parked, is_self, mount, room) in &rigs {
|
||||
let exempt =
|
||||
disabled || is_self || mount.is_some_and(|m| self_hosts.get(m.host).unwrap_or(false));
|
||||
let visible = exempt
|
||||
|| !park_all && {
|
||||
let scale = tf.to_scale_rotation_translation().0.max_element();
|
||||
let r = radius.map_or(FALLBACK_RADIUS, |r| r.0 * scale * RADIUS_SCALE + RADIUS_PAD);
|
||||
frustum.intersects_sphere(
|
||||
let sphere_in = frustum.intersects_sphere(
|
||||
&CullSphere {
|
||||
center: tf.translation().into(),
|
||||
radius: r,
|
||||
},
|
||||
false,
|
||||
)
|
||||
);
|
||||
// The room leg: only a rig the frustum leg kept can be room-parked.
|
||||
let room_in = !sphere_in || (no_room && !cost_on) || {
|
||||
let ok = room_pvs_visible(room, &instances, &wmos);
|
||||
if !ok {
|
||||
room_out += 1;
|
||||
}
|
||||
ok || no_room
|
||||
};
|
||||
sphere_in && room_in
|
||||
};
|
||||
if cost_on {
|
||||
n_rigs += 1;
|
||||
n_parked += u32::from(parked);
|
||||
}
|
||||
if visible {
|
||||
out_since.remove(&entity);
|
||||
if parked {
|
||||
@@ -121,10 +178,54 @@ pub(super) fn gate_rig_animation(
|
||||
}
|
||||
}
|
||||
}
|
||||
if cost_on {
|
||||
eprintln!("[rig-gate] rigs={n_rigs} parked={n_parked} room_out={room_out}");
|
||||
}
|
||||
// Drop entries for rigs that despawned (or lost their model) before parking.
|
||||
out_since.retain(|e, _| rigs.contains(*e));
|
||||
}
|
||||
|
||||
/// The room leg's resolve chain: unit claim → placement instance → model → PVS bits. Fail-open
|
||||
/// at every seam (no claim, despawned placement, still-loading model) — a lookup miss must never
|
||||
/// park a drawable rig.
|
||||
fn room_pvs_visible(
|
||||
room: Option<&UnitWmoRoom>,
|
||||
instances: &Query<&WmoPortalInstance>,
|
||||
wmos: &Assets<WmoModel>,
|
||||
) -> bool {
|
||||
let Some(WmoRoom { instance, group }) = room.and_then(|r| r.room()) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(inst) = instances.get(instance) else {
|
||||
return true;
|
||||
};
|
||||
let Some(model) = wmos.get(&inst.handle) else {
|
||||
return true;
|
||||
};
|
||||
room_visible(&inst.visible, &model.group_nav, &model.portal_refs, group)
|
||||
}
|
||||
|
||||
/// Is the claimed group — or any group one portal hop from it — in the PVS? The one-hop union is
|
||||
/// the doorway-straddle guard: a body can extend past its feet's room only through a portal
|
||||
/// opening, so the neighbour set bounds everything of the unit that could be on screen. Indices
|
||||
/// out of range read visible (fail-open, [`crate::wmo_portal::WmoGroupVis::drawn_by`]'s
|
||||
/// convention); a group with no portal refs at all can only be seen with the camera inside it
|
||||
/// (the flood cannot reach it), which the direct bit already answered.
|
||||
fn room_visible(visible: &[bool], nav: &[WmoGroupNav], refs: &[WmoPortalRef], group: u16) -> bool {
|
||||
let vis = |g: usize| visible.get(g).copied().unwrap_or(true);
|
||||
if vis(group as usize) {
|
||||
return true;
|
||||
}
|
||||
let Some(n) = nav.get(group as usize) else {
|
||||
return true;
|
||||
};
|
||||
let Some(hops) = refs.get(n.ref_start as usize..(n.ref_start as usize + n.ref_count as usize))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
hops.iter().any(|r| vis(r.group as usize))
|
||||
}
|
||||
|
||||
/// Register the gate: [`gate_rig_animation`] before the frame's pose evaluation.
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.add_systems(PostUpdate, gate_rig_animation.before(AnimationSystems));
|
||||
@@ -140,7 +241,7 @@ mod tests {
|
||||
bone_target_id, AnimClip, ModelAnimations, PoseBone, PoseClip, PoseSource, PoseTrack,
|
||||
};
|
||||
|
||||
use super::super::{events::fire_anim_events, AnimSoundEvent};
|
||||
use super::super::{events::fire_anim_events, AnimDriver, AnimSoundEvent};
|
||||
use super::*;
|
||||
|
||||
/// The test clip's loop length (seconds) — short so real-dt frames wrap it several times.
|
||||
@@ -266,6 +367,7 @@ mod tests {
|
||||
AssetPlugin::default(),
|
||||
bevy::animation::AnimationPlugin,
|
||||
));
|
||||
app.init_asset::<WmoModel>();
|
||||
app.add_message::<AnimSoundEvent>();
|
||||
// The live schedule's shape: the gate ahead of the frame's pose evaluation; the event
|
||||
// scanner reading the advanced seek after it.
|
||||
@@ -376,6 +478,139 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A [`WmoGroupNav`] whose only meaningful fields are its portal-ref slice — the room-leg
|
||||
/// tests never touch flags/bounds.
|
||||
fn nav(ref_start: u16, ref_count: u16) -> WmoGroupNav {
|
||||
WmoGroupNav {
|
||||
flags: 0,
|
||||
bbox_min: [0.0; 3],
|
||||
bbox_max: [0.0; 3],
|
||||
ref_start,
|
||||
ref_count,
|
||||
area_table_id: 0,
|
||||
fog_indices: [0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
fn pref(group: u16) -> WmoPortalRef {
|
||||
WmoPortalRef {
|
||||
portal: 0,
|
||||
group,
|
||||
side: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The room predicate's whole truth table: direct bit, the one-hop straddle guard, the
|
||||
/// all-dark park, the sealed room, and both fail-open seams (group past every table, a
|
||||
/// ref slice past the refs vec).
|
||||
#[test]
|
||||
fn room_visible_covers_the_hop_guard_and_fails_open() {
|
||||
// Groups 0 ↔ 1 share one portal; group 2 is sealed (no refs).
|
||||
let navs = vec![nav(0, 1), nav(1, 1), nav(2, 0)];
|
||||
let refs = vec![pref(1), pref(0)];
|
||||
assert!(
|
||||
room_visible(&[true, false, false], &navs, &refs, 0),
|
||||
"direct PVS bit"
|
||||
);
|
||||
assert!(
|
||||
room_visible(&[true, false, false], &navs, &refs, 1),
|
||||
"own bit dark but the neighbour lit — the doorway-straddle guard keeps it live"
|
||||
);
|
||||
assert!(
|
||||
!room_visible(&[false, false, true], &navs, &refs, 0),
|
||||
"own room and every hop dark ⇒ parked"
|
||||
);
|
||||
assert!(
|
||||
!room_visible(&[true, true, false], &navs, &refs, 2),
|
||||
"a sealed room's own bit decides — no hops exist to save it"
|
||||
);
|
||||
assert!(
|
||||
room_visible(&[false], &navs, &refs, 9),
|
||||
"a group past the PVS table reads visible (fail-open)"
|
||||
);
|
||||
assert!(
|
||||
room_visible(&[false], &[nav(7, 2)], &refs, 0),
|
||||
"a ref slice past the refs vec reads visible (fail-open)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The room leg end to end: an IN-FRUSTUM rig whose claimed room (and its one hop) is
|
||||
/// outside the PVS parks past the hysteresis window; setting the room's bit wakes it and its
|
||||
/// bones sample again — the same instant-wake law as the frustum leg.
|
||||
#[test]
|
||||
fn a_room_dark_rig_parks_and_wakes_when_the_pvs_reaches_it() {
|
||||
let mut app = app();
|
||||
spawn_camera(&mut app);
|
||||
// In front of the camera — only the room leg can park this rig.
|
||||
let (rig, joint) = spawn_rig(&mut app, -Vec3::Z * 20.0);
|
||||
let model = WmoModel {
|
||||
wmo_id: 1,
|
||||
portal_refs: vec![pref(1), pref(0)],
|
||||
group_nav: vec![nav(0, 1), nav(1, 1)],
|
||||
..Default::default()
|
||||
};
|
||||
let handle = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<WmoModel>>()
|
||||
.add(model);
|
||||
let inst = app
|
||||
.world_mut()
|
||||
.spawn(WmoPortalInstance {
|
||||
handle,
|
||||
world_from_local: bevy::math::Affine3A::IDENTITY,
|
||||
name_set: 0,
|
||||
visible: vec![false, false],
|
||||
})
|
||||
.id();
|
||||
app.world_mut()
|
||||
.entity_mut(rig)
|
||||
.insert(UnitWmoRoom::claimed(WmoRoom {
|
||||
instance: inst,
|
||||
group: 0,
|
||||
}));
|
||||
advance(&mut app, PARK_AFTER_SECS + 0.2, 4);
|
||||
assert!(
|
||||
app.world().entity(rig).contains::<AnimParked>(),
|
||||
"in-frustum but room-dark past the hysteresis window ⇒ parked"
|
||||
);
|
||||
let frozen = bone(&app, joint);
|
||||
// The flood reaches the room (a doorway's screen window opened): wake and sample.
|
||||
app.world_mut()
|
||||
.entity_mut(inst)
|
||||
.get_mut::<WmoPortalInstance>()
|
||||
.unwrap()
|
||||
.visible[0] = true;
|
||||
advance(&mut app, DUR * 0.3, 2);
|
||||
assert!(
|
||||
!app.world().entity(rig).contains::<AnimParked>(),
|
||||
"PVS reach ⇒ instant wake"
|
||||
);
|
||||
assert_ne!(
|
||||
frozen,
|
||||
bone(&app, joint),
|
||||
"the woken rig's bones sample again"
|
||||
);
|
||||
}
|
||||
|
||||
/// The coverage law (decision 0739): a driverless rig — a GameObject's, whose looping
|
||||
/// player is its whole animation — parks by the same gate. `With<RigPose>`, not
|
||||
/// `With<AnimDriver>`.
|
||||
#[test]
|
||||
fn a_driverless_go_rig_parks_too() {
|
||||
let mut app = app();
|
||||
spawn_camera(&mut app);
|
||||
let (behind, joint) = spawn_rig(&mut app, Vec3::Z * 50.0);
|
||||
app.world_mut().entity_mut(behind).remove::<AnimDriver>();
|
||||
advance(&mut app, PARK_AFTER_SECS + 0.2, 4);
|
||||
assert!(
|
||||
app.world().entity(behind).contains::<AnimParked>(),
|
||||
"no driver, still parked — the gate keys on RigPose"
|
||||
);
|
||||
let frozen = bone(&app, joint);
|
||||
advance(&mut app, DUR * 0.35, 2);
|
||||
assert_eq!(frozen, bone(&app, joint), "the parked GO rig's bones hold");
|
||||
}
|
||||
|
||||
/// The self-avatar never parks (the camera rides its attachment pivot; first-person fades it
|
||||
/// while its swings still drive combat audio and the camera seat).
|
||||
#[test]
|
||||
|
||||
@@ -11,11 +11,17 @@
|
||||
//! The two cases out of current scope (taxi/boat/zeppelin's moving flight-path icon; a richer
|
||||
//! progress model) slot in as an extra overlay layer / a different progress source without restructure.
|
||||
//!
|
||||
//! Trigger (scope locked with the user): **startup + `CurrentMap` change only** — normal streaming
|
||||
//! stays seamless. Cleared once the tiles the streamer wants around the view are resident
|
||||
//! ([`WorldLoadProgress`], published by `terrain_stream`). The exact reference clear-condition +
|
||||
//! progress-fraction backing are still INFERRED (see the knowledge note); the streamer residency
|
||||
//! ratio is our faithful-enough stand-in until a capture says otherwise.
|
||||
//! **The lifecycle is event-raised, readiness-cleared (decision 0737).** The reference *blocks* on
|
||||
//! its world load, so its screen covers from the entry edge until the world is loaded by
|
||||
//! construction; an async-streaming client has to build that observable explicitly. The screen
|
||||
//! rises at the explicit edges — the character pick's `Connected` edge (before the glue tears
|
||||
//! down), `SMSG_TRANSFER_PENDING` (the portal walk-in), the worldport snap — all observed *here*,
|
||||
//! from the messages/resources the net bridge already publishes; a residency backstop catches any
|
||||
//! path with no edge (and covers boot, which is what warms the world pipelines behind the glue —
|
||||
//! decision 0540). It clears when the destination is **scene-presentable** ([`WorldLoadProgress`],
|
||||
//! published by `terrain_stream` + the collider queue): every wanted tile spawned, the focus
|
||||
//! neighbourhood's placements up, colliders quiet, the snap no longer awaited. Never anything
|
||||
//! about the *body* — feet-on-ground is not a load condition (the flying-teleport hang, 0737).
|
||||
|
||||
use bevy::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
@@ -53,19 +59,39 @@ const BACKDROP_ASPECT: f32 = 4.0 / 3.0;
|
||||
/// Frames the world must read fully-resident before we clear the screen — debounces the post-teleport
|
||||
/// frame where `loaded` is drained (`total > 0`, `ready` momentarily 0) so we don't flicker off/on.
|
||||
const CLEAR_AFTER_READY_FRAMES: u32 = 3;
|
||||
/// The wait instrument (decision 0737): once the screen has been up this long, say *which term*
|
||||
/// still blocks the clear, every [`WAIT_LOG_EVERY`] seconds — so a "loading screen stuck" report is
|
||||
/// a one-line diagnosis instead of a session. Ordinary loads finish under the threshold and log
|
||||
/// nothing extra.
|
||||
const WAIT_LOG_AFTER: f32 = 3.0;
|
||||
const WAIT_LOG_EVERY: f32 = 2.0;
|
||||
|
||||
/// How many of the tiles the streamer wants around the view focus are actually spawned. Written each
|
||||
/// frame by `terrain_stream::stream_terrain`; read here to drive the bar + the clear condition.
|
||||
/// How much of the world the streamer wants around the view focus is actually there. Written each
|
||||
/// frame by `terrain_stream::stream_terrain` (tiles + placements) and
|
||||
/// `terrain_stream::finish_colliders` (the attach queue); read here to drive the bar + the clear
|
||||
/// condition, and by the streamer's own settle release (decision 0737 — the physics hold keys on
|
||||
/// this same signal, never on ground contact).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct WorldLoadProgress {
|
||||
/// Units done, of [`Self::total`]: desired tiles spawned + focus-neighbourhood placements up.
|
||||
pub(crate) ready: usize,
|
||||
pub(crate) total: usize,
|
||||
/// Whether the tile under the view focus (the nearest desired tile) is spawned. `false` until the
|
||||
/// streamer first runs, and again whenever a teleport/worldport drops the focus onto unloaded
|
||||
/// ground — this is what triggers the loading screen (covers startup, cross-map worldport, AND
|
||||
/// far same-map teleports like a cross-continent `.tele`, which don't change `CurrentMap`). During
|
||||
/// normal streaming the focus tile is always resident, so it never fires spuriously.
|
||||
/// ground — the backstop trigger (covers startup, cross-map worldport, AND far same-map
|
||||
/// teleports like a cross-continent `.tele`, which don't change `CurrentMap`). During normal
|
||||
/// streaming the focus tile is always resident, so it never fires spuriously.
|
||||
pub(crate) focus_resident: bool,
|
||||
/// The scene term (0737): the focus tile **and its 8 neighbours** are spawned with every
|
||||
/// placement they reference (WMOs, doodads, WMO props) — "the buildings and trees around you
|
||||
/// exist", which is what makes a reveal read as a world rather than bare terrain. Collider
|
||||
/// quiet is deliberately *not* folded in here (it is published a stage apart); consumers use
|
||||
/// [`Self::presentable`].
|
||||
pub(crate) scene_ready: bool,
|
||||
/// Outstanding collider attaches (decision 0610's queue) — published by `finish_colliders`.
|
||||
pub(crate) colliders_pending: usize,
|
||||
/// Focus-neighbourhood placements not yet spawned (the wait instrument's term).
|
||||
pub(crate) placements_pending: usize,
|
||||
}
|
||||
|
||||
impl WorldLoadProgress {
|
||||
@@ -80,11 +106,17 @@ impl WorldLoadProgress {
|
||||
}
|
||||
}
|
||||
|
||||
/// True once every wanted tile is resident. Requires `total > 0` so the cold-start / post-swap
|
||||
/// True once every wanted unit is resident. Requires `total > 0` so the cold-start / post-swap
|
||||
/// frame (before `desired` is computed) doesn't read as "done".
|
||||
fn is_ready(&self) -> bool {
|
||||
self.total > 0 && self.ready >= self.total
|
||||
}
|
||||
|
||||
/// The world at the focus is presentable — the scene term plus a quiet collider queue. What
|
||||
/// both consumers (the screen clear here, the settle release in the streamer) key on.
|
||||
pub(crate) fn presentable(&self) -> bool {
|
||||
self.scene_ready && self.colliders_pending == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevy resource wrapper around the format-crate [`LoadingScreenCatalog`] (the `LoadingScreenID` → BLP
|
||||
@@ -92,11 +124,29 @@ impl WorldLoadProgress {
|
||||
#[derive(Resource)]
|
||||
struct LoadingScreenCatalogRes(LoadingScreenCatalog);
|
||||
|
||||
/// Loading-screen state machine.
|
||||
/// Loading-screen state machine (decision 0737: event-raised, readiness-cleared).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct LoadingScreen {
|
||||
active: bool,
|
||||
/// Consecutive fully-resident frames while active (see [`CLEAR_AFTER_READY_FRAMES`]).
|
||||
/// A raise from an entry edge (the pick's `Connected`, `SMSG_TRANSFER_PENDING`) holds until the
|
||||
/// destination snap (worldport/teleport) actually lands — so the OLD location's readiness can
|
||||
/// never clear a screen raised for the NEW one. This is what closes the world-entry flash: the
|
||||
/// login-vista tiles are fully resident the moment the glue tears down, and without this hold
|
||||
/// that residency would clear the screen seconds before `SMSG_LOGIN_VERIFY_WORLD` arrives.
|
||||
awaiting_snap: bool,
|
||||
/// Destination art before `CurrentMap` catches up: the roster's `Character.map` at the pick
|
||||
/// edge, the transfer's map at `SMSG_TRANSFER_PENDING`. Cleared by the snap (from which frame
|
||||
/// `CurrentMap` itself is correct).
|
||||
pending_map: Option<u32>,
|
||||
/// **Plain black cover, no art, no bar** — the logout transition (decision 0738). The logout
|
||||
/// teardown despawns the avatar and the streamed world in the same frame's Net stage, but
|
||||
/// `CharSelect` (and the glue screen with it) only applies at the NEXT frame's state
|
||||
/// transition — without this the dead world renders uncovered for that frame, and it is
|
||||
/// exactly the teardown-burst frame, so it lingers. The ref's world→glue swap shows black
|
||||
/// there too. Dropped the moment the state leaves `InWorld` (the glue owns the screen from
|
||||
/// then, at a higher z); any real raise replaces it with the full screen.
|
||||
blackout: bool,
|
||||
/// Consecutive presentable frames while active (see [`CLEAR_AFTER_READY_FRAMES`]).
|
||||
ready_frames: u32,
|
||||
/// Monotonic bar fill, 0..1 — only ever advances within a load (reset to 0 on each activation), so
|
||||
/// the bar reads as one continuous stream even though the raw residency ratio dips as `desired`
|
||||
@@ -104,6 +154,10 @@ pub(crate) struct LoadingScreen {
|
||||
displayed: f32,
|
||||
/// Decoded backdrop art by BLP path, so repeated teleports to a continent don't re-decode.
|
||||
art_cache: HashMap<String, Handle<Image>>,
|
||||
/// `Time::elapsed_secs` at the last raise + at the last wait-instrument line (see
|
||||
/// [`WAIT_LOG_AFTER`]).
|
||||
active_since: f32,
|
||||
last_wait_log: f32,
|
||||
}
|
||||
|
||||
impl LoadingScreen {
|
||||
@@ -112,6 +166,20 @@ impl LoadingScreen {
|
||||
pub(crate) fn covering(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Raise (or re-arm) the screen for a fresh load. `awaiting_snap` marks a raise whose
|
||||
/// destination snap is still in flight; `map` is the destination when the edge knows it.
|
||||
fn raise(&mut self, reason: &str, awaiting_snap: bool, map: Option<u32>, now: f32) {
|
||||
self.active = true;
|
||||
self.blackout = false;
|
||||
self.awaiting_snap = awaiting_snap;
|
||||
self.pending_map = map;
|
||||
self.ready_frames = 0;
|
||||
self.displayed = 0.0; // restart the fill for the new load
|
||||
self.active_since = now;
|
||||
self.last_wait_log = now;
|
||||
info!("loading screen: up ({reason}, map {map:?})");
|
||||
}
|
||||
}
|
||||
|
||||
// UI entity markers.
|
||||
@@ -121,6 +189,8 @@ struct LoadingRoot;
|
||||
struct LoadingBackdrop;
|
||||
#[derive(Component)]
|
||||
struct LoadingBarFill;
|
||||
#[derive(Component)]
|
||||
struct LoadingBarBorder;
|
||||
|
||||
pub(crate) struct LoadingScreenPlugin;
|
||||
|
||||
@@ -237,6 +307,7 @@ fn setup_loading_screen(
|
||||
));
|
||||
// Border frame, on top.
|
||||
area.spawn((
|
||||
LoadingBarBorder,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(BORDER_LEFT * 100.0),
|
||||
@@ -255,8 +326,8 @@ fn setup_loading_screen(
|
||||
});
|
||||
}
|
||||
|
||||
/// Per-frame: run the trigger state machine, resolve backdrop art on (re)activation, and push the
|
||||
/// progress fraction into the bar.
|
||||
/// Per-frame: observe the lifecycle edges, run the trigger/clear state machine, resolve backdrop
|
||||
/// art, and push the progress fraction into the bar.
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
fn drive_loading_screen(
|
||||
mut screen: ResMut<LoadingScreen>,
|
||||
@@ -266,43 +337,143 @@ fn drive_loading_screen(
|
||||
screens: Option<Res<LoadingScreenCatalogRes>>,
|
||||
mut assets: Option<ResMut<WorldAssets>>,
|
||||
mut images: ResMut<Assets<Image>>,
|
||||
mut root: Query<&mut Visibility, With<LoadingRoot>>,
|
||||
mut backdrop: Query<&mut ImageNode, With<LoadingBackdrop>>,
|
||||
mut root: Query<&mut Visibility, (With<LoadingRoot>, Without<LoadingBackdrop>)>,
|
||||
mut backdrop: Query<(&mut ImageNode, &mut Visibility), With<LoadingBackdrop>>,
|
||||
mut fill: Query<&mut Node, With<LoadingBarFill>>,
|
||||
mut bar_vis: Query<
|
||||
&mut Visibility,
|
||||
(
|
||||
Or<(With<LoadingBarFill>, With<LoadingBarBorder>)>,
|
||||
Without<LoadingRoot>,
|
||||
Without<LoadingBackdrop>,
|
||||
),
|
||||
>,
|
||||
player: Option<Res<crate::player::Player>>,
|
||||
time: Res<Time>,
|
||||
// The lifecycle edges (decisions 0737/0738), all observed from what the net bridge already
|
||||
// publishes — this module owns the whole state machine, nothing else is instrumented for it.
|
||||
edges: (
|
||||
Res<State<crate::char_select::ClientState>>,
|
||||
MessageReader<crate::net::EnteredWorldMessage>,
|
||||
MessageReader<crate::net::WorldportMessage>,
|
||||
MessageReader<crate::net::TeleportMessage>,
|
||||
MessageReader<crate::net::LoggedOutMessage>,
|
||||
Option<Res<crate::net::PendingTransfer>>,
|
||||
Option<Res<crate::char_select::Roster>>,
|
||||
),
|
||||
) {
|
||||
let (state, mut entered, mut worldports, mut teleports, mut logouts, transfer, roster) = edges;
|
||||
let now = time.elapsed_secs();
|
||||
let map_id = current_map.as_ref().map(|m| m.0);
|
||||
// The avatar is "settling" after a teleport until the collision under it (terrain *and* the
|
||||
// destination's WMO building floors) has streamed in. Hold the screen until then — otherwise it
|
||||
// clears the moment the terrain is ready, before the buildings load (and the avatar is mid-settle).
|
||||
// The physics hold releases on the same residency signal that clears this screen (decision
|
||||
// 0737 — never on ground contact), so waiting for it costs nothing and guarantees the safe
|
||||
// order: the body's world is live before the reveal, never a reveal of a body about to drop.
|
||||
let player_settling = player.as_ref().is_some_and(|p| p.settling);
|
||||
|
||||
// --- Trigger: show whenever the ground under the view focus isn't resident (startup, cross-map
|
||||
// worldport, or a far same-map teleport — all land us on unloaded tiles); clear once the desired
|
||||
// ring is fully resident for a few frames. Normal streaming keeps the focus tile resident, so this
|
||||
// never fires while walking. ---
|
||||
if !screen.active && !progress.focus_resident {
|
||||
screen.active = true;
|
||||
screen.ready_frames = 0;
|
||||
screen.displayed = 0.0; // restart the fill for the new load
|
||||
info!("loading screen: up (map {map_id:?})");
|
||||
// --- The edges. ---
|
||||
// A real glue entry (not 0065's seamless in-world reconnect, which must stay seamless): the
|
||||
// same frame `enter_on_connected` flips the state that tears the glue down, this raise puts
|
||||
// the cover up — raise and teardown are atomic by construction. The destination snap
|
||||
// (`SMSG_LOGIN_VERIFY_WORLD`) is still a server character-load away; `awaiting_snap` holds the
|
||||
// screen across that gap. Art resolves at once from the roster's own `Character.map`.
|
||||
if entered.read().next().is_some() && *state.get() != crate::char_select::ClientState::InWorld {
|
||||
let map = roster.as_ref().and_then(|r| r.pending_map());
|
||||
screen.raise("world entry", true, map, now);
|
||||
}
|
||||
// A portal walk-in (`SMSG_TRANSFER_PENDING`, no transport): the server is about to unload us
|
||||
// and the `SMSG_NEW_WORLD` snap follows after its own load — cover now, like the reference.
|
||||
// Transport crossings (0455) keep riding visibly until their worldport lands below.
|
||||
if let Some(t) = transfer.as_ref().filter(|t| t.is_changed()) {
|
||||
match &t.0 {
|
||||
Some(info) if info.transport_entry.is_none() => {
|
||||
screen.raise("transfer pending", true, Some(info.map_id), now);
|
||||
}
|
||||
// The latch cleared with no snap in flight = `SMSG_TRANSFER_ABORTED` (a worldport
|
||||
// clears it too, but that path also lands below this frame): stop awaiting, and the
|
||||
// still-resident old world clears the screen through the ordinary debounce.
|
||||
None => screen.awaiting_snap = false,
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
// The snap: a worldport IS a fresh cross-map load (raise covers server-initiated ports that
|
||||
// had no preceding edge); a same-map teleport just ends any awaited snap — whether it needs a
|
||||
// screen at all is the backstop's call (a summon across the room shouldn't flash one).
|
||||
for w in worldports.read() {
|
||||
screen.raise("worldport", false, Some(w.map_id), now);
|
||||
}
|
||||
if teleports.read().next().is_some() {
|
||||
screen.awaiting_snap = false;
|
||||
screen.pending_map = None;
|
||||
}
|
||||
// Logout (decision 0738): the same frame's Net stage despawned the avatar and tore the world
|
||||
// down, but `CharSelect` only applies at the NEXT frame's state transition — cover the dead
|
||||
// world with plain black until the glue owns the screen. No art, no bar: the ref's
|
||||
// world→glue swap is a black cut, not a loading screen.
|
||||
if logouts.read().next().is_some() {
|
||||
screen.active = true;
|
||||
screen.blackout = true;
|
||||
screen.awaiting_snap = false;
|
||||
screen.pending_map = None;
|
||||
screen.ready_frames = 0;
|
||||
info!("loading screen: blackout (logout)");
|
||||
}
|
||||
if screen.blackout && *state.get() != crate::char_select::ClientState::InWorld {
|
||||
// The glue is up (it renders above this root); the cover's job is done.
|
||||
screen.blackout = false;
|
||||
screen.active = false;
|
||||
}
|
||||
|
||||
// --- Backstop trigger: the ground under the view focus isn't resident and nothing raised us
|
||||
// (a far same-map `.tele`, boot — which is what warms the world pipelines behind the glue,
|
||||
// decision 0540 — or any path with no edge). Normal streaming keeps the focus tile resident,
|
||||
// so this never fires while walking. ---
|
||||
if !screen.active && !progress.focus_resident {
|
||||
screen.raise("focus not resident", false, None, now);
|
||||
}
|
||||
|
||||
// --- Clear: the destination snap has landed, the scene is presentable (tiles + focus
|
||||
// placements + colliders — [`WorldLoadProgress::presentable`]), and the physics hold is done,
|
||||
// sustained a few frames. ---
|
||||
if screen.active {
|
||||
if progress.is_ready() && !player_settling {
|
||||
if progress.is_ready()
|
||||
&& progress.presentable()
|
||||
&& !screen.awaiting_snap
|
||||
&& !player_settling
|
||||
{
|
||||
screen.ready_frames += 1;
|
||||
if screen.ready_frames >= CLEAR_AFTER_READY_FRAMES {
|
||||
screen.active = false;
|
||||
info!(
|
||||
"loading screen: cleared ({}/{} tiles resident, settle done)",
|
||||
progress.ready, progress.total
|
||||
"loading screen: cleared ({}/{} resident, {:.1}s)",
|
||||
progress.ready,
|
||||
progress.total,
|
||||
now - screen.active_since
|
||||
);
|
||||
}
|
||||
} else {
|
||||
screen.ready_frames = 0;
|
||||
// The wait instrument (0737): a screen up past the threshold names the blocking term.
|
||||
if now - screen.active_since > WAIT_LOG_AFTER
|
||||
&& now - screen.last_wait_log >= WAIT_LOG_EVERY
|
||||
{
|
||||
screen.last_wait_log = now;
|
||||
info!(
|
||||
"loading screen: waiting {:.1}s — {}/{} resident, {} placements pending, \
|
||||
{} colliders pending, awaiting_snap={}, settling={}",
|
||||
now - screen.active_since,
|
||||
progress.ready,
|
||||
progress.total,
|
||||
progress.placements_pending,
|
||||
progress.colliders_pending,
|
||||
screen.awaiting_snap,
|
||||
player_settling,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Visibility. ---
|
||||
// --- Visibility. The root is the cover; the backdrop art + bar hide under blackout (0738's
|
||||
// plain black cut — the root's black background IS the frame then). ---
|
||||
if let Ok(mut vis) = root.single_mut() {
|
||||
*vis = if screen.active {
|
||||
Visibility::Visible
|
||||
@@ -310,13 +481,30 @@ fn drive_loading_screen(
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
if !screen.active {
|
||||
let content_vis = if screen.blackout {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
if let Ok((_, mut vis)) = backdrop.single_mut() {
|
||||
if *vis != content_vis {
|
||||
*vis = content_vis;
|
||||
}
|
||||
}
|
||||
for mut vis in &mut bar_vis {
|
||||
if *vis != content_vis {
|
||||
*vis = content_vis;
|
||||
}
|
||||
}
|
||||
if !screen.active || screen.blackout {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Backdrop art (resolve once per map via the FK chain; cached by path). ---
|
||||
// --- Backdrop art (resolve via the FK chain; cached by path). Before the snap lands the
|
||||
// destination is `pending_map` (the roster/transfer told us); after it, `CurrentMap`. ---
|
||||
let art_map = screen.pending_map.or(map_id);
|
||||
if let (Some(map_id), Some(maps), Some(screens), Some(assets)) =
|
||||
(map_id, maps.as_ref(), screens.as_ref(), assets.as_mut())
|
||||
(art_map, maps.as_ref(), screens.as_ref(), assets.as_mut())
|
||||
{
|
||||
if let Some(path) = maps
|
||||
.0
|
||||
@@ -330,7 +518,7 @@ fn drive_loading_screen(
|
||||
screen.art_cache.insert(path.clone(), h.clone());
|
||||
}),
|
||||
};
|
||||
if let (Some(handle), Ok(mut img)) = (handle, backdrop.single_mut()) {
|
||||
if let (Some(handle), Ok((mut img, _))) = (handle, backdrop.single_mut()) {
|
||||
if img.image != handle {
|
||||
img.image = handle;
|
||||
img.color = Color::WHITE; // reveal the art (was tinted black)
|
||||
@@ -339,8 +527,12 @@ fn drive_loading_screen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Progress bar: monotonic fill, reveals left→right, width = progress·FILL_MAX_WIDTH. ---
|
||||
screen.displayed = screen.displayed.max(progress.fraction());
|
||||
// --- Progress bar: monotonic fill, reveals left→right, width = progress·FILL_MAX_WIDTH.
|
||||
// While the snap is still in flight the residency being published is the OLD location's
|
||||
// (fully resident) — hold the bar at zero until the destination's own numbers exist. ---
|
||||
if !screen.awaiting_snap {
|
||||
screen.displayed = screen.displayed.max(progress.fraction());
|
||||
}
|
||||
let frac = screen.displayed;
|
||||
if let Ok(mut node) = fill.single_mut() {
|
||||
node.width = Val::Percent(frac * FILL_MAX_WIDTH * 100.0);
|
||||
|
||||
@@ -248,7 +248,7 @@ pub struct PendingAppearFade {
|
||||
|
||||
/// Backstop: arm a pending fade after this long even if the world never reports "shown" — bounds the
|
||||
/// worst case (a stuck load) so an entity can never hang invisible. Generous so it almost never fires
|
||||
/// before the real `focus_resident` signal does.
|
||||
/// before the real signal (the loading screen dropping) does.
|
||||
const PENDING_TIMEOUT_SECS: f32 = 8.0;
|
||||
|
||||
/// The appear-fade's clock, mirrored onto a streamed unit's **root** entity the moment its body first
|
||||
@@ -301,27 +301,29 @@ pub fn join_unit_appear_fade(unit: Option<UnitAppearFade>) -> JoinedFade {
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm each [`PendingAppearFade`] into a live [`RenderFade`] once the world is on-screen — the loading
|
||||
/// screen's focus tile is resident (`WorldLoadProgress::focus_resident`), so we're not covered — or after
|
||||
/// [`PENDING_TIMEOUT_SECS`] as a backstop. This is the faithful trigger: the ramp starts when the player
|
||||
/// can actually see the entity. Also advances each unit-root [`UnitAppearFade`] clock in lockstep (same
|
||||
/// trigger, same instant) — a separate, smaller query since the root marker carries no material handles.
|
||||
/// Arm each [`PendingAppearFade`] into a live [`RenderFade`] once the world is on-screen — the
|
||||
/// loading screen is not covering (it used to read the `focus_resident` proxy, which goes true
|
||||
/// well before the screen actually drops now that the clear waits for the whole scene — decision
|
||||
/// 0737) — or after [`PENDING_TIMEOUT_SECS`] as a backstop. This is the faithful trigger: the ramp
|
||||
/// starts when the player can actually see the entity. Also advances each unit-root
|
||||
/// [`UnitAppearFade`] clock in lockstep (same trigger, same instant) — a separate, smaller query
|
||||
/// since the root marker carries no material handles.
|
||||
pub(crate) fn arm_appear_fade(
|
||||
time: Res<Time>,
|
||||
progress: Res<crate::loading_screen::WorldLoadProgress>,
|
||||
screen: Res<crate::loading_screen::LoadingScreen>,
|
||||
mut commands: Commands,
|
||||
q: Query<(Entity, &PendingAppearFade)>,
|
||||
mut units: Query<&mut UnitAppearFade>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
let shown = progress.focus_resident;
|
||||
let shown = !screen.covering();
|
||||
for (entity, pending) in &q {
|
||||
if shown || now - pending.since > PENDING_TIMEOUT_SECS {
|
||||
// `try_*`, like every fade command here: these entities are **wire-owned** — a net
|
||||
// destroy can apply at any sync point between this system's query and its own
|
||||
// commands, so fade bookkeeping on an already-dead entity is a no-op, never an error.
|
||||
// (The observed crash: login flips `focus_resident`, every pending fade arms in one
|
||||
// frame, and a same-frame wire despawn beat this insert — decision 0200.)
|
||||
// (The observed crash: the login load ends, every pending fade arms in one frame,
|
||||
// and a same-frame wire despawn beat this insert — decision 0200.)
|
||||
commands
|
||||
.entity(entity)
|
||||
.try_insert(RenderFade::appear(
|
||||
@@ -477,11 +479,8 @@ mod tests {
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Time>();
|
||||
world.insert_resource(crate::loading_screen::WorldLoadProgress {
|
||||
ready: 1,
|
||||
total: 1,
|
||||
focus_resident: true, // "world shown" — every pending fade arms this frame
|
||||
});
|
||||
// Default screen = not covering = "world shown" — every pending fade arms this frame.
|
||||
world.init_resource::<crate::loading_screen::LoadingScreen>();
|
||||
let doomed = world
|
||||
.spawn(PendingAppearFade {
|
||||
cutout: Handle::default(),
|
||||
@@ -492,13 +491,13 @@ mod tests {
|
||||
|
||||
let mut state: SystemState<(
|
||||
Res<Time>,
|
||||
Res<crate::loading_screen::WorldLoadProgress>,
|
||||
Res<crate::loading_screen::LoadingScreen>,
|
||||
Commands,
|
||||
Query<(Entity, &PendingAppearFade)>,
|
||||
Query<&mut UnitAppearFade>,
|
||||
)> = SystemState::new(&mut world);
|
||||
let (time, progress, commands, q, units) = state.get_mut(&mut world);
|
||||
arm_appear_fade(time, progress, commands, q, units);
|
||||
let (time, screen, commands, q, units) = state.get_mut(&mut world);
|
||||
arm_appear_fade(time, screen, commands, q, units);
|
||||
|
||||
// The wire destroy beats the fade commands to the sync point.
|
||||
world.despawn(doomed);
|
||||
|
||||
@@ -64,12 +64,15 @@ pub(crate) use camera::{head_height, CameraControl, CameraPivot, WorldCamera, CA
|
||||
use state::{
|
||||
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, CAPSULE_RADIUS, FALL_FAR_DROP, FALL_FAR_TIME,
|
||||
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
|
||||
SETTLE_REACH, SETTLE_TIMEOUT, SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO,
|
||||
STEP_SNAP_SLACK, STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL,
|
||||
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT,
|
||||
TURN_RATE, TURN_RATE_MOVING, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
};
|
||||
// `SETTLE_TIMEOUT` is `pub(crate)`: the settle release lives in the terrain streamer (decision
|
||||
// 0737 — residency releases the hold, not ground contact), which owns the deadline push while the
|
||||
// resident world is still the departed map's (0710).
|
||||
pub(crate) use state::{
|
||||
Player, PlayerCapsule, CAPSULE_HEIGHT, DEFAULT_COLLISION_HEIGHT, GRAVITY, TERMINAL_VELOCITY,
|
||||
Player, PlayerCapsule, CAPSULE_HEIGHT, DEFAULT_COLLISION_HEIGHT, GRAVITY, SETTLE_TIMEOUT,
|
||||
TERMINAL_VELOCITY,
|
||||
};
|
||||
/// The swim boundary `0.75·h` — and therefore the **wade ceiling**, since wading is the implicit
|
||||
/// in-liquid-but-not-swimming state and the two cannot be different numbers. Read by the creature
|
||||
|
||||
@@ -89,20 +89,21 @@ pub(super) fn swim(feet_y: f32, surface_y: f32, swimming: bool, h: f32) {
|
||||
);
|
||||
}
|
||||
|
||||
/// How the post-teleport settle gate **ended** — the whole diagnosis of a fall-through report.
|
||||
/// How the post-teleport settle hold **ended** — the whole diagnosis of a fall-through report.
|
||||
///
|
||||
/// `found` means a floor was under the feet and the hold released onto it; `!found` is the
|
||||
/// [`super::SETTLE_TIMEOUT`] backstop firing, which switches gravity on having never seen ground.
|
||||
/// The distinction is invisible from inside the game (both look like "the loading screen went
|
||||
/// away") and it is the difference between a world that streamed in time and one that did not, so
|
||||
/// the timeout end is also a `warn!` on the ordinary log — a reporter's paste can then name it
|
||||
/// without owning a trace.
|
||||
pub(super) fn settle(found: bool, waited: f32, pos: bevy::prelude::Vec3) {
|
||||
if !found {
|
||||
/// `resident` means the destination's world arrived (scene spawned + collider queue quiet —
|
||||
/// decision 0737's release) and the hold released onto it; `!resident` is the
|
||||
/// [`super::SETTLE_TIMEOUT`] backstop firing, which switches gravity on with the world never
|
||||
/// having become resident. The distinction is invisible from inside the game (both look like "the
|
||||
/// loading screen went away") and it is the difference between a world that streamed in time and
|
||||
/// one that did not, so the timeout end is also a `warn!` on the ordinary log — a reporter's paste
|
||||
/// can then name it without owning a trace.
|
||||
pub(super) fn settle(resident: bool, waited: f32, pos: bevy::prelude::Vec3) {
|
||||
if !resident {
|
||||
bevy::log::warn!(
|
||||
"settle: TIMED OUT after {waited:.2}s with no ground under the feet at \
|
||||
({:.1},{:.1},{:.1}) — releasing into free fall. If a building stands here, its \
|
||||
collider had not finished streaming and the body is about to fall through it.",
|
||||
"settle: TIMED OUT after {waited:.2}s with the world never resident at \
|
||||
({:.1},{:.1},{:.1}) — releasing anyway. If a building stands here, its collider \
|
||||
had not finished streaming and the body is about to fall through it.",
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
@@ -115,10 +116,10 @@ pub(super) fn settle(found: bool, waited: f32, pos: bevy::prelude::Vec3) {
|
||||
"sett",
|
||||
&format!(
|
||||
"{} after {waited:6.2}s at ({:8.2},{:7.2},{:8.2})",
|
||||
if found {
|
||||
"ground found"
|
||||
if resident {
|
||||
"world resident"
|
||||
} else {
|
||||
"TIMED OUT "
|
||||
"TIMED OUT "
|
||||
},
|
||||
pos.x,
|
||||
pos.y,
|
||||
|
||||
@@ -33,9 +33,8 @@ use crate::collision::player_query_filter;
|
||||
|
||||
use super::{
|
||||
move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE,
|
||||
JUMP_SPEED, LAND_PROBE, SETTLE_REACH, SETTLE_TIMEOUT, SKIN_WIDTH, STEP_SLOPE_RATIO,
|
||||
STEP_SNAP_SLACK, STEP_UP_HEIGHT, TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO,
|
||||
WEDGE_STILL_FRAMES,
|
||||
JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_HEIGHT,
|
||||
TERMINAL_VELOCITY, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
|
||||
};
|
||||
|
||||
/// What the step decided — read by the move-flags / wire logic that follows it in `control`.
|
||||
@@ -55,8 +54,9 @@ pub(super) struct Outcome {
|
||||
pub ground: Option<Entity>,
|
||||
}
|
||||
|
||||
/// Advance the player mover one frame: settle gate, ground classify, the slide, and the
|
||||
/// step-down snap. Writes `player.pos`/`vel_y`/`horiz_vel`/`settling`.
|
||||
/// Advance the player mover one frame: settle hold, ground classify, the slide, and the
|
||||
/// step-down snap. Writes `player.pos`/`vel_y`/`horiz_vel` (the settle *release* is the terrain
|
||||
/// streamer's — decision 0737).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn step(
|
||||
player: &mut Player,
|
||||
@@ -101,37 +101,15 @@ pub(super) fn step(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Settle gate (post-teleport/summon/login): the streamed world — terrain *and* WMO building
|
||||
// Settle hold (post-teleport/summon/login): the streamed world — terrain *and* WMO building
|
||||
// floors + their colliders — arrives over several frames, so the ground under the snap isn't
|
||||
// there yet. End settling once a probe finds the ground under our feet (`SETTLE_REACH`, kept
|
||||
// small so it's the *close* floor we stand on, not distant terrain seen through an unloaded
|
||||
// building) or once we time out. Until then `held` keeps gravity OFF and freezes us in place,
|
||||
// so we don't fall through the not-yet-loaded city/building (the loading screen stays up too).
|
||||
//
|
||||
// Which of the two ends it is the whole diagnosis of a fall-through report, so say it out loud.
|
||||
// The timeout branch is a **fail-open**: it switches gravity on without ever having found a
|
||||
// floor, so a reporter's log has to be able to name it — a `warn!` here is the difference
|
||||
// between a bug we can confirm and one we can only fail to reproduce.
|
||||
//
|
||||
// Neither end may be judged while the world under us is still the one we just left
|
||||
// ([`Player::world_stale`]) — that probe would be asking the wrong map, and answering "found"
|
||||
// is the instance-entry fall-through. The deadline is held off with it, so the timeout budget
|
||||
// measures time spent waiting for the *destination's* floor rather than being spent before the
|
||||
// destination exists; a world that never arrives keeps the hold (and the loading screen) rather
|
||||
// than dropping the body into the void.
|
||||
if player.settling {
|
||||
if player.world_stale {
|
||||
player.settle_deadline = time.elapsed_secs() + SETTLE_TIMEOUT;
|
||||
} else {
|
||||
let found = probe_down(center, SETTLE_REACH).is_some_and(|h| h.normal1.y >= GROUND_COS);
|
||||
let timed_out = time.elapsed_secs() >= player.settle_deadline;
|
||||
if found || timed_out {
|
||||
player.settling = false;
|
||||
let waited = SETTLE_TIMEOUT - (player.settle_deadline - time.elapsed_secs());
|
||||
move_trace::settle(found, waited, player.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
// there yet. While settling, `held` keeps gravity OFF and freezes us in place, so we don't
|
||||
// fall through the not-yet-loaded city/building (the loading screen stays up too). The
|
||||
// *release* does not live here (decision 0737): it is the terrain streamer's, keyed on the
|
||||
// destination's residency (scene + colliders, `WorldLoadProgress`) with the timeout backstop —
|
||||
// never on ground contact, which only the walk mover could observe and which a flyer, a
|
||||
// swimmer, or a genuinely airborne teleport never produces (the loading-screen-until-landing
|
||||
// hang). The streamer runs every frame in every mover mode, so every mode releases the same way.
|
||||
let held = player.settling;
|
||||
let on_floor = !held && on_walkable && player.vel_y <= 0.0;
|
||||
|
||||
|
||||
@@ -140,13 +140,11 @@ pub(super) const FALL_FAR_TIME: f32 = 0.5;
|
||||
pub(super) const SKIN_WIDTH: f32 = 0.02;
|
||||
|
||||
/// Max seconds to hold the avatar after a teleport while the world streams in (see [`Player::settling`]).
|
||||
/// Generous — a dense city's WMO colliders load in a couple seconds; this only backstops a genuinely
|
||||
/// airborne teleport or a missing collider so we never hang in mid-air forever.
|
||||
pub(super) const SETTLE_TIMEOUT: f32 = 6.0;
|
||||
/// How far below the feet a settle probe looks for the ground that ends settling. Small, so it ends on
|
||||
/// the *close* floor we were placed on (terrain or a now-loaded building floor) — not on distant terrain
|
||||
/// glimpsed through a building whose collider hasn't streamed in yet.
|
||||
pub(super) const SETTLE_REACH: f32 = 1.0;
|
||||
/// Generous — a dense city's spawn + collider queue drains in a couple seconds; this only backstops a
|
||||
/// world that never becomes resident (missing data, a stalled stream) so we never hang forever. The
|
||||
/// release itself is the terrain streamer's (decision 0737), which also pushes the deadline while the
|
||||
/// resident world is still the departed map's (0710's fail-closed law).
|
||||
pub(crate) const SETTLE_TIMEOUT: f32 = 6.0;
|
||||
|
||||
/// The player body's collision capsule, built once at startup and swept by avian's `MoveAndSlide`
|
||||
/// each frame. Its origin is the capsule centre; the player's `pos` is its feet (centre −
|
||||
@@ -233,30 +231,30 @@ pub(crate) struct Player {
|
||||
pub(super) stand_pending: Option<u8>,
|
||||
/// **Settling after a teleport/summon/login**: the streamed world (terrain *and* its WMO
|
||||
/// buildings + colliders) arrives over several frames, so the collision under the destination
|
||||
/// isn't there the instant we snap to it. While settling we hold the avatar in place with gravity
|
||||
/// **off** — otherwise it falls through the not-yet-loaded city/building floor — and keep the
|
||||
/// loading screen up. Cleared once a downward probe finds the ground under our feet, or after
|
||||
/// [`SETTLE_TIMEOUT`] (so a genuinely airborne teleport, or missing collision, still releases).
|
||||
/// isn't there the instant we snap to it. While settling the movers hold the avatar in place
|
||||
/// with gravity **off** — otherwise it falls through the not-yet-loaded city/building floor —
|
||||
/// and the loading screen waits for the release. **Released by the terrain streamer** (decision
|
||||
/// 0737) once the destination is resident — scene spawned and collider queue quiet
|
||||
/// ([`crate::loading_screen::WorldLoadProgress`]) — or at [`SETTLE_TIMEOUT`]; **never by ground
|
||||
/// contact**, which a flyer, a swimmer, or a genuinely airborne teleport never produces (the
|
||||
/// old probe release ran only in the walk mover, and each other mover mode needed — and got —
|
||||
/// its own leak patch before 0737 deleted the class).
|
||||
pub(crate) settling: bool,
|
||||
/// `Time::elapsed_secs` deadline to give up settling and release (see [`Player::settling`]).
|
||||
pub(super) settle_deadline: f32,
|
||||
/// Pushed forward by the streamer while [`Player::world_stale`] (0710's fail-closed law).
|
||||
pub(crate) settle_deadline: f32,
|
||||
/// **The colliders under our feet may still belong to the map we just left.** Set at every
|
||||
/// snap, cleared by the terrain streamer once the destination's own world is resident.
|
||||
///
|
||||
/// The snap and the settle probe run in the SAME system (`control`: `wire_in` then the mover),
|
||||
/// one whole `WorldStage` *before* the streamer swaps maps — so on the arrival frame the physics
|
||||
/// world is still entirely the old map's, and its despawn is a deferred command on top of that.
|
||||
/// A settle probe that fires into that window is asking the wrong world whether there is a floor.
|
||||
///
|
||||
/// It normally answers "no" and costs nothing: a `.go` across the continent lands nowhere near
|
||||
/// the old ground. But an **instance entrance sits within a yard of its own outdoor portal** —
|
||||
/// Zul'Gurub's pad is z 92.53 against the Stranglethorn ground you were standing on at 92.30 —
|
||||
/// so the probe hit the world we were leaving, declared the floor found, released the hold on
|
||||
/// frame 0, and dropped the body 15 yd through the WMO onto the terrain below (the ZG and
|
||||
/// Stratholme entry fall-through). Only walking a portal could show it; `.go` between the two
|
||||
/// maps never reproduces, because only the portal puts the two floors on top of each other.
|
||||
/// Cleared by `terrain_stream` (the only system that knows which map the resident colliders
|
||||
/// belong to); read by the mover's settle gate.
|
||||
/// The snap runs a whole `WorldStage` *before* the streamer swaps maps — so on the arrival
|
||||
/// frame the physics world is still entirely the old map's, and its despawn is a deferred
|
||||
/// command on top of that. Any settle judgement made in that window would be about the wrong
|
||||
/// world — and an **instance entrance sits within a yard of its own outdoor portal**
|
||||
/// (Zul'Gurub's pad is z 92.53 against the Stranglethorn ground you left at 92.30), which is
|
||||
/// how the pre-0710 ground probe declared the floor found on frame 0 and dropped the body
|
||||
/// 15 yd through the ZG city WMO. While this flag is set the streamer makes no release
|
||||
/// judgement and pushes [`Player::settle_deadline`] forward, so the timeout budget measures
|
||||
/// time waiting for the *destination's* world (decisions 0710 + 0737).
|
||||
pub(crate) world_stale: bool,
|
||||
/// A same-map teleport landed: the server relocated the mover, so any in-progress self
|
||||
/// server-ride (charge/taxi) is **void** — vmangos teleports at ITS flight end (its own spline
|
||||
@@ -388,6 +386,16 @@ impl Player {
|
||||
self.face_yaw
|
||||
}
|
||||
|
||||
/// End the post-snap settle hold (decision 0737) — called by the terrain streamer, the only
|
||||
/// system that knows the destination's residency. `resident` = the world arrived (scene +
|
||||
/// colliders); `false` = the [`SETTLE_TIMEOUT`] backstop fired without it. Which end it was is
|
||||
/// the whole diagnosis of a fall-through report, so it goes through the `sett` trace either way.
|
||||
pub(crate) fn end_settle(&mut self, resident: bool, now: f32) {
|
||||
self.settling = false;
|
||||
let waited = SETTLE_TIMEOUT - (self.settle_deadline - now);
|
||||
super::move_trace::settle(resident, waited, self.pos);
|
||||
}
|
||||
|
||||
/// The avatar's current CMovement move-flags as last streamed (directional + turn bits — see
|
||||
/// [`crate::creature_anim::move_flags`]). The water-foam selector reads the same two bit-tests as
|
||||
/// the reference (`& 0xf` translating, `& 0x30` turning; wow-re CWater0Ripple driver `0x5fa760`).
|
||||
|
||||
@@ -146,13 +146,10 @@ pub(super) fn surface_over_feet(
|
||||
/// clears FALLING, `+0xa0` left dormant), which our swim arm mirrors (`swim_step` zeroes `vel_y`,
|
||||
/// the controller clears the arc). `now` is `Time::elapsed_secs`, for the airborne clock.
|
||||
///
|
||||
/// A latched swim also **ends the post-teleport/login settle** (benilla's own streaming gate —
|
||||
/// the ref blocks on load and has no such state). Settling holds the avatar so it can't fall
|
||||
/// through not-yet-streamed floors, and its release gate runs only in the walk mover
|
||||
/// ([`super::mover::step`]) — which a swimmer never reaches — while the floating resolver can't
|
||||
/// fall at all (gravity bypassed, depth frozen). The latch itself proves the tile's liquid is
|
||||
/// resident, so the water IS the arrived support; without this, a login into swim-depth water
|
||||
/// held `settling` — and the loading screen, which waits on it — forever.
|
||||
/// The latch deliberately does NOT touch [`Player::settling`]: the settle release is the terrain
|
||||
/// streamer's, keyed on the destination's residency in every mover mode alike (decision 0737 —
|
||||
/// this latch used to release it as a special case, because the old release was a ground probe
|
||||
/// only the walk mover ran).
|
||||
pub(super) fn update_swimming(player: &mut Player, surface_y: Option<f32>, now: f32) -> bool {
|
||||
// **LEVITATING bails the whole decision** — the reference's very first instruction here
|
||||
// (`0x6030d2 test ah,4` → `0x6031fa`; VERIFIED, wow-re `swim-transition.md`): neither the ENTER
|
||||
@@ -178,9 +175,6 @@ pub(super) fn update_swimming(player: &mut Player, surface_y: Option<f32>, now:
|
||||
.is_some_and(|t0| now - t0 < player.jump_zspeed / (2.0 * GRAVITY));
|
||||
depth > swim_enter_depth(h) && !hop_blocked
|
||||
};
|
||||
if player.swimming {
|
||||
player.settling = false;
|
||||
}
|
||||
player.swimming
|
||||
}
|
||||
|
||||
@@ -493,38 +487,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A login/teleport into swim-depth water must end the settle hold: the settle release gate
|
||||
/// runs only in the walk mover, which a swimmer never reaches — before this rule, `settling`
|
||||
/// stayed latched forever and the loading screen (which clears only on `!settling`) hung at a
|
||||
/// full bar (the Booty Bay underwater-login hang, 2026-07-18). Wading depth and dry land keep
|
||||
/// the hold — there the walk mover runs and its own gate (ground probe / timeout) decides.
|
||||
/// The latch never touches the settle hold (decision 0737): the release is the terrain
|
||||
/// streamer's, keyed on residency in every mover mode alike. This pins the *absence* of the
|
||||
/// old special case — a swim latch that cleared `settling` here would race the streamer's
|
||||
/// judgement (the pre-0737 rule existed only because the old release was a walk-mover ground
|
||||
/// probe a swimmer never reached; the Booty Bay underwater-login hang, 2026-07-18).
|
||||
#[test]
|
||||
fn latching_swim_ends_the_settle_hold() {
|
||||
let mut p = player_at(0.0);
|
||||
p.settling = true;
|
||||
assert!(update_swimming(
|
||||
&mut p,
|
||||
Some(swim_enter_depth(HUMAN_MALE) + 1.0),
|
||||
0.0
|
||||
));
|
||||
assert!(!p.settling, "the water is the support settling waited for");
|
||||
|
||||
let mut wading = player_at(0.0);
|
||||
wading.settling = true;
|
||||
assert!(!update_swimming(
|
||||
&mut wading,
|
||||
Some(swim_exit_depth(HUMAN_MALE) - 0.5),
|
||||
0.0
|
||||
));
|
||||
assert!(
|
||||
wading.settling,
|
||||
"wading depth leaves the hold to the walk gate"
|
||||
);
|
||||
|
||||
let mut dry = player_at(0.0);
|
||||
dry.settling = true;
|
||||
assert!(!update_swimming(&mut dry, None, 0.0));
|
||||
assert!(dry.settling, "no liquid leaves the hold to the walk gate");
|
||||
fn the_latch_leaves_the_settle_hold_alone() {
|
||||
for (surface, name) in [
|
||||
(Some(swim_enter_depth(HUMAN_MALE) + 1.0), "swim depth"),
|
||||
(Some(swim_exit_depth(HUMAN_MALE) - 0.5), "wading depth"),
|
||||
(None, "dry land"),
|
||||
] {
|
||||
let mut p = player_at(0.0);
|
||||
p.settling = true;
|
||||
update_swimming(&mut p, surface, 0.0);
|
||||
assert!(p.settling, "{name} must leave the hold to the streamer");
|
||||
}
|
||||
}
|
||||
|
||||
/// The verified fall re-entry gate (`0x7c5de0`): a fresh swim jump is not re-latched into swim
|
||||
|
||||
@@ -331,14 +331,10 @@ fn apply_self_move(
|
||||
// echo"). This pair is GM flight: `.cheat fly` sends SWIMMING + LEVITATING together.
|
||||
player.swimming = player.move_flags & move_flags::SWIMMING != 0;
|
||||
player.levitating = player.move_flags & move_flags::LEVITATING != 0;
|
||||
if player.swimming {
|
||||
// A latched swim ends the settle hold — `update_swimming`'s own rule, and it has to be
|
||||
// repeated here because that function is exactly what LEVITATING switches off. Without it,
|
||||
// `.cheat fly` during a post-teleport settle strands `settling` forever: the swim mover
|
||||
// never reaches the walk mover's release gate, and the loading screen waits on `!settling`
|
||||
// (the shape of the Booty Bay underwater-login hang).
|
||||
player.settling = false;
|
||||
}
|
||||
// Neither mode touches `settling`: the settle release is the terrain streamer's, keyed on the
|
||||
// destination's residency in every mover mode alike (decision 0737). The pre-0737 special case
|
||||
// here (swim clears the hold) existed only because the old release was a walk-mover ground
|
||||
// probe a swimmer/flyer never reached.
|
||||
|
||||
player.pos = wow_to_bevy(m.position);
|
||||
// Facing turns **rigidly** — aim, rendered body and camera all take the same delta (the
|
||||
|
||||
@@ -94,6 +94,12 @@ pub(crate) struct RigPalettes {
|
||||
peak_slots: usize,
|
||||
peak_bones: u32,
|
||||
live_bones: u32,
|
||||
/// `WOW_RIG_COST` meters (decision 0736 premise check): whole-vec deep copies this frame
|
||||
/// (`Arc::make_mut` clones when the extract still holds last publish's reference), the µs
|
||||
/// they took, and the rows (bones) written. Printed + reset by [`publish_rig_palettes`].
|
||||
cost_copies: u32,
|
||||
cost_copy_us: f32,
|
||||
cost_rows: u32,
|
||||
}
|
||||
|
||||
impl Default for RigPalettes {
|
||||
@@ -111,10 +117,40 @@ impl Default for RigPalettes {
|
||||
peak_slots: 0,
|
||||
peak_bones: 0,
|
||||
live_bones: 0,
|
||||
cost_copies: 0,
|
||||
cost_copy_us: 0.0,
|
||||
cost_rows: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_RIG_COST=1`: the rig lane's per-frame cost split — copy vs compose vs upload — in
|
||||
/// grep-and-average lines beside `FPS_PROBE` (the 0736 premise meter; the 0734 lesson made
|
||||
/// premise checks law). Zero-cost when off: one env read, once.
|
||||
pub(crate) fn rig_cost_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_RIG_COST").is_some())
|
||||
}
|
||||
|
||||
/// `Arc::make_mut` on the shared rows, timing the calls that actually deep-copy: between a
|
||||
/// publish and the frame's first write the extract holds a second reference, so that write
|
||||
/// clones the whole ~6.3 MB vec. A static helper (not a method) so call sites keep their
|
||||
/// split-field borrows.
|
||||
fn rows_make_mut<'a>(
|
||||
rows: &'a mut Arc<Vec<[f32; 4]>>,
|
||||
copies: &mut u32,
|
||||
copy_us: &mut f32,
|
||||
) -> &'a mut Vec<[f32; 4]> {
|
||||
if Arc::strong_count(rows) > 1 || Arc::weak_count(rows) > 0 {
|
||||
let t = std::time::Instant::now();
|
||||
let out = Arc::make_mut(rows);
|
||||
*copy_us += t.elapsed().as_secs_f32() * 1e6;
|
||||
*copies += 1;
|
||||
return out;
|
||||
}
|
||||
Arc::make_mut(rows)
|
||||
}
|
||||
|
||||
impl RigPalettes {
|
||||
/// Claim a slot + a bone range for a rig. `None` when either slab is exhausted — the caller
|
||||
/// warns and falls back to the static (bind-pose) mesh, graceful and visible in the log
|
||||
@@ -167,7 +203,11 @@ impl RigPalettes {
|
||||
self.free_slots.push(slot);
|
||||
self.live_bones -= len;
|
||||
self.table_generation += 1;
|
||||
let rows = Arc::make_mut(&mut self.rows);
|
||||
let rows = rows_make_mut(
|
||||
&mut self.rows,
|
||||
&mut self.cost_copies,
|
||||
&mut self.cost_copy_us,
|
||||
);
|
||||
rows[3 * base as usize..3 * (base + len) as usize].fill([0.0; 4]);
|
||||
self.dirty.push((base, len, self.mirrored[s]));
|
||||
// Insert sorted + coalesce with both neighbours.
|
||||
@@ -202,7 +242,12 @@ impl RigPalettes {
|
||||
worlds: &Query<Ref<GlobalTransform>>,
|
||||
) {
|
||||
let n = (joints.len().min(ibp.len()) as u32).min(alloc_len);
|
||||
let rows = Arc::make_mut(&mut self.rows);
|
||||
self.cost_rows += n;
|
||||
let rows = rows_make_mut(
|
||||
&mut self.rows,
|
||||
&mut self.cost_copies,
|
||||
&mut self.cost_copy_us,
|
||||
);
|
||||
for b in 0..n as usize {
|
||||
let Ok(g) = worlds.get(joints[b]) else {
|
||||
continue; // a torn-down joint keeps its last rows
|
||||
@@ -232,7 +277,12 @@ impl RigPalettes {
|
||||
) {
|
||||
let n = (worlds.len().min(ibp.len()) as u32).min(rig.len);
|
||||
let (slot, base) = (rig.slot, rig.base);
|
||||
let rows = Arc::make_mut(&mut self.rows);
|
||||
self.cost_rows += n;
|
||||
let rows = rows_make_mut(
|
||||
&mut self.rows,
|
||||
&mut self.cost_copies,
|
||||
&mut self.cost_copy_us,
|
||||
);
|
||||
for b in 0..n as usize {
|
||||
let m = worlds[b].affine() * Affine3A::from_mat4(ibp[b]);
|
||||
let (m3, t) = (m.matrix3, m.translation);
|
||||
@@ -442,6 +492,16 @@ fn publish_rig_palettes(mut palettes: ResMut<RigPalettes>, mut out: ResMut<RigPa
|
||||
return;
|
||||
}
|
||||
let p = palettes.as_mut();
|
||||
if rig_cost_enabled() {
|
||||
eprintln!(
|
||||
"[rig-cost] ranges={} rows={} copies={} copy_ms={:.3}",
|
||||
p.dirty.len(),
|
||||
p.cost_rows,
|
||||
p.cost_copies,
|
||||
p.cost_copy_us / 1000.0
|
||||
);
|
||||
(p.cost_copies, p.cost_copy_us, p.cost_rows) = (0, 0.0, 0);
|
||||
}
|
||||
out.rows = Arc::clone(&p.rows);
|
||||
out.table = Arc::clone(&p.table);
|
||||
out.dirty = Arc::new(std::mem::take(&mut p.dirty));
|
||||
@@ -475,6 +535,9 @@ fn upload_rig_palettes(
|
||||
return;
|
||||
}
|
||||
*last = (Some(data.table_generation), dirty_ptr);
|
||||
let cost_t0 = rig_cost_enabled().then(std::time::Instant::now);
|
||||
let mut cost_calls = 0u32;
|
||||
let mut cost_bytes = 0u64;
|
||||
// Coalesce the per-rig dirty ranges before touching the queue: rigs allocate contiguously,
|
||||
// so a steady frame's ~750 one-rig ranges merge into a handful of runs — the 0724 ledger
|
||||
// measured the per-range `write_buffer` loop at 2.7 ms/frame, almost all call overhead.
|
||||
@@ -509,9 +572,18 @@ fn upload_rig_palettes(
|
||||
palette_region_offset() + base as u64 * BONE_BYTES,
|
||||
bytemuck::cast_slice(rows),
|
||||
);
|
||||
cost_calls += 1;
|
||||
cost_bytes += len as u64 * BONE_BYTES;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t0) = cost_t0 {
|
||||
eprintln!(
|
||||
"[rig-upload] calls={cost_calls} kb={} ms={:.3}",
|
||||
cost_bytes / 1024,
|
||||
t0.elapsed().as_secs_f32() * 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort + merge overlapping/adjacent `(base, len)` bone ranges into maximal runs.
|
||||
|
||||
@@ -33,6 +33,7 @@ use crate::liquid::{spawn_liquids, LiquidAssets};
|
||||
use crate::loading_screen::WorldLoadProgress;
|
||||
use crate::model_render::{m2_url, wmo_url, MaterialCache};
|
||||
use crate::player::{Player, WorldCamera};
|
||||
use crate::schedule::WorldStage;
|
||||
use crate::terrain::{TerrainExtension, TerrainMaterial};
|
||||
use crate::world_map::{CurrentMap, MapCatalogRes};
|
||||
use crate::SPAWN_XY;
|
||||
@@ -242,29 +243,36 @@ impl Plugin for TerrainPlugin {
|
||||
app.init_resource::<TerrainStreamer>()
|
||||
.init_resource::<Placements>()
|
||||
.init_resource::<CurrentArea>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
stream_terrain,
|
||||
spawn_loaded_placements,
|
||||
sync_interior_volumes,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
// Attaches off-thread-built colliders when ready (independent of the streaming chain).
|
||||
// **The streaming chain lives in `WorldStage::Stream`** — the load-bearing half of the
|
||||
// frame ordering contract (schedule.rs): the teleport snap (Input) → the streamer
|
||||
// recomputes focus + publishes residency HERE → the loading screen covers (Present),
|
||||
// all in one frame, so a swap never renders uncovered. The legacy streamer carried
|
||||
// this membership and the cutover to this one silently dropped it — the executor was
|
||||
// then free to run the streamer *before* the snap, and the cover landed a frame late
|
||||
// on exactly the burst frame (the `.tele` destination flash; decision 0738).
|
||||
// `finish_colliders` heads the chain so the collider-queue depth the settle release
|
||||
// and the Present-stage clear read is this frame's, not last frame's.
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
finish_colliders,
|
||||
// After the interior claim so the leaf override reads THIS frame's claim —
|
||||
// the client resolves leaf + indoor + names from ONE node state in one pass
|
||||
// (`0x67e510`); a stale-leaf/fresh-claim frame let the abbey login big-splash
|
||||
// (the A ≠ subzone gate saw "Northshire Valley" against the abbey's name).
|
||||
// `AreaAuthoritySet` lets the zone-text feed order after this in turn.
|
||||
update_current_area
|
||||
.after(crate::wmo_portal::WmoPvsSet)
|
||||
.in_set(AreaAuthoritySet),
|
||||
),
|
||||
stream_terrain,
|
||||
spawn_loaded_placements,
|
||||
sync_interior_volumes,
|
||||
)
|
||||
.chain()
|
||||
.in_set(WorldStage::Stream),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
// After the interior claim so the leaf override reads THIS frame's claim —
|
||||
// the client resolves leaf + indoor + names from ONE node state in one pass
|
||||
// (`0x67e510`); a stale-leaf/fresh-claim frame let the abbey login big-splash
|
||||
// (the A ≠ subzone gate saw "Northshire Valley" against the abbey's name).
|
||||
// `AreaAuthoritySet` lets the zone-text feed order after this in turn.
|
||||
update_current_area
|
||||
.after(crate::wmo_portal::WmoPvsSet)
|
||||
.in_set(AreaAuthoritySet),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -287,6 +295,7 @@ fn stream_terrain(
|
||||
ResMut<Assets<TerrainMaterial>>,
|
||||
ResMut<Assets<Mesh>>,
|
||||
Res<Assets<WdtIndex>>,
|
||||
Res<Time>,
|
||||
),
|
||||
liquid_assets: Option<Res<LiquidAssets>>,
|
||||
clutter: Option<Res<GroundClutter>>,
|
||||
@@ -299,7 +308,7 @@ fn stream_terrain(
|
||||
map_catalog: Option<Res<MapCatalogRes>>,
|
||||
mut load_progress: Option<ResMut<WorldLoadProgress>>,
|
||||
) {
|
||||
let (mut materials, mut meshes, wdts) = asset_stores;
|
||||
let (mut materials, mut meshes, wdts, time) = asset_stores;
|
||||
// The shared light buffer + map catalog are set up by other plugins' startup; until they exist
|
||||
// there's nothing to stream against, so idle.
|
||||
let (Some(shared_light), Some(map_catalog)) = (shared_light, map_catalog) else {
|
||||
@@ -532,10 +541,12 @@ fn stream_terrain(
|
||||
}
|
||||
}
|
||||
|
||||
// Publish residency for the loading screen: how many desired tiles are actually spawned, and
|
||||
// whether the tile under the view focus is up (the screen clears once it is — covers the cold-start
|
||||
// burst + the post-teleport gap). A focus tile that doesn't exist (map edge) counts as resident so
|
||||
// we never get stuck waiting for ground that isn't there.
|
||||
// Publish residency for the loading screen: how many desired tiles are actually spawned,
|
||||
// whether the tile under the view focus is up (the backstop trigger), and the scene term
|
||||
// (decision 0737) — the focus tile + its 8 neighbours with every placement they reference, so
|
||||
// "ready to reveal" means the buildings and trees around you exist, not just bare terrain. A
|
||||
// focus tile that doesn't exist (map edge) counts as resident so we never get stuck waiting
|
||||
// for ground that isn't there.
|
||||
if let Some(p) = load_progress.as_mut() {
|
||||
let spawned = |c: &(i32, i32)| state.tiles.get(c).is_some_and(|t| t.entity.is_some());
|
||||
p.total = desired.len();
|
||||
@@ -544,6 +555,35 @@ fn stream_terrain(
|
||||
.tiles
|
||||
.get(&(cx, cy))
|
||||
.is_none_or(|t| t.entity.is_some());
|
||||
// The focus neighbourhood's placements join the accounting (bar + scene term). A placement
|
||||
// is *up* once its own model spawned AND its WMO props (each an M2 arriving on its own
|
||||
// schedule) have — the furniture the reveal would otherwise pop in. Placements of tiles not
|
||||
// yet spawned aren't known yet; the missing tile itself holds `scene_ready` down.
|
||||
let mut near_pending = 0usize;
|
||||
let mut near_tile_missing = false;
|
||||
for dx in -1..=1i32 {
|
||||
for dy in -1..=1i32 {
|
||||
let c = (cx + dx, cy + dy);
|
||||
if !desired.contains(&c) {
|
||||
continue; // off-grid or WDT says no tile — nothing to wait for
|
||||
}
|
||||
match state.tiles.get(&c) {
|
||||
Some(t) if t.entity.is_some() => {
|
||||
for uid in &t.placements {
|
||||
if let Some(pl) = placements.by_id.get(uid) {
|
||||
let up = pl.spawned && pl.doodads.iter().all(|d| d.spawned);
|
||||
p.total += 1;
|
||||
p.ready += usize::from(up);
|
||||
near_pending += usize::from(!up);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => near_tile_missing = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
p.placements_pending = near_pending;
|
||||
p.scene_ready = p.focus_resident && !near_tile_missing && near_pending == 0;
|
||||
// Until the WDT answers we don't yet know what this map is made of, so nothing under the
|
||||
// focus can be called resident. Without this the post-worldport frames before the index
|
||||
// lands read as "ground is up" — harmless on an ADT map (the tile entries below close the
|
||||
@@ -551,28 +591,47 @@ fn stream_terrain(
|
||||
// difference between a loading screen and a glimpse of the void.
|
||||
if wdt_index.is_none() && !state.wdt_ungated {
|
||||
p.focus_resident = false;
|
||||
p.scene_ready = false;
|
||||
}
|
||||
// A WMO-only map's residency IS its one building (0688) — there are no tiles to count, so
|
||||
// the bar and the clear-condition ride the placement instead. Counting it keeps
|
||||
// `total > 0`, which is what `is_ready` requires before it will clear the screen at all.
|
||||
// The backstop/`world_stale` term takes the building's spawn; the scene term also waits
|
||||
// for its props (the Stockade's 740 candles are the reveal).
|
||||
if state.global_wmo {
|
||||
let up = placements
|
||||
.by_id
|
||||
.get(&GLOBAL_WMO_UID)
|
||||
.is_some_and(|p| p.spawned);
|
||||
let pl = placements.by_id.get(&GLOBAL_WMO_UID);
|
||||
let up = pl.is_some_and(|p| p.spawned);
|
||||
let full = pl.is_some_and(|p| p.spawned && p.doodads.iter().all(|d| d.spawned));
|
||||
p.total += 1;
|
||||
p.ready += usize::from(up);
|
||||
p.ready += usize::from(full);
|
||||
p.focus_resident &= up;
|
||||
p.scene_ready &= full;
|
||||
}
|
||||
// The streamer is the only authority on *which map* the colliders under the player belong
|
||||
// to, so it is what lifts the mover's post-snap hold ([`Player::world_stale`]). Residency
|
||||
// here means this map's own tile (or, on a WMO-only map, its one building) is spawned —
|
||||
// which is only reachable after the swap above drained every tile of the map we left. Until
|
||||
// then a settle probe would be answered by the old world, and at an instance entrance the
|
||||
// old floor sits within a yard of the new one.
|
||||
// which is only reachable after the swap above drained every tile of the map we left.
|
||||
if p.focus_resident && p.total > 0 {
|
||||
player.world_stale = false;
|
||||
}
|
||||
// The settle release (decision 0737): the post-snap hold ends when the destination's world
|
||||
// — scene AND colliders — is resident, never on ground contact (feet-on-ground dragged the
|
||||
// whole mover-mode matrix into a loading decision; a flyer or swimmer never touched it).
|
||||
// It lives HERE, not in a mover, precisely so every mover mode releases the same way.
|
||||
// While the resident colliders still belong to the map we just left the deadline is pushed
|
||||
// (0710's fail-closed law: a world that never arrives keeps the hold and the screen).
|
||||
// `colliders_pending` is `finish_colliders`' published count — fresh this frame (it heads
|
||||
// this chain).
|
||||
if player.settling {
|
||||
let now = time.elapsed_secs();
|
||||
if player.world_stale {
|
||||
player.settle_deadline = now + crate::player::SETTLE_TIMEOUT;
|
||||
} else if p.scene_ready && p.colliders_pending == 0 {
|
||||
player.end_settle(true, now);
|
||||
} else if now >= player.settle_deadline {
|
||||
player.end_settle(false, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,24 +80,35 @@ pub(super) fn finish_colliders(
|
||||
// Capped: a cold zone load queues *tens of thousands* of pending colliders (10 500 at once
|
||||
// entering Stormwind), and the budget below can attach at most ~500 of the cheapest ones in a
|
||||
// frame. Collecting every ready entity would rebuild a huge list each frame to use a slice of
|
||||
// it. `READY_SCAN_CAP` keeps the pass bounded while staying far above what any frame can spend;
|
||||
// it. `READY_SCAN_CAP` bounds the ready list while staying far above what any frame can spend;
|
||||
// the rest are picked up next frame (they stay in the query until attached, so none is skipped).
|
||||
// The walk itself covers the whole set regardless (it is the ~0.005 ms part) — it also yields
|
||||
// the queue-depth publish below.
|
||||
const READY_SCAN_CAP: usize = 2048;
|
||||
let mut ready: Vec<Entity> = Vec::new();
|
||||
let mut pending = 0usize;
|
||||
for (entity, mut pc) in state.get_mut(world).iter_mut() {
|
||||
pending += 1;
|
||||
if let Some(task) = pc.task.as_mut() {
|
||||
if let Some(collider) = block_on(future::poll_once(task)) {
|
||||
pc.task = None;
|
||||
pc.built = Some(collider);
|
||||
}
|
||||
}
|
||||
if pc.built.is_some() {
|
||||
if pc.built.is_some() && ready.len() < READY_SCAN_CAP {
|
||||
ready.push(entity);
|
||||
if ready.len() >= READY_SCAN_CAP {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Publish the queue depth (decision 0737): the loading-screen clear and the settle release
|
||||
// both refuse to call the world presentable while attaches are outstanding — a spawned
|
||||
// building whose collider still sits in this queue is exactly what a body must not be
|
||||
// released onto. This system heads the Stream chain (0738), so the consumers read this
|
||||
// frame's depth; the count is the depth entering the frame (attaches below shrink it next
|
||||
// publish), which can only delay a release, never wrong one.
|
||||
if let Some(mut progress) = world.get_resource_mut::<crate::loading_screen::WorldLoadProgress>()
|
||||
{
|
||||
progress.colliders_pending = pending;
|
||||
}
|
||||
if ready.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,17 @@ impl UnitWmoRoom {
|
||||
pub fn room(&self) -> Option<WmoRoom> {
|
||||
self.room
|
||||
}
|
||||
|
||||
/// A synthetic claim for tests that exercise a room consumer (the anim-LOD gate's room leg)
|
||||
/// without running the tracker.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn claimed(room: WmoRoom) -> Self {
|
||||
Self {
|
||||
room: Some(room),
|
||||
at: Vec3::ZERO,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `WMOAreaTable` lookup keys: `(WMOID, NameSetID, WMOGroupID)`.
|
||||
|
||||
Reference in New Issue
Block a user