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] 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) });