the performance hunt, day one: the frame sheds 1.85 ms

- UI layout solves what moved, not what exists — hovering a full bag no longer re-solves the whole interface
- world albedo textures stop keeping a second copy of themselves in RAM
- collision gets a coarse grid for oversized slabs (32x fewer triangles tested), idle units stop re-asking the ground every frame, and anchors spawn on first consumer instead of by the thousand
- aura and loot watchers scan when the store changes rather than on the clock, and static-transform tracking and plate reaping join the sweep

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-16 17:49:54 +07:00
co-authored by Claude Fable 5
parent 8c19ed0836
commit d99435beb8
36 changed files with 1646 additions and 385 deletions
+8 -3
View File
@@ -9,9 +9,16 @@
//! Prints one line per second: for each watched asset type, the per-frame Added/Modified counts,
//! and for images the megabytes those modifications ask the render world to re-upload, plus the
//! top offenders by how many frames they were touched in.
//!
//! The megabytes come from the image's `texture_descriptor`, not from `data.len()`. A
//! `RenderAssetUsages::RENDER_WORLD` image has its bytes MOVED into the render world on extract, so
//! main-side `data` is `None` from then on — measuring it read 0 for the sprite sheets, the effect
//! textures and all three terrain arrays, i.e. for most of the texture set. The meter was blind to
//! precisely the assets it exists to catch.
use std::collections::HashMap;
use benilla_assets::image_gpu_bytes;
use bevy::asset::AssetEvent;
use bevy::prelude::*;
use bevy::time::Real;
@@ -72,9 +79,7 @@ fn watch_images(
AssetEvent::Added { .. } => churn.counts.entry("Image").or_default().0 += 1,
AssetEvent::Modified { id } => {
churn.counts.entry("Image").or_default().1 += 1;
let bytes = images
.get(id)
.map_or(0, |i| i.data.as_ref().map_or(0, Vec::len));
let bytes = images.get(id).map_or(0, image_gpu_bytes);
churn.image_bytes += bytes as u64;
let frame = churn.frames;
let slot = churn.images.entry(id).or_default();
+10 -5
View File
@@ -37,7 +37,11 @@ pub(crate) struct Bowstring {
/// this frame's). Two segments: tip → middle → tip.
fn draw_bowstrings(
bows: Query<(&Bowstring, &GlobalTransform, &InheritedVisibility)>,
owners: Query<(&BoneAttach, Has<NockLatch>)>,
owners: Query<(
&BoneAttach,
&benilla_world::rig_anim::RigPose,
Has<NockLatch>,
)>,
joints: Query<&GlobalTransform>,
mut gizmos: Gizmos,
) {
@@ -54,11 +58,12 @@ fn draw_bowstrings(
let middle = owners
.get(bs.owner)
.ok()
.filter(|(_, latched)| *latched)
.and_then(|(bones, _)| {
.filter(|(_, _, latched)| *latched)
.and_then(|(bones, pose, _)| {
// A pure position read — `posed_point` off the composed pose, no anchor entity
// (decision 1355).
let &(bone, offset) = bones.points.get(&HAND_ARROW)?;
let joint = bones.anchor(bone)?;
Some(joints.get(joint).ok()?.transform_point(offset))
pose.posed_point(joints.get(pose.joints_root).ok()?, bone, offset)
})
.unwrap_or_else(|| (top + bottom) / 2.0);
gizmos.line(top, middle, STRING_COLOR);
@@ -142,6 +142,29 @@ fn fire_entity_census(world: &mut World) {
}
}
world.resource_mut::<EntityCensus>().fired = true;
// The anchor split (0732 slice A's premise check). `RigAnchor` leaves are the single largest
// archetype in the scene — 53 % of all entities — and slice A's claim is that most of them ride
// nothing. "Rides nothing" is directly observable: an anchor whose entity has no `Children` is
// hosting no attachment, no emitter, no ribbon, no card. Counted here rather than inferred from
// the model's bone sources, because the model says what COULD attach and the world says what
// DID.
{
let mut q = world.query_filtered::<Option<&bevy::prelude::Children>, bevy::prelude::With<benilla_world::rig_anim::RigAnchor>>();
let (mut total, mut childless) = (0u32, 0u32);
for kids in q.iter(world) {
total += 1;
if kids.is_none_or(|k| k.is_empty()) {
childless += 1;
}
}
eprintln!(
"ENTITY_CENSUS_ANCHORS total={total} childless={childless} ({:.1}%) hosting={}",
100.0 * f32::from(u16::try_from(childless).unwrap_or(u16::MAX))
/ f32::from(u16::try_from(total.max(1)).unwrap_or(u16::MAX)),
total - childless
);
}
let components = world.components();
let mut rows: Vec<(usize, String)> = world
.archetypes()
+2
View File
@@ -308,6 +308,7 @@ fn drive_bubbles(
// The overhead-anchor inputs ([`overhead_anchor`]).
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
Query<&OverheadFallback>,
Query<&GlobalTransform>,
Query<(), With<crate::entities::mount::MountChild>>,
@@ -417,6 +418,7 @@ fn drive_bubbles(
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
let seat_world = Vec3::new(tf.translation.x, anchor.y + LIFT, tf.translation.z);
let cam_tf = GlobalTransform::from(*cam_pose);
+10 -1
View File
@@ -188,6 +188,7 @@ pub(crate) fn float_combat_text(
// The overhead-anchor resolution (`0x608640`): the unit's PlayerName attachment joint, else
// the bbox fallback.
anchors: Query<&BoneAttach>,
poses: Query<&benilla_world::rig_anim::RigPose>,
fallbacks: Query<&OverheadFallback>,
globals: Query<&GlobalTransform>,
mounts: Query<(), With<crate::entities::mount::MountChild>>,
@@ -211,7 +212,15 @@ pub(crate) fn float_combat_text(
};
// The spawn snapshot (`0x6c73f0`): the unit's OVERHEAD anchor (`0x608640` — head height,
// [`overhead_anchor`]), then the client's `z − 1/3` lift. NOT feet-anchored.
let overhead = overhead_anchor(spawn.anchor, tf, &anchors, &fallbacks, &globals, &mounts);
let overhead = overhead_anchor(
spawn.anchor,
tf,
&anchors,
&poses,
&fallbacks,
&globals,
&mounts,
);
debug!(
"fct: \"{}\" (cat {}) over {:?}",
spawn.text, spawn.category, spawn.anchor
@@ -1061,8 +1061,15 @@ pub(super) fn route_cast_visuals(
/// exactly that bug: Stealth's kit 312 carries no models, no anim and no attach at all (its whole
/// visual is one proc-14 CharProc), so an effects-only test dropped it and the character showed
/// nothing.
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(crate) fn arm_aura_state_fx(
// The slot diff below is a pure function of the store's aura fields, so it re-runs only when
// the store was written — `arm_level_up_fx`'s idiom (decision 1357's sibling gate): at the
// LBRS pin this was ~800 full aura-slot walks/frame re-deriving an unchanged answer. The
// unfiltered twin runs exactly once per DBC-resource arrival: a unit that streamed in before
// `SpellVisuals`/`Spells` landed carries standing auras no store write will re-announce.
units: Query<(Entity, &ObjectStore)>,
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
mut fx: MessageWriter<SpellKitFx>,
@@ -1070,10 +1077,17 @@ pub(crate) fn arm_aura_state_fx(
mut sounds: MessageWriter<SpellKitSound>,
mut armed: Local<EntityHashMap<Vec<u32>>>,
) {
let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed())
|| spells.as_ref().is_some_and(|s| s.is_changed());
let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else {
return; // no client data — no spell visuals (the DBC-resource degrade shape)
};
for (entity, store) in &units {
let scan = if full_sweep {
units.iter().collect::<Vec<_>>()
} else {
changed.iter().collect::<Vec<_>>()
};
for (entity, store) in scan {
let prev = armed.entry(entity).or_default();
// Occupied slots, deduped (the same spell re-applied by two casters holds two slots —
// one state instance either way).
@@ -1160,15 +1174,25 @@ pub(crate) fn arm_aura_state_fx(
/// [`LOOT_FX_KEY`] — the client hangs loot art on the same `Effect_C` node type its spell
/// visuals use, so sharing the one attach body is the faithful shape.
pub(super) fn arm_loot_fx(
// Dead+lootable is a pure function of store fields — the same `Changed<ObjectStore>` gate as
// `arm_level_up_fx` below and `arm_aura_state_fx` above, with the same one-shot full sweep
// when the DBC resource lands after units already streamed in.
units: Query<(Entity, &ObjectStore)>,
changed: Query<(Entity, &ObjectStore), Changed<ObjectStore>>,
visuals: Option<Res<SpellVisuals>>,
mut fx: MessageWriter<SpellKitFx>,
mut armed: Local<EntityHashSet>,
) {
let full_sweep = visuals.as_ref().is_some_and(|v| v.is_changed());
let Some(path) = visuals.as_ref().and_then(|v| v.0.loot_art_path()) else {
return; // no client data / no such row — no loot art (the DBC-resource degrade shape)
};
for (entity, store) in &units {
let scan = if full_sweep {
units.iter().collect::<Vec<_>>()
} else {
changed.iter().collect::<Vec<_>>()
};
for (entity, store) in scan {
let lootable = store.0.unit_is_dead() && store.0.unit_lootable();
if lootable && armed.insert(entity) {
fx.write(SpellKitFx::Begin {
+8 -2
View File
@@ -143,10 +143,15 @@ pub(crate) struct OverheadFallback(pub(crate) f32);
/// per-frame by the nameplate and snapshotted at spawn by the floating combat text. Generic over
/// the joint-globals query filter so a caller that also mutates `GlobalTransform` elsewhere (the
/// nameplate placer) can pass a disjoint query.
///
/// A pure position read: it computes through `RigPose::posed_point` — the composed pose × the
/// rig root's frame — and never touches an anchor entity, so the overhead bone spawns nothing
/// (decision 1355).
pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
entity: Entity,
tf: &Transform,
anchors: &Query<&BoneAttach>,
poses: &Query<&benilla_world::rig_anim::RigPose>,
fallbacks: &Query<&OverheadFallback>,
globals: &Query<&GlobalTransform, F>,
mounts: &Query<(), With<mount::MountChild>>,
@@ -162,8 +167,9 @@ pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
ATTACH_OVERHEAD
};
let &(bone, offset) = a.points.get(&slot)?;
let joint = a.anchor(bone)?;
Some(globals.get(joint).ok()?.transform_point(offset))
let pose = poses.get(entity).ok()?;
let root = globals.get(pose.joints_root).ok()?;
pose.posed_point(root, bone, offset)
})
.unwrap_or_else(|| {
let bbox_z = fallbacks.get(entity).map_or(0.0, |f| f.0);
@@ -131,8 +131,11 @@ pub(super) struct PartDress<'a> {
pub(super) inst_slot: u16,
/// Whether the unit built a rig at all — the gate on a part drawing its skinned twin.
pub(super) rigged: bool,
/// Bone index → anchor entity, for a billboard batch's card to ride its live joint.
pub(super) anchors: &'a std::collections::HashMap<u16, Entity>,
/// Bone index → anchor entity, for a billboard batch's card to ride its live joint. Resolved
/// by the CALLER through `RigPose::anchor_for` for exactly the card bones this dress will
/// spawn (decision 1355) — owned, because the resolver needs `&mut` on the pose and this
/// context is shared immutably across every part.
pub(super) anchors: std::collections::HashMap<u16, Entity>,
/// The model's interior fold reference (model-local) — one verdict per unit, so a body can
/// never split across the two light laws.
pub(super) bake_center: Vec3,
@@ -477,7 +480,7 @@ mod tests {
let unit = app.world_mut().spawn(Transform::default()).id();
let mut fadeable = part(None, false);
fadeable.fade_blend = Some(mat(99));
let anchors = std::collections::HashMap::new();
let anchors: std::collections::HashMap<u16, Entity> = std::collections::HashMap::new();
let object = WorldObject {
kind: ModelKind::Creature,
label: String::new(),
@@ -492,7 +495,7 @@ mod tests {
object: &object,
inst_slot: 0,
rigged: false,
anchors: &anchors,
anchors,
bake_center: Vec3::ZERO,
idle_aabb: None,
now: 0.0,
@@ -541,7 +544,7 @@ mod tests {
scale_anim: None,
seq_translations: Vec::new(),
});
let anchors = std::collections::HashMap::new();
let anchors: std::collections::HashMap<u16, Entity> = std::collections::HashMap::new();
let object = WorldObject {
kind: ModelKind::Creature,
label: String::new(),
@@ -556,7 +559,7 @@ mod tests {
object: &object,
inst_slot: 0,
rigged: false,
anchors: &anchors,
anchors,
bake_center: Vec3::ZERO,
idle_aabb: None,
now: 0.0,
+90 -79
View File
@@ -40,8 +40,8 @@ mod redress;
pub(super) use redress::redress_player_looks;
/// Set up the skinned instance shared by creatures/players (decision 0019) and animated
/// GameObjects (decision 0242): the per-instance consumer-bone anchors, the pose buffer, the
/// palette rig slot, and the global-sequence drive.
/// GameObjects (decision 0242): the pose buffer, the palette rig slot, and the global-sequence
/// drive.
///
/// The **sequence clock** — the `AnimationPlayer` + graph + [`ModelAnimations`] + the driver that
/// owns the choice — is NOT set up here; it is [`arm_sequence_clock`], called by the spawn site
@@ -49,15 +49,14 @@ pub(super) use redress::redress_player_looks;
/// slot and a skinned twin, and is worth spending only on a model whose bones actually move; the
/// clock is worth arming on every instance, because a sequence drives far more than a pose.
///
/// This lane spawns **no joint
/// entities** (decision 0724): the pose lives in a [`benilla_world::rig_anim::RigPose`] array on
/// `entity`, and only the CONSUMER bones — attachment points, event markers, emitter/ribbon/
/// light hosts, billboard-card bones — get an anchor entity under `joints_root`, re-seated from
/// the composed pose each frame it changes. Returns the anchors + the palette rig slot
/// (decision 0720), or `None` when the model has no inverse bindposes. Slot `0` = the palette
/// table was full: the anchors still exist (emitters/attachments ride them), but parts fall
/// back to the static bind-pose mesh. No animations ⇒ the pose just holds bind pose
/// (Milestone A).
/// This lane spawns **no joint entities** (decision 0724) — the pose lives in a
/// [`benilla_world::rig_anim::RigPose`] array — and **no anchor entities either** (decision
/// 1355): a bone gets its anchor from `RigPose::anchor_for` the moment something actually
/// consumes it, re-seated from the composed pose each frame it changes. Returns the pose (still
/// in hand — see [`RigBuild`]) + the palette rig slot (decision 0720), or `None` when the model
/// has no inverse bindposes. Slot `0` = the palette table was full: anchors still resolve
/// (emitters/attachments ride them), but parts fall back to the static bind-pose mesh. No
/// animations ⇒ the pose just holds bind pose (Milestone A).
fn setup_skinned_instance(
commands: &mut Commands,
palettes: &mut benilla_world::rig_palette::RigPalettes,
@@ -71,48 +70,13 @@ fn setup_skinned_instance(
// rider's frame is the seat anchor instead (decision 0441), a conform-tilted model's its
// conform node, while the `AnimationPlayer`/driver components stay on `entity`. Skinned
// parts render purely from the palette, so their own parentage is free.
let mut pose = benilla_world::rig_anim::RigPose::new(joints_root, &d.skeleton);
// The consumer bones: every bone something in the world reaches by entity — an attachment
// point (held items, spell effects, the mount seat, overhead anchors), an event marker, an
// emitter/ribbon/light host, a billboard card's bone. Everything else is palette-only.
let mut bone_set = std::collections::BTreeSet::new();
bone_set.extend(d.attachments.iter().map(|a| a.bone));
bone_set.extend(d.markers.iter().map(|m| m.bone));
bone_set.extend(d.emitters.iter().map(|e| e.def.bone));
bone_set.extend(d.ribbons.iter().map(|r| r.def.bone));
bone_set.extend(
d.lights
.iter()
.filter_map(|l| u16::try_from(l.def.bone).ok()),
);
if let Some(parts) = &d.parts {
bone_set.extend(
parts
.iter()
.filter_map(|p| p.billboard.as_ref().map(|b| b.bone)),
);
}
let mut anchors = std::collections::HashMap::new();
for bone in bone_set {
let Some(m) = pose.model.get(bone as usize) else {
continue; // an out-of-range authored bone reference — no anchor, consumers miss
};
let (scale, rotation, translation) = m.to_scale_rotation_translation();
let anchor = commands
.spawn((
Transform {
translation,
rotation,
scale,
},
Visibility::default(),
benilla_world::rig_anim::RigAnchor { rig: entity, bone },
))
.id();
commands.entity(joints_root).add_child(anchor);
pose.anchors.push((bone, anchor));
anchors.insert(bone, anchor);
}
// No anchor entities are spawned here (decision 1355). A consumer bone gets its anchor the
// moment something actually consumes it — [`benilla_world::rig_anim::RigPose::anchor_for`],
// called by the emitter/ribbon/light arms, the billboard dress, held items, the mount seat,
// spell kits and quest markers — and the position-only readers (the overhead anchor, missile
// launch points, the bowstring) read `RigPose::posed_point` and never need an entity at all.
// The eager population this replaced was 96 % never-consumed at the LBRS pin (decision 1354).
let pose = benilla_world::rig_anim::RigPose::new(joints_root, &d.skeleton);
// The owned palette rig (decision 0720): the world pass writes this rig's composed frames ×
// these bindposes into the slot; every skinned part below tags the slot so the vertex stage
// finds its palette. The on-replace hook frees the slot with the visual teardown.
@@ -153,11 +117,12 @@ fn setup_skinned_instance(
commands.entity(entity).insert(drive);
}
}
// The pose buffer last — the anchors above registered themselves into it. The evaluator
// The pose buffer travels in the build result, NOT onto the entity yet: the arms of
// `attach_entity_visuals` below still resolve anchors into it (`RigPose::anchor_for` needs
// `&mut`), and it lands on the entity in one piece once the build is dressed. The evaluator
// (decision 0712) samples the player state straight into `locals`; with no joint entities and
// no `AnimationTargetId`s, Bevy's `animate_targets` has nothing of ours to touch.
commands.entity(entity).insert(pose);
Some(RigBuild { anchors, slot })
Some(RigBuild { pose, slot })
}
/// Arm this instance's **sequence clock**: the `AnimationPlayer` + graph + [`ModelAnimations`] that
@@ -241,10 +206,12 @@ fn arm_sequence_clock(
}
}
/// A collapsed rig's build result (decision 0724): the consumer anchors by bone + the palette
/// slot each skinned part tags.
/// A collapsed rig's build result (decision 0724): the pose buffer, still in hand so the build's
/// own arms can resolve anchors into it (decision 1355 — anchors spawn on first consumer, via
/// [`benilla_world::rig_anim::RigPose::anchor_for`]), + the palette slot each skinned part tags.
/// The pose lands on the entity at the end of the build.
struct RigBuild {
anchors: std::collections::HashMap<u16, Entity>,
pose: benilla_world::rig_anim::RigPose,
slot: u16,
}
@@ -268,9 +235,14 @@ pub(super) fn attach_entity_visuals(
Without<VisualAttached>,
>,
// The mount children's build state (decision 0441): a mounted unit's rider waits on its mount
// child's attach — the seat is the mount's attachment-0 joint out of this `BoneAttach`; the
// `NetEntity` is the staleness check (the field moved while the unit was still pending).
// child's attach — the seat resolves through the mount's attachment-0 point (`BoneAttach`)
// into its `RigPose` below; the `NetEntity` is the staleness check (the field moved while
// the unit was still pending).
mount_children: super::mount::MountChildren,
// Already-built rigs' pose buffers — the mount child's, for its seat anchor to resolve on
// demand (decision 1355). The unit being built here has no `RigPose` component yet (its pose
// is still in `RigBuild`), so the two never alias.
mut built_poses: Query<&mut benilla_world::rig_anim::RigPose>,
// The ItemDisplayInfo catalog (armor region textures, decision 0074). Read-only here; the
// held-item systems in the same chain hold it mutably in their own turns.
displays: Option<Res<super::ItemDisplays>>,
@@ -366,6 +338,7 @@ pub(super) fn attach_entity_visuals(
match super::mount::seat_or_spawn_mount(
&mut commands,
&mount_children,
&mut built_poses,
creatures.as_deref(),
entity,
mount_child.map(|&super::mount::MountChild(c)| c),
@@ -454,7 +427,7 @@ pub(super) fn attach_entity_visuals(
&& stores
.get(entity)
.is_ok_and(|s| crate::go_anim::go_animates(s.0.gameobject_type_id()));
let skin: Option<RigBuild> = match (net.kind, dm) {
let mut skin: Option<RigBuild> = match (net.kind, dm) {
// A creature (or player body — decision 0041) with a real skeleton. The `!is_empty`
// guard keeps a degenerate boneless model on the static mesh (its skinned twin would
// carry joint attributes but have no joints to index — out of bounds).
@@ -539,9 +512,11 @@ pub(super) fn attach_entity_visuals(
arm_sequence_clock(&mut commands, entity, anims, net.kind, go_state_machine);
}
}
// The bone-riding surface (decision 0072): the instance's joints + the model's attachment
// points, so held items (and future bone riders) can hang from the hand/hip/back joints.
if let (Some(rb), Some(d)) = (&skin, dm) {
// The bone-riding surface (decision 0072): the model's attachment points + event
// markers, so held items (and future bone riders) can hang from the hand/hip/back
// joints. Pure model data — the anchor *entities* live in `RigPose.anchors` and
// spawn on first consumer (decision 1355).
if let (Some(_), Some(d)) = (&skin, dm) {
// The event markers keep the client's first-match scan order: an ident already
// present wins (character models carry six `$CSD` records — the first is the one
// `0x7130e0` would return).
@@ -550,7 +525,6 @@ pub(super) fn attach_entity_visuals(
markers.entry(m.ident).or_insert((m.bone, m.offset));
}
commands.entity(entity).insert(super::BoneAttach {
anchors: rb.anchors.clone(),
points: d
.attachments
.iter()
@@ -672,7 +646,28 @@ pub(super) fn attach_entity_visuals(
// Everything one part's spawn reads from this unit, gathered once (`attach::dress`) —
// the same context the gear-change **re-dress** feeds `spawn_part`, so a body built at
// stream-in and a geoset that appears when a belt is swapped are dressed by one law.
let no_anchors = std::collections::HashMap::new();
//
// Billboard-card bones are the one dress input that needs an anchor ENTITY (the card
// rides its live joint), so they resolve here — through the same first-consumer
// resolver as everything else (decision 1355), over the same geoset predicate as the
// spawn loop below, so a hidden variant's card bone spawns nothing.
let card_anchors: std::collections::HashMap<u16, Entity> = match skin.as_mut() {
Some(rb) => parts
.iter()
.filter(|part| {
!visible_geosets
.as_ref()
.is_some_and(|vis| !vis.contains(&part.geoset_id))
})
.filter_map(|part| part.billboard.as_ref().map(|b| b.bone))
.filter_map(|bone| {
rb.pose
.anchor_for(&mut commands, entity, bone)
.map(|a| (bone, a))
})
.collect(),
None => std::collections::HashMap::new(),
};
let dress = PartDress {
unit: entity,
kind,
@@ -680,7 +675,7 @@ pub(super) fn attach_entity_visuals(
object: &object,
inst_slot,
rigged: skin.is_some(),
anchors: skin.as_ref().map_or(&no_anchors, |rb| &rb.anchors),
anchors: card_anchors,
bake_center,
idle_aabb,
now,
@@ -766,9 +761,9 @@ pub(super) fn attach_entity_visuals(
{
for em in emitters {
let owner = skin
.as_ref()
.and_then(|rb| rb.anchors.get(&em.def.bone))
.map_or((entity, [0.0; 3]), |&j| (j, em.bone_pivot));
.as_mut()
.and_then(|rb| rb.pose.anchor_for(&mut commands, entity, em.def.bone))
.map_or((entity, [0.0; 3]), |j| (j, em.bone_pivot));
particles::spawn_emitter(
&mut commands,
em,
@@ -802,21 +797,30 @@ pub(super) fn attach_entity_visuals(
// GameObject brazier. Same host-bone ride as the emitters, for the same reason: the
// reference re-registers each light at its LIVE bone position every frame. (The far more
// common carried light is the held torch — that one spawns on the item model, in
// `equipment`.)
// `equipment`.) The light bones resolve BEFORE the call: the closure can't borrow
// `commands` while `spawn_carried_lights` holds it.
let light_anchors: std::collections::HashMap<i16, Entity> = model_lights
.iter()
.filter(|l| l.def.casts())
.filter_map(|l| {
let bone = u16::try_from(l.def.bone).ok()?;
let rb = skin.as_mut()?;
rb.pose
.anchor_for(&mut commands, entity, bone)
.map(|a| (l.def.bone, a))
})
.collect();
super::spawn_carried_lights(&mut commands, model_lights, entity, |bone| {
skin.as_ref()
.zip(u16::try_from(bone).ok())
.and_then(|(rb, b)| rb.anchors.get(&b))
.copied()
light_anchors.get(&bone).copied()
});
// Ribbon trails (wisp streamers, trailing quest-object crystals) — the same host-bone
// ride as the emitters; the trail self-despawns when its owner joint/entity goes.
{
for rb in dm.map(|d| d.ribbons.as_slice()).unwrap_or_default() {
let (owner, use_pivot) = skin
.as_ref()
.and_then(|build| build.anchors.get(&rb.def.bone))
.map_or((entity, false), |&j| (j, true));
.as_mut()
.and_then(|build| build.pose.anchor_for(&mut commands, entity, rb.def.bone))
.map_or((entity, false), |j| (j, true));
// The `+0xc0` enable gate reads THIS instance's playing sequence, live —
// a GameObject flips state under its own trails (a trap springs, a door
// opens), so there is no spawn-time answer. Passing `None` here (the old
@@ -840,6 +844,13 @@ pub(super) fn attach_entity_visuals(
);
}
}
// The pose buffer lands last (decision 1355): every build arm above has resolved its
// anchors into it, so it arrives on the entity in one piece — the evaluator, the
// compose pass and every later `anchor_for` (a weapon equipped in combat, a spell
// kit) find the same buffer the build populated.
if let Some(rb) = skin {
commands.entity(entity).insert(rb.pose);
}
// Static collision for solid GameObjects (chests, mining veins, doors…): the model-local
// collider baked at build time rides the entity's pose, so player + camera collide with it.
// Hull-less GameObjects (herbs, small props) carry none — collide-iff-hull. GameObjects only;
@@ -79,6 +79,9 @@ pub(in crate::entities) fn redress_player_looks(
&Children,
Option<&benilla_world::rig_palette::RigSkin>,
Option<&super::super::BoneAttach>,
// The pose buffer: a revealed billboard batch's card bone resolves its anchor on
// first demand (`RigPose::anchor_for`, decision 1355).
Option<&mut benilla_world::rig_anim::RigPose>,
Option<&benilla_world::interior::BodyBakeCenter>,
Option<&benilla_world::model_fade::UnitAppearFade>,
),
@@ -113,7 +116,7 @@ pub(in crate::entities) fn redress_player_looks(
let (sections, world_assets, mut images, mut skin_composites, asset_server, mut mats) =
skin_build;
let now = time.elapsed_secs();
for (entity, net, live, mut applied, children, rig, bones, bake_center, unit_fade) in
for (entity, net, live, mut applied, children, rig, bones, mut pose, bake_center, unit_fade) in
&mut players
{
// `settled` (decision 0074): every non-empty visible-item entry has resolved through the
@@ -206,7 +209,19 @@ pub(in crate::entities) fn redress_player_looks(
// Pass 2 — the batches the new gear reveals. They join the unit's own appear-fade clock if
// one is still in flight (the login gear cascade lands mid-ramp), exactly as a
// late-resolving held item does — never a second ramp of their own, never a pop.
let no_anchors = std::collections::HashMap::new();
//
// A revealed billboard batch's card bone resolves its anchor first (decision 1355), over
// exactly the parts the spawn loop below will dress.
let card_anchors: std::collections::HashMap<u16, Entity> = match pose.as_mut() {
Some(p) => parts
.iter()
.enumerate()
.filter(|&(i, part)| !present[i] && shows(part.geoset_id))
.filter_map(|(_, part)| part.billboard.as_ref().map(|b| b.bone))
.filter_map(|bone| p.anchor_for(&mut commands, entity, bone).map(|a| (bone, a)))
.collect(),
None => std::collections::HashMap::new(),
};
let dress = PartDress {
unit: entity,
kind: benilla_world::model_render::ModelKind::Creature,
@@ -219,7 +234,7 @@ pub(in crate::entities) fn redress_player_looks(
},
inst_slot: rig.map_or(0, |r| r.slot),
rigged: bones.is_some(),
anchors: bones.map_or(&no_anchors, |b| &b.anchors),
anchors: card_anchors,
bake_center: bake_center.map_or(dm.bake_center_local, |c| c.0),
idle_aabb: idle_aabb(dm),
now,
@@ -426,7 +441,6 @@ mod tests {
.spawn((Transform::default(), Visibility::default(), ChildOf(joint)))
.id();
app.world_mut().entity_mut(player).insert(BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::new(),
markers: HashMap::new(),
});
@@ -74,7 +74,7 @@ use crate::net::GuidIndex;
use benilla_world::particles::buffer::EffectVertex;
use benilla_world::view::WorldCamera;
use super::{BoneAttach, OverheadFallback};
use super::OverheadFallback;
/// The caster-end anchor's height factor when the model has no `$CSL` marker: the reference's
/// `base + (0, 0, modelHeight × modelScale × 0.75)` (`0x6ec73e`–`0x6ec771`, the `0.75f` at
@@ -178,14 +178,14 @@ fn jitter(rng: &mut u32) -> f32 {
/// `0x61ceb0` read the same table the same way.
fn caster_world_pos(
caster: Entity,
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
units: &super::missile::AttachPosQuery,
joints: &Query<&GlobalTransform>,
heights: &Query<&OverheadFallback>,
) -> Option<Vec3> {
let (base, bones) = units.get(caster).ok()?;
let marker = bones.and_then(|b| {
let (base, bones, pose) = units.get(caster).ok()?;
let marker = bones.zip(pose).and_then(|(b, p)| {
let &(bone, offset) = b.markers.get(b"$CSL")?;
Some(joints.get(b.anchor(bone)?).ok()?.transform_point(offset))
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
});
Some(marker.unwrap_or_else(|| {
let (scale, _, translation) = base.to_scale_rotation_translation();
@@ -437,7 +437,7 @@ pub(crate) fn simulate_chain_beams(
mut draw: benilla_world::particles::buffer::WorldEffectDraw,
images: Res<Assets<Image>>,
world_cam: Query<Entity, With<WorldCamera>>,
units: Query<(&GlobalTransform, Option<&BoneAttach>)>,
units: super::missile::AttachPosQuery,
joints: Query<&GlobalTransform>,
heights: Query<&OverheadFallback>,
mut beams: Query<(Entity, &mut ChainBeam)>,
@@ -216,16 +216,14 @@ pub(super) enum ItemModelKind {
Quiver,
}
/// The per-instance bone-riding surface, inserted at visual attach (decision 0072): the rig's
/// consumer **anchors** by bone (decision 0724 — one anchor entity per bone something reaches,
/// not a full joint hierarchy) + the model's attachment points (id → bone + Bevy-space local
/// offset). Held items spawn under `anchor(bone)` at the offset; every bone rider (mount seat,
/// spell effects, overhead anchors) uses the same two-step lookup.
/// The per-instance bone-riding surface, inserted at visual attach (decision 0072): the model's
/// attachment points and event markers (id → bone + Bevy-space local offset). **Pure model data**
/// — the anchor *entities* live in `RigPose.anchors` and spawn on first consumer (decision 1355):
/// a rider that needs a frame to parent under resolves `points`/`markers` here, then
/// `RigPose::anchor_for(bone)`; a reader that only needs a position goes straight to
/// `RigPose::posed_point(bone, offset)` and touches no entity at all.
#[derive(Component)]
pub(crate) struct BoneAttach {
/// Bone index → its anchor entity. Populated for every bone in `points`/`markers` (and the
/// emitter/ribbon/light/billboard hosts) — a `points` hit always resolves.
pub(crate) anchors: HashMap<u16, Entity>,
/// Attachment id → `(bone index, bevy-space offset from the bone's bind pivot)`.
pub(crate) points: HashMap<u16, (u16, Vec3)>,
/// Animation-event marker 4CC → the same `(bone, offset)` shape — first record per ident
@@ -234,13 +232,6 @@ pub(crate) struct BoneAttach {
pub(crate) markers: HashMap<[u8; 4], (u16, Vec3)>,
}
impl BoneAttach {
/// The anchor entity standing in for `bone` — the frame a rider parents under / reads.
pub(crate) fn anchor(&self, bone: u16) -> Option<Entity> {
self.anchors.get(&bone).copied()
}
}
/// The held-item children currently spawned for a unit: the [`HeldItems`] they were built from (the
/// diff key) + the spawned root entity **per slot** — indexed, not a flat list, because the diff is
/// per slot (decision 0826): a slot that didn't change keeps its root, and a slot that only changed
@@ -136,6 +136,10 @@ pub(in crate::entities) fn attach_held_items(
// gated on the mesh's own joint attributes. The one exception is an item that rigs itself
// (0841) — it carries its own slot instead, and inherits the tint up the `ParentModel` chain.
Option<&benilla_world::rig_palette::RigSkin>,
// The wearer's pose buffer: the attach joint spawns on first demand from the composed
// pose (`RigPose::anchor_for`, decision 1355) — a weapon equipped in combat seats at the
// live pose, never the rest pose.
Option<&mut benilla_world::rig_anim::RigPose>,
)>,
held: Option<Res<ItemDisplays>>,
time: Res<Time>,
@@ -148,7 +152,9 @@ pub(in crate::entities) fn attach_held_items(
return;
};
let now = time.elapsed_secs();
for (items, bones, attached, entity, unit_fade, body_center, unit_tf, skin) in &mut units {
for (items, bones, attached, entity, unit_fade, body_center, unit_tf, skin, mut pose) in
&mut units
{
// A held item / helm / shoulder resolves and spawns asynchronously (a template round trip, a
// model load) — often *after* the body has already armed its appear-fade (decision 0032 is a
// per-unit property: the reference fades the whole unit, attachments included, as one). Read
@@ -209,7 +215,10 @@ pub(in crate::entities) fn attach_held_items(
.get(&n.attach)
.zip(ctx.bones.points.get(&w.attach))
{
if let Some(joint) = ctx.bones.anchor(bone) {
if let Some(joint) = pose
.as_mut()
.and_then(|p| p.anchor_for(&mut commands, entity, bone))
{
commands.entity(joint).add_child(root);
commands
.entity(root)
@@ -230,7 +239,15 @@ pub(in crate::entities) fn attach_held_items(
commands.entity(old).despawn();
}
*root = wants.and_then(|hs| {
spawn_slot(&mut commands, &mut palettes, &ctx, &held, slot_idx, &hs)
spawn_slot(
&mut commands,
&mut palettes,
&ctx,
pose.as_deref_mut(),
&held,
slot_idx,
&hs,
)
});
}
let applied = HeldAttached {
@@ -253,6 +270,7 @@ fn spawn_slot(
commands: &mut Commands,
palettes: &mut benilla_world::rig_palette::RigPalettes,
ctx: &WearerCtx,
pose: Option<&mut benilla_world::rig_anim::RigPose>,
held: &ItemDisplays,
slot_idx: usize,
hs: &HeldSlot,
@@ -263,7 +281,8 @@ fn spawn_slot(
let parts = dm.parts.as_ref()?;
// Body model has no such attach point (a non-character skeleton) — hold nothing.
let &(bone, offset) = bones.points.get(&hs.attach)?;
let joint = bones.anchor(bone)?;
// The attach joint, spawned on first demand from the wearer's composed pose (decision 1355).
let joint = pose?.anchor_for(commands, entity, bone)?;
let root = commands
.spawn((
Transform::from_translation(offset),
@@ -766,9 +785,7 @@ mod tests {
displays.models.insert((7, KIND), dm);
app.insert_resource(displays);
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::SHOULDER_LEFT, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
@@ -788,8 +805,12 @@ mod tests {
)
.expect("a fresh palette has room");
let wearer_slot = skin.slot;
app.world_mut()
.spawn((items, bones, Transform::default(), skin));
let wearer = app
.world_mut()
.spawn((items, bones, Transform::default(), skin))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
app.world_mut().entity_mut(wearer).insert(pose);
app.add_systems(Update, attach_held_items);
app.update();
@@ -836,10 +857,7 @@ mod tests {
displays.models.insert((7, HELM_KIND), dm);
app.insert_resource(displays);
// The bone the helm hangs on, and the wearer.
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::HELM, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
@@ -865,6 +883,9 @@ mod tests {
if let Some(skin) = skin {
wearer.insert(skin);
}
let wearer = wearer.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
app.world_mut().entity_mut(wearer).insert(pose);
app.add_systems(Update, attach_held_items);
app.update();
@@ -1005,9 +1026,7 @@ mod tests {
displays.models.insert((7, SHOULDER_KIND), dm);
app.insert_resource(displays);
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::SHOULDER_RIGHT, (3u16, ATTACH))]),
markers: HashMap::new(),
};
@@ -1018,7 +1037,12 @@ mod tests {
attach: attach_id::SHOULDER_RIGHT,
visual: NO_GLOW,
});
app.world_mut().spawn((items, bones, Transform::default()));
let wearer = app
.world_mut()
.spawn((items, bones, Transform::default()))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
app.world_mut().entity_mut(wearer).insert(pose);
app.add_systems(Update, attach_held_items);
app.update();
@@ -1097,9 +1121,7 @@ mod tests {
displays.models.insert((7, KIND), dm);
app.insert_resource(displays);
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::HAND_RIGHT, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
@@ -1112,12 +1134,17 @@ mod tests {
});
// The wearer is mid-login: its own ramp is still pending, so everything attaching to it
// must join that ramp rather than open opaque beside it.
app.world_mut().spawn((
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
));
let wearer = app
.world_mut()
.spawn((
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
app.world_mut().entity_mut(wearer).insert(pose);
app.add_systems(Update, attach_held_items);
app.update();
@@ -1190,9 +1217,7 @@ mod tests {
displays.models.insert((7, KIND), dm);
app.insert_resource(displays);
let joint = app.world_mut().spawn(Transform::default()).id();
let bones = BoneAttach {
anchors: HashMap::from([(3u16, joint)]),
points: HashMap::from([(attach_id::HAND_RIGHT, (3u16, Vec3::ZERO))]),
markers: HashMap::new(),
};
@@ -1203,12 +1228,17 @@ mod tests {
attach: attach_id::HAND_RIGHT,
visual: NO_GLOW,
});
app.world_mut().spawn((
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
));
let wearer = app
.world_mut()
.spawn((
items,
bones,
Transform::default(),
UnitAppearFade::Pending { since: SINCE },
))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
app.world_mut().entity_mut(wearer).insert(pose);
app.add_systems(Update, attach_held_items);
app.update();
@@ -1271,15 +1301,9 @@ mod tests {
}
app.insert_resource(displays);
// Three attach points on three bones: the hand, the back (where a stowed weapon rides) and
// the shoulder.
let (hand, back, shoulder) = (
app.world_mut().spawn(Transform::default()).id(),
app.world_mut().spawn(Transform::default()).id(),
app.world_mut().spawn(Transform::default()).id(),
);
// Three attach points on three bones: the hand, the back (where a stowed weapon rides)
// and the shoulder — their joints spawn on first demand (decision 1355).
let bones = BoneAttach {
anchors: HashMap::from([(1u16, hand), (2u16, back), (3u16, shoulder)]),
points: HashMap::from([
(attach_id::HAND_RIGHT, (1u16, HAND_AT)),
(SHEATH_BACK, (2u16, BACK_AT)),
@@ -1304,6 +1328,8 @@ mod tests {
.world_mut()
.spawn((items, bones, Transform::default()))
.id();
let pose = benilla_world::testing::test_rig_pose(wearer, &[Vec3::ZERO; 4]);
app.world_mut().entity_mut(wearer).insert(pose);
app.add_systems(Update, attach_held_items);
app.update();
(app, wearer)
@@ -1367,9 +1393,12 @@ mod tests {
.map(|c| c.parent()),
app.world()
.entity(wearer)
.get::<BoneAttach>()
.get::<benilla_world::rig_anim::RigPose>()
.unwrap()
.anchor(2),
.anchors
.iter()
.find(|&&(b, _)| b == 2)
.map(|&(_, j)| j),
"re-parented onto the sheath point's joint"
);
assert_eq!(
+40 -39
View File
@@ -183,37 +183,43 @@ pub(super) struct Missile {
weapon_visual: Option<u32>,
}
/// The unit-side inputs every attach/marker **position read** shares: the base frame, the
/// attachment/marker tables, and the pose the point is computed from. A pure read — the point
/// comes from `RigPose::posed_point` (composed pose × rig root frame), so these reads spawn no
/// anchor entity (decision 1355). Shared with the chain-beam lane.
pub(super) type AttachPosQuery<'w, 's> = Query<
'w,
's,
(
&'static GlobalTransform,
Option<&'static BoneAttach>,
Option<&'static benilla_world::rig_anim::RigPose>,
),
>;
/// A unit's attach-point world position through the given tag cascade, else its base translation.
/// `None` only when the unit itself is gone. Shared with the chain-beam lane, whose non-caster
/// endpoints anchor through the very same table and cascade (`0x6ec780`; decision 0955).
pub(super) fn attach_world_pos(
unit: Entity,
tags: impl IntoIterator<Item = u16>,
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
units: &AttachPosQuery,
joints: &Query<&GlobalTransform>,
) -> Option<Vec3> {
let (base, bones) = units.get(unit).ok()?;
let point = bones.and_then(|b| {
tags.into_iter()
.find_map(|tag| b.points.get(&tag).copied())
.and_then(|(bone, offset)| b.anchor(bone).map(|j| (j, offset)))
let (base, bones, pose) = units.get(unit).ok()?;
let point = bones.zip(pose).and_then(|(b, p)| {
let (bone, offset) = tags
.into_iter()
.find_map(|tag| b.points.get(&tag).copied())?;
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
});
Some(
match point.and_then(|(j, off)| joints.get(j).ok().map(|g| g.transform_point(off))) {
Some(p) => p,
None => base.translation(),
},
)
Some(point.unwrap_or_else(|| base.translation()))
}
/// Where a missile is heading **this frame**: a unit target's live dest attach point (so the
/// path bends as it moves — the client's re-solve per tick), or the ground shot's fixed point.
/// `None` only when a unit target is gone.
fn aim_point(
aim: Aim,
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
joints: &Query<&GlobalTransform>,
) -> Option<Vec3> {
fn aim_point(aim: Aim, units: &AttachPosQuery, joints: &Query<&GlobalTransform>) -> Option<Vec3> {
match aim {
Aim::Unit {
target, dest_tag, ..
@@ -235,23 +241,18 @@ fn aim_point(
fn launch_world_pos(
caster: Entity,
fired: Option<[u8; 4]>,
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
units: &AttachPosQuery,
joints: &Query<&GlobalTransform>,
) -> Option<Vec3> {
let (base, bones) = units.get(caster).ok()?;
let point = bones.and_then(|b| {
fired
let (base, bones, pose) = units.get(caster).ok()?;
let point = bones.zip(pose).and_then(|(b, p)| {
let (bone, offset) = fired
.into_iter()
.chain(MARKER_CASCADE)
.find_map(|ident| b.markers.get(&ident).copied())
.and_then(|(bone, offset)| b.anchor(bone).map(|j| (j, offset)))
.find_map(|ident| b.markers.get(&ident).copied())?;
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
});
Some(
match point.and_then(|(j, off)| joints.get(j).ok().map(|g| g.transform_point(off))) {
Some(p) => p,
None => base.translation(),
},
)
Some(point.unwrap_or_else(|| base.translation()))
}
/// One GO's projectiles parked on their caster, waiting for the release keyframe — the client's
@@ -380,7 +381,7 @@ fn launch_go(
go: &QueuedGo,
launch: Vec3,
commands: &mut Commands,
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
units: &AttachPosQuery,
joints: &Query<&GlobalTransform>,
sounds: &mut MessageWriter<MissileSound>,
impacts: &mut MessageWriter<CastEvent>,
@@ -459,7 +460,7 @@ pub(super) fn spawn_missiles(
fx: Option<ResMut<SpellFx>>,
displays: Option<Res<ItemDisplays>>,
asset_server: Res<AssetServer>,
units: Query<(&GlobalTransform, Option<&BoneAttach>)>,
units: AttachPosQuery,
joints: Query<&GlobalTransform>,
casters: Query<(
&AnimDriver,
@@ -661,7 +662,7 @@ pub(super) fn move_missiles(
mut commands: Commands,
time: Res<Time>,
mut missiles: Query<(Entity, &mut Missile, &mut Transform)>,
units: Query<(&GlobalTransform, Option<&BoneAttach>)>,
units: AttachPosQuery,
joints: Query<&GlobalTransform>,
mut impacts: MessageWriter<CastEvent>,
mut defenses: MessageWriter<DefenseAnim>,
@@ -734,22 +735,22 @@ mod tests {
app.update();
}
/// A caster whose `$CSL` marker joint sits at `hand` (the release launch point).
/// A caster whose `$CSL` marker sits at `hand` (the release launch point) — bone 0 of its
/// pose is seated there, and the read computes through `posed_point` (decision 1355).
fn caster(app: &mut App, hand: Vec3) -> Entity {
let joint = app
let caster = app
.world_mut()
.spawn(GlobalTransform::from_translation(hand))
.id();
app.world_mut()
.spawn((
GlobalTransform::default(),
BoneAttach {
anchors: HashMap::from([(0u16, joint)]),
points: HashMap::new(),
markers: HashMap::from([(*b"$CSL", (0u16, Vec3::ZERO))]),
},
))
.id()
.id();
let pose = benilla_world::testing::test_rig_pose(caster, &[hand]);
app.world_mut().entity_mut(caster).insert(pose);
caster
}
fn go(caster: Entity, target: Entity, awaits_release: bool) -> MissileSpawn {
+29 -13
View File
@@ -156,9 +156,11 @@ pub(super) type MountChildren<'w, 's> = Query<
/// Drive one unit's mount side to the point where the rider can be seated. Spawns the child when
/// it is missing, drops and retries a child built for a display the field has since moved off, and
/// resolves the seat frame once the child's rig is up.
#[allow(clippy::too_many_arguments)] // the seat law's full input set, like its callers
pub(super) fn seat_or_spawn_mount(
commands: &mut Commands,
children: &MountChildren,
poses: &mut Query<&mut RigPose>,
creatures: Option<&super::Creatures>,
unit: Entity,
child: Option<Entity>,
@@ -188,11 +190,16 @@ pub(super) fn seat_or_spawn_mount(
let Ok((true, Some(bones), _)) = children.get(child) else {
return Seat::Wait; // the mount child is still building
};
let Some((joint, offset)) = bones
.points
.get(&0)
.and_then(|&(bone, offset)| bones.anchor(bone).map(|j| (j, offset)))
else {
// The mount's attachment-0 joint, spawned on first demand from the MOUNT's composed pose
// (`RigPose::anchor_for`, decision 1355) — registered into the mount's own `RigPose.anchors`,
// which is what lets the world pass's patch walk find the seat subtree and cascade the rider.
let Some((joint, offset)) = bones.points.get(&0).and_then(|&(bone, offset)| {
poses
.get_mut(child)
.ok()
.and_then(|mut p| p.anchor_for(commands, child, bone))
.map(|j| (j, offset))
}) else {
warn!(
"MOUNTDISPLAYIDNOMOUNTATTACHMENT: display {mount_display} authors no attachment 0 — \
rider stays at the unit matrix"
@@ -274,22 +281,26 @@ fn reseat_rig(commands: &mut Commands, rig: &mut RigPose, frame: Entity, conform
#[allow(clippy::type_complexity)]
pub(super) fn reseat_mounts(
mut commands: Commands,
mut units: Query<
units: Query<
(
Entity,
&NetEntity,
&ObjectStore,
Option<&mut RigPose>,
Option<&AppliedMount>,
Option<&MountChild>,
),
With<super::VisualAttached>,
>,
// One pose query for BOTH sides of the seat — the rider's rig (re-rooted onto the seat) and
// the mount's (its attachment-0 anchor resolves on demand, decision 1355). Split from the
// `units` query because the two sides are different entities out of the same component, and
// borrowed strictly in sequence below.
mut poses: Query<&mut RigPose>,
children: MountChildren,
conforms: Query<(), With<super::conform::ConformNode>>,
creatures: Option<Res<super::Creatures>>,
) {
for (entity, net, store, rig, applied, child) in &mut units {
for (entity, net, store, applied, child) in &units {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
@@ -307,7 +318,7 @@ pub(super) fn reseat_mounts(
// orders it a mount — stamp the field so it can never churn, exactly as the first build
// does, and sweep a child if one ever did reach it (the stamp alone would then re-fire
// every frame, since the settled test asks about the child too).
let Some(mut rig) = rig else {
let Ok(seated_root) = poses.get(entity).map(|r| r.joints_root) else {
if let Some(&MountChild(child)) = child {
if let Ok(mut ec) = commands.get_entity(child) {
ec.despawn();
@@ -328,9 +339,11 @@ pub(super) fn reseat_mounts(
// on its own entity, or on the conform node that tilts it — is where it belongs, and
// asking for a ground frame anyway would orphan a second conform node beside the
// first.
if rig.joints_root != entity && !conforms.contains(rig.joints_root) {
if seated_root != entity && !conforms.contains(seated_root) {
let ground = ground_frame(&mut commands, entity, net, creatures.as_deref());
reseat_rig(&mut commands, &mut rig, ground, false);
if let Ok(mut rig) = poses.get_mut(entity) {
reseat_rig(&mut commands, &mut rig, ground, false);
}
}
if let Some(child) = child {
if let Ok(mut ec) = commands.get_entity(child) {
@@ -347,6 +360,7 @@ pub(super) fn reseat_mounts(
match seat_or_spawn_mount(
&mut commands,
&children,
&mut poses,
creatures.as_deref(),
entity,
child,
@@ -355,8 +369,10 @@ pub(super) fn reseat_mounts(
) {
Seat::Wait => {}
Seat::Frame(anchor) => {
let leaving_conform = conforms.contains(rig.joints_root);
reseat_rig(&mut commands, &mut rig, anchor, leaving_conform);
if let Ok(mut rig) = poses.get_mut(entity) {
let leaving_conform = conforms.contains(seated_root);
reseat_rig(&mut commands, &mut rig, anchor, leaving_conform);
}
commands.entity(entity).insert(AppliedMount(live));
}
Seat::UnitMatrix => {
@@ -111,15 +111,14 @@ impl Standing {
/// give it the attachment-0 anchor the seat hangs from. Returns the child.
fn mount_attaches(&mut self) -> Entity {
let child = self.mount_child().expect("a mount child was ordered");
let joint = self
.app
.world_mut()
.spawn((Transform::default(), Visibility::default(), ChildOf(child)))
.id();
// The seat joint spawns on first demand out of the mount's pose (decision 1355) — the
// test provides the pose, not a hand-built joint.
let pose =
benilla_world::testing::test_rig_pose(child, &[Vec3::ZERO; SEAT_BONE as usize + 1]);
self.app.world_mut().entity_mut(child).insert((
VisualAttached,
pose,
BoneAttach {
anchors: [(SEAT_BONE, joint)].into_iter().collect(),
points: [(0u16, (SEAT_BONE, Vec3::Y))].into_iter().collect(),
markers: Default::default(),
},
@@ -306,7 +305,6 @@ fn a_mount_without_a_seat_leaves_the_body_at_the_unit_matrix() {
s.app.world_mut().entity_mut(child).insert((
VisualAttached,
BoneAttach {
anchors: Default::default(),
points: Default::default(),
markers: Default::default(),
},
@@ -777,13 +777,16 @@ fn reap_matching(
/// so the whole effect — meshes and particle emitters alike — rides the animating bone. The one
/// exception is the kit's **world-plant slot** ([`benilla_formats::WORLD_EFFECT_TAG`], kit field
/// 12): its root is a free world entity at the owner's position/facing/scale, [`WorldPlantFx`].
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
pub(super) fn attach_spell_fx(
mut commands: Commands,
mut units: Query<(
Entity,
&mut FxAttached,
Option<&BoneAttach>,
// The unit's pose buffer: the attach joint spawns on first demand from the composed
// pose (`RigPose::anchor_for`, decision 1355).
Option<&mut benilla_world::rig_anim::RigPose>,
&GlobalTransform,
)>,
fx: Option<Res<SpellFx>>,
@@ -798,7 +801,7 @@ pub(super) fn attach_spell_fx(
return;
};
let now = time.elapsed_secs();
for (unit, mut att, bones, unit_gt) in &mut units {
for (unit, mut att, bones, mut pose, unit_gt) in &mut units {
att.instances.retain_mut(|inst| {
// Self-termination (the cast-release flash ran its span).
if let Some(expires) = inst.expires {
@@ -880,7 +883,9 @@ pub(super) fn attach_spell_fx(
.chain(ATTACH_FALLBACKS)
.find_map(|tag| b.points.get(&tag).copied().map(|p| (tag, p)))
.and_then(|(tag, (bone, offset))| {
b.anchor(bone).map(|joint| (tag, joint, offset))
pose.as_mut()
.and_then(|p| p.anchor_for(&mut commands, unit, bone))
.map(|joint| (tag, joint, offset))
})
});
let ground_anchor = point.is_none_or(|(tag, ..)| tag == 0x13);
+10 -5
View File
@@ -164,7 +164,12 @@ fn spawn_footprints(
time: Res<Time>,
// GlobalTransform for the same reason as the footstep sounds (0441): a mounted unit's steps
// are the MOUNT child's tags, whose local Transform is the seat-relative ~origin.
units: Query<(&NetEntity, &GlobalTransform, Option<&BoneAttach>)>,
units: Query<(
&NetEntity,
&GlobalTransform,
Option<&BoneAttach>,
Option<&benilla_world::rig_anim::RigPose>,
)>,
// The spawner's ROOT (the rider for a mount child): the pool select, the print scale (the
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
parents: Query<&ChildOf>,
@@ -192,7 +197,7 @@ fn spawn_footprints(
let Some(side) = footfall_side(&ev.ident) else {
continue;
};
let Ok((net, transform, attach)) = units.get(ev.entity) else {
let Ok((net, transform, attach, pose)) = units.get(ev.entity) else {
continue;
};
// The ink + dims come from the EVENT's model (the mount for a mounted composite); the
@@ -223,10 +228,10 @@ fn spawn_footprints(
// The planted foot: the event's own marker (bone + offset) through the live joint —
// exactly the shape the missile launch points use. No marker/joint = the unit origin.
let foot = attach
.and_then(|a| {
.zip(pose)
.and_then(|(a, p)| {
let (bone, offset) = a.markers.get(&ev.ident).copied()?;
let joint = a.anchor(bone)?;
Some(joints.get(joint).ok()?.transform_point(offset))
p.posed_point(joints.get(p.joints_root).ok()?, bone, offset)
})
.unwrap_or_else(|| transform.translation());
// The reference's 50-yd radius about the local player (2500 yd², self always passes).
+8
View File
@@ -235,6 +235,14 @@ pub fn run(build: BuildId) -> AppExit {
let mut app = App::new();
// The stamp is plain data from here on — the panel footer and preflight banner read it back.
app.insert_resource(build);
// Pin Bevy's static-scene transform tracking ON (decision 1356). At the default threshold
// `mark_dirty_trees` re-decides per frame by counting every changed-Transform row and every
// tree row — two full scans of the very population the tracking exists to skip. This scene
// is provably static-heavy (terrain/WMO batches and parked units barely move), so the
// auto-tuner can only ever confirm what this line states. The guard it removes bites only
// when MOST rows move in one frame (a load burst into a near-empty world), where
// dirty-marking briefly costs more than it saves — a loading-band term, accepted knowingly.
app.insert_resource(bevy::transform::systems::StaticTransformOptimizations::enabled());
// …and one startup line says which build produced this log. Registered HERE, beside the stamp
// it prints, rather than in `preflight` where it sat until decision 1179: **which build is
// this** is the first thing a bug report from someone else's machine has to establish, and a
+16 -5
View File
@@ -358,6 +358,7 @@ pub(crate) fn drive_nameplates(
// The overhead-anchor inputs ([`overhead_anchor`]) — the spawn-frame seat only.
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
Query<&OverheadFallback>,
Query<&GlobalTransform>,
Query<(), With<crate::entities::mount::MountChild>>,
@@ -417,7 +418,7 @@ pub(crate) fn drive_nameplates(
}
// The billboard facing: screen-aligned (the camera's own rotation), this frame's camera.
let facing = cam_tf.rotation;
let mut seen: Vec<Entity> = Vec::new();
let mut seen = bevy::ecs::entity::EntityHashSet::default();
for (entity, net, guid, tf, store, is_self, drawn) in &units {
// **A name needs a body to float over.** The director, inside Caverns of Time: the Tanaris
// mobs 222 yd overhead had correctly stopped drawing, and their names went on hanging in
@@ -530,6 +531,7 @@ pub(crate) fn drive_nameplates(
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
let scale = height_scale(anchor.y - tf.translation.y);
let place = Transform {
@@ -537,7 +539,7 @@ pub(crate) fn drive_nameplates(
rotation: facing,
scale: Vec3::splat(scale),
};
seen.push(entity);
seen.insert(entity);
match plates.live.get(&entity) {
Some((_, l, c)) if *l == lines && *c == color => {}
stale => {
@@ -588,6 +590,7 @@ fn place_nameplates(
mut plate_tfs: Query<(&mut Transform, &mut GlobalTransform), With<NamePlate>>,
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
Query<&OverheadFallback>,
Query<&GlobalTransform, Without<NamePlate>>, // disjoint from the plate global write
Query<(), With<crate::entities::mount::MountChild>>,
@@ -610,11 +613,19 @@ fn place_nameplates(
else {
continue; // spawned this frame and not yet flushed, or unit despawning — next frame
};
let raw = overhead_anchor(unit, tf, &anchor_q.0, &anchor_q.1, &anchor_q.2, &anchor_q.3);
let raw = overhead_anchor(
unit,
tf,
&anchor_q.0,
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
// The mounted rock damp: seat the plate at mean + ROCK_KEEP·residual instead of the raw
// gallop-riding anchor. Everything is in root-relative offsets, so the damped plate still
// tracks a 7 yd/s run with zero world-space lag (the on-foot trailing lesson).
let anchor = if anchor_q.3.contains(unit) {
let anchor = if anchor_q.4.contains(unit) {
let off = raw - tf.translation;
let mean = rock.entry(unit).or_insert(off);
if (off - *mean).length_squared() > ROCK_SNAP * ROCK_SNAP {
@@ -646,7 +657,7 @@ fn place_nameplates(
*pglobal = GlobalTransform::from(place);
}
// Dismounted or despawned units drop their damping state (a re-mount starts fresh).
rock.retain(|e, _| anchor_q.3.contains(*e));
rock.retain(|e, _| anchor_q.4.contains(*e));
}
/// Registers the plate driver (Update — the entity/mesh churn stays in the schedule the mesh
+96 -3
View File
@@ -427,34 +427,112 @@ const GROUND_CLAMP_DOWN: f32 = 4.0;
/// Z untouched (no pop). Runs right after [`sample_splines`] so a walker's freshly-sampled XY+Z is
/// what we re-ground; an idle unit (no spline) is re-grounded in place each frame, which also catches
/// it once its terrain collider loads.
#[allow(clippy::type_complexity)] // one Bevy system's full input set
pub(in crate::net) fn ground_clamp_creatures(
world: benilla_world::collision::WorldCollision,
mut commands: Commands,
mut q: Query<(
Entity,
&NetEntity,
Option<&Spline>,
&mut Transform,
Option<&mut GroundClamped>,
Has<CreatureSwimming>,
)>,
) {
for (net, spline, mut t, swimming) in &mut q {
let cost = clamp_cost_enabled();
let t0 = cost.then(std::time::Instant::now);
let (mut visited, mut skipped, mut held, mut cast, mut hit_n, mut moved) =
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
for (entity, net, spline, mut t, mut clamped, swimming) in &mut q {
visited += 1;
if net.kind != EntityKind::Unit {
skipped += 1;
continue; // players + GameObjects own their Z
}
if spline.is_some_and(|s| !s.grounded) {
skipped += 1;
continue; // a flying path is authoritative on Z
}
if swimming {
skipped += 1;
continue; // in-liquid: the wire Z is the creature's swim depth
}
// The cast gate (decision 1357): a unit standing exactly where its last cast stood, with
// a Y nothing has touched since that cast wrote it, cannot get a different answer — the
// ray's inputs are its position and the walking colliders, and a *moving* floor (a lift,
// a transport deck) reaches its rider through the wire's own position writes, which break
// the bit-equality below and re-arm the cast. A MISS never caches (`hit` gates it), so a
// unit standing on a tile whose collider hasn't streamed in keeps asking until it lands —
// the original re-ground-in-place behaviour, kept exactly where it is load-bearing.
let xz = [t.translation.x, t.translation.z];
if let Some(c) = clamped.as_deref() {
if c.hit && c.xz == xz && c.y_written == t.translation.y {
held += 1;
continue;
}
}
let origin = t.translation + Vec3::Y * GROUND_CLAMP_UP;
let reach = GROUND_CLAMP_UP + GROUND_CLAMP_DOWN;
cast += 1;
// The one-sided down-ray (decision 0970): a creature grounds like the player grounds — a
// face whose winding points away is no floor, or an idle NPC would stand mid-air on the
// very shell face the player mover now falls through.
if let Some(hit) = world.ray_body(origin, Dir3::NEG_Y, reach) {
t.translation.y = origin.y - hit.distance; // the down-ray's hit point Y = feet on the surface
let hit = world.ray_body(origin, Dir3::NEG_Y, reach);
if let Some(hit) = &hit {
hit_n += 1;
let y = origin.y - hit.distance; // the down-ray's hit point Y = feet on the surface
// Exact bit equality is deliberate, not a sloppy float compare: the question is
// "would the write change anything" — Bevy's change detection fires on the
// DerefMut regardless of value, so writing an equal Y every frame marked every
// standing creature's transform subtree dirty. An epsilon would answer a
// different question: a real sub-epsilon ground shift must still land.
if y != t.translation.y {
moved += 1;
t.translation.y = y;
}
}
let state = GroundClamped {
xz,
y_written: t.translation.y,
hit: hit.is_some(),
};
match clamped.as_deref_mut() {
Some(c) => *c = state,
None => {
commands.entity(entity).insert(state);
}
}
}
if let Some(t0) = t0 {
// `WOW_CLAMP_COST=1` — the premise check for gating this sweep (0732 slice S), which
// 0732 sized at 0.42 traced against avian's `SpatialQuery::cast_ray`. **0970 replaced
// that ray** with a broadphase-plus-BVH gather, so the recorded price measures a
// mechanism that no longer exists and the lane is honestly unsized until this prints.
//
// The two fields that decide the two possible gates, and their kill conditions:
// · `cast` vs `visited` — how much of the walk even reaches a ray. A movement gate can
// only ever save the `cast` share; if `ms` is small this whole item is dead.
// · `moved` vs `hit` — how often the write actually CHANGES Y. Every hit writes
// `Transform` unconditionally today, and a write dirties the transform subtree whether
// or not the value differs. If `moved` is a small fraction of `hit`, an equality gate
// on the write is the cheap half and needs no movement tracking at all.
//
// Per 0734's law (~10.5 ns per row visit), the walk itself is never the cost here: at ~800
// units it is ~8 µs. Only `ms` justifies the slice — quote it, not the counts.
eprintln!(
"[clamp-cost] visited={visited} skipped={skipped} held={held} cast={cast} hit={hit_n} moved={moved} ms={:.3}",
t0.elapsed().as_secs_f32() * 1000.0
);
}
}
/// Whether the ground-clamp meter is armed (`WOW_CLAMP_COST`). Read once, then a relaxed bool.
fn clamp_cost_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_CLAMP_COST").is_some())
}
/// What [`mark_swimming_creatures`] reads per unit: identity, kind, pose, its own collision height
@@ -489,6 +567,21 @@ const CREATURE_SWIM_EXIT_BAND: f32 = 1.0 / 36.0;
#[derive(Component)]
pub(crate) struct CreatureSwimming;
/// The last ground clamp this unit took (decision 1357) — [`ground_clamp_creatures`]' cast gate.
/// All three fields compare by bit: the gate's question is "are the ray's inputs identical to the
/// cast that produced this", never "close enough".
#[derive(Component)]
pub(in crate::net) struct GroundClamped {
/// Where the last cast stood.
xz: [f32; 2],
/// The Y standing after that cast (written or left) — an external write (the wire moving the
/// unit, a lift carrying it) breaks equality and re-arms the cast.
y_written: f32,
/// Only a HIT caches: a miss keeps casting, so a tile whose collider streams in late still
/// catches its standing units.
hit: bool,
}
/// Maintain [`CreatureSwimming`] on `Unit` creatures from the water over their feet (module docs on
/// the INTERIM boundary). Runs chained before [`ground_clamp_creatures`] so a fresh mark exempts the
/// clamp the same frame (Bevy inserts the deferred-command sync point for the chain).
+10 -5
View File
@@ -258,6 +258,9 @@ fn build_markers(
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
index: Res<GuidIndex>,
anchors: Query<&BoneAttach>,
// The unit's pose buffer: the overhead joint the marker parents under spawns on first
// demand (`RigPose::anchor_for`, decision 1355).
mut poses: Query<&mut benilla_world::rig_anim::RigPose>,
mounts: Query<(), With<crate::entities::mount::MountChild>>,
time: Res<Time>,
) {
@@ -290,11 +293,13 @@ fn build_markers(
} else {
ATTACH_OVERHEAD
};
let Some((joint, offset)) = anchor
.points
.get(&slot)
.and_then(|&(bone, offset)| Some((anchor.anchor(bone)?, offset)))
else {
let Some((joint, offset)) = anchor.points.get(&slot).and_then(|&(bone, offset)| {
poses
.get_mut(unit)
.ok()
.and_then(|mut p| p.anchor_for(&mut commands, unit, bone))
.map(|joint| (joint, offset))
}) else {
debug!(
"quest_markers: {:#x} has no slot-{slot} attachment — marker never parents (invisible, the client's own behavior)",
marker.npc
+6 -1
View File
@@ -105,11 +105,12 @@ fn mark_place<F: bevy::ecs::query::QueryFilter>(
plates: &Nameplates,
facing: Quat,
attach: &Query<&BoneAttach>,
poses: &Query<&benilla_world::rig_anim::RigPose>,
fallback: &Query<&OverheadFallback>,
globals: &Query<&GlobalTransform, F>,
mounts: &Query<(), With<crate::entities::mount::MountChild>>,
) -> Transform {
let anchor = overhead_anchor(unit, tf, attach, fallback, globals, mounts);
let anchor = overhead_anchor(unit, tf, attach, poses, fallback, globals, mounts);
// `scale` (the name's world height-law) drives ONLY the seat — the marker sits one
// line-pitch above the top of the name block. The quad SIZE is a fixed world unit (§6:
// `scale` never enters the marker geometry), not `scale`-scaled.
@@ -141,6 +142,7 @@ fn drive_raid_marks(
camera: Query<&Transform, With<WorldCamera>>,
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
Query<&OverheadFallback>,
Query<&GlobalTransform>,
Query<(), With<crate::entities::mount::MountChild>>,
@@ -203,6 +205,7 @@ fn drive_raid_marks(
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
)
});
let marker = commands
@@ -228,6 +231,7 @@ fn place_raid_marks(
mut mark_tfs: Query<(&mut Transform, &mut GlobalTransform), With<RaidMarkBillboard>>,
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
Query<&OverheadFallback>,
Query<&GlobalTransform, Without<RaidMarkBillboard>>,
Query<(), With<crate::entities::mount::MountChild>>,
@@ -252,6 +256,7 @@ fn place_raid_marks(
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
*mtf = place;
*mglobal = GlobalTransform::from(place);
+21
View File
@@ -626,6 +626,12 @@ pub(crate) fn feed_containers(
return;
};
let memory = memory.get(&script);
// `WOW_FEED_COST=1` — the premise counter for gating this feed (the 2026-08-15 ledger's #8):
// the whole snapshot+diff below runs per frame; before an epoch gate is built, this says what
// that actually costs on a real bag population. Printed once a second.
let cost_t0 = std::env::var_os("WOW_FEED_COST")
.is_some()
.then(std::time::Instant::now);
// The frame's atomic clock pair (`crate::ui_script::UiClock`): the slot resolves read the
// cooldown store and convert triples through ONE coherent instant, so a running cooldown's
// pushed start is frame-stable (the resource's own doc).
@@ -922,6 +928,21 @@ pub(crate) fn feed_containers(
script.set_has_key(key);
}
if let Some(t0) = cost_t0 {
use std::sync::atomic::{AtomicU64, Ordering};
static SUM_NS: AtomicU64 = AtomicU64::new(0);
static N: AtomicU64 = AtomicU64::new(0);
let sum = SUM_NS.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
let n = N.fetch_add(1, Ordering::Relaxed) + 1;
if n.is_multiple_of(60) {
let slots: usize = fresh.values().map(|c| c.slots.len()).sum();
eprintln!(
"[feed-cost] ms={:.3} slots={slots} containers={}",
sum as f64 / n as f64 / 1e6,
fresh.len(),
);
}
}
apply_container_source(
&mut script,
memory,
@@ -9,8 +9,11 @@
//! that actually change. That is what [`tooltip_change_costs_a_whole_ui_solve`] drives, and with it
//! the cost reproduces headlessly and can be measured against a fix.
//!
//! Run with `cargo test --release -p benilla -- --ignored --nocapture resolve_bench`;
//! `WOW_LAYOUT_PROF=1` adds the per-solve shape (rounds, graph size, frame-vs-region split).
//! Run with `cargo test --release -p benilla-app --lib -- --ignored --nocapture resolve_bench`;
//! `WOW_LAYOUT_PROF=1` adds the per-solve shape — and since decision 1350 its `solved=`/`swept=`
//! columns are the ones to read: they are the SCOPE of the solve, and 1350's whole claim is that
//! they stay a handful while `frames=`/`anchored=` grow without bound. A solve whose scope tracks
//! the graph again is precisely the regression this file exists to catch.
use std::time::Instant;
@@ -155,3 +158,98 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
"after a settled frame nothing may still be waiting to be measured"
);
}
/// The perf half of decision 1350, asserted as a COUNT: a tooltip content change must cost a
/// solve whose SCOPE is tooltip-sized, on a UI of 3,000 frames and 8,800 anchored regions.
///
/// Milliseconds are deliberately not the gate here. This exact cost has now been chased three
/// times — 0735 (the measure cache), 0771 (the double solve), and this — and on two of those the
/// ms column was contaminated by machine state (0713's stall class) while the work counts were
/// clean. The bound is also what makes the regression *reportable*: 1350's pin found the sweep's
/// cost tripling with no counter behind it, because `regions_swept` counted the entries the sweep
/// skipped as well as the ones it resolved.
#[test]
fn a_tooltip_content_change_solves_a_tooltip_sized_scope() {
let mut s = settled_default_ui();
install_changing_tooltip(&s, "ScopeSizeOwner", "scope_size_change");
// Settle the newly-created owner and the tooltip's first content — a birth is legitimately a
// wide solve (a new node has no cached rect to trust).
for _ in 0..6 {
s.run("scope_size_change()").unwrap();
app_frame(&mut s);
}
for step in 0..10 {
s.run("scope_size_change()").unwrap();
app_frame(&mut s);
let (frames, regions) = s.layout_last_scope();
assert!(
frames < 100 && regions < 500,
"step {step}: a tooltip content change solved {frames} frames and swept {regions} \
regions — the scope is tracking the graph, not the change (measured at the time of \
writing: 5 frames, 67 regions, against 3,003 and 8,821 in the graph)"
);
}
}
/// The scoped resolve's falsifier (decision 1350): on the shipped UI, mid-sweep, a solve that
/// touched only the dirty closure must produce **exactly** the rects a from-scratch whole-graph
/// solve produces.
///
/// This is the gate for the whole change, and it is deliberately run on the frames that never
/// settle — a hover sweep changes content every frame, so `WOW_LAYOUT_VERIFY`'s settled-frame
/// comparison never fires there, and those are the only frames the scope was built for. The
/// comparison is `extract()`, not an internal rect map: a stale rect that no quad carries is not a
/// bug, and a stale rect that one does carry is the bug in the form the screen would show it.
#[test]
fn a_scoped_resolve_reproduces_the_whole_graph_solve() {
let mut s = settled_default_ui();
install_changing_tooltip(&s, "ScopeOwner", "scope_change");
for step in 0..12 {
// A content change, resolved the way the app resolves it — scoped.
s.run("scope_change()").unwrap();
app_frame(&mut s);
let scoped = s.extract();
// The same model, re-solved from nothing but its inputs.
s.force_full_layout_resolve();
app_frame(&mut s);
let full = s.extract();
assert!(
scoped == full,
"step {step}: the scoped resolve and the whole-graph resolve disagree — a node the \
scope judged clean did move. {} quads vs {}",
scoped.len(),
full.len(),
);
}
}
/// The steady-state cost of the per-frame measure sweep alone: the UI settled, every FontString
/// measured, every key a cache hit — what does *asking* cost? It is paid on EVERY frame, quiet
/// ones included, which made it a suspect on 1350's successor list — and this bench closed it:
/// 0.046 ms/sweep on the full shipped UI in release (2026-08-15; the ~1.0 ms once quoted was a
/// dev-build number). Kept as the lane's regression watch: if this climbs toward a milli-
/// second, the sweep's per-row clones/hash grew or the cache stopped hitting.
#[test]
#[ignore]
fn measure_sweep_steady_state_cost() {
let mut s = settled_default_ui();
// One more settle so the sweep below is provably pure cache hits.
let residual = answer_measures(&mut s);
let t = Instant::now();
let mut asked = 0usize;
const N: u32 = 200;
for _ in 0..N {
asked += s.fontstrings_needing_measure().len();
}
println!(
"measure sweep steady state: {:.4} ms/sweep ({} sweeps, {} residual requests, {} pre-settle)",
ms(t) / f64::from(N),
N,
asked,
residual,
);
}
+2
View File
@@ -443,6 +443,7 @@ fn drive_vplates(
// The overhead-anchor inputs ([`overhead_anchor`]).
anchor_q: (
Query<&BoneAttach>,
Query<&benilla_world::rig_anim::RigPose>,
Query<&OverheadFallback>,
Query<&GlobalTransform>,
Query<(), With<crate::entities::mount::MountChild>>,
@@ -573,6 +574,7 @@ fn drive_vplates(
&anchor_q.1,
&anchor_q.2,
&anchor_q.3,
&anchor_q.4,
);
let Some(screen) =
crate::ui_pass::project_overlay(cam, &cam_tf, anchor + Vec3::Y * PLATE_LIFT, viewport)
+10 -1
View File
@@ -125,7 +125,16 @@ fn world_art_image(chain: BlpMipChain, wrap: (bool, bool)) -> Image {
TextureDimension::D2,
mip0,
color_format(),
RenderAssetUsages::default(),
// `RENDER_WORLD`: the render world TAKES the mip chain on extract instead of cloning it and
// leaving a main-world copy resident for the asset's life (bevy_render `render_asset.rs` —
// the `asset_usage == RENDER_WORLD` branch moves, every other usage clones). This is the
// variant carrying every terrain layer, model albedo and WMO albedo in the game, so the
// default usage was paying a full mip-chain memcpy on every landing frame and holding a
// second copy of the whole world's art in system RAM. Nothing reads a world texture
// main-side (the minimap, colliders and clutter all derive from other sources) — same
// reasoning, and now the same usage, as the terrain tile arrays (`terrain.rs`) and the
// Sprite/Effect/PointSprite variants below.
RenderAssetUsages::RENDER_WORLD,
);
image.data = Some(data);
image.texture_descriptor.mip_level_count = levels;
+291 -102
View File
@@ -27,11 +27,20 @@
//! visits them, so every tie-break downstream (`down_ray_claim`'s first-wins, `footprint_sample`'s
//! later-wins) resolves exactly as it did before the index existed.
/// A triangle whose XY AABB touches more cells than this is not binned — it goes to
/// [`ColumnGrid::spanning`] and is a candidate for every query. Big floor slabs are the case: at a
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
/// A triangle whose XY AABB touches more cells than this is not binned at the level being built —
/// it is handed to the next level down (the coarse grid), and only a face too large for *that*
/// becomes a [`ColumnGrid::spanning`] always-tested residue. Big floor slabs are the case: at a
/// ~1-triangle-per-cell sizing a 100-yd slab would otherwise be copied into hundreds of cells.
const MAX_SPAN_CELLS: u32 = 32;
/// Below this many oversized faces, a second grid is not worth its own allocation pair and lookup —
/// they stay an always-tested list. Well under the per-query counts 1351 measured (~480), so the
/// pin's groups all take the coarse level.
const MIN_COARSE_TRIS: usize = 24;
/// Never build cells finer than this (yd) — a degenerate face set (all triangles stacked in one
/// spot) would otherwise ask for an unbounded grid.
const MIN_CELL: f32 = 0.25;
@@ -40,11 +49,11 @@ const MIN_CELL: f32 = 0.25;
/// 500-yd dungeon group costs tens of KB of index, not tens of MB.
const MAX_CELLS: usize = 1 << 16;
/// A face set's uniform XY grid — CSR (`starts`/`items`), so one allocation pair per group rather
/// than a `Vec` per cell.
/// One uniform XY grid over some set of triangles — CSR (`starts`/`items`), so one allocation
/// pair per level rather than a `Vec` per cell.
#[derive(Debug, Clone)]
pub struct ColumnGrid {
/// Grid origin (the face set's XY minimum).
struct Level {
/// Grid origin (the indexed set's XY minimum).
min: [f32; 2],
/// 1 / cell size, in cells per yard.
inv_cell: f32,
@@ -52,12 +61,185 @@ pub struct ColumnGrid {
ny: u32,
/// CSR row offsets into [`Self::items`], length `nx * ny + 1`.
starts: Vec<u32>,
/// Triangle indices, ascending within each cell.
/// **Global** triangle indices, ascending within each cell.
items: Vec<u32>,
/// Triangles too large to bin (see [`MAX_SPAN_CELLS`]), ascending — candidates for every query.
}
/// A face set's column index: **two grids and a residue**.
///
/// One grid cannot serve a dungeon group. Sizing cells at ~1 triangle each is right for the ~11–16k
/// small faces, and catastrophic for the handful of 40–100 yd floor slabs sharing the group: a slab
/// would be copied into hundreds of cells, so [`MAX_SPAN_CELLS`] sets it aside instead — and
/// "aside" used to mean *tested on every query*.
///
/// 1351 measured what that cost at the LBRS pin: **99.26 %** of all triangles tested per frame were
/// those set-aside slabs — 93,624 of 94,322, against 698 that came from a cell list. The index was
/// accelerating the 0.7 %.
///
/// So the oversized set gets its own grid, sized from its own extent and its own count (0711's
/// named fix, deferred there and confirmed by 1351). Cells at that level are ~an order of magnitude
/// wider, which is exactly what a slab needs to bin. Whatever is still too big for the coarse level
/// — a face spanning the whole group — falls to [`Self::spanning`] and keeps the old always-tested
/// behaviour, which is now a residue rather than the main term.
///
/// **Two levels, not recursion.** A recursive grid would need a boxed iterator per query on the
/// hottest path in the interior lane; two explicit levels keep [`ColumnGrid::candidates`] a
/// three-way merge of three ascending *slices* — no allocation, no indirection, and the
/// superset+ascending invariants hold at each level independently.
#[derive(Debug, Clone)]
pub struct ColumnGrid {
/// The ~1-triangle-per-cell grid over the ordinary faces.
fine: Level,
/// The coarse grid over the faces `fine` set aside. `None` when there were too few of them to
/// be worth a second index — then they are all in [`Self::spanning`], as before.
coarse: Option<Level>,
/// Faces too large to bin at **either** level, ascending — candidates for every query.
spanning: Vec<u32>,
}
/// `WOW_COLUMN_COST=1`: how many triangles a column query actually tests, split by **source** —
/// `binned` (the fine level's cell), `coarse` (the coarse level's cell), and `spanning` (the
/// residue too large for either, tested on every query).
///
/// This is 0711's residual, and the counter that confirmed it. That hunt cut the lane from ~1.2 M
/// triangle tests/frame to an 88 k remainder, wrote down what the remainder was — big floor slabs,
/// too large to bin, merged into every column query — named the fix, and left it. 1351 measured the
/// remainder three weeks later: **99.26 %** of tested triangles were that set, and `resolve_ms` was
/// still 1.14, byte for byte the number 0711 recorded, because nothing had touched the file.
///
/// The counter stays as the **regression watch** on the two-level index: `spanning_pct` is now the
/// residue's share and should read near zero. If it climbs, some face set has a slab too big for
/// even the coarse level and the lane is quietly reverting to 0711's shape.
static COLUMN_QUERIES: AtomicU64 = AtomicU64::new(0);
static COLUMN_BINNED: AtomicU64 = AtomicU64::new(0);
static COLUMN_COARSE: AtomicU64 = AtomicU64::new(0);
static COLUMN_SPANNING: AtomicU64 = AtomicU64::new(0);
/// Whether the column-query meter is armed (`WOW_COLUMN_COST`). Read once, then a relaxed bool.
pub fn column_cost_enabled() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_COLUMN_COST").is_some())
}
/// Take and zero the column-query counters: `(queries, binned, coarse, spanning)` triangles tested.
///
/// One caller per frame — the meter that prints them — so the numbers are always per-frame.
pub fn take_column_query_stats() -> (u64, u64, u64, u64) {
(
COLUMN_QUERIES.swap(0, Ordering::Relaxed),
COLUMN_BINNED.swap(0, Ordering::Relaxed),
COLUMN_COARSE.swap(0, Ordering::Relaxed),
COLUMN_SPANNING.swap(0, Ordering::Relaxed),
)
}
impl Level {
/// Index `ids` (ascending, global triangle indices) into one uniform grid.
///
/// Returns the level plus the **overflow**: the ids whose XY AABB spans more than
/// [`MAX_SPAN_CELLS`] cells at this level's sizing, ascending, for the next level down.
/// `None` when the set has no finite extent to grid.
fn build(
ids: &[u32],
xy_aabb: &impl Fn(usize) -> ([f32; 2], [f32; 2]),
) -> Option<(Self, Vec<u32>)> {
let (mut min, mut max) = ([f32::MAX; 2], [f32::MIN; 2]);
for &i in ids {
let (lo, hi) = xy_aabb(i as usize);
for a in 0..2 {
min[a] = min[a].min(lo[a]);
max[a] = max[a].max(hi[a]);
}
}
let (w, h) = ((max[0] - min[0]).max(0.0), (max[1] - min[1]).max(0.0));
if !w.is_finite() || !h.is_finite() || (w <= 0.0 && h <= 0.0) {
return None;
}
// Aim for ~one triangle per cell, then clamp both ways: never finer than MIN_CELL, never
// more than MAX_CELLS cells. Sizing from THIS set's own count and extent is what makes the
// coarse level coarse: a few hundred slabs over the same group span give cells ~an order of
// magnitude wider than the fine level's, which is what lets a slab bin at all.
let target_cells = ids.len().clamp(1, MAX_CELLS) as f32;
let area = (w * h).max(f32::MIN_POSITIVE);
let mut cell = (area / target_cells).sqrt().max(MIN_CELL);
let (mut nx, mut ny) = dims(w, h, cell);
while nx as usize * ny as usize > MAX_CELLS {
cell *= 2.0;
(nx, ny) = dims(w, h, cell);
}
let inv_cell = 1.0 / cell;
let cells = nx as usize * ny as usize;
// Pass 1 — count per cell, and set the oversized triangles aside for the next level.
let mut counts = vec![0u32; cells + 1];
let mut overflow = Vec::new();
let span_of = |i: u32| -> Option<(u32, u32, u32, u32)> {
let (lo, hi) = xy_aabb(i as usize);
let x0 = cell_of(lo[0], min[0], inv_cell, nx);
let x1 = cell_of(hi[0], min[0], inv_cell, nx);
let y0 = cell_of(lo[1], min[1], inv_cell, ny);
let y1 = cell_of(hi[1], min[1], inv_cell, ny);
let touched = (x1 - x0 + 1) * (y1 - y0 + 1);
(touched <= MAX_SPAN_CELLS).then_some((x0, x1, y0, y1))
};
for &i in ids {
match span_of(i) {
Some((x0, x1, y0, y1)) => {
for y in y0..=y1 {
for x in x0..=x1 {
counts[(y * nx + x) as usize] += 1;
}
}
}
None => overflow.push(i),
}
}
// Prefix sum → CSR starts.
let mut starts = vec![0u32; cells + 1];
let mut acc = 0u32;
for c in 0..cells {
starts[c] = acc;
acc += counts[c];
}
starts[cells] = acc;
// Pass 2 — fill. `ids` is ascending, so each cell fills in ascending triangle order, which
// is the order guarantee the tie-breaks downstream rest on.
let mut items = vec![0u32; acc as usize];
let mut cursor = starts.clone();
for &i in ids {
if let Some((x0, x1, y0, y1)) = span_of(i) {
for y in y0..=y1 {
for x in x0..=x1 {
let c = (y * nx + x) as usize;
items[cursor[c] as usize] = i;
cursor[c] += 1;
}
}
}
}
Some((
Self {
min,
inv_cell,
nx,
ny,
starts,
items,
},
overflow,
))
}
/// This level's own cell list for the column at `(x, y)` — ascending, possibly empty.
fn cell(&self, x: f32, y: f32) -> &[u32] {
let cx = cell_of(x, self.min[0], self.inv_cell, self.nx);
let cy = cell_of(y, self.min[1], self.inv_cell, self.ny);
let c = (cy * self.nx + cx) as usize;
&self.items[self.starts[c] as usize..self.starts[c + 1] as usize]
}
}
impl ColumnGrid {
/// Build the index for `count` triangles, given each one's XY AABB by index.
///
@@ -69,112 +251,63 @@ impl ColumnGrid {
if count < MIN_TRIS {
return None;
}
let (mut min, mut max) = ([f32::MAX; 2], [f32::MIN; 2]);
for i in 0..count {
let (lo, hi) = xy_aabb(i);
for a in 0..2 {
min[a] = min[a].min(lo[a]);
max[a] = max[a].max(hi[a]);
}
}
let (w, h) = ((max[0] - min[0]).max(0.0), (max[1] - min[1]).max(0.0));
if !w.is_finite() || !h.is_finite() || (w <= 0.0 && h <= 0.0) {
return None;
}
// Aim for ~one triangle per cell, then clamp both ways: never finer than MIN_CELL, never
// more than MAX_CELLS cells.
let target_cells = count.clamp(1, MAX_CELLS) as f32;
let area = (w * h).max(f32::MIN_POSITIVE);
let mut cell = (area / target_cells).sqrt().max(MIN_CELL);
let (mut nx, mut ny) = dims(w, h, cell);
while nx as usize * ny as usize > MAX_CELLS {
cell *= 2.0;
(nx, ny) = dims(w, h, cell);
}
let inv_cell = 1.0 / cell;
let cells = nx as usize * ny as usize;
let all: Vec<u32> = (0..count as u32).collect();
let (fine, oversized) = Level::build(&all, &xy_aabb)?;
// Pass 1 — count per cell, and set the oversized triangles aside.
let mut counts = vec![0u32; cells + 1];
let mut spanning = Vec::new();
let span_of = |i: usize| -> Option<(u32, u32, u32, u32)> {
let (lo, hi) = xy_aabb(i);
let x0 = cell_of(lo[0], min[0], inv_cell, nx);
let x1 = cell_of(hi[0], min[0], inv_cell, nx);
let y0 = cell_of(lo[1], min[1], inv_cell, ny);
let y1 = cell_of(hi[1], min[1], inv_cell, ny);
let touched = (x1 - x0 + 1) * (y1 - y0 + 1);
(touched <= MAX_SPAN_CELLS).then_some((x0, x1, y0, y1))
// The oversized set gets its own grid when there are enough of them to pay for one. What
// *that* level cannot bin stays an always-tested residue — the old behaviour, now applied
// to a set 1351 predicts is tiny rather than to 99 % of the traffic.
let (coarse, spanning) = if oversized.len() >= MIN_COARSE_TRIS {
match Level::build(&oversized, &xy_aabb) {
Some((level, residue)) => (Some(level), residue),
None => (None, oversized),
}
} else {
(None, oversized)
};
for i in 0..count {
match span_of(i) {
Some((x0, x1, y0, y1)) => {
for y in y0..=y1 {
for x in x0..=x1 {
counts[(y * nx + x) as usize] += 1;
}
}
}
None => spanning.push(i as u32),
}
}
// Prefix sum → CSR starts.
let mut starts = vec![0u32; cells + 1];
let mut acc = 0u32;
for c in 0..cells {
starts[c] = acc;
acc += counts[c];
}
starts[cells] = acc;
// Pass 2 — fill. Ascending `i` fills each cell in ascending triangle order, which is the
// order guarantee the tie-breaks downstream rest on.
let mut items = vec![0u32; acc as usize];
let mut cursor = starts.clone();
for i in 0..count {
if let Some((x0, x1, y0, y1)) = span_of(i) {
for y in y0..=y1 {
for x in x0..=x1 {
let c = (y * nx + x) as usize;
items[cursor[c] as usize] = i as u32;
cursor[c] += 1;
}
}
}
}
Some(Self {
min,
inv_cell,
nx,
ny,
starts,
items,
fine,
coarse,
spanning,
})
}
/// The triangles that can own the column at `(x, y)` — a superset, in ascending index order.
pub fn candidates(&self, x: f32, y: f32) -> ColumnCandidates<'_> {
let cx = cell_of(x, self.min[0], self.inv_cell, self.nx);
let cy = cell_of(y, self.min[1], self.inv_cell, self.ny);
let c = (cy * self.nx + cx) as usize;
let (lo, hi) = (self.starts[c] as usize, self.starts[c + 1] as usize);
let binned = self.fine.cell(x, y);
let coarse = self.coarse.as_ref().map_or(&[][..], |c| c.cell(x, y));
if column_cost_enabled() {
COLUMN_QUERIES.fetch_add(1, Ordering::Relaxed);
COLUMN_BINNED.fetch_add(binned.len() as u64, Ordering::Relaxed);
COLUMN_COARSE.fetch_add(coarse.len() as u64, Ordering::Relaxed);
COLUMN_SPANNING.fetch_add(self.spanning.len() as u64, Ordering::Relaxed);
}
ColumnCandidates {
binned: &self.items[lo..hi],
binned,
coarse,
spanning: &self.spanning,
}
}
/// Index size in triangle slots — the memory the acceleration costs, for the load-time log.
pub fn slots(&self) -> usize {
self.items.len() + self.spanning.len()
self.fine.items.len()
+ self.coarse.as_ref().map_or(0, |c| c.items.len())
+ self.spanning.len()
}
}
/// Ascending merge of one cell's triangles with the always-tested oversized ones. Both inputs are
/// ascending, so this is a two-pointer walk and the output order matches a linear scan exactly.
/// Ascending merge of the three sources a column can draw from: the fine level's cell, the coarse
/// level's cell, and the always-tested residue. All three are ascending, so this is a three-pointer
/// walk and the output order matches a linear scan exactly — which is the guarantee every downstream
/// tie-break (`down_ray_claim`'s first-wins, `footprint_sample`'s later-wins) rests on.
///
/// A triangle appears in exactly one source by construction: a face is binned at the fine level, or
/// handed down to the coarse level, or in the residue — never two. So the merge cannot duplicate.
pub struct ColumnCandidates<'a> {
binned: &'a [u32],
coarse: &'a [u32],
spanning: &'a [u32],
}
@@ -182,16 +315,22 @@ impl Iterator for ColumnCandidates<'_> {
type Item = usize;
fn next(&mut self) -> Option<usize> {
let take_binned = match (self.binned.first(), self.spanning.first()) {
(Some(b), Some(s)) => b <= s,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => return None,
};
let src = if take_binned {
&mut self.binned
} else {
&mut self.spanning
// Pick the smallest head across the three streams; `None` sorts last.
let heads = [
self.binned.first().copied(),
self.coarse.first().copied(),
self.spanning.first().copied(),
];
let pick = heads
.iter()
.enumerate()
.filter_map(|(n, h)| h.map(|v| (n, v)))
.min_by_key(|&(_, v)| v)?
.0;
let src = match pick {
0 => &mut self.binned,
1 => &mut self.coarse,
_ => &mut self.spanning,
};
let (first, rest) = src.split_first()?;
*src = rest;
@@ -319,6 +458,56 @@ mod tests {
}
}
/// The two-level structure actually engages on the dungeon shape, and the residue it leaves is
/// small. Without this, a regression that quietly stopped building the coarse level would still
/// pass every invariant test above — the index would just be slow again, which is exactly how
/// 0711's residual survived ~600 decision records unnoticed.
#[test]
fn the_coarse_level_takes_the_slabs() {
let tris = field(4000);
let grid = ColumnGrid::build(tris.len(), |i| aabb(tris[i])).expect("field indexes");
// `field` plants a slab every 97th triangle; every one of them is far too wide to bin at
// the fine level's ~1-triangle-per-cell sizing.
let slabs = tris.len().div_ceil(97);
let coarse = grid
.coarse
.as_ref()
.expect("the oversized set must get its own grid");
assert!(
coarse.items.len() >= slabs / 2,
"the coarse level binned {} of ~{slabs} slabs — it is not taking them",
coarse.items.len()
);
assert!(
grid.spanning.len() * 4 < slabs,
"residue {} is not small against ~{slabs} slabs — the coarse cells are too fine",
grid.spanning.len()
);
}
/// The merge draws from three sources; a face must appear in exactly one of them, or a column
/// would test it twice and `later-wins` tie-breaks would read the same triangle as two hits.
#[test]
fn no_triangle_is_in_two_sources() {
let tris = field(3000);
let grid = ColumnGrid::build(tris.len(), |i| aabb(tris[i])).expect("field indexes");
for gx in -10..=10 {
for gy in -10..=10 {
let (x, y) = (gx as f32 * 11.3, gy as f32 * 10.7);
let got: Vec<usize> = grid.candidates(x, y).collect();
let mut sorted = got.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
got.len(),
"column ({x}, {y}) yielded a duplicate"
);
}
}
}
/// Small face sets opt out — the caller keeps its linear scan rather than paying indirection.
#[test]
fn tiny_face_sets_are_not_indexed() {
@@ -49,7 +49,12 @@ pub fn repeat_texture_authored(chain: BlpMipChain, wrap: (bool, bool)) -> Image
TextureDimension::D2,
mip0,
color_texture_format(),
RenderAssetUsages::default(),
// `RENDER_WORLD` — see [`image_gpu_bytes`] and the `blp.rs` `WorldArt` variant this is the
// synchronous twin of: the render world takes the chain rather than cloning it and keeping
// a main-world copy. Its callers are `WorldAssets::texture` and the character-skin
// composite, and neither reads the result back; a composite is built CPU-side and handed
// over whole.
RenderAssetUsages::RENDER_WORLD,
);
image.data = Some(data);
image.texture_descriptor.mip_level_count = levels;
@@ -124,7 +129,11 @@ pub fn liquid_frame_array(frames: Vec<BlpMipChain>) -> Image {
},
TextureDimension::D2,
color_texture_format(),
RenderAssetUsages::default(),
// `RENDER_WORLD`: 136 frames of a 256² nine-level RGBA8 pyramid is 47,535,264 B (the figure
// `gpu_bytes_count_every_array_layer` pins), built at `Startup` and resident for the whole
// process. The default usage held that twice — once on the GPU and once in system RAM,
// which nothing ever read.
RenderAssetUsages::RENDER_WORLD,
);
image.data = Some(data);
image.texture_descriptor.mip_level_count = mip_level_count;
@@ -273,6 +282,41 @@ pub fn sprite_image_tiled(width: u32, height: u32, rgba: Vec<u8>) -> Image {
image
}
/// The bytes an [`Image`] occupies **on the GPU** — every mip level of every array layer, derived
/// from the `texture_descriptor` alone.
///
/// Read from the descriptor, never from `image.data.len()`: an asset declared
/// [`RenderAssetUsages::RENDER_WORLD`] has its bytes *moved* into the render world on extract
/// (`bevy_render::render_asset`), so main-side `data` is `None` for its whole life. A meter that
/// measures `data` therefore reads 0 for exactly the textures that cost the most — the terrain
/// arrays, the sprite sheets, the world albedo. This is the arithmetic bevy_image itself uses, and
/// it is block-aware, so it stays right if a format is ever uploaded compressed.
pub fn image_gpu_bytes(image: &Image) -> usize {
let d = &image.texture_descriptor;
let format = d.format;
let (bw, bh) = format.block_dimensions();
// A copy is defined per block, and a block is only whole: a 1×1 mip of a 4×4-block format still
// costs one full block.
let Some(block_bytes) = format.block_copy_size(None) else {
return 0; // multi-planar/depth-stencil: no single block size, and we upload neither
};
let volume = d.dimension == TextureDimension::D3;
(0..d.mip_level_count.max(1))
.map(|i| {
let w = (d.size.width >> i).max(1);
let h = (d.size.height >> i).max(1);
// Array layers do not halve with the mip level; a 3-D texture's depth does.
let layers = if volume {
(d.size.depth_or_array_layers >> i).max(1)
} else {
d.size.depth_or_array_layers.max(1)
};
let blocks = w.div_ceil(bw) * h.div_ceil(bh);
(blocks * block_bytes * layers) as usize
})
.sum()
}
/// Total bytes for a mip pyramid of `mip_count` levels starting at `top²` RGBA8.
pub fn mip_chain_byte_size(top: u32, mip_count: u32) -> usize {
(0..mip_count)
@@ -299,3 +343,90 @@ pub fn extend_nearest(out: &mut Vec<u8>, src: &[u8], sw: u32, sh: u32, dw: u32,
let resized = image::imageops::resize(&buf, dw, dh, image::imageops::FilterType::Nearest);
out.extend_from_slice(resized.as_raw());
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor_only(size: Extent3d, mips: u32, format: TextureFormat) -> Image {
let mut image = Image::new_uninit(
size,
TextureDimension::D2,
format,
RenderAssetUsages::RENDER_WORLD,
);
image.texture_descriptor.mip_level_count = mips;
image
}
/// The arithmetic must agree with [`mip_chain_byte_size`], which the liquid array already
/// budgets with — two independent expressions of the same pyramid.
#[test]
fn gpu_bytes_agree_with_the_rgba8_chain_arithmetic() {
for (top, mips) in [(256, 9), (128, 8), (64, 7), (1, 1)] {
let image = descriptor_only(
Extent3d {
width: top,
height: top,
depth_or_array_layers: 1,
},
mips,
TextureFormat::Rgba8Unorm,
);
assert_eq!(
image_gpu_bytes(&image),
mip_chain_byte_size(top, mips),
"{top}²/{mips}"
);
}
}
/// The number the liquid lane costs, stated once so it cannot drift silently: 136 frames of a
/// 256² RGBA8 nine-level pyramid, stacked as array layers.
#[test]
fn gpu_bytes_count_every_array_layer() {
let frames = 136;
let image = descriptor_only(
Extent3d {
width: 256,
height: 256,
depth_or_array_layers: frames,
},
9,
TextureFormat::Rgba8Unorm,
);
assert_eq!(image_gpu_bytes(&image), 47_535_264); // 45.33 MiB
assert_eq!(
image_gpu_bytes(&image),
mip_chain_byte_size(256, 9) * frames as usize
);
}
/// Block-compressed formats are the point of reading the descriptor rather than a pixel size:
/// a 4×4-block format still owes a WHOLE block for its 2×2 and 1×1 mips.
#[test]
fn gpu_bytes_are_block_aware_and_round_up() {
let bc1 = descriptor_only(
Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
3,
TextureFormat::Bc1RgbaUnorm,
);
// 4×4 → 1 block, 2×2 → 1 block, 1×1 → 1 block; BC1 is 8 bytes a block.
assert_eq!(image_gpu_bytes(&bc1), 24);
// The same pyramid uncompressed is 4 B/texel with no rounding: 64 + 16 + 4.
let rgba = descriptor_only(
Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
3,
TextureFormat::Rgba8Unorm,
);
assert_eq!(image_gpu_bytes(&rgba), 84);
}
}
+9
View File
@@ -509,6 +509,15 @@ fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 {
/// `width·scale` / `height·scale` — the `layout_767_span` PRIMITIVE (`rf32-composed-resolver.md`):
/// operands are `f32` fields, the product narrowed to `f32` at the `fstp [esp]` site, negated for
/// LEFT/BOTTOM.
///
/// **`size` is not always the authored field, and this is the trap** (decision 1349, wow-re
/// `region-size-fallback.md`): each caller's `call [eax+0x1c]` is **virtual**. A plain frame lands on
/// `0x768420 fld [G+0x50]` — the flat read this signature assumes — but a `CSimpleTexture` lands on
/// `0x770720` and a `CSimpleFontString` on `0x772930`, both of which substitute a **content-derived**
/// extent when the authored value is exactly `0.0` (the texture's own texel size, one texel per
/// FrameXML unit; the string's measured text, floored at one unit). benilla feeds this the authored
/// number only, so a zero-authored *texture* axis yields a zero span here where the client yields a
/// real one. 1349 §4 carries the change and its scope; do not read this kernel as the whole law.
fn size_span(size: f32, scale: f32, negate: bool) -> f32 {
let span = (f64::from(size) * f64::from(scale)) as f32;
if negate {
+416 -33
View File
@@ -5,6 +5,7 @@ use crate::order::ZTarget;
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
use super::backdrop;
use super::types::RegionData;
use super::{ExtractedQuad, MeasureRequest, Model, QuadContent, UiScript, SCREEN};
/// The paint a frame slot's derived quads inherit — its `effective_alpha` and `effective_scale`
@@ -94,6 +95,19 @@ impl InputFingerprint {
self.f32(i.extent_y);
}
/// Collapse to the single `u64` [`LayoutScope`] compares per node. Never [`NO_NODE`]: a
/// present node reading as *absent* would be a false CLEAN — the one direction that ships a
/// stale rect rather than a wasted resolve.
#[inline]
fn finish(self) -> u64 {
let h = self.0 ^ self.1.rotate_left(32);
if h == NO_NODE {
1
} else {
h
}
}
#[inline]
fn rect(&mut self, r: Rect) {
self.f32(r.bottom);
@@ -103,6 +117,161 @@ impl InputFingerprint {
}
}
/// The sentinel in [`LayoutScope::last`]/`now` for "this id was not in that pass's graph". A real
/// hash colliding with it costs a spurious DIRTY (a node re-solved for nothing), never a stale
/// rect — the same one-sided failure the fingerprint above is built on.
const NO_NODE: u64 = 0;
/// [`LayoutScope::node_of`] holds one roster index per id, and the two rosters are separate
/// (`plan` for frames, `regions` for regions) — this bit says which one. Node counts run in the
/// thousands, so the top bit is free forever.
const REGION_TAG: u32 = 0x8000_0000;
/// Per-node layout scope — the state that lets a resolve touch only what MOVED (decision 1350,
/// the phase 2 decision 0771 left open).
///
/// 0771 halved the per-content-change cost by folding two whole-UI solves into one; it also said
/// plainly that halving it was not fixing it, because *one* solve is still a fixpoint over every
/// frame and every region for a change that touches ten FontStrings inside one frame. Two later
/// correctness fixes then tripled what a region costs — `0dd6a559a` (a region whose owner has no
/// rect resolves from its own anchors) and 1310 (every anchor-less Texture/FontString gets
/// implicit anchors at creation) each moved a large population out of the sweep's cheap
/// `continue` and into full resolution. Neither is wrong; both are why the whole-UI shape had to
/// go.
///
/// Everything here is indexed **densely by layout id** — frames and regions are minted from one
/// monotonic counter (`Model::next_id`), so a single array covers both, and the solver's own rect
/// array is indexed the same way.
///
/// Nothing in here allocates on a steady frame: every buffer is resized once and refilled.
#[derive(Default)]
pub(crate) struct LayoutScope {
/// Per id: the input hash the last CONVERGED resolve saw, [`NO_NODE`] if the id was not in
/// that graph. A mismatch is a dirty SEED — and a birth is a mismatch for free.
last: Vec<u64>,
/// How many ids `last` holds a real hash for. Compared against how many of THIS pass's nodes
/// were also in the last graph: a shortfall means a node DIED, whose dependents we can no
/// longer enumerate (its edges died with it), so that pass falls back to full scope.
last_count: usize,
/// The screen rect the `last` set was hashed under. It is every clamped frame's extent and the
/// root of every chain, so a move is a full-scope event.
last_screen: Option<Rect>,
/// This pass's hash per id (parallel to `last`); swapped into `last` on convergence.
now: Vec<u64>,
/// Per id: index into this pass's node roster, or [`u32::MAX`].
node_of: Vec<u32>,
/// Per id: head index into `dep_to`/`dep_next` of the nodes that DEPEND on it, or
/// [`u32::MAX`]. An intrusive list rather than a CSR so the edges are built in one walk.
dep_head: Vec<u32>,
dep_to: Vec<u32>,
dep_next: Vec<u32>,
/// Per id: in the dirty closure.
dirty: Vec<bool>,
/// The closure's worklist.
stack: Vec<u32>,
}
/// One anchored, live region as the round loop consumes it — resolved ONCE per call instead of
/// re-derived per round (decision 1350). Before this list the sweep walked `region_data` itself
/// every round and paid, per region per round, an `arena.region` probe for liveness/owner/kind and
/// a `region_to_id` probe to publish the result — and it paid them for the anchor-less entries too,
/// which it then `continue`d past.
///
/// It is also the addressable form the scope needs: a dirty set is a list of INDICES into this,
/// and "sweep only what moved" is `for &i in sweep { … regions[i] … }`.
struct RegionNode<'a> {
rh: RegionHandle,
data: &'a RegionData,
/// The layout id — the solver's dense array index, and the scope's node key.
id: u32,
/// The owning frame: supplies the fallback edges for the axes this region's own anchors do
/// not pin, and is therefore a layout DEPENDENCY of it.
owner: FrameHandle,
is_fontstring: bool,
}
impl LayoutScope {
/// Grow every per-id array to cover `n` ids and reset the per-pass ones. `last` keeps its
/// contents (it is the memory of the previous converged pass); everything else is scratch.
fn begin(&mut self, n: usize) {
self.last.resize(n, NO_NODE);
self.now.clear();
self.now.resize(n, NO_NODE);
self.node_of.clear();
self.node_of.resize(n, u32::MAX);
self.dep_head.clear();
self.dep_head.resize(n, u32::MAX);
self.dirty.clear();
self.dirty.resize(n, false);
self.dep_to.clear();
self.dep_next.clear();
self.stack.clear();
}
/// Record that node `from` reads `to`'s rect — so a change at `to` reaches `from`.
#[inline]
fn edge(&mut self, from: u32, to: u32) {
let Some(head) = self.dep_head.get_mut(to as usize) else {
return; // an anchor to an id this pass has no node for (the screen root, a dead
// target): it cannot become dirty, so it constrains nothing.
};
let e = self.dep_to.len() as u32;
self.dep_to.push(from);
self.dep_next.push(*head);
*head = e;
}
/// Mark `id` dirty and queue it, if it is not already.
#[inline]
fn seed(&mut self, id: u32) {
if let Some(d) = self.dirty.get_mut(id as usize) {
if !*d {
*d = true;
self.stack.push(id);
}
}
}
/// Close the dirty set under "depends on": everything that reads a dirty node's rect, and
/// everything that reads *those*, must resolve again too.
fn close(&mut self) {
while let Some(id) = self.stack.pop() {
let mut e = self.dep_head[id as usize];
while e != u32::MAX {
let to = self.dep_to[e as usize];
e = self.dep_next[e as usize];
self.seed(to);
}
}
}
/// Everything present is dirty — the fallback for the structural cases a per-node diff cannot
/// speak about (a death, a screen move, the first pass, a graph left mid-flight by a cycle).
fn dirty_all(&mut self) {
for (id, &n) in self.node_of.iter().enumerate() {
if n != u32::MAX {
self.dirty[id] = true;
}
}
}
/// Adopt this pass's hashes as the converged memory.
fn commit(&mut self, count: usize, screen: Rect) {
std::mem::swap(&mut self.last, &mut self.now);
self.last_count = count;
self.last_screen = Some(screen);
}
/// Forget it: the next resolve must run in full scope. Called wherever a pass leaves the
/// rects mid-flight (the cycle bail), because the memory would then describe a graph that
/// never settled.
pub(crate) fn invalidate(&mut self) {
self.last_count = 0;
self.last_screen = None;
self.last.clear();
}
}
/// `WOW_LAYOUT_VERIFY=1` — the change gate's self-check (decision 0022's instruments-first
/// posture). When set, a resolve the gate wants to SKIP runs in full anyway and asserts it
/// reproduced the previous rects exactly; a divergence means the fingerprint's read set is
@@ -214,12 +383,15 @@ impl UiScript {
frame_to_id,
id_to_region,
region_to_id,
next_id,
screen,
warnings,
solver,
layout_scope: scope,
layout_fingerprint,
layout_epoch_resolved,
layout_solves,
layout_last_scope,
layout_rounds,
pending_size_changed,
..
@@ -343,18 +515,57 @@ impl UiScript {
// `resolved` / `region_resolved` are the previous pass's OUTPUT carried forward as the
// 0294 seed; at convergence seed and output are equal, so re-running against an unchanged
// input set is guaranteed to reproduce them — which is precisely what makes the skip safe.
//
// ── …and, in the same walk, the SCOPE (decision 1350) ─────────────────────────────────
// Each node's own inputs are hashed a second time on their own, into
// [`LayoutScope::now`], and its anchor targets are recorded as reverse edges. That turns
// the gate's one verdict ("something moved") into the far more useful one ("*these* moved,
// and here is everything downstream of them"), for the cost of a second accumulator over
// a read set we were walking anyway. Everything the aggregate `fp` feeds still feeds it,
// byte for byte, so tiers 1 and 2 are untouched.
// Sized past `next_id` because the region walk below MINTS ids (see there) — every id it
// can mint is one more than the anchored regions it walks.
scope.begin(*next_id as usize + region_data.len() + 1);
let mut fp = InputFingerprint::default();
fp.rect(*screen);
fp.feed(plan.len() as u64);
for &(_, id, input, over) in &plan {
for (i, &(_, id, input, over)) in plan.iter().enumerate() {
fp.feed(u64::from(id));
fp.input(input);
let mut node = InputFingerprint::default();
node.input(input);
match over {
Some(a) => fp.anchors(std::slice::from_ref(&a)),
None => fp.feed(u64::MAX),
Some(a) => {
fp.anchors(std::slice::from_ref(&a));
node.anchors(std::slice::from_ref(&a));
}
None => {
fp.feed(u64::MAX);
node.feed(u64::MAX);
}
}
if let Some(slot) = scope.now.get_mut(id as usize) {
*slot = node.finish();
scope.node_of[id as usize] = i as u32;
}
// A frame reads the rect of every anchor target it has — its OVERRIDE's target when a
// ScrollFrame supplies one (the authored anchors are not consulted at all in that
// case, exactly as the round loop's `set_frame_anchored` does not consult them).
match over {
Some(a) => scope.edge(id, a.relative_to),
None => {
for a in &input.anchors {
scope.edge(id, a.relative_to);
}
}
}
}
let mut fed_regions = 0u64;
// The round loop's region roster, built in this same walk (decision 1350) — see
// [`RegionNode`]. Only LIVE, anchored regions: an anchor-less one is invisible to the
// rounds and a destroyed one has already been dropped from `region_resolved` by the retain
// above, so both are exactly what the sweep used to `continue` past.
let mut regions: Vec<RegionNode> = Vec::with_capacity(region_data.len());
for (&rh, data) in region_data.iter() {
// Anchor-less entries are invisible to the rounds — the sweep `continue`s on empty
// anchors and the seed retain drops them — so they are not inputs and must not be
@@ -364,21 +575,74 @@ impl UiScript {
continue;
}
fed_regions += 1;
let live = arena.region(rh);
let mut node = InputFingerprint::default();
fp.feed(rh.fingerprint_bits());
fp.anchors(&data.anchors);
node.anchors(&data.anchors);
match data.size {
Some((w, h)) => {
fp.f32(w);
fp.f32(h);
node.f32(w);
node.f32(h);
}
None => {
fp.feed(u64::MAX);
node.feed(u64::MAX);
}
None => fp.feed(u64::MAX),
}
match data.measured {
Some(m) => {
fp.f32(m.w);
fp.f32(m.h);
node.f32(m.w);
node.f32(m.h);
}
None => fp.feed(u64::MAX),
None => {
fp.feed(u64::MAX);
node.feed(u64::MAX);
}
}
if let Some(r) = live {
// Mint the layout id here if the region has never needed one. The sweep used to
// resolve an id-less region and simply not publish its rect (`if let Some(&id) =
// region_to_id.get(..)`) — an EditBox's implicit text FontString is exactly that
// shape, created and anchored by `SetTextInsets` and given an id only when extract
// or a measure first asks. The scope addresses nodes BY id, so it needs one; and
// minting it is pure addressing — it moves no rect, so unlike `Model::region_id`
// this does not touch the layout epoch (doing so would re-dirty the very resolve
// that is running).
let id = match region_to_id.get(&rh) {
Some(&id) => id,
None => {
let id = *next_id;
*next_id += 1;
region_to_id.insert(rh, id);
id_to_region.insert(id, rh);
id
}
};
if let Some(slot) = scope.now.get_mut(id as usize) {
*slot = node.finish();
scope.node_of[id as usize] = regions.len() as u32 | REGION_TAG;
}
// A region reads its anchor targets' rects, and — for any axis its anchors do not
// pin — its OWNER's rect and scale. Both are dependencies; the owner one is the
// edge that carries a window moving to every unpinned texture inside it.
for a in &data.anchors {
scope.edge(id, a.relative_to);
}
if let Some(&owner_id) = frame_to_id.get(&r.owner) {
scope.edge(id, owner_id);
}
regions.push(RegionNode {
rh,
data,
id,
owner: r.owner,
is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString),
});
}
// Liveness only. The sweep also reads the region's `owner` and `kind`, but both are
// fixed at creation and can never change under a live handle; the owner's contribution
@@ -387,7 +651,7 @@ impl UiScript {
// frame pass's own output). A DESTROYED region does still matter: `WidgetArena::destroy`
// drops the arena entry but leaves `region_data` behind, so membership alone would miss
// it and the sweep's `continue` would go unnoticed.
fp.feed(u64::from(arena.region(rh).is_some()));
fp.feed(u64::from(live.is_some()));
}
// The count of FED entries (not the map's len — see the vacuous-entry skip above), so an
// entry leaving the anchored set is a change even if a sibling enters the same frame.
@@ -439,7 +703,95 @@ impl UiScript {
if !gate_skips {
*layout_solves += 1;
}
// ── The scope: which nodes this solve is actually allowed to touch (decision 1350) ────
// A node whose own inputs are unchanged, and none of whose dependencies moved, recomputes
// to the rect it already holds — the 0294 seed property, stated per node instead of for
// the graph as a whole. So the solve below runs over the dirty CLOSURE and leaves every
// other rect at its cached value, feeding it to the solver as an external where a dirty
// node anchors to it.
//
// Four structural cases the per-node diff cannot speak about fall back to full scope,
// because getting them wrong ships a stale rect rather than a slow frame:
// * no memory yet (the first resolve, or a pass invalidated below);
// * the SCREEN moved — the root external and every clamped frame's extent;
// * a node DIED: its reverse edges died with it, so its dependents are unreachable.
// Counted, not searched: if fewer of this pass's nodes carry a `last` hash than the
// last pass had nodes, something left;
// * verify, which runs the scoped pass and then the full one and compares.
let mut matched = 0usize;
let mut scope_nodes = 0usize;
for (id, &n) in scope.node_of.iter().enumerate() {
if n == u32::MAX {
continue;
}
scope_nodes += 1;
if scope.last.get(id).copied().unwrap_or(NO_NODE) != NO_NODE {
matched += 1;
}
}
let a_node_died = matched != scope.last_count;
let full_scope = scope.last.is_empty()
|| scope.last_screen != Some(*screen)
|| a_node_died
|| gate_skips; // the verify path's re-run: it must reproduce the whole graph
if full_scope {
scope.dirty_all();
} else {
for (id, &n) in scope.node_of.iter().enumerate() {
if n != u32::MAX && scope.now[id] != scope.last[id] {
scope.stack.push(id as u32);
scope.dirty[id] = true;
}
}
scope.close();
}
// The rosters the rounds walk. A frame in the closure is SOLVED; a region in it is SWEPT;
// anything a closure node anchors to is SEEDED as an external at its cached rect. A dirty
// REGION is seeded as well as swept: the frame pass runs before the sweep that refreshes
// it, so it needs last round's value in front of it — which is exactly what the whole-graph
// seeding did before.
let mut solve_frames: Vec<u32> = Vec::new();
let mut sweep_regions: Vec<u32> = Vec::new();
let mut seed_frames: Vec<u32> = Vec::new();
let mut seed_regions: Vec<u32> = Vec::new();
// Is any node that reads `id` in the closure? (Seeding is per-node work too, so a rect
// nothing dirty reads must not be seeded.)
let read_by_dirty = |scope: &LayoutScope, id: u32| -> bool {
let mut e = scope.dep_head[id as usize];
while e != u32::MAX {
if scope.dirty[scope.dep_to[e as usize] as usize] {
return true;
}
e = scope.dep_next[e as usize];
}
false
};
for id in 0..scope.node_of.len() {
let n = scope.node_of[id];
if n == u32::MAX {
continue;
}
let (idx, is_region, dirty) = (n & !REGION_TAG, n & REGION_TAG != 0, scope.dirty[id]);
#[allow(clippy::cast_possible_truncation)]
let id32 = id as u32;
if is_region {
if dirty {
sweep_regions.push(idx);
seed_regions.push(idx);
} else if read_by_dirty(scope, id32) {
seed_regions.push(idx);
}
} else if dirty {
solve_frames.push(idx);
} else if read_by_dirty(scope, id32) {
seed_frames.push(idx);
}
}
// The meter: how WIDE this solve is, beside `layout_solves` (how often) and
// `layout_rounds` (how deep). The gate asserts on it, because a scope that tracks the
// graph is the regression and milliseconds are not evidence of it (decision 0735's lesson,
// paid for twice).
*layout_last_scope = (solve_frames.len(), sweep_regions.len());
let round_cap = plan.len() + region_data.len() + 2;
// `WOW_LAYOUT_PROF=1` — the per-solve shape: rounds, the graph's size, and the split
// between the frame solve and the region sweep. The numbers that say whether a solve is
@@ -453,25 +805,35 @@ impl UiScript {
let t_round = prof.then(std::time::Instant::now);
let mut changed = false;
// Seed the solver: the screen root, then every region rect settled so far (regions are
// externals to the frame solve — the fixpoint is what closes the loop between them).
// Frame ids and region ids come from one monotonic counter, so both live in the
// solver's single dense rect array and every anchor-target lookup is an array index.
// Seed the solver: the screen root, then every rect this pass is NOT recomputing but
// something in it reads (regions are externals to the frame solve — the fixpoint is
// what closes the loop between them). Frame ids and region ids come from one monotonic
// counter, so both live in the solver's single dense rect array and every anchor-target
// lookup is an array index.
solver.begin();
solver.set_external(SCREEN, *screen);
for (&id, rh) in id_to_region.iter() {
if let Some(r) = region_resolved.get(rh) {
for &i in &seed_regions {
let n = &regions[i as usize];
if let Some(r) = region_resolved.get(&n.rh) {
solver.set_external(n.id, *r);
}
}
for &i in &seed_frames {
let (h, id, _, _) = plan[i as usize];
if let Some(r) = resolved.get(&h) {
solver.set_external(id, *r);
}
}
for &(_, id, input, over) in &plan {
for &i in &solve_frames {
let (_, id, input, over) = plan[i as usize];
match over {
Some(a) => solver.set_frame_anchored(id, input, a),
None => solver.set_frame(id, input),
}
}
solver.solve();
for &(h, id, _, _) in &plan {
for &i in &solve_frames {
let (h, id, _, _) = plan[i as usize];
match solver.rect(id) {
Some(r) => {
if resolved.get(&h) != Some(&r) {
@@ -503,21 +865,17 @@ impl UiScript {
}
let t_reg = prof.then(std::time::Instant::now);
let mut scratch = LayoutInput::default();
for (&rh, data) in region_data.iter() {
for &ri in &sweep_regions {
let &RegionNode {
rh,
data,
id: region_id,
owner,
is_fontstring,
} = &regions[ri as usize];
if prof {
n_regions_swept += 1;
}
if data.anchors.is_empty() {
continue;
}
let Some((owner, is_fontstring)) = arena.region(rh).map(|r| {
(
r.owner,
matches!(r.kind, crate::widget::RegionKind::FontString),
)
}) else {
continue;
};
// An owner with NO resolved rect does not disqualify its regions. `owner_rect`
// is only the fallback for the axes this region's own anchors do not pin (see the
// two `axis(..)` calls below) — a region anchored fully to some OTHER frame needs
@@ -579,6 +937,16 @@ impl UiScript {
//
// `None` = this axis needs the owner and the owner has no rect (above): the region
// is unresolvable, like its owner.
//
// **The owner fallback is OURS, not the client's** (decision 1349, from wow-re's
// byte-verified `region-size-fallback.md`): a complete operand enumeration of the
// real resolver `[0x7671a0, 0x76761f)` finds no parent pointer in it at all. The
// client reaches the same answer for the case that matters — a region with a parent
// and NO anchors — at *attach* time instead, via an implicit `SetAllPoints(parent)`
// (`0x7701c0` / `0x771480`), and reaches a DIFFERENT answer for every other shape,
// because its size getters are virtual and content-derived (see `layout::size_span`).
// That is why an authored-zero-width texture with one anchor resolves to its OWNER's
// width here and to 8 points there. 1349 §4 carries the replacement and its scope.
let axis = |lo: Option<f32>,
hi: Option<f32>,
owner: Option<(f32, f32)>|
@@ -607,9 +975,7 @@ impl UiScript {
// Publish into the solver as well as the model: a later region in THIS same sweep
// that anchors to this one must see the fresh rect (the sweep has always worked
// that way — it read the same `region_resolved` map it was writing).
if let Some(&id) = region_to_id.get(&rh) {
solver.set_external(id, rect);
}
solver.set_external(region_id, rect);
if region_resolved.get(&rh) != Some(&rect) {
region_resolved.insert(rh, rect);
changed = true;
@@ -621,12 +987,22 @@ impl UiScript {
}
if !changed {
if prof {
// `anchored` (live + anchored regions) is reported beside `regions_total`
// deliberately: the sweep never touched the difference, so a solve's real
// population was invisible in this line — which is exactly why two commits
// that moved a large batch of regions from "skipped" to "resolved" (1350's
// pin) showed up as a cost with no counter behind it. `solved`/`swept` are
// this pass's SCOPE: with the graph settled they should be a handful.
eprintln!(
"[layout-prof] rounds={} frames={} regions_total={} regions_swept={} \
frame_us={} region_us={}",
"[layout-prof] rounds={} frames={} regions_total={} anchored={} \
solved={} swept={} scope={} regions_swept={} frame_us={} region_us={}",
round + 1,
plan.len(),
region_data.len(),
regions.len(),
solve_frames.len(),
sweep_regions.len(),
if full_scope { "full" } else { "dirty" },
n_regions_swept,
t_frames.as_micros(),
t_regions.as_micros()
@@ -645,6 +1021,11 @@ impl UiScript {
);
}
*layout_fingerprint = Some(fp);
// The scope's memory is adopted only HERE, on the converged path, for the same
// reason the fingerprint is: a pass left mid-flight describes a graph that never
// settled, and seeding the next pass's "unchanged ⇒ unmoved" argument from it
// would be seeding it from a lie.
scope.commit(scope_nodes, *screen);
// Tier 1 closes only on a `gate_skips` frame: a real solve (`!gate_skips`) hashed
// `fp` over now-outgrown seeds — see the gate above. The re-store here is for the
// VERIFY path, whose full re-run of a settled frame passed through the state
@@ -664,7 +1045,9 @@ impl UiScript {
}
// The cycle bail (the loop ran out of rounds and warned above): the rects it leaves are
// still the ones every reader will see this frame, so the sizes that moved get their
// `OnSizeChanged` here exactly as on the converged path.
// `OnSizeChanged` here exactly as on the converged path — and the scope forgets, so the
// next resolve rebuilds the whole graph rather than trusting a half-solved one.
scope.invalidate();
queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed);
}
+30
View File
@@ -930,6 +930,25 @@ impl UiScript {
/// engine deliberately never sees the seam scale — so staleness is the host's to declare; the
/// round-trips re-answer on the frames that follow (the same one-frame convergence every
/// measure already has).
/// Force the next [`UiScript::resolve`] to rebuild the **whole** layout graph rather than the
/// dirty closure a scoped resolve would (decision 1350) — and to run at all, rather than stop
/// at either change gate.
///
/// This is the scoped resolve's own falsifier, and it exists so the claim the scope rests on —
/// *a node whose own inputs and whose dependencies did not move recomputes to the rect it
/// already holds* — is something a test can **disprove** rather than something the engine
/// argues. Drive a change, resolve, read the rects; call this, resolve again, read them again;
/// they must be identical. `WOW_LAYOUT_VERIFY` makes the same comparison automatically on every
/// SETTLED frame; this is for the frames that never settle, which is exactly the hover sweep
/// the scope was built for.
pub fn force_full_layout_resolve(&mut self) {
let mut model = self.model_mut();
model.layout_scope.invalidate();
model.layout_fingerprint = None;
model.layout_epoch_resolved = None;
model.touch_layout();
}
pub fn invalidate_text_measures(&mut self) {
let mut model = self.model_mut();
for d in model.region_data.values_mut() {
@@ -1061,6 +1080,17 @@ impl UiScript {
self.model_ref().layout_rounds
}
/// The last solve's SCOPE — `(frames solved, regions swept)`, decision 1350's meter.
///
/// The third axis of a solve's cost, and the one that used to be "all of it": solves says how
/// OFTEN, rounds says how DEEP, this says how WIDE. A change that touches ten FontStrings must
/// read a handful here however large the UI grows; a scope that tracks the graph is the
/// regression, and it is asserted as a COUNT because milliseconds have twice failed to catch
/// this class (0735, 0771).
pub fn layout_last_scope(&self) -> (usize, usize) {
self.model_ref().layout_last_scope
}
/// Is `name` a registered FrameXML template — one `CreateFrame`'s fourth argument or an
/// `inherits=` can resolve (decision 1203)?
///
+11
View File
@@ -85,6 +85,10 @@ pub(crate) struct Model {
/// change-gated — `tooltip::append_line`, and the gate
/// `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`.
pub(crate) layout_epoch: u64,
/// Per-node input hashes + the dirty-closure scratch — what makes a resolve cost the nodes
/// that MOVED rather than the whole graph (decision 1350; see `script::layout::LayoutScope`).
/// Tiers 1 and 2 above decide *whether* to solve; this decides *what*.
pub(crate) layout_scope: super::layout::LayoutScope,
/// The [`Self::layout_epoch`] value the last SETTLED resolve closed on — one whose
/// fingerprint matched, proving the input set (seeds included) has stopped moving. `None`
/// forces the next resolve through tier 1: the initial state, a cycle-bailed pass, and every
@@ -97,6 +101,11 @@ pub(crate) struct Model {
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
pub(crate) layout_solves: u64,
/// The SCOPE of the last solve that ran: `(frames solved, regions swept)` — decision 1350's
/// own meter, and the number its gate asserts on. `layout_solves` says how often the gate let
/// a solve through and `layout_rounds` how deep each went; this says how WIDE, which is the
/// axis that used to be "the whole UI" and is the one a growing UI silently inflates.
pub(crate) layout_last_scope: (usize, usize),
/// How many fixpoint ROUNDS those solves ran in total. A solve's cost is rounds × the whole
/// graph, so this is what separates "the gate let too much through" from "each pass is doing
/// too much" — the two have different fixes.
@@ -1087,6 +1096,8 @@ impl Model {
solver: LayoutSolver::new(),
layout_fingerprint: None,
layout_epoch: 0,
layout_scope: super::layout::LayoutScope::default(),
layout_last_scope: (0, 0),
layout_epoch_resolved: None,
layout_solves: 0,
layout_rounds: 0,
+21 -1
View File
@@ -615,8 +615,28 @@ pub fn classify_entity_interior(
// anchor walk replaced). Cheap enough to leave in: three counters and one `Instant` per frame.
static COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if *COST.get_or_init(|| std::env::var_os("WOW_INTERIOR_COST").is_some()) {
// `WOW_COLUMN_COST=1` adds the column-query split to the same line: this lane IS the
// grid's caller, so its per-frame traffic belongs beside its per-frame cost. `spanning` was
// 0711's residual — the oversized floor slabs tested on every query, 99.26 % of everything
// tested when 1351 finally measured it. It is now the coarse level's residue and should
// read near zero; `spanning_pct` climbing is the regression watch.
let column = if benilla_assets::column_grid::column_cost_enabled() {
let (queries, binned, coarse, spanning) =
benilla_assets::column_grid::take_column_query_stats();
let tested = binned + coarse + spanning;
let pct = if tested == 0 {
0.0
} else {
100.0 * spanning as f32 / tested as f32
};
format!(
" col_queries={queries} col_tested={tested} col_binned={binned} col_coarse={coarse} col_spanning={spanning} spanning_pct={pct:.1}"
)
} else {
String::new()
};
eprintln!(
"[interior-cost] anchors={n_anchors} resolved={n_resolved} fade_blocked={n_fade_blocked} parts_written={n_written} resolve_ms={:.2} total_ms={:.2}",
"[interior-cost] anchors={n_anchors} resolved={n_resolved} fade_blocked={n_fade_blocked} parts_written={n_written} resolve_ms={:.2} total_ms={:.2}{column}",
resolve_us / 1000.0,
_t0.elapsed().as_secs_f32() * 1000.0
);
+24
View File
@@ -98,6 +98,30 @@ pub mod testing {
pub fn plain_particle_def() -> ParticleEmitterDef {
particle_def(ParticleShape::Plane)
}
/// A bind-pose [`crate::rig_anim::RigPose`] for tests that resolve consumer anchors
/// (decision 1355: anchors spawn on first demand through `RigPose::anchor_for`, so a test
/// wearer/mount/caster needs a pose, not a hand-built joint map): one root-parented joint per
/// entry, seated at its local translation.
pub fn test_rig_pose(
root: bevy::ecs::entity::Entity,
joints: &[bevy::math::Vec3],
) -> crate::rig_anim::RigPose {
let skeleton = benilla_assets::ModelSkeleton {
joints: joints
.iter()
.map(|&t| benilla_assets::ModelJoint {
parent: -1,
local_translation: t,
billboard: None,
parent_arm: None,
})
.collect(),
spine_bone: None,
head_bone: None,
};
crate::rig_anim::RigPose::new(root, &skeleton)
}
}
pub mod art_scope;
+65
View File
@@ -139,6 +139,71 @@ impl RigPose {
} * local;
}
}
/// The anchor entity standing in for `bone`, spawned on first demand (decision 1355): a
/// consumer that needs an *entity* on a bone — a held item's parent, an emitter's owner
/// frame, a quest marker's seat — resolves it here, and only bones something actually
/// consumes ever get one. At the LBRS pin, 96 % of the eagerly-spawned population hosted
/// nothing (decision 1354).
///
/// The spawn seats from `model` — the same matrices the compose pass re-seats every anchor
/// from — so an anchor created mid-frame (a weapon equipped in combat) stands at the current
/// composed pose, never the rest pose. `rig` is the [`RigPose`] holder (`self` cannot know
/// its own entity); the anchor parents under [`Self::joints_root`] and dies with it.
///
/// `None` = the bone is outside this skeleton (an out-of-range authored reference — the
/// consumer misses, as it always has).
pub fn anchor_for(
&mut self,
commands: &mut Commands,
rig: Entity,
bone: u16,
) -> Option<Entity> {
if let Some(&(_, anchor)) = self.anchors.iter().find(|&&(b, _)| b == bone) {
return Some(anchor);
}
let m = self.model.get(bone as usize)?;
let (scale, rotation, translation) = m.to_scale_rotation_translation();
let anchor = commands
.spawn((
Transform {
translation,
rotation,
scale,
},
Visibility::default(),
RigAnchor { rig, bone },
))
.id();
commands.entity(self.joints_root).add_child(anchor);
self.anchors.push((bone, anchor));
Some(anchor)
}
/// The world-space point `offset` under `bone` at the current composed pose — what a
/// consumer that only ever *reads* a position (the overhead anchor, a missile launch point,
/// the bowstring's nock) gets instead of an anchor entity, so those reads spawn nothing
/// (decision 1355). `root_global` is [`Self::joints_root`]'s `GlobalTransform` — exactly the
/// frame an anchor's own global would compose through.
///
/// One deliberate difference from reading a spawned anchor: `model` carries the composed
/// pose with the `flags & 0x7` arm folded in but **not** the world pass's camera-billboard
/// replacement. No attachment point or event marker sits on a camera-faced bone (those bones
/// carry cards, which stay entity-anchored via [`Self::anchor_for`]), so nothing observable
/// rides on the difference.
pub fn posed_point(
&self,
root_global: &GlobalTransform,
bone: u16,
offset: Vec3,
) -> Option<Vec3> {
let m = self.model.get(bone as usize)?;
Some(
root_global
.affine()
.transform_point3(m.transform_point3(offset)),
)
}
}
/// On every consumer anchor: which rig and bone it stands in for. The body twist's model-frame