From 21ffe30b74deb08a06bad6f13d3564ae0ffcc8e9 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:17:15 +1000 Subject: [PATCH 01/23] Keep the Predator view on the missile from impact until the static On every Predator impact the screen showed about 83 ms of black world under the Predator HUD, then about 50 ms of the player's own first-person laptop view, and only then the static. The black frames came from the camera running past the snapshot. The missile camera evaluated the trajectory at the presentation clock with no limit, and that clock runs up to a tick ahead of the newest snapshot, so on the last frames of the flight the eye went through the ground. Up to the newest snapshot the trajectory is now exact. Past it, the eye extrapolates for one tick at most and stops at the first world surface (a camera-sized box trace), which is where the missile detonates. The camera also follows the presentation's trajectory time rather than the live clock. The same camera draws the killcam replay of a Predator kill, which has no killcam entity, and a killcam seat shows an archived snapshot held back by the proxy delay. At the live clock the replayed eye sat a tick past every snapshot and moved only when a new one arrived, 20 times a second. The first-person frames came from dropping the camera too early. The missile leaves the snapshot on impact and the controls unlink a tick later, but the script keeps the camera linked until after the static. The last missile pose is now held for as long as the player's remote missile link exists, and cleared when the link goes, on a new life, or when a different missile is linked. The view counts as third person for the whole link, so the laptop viewmodel stays hidden under the hold. Checked on our iPhone build in a Sub Base match: Predators steered into the ground cut straight from the missile view to the static, with no black frame and no glimpse of the arms and laptop. Co-Authored-By: Claude Opus 5.5 --- .../render_anim/src/occupancy/third_person.rs | 107 ++++++++++++++---- crates/render_anim/src/occupancy/view_kick.rs | 10 +- 2 files changed, 96 insertions(+), 21 deletions(-) diff --git a/crates/render_anim/src/occupancy/third_person.rs b/crates/render_anim/src/occupancy/third_person.rs index c3c3406..b64dd0c 100644 --- a/crates/render_anim/src/occupancy/third_person.rs +++ b/crates/render_anim/src/occupancy/third_person.rs @@ -1,11 +1,11 @@ use asset_world::ClipCollision; -use net::PresentedSnapshot; +use net::{AUTHORITY_MS, PresentedProjectile, PresentedSnapshot}; use playerstate_iw4::{ CG_CAMERA_PULLBACK_BOX_HALF, CG_CAMERA_PULLBACK_CLIPMASK, CG_THIRD_PERSON_RANGE_DEFAULT, KillCamMode, OffsetThirdPersonViewInputs, ThirdPersonViewInputs, is_third_person_view, offset_third_person_view, }; -use sim::ClientId; +use sim::{ClientId, LifeSequence, ProjectileId, RemoteMissile}; use render_scene::WorldCameraPose; @@ -29,7 +29,7 @@ pub fn presented_is_third_person( if in_killcam && ps.kill_cam_entity != playerstate_iw4::ENTITYNUM_NONE { return true; } - if remote_missile_camera(presented, local, 0).is_some() { + if remote_missile_link(presented, local).is_some() { return true; } is_third_person_view(ThirdPersonViewInputs { @@ -42,25 +42,94 @@ pub fn presented_is_third_person( }) } -pub fn remote_missile_camera( +fn remote_missile_link( presented: &PresentedSnapshot, local: ClientId, +) -> Option<(RemoteMissile, LifeSequence)> { + let meta = presented.snapshot()?.meta.for_client(local)?; + Some((meta.remote_missile?, meta.life_sequence)) +} + +// The missile leaves the snapshot on impact, before the link goes and the +// static starts. The last pose is held until then so that neither the +// player's own view nor a black frame shows in between. +#[derive(Default)] +pub struct RemoteMissileView { + held: Option, +} + +#[derive(Clone, Copy, Debug)] +struct HeldMissileView { + life: LifeSequence, + projectile: ProjectileId, + pose: WorldCameraPose, +} + +impl RemoteMissileView { + pub fn camera( + &mut self, + presented: &PresentedSnapshot, + local: ClientId, + render_time: i32, + clip: Option<&ClipCollision>, + ) -> Option { + let at_time = presented.trajectory_time_ms(render_time); + let Some((link, life)) = remote_missile_link(presented, local) else { + self.held = None; + return None; + }; + self.held = self + .held + .filter(|held| held.life == life && held.projectile == link.projectile); + let flying = link + .unlink_at_ms + .is_none() + .then(|| { + presented + .presented_projectiles() + .iter() + .find(|p| p.authoritative_id() == Some(link.projectile)) + }) + .flatten(); + let snapshot_ms = presented.tick().map_or(at_time, sim::level_time_ms); + if let Some(missile) = flying { + let pose = WorldCameraPose { + origin: missile_eye(missile, snapshot_ms, at_time, clip), + angles: link.angles, + }; + self.held = Some(HeldMissileView { + life, + projectile: link.projectile, + pose, + }); + } + self.held.map(|held| held.pose) + } +} + +fn missile_eye( + missile: &PresentedProjectile, + snapshot_ms: i32, at_time: i32, -) -> Option { - let link = presented - .snapshot()? - .meta - .for_client(local)? - .remote_missile - .filter(|link| link.unlink_at_ms.is_none())?; - let missile = presented - .presented_projectiles() - .iter() - .find(|p| p.authoritative_id() == Some(link.projectile))?; - Some(WorldCameraPose { - origin: missile.origin_at(at_time), - angles: link.angles, - }) + clip: Option<&ClipCollision>, +) -> [f32; 3] { + let known = missile.origin_at(at_time.min(snapshot_ms)); + if at_time <= snapshot_ms { + return known; + } + let ahead = missile.origin_at(at_time.min(snapshot_ms.saturating_add(AUTHORITY_MS))); + let fraction = clip.map_or(1.0, |clip| { + let half = CG_CAMERA_PULLBACK_BOX_HALF; + let hit = clip.sweep_box( + known, + ahead, + [-half; 3], + [half; 3], + CG_CAMERA_PULLBACK_CLIPMASK, + ); + if hit.allsolid { 0.0 } else { hit.fraction } + }); + std::array::from_fn(|i| known[i] + (ahead[i] - known[i]) * fraction.clamp(0.0, 1.0)) } pub fn linked_weapon_camera( diff --git a/crates/render_anim/src/occupancy/view_kick.rs b/crates/render_anim/src/occupancy/view_kick.rs index 59d5984..5b81a10 100644 --- a/crates/render_anim/src/occupancy/view_kick.rs +++ b/crates/render_anim/src/occupancy/view_kick.rs @@ -21,7 +21,7 @@ use crate::anim::view_kick_state::{KickParams, ViewKickState, add_kick_to_viewan use crate::anim::view_sway::ViewSwayState; use crate::occupancy::remote_body::RemotePlayer; use crate::occupancy::third_person::{ - linked_weapon_camera, presented_is_third_person, remote_missile_camera, third_person_camera, + RemoteMissileView, linked_weapon_camera, presented_is_third_person, third_person_camera, }; use render_scene::{FlyCamera, FpvLens, SimCamera, transform_from_iw_view}; use render_scene::{WorldCameraPose, WorldScriptModelInstance}; @@ -228,6 +228,7 @@ pub fn sync_camera_from_presented( mut lenses: Query<&mut Projection, With>, view: Res, death_cam_clip: Res, + mut missile_view: Local, ) { kick.killcam_focus_distance = None; if let Some(view) = sim_cam.view_override { @@ -283,7 +284,12 @@ pub fn sync_camera_from_presented( kick.killcam_focus_distance = focus_distance; return; } - if let Some(pose) = remote_missile_camera(&presented, local.0, clock.time()) { + if let Some(pose) = missile_view.camera( + &presented, + local.0, + clock.time(), + death_cam_clip.0.as_deref(), + ) { let pose = earthquake_pose(pose, &presented, clock.time()); let eye = transform_from_iw_view(pose); for mut transform in &mut q { From 73315a0d7778c27edcb9ae5a313a79653974d14b Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:17:44 +1000 Subject: [PATCH 02/23] Turn the Predator camera smoothly while steering The authority steers a remote missile once a tick, so the link angles in the snapshots change in 20 Hz steps, and the missile camera showed the presented snapshot's angles as they came: at 60 fps the view turned on one frame in three, up to 2-3 degrees at once, and stood still on the other two. The eye position was already carried along the trajectory every frame; the angles were not. The missile view now keeps the link angles of the last four snapshots of the missile it shows and draws the angles a tick behind the presentation's trajectory time (the listen clock live, the delayed replay time in a killcam seat), interpolated per axis along the shortest arc between the two snapshots around it, so 359 to 1 degree turns 2 degrees and not 358. It never runs past the newest snapshot, so when the pilot lets go the view stops where the missile stopped instead of overshooting and swinging back. A new life, a new missile, losing the link, or a switch between live and archived presentation starts a fresh history. The last matters for the final killcam of the pilot's own kill, which replays the same missile of the same life from seat snapshots stamped with the live tick. Four samples cover a seat's clock running up to two ticks behind its archived snapshot. The missile turns by the remote-control stick axes in each command, not by the pilot's view angles, so there are no locally predicted angles to show instead. Extrapolating the turn rate would answer sooner but overshoot by up to a tick's turn every time the stick is released. The cost is at most one tick (50 ms) of turn latency. Checked on the Mac (mp_estate, a Predator steered by scripted mouse input), frames drawn while the snapshots' link angles were changing that turned the view, before / after: with the stick held, 28 of 75 (up to 2.8 degrees in one frame) / 71 of 71 (at most 1.3, median 0.45); a second flight steered over and back, 19 of 49 / 54 of 54; that flight's final killcam, 19 of 47 / 51 of 51. After the stick is released the view never goes past the missile's final angles. On our iPhone build in a Sub Base match, steering was smooth. Co-Authored-By: Claude Opus 5.5 --- .../net/src/client/presentation/presented.rs | 4 ++ .../render_anim/src/occupancy/third_person.rs | 68 ++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/net/src/client/presentation/presented.rs b/crates/net/src/client/presentation/presented.rs index 63ddb55..34ad5bd 100644 --- a/crates/net/src/client/presentation/presented.rs +++ b/crates/net/src/client/presentation/presented.rs @@ -194,6 +194,10 @@ impl PresentedSnapshot { self.trajectory_time_ms.unwrap_or(render_time_ms) } + pub fn is_archived_replay(&self) -> bool { + self.trajectory_time_ms.is_some() + } + pub(crate) fn set_trajectory_sample( &mut self, time_ms: Option, diff --git a/crates/render_anim/src/occupancy/third_person.rs b/crates/render_anim/src/occupancy/third_person.rs index b64dd0c..c8ccf47 100644 --- a/crates/render_anim/src/occupancy/third_person.rs +++ b/crates/render_anim/src/occupancy/third_person.rs @@ -56,6 +56,7 @@ fn remote_missile_link( #[derive(Default)] pub struct RemoteMissileView { held: Option, + turn: MissileTurn, } #[derive(Clone, Copy, Debug)] @@ -65,6 +66,64 @@ struct HeldMissileView { pose: WorldCameraPose, } +const TURN_SAMPLES: usize = 4; + +#[derive(Default)] +struct MissileTurn { + missile: Option<(LifeSequence, ProjectileId)>, + archived: bool, + samples: Vec<(i32, [f32; 3])>, +} + +impl MissileTurn { + fn record( + &mut self, + missile: (LifeSequence, ProjectileId), + archived: bool, + snapshot_ms: i32, + angles: [f32; 3], + ) { + // The final killcam of the pilot's own kill replays the same missile + // of the same life from seat snapshots stamped with the live tick, + // later than every sample of the flight, so a switch between live and + // archived presentation starts a fresh history. + if self.missile != Some(missile) || self.archived != archived { + self.missile = Some(missile); + self.archived = archived; + self.samples.clear(); + } + match self.samples.last_mut() { + Some(last) if last.0 == snapshot_ms => last.1 = angles, + Some(last) if last.0 > snapshot_ms => { + self.samples.clear(); + self.samples.push((snapshot_ms, angles)); + } + _ => { + if self.samples.len() == TURN_SAMPLES { + self.samples.remove(0); + } + self.samples.push((snapshot_ms, angles)); + } + } + } + + fn angles_at(&self, at_time: i32) -> Option<[f32; 3]> { + let shown = at_time.saturating_sub(AUTHORITY_MS); + let &(first_ms, first) = self.samples.first()?; + if shown <= first_ms { + return Some(first); + } + let Some(pair) = self.samples.windows(2).find(|pair| shown < pair[1].0) else { + return self.samples.last().map(|&(_, newest)| newest); + }; + let ((from_ms, from), (to_ms, to)) = (pair[0], pair[1]); + let fraction = (shown - from_ms) as f32 / (to_ms - from_ms) as f32; + Some(std::array::from_fn(|i| { + from[i] + math_iw4::angle_subtract(to[i], from[i]) * fraction + })) + } +} + impl RemoteMissileView { pub fn camera( &mut self, @@ -76,6 +135,7 @@ impl RemoteMissileView { let at_time = presented.trajectory_time_ms(render_time); let Some((link, life)) = remote_missile_link(presented, local) else { self.held = None; + self.turn = MissileTurn::default(); return None; }; self.held = self @@ -93,9 +153,15 @@ impl RemoteMissileView { .flatten(); let snapshot_ms = presented.tick().map_or(at_time, sim::level_time_ms); if let Some(missile) = flying { + self.turn.record( + (life, link.projectile), + presented.is_archived_replay(), + snapshot_ms, + link.angles, + ); let pose = WorldCameraPose { origin: missile_eye(missile, snapshot_ms, at_time, clip), - angles: link.angles, + angles: self.turn.angles_at(at_time).unwrap_or(link.angles), }; self.held = Some(HeldMissileView { life, From fa0a1c3e1711e539661aea8a2a39f35fb6c87ffe Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:16:45 +1000 Subject: [PATCH 03/23] Let dead bodies fall off edges, slide down walls and follow the ground A body whose death animation carried it past an edge stayed in the air at its death height, until newer deaths recycled the slot. The open side of the cliff stairs on mp_estate showed it often. phase_run_corpse_move set a body resting (TR_INTERPOLATE, not falling) after every unblocked animation step and never looked for ground there. Its only downward probe ran for ragdolls, which never start, and its test was reversed. One body's server tick is now step_corpse: - on the ground only the animation step's x/y moves the body. It follows the ground down by up to a stair step (18 units), so it goes down slopes and stairs without hopping. With nothing within a step below it falls from rest, so it goes over an edge instead of floating; - in the air the trajectory keeps its clock and speed, so gravity builds, and the animation step is added to its base once; - a falling body that meets a wall slides down it with the vertical speed it had and lands. Before, it restarted its fall from rest every tick and stood pinned against the wall for up to a second; - a body stopped part way on the ground moves its trajectory base with it, so clients draw it where it stopped. The clone velocity clamp now holds x and y to 80 in both directions. Before, only +x and +y were held, so a body thrown toward -x or -y flew further. Seen on the Mac (mp_favela, seven point-blank shotgun kills of a bot on an open terrace, pb_shotgun_death_back2): the body travels 106-111 units (the clip's root motion is 120), drops over the terrace edge, lands after 0.35 s and slides down the slope without falling again. Against a wall it slides down and lands in 0.2 s. A player killed 300 units up lands in 0.8 s. On an iPhone build, bodies killed on the mp_estate cliff stairs dropped onto the slope, and point-blank shotgun kills on mp_favela flew back a little and dropped. There is still no ragdoll, so the death clips' root motion still moves the body, their end poses (legs up, headstands) remain, and limbs can still reach into walls. Co-Authored-By: Claude Opus 5.5 --- crates/sim/src/corpse.rs | 323 +++++++++++++++++++++++++-------------- 1 file changed, 208 insertions(+), 115 deletions(-) diff --git a/crates/sim/src/corpse.rs b/crates/sim/src/corpse.rs index 4a960a8..a6a9209 100644 --- a/crates/sim/src/corpse.rs +++ b/crates/sim/src/corpse.rs @@ -1,6 +1,8 @@ use crate::frame::FrameWorld; use crate::world::ClientId; -use entity_iw4::{TR_GRAVITY, TR_INTERPOLATE, Trajectory, evaluate_trajectory}; +use entity_iw4::{ + TR_GRAVITY, TR_INTERPOLATE, Trajectory, evaluate_trajectory, evaluate_trajectory_delta, +}; use playerstate_iw4::{ AnimPair, ENTITYNUM_NONE, LINK_FLAGS_WEAPON_VIEW_ONLY, MAX_CLIENT_CORPSES, PLAYER_CORPSE_ENTITY_BASE, PlayerState, @@ -209,11 +211,8 @@ fn corpse_land_angles(yaw: f32, normal: [f32; 3]) -> [f32; 3] { } fn clamp_clone_tr_delta(mut delta: [f32; 3]) -> [f32; 3] { - if delta[0] > G_CLONE_PLAYER_MAX_VELOCITY { - delta[0] = G_CLONE_PLAYER_MAX_VELOCITY; - } - if delta[1] > G_CLONE_PLAYER_MAX_VELOCITY { - delta[1] = G_CLONE_PLAYER_MAX_VELOCITY; + for speed in &mut delta[..2] { + *speed = speed.clamp(-G_CLONE_PLAYER_MAX_VELOCITY, G_CLONE_PLAYER_MAX_VELOCITY); } delta } @@ -224,7 +223,9 @@ const CORPSE_ANIM_AXIS_Y_SCALE: f32 = -1.0; const CORPSE_GROUND_PROBE_DROP: f32 = 1.0; -const CORPSE_ANIM_DELTA_TO_VELOCITY: f32 = 20.0; +const CORPSE_STEP_DOWN: f32 = 18.0; + +const CORPSE_WALL_SLIDE_GAP: f32 = 0.125; const CORPSE_UNSTICK_Z_LIFT: f32 = 32.0; @@ -245,109 +246,210 @@ pub(crate) fn phase_run_corpse_move(world: &mut FrameWorld, time_ms: i32) { if !slot.occupied { continue; } - let ragdoll = is_ragdoll_tr_type(slot.tr_type); - - let anim_delta = world - .corpse_dobj_tree_delta(slot.entnum, crate::MATCH_TICK_MS as i32) - .filter(|d| { - d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > CORPSE_ANIM_DELTA_LEN_SQ_MIN - && (!slot.falling || !ragdoll) - }); - if !slot.falling && anim_delta.is_none() { - continue; - } - - let mut desired = evaluate_trajectory(&slot.trajectory(), time_ms); - let world_delta = anim_delta.map(|delta| corpse_anim_world_delta(slot.viewangles, delta)); - if let Some(world_delta) = world_delta { - for axis in 0..3 { - desired[axis] += world_delta[axis]; - } - } - - let hit = world.trace_clip( - slot.origin, - desired, - PLAYER_MINS, - PLAYER_MAXS, - MASK_PLAYER_SOLID, - ); - let mut fraction = hit.fraction; - let endpos = if fraction >= 1.0 { desired } else { hit.endpos }; - world.corpses_mut().slots[i].origin = endpos; - if hit.startsolid != 0 { - fraction = 0.0; - } - - if fraction >= 1.0 { - let Some(world_delta) = world_delta else { - continue; - }; - - { - let slot = &mut world.corpses_mut().slots[i]; - slot.tr_type = if ragdoll { - TR_RAGDOLL_SETTLED - } else { - TR_INTERPOLATE - }; - slot.tr_base = endpos; - slot.tr_time = 0; - slot.tr_duration = 0; - slot.tr_delta = [0.0; 3]; - } - if ragdoll { - let mut probe = desired; - probe[2] -= CORPSE_GROUND_PROBE_DROP; - let ground = - world.trace_clip(endpos, probe, PLAYER_MINS, PLAYER_MAXS, MASK_PLAYER_SOLID); - if ground.fraction < 1.0 || ground.startsolid != 0 { - let slot = &mut world.corpses_mut().slots[i]; - slot.falling = true; - let mut vel = slot.tr_delta; - for axis in 0..3 { - vel[axis] = (vel[axis] + world_delta[axis]) * CORPSE_ANIM_DELTA_TO_VELOCITY; - } - slot.tr_delta = vel; - slot.tr_type = TR_BOUNCE_GRAVITY; - slot.tr_time = time_ms; - slot.tr_duration = 0; - continue; - } - } - world.corpses_mut().slots[i].falling = false; - continue; - } - - if !slot.falling { - continue; - } - - if hit.allsolid != 0 { - let mut lifted = world.corpses().slots[i].origin; - lifted[2] += CORPSE_UNSTICK_Z_LIFT; - let retry = world.trace_clip( - lifted, - desired, - PLAYER_MINS, - PLAYER_MAXS, - MASK_PLAYER_SOLID & !CORPSE_UNSTICK_CLIPMASK_DROP, - ); - if retry.allsolid == 0 { - let f = retry.fraction; - world.corpses_mut().slots[i].origin = [ - lifted[0] + (desired[0] - lifted[0]) * f, - lifted[1] + (desired[1] - lifted[1]) * f, - lifted[2] + (desired[2] - lifted[2]) * f, - ]; - } - } - corpse_land(world, i, hit, time_ms); + let anim_delta = world.corpse_dobj_tree_delta(slot.entnum, crate::MATCH_TICK_MS as i32); + let frame = &*world; + let stepped = step_corpse(slot, anim_delta, time_ms, |start, end, mask| { + frame.trace_clip(start, end, PLAYER_MINS, PLAYER_MAXS, mask) + }); + world.corpses_mut().slots[i] = stepped; } let live = world.corpses().entnums(); world.corpse_dobj_tree_retain(&live); } +fn step_corpse( + mut slot: PlayerCorpseSlot, + anim_delta: Option<[f32; 3]>, + time_ms: i32, + mut trace: impl FnMut([f32; 3], [f32; 3], u32) -> trace_iw4::Trace, +) -> PlayerCorpseSlot { + let was_falling = slot.falling; + let ragdoll = is_ragdoll_tr_type(slot.tr_type); + let anim_delta = anim_delta.filter(|d| { + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > CORPSE_ANIM_DELTA_LEN_SQ_MIN + && (!was_falling || !ragdoll) + }); + if !was_falling && anim_delta.is_none() { + return slot; + } + let world_delta = anim_delta.map(|delta| { + let step = corpse_anim_world_delta(slot.viewangles, delta); + if was_falling { + step + } else { + [step[0], step[1], 0.0] + } + }); + + let mut desired = evaluate_trajectory(&slot.trajectory(), time_ms); + if let Some(step) = world_delta { + for axis in 0..3 { + desired[axis] += step[axis]; + } + } + let hit = trace(slot.origin, desired, MASK_PLAYER_SOLID); + let endpos = if hit.fraction >= 1.0 { + desired + } else { + hit.endpos + }; + slot.origin = endpos; + + if hit.fraction >= 1.0 && hit.startsolid == 0 { + let Some(step) = world_delta else { + return slot; + }; + if was_falling { + for (base, step) in slot.tr_base.iter_mut().zip(step) { + *base += step; + } + return slot; + } + slot.tr_type = if ragdoll { + TR_RAGDOLL_SETTLED + } else { + TR_INTERPOLATE + }; + slot.tr_base = endpos; + slot.tr_time = 0; + slot.tr_duration = 0; + slot.tr_delta = [0.0; 3]; + follow_ground_or_fall(&mut slot, time_ms, ragdoll, &mut trace); + return slot; + } + + if !was_falling { + slot.tr_base = endpos; + if hit.fraction > 0.0 && hit.startsolid == 0 { + follow_ground_or_fall(&mut slot, time_ms, ragdoll, &mut trace); + } + return slot; + } + + if hit.allsolid != 0 { + let mut lifted = slot.origin; + lifted[2] += CORPSE_UNSTICK_Z_LIFT; + let retry = trace( + lifted, + desired, + MASK_PLAYER_SOLID & !CORPSE_UNSTICK_CLIPMASK_DROP, + ); + if retry.allsolid == 0 { + let f = retry.fraction; + slot.origin = [ + lifted[0] + (desired[0] - lifted[0]) * f, + lifted[1] + (desired[1] - lifted[1]) * f, + lifted[2] + (desired[2] - lifted[2]) * f, + ]; + } + land_corpse(&mut slot, &hit); + return slot; + } + if hit.normal[2] <= 0.0 { + if hit.startsolid != 0 { + for axis in 0..3 { + slot.origin[axis] += hit.normal[axis]; + } + slot.tr_base = slot.origin; + slot.tr_time = time_ms; + slot.tr_delta = [0.0; 3]; + return slot; + } + slide_down_wall(&mut slot, &hit, desired, time_ms, &mut trace); + return slot; + } + land_corpse(&mut slot, &hit); + slot +} + +fn follow_ground_or_fall( + slot: &mut PlayerCorpseSlot, + time_ms: i32, + ragdoll: bool, + trace: &mut impl FnMut([f32; 3], [f32; 3], u32) -> trace_iw4::Trace, +) { + let at = slot.origin; + let mut probe = at; + probe[2] -= CORPSE_GROUND_PROBE_DROP; + let ground = trace(at, probe, MASK_PLAYER_SOLID); + if ground.fraction < 1.0 || ground.startsolid != 0 { + slot.falling = false; + return; + } + let mut step_probe = at; + step_probe[2] -= CORPSE_STEP_DOWN; + let step = trace(at, step_probe, MASK_PLAYER_SOLID); + if step.fraction < 1.0 && step.startsolid == 0 && step.allsolid == 0 && step.normal[2] > 0.0 { + slot.origin = step.endpos; + slot.tr_base = step.endpos; + slot.falling = false; + slot.ground_entity_num = i32::from(trace_iw4::trace_get_entity_hit_id( + step.hit_type, + step.hit_id, + )); + return; + } + slot.falling = true; + slot.tr_type = if ragdoll { + TR_BOUNCE_GRAVITY + } else { + TR_GRAVITY + }; + slot.tr_base = at; + slot.tr_time = time_ms; + slot.tr_duration = 0; + slot.tr_delta = [0.0; 3]; +} + +fn slide_down_wall( + slot: &mut PlayerCorpseSlot, + hit: &trace_iw4::Trace, + desired: [f32; 3], + time_ms: i32, + trace: &mut impl FnMut([f32; 3], [f32; 3], u32) -> trace_iw4::Trace, +) { + let n = hit.normal; + let mut start = hit.endpos; + let mut rest = [0.0f32; 3]; + for axis in 0..3 { + start[axis] += n[axis] * CORPSE_WALL_SLIDE_GAP; + rest[axis] = desired[axis] - hit.endpos[axis]; + } + let into_wall = dot(rest, n); + let mut end = start; + for axis in 0..3 { + end[axis] += rest[axis] - into_wall * n[axis]; + } + let slid = trace(start, end, MASK_PLAYER_SOLID); + if slid.allsolid == 0 && slid.startsolid == 0 { + slot.origin = if slid.fraction >= 1.0 { + end + } else { + slid.endpos + }; + if slid.fraction < 1.0 && slid.normal[2] > 0.0 { + land_corpse(slot, &slid); + return; + } + } + let speed = evaluate_trajectory_delta(&slot.trajectory(), time_ms); + let mut velocity = [0.0, 0.0, speed[2]]; + let into = dot(velocity, n); + if into < 0.0 { + for axis in 0..3 { + velocity[axis] -= into * n[axis]; + } + } + slot.tr_base = slot.origin; + slot.tr_time = time_ms; + slot.tr_duration = 0; + slot.tr_delta = velocity; +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + fn corpse_anim_world_delta(angles: [f32; 3], delta: [f32; 3]) -> [f32; 3] { let (mut fwd, right, up) = math_iw4::angle_vectors(angles); let mut left = [ @@ -364,18 +466,9 @@ fn corpse_anim_world_delta(angles: [f32; 3], delta: [f32; 3]) -> [f32; 3] { out } -fn corpse_land(world: &mut FrameWorld, i: usize, hit: trace_iw4::Trace, time_ms: i32) { - let slot = &mut world.corpses_mut().slots[i]; +fn land_corpse(slot: &mut PlayerCorpseSlot, hit: &trace_iw4::Trace) { let ragdoll = is_ragdoll_tr_type(slot.tr_type); slot.tr_delta = [0.0; 3]; - if hit.allsolid == 0 && hit.normal[2] <= 0.0 { - for axis in 0..3 { - slot.origin[axis] += hit.normal[axis]; - } - slot.tr_base = slot.origin; - slot.tr_time = time_ms; - return; - } slot.falling = false; slot.tr_type = if ragdoll { TR_RAGDOLL_SETTLED From 8d93a3102e6e918390624f9e2fbb2876e827287c Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:17:36 +1000 Subject: [PATCH 04/23] Play a body's death animation once instead of restarting it as it falls The client kept a corpse's animation tree only while the corpse's tr_time stayed the same. The server changes tr_time whenever a body's fall changes: when it goes over an edge, when it slides down a wall, and when it lands. Each change copied the tree again from the victim, whose tree is already gone because a dead player is not posed, so the corpse got a fresh tree with the death clip back at its first frame. Bodies flew off standing upright in the clip's first 50 ms, stood against walls, and then played the whole fall again where they stopped. The server now stamps each corpse slot with the level time of the death (spawn_time_ms, sent with the corpse pool), and the client keys the tree on that time and the victim. Both stay the same for one body's life and change when the slot holds another body. When there is no victim tree to copy (a body first seen late, or an entity number that now holds another body), the fresh tree starts as far into the death clip as the body is old, so an old body shows the pose it already lies in instead of dying again. The corpse pool's wire format changes, so PROTOCOL_VERSION is 117. Seen on the Mac (mp_favela, point-blank SPAS-12 kills of a bot on an open terrace, with temporary logging): every body copies its tree once and its death clip plays once, from the victim's pose to the end. Before, the same kill copied it 18 times and drew the body upright in the clip's first 50 ms for about 0.8 s. On an iPhone build, point-blank shotgun kills on mp_favela no longer glided off upright, stood pinned to walls or replayed their fall. Co-Authored-By: Claude Opus 5.5 --- .../client/presentation/centity_runtime.rs | 1 + crates/net/src/lib.rs | 2 +- crates/net/src/transport/meta_wire.rs | 2 + .../render_anim/src/anim/remote_animation.rs | 51 ++++++++++++++++--- .../render_anim/src/occupancy/remote_body.rs | 28 +++++----- crates/sim/src/corpse.rs | 4 ++ 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/crates/net/src/client/presentation/centity_runtime.rs b/crates/net/src/client/presentation/centity_runtime.rs index 09ff883..97ab1b4 100644 --- a/crates/net/src/client/presentation/centity_runtime.rs +++ b/crates/net/src/client/presentation/centity_runtime.rs @@ -335,6 +335,7 @@ pub fn corpse_slot_to_entity_state(slot: &PlayerCorpseSlot) -> EntityState { es.number = slot.entnum; es.e_type = ET_PLAYER_CORPSE; es.client_num = i32::try_from(slot.victim.0).unwrap_or(0); + es.time2 = slot.spawn_time_ms; es.e_flags = slot.e_flags; es.tr_type = slot.tr_type; es.tr_time = slot.tr_time; diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 0419ea7..6e76cef 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -175,6 +175,6 @@ pub use transport::wire::{WireError, WireReader, WireWriter}; pub use svc_script_audio::SvcScriptAudio; -pub const PROTOCOL_VERSION: u32 = 116; +pub const PROTOCOL_VERSION: u32 = 117; pub use client::fire_verdict::{FireVerdictState, PredictedFireStatus}; diff --git a/crates/net/src/transport/meta_wire.rs b/crates/net/src/transport/meta_wire.rs index bb2a565..4930ec1 100644 --- a/crates/net/src/transport/meta_wire.rs +++ b/crates/net/src/transport/meta_wire.rs @@ -2210,6 +2210,7 @@ fn encode_corpse_pool(out: &mut WireWriter, pool: &PlayerCorpsePool) { } out.put_u8(u8::from(slot.falling)); out.put_i32(slot.ground_entity_num); + out.put_i32(slot.spawn_time_ms); } } @@ -2243,6 +2244,7 @@ fn decode_corpse_pool(input: &mut WireReader<'_>) -> Result return Err(WireError::Malformed("bad corpse slot tag")), diff --git a/crates/render_anim/src/anim/remote_animation.rs b/crates/render_anim/src/anim/remote_animation.rs index 66af2ed..956bd6f 100644 --- a/crates/render_anim/src/anim/remote_animation.rs +++ b/crates/render_anim/src/anim/remote_animation.rs @@ -12,7 +12,7 @@ pub struct PersistentRemoteTree { pub(crate) legs_restart: bool, pub(crate) torso_restart: bool, - pub(crate) occupation_tr_time: Option, + pub(crate) occupation_key: Option, pub(crate) legs_rate_sample: ClientAnimSample, @@ -88,16 +88,36 @@ pub fn skip_frozen_corpse_dobj( is_corpse && tree_reused && last_was_cache_hit && has_cache } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CorpseOccupationKey { + pub spawn_time_ms: i32, + pub victim: i32, +} + +pub fn corpse_occupation_key(es: &entity_iw4::EntityState) -> CorpseOccupationKey { + CorpseOccupationKey { + spawn_time_ms: es.time2, + victim: es.client_num, + } +} + +pub fn fresh_corpse_clip_time(age_ms: i32, clip_length_ms: i32) -> f32 { + if clip_length_ms <= 0 || age_ms <= 0 { + return 0.0; + } + (age_ms as f32 / clip_length_ms as f32).min(1.0) +} + pub fn clone_corpse_tree_from_victim( trees: &mut RemoteBodyTrees, corpse_ent: u32, victim: u32, - tr_time: i32, + key: CorpseOccupationKey, ) { if trees .by_ent .get(&corpse_ent) - .is_some_and(|slot| slot.occupation_tr_time == Some(tr_time)) + .is_some_and(|slot| slot.occupation_key == Some(key)) { return; } @@ -108,7 +128,7 @@ pub fn clone_corpse_tree_from_victim( let mut dst = src; dst.dobj = None; dst.reuse_key = None; - dst.occupation_tr_time = Some(tr_time); + dst.occupation_key = Some(key); trees.by_ent.insert(corpse_ent, dst); } @@ -129,6 +149,7 @@ pub fn advance_remote_tree( dt: f32, origin: [f32; 3], pose_time_ms: i32, + corpse_age_ms: Option, trees: &mut RemoteBodyTrees, ) -> Result { let tree = binding.tree(); @@ -139,7 +160,7 @@ pub fn advance_remote_tree( let legs_restart = legs.restart_toggle(); let torso_restart = torso.restart_toggle(); let previous = trees.by_ent.remove(&persist_key); - let occupation_tr_time = previous.as_ref().and_then(|slot| slot.occupation_tr_time); + let occupation_key = previous.as_ref().and_then(|slot| slot.occupation_key); let prev = previous.filter(|slot| Arc::ptr_eq(&slot.binding, binding)); let prev_restart_changed_legs = prev .as_ref() @@ -178,6 +199,7 @@ pub fn advance_remote_tree( let overlayed = overlay_legs_clip(&clip, &torso_clip); (Arc::new(overlayed), Some(torso_clip)) }; + let fresh = prev.is_none(); let old_legs = prev.as_ref().map(|slot| slot.legs).unwrap_or(0); let old_torso = prev.as_ref().map(|slot| slot.torso).unwrap_or(0); let old_legs_moving = prev @@ -225,7 +247,7 @@ pub fn advance_remote_tree( torso: 0, legs_restart: false, torso_restart: false, - occupation_tr_time, + occupation_key, legs_rate_sample: ClientAnimSample::default(), torso_rate_sample: ClientAnimSample::default(), }, @@ -292,6 +314,23 @@ pub fn advance_remote_tree( .set_state(id, state) .map_err(|error| error.to_string())?; } + if fresh && let Some(age_ms) = corpse_age_ms { + for (index, leaf_clip) in [ + (legs_index, Some(&clip)), + (torso_index, torso_clip.as_ref()), + ] { + let Some(leaf_clip) = leaf_clip else { + continue; + }; + let time = fresh_corpse_clip_time(age_ms, leaf_clip.length_msec()); + let mut state = slot.runtime.states()[index as usize]; + state.time = time; + state.old_time = time; + slot.runtime + .set_state(xmodel_runtime::XAnimNodeId(index), state) + .map_err(|error| error.to_string())?; + } + } clips = Some(PoseClips { clip, torso_clip, diff --git a/crates/render_anim/src/occupancy/remote_body.rs b/crates/render_anim/src/occupancy/remote_body.rs index 07fcdb8..435b046 100644 --- a/crates/render_anim/src/occupancy/remote_body.rs +++ b/crates/render_anim/src/occupancy/remote_body.rs @@ -3,10 +3,10 @@ use crate::anim::remote_body::{ RemoteBodySkinnedItem, RemoteBodySkinnedQueue, RemoteBodyTrees, RemoteModelLods, RemoteSkinModels, RemoteSkinPoseHashes, SkinAfterPose, WorldGunGap, advance_remote_tree, bind_remote_skin_models, clone_corpse_tree_from_victim, commit_assembled_body, - ensure_remote_dobj, hash_skin_matrices, occupy_lod_byte, occupy_remote_kit_dobj, packed_anim, - pose_remote_dobj, push_cached_surfaces, radii, remote_player_controller, select_remote_lods, - select_remote_models, skin_after_pose, skin_slot_need, skip_frozen_corpse_dobj, - take_unique_geom, validate_remote_tracks, zero_anim, + corpse_occupation_key, ensure_remote_dobj, hash_skin_matrices, occupy_lod_byte, + occupy_remote_kit_dobj, packed_anim, pose_remote_dobj, push_cached_surfaces, radii, + remote_player_controller, select_remote_lods, select_remote_models, skin_after_pose, + skin_slot_need, skip_frozen_corpse_dobj, take_unique_geom, validate_remote_tracks, zero_anim, }; use crate::anim::scene_submission::{AnimDObjSceneSkels, AnimDObjSceneSubmission, AnimSceneSubmit}; use crate::anim::world_weapon::PreparedItemCompositions; @@ -868,16 +868,13 @@ impl<'a> RemotePoseFrame<'a> { let persist_key = u32::from(identity.number()); let is_corpse = runtime.next_state.e_type == ET_PLAYER_CORPSE || runtime.pose_e_type == ET_PLAYER_CORPSE as u8; - let occupation_tr_time = is_corpse.then_some(runtime.next_state.tr_time); - if is_corpse { + let occupation_key = is_corpse.then(|| corpse_occupation_key(&runtime.next_state)); + if let Some(key) = occupation_key { let victim = u32::try_from(runtime.next_state.client_num).unwrap_or(0); - clone_corpse_tree_from_victim( - self.trees, - persist_key, - victim, - runtime.next_state.tr_time, - ); + clone_corpse_tree_from_victim(self.trees, persist_key, victim, key); } + let corpse_age_ms = + occupation_key.map(|key| runtime.pose_time_ms.wrapping_sub(key.spawn_time_ms)); if runtime.previous_pose.is_none() { if let Some(slot) = self.trees.get_mut(persist_key) { @@ -955,6 +952,7 @@ impl<'a> RemotePoseFrame<'a> { dt, sample.rate_origin, sample.rate_time_ms, + corpse_age_ms, trees, )?; @@ -1051,11 +1049,11 @@ impl<'a> RemotePoseFrame<'a> { } Ok(PoseOneOutcome::Posed) })(); - if let Some(tr_time) = occupation_tr_time + if let Some(key) = occupation_key && let Some(slot) = trees.get_mut(persist_key) - && slot.occupation_tr_time.is_none() + && slot.occupation_key.is_none() { - slot.occupation_tr_time = Some(tr_time); + slot.occupation_key = Some(key); } result } diff --git a/crates/sim/src/corpse.rs b/crates/sim/src/corpse.rs index a6a9209..078041f 100644 --- a/crates/sim/src/corpse.rs +++ b/crates/sim/src/corpse.rs @@ -36,6 +36,8 @@ pub struct PlayerCorpseSlot { pub falling: bool, pub ground_entity_num: i32, + + pub spawn_time_ms: i32, } impl Default for PlayerCorpseSlot { @@ -57,6 +59,7 @@ impl Default for PlayerCorpseSlot { tr_base: [0.0; 3], falling: false, ground_entity_num: ENTITYNUM_NONE, + spawn_time_ms: 0, } } } @@ -178,6 +181,7 @@ pub(crate) fn occupy_player_clone( tr_delta: clamp_clone_tr_delta(ps.velocity), tr_base: ps.origin, falling: true, + spawn_time_ms: time_ms, ..PlayerCorpseSlot::default() }, ); From 1d11c4deeecdcda54de0720b6d2be166cbaba0cd Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:17:54 +1000 Subject: [PATCH 05/23] Draw a falling body between its snapshots instead of in 20 Hz jumps present_pose drew a body between its previous and current snapshot only when both were TR_INTERPOLATE. A falling or sliding corpse (TR_GRAVITY) was drawn at its snapshot position for a whole 50 ms and then jumped, and so was every switch between resting and falling. Each snapshot position of a corpse is already where the body was at that snapshot, so corpses are now drawn between the two whatever their trajectory type. Players keep the old rule. A corpse slot is reused for later deaths, so an entity number can hold a new body. A corpse whose death time (time2) or victim changes is now a new entity and is not drawn sliding over from where the old body lay. Seen on the Mac: a flying body's drawn position changes on every frame while the server moves it. Co-Authored-By: Claude Opus 5.5 --- .../client/presentation/centity_runtime.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/net/src/client/presentation/centity_runtime.rs b/crates/net/src/client/presentation/centity_runtime.rs index 97ab1b4..f41b4a8 100644 --- a/crates/net/src/client/presentation/centity_runtime.rs +++ b/crates/net/src/client/presentation/centity_runtime.rs @@ -64,9 +64,11 @@ impl CEntityRuntime { return; }; if !self.in_next_snap() - || !matches!(self.pose_e_type as i32, ET_PLAYER | ET_PLAYER_CORPSE) - || previous.pos.tr_type != TR_INTERPOLATE - || self.current.pos.tr_type != TR_INTERPOLATE + || !lerps_between_snapshots( + self.pose_e_type as i32, + previous.pos.tr_type, + self.current.pos.tr_type, + ) { return; } @@ -92,6 +94,19 @@ impl CEntityRuntime { } } +fn lerps_between_snapshots(e_type: i32, previous_tr_type: i32, current_tr_type: i32) -> bool { + match e_type { + ET_PLAYER_CORPSE => true, + ET_PLAYER => previous_tr_type == TR_INTERPOLATE && current_tr_type == TR_INTERPOLATE, + _ => false, + } +} + +fn another_body(current: &EntityState, next: &EntityState) -> bool { + next.e_type == ET_PLAYER_CORPSE + && (current.time2 != next.time2 || current.client_num != next.client_num) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PlayerDrawGate { pub eyes_entity_num: i32, @@ -246,8 +261,9 @@ pub fn apply_existing(rt: &mut CEntityRuntime, next: EntityState, at_time_ms: i3 let was_in = rt.in_next_snap(); let e_type_changed = rt.next_state.e_type != next.e_type; let teleport = (rt.next_state.e_flags ^ next.e_flags) & EFLAGS_TELEPORT != 0; - if !was_in || e_type_changed || teleport { - let new_entity = !was_in || e_type_changed; + let other_body = another_body(&rt.next_state, &next); + if !was_in || e_type_changed || teleport || other_body { + let new_entity = !was_in || e_type_changed || other_body; reset_entity(rt, next, at_time_ms, new_entity); return; } From c88f7cce67d8327581c4229b9c0621100df46267 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:18:08 +1000 Subject: [PATCH 06/23] Run a body in a killcam replay on the replay clock A killcam seat shifts the archived world onto the live clock: entity rows, script movers and projectiles get the rebase delta added to their level times. Player corpses did not, so a body in a replay was evaluated with its trajectory and death time a whole archive length in the past. A falling body was drawn that far along its fall, its death animation was keyed on the wrong time, and the dying player's body vanished for about 0.1 s right after the hit in death killcams. An occupied body's trajectory time (when it has one) and its death time are now shifted the same way. The vanishing body was seen on the Mac. This change was part of the iPhone build on which death and final killcams were watched on mp_estate; it was not checked on its own. Co-Authored-By: Claude Opus 5.5 --- crates/net/src/policy/seat.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/net/src/policy/seat.rs b/crates/net/src/policy/seat.rs index 6c4bdc9..01d7e2b 100644 --- a/crates/net/src/policy/seat.rs +++ b/crates/net/src/policy/seat.rs @@ -377,6 +377,15 @@ fn rebase_archived_world(out: &mut Snapshot, viewer: ClientId, delta_ms: i32) { p.detonate_at_ms = p.detonate_at_ms.map(|t| t.wrapping_add(delta_ms)); p.cleanup_at_ms = p.cleanup_at_ms.wrapping_add(delta_ms); } + let bodies = out.meta.corpses.slots.iter_mut(); + for body in bodies.filter(|body| body.occupied) { + if body.tr_time != 0 { + body.tr_time = body.tr_time.wrapping_add(delta_ms); + } + if body.spawn_time_ms != 0 { + body.spawn_time_ms = body.spawn_time_ms.wrapping_add(delta_ms); + } + } for (id, ps) in &mut out.players { if *id != viewer { rebase_archived_timers(ps, delta_ms); From f5fe2e7598d9a5866283cf6e63a6eed2c7b618fc Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:18:58 +1000 Subject: [PATCH 07/23] Keep the shield a body had on its back when it died A dead player's body drew whatever shield its player had at that moment, read from the victim's live client state. That state changes after death: a bot that respawns with a riot shield in hand gives every one of its old bodies a shield on the left arm, posed with the body and sunk into the floor. On mp_subbase this showed as upright shields half in the floor beside bodies, next to the shield dropped as a pickup. Each corpse slot now records the shield stowed on the victim's back when the body is made, and sends it with the corpse pool. A body draws that shield and nothing else; a live player still draws the one it holds. A shield held in hand at death is the one dropped as an item, so the body takes no copy of it. The corpse pool's wire format changes again, so PROTOCOL_VERSION is 118. Checked on an iPhone build in two matches with riot-shield bots (mp_favela and mp_estate), killing bots with the shield held up and with it on their back: dropped shields lay flat and no shield stood upright by a body. Co-Authored-By: Claude Opus 5.5 --- crates/net/src/lib.rs | 2 +- crates/net/src/transport/meta_wire.rs | 15 ++++++++++ .../render_anim/src/occupancy/remote_body.rs | 28 +++++++++++++++++-- crates/sim/src/corpse.rs | 8 ++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 6e76cef..c67f5b7 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -175,6 +175,6 @@ pub use transport::wire::{WireError, WireReader, WireWriter}; pub use svc_script_audio::SvcScriptAudio; -pub const PROTOCOL_VERSION: u32 = 117; +pub const PROTOCOL_VERSION: u32 = 118; pub use client::fire_verdict::{FireVerdictState, PredictedFireStatus}; diff --git a/crates/net/src/transport/meta_wire.rs b/crates/net/src/transport/meta_wire.rs index 4930ec1..5d6467e 100644 --- a/crates/net/src/transport/meta_wire.rs +++ b/crates/net/src/transport/meta_wire.rs @@ -2211,6 +2211,8 @@ fn encode_corpse_pool(out: &mut WireWriter, pool: &PlayerCorpsePool) { out.put_u8(u8::from(slot.falling)); out.put_i32(slot.ground_entity_num); out.put_i32(slot.spawn_time_ms); + out.put_u32(slot.shield.map_or(0, |shield| shield.weapon)); + out.put_u8(u8::from(slot.shield.is_some_and(|shield| shield.on_back))); } } @@ -2245,6 +2247,7 @@ fn decode_corpse_pool(input: &mut WireReader<'_>) -> Result return Err(WireError::Malformed("bad corpse slot tag")), @@ -2253,6 +2256,18 @@ fn decode_corpse_pool(input: &mut WireReader<'_>) -> Result, +) -> Result, WireError> { + let weapon = input.get_u32()?; + let on_back = match input.get_u8()? { + 0 => false, + 1 => true, + _ => return Err(WireError::Malformed("bad corpse shield tag")), + }; + Ok((weapon != 0).then_some(sim::ShieldAttachment { weapon, on_back })) +} + fn encode_entity_states(out: &mut WireWriter, rows: &[entity_iw4::EntityState]) { debug_assert!(rows.len() <= u16::MAX as usize); out.put_u16(rows.len() as u16); diff --git a/crates/render_anim/src/occupancy/remote_body.rs b/crates/render_anim/src/occupancy/remote_body.rs index 435b046..48005ce 100644 --- a/crates/render_anim/src/occupancy/remote_body.rs +++ b/crates/render_anim/src/occupancy/remote_body.rs @@ -52,6 +52,26 @@ pub struct RemotePlayer { pub shield: Option, } +fn drawn_shield( + snapshot: &sim::Snapshot, + number: u16, + client: sim::ClientId, +) -> Option { + let body = snapshot + .meta + .corpses + .slots + .iter() + .find(|slot| slot.occupied && slot.entnum == i32::from(number)); + match body { + Some(body) => body.shield, + None => snapshot + .meta + .for_client(client) + .and_then(|meta| meta.shield), + } +} + #[derive(Component, Default)] pub struct RemoteFxBolts { pub flash: Option, @@ -384,7 +404,9 @@ fn occupy_remote_scene_ents( if !soldier.owns_bodies(&bodies.0) { continue; } - let shield = meta.and_then(|meta| meta.shield); + let shield = presented + .snapshot() + .and_then(|snap| drawn_shield(snap, identity.number(), client)); let held = remote_pose_sample(runtime).weapon; let (kit_models, radius, hide_part_bits) = match live_kits.filter(|_| shield.is_none()) { Some((kits, _world)) => { @@ -556,7 +578,9 @@ fn sync_remote_bodies( let marker = RemotePlayer { ffa_team, client_state_team, - shield: meta.and_then(|meta| meta.shield), + shield: presented + .snapshot() + .and_then(|snap| drawn_shield(snap, identity.number(), client)), }; if remote.is_some() { commands.entity(entity).insert((pose, marker)); diff --git a/crates/sim/src/corpse.rs b/crates/sim/src/corpse.rs index 078041f..292e930 100644 --- a/crates/sim/src/corpse.rs +++ b/crates/sim/src/corpse.rs @@ -38,6 +38,8 @@ pub struct PlayerCorpseSlot { pub ground_entity_num: i32, pub spawn_time_ms: i32, + + pub shield: Option, } impl Default for PlayerCorpseSlot { @@ -60,6 +62,7 @@ impl Default for PlayerCorpseSlot { falling: false, ground_entity_num: ENTITYNUM_NONE, spawn_time_ms: 0, + shield: None, } } } @@ -160,6 +163,10 @@ pub(crate) fn occupy_player_clone( ps: &PlayerState, time_ms: i32, ) -> u8 { + let shield = world + .client_meta(victim) + .and_then(|meta| meta.shield) + .filter(|shield| shield.on_back); let pool = world.corpses_mut(); let entnum = pool.alloc_clone_entity(); let slot = pool.get_free(ps.origin); @@ -182,6 +189,7 @@ pub(crate) fn occupy_player_clone( tr_base: ps.origin, falling: true, spawn_time_ms: time_ms, + shield, ..PlayerCorpseSlot::default() }, ); From f5ab715755b40cfcd80a176bb52c9580bca14859 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:18:13 +1000 Subject: [PATCH 08/23] Let the Predator fly down through the sky ceiling The Predator script starts the missile 14000 units from its target, or 14000 above the player on maps without Predator spawn points. On Sub Base, Wasteland, Karachi, Invasion, Rundown, Carnival, Storm and Trailer Park that start is above the map's sky ceiling, a slab of sky brushes (contents 0x800; on Sub Base at z 8128-8192), and on Fuel a rooftop Predator starts inside it. The projectile trace uses MASK_SHOT, which includes the sky bit, so the missile hit the top of the slab about 1.75 s after launch and blew up some 7,900 units above Sub Base, with the view still a wash of fog. The Predator works on those maps in MW2, so it has to pass the ceiling there. A missile is now marked as remote-controlled when a player is linked to it as its pilot, and keeps the mark for its whole flight, since the control link is dropped when the pilot dies and the missile must still pass the ceiling then. The mark goes when the projectile is gone and on game shutdown. The marked missile's world trace leaves out sky brushes. A second, sky-only trace over the same tick still stops it on a sky wall or underside (a sky face with normal z of 0.7 or less) through the usual contact detonation, so a Predator steered sideways below the ceiling still ends at the edge of the map. A sky trace that starts inside a sky brush is ignored, which covers a missile inside the slab and the Fuel start. Every other projectile keeps the full mask: an RPG fired straight up still explodes on the ceiling. Checked on the Mac on Sub Base: the RPG still explodes at z 8127.9. The unsteered Predator passes z 8192 and reaches the ground about 5.4 s after launch, with a clear view of the base before impact (before: explosion at z 8192.1 after 1.75 s). A Predator whose pilot is killed about 1.5 s after launch still passes the ceiling and lands. One steered flat below the ceiling explodes on the south sky wall at y -15391.9. On our iPhone build in a Sub Base match, Predators reached the ground with a clear view before impact. Co-Authored-By: Claude Opus 5.5 --- crates/sim/src/bullet_collision.rs | 2 + crates/sim/src/equipment.rs | 20 ++++++- crates/sim/src/frame.rs | 2 + crates/sim/src/remote_missile.rs | 85 +++++++++++++++++++++++++++ crates/sim/src/script/host/weapons.rs | 1 + 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/crates/sim/src/bullet_collision.rs b/crates/sim/src/bullet_collision.rs index 4fa21ac..daff3d6 100644 --- a/crates/sim/src/bullet_collision.rs +++ b/crates/sim/src/bullet_collision.rs @@ -9,6 +9,8 @@ use weapon_iw4::{ pub const CONTENTS_SOLID: u32 = 0x0000_0001; +pub const CONTENTS_SKY: u32 = 0x0000_0800; + pub const MASK_PLAYER_SOLID: u32 = 0x0281_0011; pub const MASK_SHOT: u32 = 0x0280_6831; diff --git a/crates/sim/src/equipment.rs b/crates/sim/src/equipment.rs index bd802fb..14431a0 100644 --- a/crates/sim/src/equipment.rs +++ b/crates/sim/src/equipment.rs @@ -647,6 +647,8 @@ pub(crate) fn think_projectile(world: &mut FrameWorld, tick: Tick, entnum: i32) } return; } + let remote = crate::remote_missile::is_remote(world, projectile.id); + let mask = crate::remote_missile::projectile_mask(remote); let mut trace_start = start; let mut hops = 0u32; let mut hop_capped = false; @@ -665,7 +667,7 @@ pub(crate) fn think_projectile(world: &mut FrameWorld, tick: Tick, entnum: i32) &BulletTraceQuery { start: trace_start, end, - mask: MASK_BULLET_WORLD, + mask, ignore: Some(projectile.owner), ignore_hit: None, ignore_model: None, @@ -737,6 +739,22 @@ pub(crate) fn think_projectile(world: &mut FrameWorld, tick: Tick, entnum: i32) } break outcome; }; + let outcome = if remote { + let sky = crate::world::clip_trace( + brushes, + bsp, + mesh, + start, + end, + [0.0; 3], + [0.0; 3], + crate::bullet_collision::CONTENTS_SKY, + &|_| true, + ); + crate::remote_missile::sky_wall_outcome(outcome, start, &sky) + } else { + outcome + }; if world.publishes_snapshot() && projectile.live && crate::script_player::means( diff --git a/crates/sim/src/frame.rs b/crates/sim/src/frame.rs index 055b48a..b6c2f02 100644 --- a/crates/sim/src/frame.rs +++ b/crates/sim/src/frame.rs @@ -608,6 +608,8 @@ impl FrameWorld<'_> { pub fn shutdown_game(&mut self) { *self.deref_mut() = SimState::new(); clear_payload_rows(self.ecs); + self.ecs + .remove_resource::(); } } diff --git a/crates/sim/src/remote_missile.rs b/crates/sim/src/remote_missile.rs index cbdef31..23cf438 100644 --- a/crates/sim/src/remote_missile.rs +++ b/crates/sim/src/remote_missile.rs @@ -1,6 +1,9 @@ +use crate::ProjectileId; +use crate::bullet_collision::{CONTENTS_SKY, ColliderId, MASK_BULLET_WORLD, TraceOutcome}; use crate::frame::FrameWorld; use crate::match_state::ClientLifecycle; use crate::world::{ClientId, Tick}; +use bevy_ecs::prelude::{Resource, World}; const PITCH_RANGE: [f32; 2] = [1.0, 87.0]; const PITCH_RATE: f32 = 15.0; @@ -9,6 +12,87 @@ const SPEED_RANGE: [f32; 2] = [3_000.0, 6_000.0]; const SPEED_UP: f32 = 2_000.0; const SPEED_DOWN: f32 = 500.0; +const SKY_CEILING_MIN_NORMAL_Z: f32 = 0.7; + +// The mark is kept for the missile's whole flight. The pilot's control link is +// dropped when the pilot dies, and the missile must still pass the sky ceiling then. +#[derive(Resource, Debug, Default)] +pub(crate) struct RemoteMissiles(Vec); + +pub(crate) fn mark_remote(world: &mut World, id: ProjectileId) { + let mut marked = world.get_resource_or_insert_with(RemoteMissiles::default); + if !marked.0.contains(&id) { + marked.0.push(id); + } +} + +pub(crate) fn is_remote(world: &mut FrameWorld, id: ProjectileId) -> bool { + world + .ecs() + .get_resource::() + .is_some_and(|marked| marked.0.contains(&id)) +} + +fn forget_gone_missiles(world: &mut FrameWorld) { + let marked = match world.ecs().get_resource::() { + Some(marked) if !marked.0.is_empty() => marked.0.clone(), + _ => return, + }; + let mut flying = Vec::with_capacity(marked.len()); + world.visit_projectiles(|p| { + if marked.contains(&p.id) { + flying.push(p.id); + } + }); + if flying.len() != marked.len() { + world.ecs().resource_mut::().0 = flying; + } +} + +pub(crate) fn projectile_mask(remote: bool) -> u32 { + if remote { + MASK_BULLET_WORLD & !CONTENTS_SKY + } else { + MASK_BULLET_WORLD + } +} + +pub(crate) fn sky_wall_outcome( + outcome: TraceOutcome, + start: [f32; 3], + sky: &trace_iw4::Trace, +) -> TraceOutcome { + if sky.fraction >= 1.0 + || sky.startsolid != 0 + || sky.allsolid != 0 + || sky.normal[2] > SKY_CEILING_MIN_NORMAL_Z + { + return outcome; + } + let reach = match outcome { + TraceOutcome::Hit { end, .. } | TraceOutcome::Miss { end } => distance(start, end), + TraceOutcome::StartSolid { .. } | TraceOutcome::Invalid { .. } => return outcome, + }; + if distance(start, sky.endpos) >= reach { + return outcome; + } + TraceOutcome::Hit { + fraction: sky.fraction, + end: sky.endpos, + normal: sky.normal, + collider: ColliderId::World { + surface_flags: sky.surface_flags, + contents: sky.contents, + glass_encoded: 0, + }, + } +} + +fn distance(a: [f32; 3], b: [f32; 3]) -> f32 { + let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() +} + pub(crate) fn steer( world: &mut FrameWorld, id: ClientId, @@ -49,6 +133,7 @@ pub(crate) fn advance(world: &mut FrameWorld, tick: Tick) { if !world.publishes_snapshot() { return; } + forget_gone_missiles(world); let now = crate::level_time_ms(tick); for id in world.client_ids_sorted() { let Some(mut link) = world.client_meta(id).and_then(|m| m.remote_missile) else { diff --git a/crates/sim/src/script/host/weapons.rs b/crates/sim/src/script/host/weapons.rs index ecc27f4..5651d54 100644 --- a/crates/sim/src/script/host/weapons.rs +++ b/crates/sim/src/script/host/weapons.rs @@ -170,6 +170,7 @@ pub(crate) fn register(registry: &mut NativeRegistry) { angles, ..Default::default() }); + crate::remote_missile::mark_remote(frame.ecs(), id); } Ok(Value::Undefined) }); From 5bc6a315002797a34fe9b3e0024cb2e8bbecd612 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:19:51 +1000 Subject: [PATCH 09/23] Show the end-of-match killcam to every player, the winner included The final killcam rarely reached the local screen, whoever made the winning kill. Three bugs stacked up. The winner was never seated. The end-of-match killcam script runs the killcam for every player, so the attacker is forced to spectate itself, and spectator resolution refused any self target. An archived seat (archivetime > 0) may now follow the viewer itself. Live spectating and cycling still skip yourself. A seat did nothing for a viewer who was still alive, and every survivor is alive at the end of a match. seat_applies() lets a seat through only when it is final or the viewer is dead, and the final flag was hardcoded to false. It now comes from the script's level.showingFinalKillcam when the seat is read, for archived seats only, so the end-of-match replay is shown to living players as well. It also feeds the killcam HUD's game-ended state. The replay lost remote cameras. The AC-130 and Chopper Gunner camera (linked_weapon_view) and the Predator link (remote_missile) are per-client rows, not player state, and the seat snapshot kept the viewer's live row, so the gunship view fell back to a third-person orbit around the body on the ground. The seat now copies the focus's archived camera rows into the viewer's row, with the Predator unlink time moved to the live clock. The rows are None when the archived focus had no remote camera, so the winner's own live gunship cannot leak into a replay. While seated on an archived killcam, a player's live body takes no movement, fire or weapon-switch input. Only end-of-match seats have a living body. The AC-130's 40 s timeout can clear the remote state between the win and the killcam, which unfroze the player; the body then walked around unseen and the view snapped to the new spot when the killcam ended. Script button reads still see the raw command. Checked on the Mac (Estate, scr_dm_scorelimit 100): an AC-130 105 mm shot wins the match and the final killcam shows the thermal gunship view with the AC-130 HUD and the Killed By card. A bot wins with an AT4 while the player lives: the player watches the rocket replay under GAME WINNING KILL. A bot shoots the player: the ordinary killcam is still the attacker's first-person replay. On our iPhone build in an Estate match, the final killcam played after the win and its timer counted down. Co-Authored-By: Claude Opus 5.5 --- crates/net/src/policy/killcam.rs | 2 +- crates/net/src/policy/seat.rs | 32 +++++++++++++++++++++++ crates/sim/src/match_state/client_view.rs | 2 ++ crates/sim/src/script/host/players.rs | 19 ++++++++++++-- crates/sim/src/script/host/spectators.rs | 30 ++++++++++++++++++++- crates/sim/src/script/mod.rs | 1 + crates/sim/src/step.rs | 1 + 7 files changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/net/src/policy/killcam.rs b/crates/net/src/policy/killcam.rs index 77ac580..7867233 100644 --- a/crates/net/src/policy/killcam.rs +++ b/crates/net/src/policy/killcam.rs @@ -86,7 +86,7 @@ pub(crate) fn play_script_seats( focus_life, focus, ends_at_ms: i32::MAX, - final_kill: false, + final_kill: seat.final_kill && seat.archive_ms > 0, started_at_ms, killcamoffset_ms: seat.archive_ms, predelay_ms: 0, diff --git a/crates/net/src/policy/seat.rs b/crates/net/src/policy/seat.rs index 6c4bdc9..5c5a884 100644 --- a/crates/net/src/policy/seat.rs +++ b/crates/net/src/policy/seat.rs @@ -273,6 +273,13 @@ fn seat_snapshot( return (live.clone(), None); } let archived = lookup.tick.and_then(|tick| archive.frame(tick)); + overlay_archived_cameras( + &mut out, + live, + archived.map(|f| &f.snapshot), + viewer, + session.focus_client, + ); overlay_killcam_hud( &mut out, live, @@ -341,6 +348,31 @@ fn overlay_archived_world( out } +fn overlay_archived_cameras( + out: &mut Snapshot, + live: &Snapshot, + archived: Option<&Snapshot>, + viewer: ClientId, + focus: ClientId, +) { + let focus_row = archived.and_then(|snap| snap.meta.for_client(focus)); + let delta_ms = archived.map_or(0, |snap| { + sim::level_time_ms(live.tick).wrapping_sub(sim::level_time_ms(snap.tick)) + }); + let linked_weapon_view = focus_row.and_then(|meta| meta.linked_weapon_view); + let remote_missile = + focus_row + .and_then(|meta| meta.remote_missile) + .map(|link| sim::RemoteMissile { + unlink_at_ms: link.unlink_at_ms.map(|ms| ms.wrapping_add(delta_ms)), + ..link + }); + if let Some((_, meta)) = out.meta.clients.iter_mut().find(|(id, _)| *id == viewer) { + meta.linked_weapon_view = linked_weapon_view; + meta.remote_missile = remote_missile; + } +} + fn rebase_entity_state(es: &mut entity_iw4::EntityState, delta_ms: i32) { if es.tr_time != 0 { es.tr_time = es.tr_time.wrapping_add(delta_ms); diff --git a/crates/sim/src/match_state/client_view.rs b/crates/sim/src/match_state/client_view.rs index a802bdc..db8101d 100644 --- a/crates/sim/src/match_state/client_view.rs +++ b/crates/sim/src/match_state/client_view.rs @@ -6,6 +6,7 @@ pub struct ScriptSeat { pub archive_ms: i32, pub ps_offset_ms: i32, pub length_ms: i32, + pub final_kill: bool, } impl Default for ScriptSeat { @@ -17,6 +18,7 @@ impl Default for ScriptSeat { archive_ms: 0, ps_offset_ms: 0, length_ms: 0, + final_kill: false, } } } diff --git a/crates/sim/src/script/host/players.rs b/crates/sim/src/script/host/players.rs index 0890db5..00d7365 100644 --- a/crates/sim/src/script/host/players.rs +++ b/crates/sim/src/script/host/players.rs @@ -1061,19 +1061,34 @@ impl PlayerSlot { } pub(crate) fn script_seats(world: &World) -> Vec<(ClientId, crate::ScriptSeat)> { - world - .resource::() + let runtime = world.resource::(); + let final_kill = level_flag(runtime, "showingfinalkillcam"); + runtime .players .iter() .filter(|(_, slot)| &*slot.sessionstate == "spectator" && slot.spectator.target.is_some()) .map(|(client, slot)| { let mut seat = slot.seat; seat.spectator_client = slot.spectator.target.unwrap() as i32; + seat.final_kill = final_kill; (ClientId(*client), seat) }) .collect() } +fn level_flag(runtime: &RoundScript, name: &str) -> bool { + let symbol = runtime + .program + .as_ref() + .and_then(|program| program.symbol_ids.get(name)) + .or_else(|| runtime.dynamic_symbols.get(name)); + match symbol.and_then(|symbol| runtime.objects.get(&0)?.get(symbol)) { + Some(Value::Int(n)) => *n != 0, + Some(Value::Float(f)) => *f != 0.0, + _ => false, + } +} + const SEAT_FIELDS: [&str; 6] = [ "forcespectatorclient", "killcamentity", diff --git a/crates/sim/src/script/host/spectators.rs b/crates/sim/src/script/host/spectators.rs index 8274fb1..20d71df 100644 --- a/crates/sim/src/script/host/spectators.rs +++ b/crates/sim/src/script/host/spectators.rs @@ -97,6 +97,32 @@ pub(crate) fn advance(world: &mut World) { update(world, true); } +pub(crate) fn hold_killcam_viewer( + world: &mut World, + client: ClientId, + cmd: &mut playerstate_iw4::UserCmd, +) { + let seated = world + .get_resource::() + .and_then(|runtime| runtime.players.get(&client.0)) + .is_some_and(|slot| { + &*slot.sessionstate == "spectator" + && slot.seat.archive_ms > 0 + && slot.spectator.target.is_some() + }); + if !seated { + return; + } + use playerstate_iw4::buttons; + let held = FrameWorld::from_world(world) + .player(client) + .map_or(0, |ps| ps.weapon); + cmd.forwardmove = 0; + cmd.rightmove = 0; + cmd.buttons &= buttons::PRONE | buttons::CROUCH | buttons::STANCE_HELD; + cmd.weapon = held as u16; +} + fn update(world: &mut World, input: bool) { let replacement = RoundScript::new(world.resource::()); let mut runtime = std::mem::replace(&mut *world.resource_mut::(), replacement); @@ -148,7 +174,9 @@ fn update(world: &mut World, input: bool) { && !archived && pressed & (playerstate_iw4::buttons::ATTACK | playerstate_iw4::buttons::ADS) != 0; if let Some(forced) = forced { - slot.spectator.target = allowed.contains(&forced).then_some(forced); + let own_replay = + archived && forced == client && targets.iter().any(|(id, ..)| *id == client); + slot.spectator.target = (own_replay || allowed.contains(&forced)).then_some(forced); slot.spectator.free = false; } else if input && pressed & playerstate_iw4::buttons::USE != 0 diff --git a/crates/sim/src/script/mod.rs b/crates/sim/src/script/mod.rs index fe256a7..749778a 100644 --- a/crates/sim/src/script/mod.rs +++ b/crates/sim/src/script/mod.rs @@ -37,6 +37,7 @@ pub(crate) use host::players::{ pub(crate) use host::presence::sync_presence; pub use host::registry::{Native, NativeRegistry}; pub(crate) use host::restart::restart_level; +pub(crate) use host::spectators::hold_killcam_viewer; pub(crate) use host::weapons::{publish_projectile_launches, sync_engine_events}; pub use ir::IR_VERSION; pub(crate) use ir::{Binary, Callee, Function, Global, Op, Unary}; diff --git a/crates/sim/src/step.rs b/crates/sim/src/step.rs index 4f09414..8ca418d 100644 --- a/crates/sim/src/step.rs +++ b/crates/sim/src/step.rs @@ -393,6 +393,7 @@ fn run_players_system(ecs: &mut World) { crate::script::player_commands(world.ecs(), id.0, cmd.buttons, old_buttons); let mut cmd = *cmd; crate::script_player::constrain_cmd(&mut world, *id, &mut cmd); + crate::script::hold_killcam_viewer(world.ecs(), *id, &mut cmd); crate::script::select_location(world.ecs(), id.0, &mut cmd, old_buttons); if world .client_meta(*id) From 679118da0bf3bcaa5169ef0d4c52b79ec4edad5e Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:24:01 +1000 Subject: [PATCH 10/23] Light walls with the warm colour of muzzle flashes and explosions Muzzle flashes, bullet impacts and explosions lit walls and snow blue or cyan while their own flash and fireball sprites stayed orange. The effect colour is stored B,G,R,A. render_fx swaps it once to R,G,B,A for every sprite, trail and spark, and the light path then passed that value to a helper that swapped red and blue a second time. GfxLightPack.color is R,G,B like a map primary light, so every effect omni and spot light came out with red and blue reversed. The helper is now elem_light_color_rgb and keeps the order. The light instance field and the omni and spot pack parameters are renamed color_rgb so the names say what the value holds. Read in the same order the sprites use, the light elements of these effects are amber and orange, matching their flash and fireball sprites. Measured on the Mac on mp_subbase, facing the concrete wall above the snow bank: the light an RPG blast adds to wall and snow went from linear R:G:B 0.28:0.81:1.00 (cyan) to 1.00:0.82:0.33 (amber), and an M4 burst from 0.34:0.48:1.00 (blue) to 1.00:0.60:0.55 (warm). Wall and snow before the shots are unchanged, since map lights take another path. On an iPhone build on mp_subbase, a long burst at a concrete wall lit it warm instead of blue. Co-Authored-By: Claude Opus 5.5 --- crates/fx/src/draw.rs | 2 +- crates/fx_iw4/src/cull.rs | 9 +++------ crates/fx_iw4/src/lib.rs | 2 +- crates/lighting_iw4/src/scene_light.rs | 12 ++++++------ crates/render_frontend/src/adapters/fx/system.rs | 4 ++-- crates/render_fx/src/present.rs | 4 ++-- 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/fx/src/draw.rs b/crates/fx/src/draw.rs index e8dde36..84b366c 100644 --- a/crates/fx/src/draw.rs +++ b/crates/fx/src/draw.rs @@ -205,7 +205,7 @@ pub struct FxElemLightInstance { pub is_spot: bool, pub origin: [f32; 3], pub radius: f32, - pub color_bgr: [f32; 3], + pub color_rgb: [f32; 3], pub axis: [[f32; 3]; 3], } diff --git a/crates/fx_iw4/src/cull.rs b/crates/fx_iw4/src/cull.rs index 5987701..84c9d22 100644 --- a/crates/fx_iw4/src/cull.rs +++ b/crates/fx_iw4/src/cull.rs @@ -94,11 +94,8 @@ pub fn cull_elem_light( } #[inline] -pub fn elem_light_color_bgr(color_rgba: [u8; 4], scale: f32) -> [f32; 3] { +pub fn elem_light_color_rgb(color_rgba: [u8; 4], scale: f32) -> [f32; 3] { let s = scale * (crate::FX_RECIP_255 as f32); - [ - color_rgba[2] as f32 * s, - color_rgba[1] as f32 * s, - color_rgba[0] as f32 * s, - ] + let [r, g, b, _a] = color_rgba; + [r as f32 * s, g as f32 * s, b as f32 * s] } diff --git a/crates/fx_iw4/src/lib.rs b/crates/fx_iw4/src/lib.rs index d8695f0..cc85018 100644 --- a/crates/fx_iw4/src/lib.rs +++ b/crates/fx_iw4/src/lib.rs @@ -95,7 +95,7 @@ pub use collide::{ }; pub use cull::{ FX_ELEM_FLAG_CULL_DRAW_5_PLANES, cull_cloud, cull_cloud_plane_count, cull_cloud_radius, - cull_elem_for_spawn_allows, cull_elem_light, cull_sphere, elem_light_color_bgr, + cull_elem_for_spawn_allows, cull_elem_light, cull_sphere, elem_light_color_rgb, }; pub use draw::{FX_DRAW_ELEM_HANDLER_PRESENT, FxElemType, draw_elem_handler_present}; pub use effect_def::FxEffectDef; diff --git a/crates/lighting_iw4/src/scene_light.rs b/crates/lighting_iw4/src/scene_light.rs index 1b0fb11..547fce7 100644 --- a/crates/lighting_iw4/src/scene_light.rs +++ b/crates/lighting_iw4/src/scene_light.rs @@ -212,10 +212,10 @@ pub fn add_omni_light_to_scene_allows( Ok(()) } -pub fn omni_light_pack(origin: [f32; 3], radius: f32, color_bgr: [f32; 3]) -> GfxLightPack { +pub fn omni_light_pack(origin: [f32; 3], radius: f32, color_rgb: [f32; 3]) -> GfxLightPack { GfxLightPack { light_type: GFX_LIGHT_TYPE_OMNI, - color: color_bgr, + color: color_rgb, direction: [0.0; 3], origin, radius, @@ -281,7 +281,7 @@ pub fn spot_light_pack( origin: [f32; 3], forward: [f32; 3], radius: f32, - color_bgr: [f32; 3], + color_rgb: [f32; 3], cone: SpotLightConeDvars, ) -> GfxLightPack { let end = spot_light_clamp_end(cone.start_radius, cone.end_radius, radius); @@ -301,9 +301,9 @@ pub fn spot_light_pack( GfxLightPack { light_type: GFX_LIGHT_TYPE_SPOT, color: [ - color_bgr[0] * cone.brightness, - color_bgr[1] * cone.brightness, - color_bgr[2] * cone.brightness, + color_rgb[0] * cone.brightness, + color_rgb[1] * cone.brightness, + color_rgb[2] * cone.brightness, ], direction: dir, origin, diff --git a/crates/render_frontend/src/adapters/fx/system.rs b/crates/render_frontend/src/adapters/fx/system.rs index 22e3b8a..fd63272 100644 --- a/crates/render_frontend/src/adapters/fx/system.rs +++ b/crates/render_frontend/src/adapters/fx/system.rs @@ -707,7 +707,7 @@ fn commit_fx_transaction( light.origin, light.axis[0], light.radius, - light.color_bgr, + light.color_rgb, spot_cone, )), Err(lighting_iw4::AddOmniLightRefuse::Cap) => { @@ -725,7 +725,7 @@ fn commit_fx_transaction( Ok(()) => env.dlights.scene.push(lighting_iw4::omni_light_pack( light.origin, light.radius, - light.color_bgr, + light.color_rgb, )), Err(lighting_iw4::AddOmniLightRefuse::Cap) => { env.dlights.cap_full = env.dlights.cap_full.saturating_add(1); diff --git a/crates/render_fx/src/present.rs b/crates/render_fx/src/present.rs index 8178b88..586197f 100644 --- a/crates/render_fx/src/present.rs +++ b/crates/render_fx/src/present.rs @@ -19,7 +19,7 @@ use fx::{ use fx_iw4::{ FX_ELEM_ATLAS_OFF, FX_ELEM_ATLAS_SIZE, FxElemType, FxRandomChannel, apply_lighting_frac_bgra, build_cloud, clamp_elem_rotation_time, cull_cloud, cull_elem_light, - effect_def_needs_lighting_sample, elem_light_color_bgr, elem_uses_lighting_frac, + effect_def_needs_lighting_sample, elem_light_color_rgb, elem_uses_lighting_frac, evaluate_rotation_total, evaluate_scale, evaluate_size0, evaluate_size1, get_elem_angles_axis, get_velocity_at_time, sample_f32, sprite_atlas_uv, vec3_normalize, vis_blocker_add_prepared, }; @@ -1467,7 +1467,7 @@ pub fn build_fx_verts( is_spot: ctx.elem_type == FxElemType::SpotLight as u8, origin: ctx.origin, radius: size0, - color_bgr: elem_light_color_bgr(color, scale), + color_rgb: elem_light_color_rgb(color, scale), axis, }) }; From b603b1834eb3e0bfead0ce631d7a7e00b8a0daf6 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:26:37 +1000 Subject: [PATCH 11/23] Show the bound button in menu text that names +activate or +usereload The Chopper Gunner hint reads '[{+activate}] Toggle Thermal'. The controller layouts bind the use button as +usereload, and the menu-text lookup only tried the exact command, so the code word stayed on screen. The game treats the two commands as one button, and the other hint texts (use hints, splashes, HUD elements) already map both. Menu text now falls back to the other name too. Checked on our iPhone build with a controller layout in a Favela match: the Chopper Gunner hint reads '[WEST] Toggle Thermal'. Co-Authored-By: Claude Opus 5.5 --- crates/hud/src/weaponbar.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/hud/src/weaponbar.rs b/crates/hud/src/weaponbar.rs index b2ea179..d1cc20a 100644 --- a/crates/hud/src/weaponbar.rs +++ b/crates/hud/src/weaponbar.rs @@ -106,7 +106,14 @@ impl WeaponbarExprHost<'_> { impl ExprHost for WeaponbarExprHost<'_> { fn binding_label(&self, command: &str) -> Option<&str> { - self.input?.binding_keys.get(command).map(String::as_str) + let keys = &self.input?.binding_keys; + keys.get(command) + .or_else(|| match command { + "+activate" => keys.get("+usereload"), + "+usereload" => keys.get("+activate"), + _ => None, + }) + .map(String::as_str) } fn ui_active(&self) -> Result { From a7ae6120d5e2824cb5240c3e77dc640248f725bb Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:27:12 +1000 Subject: [PATCH 12/23] Make centred 16:9 HUD overlays cover screens wider than 16:9 The Chopper Gunner's dark frame and the night-vision goggles are drawn as one centred 854x480 item in the HUD chrome menus, so on a 2.17:1 phone screen they stopped short of both sides and left a bright strip down each edge. Items in the HUD chrome menus with exactly that rect and a background now get the `cover` scaling the main menu's background art already uses. Nothing changes at 16:9 or narrower. Cover scaling grew an item to the screen's width but kept its top edge, so the extra height all went below. For the gunner's frame that put its centre, and the reticle drawn in it, 11% of the screen height under the view centre, where the bullets go. A covering item now grows about its centre on both axes. This also applies to the main menu's background art, which is now cropped equally at the top and bottom on wide screens instead of only at the bottom. Checked on the Mac at 2868x1320: before, bright strips on both sides of the gunner's frame; after, the frame reaches both edges and the reticle sits on the view centre. On our iPhone build in a Favela match the frame reached both edges, and the bullets and crosshair lined up. Co-Authored-By: Claude Opus 5.5 --- crates/asset_game/src/menu_catalog.rs | 18 ++++++++++++++++++ crates/hud/src/chrome.rs | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/asset_game/src/menu_catalog.rs b/crates/asset_game/src/menu_catalog.rs index 6b513ef..6b3dfb3 100644 --- a/crates/asset_game/src/menu_catalog.rs +++ b/crates/asset_game/src/menu_catalog.rs @@ -552,6 +552,23 @@ pub const HUD_CHROME_MENUS: &[&str] = &[ "perks_info_hd", ]; +fn cover_wide_hud_overlays(catalog: &mut MenuCatalog) { + for name in HUD_CHROME_MENUS { + let Some(menu) = catalog.menus.get_mut(*name) else { + continue; + }; + for item in &mut menu.items { + let rect = &item.rect; + if !item.background.is_empty() + && (rect.x, rect.y, rect.w, rect.h) == (-427.0, -240.0, 854.0, 480.0) + && (rect.horz_align, rect.vert_align) == (2, 2) + { + item.cover = true; + } + } + } +} + pub fn ui_games_root(games: &GamesRoot) -> Result { let anchor = find_zone_file(games, "iw4:common_mp")?; game_root_for_zone(&anchor.path).map(GamesRoot) @@ -1025,6 +1042,7 @@ impl MenuSink { fn finish_and_take(mut self, games: Option<&Path>) -> MenuCatalog { self.finish_ui_images(games); + cover_wide_hud_overlays(&mut self.catalog); self.catalog } diff --git a/crates/hud/src/chrome.rs b/crates/hud/src/chrome.rs index 9e8090e..f1b6cf8 100644 --- a/crates/hud/src/chrome.rs +++ b/crates/hud/src/chrome.rs @@ -948,10 +948,12 @@ fn push_stretch( ); if rest.w > f32::EPSILON && rest.w < surface.width() { let grow = surface.width() / rest.w; - let centre = applied.x + applied.w * 0.5; + let centre_x = applied.x + applied.w * 0.5; + let centre_y = applied.y + applied.h * 0.5; applied.w *= grow; applied.h *= grow; - applied.x = centre - applied.w * 0.5; + applied.x = centre_x - applied.w * 0.5; + applied.y = centre_y - applied.h * 0.5; } } let color = style.fill_color(item); From 2d9123a73e2eacd4f265aa0ab3f3492fadba9981 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:27:21 +1000 Subject: [PATCH 13/23] Fire +activate player-command notifies on a controller's use button A controller binds its use button as +usereload, which sets USE_RELOAD, while notifies registered for "+activate" (and "+usereload") only matched USE. The Chopper Gunner's and the AC-130's thermal toggles listen for +activate, so on a controller pressing the use button did nothing; a keyboard sends +activate and worked. Both commands now match either bit, as the use-button read in scripts already does. Checked on the Mac (Favela): 'press +usereload' in the Chopper Gunner flips the picture to black-hot and back. On our iPhone build in a Favela match, the RELOAD touch button (bound as +usereload) flipped the gunner's picture several times. Co-Authored-By: Claude Opus 5.5 --- crates/sim/src/script/host/controls.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sim/src/script/host/controls.rs b/crates/sim/src/script/host/controls.rs index a27f292..3097295 100644 --- a/crates/sim/src/script/host/controls.rs +++ b/crates/sim/src/script/host/controls.rs @@ -419,7 +419,7 @@ pub(crate) fn select_location( pub(crate) fn command_buttons(command: &str) -> u32 { match command { "+attack" | "mouse1" => buttons::ATTACK, - "+activate" | "+usereload" => buttons::USE, + "+activate" | "+usereload" => buttons::USE | buttons::USE_RELOAD, "+melee" => buttons::MELEE_CHARGE, "+frag" => buttons::FRAG, "+smoke" => buttons::SMOKE, From 7d7bf24cd075426ee2092d35c928e1461068141d Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:27:30 +1000 Subject: [PATCH 14/23] Keep muzzle flashes and attached effects playing past the first minutes spawn_effect takes one of 255 bolt records for every effect played on a tag, and nothing gave them back: run_garbage_collection cleared the effect slot and dropped the record. A minute or two into a match with bots, the muzzle flashes and shell ejects of every shot had used them all, and from then on every bolted effect failed with EffectLimit and was dropped without a word. That took later muzzle flashes, the jets' afterburners and the precision airstrike's cluster bomb, which is played on its tag_origin model and spawns the bomblets' ground explosions, so the airstrike did its damage with no fire or smoke. Garbage collection now puts a dead effect's record back on the free list, and the ring-full check runs before a record is taken, so that refusal no longer leaks one either. Seen on an iPhone build on mp_terminal before the fix: Uzi muzzle flashes at 0:30, none under steady fire at 2:40, and no fire or smoke where a precision airstrike landed about 3 minutes in. With the fix, in another match on the same map, the stealth bomber's fire and smoke showed and the player's SCAR still flashed 4 minutes in. Co-Authored-By: Claude Opus 5.5 --- crates/fx/src/system.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/fx/src/system.rs b/crates/fx/src/system.rs index 34d360e..a9b30db 100644 --- a/crates/fx/src/system.rs +++ b/crates/fx/src/system.rs @@ -860,6 +860,14 @@ impl FxSystemHost { Some(idx as u8) } + fn free_bolt_record(&mut self, index: u8) { + let Some(next) = self.bolt_next.get_mut(usize::from(index)) else { + return; + }; + *next = self.bolt_first_free; + self.bolt_first_free = i32::from(index); + } + pub fn spawn_effect( &mut self, def_name: &str, @@ -873,6 +881,9 @@ impl FxSystemHost { if wants_spotlight && self.spotlight_count >= FX_SPOT_LIGHT_LIMIT { return Err(SpawnFail::TooManySpotlights); } + if self.ring_occupancy() >= FX_EFFECT_HANDLE_RING_SIZE as i32 { + return Err(SpawnFail::RingFull); + } let bolt_index = if bolt == FX_SPAWN_BOLT_NONE { 0xff } else { @@ -883,9 +894,6 @@ impl FxSystemHost { }; let alloc = self.first_free_effect; - if self.ring_occupancy() >= FX_EFFECT_HANDLE_RING_SIZE as i32 { - return Err(SpawnFail::RingFull); - } self.first_free_effect = alloc.wrapping_add(1); let ring_i = (alloc as u32 & (FX_EFFECT_HANDLE_RING_SIZE - 1)) as usize; @@ -986,6 +994,10 @@ impl FxSystemHost { self.set_handle_at_ring(active_index as u32, handle); active_index = active_index.wrapping_add(1); if let Some(slot) = handle_to_slot(handle) { + let bolt = self.effects[slot].bolt; + if bolt != 0xff { + self.free_bolt_record(bolt); + } self.effects[slot] = FxEffectSlot::default(); } } From ffa55e17f345a7f2c8ab11d636c4ba56476a4b5d Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:27:53 +1000 Subject: [PATCH 15/23] Aim the Chopper Gunner's turret at the point under the crosshair The turret's target was a point 8192 units along the gunner's view, measured from the helicopter's origin rather than the gunner's eye, and the bullets fly from the muzzle towards it. Short of that range the two lines don't meet, so the impacts landed well above the crosshair (about a sixth of the screen height on a phone). The target is now the end of a trace from the gunner's eye (the linked weapon view) along the view, ignoring the helicopter and the gunner, so the bullets go where the crosshair points. Checked on our iPhone build in Favela matches: before, the bullet impacts landed about 100 px above the crosshair in a 590 px tall recording; after this and the overlay centring fix, the bullets and the crosshair lined up and the gunner got three kills in one run. That build also had a steadier gunner camera, which is not part of this change. Co-Authored-By: Claude Opus 5.5 --- crates/sim/src/script/host/vehicles.rs | 55 ++++++++++++++++++++------ 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/sim/src/script/host/vehicles.rs b/crates/sim/src/script/host/vehicles.rs index cbf1a9a..2f107af 100644 --- a/crates/sim/src/script/host/vehicles.rs +++ b/crates/sim/src/script/host/vehicles.rs @@ -180,14 +180,45 @@ fn aim_point(world: &mut World, aim: TurretAim) -> Option<[f32; 3]> { } } -fn gunner_input(world: &mut World, client: u32) -> Option<([f32; 3], bool)> { - let mut frame = crate::frame::FrameWorld::from_world(world); - let angles = frame.player(crate::ClientId(client))?.viewangles; - let held = crate::script_player::buttons(&mut frame, crate::ClientId(client)); - Some(( - math_iw4::angle_vectors(angles).0, - held & playerstate_iw4::buttons::ATTACK != 0, - )) +fn gunner_input(world: &mut World, heli: u64, client: u32) -> Option<([f32; 3], bool)> { + let id = crate::ClientId(client); + let (eye, angles, held) = { + let mut frame = crate::frame::FrameWorld::from_world(world); + let ps = *frame.player(id)?; + let eye = frame + .client_meta(id) + .and_then(|meta| meta.linked_weapon_view) + .map_or( + [ + ps.origin[0], + ps.origin[1], + ps.origin[2] + ps.view_height_current, + ], + |view| view.origin, + ); + ( + eye, + ps.viewangles, + crate::script_player::buttons(&mut frame, id), + ) + }; + let dir = math_iw4::angle_vectors(angles).0; + let end = std::array::from_fn(|i| eye[i] + dir[i] * GUNNER_RANGE); + let ignore = super::natives::engine::TraceIgnore { + client: Some(id), + ..super::turrets::ignore_self(world, heli) + }; + let point = match super::natives::engine::entity_trace( + world, + eye, + end, + crate::bullet_collision::MASK_SHOT, + ignore, + ) { + crate::bullet_collision::TraceOutcome::Hit { end, .. } => end, + _ => end, + }; + Some((point, held & playerstate_iw4::buttons::ATTACK != 0)) } fn fire_weapon(world: &mut World, receiver: &Value, args: &[Value]) -> Result { @@ -992,7 +1023,7 @@ pub(crate) fn advance(world: &mut World) { .map_or((None, None), |heli| (heli.gunner, heli.weapon)); let control = gunner .filter(|client| world.resource::().players.contains_key(client)) - .and_then(|client| gunner_input(world, client)); + .and_then(|client| gunner_input(world, id, client)); let fire_ms = weapon .and_then(|weapon| crate::frame::FrameWorld::from_world(world).combat_facts_for(weapon)) .map_or(100, |facts| facts.fire_time_ms.max(1)); @@ -1111,10 +1142,8 @@ pub(crate) fn advance(world: &mut World) { } let [pitch, yaw, roll] = angles; match control { - Some((view, attack)) => { - heli.turret = Some(TurretAim::Point(std::array::from_fn(|i| { - next[i] + view[i] * GUNNER_RANGE - }))); + Some((point, attack)) => { + heli.turret = Some(TurretAim::Point(point)); heli.on_target = true; if attack && now >= heli.next_fire_ms { heli.next_fire_ms = now + fire_ms; From 8d566bc12b8669b0e48ba8c1d23a49d0b9211667 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:28:03 +1000 Subject: [PATCH 16/23] Keep a straight up or down vector pointing the same way in vect_to_angles For a vector with no horizontal part, vect_to_angles returned pitch 270 (up) for straight down and 90 (down) for straight up, the opposite of what it returns for a vector a hair off vertical and of what axis_to_angles gives. The Chopper Gunner's helicopter steers toward a pass-through goal by turning its heading toward vect_to_angles of the goal, and the helicopter script sets such a goal straight above the helicopter when the way to the next attack node is blocked. After a vertical drop onto the previous node, the heading turned to "down", and since it then never moved sideways the goal stayed exactly overhead and the helicopter flew down until the 40 s timer ended the ride, through the ground and under the map. Checked: a replay of the helicopter steering sinks 6500 units in 10 s with the old pitch and climbs to the goal with the new one. On our iPhone build the gunner had sunk through the ground on Favela near the end of a run; with the fix, in a later match, it stayed above the map and moved as expected. Co-Authored-By: Claude Opus 5.5 --- crates/math_iw4/src/angles.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/math_iw4/src/angles.rs b/crates/math_iw4/src/angles.rs index bb7217b..56ec2d4 100644 --- a/crates/math_iw4/src/angles.rs +++ b/crates/math_iw4/src/angles.rs @@ -83,9 +83,9 @@ pub fn vec_to_yaw(x: f32, y: f32) -> f32 { const VECT_TO_ANGLES_PITCH_SCALE: f32 = -57.2957763671875_f32; -const VECT_TO_ANGLES_PITCH_DOWN: f32 = 270.0; +const VECT_TO_ANGLES_PITCH_DOWN: f32 = 90.0; -const VECT_TO_ANGLES_PITCH_UP: f32 = 90.0; +const VECT_TO_ANGLES_PITCH_UP: f32 = 270.0; pub fn vect_to_angles(forward: [f32; 3]) -> [f32; 3] { let [x, y, z] = forward; @@ -118,7 +118,7 @@ fn axis_to_angles_roll_atan(v: [f32; 3]) -> f32 { let [x, y, z] = v; if y == 0.0 && x == 0.0 { return if 0.0 <= -z { - VECT_TO_ANGLES_PITCH_UP + VECT_TO_ANGLES_PITCH_DOWN } else { AXIS_TO_ANGLES_ROLL_PITCH_NEG }; From 967c2a1dad52a1875389bd9416cd38afd8e1abf5 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:28:05 +1000 Subject: [PATCH 17/23] Remove bullet marks from shot-out glass once the mark pool has wrapped The mark pool recycles its oldest slot, but the list of marks attached to script-model bones kept every entry until the match reset. Once the pool had filled, an old attachment on a car's body bone pointed at a new bullet star on its window, so the star kept drawing after the window was shot out and its glass hidden. Stray marks were also drawn through other entities' bones. An attachment is now dropped when its slot is allocated again, and whenever the slot no longer holds a mark on its own entity. This also stops shot models being posed every frame for marks that are long gone. Checked on an iPhone build on mp_estate: after 8 minutes of play, the bullet stars went with an SUV's window glass when it was shot out. Before, on the same map late in a match, the stars stayed hanging in the air after the glass was gone. Co-Authored-By: Claude Opus 5.5 --- crates/render_frontend/src/adapters/fx/entity_mark.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/render_frontend/src/adapters/fx/entity_mark.rs b/crates/render_frontend/src/adapters/fx/entity_mark.rs index 9733c39..4cb25e7 100644 --- a/crates/render_frontend/src/adapters/fx/entity_mark.rs +++ b/crates/render_frontend/src/adapters/fx/entity_mark.rs @@ -47,6 +47,11 @@ pub(crate) fn generate( models: &asset_world::MapXModelSceneCatalog, ) { let mut state = marks.lock(); + state.attached.retain(|a| { + host.marks + .constructed(a.handle) + .is_some_and(|m| m.context as u8 == 3 && (m.context >> 16) as u16 == a.entity) + }); let pending = std::mem::take(&mut state.pending); let before = state.unsupported_triangles; let before_receivers = state.unsupported_receivers; @@ -378,6 +383,7 @@ fn stage( host.marks .set_material_name(handle, Some(&request.material)); host.note_models_go_fire(); + state.attached.retain(|a| a.handle != handle); state.attached.push(EntityMarkAttachment { entity: request.entity, model: model.to_string(), From 35a6ecb4dac11d8c90031b34db9833953a8e8c74 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:28:49 +1000 Subject: [PATCH 18/23] Resolve tags on vehicles, so the AC-130 drops its flares The AC-130's flare script reads tag_flash_flares from the plane, and every call failed with "tag does not exist" (7 errors in one Sub Base match, more on Highrise); the thread stopped and no flares came out. The plane is a vehicle, not a script mover, so it is never given a posed skeleton, and the tag lookups found none. Its model is loaded and has the tag. Tag lookups now pose an entity's model at its origin and angles when it has no skeleton of its own. getTagOrigin, getTagAngles and linkTo offsets use the same path. Checked on the Mac (Sub Base, AC-130, 5 bots): 3 or more script errors before, none after; the Chopper Gunner, Predator and Pave Low in one match afterwards, no script errors. On our iPhone build in a Favela match, an AC-130 ran with no script errors. The flares themselves were not watched for. Co-Authored-By: Claude Opus 5.5 --- crates/sim/src/script/host/presence.rs | 61 ++++++++++++++++---------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/crates/sim/src/script/host/presence.rs b/crates/sim/src/script/host/presence.rs index 5ea8178..b9d2226 100644 --- a/crates/sim/src/script/host/presence.rs +++ b/crates/sim/src/script/host/presence.rs @@ -313,25 +313,54 @@ fn present(world: &mut World, now: i32) { } } -fn tag_lookup(world: &mut World, object: u64, tag: &str) -> Option> { +fn with_tag_dobj( + world: &mut World, + object: u64, + read: impl FnOnce(&AuthorityDObjState) -> Option, +) -> Option { let presence = world .resource::() .entities .get(&object)? .presence?; let frame = settled(world); - let dobj = frame + if let Some(dobj) = frame .entity_collision_capabilities() .iter() .find(|row| row.owner.script_model() == Some(presence))? .dobj - .as_ref()?; - Some(dobj.tag_world_pose(tag).map(|(at, _)| { - dobj.world_from_model - .inverse() - .transform_point3(glam::Vec3::from(at)) - .to_array() - })) + .as_ref() + { + return read(dobj); + } + let (model, origin, angles) = { + let mut runtime = world.resource_mut::(); + ( + model_field(&mut runtime, object)?, + vector(&mut runtime, object, "origin"), + vector(&mut runtime, object, "angles"), + ) + }; + let capability = FrameWorld::from_world(world) + .model_capability(&model) + .flatten()?; + read(&AuthorityDObjState::at_pose( + &model, + Some(capability), + origin, + angles, + )) +} + +fn tag_lookup(world: &mut World, object: u64, tag: &str) -> Option> { + with_tag_dobj(world, object, |dobj| { + Some(dobj.tag_world_pose(tag).map(|(at, _)| { + dobj.world_from_model + .inverse() + .transform_point3(glam::Vec3::from(at)) + .to_array() + })) + }) } pub(crate) fn tag_world( @@ -339,19 +368,7 @@ pub(crate) fn tag_world( object: u64, tag: &str, ) -> Option<([f32; 3], [[f32; 3]; 3])> { - let presence = world - .resource::() - .entities - .get(&object)? - .presence?; - let frame = settled(world); - let matrix = frame - .entity_collision_capabilities() - .iter() - .find(|row| row.owner.script_model() == Some(presence))? - .dobj - .as_ref()? - .tag_world_matrix(tag)?; + let matrix = with_tag_dobj(world, object, |dobj| dobj.tag_world_matrix(tag))?; let axis = |v: glam::Vec4| v.truncate().normalize_or_zero().to_array(); Some(( matrix.w_axis.truncate().to_array(), From 0bbab054794ae5e329137b9a61caefabc237a0ea Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:40:53 +1000 Subject: [PATCH 19/23] Let shot desk props move from where they are placed, and stand on desks A placed prop often starts inside the world: a monitor's stand sits a hair inside the desk, and its unrotated model bounds reach into the cubicle wall behind it. integrate_body stops a body that starts inside, so such a prop never moved however often it was shot, such as the back-office com_widescreen_monitor on mp_terminal. The physics also collided only with solid, while a static-model desk stands in the clipmap as item clip, so a keyboard or phone knocked off its spot dropped through the desk to the floor. On its first wake a body now takes the nearest clear pose (small lifts up to 2 units, then a footprint narrowed to 75% or 50%), colliding with solid and item clip where it can and with solid alone where it can't, and keeps that mask. A prop with no clear pose stays where it was placed and settles, as 61b7c43 made it. Props still only slide; they don't tip over. Measured on our build with a replay of the map data: of the 1007 mp_terminal dynents with physics, 78 started stuck and this frees 38 of them, and it freezes none that moved before. On an iPhone build on mp_terminal, the back-office keyboards, phones and monitors moved when shot and stayed on their desks. Co-Authored-By: Claude Opus 5.5 --- .../render_anim/src/occupancy/dyn_ent_phys.rs | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/crates/render_anim/src/occupancy/dyn_ent_phys.rs b/crates/render_anim/src/occupancy/dyn_ent_phys.rs index 82d095f..f082f70 100644 --- a/crates/render_anim/src/occupancy/dyn_ent_phys.rs +++ b/crates/render_anim/src/occupancy/dyn_ent_phys.rs @@ -17,6 +17,10 @@ const MSEC_STEP: i32 = 17; const MAX_ITERS: i32 = 2; const CONTENTS_SOLID: u32 = 1; +const CONTENTS_ITEMCLIP: u32 = 0x400; +const WAKE_MASKS: [u32; 2] = [CONTENTS_SOLID | CONTENTS_ITEMCLIP, CONTENTS_SOLID]; +const WAKE_LIFTS: [f32; 6] = [0.0, CONTACT_SLOP, 0.25, 0.5, 1.0, 2.0]; +const WAKE_FOOTPRINTS: [f32; 3] = [1.0, 0.75, 0.5]; const SLEEP_SPEED: f32 = 4.0; const GROUND_NZ: f32 = 0.7; const CONTACT_SLOP: f32 = 0.125; @@ -56,6 +60,7 @@ struct DynEntPhysBody { maxs: [f32; 3], bounce: f32, friction: f32, + mask: u32, on_ground: bool, asleep_ms: i32, } @@ -99,6 +104,7 @@ pub fn step_phys_world0( } let (mins, maxs) = xmodel_phys_hull(catalog, &inst.current_model); let add_vel = impulse.impulse / preset.mass; + let phys_clip = clip.0.as_deref(); world .bodies .entry(entity) @@ -106,15 +112,22 @@ pub fn step_phys_world0( body.vel += add_vel; body.asleep_ms = 0; }) - .or_insert_with(|| DynEntPhysBody { - origin: transform.translation, - vel: add_vel, - mins, - maxs, - bounce: preset.bounce, - friction: preset.friction, - on_ground: false, - asleep_ms: 0, + .or_insert_with(|| { + let pose = phys_clip + .and_then(|clip| clear_wake_pose(clip, transform.translation, mins, maxs)); + let (origin, mins, maxs, mask) = + pose.unwrap_or((transform.translation, mins, maxs, CONTENTS_SOLID)); + DynEntPhysBody { + origin, + vel: add_vel, + mins, + maxs, + bounce: preset.bounce, + friction: preset.friction, + mask, + on_ground: false, + asleep_ms: 0, + } }); } @@ -185,6 +198,39 @@ pub fn step_phys_world0( } } +fn clear_wake_pose( + clip: &ClipCollision, + origin: Vec3, + mins: [f32; 3], + maxs: [f32; 3], +) -> Option<(Vec3, [f32; 3], [f32; 3], u32)> { + for mask in WAKE_MASKS { + for scale in WAKE_FOOTPRINTS { + let (mins, maxs) = narrowed_footprint(mins, maxs, scale); + for lift in WAKE_LIFTS { + let at = (origin + Vec3::Z * lift).to_array(); + let probe = clip.sweep_box(at, at, mins, maxs, mask); + if !probe.startsolid && !probe.allsolid { + return Some((Vec3::from_array(at), mins, maxs, mask)); + } + } + } + } + None +} + +fn narrowed_footprint(mins: [f32; 3], maxs: [f32; 3], scale: f32) -> ([f32; 3], [f32; 3]) { + let mut lo = mins; + let mut hi = maxs; + for axis in 0..2 { + let mid = (mins[axis] + maxs[axis]) * 0.5; + let half = (maxs[axis] - mins[axis]) * 0.5 * scale; + lo[axis] = mid - half; + hi[axis] = mid + half; + } + (lo, hi) +} + fn integrate_free(body: &mut DynEntPhysBody, dt: f32) { body.vel.z += GRAVITY_Z * dt; body.origin += body.vel * dt; @@ -200,7 +246,7 @@ fn integrate_body(body: &mut DynEntPhysBody, clip: &ClipCollision, dt: f32) { end.to_array(), body.mins, body.maxs, - CONTENTS_SOLID, + body.mask, ); if hit.allsolid || hit.startsolid { body.vel = Vec3::ZERO; From 4ee1865ca0fd7578e9829779d0626aa149a0ac3b Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:40:51 +1000 Subject: [PATCH 20/23] Spawn spectators with no health so scripts stop counting them alive spawn() rebuilt the player state with full health for every session state, and map_restart respawns every client as a spectator, so a dead player, or a bot that joined mid-round and had not spawned yet, held 100 health. isAlive() is health above 0, so the scripts counted them alive. At a Search and Destroy round end the stock _matchdata logging then ran logPlayerLife on bots added mid-round, which have no spawn fields, and logged 39 script errors. Sentry turrets also took spectators as live targets. MW2's spawnPlayer sets self.health = self.maxhealth itself before it calls spawn(), and spawnSpectator, spawnIntermission and spawnEndOfGame never touch health. So now only a "playing" spawn fills health to max. A spectator holds 0, and "dead" and "intermission" keep the health the client had. Checked on a Mac with our build (S&D on mp_brecourt, 4 bots, 5 more added 20 s into round 1, then the human dies): before, every spectator held 100 health and the round-1 end logged the _matchdata errors. After, the spectators held 0, rounds 2, 3 and 4 each started with all 10 players alive at full health, and no _matchdata error was logged. The IW4L port is compile-checked only. Co-Authored-By: Claude Opus 5.5 --- crates/sim/src/script_player.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/sim/src/script_player.rs b/crates/sim/src/script_player.rs index 0b1e01b..547b750 100644 --- a/crates/sim/src/script_player.rs +++ b/crates/sim/src/script_player.rs @@ -49,6 +49,7 @@ pub(crate) fn spawn( }; world.unlink_player_area(id); world.client_meta_mut(id).shield = None; + let prev_health = world.player(id).map(|p| p.health); let mut ps = spawn_player_state(origin, angles); if let Some(max) = world .client_meta(id) @@ -56,8 +57,12 @@ pub(crate) fn spawn( .filter(|&n| n > 0) { ps.max_health = max; - ps.health = max; } + ps.health = match sessionstate { + "playing" => ps.max_health, + "dead" | "intermission" => prev_health.unwrap_or(0), + _ => 0, + }; let cmd = spawn_command_angles(world, id); ps.delta_angles = std::array::from_fn(|axis| angles[axis] - cmd[axis] as f32 * SHORT2ANGLE); let prev_teleport = world From 00849fd0e6790ea88be4de1c7a11ed431e3d4b99 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:42:18 +1000 Subject: [PATCH 21/23] Let shotgun pellets break and push props such as cash registers Bullets reach the client as bullet hit events, which on_entity_bullet_hit turns into dynent damage and impulses. Shotgun pellets (and turret rounds) arrive as pellet records instead, and drain_pellet_fx only drew their tracers and impacts, so a shotgun never broke a cash register, a suitcase or a card reader, and never pushed a prop. Each pellet segment that ends on a surface now strikes the nearest dynent on it the way a bullet does, from the authoritative copy only: drain_pellet_fx sends a DynEntPelletHit, and hit_dyn_ents_from_pellets finds the dynent on the segment and damages and pushes it with the weapon's hit damage. Checked on the Mac on mp_terminal, two short AA-12 bursts at the food-court register: before, the register and card readers stayed whole; after, both broke. Reported from an iPhone match where the AA-12's pellets kicked up dust on suitcases that a SCAR then burst open. Co-Authored-By: Claude Opus 5.5 --- .../src/adapters/anim/dyn_ent_wake.rs | 68 +++++++++++++++++++ .../render_frontend/src/adapters/fx/system.rs | 11 ++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs b/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs index 3b3709b..fd1fa7b 100644 --- a/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs +++ b/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs @@ -52,6 +52,14 @@ pub struct DynEntWakeBroadphase { pub query_ms: f32, } +#[derive(Clone, Copy, Debug, Message)] +pub struct DynEntPelletHit { + pub world: frame::WorldStamp, + pub weapon: u32, + pub start: Vec3, + pub end: Vec3, +} + #[derive(Clone, Copy, Debug)] struct DynEntWakeEntry { entity: Entity, @@ -787,6 +795,59 @@ pub(crate) fn on_entity_bullet_hit( } } +#[allow(clippy::too_many_arguments)] +pub(crate) fn hit_dyn_ents_from_pellets( + mut hits: MessageReader, + weapons: Option>, + catalog: Option>, + mut broadphase: ResMut, + mut instances: Query< + ( + Entity, + &mut WorldDynEntInstance, + &Transform, + &mut Visibility, + ), + With, + >, + mut impulses: MessageWriter, + mut phys: ResMut, + mut host: Option>, + fx: Option>, + scene: Option>, + entity_marks: Res, +) { + let catalog = catalog.as_deref(); + let fx = fx.as_deref(); + let scene = scene.as_deref(); + let marks = entity_marks.as_ref(); + for hit in hits.read() { + let Some((entity, pose, bullet_scale)) = + closest_segment_hit(hit.start, hit.end, catalog, &mut broadphase, &instances) + else { + continue; + }; + let weapons = weapons + .as_deref() + .and_then(|weapons| weapons.for_event(hit.world).ok()); + hit_one_dyn_ent( + entity, + pose, + hit.start, + hit.end, + bullet_scale, + weapon_hit_damage(weapons.as_ref(), hit.weapon), + &mut instances, + &mut impulses, + phys.as_mut(), + host.as_deref_mut(), + fx, + scene, + marks, + ); + } +} + pub(crate) fn on_entity_event_sound( sound: On, weapons: Option>, @@ -917,6 +978,7 @@ fn on_physics_sphere( pub fn register_dyn_ent_wake(app: &mut App) { app.scoped::(frame::MatchScope::Live) + .scoped_message::(frame::MatchScope::Live) .add_observer(on_entity_explosion) .add_observer(on_physics_sphere) .add_observer(on_entity_bullet_hit) @@ -927,6 +989,12 @@ pub fn register_dyn_ent_wake(app: &mut App) { .in_set(frame::InMatch) .in_set(net::ClientSet::Receive), ) + .add_systems( + Update, + hit_dyn_ents_from_pellets + .in_set(frame::InMatch) + .in_set(net::ClientSet::Present), + ) .add_systems( Update, wake_player_overlap diff --git a/crates/render_frontend/src/adapters/fx/system.rs b/crates/render_frontend/src/adapters/fx/system.rs index 22e3b8a..489c68a 100644 --- a/crates/render_frontend/src/adapters/fx/system.rs +++ b/crates/render_frontend/src/adapters/fx/system.rs @@ -3261,12 +3261,13 @@ fn drain_pellet_fx( tracers: Option>, mut tracer_world: ResMut, mut gate: ResMut, - (local, generation, timeline, mut occurrences, verdicts): ( + (local, generation, timeline, mut occurrences, verdicts, mut dyn_ent_hits): ( Res, Res, Res, ResMut, Res, + MessageWriter, ), mut host: ResMut, mut cursor: ResMut, @@ -3280,6 +3281,14 @@ fn drain_pellet_fx( if world != generation.stamp() { continue; } + if domain != net::EntityEventDomain::Predicted && record.normal != [0.0; 3] { + dyn_ent_hits.write(crate::adapters::anim::dyn_ent_wake::DynEntPelletHit { + world, + weapon: record.weapon, + start: Vec3::from_array(record.start), + end: Vec3::from_array(record.end), + }); + } let now = host.0.msec_now; let request = FireFxRequest { world, From 637128a8ac1a335ff477c79684b999454d3269d8 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:51:23 +1000 Subject: [PATCH 22/23] Return to the menu with the map error when the map walk panics The map walk runs as a task on the load pool. When it, or a task it waits on, panics, the executor hands the panic to whoever polls the task: poll_match_load on the main thread, which then unwinds out of the app and closes it. Bad or unexpected map data therefore closed the game instead of failing the load. The poll now runs inside catch_unwind, and a panic becomes the MapLoadFailed a refused map already produces. The session returns to the menu, which shows the map error dialog ("Could not load map ''. The load stopped on an internal error: "), and the log gets an error line. The default panic hook still prints where it happened. The rest is the refused-map path, unchanged: the task, MatchLoadAccepted and the busy flag go, and the next request gets its own LoadProgress. Checked on IW4 Pocket's build before this port (Mac M3, menu) with a panic injected into the walk of mp_rust right after its zone opened: `map mp_rust` showed the map error dialog, then `map mp_estate` loaded, the match ran with 5 bots and the player spawned, and the process exited 0. Without the injected panic Estate loaded and ran the same, with the same warning kinds in the log. Co-Authored-By: Claude Opus 5.5 --- crates/assets/src/match_load.rs | 34 +++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/assets/src/match_load.rs b/crates/assets/src/match_load.rs index 735b1a7..9f36850 100644 --- a/crates/assets/src/match_load.rs +++ b/crates/assets/src/match_load.rs @@ -36,6 +36,8 @@ pub struct MatchLoadRequest { struct MatchLoadTask { task: Task, MapLoadFailed>>, request_id: u64, + load_key: frame::LocalLoadKey, + zone: String, progress: LoadProgress, } @@ -141,6 +143,7 @@ fn start_match_load( let load_key = request.load_key; let zone = request.zone.clone(); let zone_accepted = zone.clone(); + let zone_task = zone.clone(); let cancel_handle = progress.clone(); // The process outlives the overlay and every task this spawns: it is what // the table reads, what admission finishes, and what the bench still has @@ -224,6 +227,8 @@ fn start_match_load( MatchLoadTask { task, request_id, + load_key, + zone: zone_task, progress: cancel_handle, }, frame::MatchScope::Loading, @@ -356,8 +361,13 @@ fn poll_match_load( task.request_id ); } - let Some(outcome) = future::block_on(future::poll_once(&mut task.task)) else { - return; + let polled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + future::block_on(future::poll_once(&mut task.task)) + })); + let outcome = match polled { + Ok(None) => return, + Ok(Some(outcome)) => outcome, + Err(panic) => Err(walk_panic_failure(task, panic.as_ref())), }; let request_id = task.request_id; inflight.0 = false; @@ -418,6 +428,26 @@ fn poll_match_load( }); } +fn walk_panic_failure(task: &MatchLoadTask, panic: &(dyn std::any::Any + Send)) -> MapLoadFailed { + let message = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or("no message"); + diag::error!( + World, + "match load: walk request #{} (`{}`) panicked: {message} — reported as a failed map load", + task.request_id, + task.zone + ); + MapLoadFailed { + request_id: task.request_id, + load_key: task.load_key, + zone: task.zone.clone(), + error: format!("The load stopped on an internal error: {message}"), + } +} + fn expire_match_load_abort( mut commands: Commands, abort: Option>, From 79be3e15b0cfa056b40faf875b8b0e0b5e6f9548 Mon Sep 17 00:00:00 2001 From: JohnM <234386938+treuenten@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:38:40 +1000 Subject: [PATCH 23/23] Let effect model pieces fall, bounce and come to rest An effect model element with the model-physics flag (a cash register's top and base, a card reader's pad, suitcase halves and contents, bottles and tins) is launched once by its velocity graphs and then falls, bounces off the world and settles. Nothing simulated it. It took the ordinary path instead, where the velocity graphs keep pushing for the whole 7-10 s life, the element's own reflection factor (1.0 for most of these) bounces it with no loss, and with no at-rest fraction it never stops spinning. Since 3039cf4 these pieces are drawn, so on mp_terminal register screens hovered at head height for seconds, turning two to four times a second, and card reader pads rose and stuck to the ceiling. evaluate_elem_physics_motion now runs these pieces in the collide pass: launch velocity from the graphs at age 0, gravity 800, a fixed bounce of 0.2 and friction of 0.5, a box reaching 1 unit each way from the model's origin, a lift out of a floor it spawns in, and a stop once it is slow on the ground. The at-rest fraction marks when the piece first touches the ground, and its spin stops there with no easing turn. Spinning on until it was slow, plus the 150 ms of turn clamp_elem_rotation_time eases in after the at-rest time, twists a luggage half (authored at 600-950 degrees a second) up to another half turn on the floor, and it froze standing on an edge or a corner. From the landing on, the piece is drawn turned the least way that stands its thinnest bounds axis upright, so it lies on its broad face, raised or lowered so that face meets what its box rests on. It still bounces and slides to a stop as before. The box is not sized from the model's bounds (IW4's midpoint and half size). A piece's model origin sits at an edge of the model (a suitcase half's at the case's bottom edge), so a box sized from the bounds around it is no truer, and a 5-unit one starts the halves inside the floor, where the lift out of it drops their launch. The bounds only pick the face it lies on. The clothes from a suitcase are ordinary colliding model elements that spawn around the case's origin on the floor. Their first step starts inside the floor and leaves it (startsolid, not allsolid), and that froze them there, spinning in place. A model element is now let out of a brush it starts in when its step leaves it; other element types still freeze. No draws or traces are added for the landing; a landed piece costs one 3x3 turn a frame. Still short of a physics world: the box doesn't turn with the model, a piece spins at the authored rate in the air instead of a simulated one, it is laid flat on landing instead of tumbling onto a face, and pieces don't meet each other or entities. Checked on IW4 Pocket's code, which carries the same change with bounce and friction from each model's physics preset; this port is compile-checked only. On the Mac and on an iPhone build on mp_terminal, shot registers' pieces landed on the counter and stayed, bills flew as before, and a shot suitcase's halves and clothes landed and stayed until their life ended. For the landing pose, on the Mac at an mp_terminal suitcase, a screenshot every 0.1 s, shot with the SCAR and with the AA-12: before, the halves stood on an edge or a corner until they vanished; after, they land and lie flat. At the food-court register the screen top, base and card-reader pads lie flat on the counter. Co-Authored-By: Claude Opus 5.5 --- crates/asset_game/src/fx_catalog.rs | 30 ++++++ crates/fx/src/lib.rs | 4 +- crates/fx/src/motion.rs | 152 ++++++++++++++++++++++++++-- crates/fx/src/update.rs | 6 +- crates/fx_iw4/src/collide.rs | 56 ++++++++++ crates/fx_iw4/src/lib.rs | 13 ++- crates/fx_iw4/src/velocity.rs | 33 ++++++ crates/render_fx/src/present.rs | 79 ++++++++++++--- 8 files changed, 344 insertions(+), 29 deletions(-) diff --git a/crates/asset_game/src/fx_catalog.rs b/crates/asset_game/src/fx_catalog.rs index da7e15b..c896838 100644 --- a/crates/asset_game/src/fx_catalog.rs +++ b/crates/asset_game/src/fx_catalog.rs @@ -274,6 +274,8 @@ pub struct OwnedFxElemDef { pub trail_def: Option, pub spark_fountain_def: Option, + + pub phys_pieces: Vec>, } impl OwnedFxElemDef { @@ -338,6 +340,11 @@ impl OwnedFxElemDef { } } + pub fn phys_piece(&self, random_seed: u64) -> Option { + let idx = fx_iw4::elem_visual_index(self.view.visual_count, random_seed); + self.phys_pieces.get(idx).copied().flatten() + } + pub fn decal_mark_pair(&self, random_seed: u64) -> Option<&OwnedFxVisual> { if self.view.elem_type != elem_type::DECAL { return None; @@ -953,6 +960,15 @@ impl FxDefinitions { *edge = model_hint_edge(ns, hint.as_deref(), models); } } + elem.phys_pieces = + if fx_iw4::elem_skips_position_update(elem.view.elem_type, elem.view.flags) { + elem.visuals + .iter() + .map(|vis| model_phys_piece(vis, models)) + .collect() + } else { + Vec::new() + }; } } } @@ -1446,6 +1462,7 @@ fn capture_elem( has_extended, trail_def, spark_fountain_def, + phys_pieces: Vec::new(), }) } @@ -1695,6 +1712,17 @@ fn model_hint_edge( } } +fn model_phys_piece( + vis: &OwnedFxVisual, + models: &crate::FxModelCatalog, +) -> Option { + let OwnedFxVisual::Model { edge, .. } = vis else { + return None; + }; + let (mid, half) = models.get_at(edge.bound_index()?)?.skel.bounds?; + fx_iw4::FxPhysPiece::from_bounds(mid, half) +} + fn resolve_material_visual( s: &impl FxRead, materials: &MaterialCatalog, @@ -2056,6 +2084,7 @@ fn leftover_capture_elem_t5( has_extended, trail_def: None, spark_fountain_def: None, + phys_pieces: Vec::new(), }) } @@ -2544,5 +2573,6 @@ fn capture_elem_t6( has_extended: false, trail_def: None, spark_fountain_def: None, + phys_pieces: Vec::new(), }) } diff --git a/crates/fx/src/lib.rs b/crates/fx/src/lib.rs index 76d0435..678838e 100644 --- a/crates/fx/src/lib.rs +++ b/crates/fx/src/lib.rs @@ -37,7 +37,9 @@ pub use marks::{ FxMarksSystemHost, GfxMarkMeshCensus, GfxMarkMeshSurf, MarkImpactRequest, MarkImpactResult, MarkReceiverEnable, MarkTraceRecord, }; -pub use motion::{evaluate_elem_collide_motion, evaluate_elem_motion}; +pub use motion::{ + evaluate_elem_collide_motion, evaluate_elem_motion, evaluate_elem_physics_motion, +}; pub use play::{ FxPlayPose, FxPlayRequest, PlayResult, axis_from_hit_normal, axis_from_impact_velocity, play_at_origin, play_bolted, play_oriented, spawn_impact_or_death_effect, spawn_oriented, diff --git a/crates/fx/src/motion.rs b/crates/fx/src/motion.rs index 3fe9ccd..e5a63fa 100644 --- a/crates/fx/src/motion.rs +++ b/crates/fx/src/motion.rs @@ -1,10 +1,11 @@ use fx_iw4::{ - FX_ELEM_AT_REST_NONE, collide_marks_at_rest, collide_substep_schedule, - collision_reflect_base_vel_delta, elem_dies_on_touch, elem_gravity_accel_z, - elem_update_has_velocity_graph, elem_uses_collision, elem_uses_vel_local, elem_uses_vel_world, - get_at_rest_fraction, get_velocity_at_time_sampled, impact_child_speed_allows, - integrate_velocity_graph_sampled, orientation_pos_from_world, orientation_pos_to_world, - sample_reflection_factor, trace_mask, vec3_length_sq, + FX_ELEM_AT_REST_NONE, FX_PHYS_PIECE_HALF, collide_marks_at_rest, collide_on_ground, + collide_substep_schedule, collision_reflect_base_vel_delta, elem_dies_on_touch, + elem_gravity_accel_z, elem_launch_velocity, elem_update_has_velocity_graph, + elem_uses_collision, elem_uses_vel_local, elem_uses_vel_world, get_at_rest_fraction, + get_velocity_at_time_sampled, impact_child_speed_allows, integrate_velocity_graph_sampled, + orientation_pos_from_world, orientation_pos_to_world, sample_reflection_factor, trace_mask, + vec3_length_sq, }; use crate::update::{FxElemMotionQuery, FxElemMotionResult, FxElemTraceHit, FxImpactSpawn}; @@ -41,6 +42,7 @@ pub fn evaluate_elem_collide_motion( coll_maxs: [f32; 3], use_item_clip: bool, has_effect_on_impact: bool, + leaves_start_solid: bool, vel_graph_local: &[fx_iw4::FxElemVec3Range], vel_graph_world: &[fx_iw4::FxElemVec3Range], q: FxElemMotionQuery<'_>, @@ -125,7 +127,7 @@ pub fn evaluate_elem_collide_motion( orientation_pos_to_world(q.orient.origin, q.orient.axis, end_local), ); let hit = trace(start, end, coll_mins, coll_maxs, mask); - if hit.startsolid || hit.allsolid { + if hit.allsolid || (hit.startsolid && !leaves_start_solid) { base_vel = [0.0; 3]; break; } @@ -224,6 +226,142 @@ pub fn evaluate_elem_collide_motion( }) } +const FX_PHYS_GRAVITY: f32 = 800.0; +const FX_PHYS_BOUNCE: f32 = 0.2; +const FX_PHYS_FRICTION: f32 = 0.5; +const FX_PHYS_REST_SPEED: f32 = 20.0; +const FX_PHYS_UNSTICK_MAX_LIFT: f32 = 16.0; +const FX_PHYS_CONTACT_SLOP: f32 = 0.125; + +pub fn evaluate_elem_physics_motion( + flags: i32, + use_item_clip: bool, + vel_graph_local: &[fx_iw4::FxElemVec3Range], + vel_graph_world: &[fx_iw4::FxElemVec3Range], + q: FxElemMotionQuery<'_>, + mut trace: impl FnMut([f32; 3], [f32; 3], [f32; 3], [f32; 3], u32) -> FxElemTraceHit, +) -> FxElemMotionResult { + let mut landed = (q.at_rest_fraction != FX_ELEM_AT_REST_NONE).then_some(q.at_rest_fraction); + let mut result = FxElemMotionResult { + origin_delta: [0.0; 3], + base_vel: q.base_vel, + remove: false, + at_rest_fraction: landed, + spawn_impact: None, + }; + if landed.is_some() && q.base_vel == [0.0; 3] { + return result; + } + let from = q.prev_msec.max(q.msec_begin); + if q.msec_now <= from { + return result; + } + let mut vel = if q.prev_msec <= q.msec_begin { + elem_launch_velocity( + flags, + vel_graph_local, + vel_graph_world, + q.orient.axis, + q.motion_random.velocity, + ) + } else { + q.base_vel + }; + let mask = trace_mask(use_item_clip); + let (mins, maxs) = ([-FX_PHYS_PIECE_HALF; 3], [FX_PHYS_PIECE_HALF; 3]); + let mut pos = orientation_pos_to_world(q.orient.origin, q.orient.axis, q.origin); + for step in collide_substep_schedule(from, q.msec_now) { + let dt = (step.msec_end.saturating_sub(step.msec_start)).max(0) as f32 * 0.001; + vel[2] -= FX_PHYS_GRAVITY * dt; + let end = [ + pos[0] + vel[0] * dt, + pos[1] + vel[1] * dt, + pos[2] + vel[2] * dt, + ]; + let hit = trace(pos, end, mins, maxs, mask); + if hit.allsolid { + vel = [0.0; 3]; + match lift_out_of_solid(pos, mins, maxs, mask, &mut trace) { + Some(lifted) => { + pos = lifted; + continue; + } + None => { + landed.get_or_insert(at_rest_byte(step.msec_start, q.msec_begin, q.life_ms)); + break; + } + } + } + if hit.fraction >= 1.0 { + pos = end; + continue; + } + pos = [ + pos[0] + (end[0] - pos[0]) * hit.fraction, + pos[1] + (end[1] - pos[1]) * hit.fraction, + pos[2] + (end[2] - pos[2]) * hit.fraction, + ]; + let n = hit.normal; + let into = dot(vel, n); + if into < 0.0 { + let push = (1.0 + FX_PHYS_BOUNCE) * into; + for (v, n) in vel.iter_mut().zip(n) { + *v -= push * n; + } + let along = dot(vel, n); + for (v, n) in vel.iter_mut().zip(n) { + let normal_part = along * n; + *v = normal_part + (*v - normal_part) * (1.0 - FX_PHYS_FRICTION); + } + } + if collide_on_ground(n[2]) { + landed.get_or_insert(at_rest_byte(step.msec_end, q.msec_begin, q.life_ms)); + if vec3_length_sq(vel) < FX_PHYS_REST_SPEED * FX_PHYS_REST_SPEED { + vel = [0.0; 3]; + break; + } + } + } + let stored = orientation_pos_from_world(q.orient.origin, q.orient.axis, pos); + result.origin_delta = [ + stored[0] - q.origin[0], + stored[1] - q.origin[1], + stored[2] - q.origin[2], + ]; + result.base_vel = vel; + result.at_rest_fraction = landed; + result +} + +fn lift_out_of_solid( + pos: [f32; 3], + mins: [f32; 3], + maxs: [f32; 3], + mask: u32, + trace: &mut impl FnMut([f32; 3], [f32; 3], [f32; 3], [f32; 3], u32) -> FxElemTraceHit, +) -> Option<[f32; 3]> { + let mut dz = -mins[2] + FX_PHYS_CONTACT_SLOP; + let cap = dz.max(FX_PHYS_UNSTICK_MAX_LIFT); + while dz <= cap { + let lifted = [pos[0], pos[1], pos[2] + dz]; + let probe = trace(lifted, lifted, mins, maxs, mask); + if !probe.startsolid && !probe.allsolid { + return Some(lifted); + } + dz *= 2.0; + } + None +} + +fn at_rest_byte(msec: i32, msec_begin: i32, life_ms: f32) -> u8 { + let frac = get_at_rest_fraction(msec as f32, msec_begin as f32, 1.0 / life_ms.max(1.0)); + frac.clamp(0.0, 254.0) as u8 +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + fn integrate_free_flight( flags: i32, gravity_base: f32, diff --git a/crates/fx/src/update.rs b/crates/fx/src/update.rs index 013ec54..da5efa5 100644 --- a/crates/fx/src/update.rs +++ b/crates/fx/src/update.rs @@ -2,8 +2,8 @@ use fx_iw4::{ FX_ELEM_TYPE_SPARK_CLOUD, FX_ELEM_TYPE_SPARK_FOUNTAIN, FX_STATUS_HAS_PENDING_LOOP_ELEMS, FX_STATUS_REF_COUNT_MASK_IW4, FxUpdateEffectBolt, axis_to_quat, begin_iterating_over_effects_exclusive, bolt_compose_orientation, bolt_mark_lost, - elem_norm_time, elem_uses_collision, end_iterating_over_effects, end_iterating_runs_gc, - unit_quat_to_axis, update_effect_bolt, vector_vectors, + elem_norm_time, elem_skips_position_update, elem_uses_collision, end_iterating_over_effects, + end_iterating_runs_gc, unit_quat_to_axis, update_effect_bolt, vector_vectors, }; use std::collections::HashMap; @@ -384,7 +384,7 @@ fn pending_collide_for_elem( if msec_now >= death { return None; } - if !elem_uses_collision(elem.flags) { + if !elem_uses_collision(elem.flags) && !elem_skips_position_update(elem.elem_type, elem.flags) { return None; } let def_index = elem.def_index; diff --git a/crates/fx_iw4/src/collide.rs b/crates/fx_iw4/src/collide.rs index af3f71a..f22abe2 100644 --- a/crates/fx_iw4/src/collide.rs +++ b/crates/fx_iw4/src/collide.rs @@ -119,3 +119,59 @@ pub fn collision_reflect_base_vel_delta( reflected[2] - pre_impact_vel[2], ] } + +pub const FX_PHYS_PIECE_HALF: f32 = 1.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FxPhysPiece { + flat_axis: usize, + flat_mid: f32, + flat_half: f32, +} + +impl FxPhysPiece { + pub fn from_bounds(mid: [f32; 3], half: [f32; 3]) -> Option { + if !mid.iter().chain(&half).all(|v| v.is_finite()) || half.iter().any(|v| *v <= 0.0) { + return None; + } + let flat_axis = (0..3).min_by(|&a, &b| half[a].total_cmp(&half[b]))?; + Some(Self { + flat_axis, + flat_mid: mid[flat_axis], + flat_half: half[flat_axis], + }) + } + + pub fn rest_pose(&self, axis: [[f32; 3]; 3], scale: f32) -> ([[f32; 3]; 3], f32) { + let a = axis[self.flat_axis]; + let up = if a[2] >= 0.0 { 1.0 } else { -1.0 }; + let v = [a[1] * up, -a[0] * up, 0.0]; + let k = 1.0 / (1.0 + a[2] * up); + let turn = |p: [f32; 3]| { + let vxp = cross(v, p); + let vxvxp = cross(v, vxp); + [ + p[0] + vxp[0] + vxvxp[0] * k, + p[1] + vxp[1] + vxvxp[1] * k, + p[2] + vxp[2] + vxvxp[2] * k, + ] + }; + let lowest = if up > 0.0 { + self.flat_mid - self.flat_half + } else { + -(self.flat_mid + self.flat_half) + }; + ( + [turn(axis[0]), turn(axis[1]), turn(axis[2])], + -FX_PHYS_PIECE_HALF - lowest * scale, + ) + } +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} diff --git a/crates/fx_iw4/src/lib.rs b/crates/fx_iw4/src/lib.rs index d8695f0..81bcff0 100644 --- a/crates/fx_iw4/src/lib.rs +++ b/crates/fx_iw4/src/lib.rs @@ -88,10 +88,11 @@ pub use bolt::{ update_effect_bolt, }; pub use collide::{ - FX_COLLIDE_SUBSTEP_MS, FX_COLLISION_REFLECT_SCALE, FX_IMPACT_CHILD_MIN_SPEED_SQ, FX_TRACE_MASK, - FX_TRACE_MASK_ITEM_CLIP, FxCollideSubstep, collide_marks_at_rest, collide_on_ground, - collide_substep_schedule, collision_reflect_base_vel_delta, impact_child_speed_allows, - sample_reflection_factor, trace_mask, + FX_COLLIDE_SUBSTEP_MS, FX_COLLISION_REFLECT_SCALE, FX_IMPACT_CHILD_MIN_SPEED_SQ, + FX_PHYS_PIECE_HALF, FX_TRACE_MASK, FX_TRACE_MASK_ITEM_CLIP, FxCollideSubstep, FxPhysPiece, + collide_marks_at_rest, collide_on_ground, collide_substep_schedule, + collision_reflect_base_vel_delta, impact_child_speed_allows, sample_reflection_factor, + trace_mask, }; pub use cull::{ FX_ELEM_FLAG_CULL_DRAW_5_PLANES, cull_cloud, cull_cloud_plane_count, cull_cloud_radius, @@ -341,7 +342,9 @@ pub use vec::{ FX_PERP_VECTOR_UNIT, effect_orient_arc, perpendicular_vector, vec3_distance, vec3_length_sq, vec3_normalize, vector_vectors, }; -pub use velocity::{FX_VEL_AT_TIME_SCALE, get_velocity_at_time, get_velocity_at_time_sampled}; +pub use velocity::{ + FX_VEL_AT_TIME_SCALE, elem_launch_velocity, get_velocity_at_time, get_velocity_at_time_sampled, +}; pub use view::{ FX_EFFECT_DEF_SIZE, FX_ELEM_DEF_STRIDE, FxEffectDefView, FxElemDefView, effect_def_view, elem_def_gravity_accel_z, elem_def_view, elem_def_view_x64, diff --git a/crates/fx_iw4/src/velocity.rs b/crates/fx_iw4/src/velocity.rs index 7850433..8fed1e2 100644 --- a/crates/fx_iw4/src/velocity.rs +++ b/crates/fx_iw4/src/velocity.rs @@ -75,3 +75,36 @@ pub fn get_velocity_at_time_sampled( } out } + +#[inline] +pub fn elem_launch_velocity( + flags: i32, + local_samples: &[crate::FxElemVec3Range], + world_samples: &[crate::FxElemVec3Range], + orient_axis: [[f32; 3]; 3], + random: [f32; 3], +) -> [f32; 3] { + let mut out = [0.0f32; 3]; + if elem_uses_vel_world(flags) && world_samples.len() >= 2 { + let s = sample_vel_graph_at_age_sampled(world_samples, 0.0, random); + let scale = graph_speed_scale(world_samples.len()); + for (out, s) in out.iter_mut().zip(s) { + *out += s * scale; + } + } + if elem_uses_vel_local(flags) && local_samples.len() >= 2 { + let s = sample_vel_graph_at_age_sampled(local_samples, 0.0, random); + let scale = graph_speed_scale(local_samples.len()); + for (axis, out) in out.iter_mut().enumerate() { + let w = s[0] * orient_axis[0][axis] + + s[1] * orient_axis[1][axis] + + s[2] * orient_axis[2][axis]; + *out += w * scale; + } + } + out +} + +fn graph_speed_scale(fenceposts: usize) -> f32 { + (fenceposts - 1) as f32 * FX_VEL_AT_TIME_SCALE as f32 +} diff --git a/crates/render_fx/src/present.rs b/crates/render_fx/src/present.rs index 8178b88..2ec40fd 100644 --- a/crates/render_fx/src/present.rs +++ b/crates/render_fx/src/present.rs @@ -12,16 +12,17 @@ use fx::{ FxModelInstance, FxPackedLightingSrc, FxPlayPose, FxPlayRequest, FxSparkDrawQuery, FxSparkFillQuery, FxSparkFillVisual, FxSpriteInstance, FxSystemHost, FxTrailCollideHit, FxTrailDrawDef, FxTrailSampleVisual, PendingCollide, PlayResult, axis_from_hit_normal, - evaluate_elem_collide_motion, evaluate_elem_motion, generate_verts_with_trails, play_bolted, - play_oriented, spawn_impact_or_death_effect, spawn_looping_partial, spawn_oriented, - update as fx_update, + evaluate_elem_collide_motion, evaluate_elem_motion, evaluate_elem_physics_motion, + generate_verts_with_trails, play_bolted, play_oriented, spawn_impact_or_death_effect, + spawn_looping_partial, spawn_oriented, update as fx_update, }; use fx_iw4::{ - FX_ELEM_ATLAS_OFF, FX_ELEM_ATLAS_SIZE, FxElemType, FxRandomChannel, apply_lighting_frac_bgra, - build_cloud, clamp_elem_rotation_time, cull_cloud, cull_elem_light, + FX_ELEM_AT_REST_NONE, FX_ELEM_ATLAS_OFF, FX_ELEM_ATLAS_SIZE, FxElemType, FxRandomChannel, + apply_lighting_frac_bgra, build_cloud, clamp_elem_rotation_time, cull_cloud, cull_elem_light, effect_def_needs_lighting_sample, elem_light_color_bgr, elem_uses_lighting_frac, evaluate_rotation_total, evaluate_scale, evaluate_size0, evaluate_size1, get_elem_angles_axis, - get_velocity_at_time, sample_f32, sprite_atlas_uv, vec3_normalize, vis_blocker_add_prepared, + get_velocity_at_time, msec_for_sampling_axis, sample_f32, sprite_atlas_uv, vec3_normalize, + vis_blocker_add_prepared, }; use math_iw4::angle_vectors; use sim::SimWorld; @@ -658,6 +659,14 @@ fn eval_pending_collide( let Some(elem) = effect.elems.get(q.def_index as usize) else { return out; }; + if fx_iw4::elem_skips_position_update(elem.view.elem_type, elem.view.flags) { + let Some(world) = clip_world else { + out.gap = Some(q.def_index); + return out; + }; + out.result = Some(evaluate_physics_piece(elem, world, q)); + return out; + } if fx_iw4::elem_uses_collision(elem.view.flags) { let Some(world) = clip_world else { out.gap = Some(q.def_index); @@ -673,6 +682,7 @@ fn eval_pending_collide( elem.view.coll_maxs, elem.view.use_item_clip != 0, !elem.effect_on_impact.is_absent(), + elem.view.elem_type == FxElemType::Model as u8, elem.vel_graph_local.as_slice(), elem.vel_graph_world.as_slice(), q, @@ -699,6 +709,29 @@ fn eval_pending_collide( out } +fn evaluate_physics_piece( + elem: &asset_game::OwnedFxElemDef, + world: &SimWorld, + q: fx::FxElemMotionQuery<'_>, +) -> FxElemMotionResult { + evaluate_elem_physics_motion( + elem.view.flags, + elem.view.use_item_clip != 0, + elem.vel_graph_local.as_slice(), + elem.vel_graph_world.as_slice(), + q, + |start, end, mins, maxs, mask| { + let t = world.trace_world(start, end, mins, maxs, mask); + FxElemTraceHit { + fraction: t.fraction, + normal: t.normal, + startsolid: t.startsolid != 0, + allsolid: t.allsolid != 0, + } + }, + ) +} + const COLLIDE_JOBS_PER_WORKER: usize = 16; fn collide_worker_count(jobs: usize) -> usize { @@ -830,6 +863,13 @@ fn tick_fx_pass( let out = (|| { let effect = catalog_lookup(catalog, q.catalog_index)?; let elem = effect.elems.get(q.def_index as usize)?; + if fx_iw4::elem_skips_position_update(elem.view.elem_type, elem.view.flags) { + let Some(world) = clip_world else { + collide_gap_indices.borrow_mut().push(q.def_index); + return None; + }; + return Some(evaluate_physics_piece(elem, world, q)); + } if fx_iw4::elem_uses_collision(elem.view.flags) { let Some(world) = clip_world else { collide_gap_indices.borrow_mut().push(q.def_index); @@ -845,6 +885,7 @@ fn tick_fx_pass( elem.view.coll_maxs, elem.view.use_item_clip != 0, !elem.effect_on_impact.is_absent(), + elem.view.elem_type == FxElemType::Model as u8, elem.vel_graph_local.as_slice(), elem.vel_graph_world.as_slice(), q, @@ -1521,24 +1562,36 @@ fn evaluate_fx_model_instance( if scale == 0.0 { return None; } - let rot_t = clamp_elem_rotation_time( - ctx.age_msec as f32, - ctx.life_msec as f32, - ctx.at_rest_fraction, - ); - let axis = get_elem_angles_axis( + let landed_piece = ctx.at_rest_fraction != FX_ELEM_AT_REST_NONE + && fx_iw4::elem_skips_position_update(elem.view.elem_type, elem.view.flags); + let rot_t = if landed_piece { + msec_for_sampling_axis(ctx.at_rest_fraction, ctx.life_msec as f32).min(ctx.age_msec as f32) + } else { + clamp_elem_rotation_time( + ctx.age_msec as f32, + ctx.life_msec as f32, + ctx.at_rest_fraction, + ) + }; + let mut axis = get_elem_angles_axis( elem.view.spawn_angles, elem.view.angular_velocity, ctx.elem_random_seed, rot_t, ctx.axis, ); + let mut origin = ctx.origin; + if landed_piece && let Some(piece) = elem.phys_piece(ctx.elem_random_seed) { + let (flat, lift) = piece.rest_pose(axis, scale); + axis = flat; + origin[2] += lift; + } Some(FxModelInstance { def_name: ctx.def_name.to_owned(), def_index: ctx.def_index, model_index, elem_handle: ctx.elem_handle, - origin: ctx.origin, + origin, axis, scale, flags: ctx.flags,