diff --git a/crates/benilla-app/src/creature_anim/driver.rs b/crates/benilla-app/src/creature_anim/driver.rs index f9fab96c..366b2f6c 100644 --- a/crates/benilla-app/src/creature_anim/driver.rs +++ b/crates/benilla-app/src/creature_anim/driver.rs @@ -420,6 +420,13 @@ pub(super) fn drive_animations( // The missing-clip resolver's DBC source (decision 0082): `None` for the brief window before // `AnimationData.dbc` loads, in which every lookup below degrades to identity. let catalog = anim_data.as_deref().map(|d| &d.0); + // `WOW_ANIM_COST=1` — the memo-sizing counter (1370 item 9's bracket): counts the frame's + // RESTING rows, the shape an input-memo fast path would skip. Sizing only — no skip is + // taken here (an `AnimParked` filter is law-barred: 0448's off-screen-events law means a + // parked unit's driver must keep arming clips; the memo is the only safe shape, built when + // this number says it pays). + let anim_cost = anim_cost_enabled(); + let (mut cost_rows, mut cost_resting) = (0u32, 0u32); for ( entity, anims, @@ -553,6 +560,32 @@ pub(super) fn drive_animations( // same-frame land+relaunch is a new arc there ([`crate::player::Player::advance_airborne_arc`]). let prev_vertical = std::mem::replace(&mut drv.last_vertical_speed, mv.vertical_speed); let launched = mv.vertical_speed > JUMP_ARC_MIN_UP && prev_vertical <= JUMP_ARC_MIN_UP; + // The resting predicate, counted (never acted on — see the counter's note above): mode + // Gait with nothing in flight, nothing pending, grounded and motionless. A strict lower + // bound on what an input-memo would skip. + if anim_cost { + cost_rows += 1; + if drv.mode == Mode::Gait + && drv.overlay.is_none() + && drv.overlay_fade.is_none() + && drv.wound.is_none() + && drv.sheath_swap.is_none() + && !first + && !mount_edge + && cast_hold.is_none() + && !moving + && !falling + && !was_falling + && !launched + && !pending.contains_key(&entity) + && !pending_wound.contains_key(&entity) + && !pending_defense.contains_key(&entity) + && !pending_slow.contains(&entity) + && !pending_sheath.contains_key(&entity) + { + cost_resting += 1; + } + } if falling && (!was_falling || launched) { drv.jump_arc = mv.vertical_speed > JUMP_ARC_MIN_UP; } @@ -1400,4 +1433,18 @@ pub(super) fn drive_animations( commands.entity(entity).remove::(); } } + if anim_cost && cost_rows > 0 { + use std::sync::atomic::{AtomicU32, Ordering}; + static FRAME: AtomicU32 = AtomicU32::new(0); + // Once a second at 60 fps, not per frame — the counter is a sizing read, not a trace. + if FRAME.fetch_add(1, Ordering::Relaxed).is_multiple_of(64) { + eprintln!("[anim-cost] rows={cost_rows} resting={cost_resting}"); + } + } +} + +/// `WOW_ANIM_COST=1` — arms the memo-sizing counter above. Read once, the meter posture. +fn anim_cost_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("WOW_ANIM_COST").as_deref() == Ok("1")) } diff --git a/crates/benilla-app/src/entities/attach/dress.rs b/crates/benilla-app/src/entities/attach/dress.rs index ccac0118..665bd705 100644 --- a/crates/benilla-app/src/entities/attach/dress.rs +++ b/crates/benilla-app/src/entities/attach/dress.rs @@ -18,7 +18,7 @@ use bevy::prelude::*; use benilla_assets::materials::WowModelMaterial; use benilla_world::billboard::BillboardCard; -use benilla_world::interact::WorldObject; +use benilla_world::interact::{CreaturePickPart, GoPickPart, WorldObject}; use benilla_world::interior::part_interior_lit; use benilla_world::model_fade::{FadeSet, JoinedFade, PartFade}; use benilla_world::model_render::{ModelKind, ModelPart}; @@ -103,6 +103,22 @@ pub(super) fn part_materials<'a>( } } +/// Stamp a pickable part (or card) with the pick population its kind belongs to: the mouseover +/// pickers filter by these markers instead of kind-comparing every `WorldObject` row per frame. +/// One site for both the mesh-part and the billboard-card spawn, so the two can't drift. +fn insert_pick_marker(child: &mut bevy::ecs::system::EntityCommands<'_>, kind: ModelKind) { + match kind { + ModelKind::GameObject => { + child.insert(GoPickPart); + } + ModelKind::Creature => { + child.insert(CreaturePickPart); + } + // Doodad/WMO parts (terrain_stream's spawns) are never dressed here and take no marker. + ModelKind::Doodad | ModelKind::Wmo => {} + } +} + impl<'a> PartMaterials<'a> { /// The three handles the appear/despawn ramps feather through — this batch's slice of /// [`FadeSet`], so the spawn and the fade can never disagree about which twin it wears. @@ -212,6 +228,7 @@ pub(super) fn spawn_part( card: None, }, )); + insert_pick_marker(&mut child, dress.kind); // Every part gets a tag: the instance slot plus the live alpha field. Unconditional because the // slot is a per-instance identity every part needs (the tint) rather than a skinning detail — // and because the two conditional writers that used to seed it (the interior classifier, the @@ -341,6 +358,7 @@ fn spawn_billboard_part( benilla_world::interact::PickMesh(part.geometry.clone()), card_follow, )); + insert_pick_marker(&mut card, dress.kind); // A card takes its MODEL's indoor law through the same constructor as the sibling meshes it was // split out of (decision 0778), and carries the unit's INSTANCE slot like every sibling part // even though it is never skinned by it — it is a batch of the unit's own model, so a tinted @@ -601,6 +619,85 @@ mod tests { ); } + /// The dress path stamps every pickable spawn — the mesh part and the billboard CARD (never + /// the bare mirror anchor) — with exactly its kind's pick-population marker, so the mouseover + /// pickers' marker-filtered sets equal the old kind-filtered ones by construction. Asserted + /// differentially: the marker set must equal the `WorldObject`-kind set, and the other kind's + /// marker must land on nothing. + #[test] + fn a_spawned_part_carries_its_kinds_pick_marker() { + for kind in [ModelKind::Creature, ModelKind::GameObject] { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())) + .init_asset::(); + let unit = app.world_mut().spawn(Transform::default()).id(); + let mut billboard = part(None, false); + billboard.billboard = Some(benilla_assets::BillboardInfo { + pivot: Vec3::ZERO, + bone: 1, + kind: benilla_formats::BillboardKind::Spherical, + scale_anim: None, + seq_translations: Vec::new(), + }); + let object = WorldObject { + kind, + label: String::new(), + id: 0, + detail: String::new(), + }; + let empty: CharSkinMaterials = (None, None, None, (None, None)); + let dress = PartDress { + unit, + kind, + char_mats: &empty, + object: &object, + inst_slot: 0, + rigged: false, + anchors: std::collections::HashMap::new(), + bake_center: Vec3::ZERO, + idle_aabb: None, + now: 0.0, + fade: JoinedFade::Steady, + }; + let mut queue = bevy::ecs::world::CommandQueue::default(); + { + let world = app.world(); + let mut commands = Commands::new(&mut queue, world); + spawn_part(&mut commands, &part(None, false), 0, &dress); + spawn_part(&mut commands, &billboard, 1, &dress); + } + queue.apply(app.world_mut()); + let world = app.world_mut(); + let by_kind: std::collections::HashSet = world + .query::<(Entity, &WorldObject)>() + .iter(world) + .filter(|(_, o)| o.kind == kind) + .map(|(e, _)| e) + .collect(); + let (creature, go) = ( + world + .query_filtered::>() + .iter(world) + .collect::>(), + world + .query_filtered::>() + .iter(world) + .collect::>(), + ); + let (marked, other) = match kind { + ModelKind::Creature => (creature, go), + _ => (go, creature), + }; + assert_eq!( + by_kind.len(), + 2, + "the mesh part and the card, not the anchor" + ); + assert_eq!(marked, by_kind, "marker set == kind set ({kind:?})"); + assert!(other.is_empty(), "never the other kind's marker ({kind:?})"); + } + } + /// A batch with no character slot — and a slot whose row the appearance didn't resolve (a bald /// style has no hair texture, only tauren author an extra skin) — keeps the shared model's own /// built materials. That is the arm a creature, a GameObject and an unresolvable look all take. diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index 506c659e..dad0d0d8 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -903,6 +903,9 @@ pub(super) fn attach_entity_visuals( MeshMaterial3d(material), Transform::from_translation(Vec3::Y * lift), object, + // …and into the unit pick's marker-filtered fallback population like any + // dressed creature part. + benilla_world::interact::CreaturePickPart, // The cube has no resident `RenderSubmesh` to cast against — its `Aabb` IS // its shape, which it must SAY (decision 0929): the picker requires pick // geometry rather than inferring a box from its absence. diff --git a/crates/benilla-app/src/hover_log.rs b/crates/benilla-app/src/hover_log.rs index 1b27047c..4458a4c2 100644 --- a/crates/benilla-app/src/hover_log.rs +++ b/crates/benilla-app/src/hover_log.rs @@ -85,7 +85,7 @@ impl Plugin for HoverLogPlugin { out, "frame_ms,cpu_ms,tick_us,resolve_us,measure_us,extract_us,convert_us,\ diff_us,quads,solves,skipped,measured,measured_texts,tip_owner,tip_lines,\ - tip_first_line" + tip_first_line,spliced" ); info!("hover log: recording every frame to {path}"); app.insert_resource(Recorder { @@ -155,7 +155,7 @@ fn record( .unwrap_or_else(|| (String::new(), 0, String::new())); let _ = writeln!( rec.out, - "{frame_ms:.3},{cpu_ms:.3},{},{},{},{},{},{},{},{},{},{},{},{},{lines},{}", + "{frame_ms:.3},{cpu_ms:.3},{},{},{},{},{},{},{},{},{},{},{},{},{lines},{},{}", c.tick, c.resolve, c.measure, @@ -169,6 +169,7 @@ fn record( cell(&c.measured_texts.join(" ⏎ ")), cell(&owner), cell(&first), + c.spliced, ); rec.rows.push(Row { frame_ms, @@ -326,6 +327,8 @@ fn parse_row(line: &str) -> Option { quads: num(8)? as usize, solves: num(9)? as u64, skipped: num(10)? != 0.0, + // Appended column (absent in pre-splice recordings — those read back as 0). + spliced: fields.get(16).and_then(|f| f.parse().ok()).unwrap_or(0), }, tip: if owner.is_empty() { None diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 6707b115..24ee91a9 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -691,9 +691,11 @@ fn emit_minimap( /// persisting across the transition. Runs before the script tick, so a `SetZoom` fired from a button /// handler this frame routes to the right index. `CurrentWmoInterior` is the same containment test the /// interior audio/zone tracker uses. -/// The state push is unconditional (a Minimap widget created *after* a transition — the cluster XML -/// loads late — would otherwise never be told, and the arena scan is nothing next to a frame). On the -/// actual inside↔outside *edge* we also fire `MINIMAP_UPDATE_ZOOM`: the active zoom index just switched +/// The state is pushed on the inside↔outside *edge* and whenever the VM's Minimap-creation count +/// moved (the cluster XML loads late, and an addon can build one whenever it likes — the counter +/// is what keeps "a widget created after the last transition is still told" true without walking +/// the ~3k-frame arena every frame; the memo resets with a fresh VM, whose rebuilt widgets bump +/// the fresh counter). On the edge we also fire `MINIMAP_UPDATE_ZOOM`: the active zoom index just switched /// to the other (independent) level, so the cluster must re-sync the +/- buttons' enabled state to it /// — the client's own signal for "the effective zoom changed" (FrameXML `Minimap_OnEvent`). Without it /// the buttons keep the level you left (e.g. `ZoomIn` greyed from an outdoor max-zoom, still greyed @@ -702,13 +704,18 @@ fn feed_minimap_inside( script: Option>, world: benilla_world::world_point::WorldPoint, mut was_inside: Local>>, + mut pushed_at: Local>, ) { let Some(mut script) = script else { return }; - let was_inside = was_inside.get(&script); let inside = world.interior().is_some(); - script.set_minimap_inside(inside); - if *was_inside != Some(inside) { - *was_inside = Some(inside); + let edge = *was_inside.get(&script) != Some(inside); + let created = script.minimap_widgets_created(); + if edge || *pushed_at.get(&script) != created { + script.set_minimap_inside(inside); + *pushed_at.get(&script) = created; + } + if edge { + *was_inside.get(&script) = Some(inside); script.fire_event("MINIMAP_UPDATE_ZOOM", vec![]); } } diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index 6879e3c7..880f50f1 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -120,6 +120,37 @@ fn flag_prefix(player_flags: u32) -> String { .collect() } +/// Whether `cached` is exactly the line stack [`drive_nameplates`]'s stale arm would build from +/// these inputs — compared in place, because the steady frame must not allocate three strings per +/// shown unit just to learn nothing changed. Equivalent to string equality against the built stack +/// (the differential test pins it): line 0 is the set [`FLAG_PREFIXES`] tags in slot order glued +/// onto the name, line 1 the `<{sub}>` decoration when a subname shows. +fn lines_current(cached: &[String], player_flags: u32, name: &str, sub: Option<&str>) -> bool { + let name_line = |line: &str| { + let mut rest = line; + for (bit, tag) in FLAG_PREFIXES { + if player_flags & bit != 0 { + match rest.strip_prefix(tag) { + Some(r) => rest = r, + None => return false, + } + } + } + rest == name + }; + match (cached, sub) { + ([l0], None) => name_line(l0), + ([l0, l1], Some(sub)) => { + name_line(l0) + && l1 + .strip_prefix('<') + .and_then(|r| r.strip_suffix('>')) + .is_some_and(|r| r == sub) + } + _ => false, + } +} + /// The name's world scale for a unit whose overhead anchor sits `d` world units above its feet. /// Shared with the raid-target marker ([`crate::raid_marks`]) — §6 of the same geometry law. pub(crate) fn height_scale(d: f32) -> f32 { @@ -469,31 +500,31 @@ pub(crate) fn drive_nameplates( if !show { continue; } - let Some(name) = names.resolve(guid.0, &net_commands).map(str::to_owned) else { + // Ask-once resolve, then re-read by reference (`peek`, resolve's read-only twin): holding + // resolve's `&mut`-tied return across the subname read below is a borrow conflict, and the + // `str::to_owned` that used to break it was a per-unit-per-frame allocation made just to + // compare against the cache (the steady arm below no longer builds anything). + if names.resolve(guid.0, &net_commands).is_none() { + continue; + } + let Some(name) = names.peek(guid.0) else { continue; }; // The player flag decorations (a1–a3): glued straight onto the name line, no space. - let name = if net.kind == EntityKind::Player { - let prefix = flag_prefix(store.map_or(0, |s| s.0.player_flags())); - if prefix.is_empty() { - name - } else { - format!("{prefix}{name}") - } + let flags = if net.kind == EntityKind::Player { + store.map_or(0, |s| s.0.player_flags()) } else { - name + 0 }; - let mut lines = vec![name]; - if net.kind == EntityKind::Unit { - if let Some(sub) = benilla_protocol::guid::entry(guid.0) + let sub = if net.kind == EntityKind::Unit { + benilla_protocol::guid::entry(guid.0) .and_then(|e| names.creature_subname(e)) // vmangos ships "" (present-but-empty) for most templates — an empty wire // subname is NO subname, never an empty `<>` line. .filter(|s| !s.trim().is_empty()) - { - lines.push(format!("<{sub}>")); - } - } + } else { + None + }; // The color: the ring's byte-verified rank + the shared palette selector. let rank = ring_reaction( factions.as_deref(), @@ -521,27 +552,12 @@ pub(crate) fn drive_nameplates( )) }; - // (Re)build the plate entity when its content changed. The seat below is only the - // spawn-frame placement — [`place_nameplates`] re-seats every plate each frame from the - // propagated (same-frame) pose. - let anchor = overhead_anchor( - entity, - tf, - &anchor_q.0, - &anchor_q.1, - &anchor_q.2, - &anchor_q.3, - &anchor_q.4, - ); - let scale = height_scale(anchor.y - tf.translation.y); - let place = Transform { - translation: anchor, - rotation: facing, - scale: Vec3::splat(scale), - }; seen.insert(entity); match plates.live.get(&entity) { - Some((_, l, c)) if *l == lines && *c == color => {} + // The steady frame — very nearly all of them — compares the cached stack in place + // ([`lines_current`]): the line stack used to be BUILT here (three allocations per + // shown unit per frame) only to equality-compare against the cache and be dropped. + Some((_, l, c)) if *c == color && lines_current(l, flags, name, sub) => {} stale => { if let Some((old, _, _)) = stale { let old = *old; @@ -549,6 +565,17 @@ pub(crate) fn drive_nameplates( e.despawn(); } } + // Building the stack allocates, and only this arm does it. [`lines_current`]'s + // differential test pins the compare above to exactly this shape. + let prefix = flag_prefix(flags); + let mut lines = vec![if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}{name}") + }]; + if let Some(sub) = sub { + lines.push(format!("<{sub}>")); + } debug!("nameplates: rebuild {entity} -> {lines:?} ({color:?})"); let mesh = plates .meshes @@ -556,6 +583,25 @@ pub(crate) fn drive_nameplates( .or_insert_with(|| meshes.add(build_name_mesh(atlas, &lines))) .clone(); let material = plates.materials[&color].clone(); + // The spawn-frame seat only — [`place_nameplates`] re-seats every plate each + // frame from the propagated (same-frame) pose. Kept (not `Transform::default()`): + // the PostUpdate placement can miss a plate whose unit despawned later this same + // Update, and a plate must never render a frame at the origin. + let anchor = overhead_anchor( + entity, + tf, + &anchor_q.0, + &anchor_q.1, + &anchor_q.2, + &anchor_q.3, + &anchor_q.4, + ); + let scale = height_scale(anchor.y - tf.translation.y); + let place = Transform { + translation: anchor, + rotation: facing, + scale: Vec3::splat(scale), + }; let plate = commands .spawn((Mesh3d(mesh), MeshMaterial3d(material), place, NamePlate)) .id(); @@ -653,8 +699,19 @@ fn place_nameplates( rotation: facing, scale: Vec3::splat(scale), }; - *ptf = place; - *pglobal = GlobalTransform::from(place); + // The no-op write gate (decision 1362 — the camera's pattern, at the plate): a parked + // unit's seat is bit-stable, but writing it anyway marked every plate's transform changed + // every frame. Bit equality, not an epsilon: a real sub-epsilon drift must still land. + // The global rides the same branch — a root entity's global IS its transform, so the two + // are stale together. The mounted rock-damp seat never settles; the gate just misses there. + { + let t = ptf.bypass_change_detection(); + if *t != place { + *t = place; + ptf.set_changed(); + *pglobal = GlobalTransform::from(place); + } + } } // Dismounted or despawned units drop their damping state (a re-mount starts fresh). rock.retain(|e, _| anchor_q.4.contains(*e)); @@ -834,6 +891,95 @@ mod tests { const RED: Color = Color::linear_rgb(1.0, 0.0, 0.0); + /// The steady arm's in-place compare answers exactly "is the cached stack what the stale arm + /// would build from these inputs" — pinned differentially against that build itself (every + /// pair of cases, both directions), so the two shapes cannot drift apart. This is also the + /// keep-vs-rebuild verdict: `true` keeps the plate entity across frames, `false` rebuilds it — + /// e.g. a name that merely LOOKS pre-tagged (`Bob`, unflagged) still compares equal to a + /// flagged `Bob`, exactly as the built strings do. + #[test] + fn lines_current_matches_the_built_stack() { + let build = |flags: u32, name: &str, sub: Option<&str>| { + let mut lines = vec![format!("{}{name}", flag_prefix(flags))]; + if let Some(sub) = sub { + lines.push(format!("<{sub}>")); + } + lines + }; + let cases: &[(u32, &str, Option<&str>)] = &[ + (0, "Mankrik", None), + (0, "Young Wolf", Some("Beast")), + (0, "Young Wolf", None), + (0x2, "Bob", None), + (0x2 | 0x8, "Bob", None), + (0, "Bob", None), + ]; + for &(flags, name, sub) in cases { + let cached = build(flags, name, sub); + for &(f2, n2, s2) in cases { + assert_eq!( + lines_current(&cached, f2, n2, s2), + build(f2, n2, s2) == cached, + "cache of ({flags:#x}, {name:?}, {sub:?}) vs ({f2:#x}, {n2:?}, {s2:?})" + ); + } + } + } + + /// The placement write gate (decision 1362's pattern at the plate): a static world's second + /// placement leaves the plate's `Transform`/`GlobalTransform` change ticks untouched, and a + /// moved unit still lands its write — bit equality, so any real movement passes. + #[test] + fn a_parked_plates_seat_takes_no_write() { + use bevy::ecs::system::RunSystemOnce; + + let mut world = World::new(); + world.init_resource::