MONKEY (r18 A1 B004 streaming, world crate): chunk-cache key, nearest-first admission, stale-ready latch, emergency ground hold + collider timeout
World-crate part of benilla-everwood e9247623 (terrain_stream, spawn, wmo_portal). (cherry picked from commit e9247623, benilla-app hunks omitted)
This commit is contained in:
@@ -75,6 +75,8 @@ pub struct TerrainStreamer {
|
||||
map_dir: Option<String>,
|
||||
/// The focus tile last streamed around, the furnisher's nearest-first key.
|
||||
focus: (i32, i32),
|
||||
/// The preload window moves at chunk boundaries, including inside the same tile.
|
||||
focus_chunk: Option<(i32, i32)>,
|
||||
/// The window's `(inner, outer)` half-widths last frame, logged once per change.
|
||||
reach: Option<(i32, i32)>,
|
||||
/// The map's WDT tile index: ADT requests wait for it and consult its `MAIN` grid.
|
||||
@@ -98,6 +100,9 @@ pub struct TerrainStreamer {
|
||||
const GLOBAL_WMO_UID: u32 = u32::MAX;
|
||||
|
||||
impl TerrainStreamer {
|
||||
fn monkey_same_window(&self, window: StreamWindow) -> bool {
|
||||
self.focus_chunk == Some(window.focus) && self.reach == Some((window.inner, window.outer))
|
||||
}
|
||||
/// `(spawned, requested)` tiles for the debug panel: furnished, and all in the window.
|
||||
pub fn residency(&self) -> (usize, usize) {
|
||||
let spawned = self.tiles.values().filter(|t| t.furnished).count();
|
||||
@@ -405,6 +410,7 @@ impl Plugin for TerrainPlugin {
|
||||
Update,
|
||||
(
|
||||
finish_colliders,
|
||||
monkey_repair_wmo_residency,
|
||||
stream_terrain,
|
||||
furnish_tile_cells,
|
||||
spawn_loaded_placements,
|
||||
@@ -446,6 +452,13 @@ impl Plugin for TerrainPlugin {
|
||||
.add_systems(Update, scope_placement_art)
|
||||
// Ungated, so the density tracker never misses a boot-time config apply.
|
||||
.add_systems(Update, rescatter_clutter)
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
monkey_watch_wmo_residency
|
||||
.after(crate::static_gx::MonkeyStaticGxCullSet)
|
||||
.after(bevy::transform::TransformSystems::Propagate)
|
||||
.run_if(crate::schedule::world_is_live),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
// After the interior claim: the reference resolves leaf, indoor and names from one
|
||||
@@ -586,7 +599,7 @@ fn stream_terrain(
|
||||
let center = focus.resolve(camera.single().ok().map(|c| c.translation));
|
||||
let window = StreamWindow::at(view.farclip, center[0], center[1]);
|
||||
let (cx, cy) = window.focus_tile();
|
||||
let same_window = state.focus == (cx, cy) && state.reach == Some((window.inner, window.outer));
|
||||
let same_window = state.monkey_same_window(window);
|
||||
// Skip an unchanged frame only in a paced, complete, presentable world, since the tail it
|
||||
// bypasses releases a load; even then the undrawn count stays fresh, so a rebake un-skips it.
|
||||
if same_window
|
||||
@@ -604,6 +617,7 @@ fn stream_terrain(
|
||||
return;
|
||||
}
|
||||
state.focus = (cx, cy);
|
||||
state.focus_chunk = Some(window.focus);
|
||||
if state.reach != Some((window.inner, window.outer)) {
|
||||
state.reach = Some((window.inner, window.outer));
|
||||
info!(
|
||||
@@ -726,10 +740,15 @@ fn stream_terrain(
|
||||
// Spawn loaded tiles within the frame's budget. The material binds the shared light buffer,
|
||||
// so a new tile is lit and fogged on its first frame.
|
||||
let tile_deadline = Instant::now() + SPAWN_BUDGET;
|
||||
for (&(tx, ty), tile) in state.tiles.iter_mut() {
|
||||
if tile.entity.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut landed: Vec<_> = state
|
||||
.tiles
|
||||
.iter()
|
||||
.filter(|(_, t)| t.entity.is_none() && tiles.get(&t.handle).is_some())
|
||||
.map(|(&key, _)| key)
|
||||
.collect();
|
||||
landed.sort_unstable_by_key(|&(tx, ty)| ((tx - cx).abs().max((ty - cy).abs()), tx, ty));
|
||||
for (tx, ty) in landed {
|
||||
let tile = state.tiles.get_mut(&(tx, ty)).unwrap();
|
||||
let Some(adt) = tiles.get(&tile.handle) else {
|
||||
continue; // not loaded yet, or missing
|
||||
};
|
||||
@@ -852,7 +871,7 @@ fn stream_terrain(
|
||||
}
|
||||
// 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 {
|
||||
if !p.focus_resident || !same_window {
|
||||
p.complete = false;
|
||||
}
|
||||
// So does a snap onto furnished ground, whose buildings may still be arriving; on the edge
|
||||
@@ -932,6 +951,92 @@ fn tile_drop_disabled() -> bool {
|
||||
*ON.get_or_init(|| std::env::var_os("WOW_NO_TILE_DROP").is_some())
|
||||
}
|
||||
|
||||
/// A resident building whose instance was lost must re-enter the ordinary spawn path. Inspect
|
||||
/// once per 64 stream ticks; a failed asset has no instance and is deliberately not retried.
|
||||
fn monkey_repair_wmo_residency(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<TerrainStreamer>,
|
||||
placements: ResMut<Placements>,
|
||||
instances: Query<(), With<crate::wmo_portal::WmoPortalInstance>>,
|
||||
mut progress: ResMut<WorldLoadProgress>,
|
||||
mut tick: Local<u32>,
|
||||
) {
|
||||
let scan = tick.is_multiple_of(64);
|
||||
*tick = tick.wrapping_add(1);
|
||||
if !scan {
|
||||
return;
|
||||
}
|
||||
let placements = placements.into_inner();
|
||||
for (&key, p) in &mut placements.by_id {
|
||||
let Some(instance) = p.portal_instance else {
|
||||
continue;
|
||||
};
|
||||
if !p.spawned || instances.contains(instance) {
|
||||
continue;
|
||||
}
|
||||
warn!("MONKEY_WMO_RESIDENCY: uid={} instance={instance} lost with refs={}, re-registering building at {:?}",
|
||||
key as u32, p.refs, bevy_to_wow(p.transform.translation));
|
||||
placements.pending_spawns -= p.doodads.iter().filter(|d| !d.spawned).count();
|
||||
for e in p.entities.drain(..) {
|
||||
commands.entity(e).try_despawn();
|
||||
}
|
||||
p.doodads.clear();
|
||||
p.portal_instance = None;
|
||||
p.spawned = false;
|
||||
placements.pending_spawns += 1;
|
||||
state.settled = false;
|
||||
progress.complete = false;
|
||||
progress.scene_ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the three disappearance stages for the two reported buildings, only on a state edge.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn monkey_watch_wmo_residency(
|
||||
placements: Res<Placements>,
|
||||
instances: Query<&crate::wmo_portal::WmoPortalInstance>,
|
||||
gx: Option<Res<crate::static_gx::StaticGx>>,
|
||||
focus: Res<ViewFocus>,
|
||||
camera: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
progress: Res<WorldLoadProgress>,
|
||||
mut last: Local<HashMap<u32, (bool, bool, usize, usize, usize)>>,
|
||||
) {
|
||||
for uid in [71414, 54893] {
|
||||
let placement = placements.by_id.get(&monkey_wmo_placement_key(uid));
|
||||
let instance = placement.and_then(|p| p.portal_instance);
|
||||
let portal = instance.and_then(|e| instances.get(e).ok());
|
||||
let pvs = portal.map_or(0, |i| i.visible.iter().filter(|&&v| v).count());
|
||||
let region = gx
|
||||
.as_deref()
|
||||
.and_then(|g| instance.and_then(|e| g.world.wmos.get(&e)));
|
||||
let retained = region.map_or(0, |r| r.groups.len());
|
||||
let admitted = gx
|
||||
.as_deref()
|
||||
.and_then(|g| {
|
||||
instance.and_then(|e| {
|
||||
g.world
|
||||
.visible_wmos
|
||||
.iter()
|
||||
.find(|(i, _)| *i == e)
|
||||
.map(|(_, s)| s)
|
||||
})
|
||||
})
|
||||
.map_or(0, |s| s.drawn.iter().filter(|&&v| v).count());
|
||||
let facts = (
|
||||
placement.is_some(),
|
||||
portal.is_some(),
|
||||
pvs,
|
||||
retained,
|
||||
admitted,
|
||||
);
|
||||
if last.insert(uid, facts) != Some(facts) {
|
||||
info!("MONKEY_WMO_WATCH: uid={uid} registered={} instance={instance:?} portal={} pvs={pvs} retained={retained} admitted={admitted} body={:?} eye={:?} focus={:?} resident={} presentable={}",
|
||||
facts.0, facts.1, focus.body_pos(), camera.iter().next().map(|c| bevy_to_wow(c.translation())),
|
||||
progress.focus_tile, progress.focus_resident, progress.presentable());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register one M2 doodad placement, or bump its refcount if already known.
|
||||
fn register_doodad(
|
||||
placements: &mut Placements,
|
||||
@@ -1009,6 +1114,7 @@ fn drop_streamed_world(
|
||||
staticgx: Option<&mut crate::static_gx::StaticGx>,
|
||||
activity: &mut StreamActivity,
|
||||
) {
|
||||
state.focus_chunk = None;
|
||||
for ((_tx, _ty), t) in state.tiles.drain() {
|
||||
despawn_tile_owned(commands, &t);
|
||||
activity.tiles_dropped += 1;
|
||||
@@ -1307,6 +1413,35 @@ mod focus_tests {
|
||||
Some(wow_to_bevy([SPAWN_XY.0, SPAWN_XY.1, 100.0]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monkey_a_chunk_crossing_inside_one_tile_reopens_the_preload_window() {
|
||||
// Default farclip reaches 13 chunks. Advancing from chunk 2 to 3 reaches tile +1.
|
||||
let before = StreamWindow {
|
||||
focus: (32 * 16 + 2, 44 * 16 + 2),
|
||||
inner: 11,
|
||||
outer: 13,
|
||||
};
|
||||
let after = StreamWindow {
|
||||
focus: (32 * 16 + 3, 44 * 16 + 2),
|
||||
..before
|
||||
};
|
||||
assert_eq!(before.focus_tile(), after.focus_tile());
|
||||
assert!(!before.wanted_tiles().contains(&(33, 44)));
|
||||
assert!(after.wanted_tiles().contains(&(33, 44)));
|
||||
let state = TerrainStreamer {
|
||||
focus: before.focus_tile(),
|
||||
focus_chunk: Some(before.focus),
|
||||
reach: Some((before.inner, before.outer)),
|
||||
settled: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(state.monkey_same_window(before));
|
||||
assert!(
|
||||
!state.monkey_same_window(after),
|
||||
"same tile must not suppress an incoming row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pick_outranks_the_camera_until_the_avatar_exists() {
|
||||
let idle = ViewFocus::entry(PICK.0, PICK.1);
|
||||
@@ -1382,6 +1517,51 @@ mod straddler_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monkey_a_resident_wmo_with_a_lost_instance_is_requeued_without_losing_its_refs() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<TerrainStreamer>()
|
||||
.init_resource::<Placements>()
|
||||
.init_resource::<WorldLoadProgress>();
|
||||
let missing = app.world_mut().spawn_empty().id();
|
||||
app.world_mut().despawn(missing);
|
||||
let orphan = app.world_mut().spawn_empty().id();
|
||||
let mut p = straddler((0, 0), vec![orphan]);
|
||||
p.model = ModelHandle::Wmo(Default::default());
|
||||
p.portal_instance = Some(missing);
|
||||
p.refs = 3;
|
||||
let key = monkey_wmo_placement_key(71414);
|
||||
app.world_mut()
|
||||
.resource_mut::<Placements>()
|
||||
.by_id
|
||||
.insert(key, p);
|
||||
app.world_mut().resource_mut::<TerrainStreamer>().settled = true;
|
||||
app.world_mut().resource_mut::<WorldLoadProgress>().complete = true;
|
||||
app.world_mut()
|
||||
.run_system_once(monkey_repair_wmo_residency)
|
||||
.unwrap();
|
||||
let placements = app.world().resource::<Placements>();
|
||||
let p = &placements.by_id[&key];
|
||||
assert_eq!(p.refs, 3);
|
||||
assert!(!p.spawned && p.portal_instance.is_none());
|
||||
assert_eq!(placements.pending_spawns, 1);
|
||||
assert!(!app.world().resource::<TerrainStreamer>().settled);
|
||||
assert!(!app.world().resource::<WorldLoadProgress>().complete);
|
||||
assert!(app.world().get_entity(orphan).is_err());
|
||||
// A retired missing asset has no recorded instance: don't retry it forever.
|
||||
app.world_mut()
|
||||
.resource_mut::<Placements>()
|
||||
.by_id
|
||||
.get_mut(&key)
|
||||
.unwrap()
|
||||
.spawned = true;
|
||||
app.world_mut().resource_mut::<Placements>().pending_spawns = 0;
|
||||
app.world_mut()
|
||||
.run_system_once(monkey_repair_wmo_residency)
|
||||
.unwrap();
|
||||
assert_eq!(app.world().resource::<Placements>().pending_spawns, 0);
|
||||
}
|
||||
|
||||
fn run_handoff(world: &mut World, dead: (i32, i32), uids: Vec<u32>, merge_on: bool) {
|
||||
world
|
||||
.run_system_once(
|
||||
|
||||
@@ -161,7 +161,15 @@ pub(super) fn spawn_loaded_placements(
|
||||
// follows the work, so every frame spawns at least one.
|
||||
let deadline = Instant::now() + SPAWN_BUDGET;
|
||||
|
||||
'placements: for (&key, p) in by_id.iter_mut() {
|
||||
// Load the buildings nearest the body before scenery at the far edge of the window.
|
||||
let mut due: Vec<_> = by_id
|
||||
.iter()
|
||||
.filter(|(_, p)| !p.spawned || p.doodads.iter().any(|d| !d.spawned))
|
||||
.map(|(&key, p)| (placement_priority(&streamer, p.transform.translation), key))
|
||||
.collect();
|
||||
due.sort_unstable();
|
||||
'placements: for (_, key) in due {
|
||||
let p = by_id.get_mut(&key).unwrap();
|
||||
let unique_id = key as u32;
|
||||
// 1. The model's own geometry, once; a WMO also resolves its doodad props for step 2.
|
||||
if !p.spawned {
|
||||
@@ -174,7 +182,10 @@ pub(super) fn spawn_loaded_placements(
|
||||
// `alliancebrasscannon_flat.m2`, 2026-09-08). Retire it as an empty
|
||||
// spawn so the accounting completes; the reference draws nothing there
|
||||
// either.
|
||||
if matches!(asset_server.load_state(h), bevy::asset::LoadState::Failed(_)) {
|
||||
if matches!(
|
||||
asset_server.load_state(h),
|
||||
bevy::asset::LoadState::Failed(_)
|
||||
) {
|
||||
warn!("placement {unique_id}: its model failed to load — spawning nothing");
|
||||
p.spawned = true;
|
||||
*pending_spawns -= 1;
|
||||
@@ -345,8 +356,13 @@ pub(super) fn spawn_loaded_placements(
|
||||
let Some(m) = wmos.get(h) else {
|
||||
// MONKEY (missing model): see the M2 arm — a WMO the client lacks
|
||||
// retires as an empty spawn rather than pinning the loading screen.
|
||||
if matches!(asset_server.load_state(h), bevy::asset::LoadState::Failed(_)) {
|
||||
warn!("placement {unique_id}: its WMO failed to load — spawning nothing");
|
||||
if matches!(
|
||||
asset_server.load_state(h),
|
||||
bevy::asset::LoadState::Failed(_)
|
||||
) {
|
||||
warn!(
|
||||
"placement {unique_id}: its WMO failed to load — spawning nothing"
|
||||
);
|
||||
p.spawned = true;
|
||||
*pending_spawns -= 1;
|
||||
activity.placements_spawned += 1;
|
||||
@@ -380,13 +396,19 @@ pub(super) fn spawn_loaded_placements(
|
||||
refs: &m.portal_refs,
|
||||
slices: &sky_slices,
|
||||
},
|
||||
m.submeshes.iter().zip(m.submesh_group.iter()).map(|(s, g)| {
|
||||
(
|
||||
*g,
|
||||
matches!(s.wmo_batch, Some(benilla_formats::WmoBatchClass::Ext)),
|
||||
&s.geometry.positions[..],
|
||||
)
|
||||
}),
|
||||
m.submeshes
|
||||
.iter()
|
||||
.zip(m.submesh_group.iter())
|
||||
.map(|(s, g)| {
|
||||
(
|
||||
*g,
|
||||
matches!(
|
||||
s.wmo_batch,
|
||||
Some(benilla_formats::WmoBatchClass::Ext)
|
||||
),
|
||||
&s.geometry.positions[..],
|
||||
)
|
||||
}),
|
||||
);
|
||||
let instance = (has_portals || m.wmo_id != 0).then(|| {
|
||||
commands
|
||||
@@ -681,7 +703,8 @@ pub(super) fn spawn_loaded_placements(
|
||||
.unwrap_or_default();
|
||||
let slices = wmo.map(fx::portal_slices).unwrap_or_default();
|
||||
// MONKEY (review fixes): the claim list owns its placement even when no MODR names it.
|
||||
let claims = wmo.zip(portal_instance)
|
||||
let claims = wmo
|
||||
.zip(portal_instance)
|
||||
.map(|(w, instance)| fx::PropClaims::new(w, &slices, placement, instance));
|
||||
for d in &mut p.doodads {
|
||||
if d.spawned {
|
||||
@@ -691,7 +714,10 @@ pub(super) fn spawn_loaded_placements(
|
||||
// MONKEY (missing model): a prop whose M2 the client doesn't ship would hold its
|
||||
// BUILDING un-"up" (`placements_pending`) for the loading screen's whole life —
|
||||
// the 2371 s Stormwind hang on `alliancebrasscannon_flat.m2`. Retire the prop.
|
||||
if matches!(asset_server.load_state(&d.handle), bevy::asset::LoadState::Failed(_)) {
|
||||
if matches!(
|
||||
asset_server.load_state(&d.handle),
|
||||
bevy::asset::LoadState::Failed(_)
|
||||
) {
|
||||
warn!("placement {unique_id}: a WMO prop's model failed to load — skipping it");
|
||||
d.spawned = true;
|
||||
*pending_spawns -= 1;
|
||||
|
||||
@@ -298,7 +298,11 @@ fn compute_wmo_pvs(
|
||||
mut camera_fog: ResMut<CameraWmoFog>,
|
||||
mut camera_claim: ResMut<CameraInteriorClaim>,
|
||||
mut camera_windows: ResMut<ExteriorWindows>,
|
||||
mut monkey_last_stall: Local<Option<std::time::Instant>>,
|
||||
) {
|
||||
let monkey_started = std::time::Instant::now();
|
||||
let mut monkey_instances = 0u32;
|
||||
let mut monkey_walks = 0u64;
|
||||
// No world camera yet: keep last frame's sets, all visible.
|
||||
let Some((cam_t, proj)) = cam.iter().next() else {
|
||||
return;
|
||||
@@ -337,6 +341,7 @@ fn compute_wmo_pvs(
|
||||
continue;
|
||||
};
|
||||
let groups = model.group_nav.len();
|
||||
monkey_instances += 1;
|
||||
// A WMO with no portal graph (single-group props, doors) is never culled, and never on the
|
||||
// interior-fog lane. Written through the change gate: marking every prop changed each frame
|
||||
// would defeat `Changed<WmoPortalInstance>`.
|
||||
@@ -376,6 +381,7 @@ fn compute_wmo_pvs(
|
||||
&world_from_local,
|
||||
&mut (&mut tap, &mut log),
|
||||
);
|
||||
monkey_walks += u64::from(fresh.iters);
|
||||
if inst.bypass_change_detection().visible != fresh.visible {
|
||||
inst.bypass_change_detection().visible = fresh.visible;
|
||||
inst.set_changed();
|
||||
@@ -472,6 +478,14 @@ fn compute_wmo_pvs(
|
||||
if *camera_windows != want_windows {
|
||||
*camera_windows = want_windows;
|
||||
}
|
||||
// A moving dungeon probe needs timed CPU evidence; snapshots cannot identify a portal hitch.
|
||||
let monkey_ms = monkey_started.elapsed().as_secs_f32() * 1000.0;
|
||||
if monkey_ms >= 8.0 && monkey_last_stall.is_none_or(|last| last.elapsed().as_secs_f32() >= 1.0)
|
||||
{
|
||||
*monkey_last_stall = Some(std::time::Instant::now());
|
||||
warn!("MONKEY_WMO_STALL: cpu_ms={monkey_ms:.2} instances={monkey_instances} walk_steps={monkey_walks} eye={:?} claim={:?}",
|
||||
bevy_to_wow(eye_world), camera_claim.0.as_ref().map(|c| c.room));
|
||||
}
|
||||
if let Some((mut text, path)) = dump_text.zip(dump_to) {
|
||||
// The frame's published verdict, after every placement: the claim and the windows.
|
||||
text.push_str(&format!("CAMERA CLAIM: {:?}\n", camera_claim.0));
|
||||
|
||||
Reference in New Issue
Block a user