fix: silence the attach build's entity commands against a same-frame teardown

A unit destroyed or leaving range on the frame its visual is built is despawned
by apply_despawn_fade while attach_entity_visuals' plain insert commands are
still queued. Bevy's despawn only warns on a dead target, but insert and
insert_if_new panic, so the client aborted inside a battleground's stream churn.
Every entity-rooted write in attach_entity_visuals, setup_skinned_instance and
arm_sequence_clock now uses try_insert / try_insert_if_new, and the mount child's
MountChild does too, matching apply_despawn_fade's own try_despawn/try_insert.
This commit is contained in:
chairmanpow
2026-10-06 19:11:39 +01:00
parent 2796a5d78d
commit cb412c22c6
2 changed files with 29 additions and 25 deletions
+26 -24
View File
@@ -114,7 +114,7 @@ fn setup_skinned_instance(
// loops.
commands
.entity(entity)
.insert(rig)
.try_insert(rig)
.remove::<benilla_world::rig_palette::RigStarved>();
slot
}
@@ -125,7 +125,7 @@ fn setup_skinned_instance(
// Table full: static bind-pose parts until `heal_rig_starved` rebuilds with a slot.
commands
.entity(entity)
.insert(benilla_world::rig_palette::RigStarved);
.try_insert(benilla_world::rig_palette::RigStarved);
0
}
};
@@ -135,7 +135,7 @@ fn setup_skinned_instance(
if let Some(drive) =
benilla_world::rig_anim::GlobalSeqDrive::new_rig(&anims.global_bones, nbones)
{
commands.entity(entity).insert(drive);
commands.entity(entity).try_insert(drive);
}
}
Some(RigBuild { pose, slot })
@@ -170,14 +170,14 @@ fn arm_sequence_clock(
}
}
}
commands.entity(entity).insert((
commands.entity(entity).try_insert((
player,
AnimationGraphHandle(anims.graph.clone()),
anims.clone(),
));
match kind {
EntityKind::GameObject if go_state_machine => {
commands.entity(entity).insert((
commands.entity(entity).try_insert((
// Cross-fades each transition over the clip's blend-in time, from the seed.
transitions,
crate::go_anim::GoAnim::default(),
@@ -190,13 +190,13 @@ fn arm_sequence_clock(
EntityKind::Corpse => {
commands
.entity(entity)
.insert(AnimationTransitions::new())
.try_insert(AnimationTransitions::new())
.remove::<benilla_world::rig_anim::AnimParked>();
}
_ => {
commands
.entity(entity)
.insert((
.try_insert((
// Cross-fades over each clip's blend-in time, so a gait change eases.
AnimationTransitions::new(),
AnimDriver::default(),
@@ -375,14 +375,16 @@ pub(super) fn attach_entity_visuals(
let bake_center = dm.map(|d| d.bake_center_local).unwrap_or(Vec3::ZERO);
// One ground-shade state per object (the reference's `[obj+0xe0]`), the same 2.5/0.5
// MCSH chase for every kind; `insert_if_new` so a rebuild keeps the ramped state.
// The `try_` forms: a unit torn down in this frame (common in a battleground's stream
// churn) is gone when these apply, and `insert` would panic where `despawn` only warns.
commands
.entity(entity)
.insert_if_new(benilla_world::entity_shade::GroundShade::default());
.try_insert_if_new(benilla_world::entity_shade::GroundShade::default());
// Held items share the body's interior verdict (the reference aliases the wearer's
// collector into each item, `0x718960`), so they fold at the body's centre.
commands
.entity(entity)
.insert(benilla_world::interior::BodyBakeCenter(bake_center));
.try_insert(benilla_world::interior::BodyBakeCenter(bake_center));
// The light node's attach mode (`[node+0x90]` bit 13, set from the TYPEMASK at
// `0x613e10`/`0x670db0`, dispatched at `0x6a86d0`): a GameObject by containment at its
// bounds centre (`0x6a8c10`), a unit by down-ray at its position (`0x6a8a20`).
@@ -390,7 +392,7 @@ pub(super) fn attach_entity_visuals(
EntityKind::GameObject => {
commands
.entity(entity)
.insert(benilla_world::interior::ContainmentAttach);
.try_insert(benilla_world::interior::ContainmentAttach);
}
_ => {
commands
@@ -505,7 +507,7 @@ pub(super) fn attach_entity_visuals(
for m in &d.markers {
markers.entry(m.ident).or_insert((m.bone, m.offset));
}
commands.entity(entity).insert(super::BoneAttach {
commands.entity(entity).try_insert(super::BoneAttach {
points: d
.attachments
.iter()
@@ -524,7 +526,7 @@ pub(super) fn attach_entity_visuals(
if spine.is_some() || head.is_some() {
commands
.entity(entity)
.insert(crate::creature_anim::BodyTwist::new(spine, head));
.try_insert(crate::creature_anim::BodyTwist::new(spine, head));
}
}
// A character model carries every hair, facial and body geoset, and is shared by its
@@ -590,7 +592,7 @@ pub(super) fn attach_entity_visuals(
.map(|c| bevy::camera::primitives::Aabb::from_min_max(c.bounds_min, c.bounds_max))
.or_else(|| union_aabb(model.unwrap_or_default().iter().filter_map(|p| p.aabb)));
if let Some(aabb) = election_bound {
commands.entity(entity).insert(super::ModelBound(aabb));
commands.entity(entity).try_insert(super::ModelBound(aabb));
}
// A billboard card rides its live joint, so card anchors resolve here, over the spawn
// loop's geoset predicate: a hidden variant's card bone spawns no anchor.
@@ -657,7 +659,7 @@ pub(super) fn attach_entity_visuals(
if unit_will_fade {
commands
.entity(entity)
.insert(benilla_world::model_fade::UnitAppearFade::Pending { since: now });
.try_insert(benilla_world::model_fade::UnitAppearFade::Pending { since: now });
} else if reattached {
commands
.entity(entity)
@@ -676,25 +678,25 @@ pub(super) fn attach_entity_visuals(
if let (EntityKind::Player, Some(e)) = (net.kind, equipment) {
commands
.entity(entity)
.insert(super::equipment::AppliedEquipment(*e));
.try_insert(super::equipment::AppliedEquipment(*e));
}
// The camera's framing pivot, model-local, before scale.
commands.entity(entity).insert(CameraPivot {
commands.entity(entity).try_insert(CameraPivot {
height_local: dm.map(|d| d.pivot_height_local).unwrap_or(0.0),
// The pivot's drop while swimming, the reference's third preset `cam+0x124`
// (`0x50ccf6`); 0 without a Swim sequence.
swim_drop_local: dm.map(|d| d.swim_pivot_drop_local).unwrap_or(0.0),
});
// The overhead fallback for a model with no PlayerName attachment (`0x608640`).
commands.entity(entity).insert(super::OverheadFallback(
commands.entity(entity).try_insert(super::OverheadFallback(
dm.map(|d| d.bbox_z_local).unwrap_or(0.0),
));
// The chat bubble's height: the Stand box's Z, not the posed PlayerName attachment.
commands.entity(entity).insert(super::StandBoxHeight(
commands.entity(entity).try_insert(super::StandBoxHeight(
dm.map(|d| d.stand_box_z_local).unwrap_or(0.0),
));
// The selection ring's model-local radius, scaled by the unit's scale.
commands.entity(entity).insert(SelectionRadius(
commands.entity(entity).try_insert(SelectionRadius(
dm.map(|d| d.ground_radius_local).unwrap_or(0.0),
));
// Emitters ride their host bone's anchor, origin rebased into the bone frame
@@ -778,7 +780,7 @@ pub(super) fn attach_entity_visuals(
}
// The pose lands last, with every anchor the build resolved into it.
if let Some(rb) = skin {
commands.entity(entity).insert(rb.pose);
commands.entity(entity).try_insert(rb.pose);
}
// A GameObject with a hull collides; an anchored transport stays Kinematic, as its arm
// labelled it, since its body moves every frame.
@@ -789,7 +791,7 @@ pub(super) fn attach_entity_visuals(
} else {
RigidBody::Static
};
commands.entity(entity).insert((body, col));
commands.entity(entity).try_insert((body, col));
// This lane bypasses the streamer's attach queue, so it stamps the collider
// set itself.
collider_epoch.bump();
@@ -855,13 +857,13 @@ pub(super) fn attach_entity_visuals(
.map_or(0, |s| s.0.unit_mount_display_id());
commands
.entity(entity)
.insert(super::mount::AppliedMount(applied));
.try_insert(super::mount::AppliedMount(applied));
}
commands
.entity(entity)
.insert(VisualAttached)
.try_insert(VisualAttached)
// The display the visual was built with, for `refresh_live_display` to diff.
.insert(super::live_display::AppliedDisplay(net.display_id))
.try_insert(super::live_display::AppliedDisplay(net.display_id))
.remove::<super::equipment::Reattached>();
}
}
+3 -1
View File
@@ -61,7 +61,9 @@ pub(super) fn spawn_mount_child(
commands.entity(child).insert(super::equipment::Reattached);
}
commands.entity(host).add_child(child);
commands.entity(host).insert(MountChild(child));
// `try_`: the host may be torn down in the same frame this build runs (a battleground's
// stream churn), and a plain insert would panic where `despawn` only warns.
commands.entity(host).try_insert(MountChild(child));
child
}