- Underbarrel attachments work across IW4, IW5, T5 and T6, including
per-family impact FX, T5 flamethrower streams and T6 launcher fuses.
- Viewmodel notetrack sounds play in third person.
- Transport operations and queues are bounded; Quinn congestion control
returns to its default.
- Match publication, presentation context and prepared content semantics
are qualified and validated; cosmetic and entity occurrence lifecycles
are preserved.
- GPU skinning batches within supported device budgets.
- Immutable UI projections and correlated class replies are reconciled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fullscreen covers the display, so Resolution did nothing there, and any
display setting change re-applied the windowed size and shrank the
fullscreen window into a corner. Fullscreen now leaves the window size
alone; a resolution shorter than the display renders the world camera
into an image of that many rows at the window's aspect, which a present
camera stretches over the window. The image's scale factor keeps the
camera's logical size equal to the window's, so HUD projections of world
points line up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The macOS surface workaround ran before Bevy 0.20 applied the window
extraction commands, so the first pipelined frame still created the Metal
surface on the render thread and panicked. Applying them first lets
pipelining run, and a frame latency of two stops the render thread waiting
on the display before every frame; with a latency of one pipelining gains
nothing.
Apple M4 Pro, mp_rust, 8 bots, bench recorder, 3456x2168 fullscreen:
uncapped 95 -> 238 fps (frame p95 12.9 -> 7.0 ms); vsync on 71 fps
alternating between 60 and 120 -> steady 120 (p95 20.5 -> 8.7 ms).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 '<zone>'.
The load stopped on an internal error: <panic text>"), 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A bullet that hit a script model raised "damage" with the hit bone as the
tagName argument and an empty partName. The destructible script reads a
non-empty tagName as the tag of an attached model and matches it against
its part list. The body bone of a destructible car (the Suburban on Estate)
matches no part, so the damage was dropped and bullets never set a car on
fire. Windows, wheels and lights still broke, because their bones are part
tags either way, and explosions, which carry no bone, worked.
The bone now goes in partName with an empty tagName, as the vehicle damage
notify already does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update_smodel_lighting wrote each dirty static-model lighting tile into the
model lighting atlas through a tracked Assets::get_mut. Every frame with a
dirty tile therefore marked the whole atlas modified, so Bevy extracted all
of it again and rewrote the whole texture on the GPU, up to 1 MiB, for a few
256-byte tiles.
The tile is now written through an untracked borrow and queued in
ModelLightingAtlasTileWrites, as the moving-model lighting cache already does
for the same atlas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the games root is the build's own `target/play`, discovery walked every
file of cargo's output (incremental, deps, build, .fingerprint, examples) and
the artifacts folder, several times at boot: the window sat white and "Not
Responding" for seconds before the map loaded. Those folders never hold
zones, so the walk skips them (retail MW2, MW3 and Black Ops installs have no
folders by these names).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MW2 trees (Favela's oaks: mc/mtl_tree_oak_*) cast their sun shadow only
through a separate, never-drawn mesh (mc/mtl_tree_shadow_caster). Its
shadow-only material was kept out of the static model colour material keys,
and the sun shadow pass looked its material up in those same keys, so the
surface was dropped and the trees cast nothing. What stayed was the
lightmap's sun mask: MW2's shader forces shadow where the mask is 0, which
showed as blocky dark rectangles on otherwise sunlit ground under the trees.
Shadow-only materials are now listed in their own key, read by the sun
shadow pass only; the colour pass still never sees them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Favela's hanging laundry is animated script models. They were posed only
when the camera's scene-entity walk reached them, so once the clothes left
the screen their shadows on the wall below vanished (only the static rope's
stayed). IW4 culls scene entities for each sun partition apart from the
camera; here a script model the walk hid is kept for the sun shadow only
(colour refused, no lighting request) while its bound is inside one of the
sun partitions.
The partitions are published before the models are posed, from one shared
builder the command context now uses as well, so both see the same planes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prepare progression and loading through Bevy scheduling, preserve round HUD and bombsite props, restore grenade and T5 shadow presentation, and keep menus usable while classes load. Report update progress and continue startup after update failures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IW4L_PIPELINED_RENDERING=1 panicked on the first frame on macOS ("can only
access NSView on the main thread" in raw-window-metal), which is why the
default is off there. Bevy's create_surfaces takes a NonSendMarker so that
the multi-threaded executor hands it back to the main thread, but the render
app's Render schedule runs on a single-threaded executor. With pipelining on
that schedule runs on the render thread, so create_surfaces built the
CAMetalLayer there and touched the NSView off the main thread.
With pipelining on, macOS now also runs create_surfaces in the render app's
ExtractSchedule, after extract_cameras and so after the windows are
extracted. Extraction runs on the main thread, so the surface is created and
configured there. The Render schedule's instance then finds it configured
and skips, or after a resize reconfigures it, which only sets CAMetalLayer
properties and is allowed off the main thread.
The macOS default stays off, so nothing changes unless
IW4L_PIPELINED_RENDERING=1 is set. docs/BUILD.md now says how to turn it on
instead of pointing at the plugins.rs explanation this removes.
Measured on an Apple M3 (mp_terminal, 9 bots, uncapped present, bench
recorder), pipelining off -> on: 87 -> 105 fps average, frame p50 10.8 ->
8.3 ms, p99 19.4 -> 18.4 ms, worst frames about the same (36 -> 34 ms),
main/render overlap 5.4 ms per frame, presented state one frame older.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Effect model pieces (the bills and halves of a shot cash register, food,
luggage contents, plant pots, glass pieces) have not drawn since 69cf9ff
moved the effect model geometry into the Plan phase of the world spawn.
That phase runs before world_occupancy::place builds the model lighting
atlas, so prepare_fx_model_geometry found no atlas and quietly returned an
empty plan, and every model elem that passed its LOD and cull checks was
dropped for want of geometry.
The geometry is now built in spawn_world_finish straight after
world_occupancy::place. The Plan phase still installs an empty plan so its
readers find the resource. A missing atlas is now a warning with the model
count, and one line reports the models and LODs prepared and refused.
The bills had a second fault. mp_terminal stores `,fx_cash01` as a
placeholder for common_mp's fx_cash01, and the effect catalog kept the
comma in the model hint, so the edge never bound. Model hints now use the
bare name (AssetRef::bare_name), so fx_cash01 resolves against common_mp.
One model whose packed rows do not match its decoded vertices would make
the packed payload of all effect model geometry Unavailable, and the merged
xmodel lane then drops every xmodel draw (viewmodel, bodies, script models)
for the frame. Such a LOD is now refused and logged when the geometry is
built, and fill_fx_model_plan only draws effect models from geometry whose
Iw4 packed rows cover every decoded vertex.
The player's own first-person shell casings are still skipped by the
render-flag check in fill_fx_model_plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shooting a vending machine on mp_terminal drew large flat yellow, white
and red sheets next to it and made the game stutter. A spark fountain that
runs relative to its effect was sprayed from the effect-local spawn origin,
and the custom draw then placed the cells with the cloud matrix T(pos)
R(quat) S(vis scale). Prop sparks (props/credit_card_touchpad_exp on the
card touchpads, and explosions/powerlines_* from the vending machine's
tv_explosion) carry a vis scale of 150, so every spark was drawn 150 times
too big; barrelexp and small_vehicle_explosion (scale 10) were 10 times
too big. New cells also had every sample time at 0, which ends the ribbon at
origins[3]: about 1,780 untraced cells stacked one 150-unit quad on the
effect point until the tracer, 16 cells per update, reached them.
The spray now gets the world spawn point (spawn_origin_world), untraced cells
start as an unobstructed arc (spark_fountain_integrate_miss_cell), and the
custom draw is submitted with a unit placement (spark_fountain_draw_cloud).
The cells are already in world space and the fountain shader only applies
view and projection, so the vis scale stays a draw gate and no longer scales
the vertices. Bounce traces now run in the same space as world collision.
The elem keeps drifting on its velocity graph (0.4 units/ms on most
fountains) while the sparks stay where they were sprayed, so the fountain is
now culled around the spray point, with the radius grown by how far a spark
can have flown by then (max speed * t + |gravity| * t^2 / 2, times the bounce
fraction when it is above 1). Sparks no longer follow an effect that moves
after spraying them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The model lighting cache has 1024 tile slots and keeps a slot for two more
frames after its owner leaves it. Only glass could rewrite its own slot when
it moved. Every other moving owner (awake physics props, players, corpses,
the viewer's own sample) took a fresh slot each frame and so held three.
When the pool ran dry, as it does when many models need a slot at once, an
owner that missed got handle 0 and was not drawn that frame (props, players,
corpses, effect and world models, the viewmodel); a glass pane was drawn
with default lighting instead. One mp_highrise match (2412 dynents) on an
iPhone logged "model light cache alloc failed — remote body skipped" on 14
frames.
WorldModelLightingCache now records which owner each slot was given to, and
an owner that still holds its slot rewrites that tile in place when it
moves. The tile write is queued after every earlier frame's draws, so those
draws still read the old tile. An owner that still misses, one with no slot
of its own, is drawn with the viewer's tile for that frame, and the log line
names it. The viewer's own sample has no tile to borrow, so a miss there
still hides the viewmodel for that frame. A failed light-grid sample still
returns no lighting, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>