diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index 7d9e9507..c5d5d9a7 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -75,6 +75,8 @@ pub struct TerrainStreamer { map_dir: Option, /// 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, + placements: ResMut, + instances: Query<(), With>, + mut progress: ResMut, + mut tick: Local, +) { + 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, + instances: Query<&crate::wmo_portal::WmoPortalInstance>, + gx: Option>, + focus: Res, + camera: Query<&GlobalTransform, With>, + progress: Res, + mut last: Local>, +) { + 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::() + .init_resource::() + .init_resource::(); + 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::() + .by_id + .insert(key, p); + app.world_mut().resource_mut::().settled = true; + app.world_mut().resource_mut::().complete = true; + app.world_mut() + .run_system_once(monkey_repair_wmo_residency) + .unwrap(); + let placements = app.world().resource::(); + 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::().settled); + assert!(!app.world().resource::().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::() + .by_id + .get_mut(&key) + .unwrap() + .spawned = true; + app.world_mut().resource_mut::().pending_spawns = 0; + app.world_mut() + .run_system_once(monkey_repair_wmo_residency) + .unwrap(); + assert_eq!(app.world().resource::().pending_spawns, 0); + } + fn run_handoff(world: &mut World, dead: (i32, i32), uids: Vec, merge_on: bool) { world .run_system_once( diff --git a/crates/benilla-world/src/terrain_stream/spawn/mod.rs b/crates/benilla-world/src/terrain_stream/spawn/mod.rs index ed92ff6c..e6d31573 100644 --- a/crates/benilla-world/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla-world/src/terrain_stream/spawn/mod.rs @@ -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; diff --git a/crates/benilla-world/src/wmo_portal/mod.rs b/crates/benilla-world/src/wmo_portal/mod.rs index 292988cc..83d4bda7 100644 --- a/crates/benilla-world/src/wmo_portal/mod.rs +++ b/crates/benilla-world/src/wmo_portal/mod.rs @@ -298,7 +298,11 @@ fn compute_wmo_pvs( mut camera_fog: ResMut, mut camera_claim: ResMut, mut camera_windows: ResMut, + mut monkey_last_stall: Local>, ) { + 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`. @@ -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));