diff --git a/crates/benilla-app/src/asset_churn.rs b/crates/benilla-app/src/asset_churn.rs index ab06280f..e6831d30 100644 --- a/crates/benilla-app/src/asset_churn.rs +++ b/crates/benilla-app/src/asset_churn.rs @@ -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(); diff --git a/crates/benilla-app/src/bowstring.rs b/crates/benilla-app/src/bowstring.rs index ca493f09..c8068a47 100644 --- a/crates/benilla-app/src/bowstring.rs +++ b/crates/benilla-app/src/bowstring.rs @@ -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)>, + owners: Query<( + &BoneAttach, + &benilla_world::rig_anim::RigPose, + Has, + )>, 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); diff --git a/crates/benilla-app/src/capture/probes/world_census.rs b/crates/benilla-app/src/capture/probes/world_census.rs index c796fe1a..bafffe91 100644 --- a/crates/benilla-app/src/capture/probes/world_census.rs +++ b/crates/benilla-app/src/capture/probes/world_census.rs @@ -142,6 +142,29 @@ fn fire_entity_census(world: &mut World) { } } world.resource_mut::().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::, bevy::prelude::With>(); + 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() diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 413338e0..7887f6b5 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -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>, @@ -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); diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs index 3b72ff82..05eacc2a 100644 --- a/crates/benilla-app/src/combat_text/mod.rs +++ b/crates/benilla-app/src/combat_text/mod.rs @@ -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>, @@ -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 diff --git a/crates/benilla-app/src/creature_anim/spell_visual.rs b/crates/benilla-app/src/creature_anim/spell_visual.rs index 2c8fc6e7..710e0070 100644 --- a/crates/benilla-app/src/creature_anim/spell_visual.rs +++ b/crates/benilla-app/src/creature_anim/spell_visual.rs @@ -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>, visuals: Option>, spells: Option>, mut fx: MessageWriter, @@ -1070,10 +1077,17 @@ pub(crate) fn arm_aura_state_fx( mut sounds: MessageWriter, mut armed: Local>>, ) { + 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::>() + } else { + changed.iter().collect::>() + }; + 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` 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>, visuals: Option>, mut fx: MessageWriter, mut armed: Local, ) { + 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::>() + } else { + changed.iter().collect::>() + }; + 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 { diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 1c392409..a944512b 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -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( entity: Entity, tf: &Transform, anchors: &Query<&BoneAttach>, + poses: &Query<&benilla_world::rig_anim::RigPose>, fallbacks: &Query<&OverheadFallback>, globals: &Query<&GlobalTransform, F>, mounts: &Query<(), With>, @@ -162,8 +167,9 @@ pub(crate) fn overhead_anchor( 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); diff --git a/crates/benilla-app/src/entities/attach/dress.rs b/crates/benilla-app/src/entities/attach/dress.rs index 73d4efd9..ccac0118 100644 --- a/crates/benilla-app/src/entities/attach/dress.rs +++ b/crates/benilla-app/src/entities/attach/dress.rs @@ -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, + /// 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, /// 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 = 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 = 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, diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index b9fee33d..506c659e 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -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, + pose: benilla_world::rig_anim::RigPose, slot: u16, } @@ -268,9 +235,14 @@ pub(super) fn attach_entity_visuals( Without, >, // 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>, @@ -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 = match (net.kind, dm) { + let mut skin: Option = 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 = 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 = 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; diff --git a/crates/benilla-app/src/entities/attach/redress.rs b/crates/benilla-app/src/entities/attach/redress.rs index 77a2ea82..6e16f800 100644 --- a/crates/benilla-app/src/entities/attach/redress.rs +++ b/crates/benilla-app/src/entities/attach/redress.rs @@ -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 = 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(), }); diff --git a/crates/benilla-app/src/entities/chain_beam.rs b/crates/benilla-app/src/entities/chain_beam.rs index 300c4309..3298a791 100644 --- a/crates/benilla-app/src/entities/chain_beam.rs +++ b/crates/benilla-app/src/entities/chain_beam.rs @@ -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 { - 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>, world_cam: Query>, - units: Query<(&GlobalTransform, Option<&BoneAttach>)>, + units: super::missile::AttachPosQuery, joints: Query<&GlobalTransform>, heights: Query<&OverheadFallback>, mut beams: Query<(Entity, &mut ChainBeam)>, diff --git a/crates/benilla-app/src/entities/equipment/mod.rs b/crates/benilla-app/src/entities/equipment/mod.rs index b6a003ad..8e396712 100644 --- a/crates/benilla-app/src/entities/equipment/mod.rs +++ b/crates/benilla-app/src/entities/equipment/mod.rs @@ -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, /// Attachment id → `(bone index, bevy-space offset from the bone's bind pivot)`. pub(crate) points: HashMap, /// 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 { - 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 diff --git a/crates/benilla-app/src/entities/equipment/spawn.rs b/crates/benilla-app/src/entities/equipment/spawn.rs index 9679e6f4..19a15612 100644 --- a/crates/benilla-app/src/entities/equipment/spawn.rs +++ b/crates/benilla-app/src/entities/equipment/spawn.rs @@ -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>, time: Res