Skip to content

Testing - #562

Merged
cybersecurity merged 52 commits into
mainfrom
testing
Nov 6, 2026
Merged

cybersecurity merged 52 commits into
mainfrom
testing

Conversation

@cybersecurity

Copy link
Copy Markdown
Collaborator

No description provided.

GuvenHuso and others added 30 commits October 29, 2026 17:17
conftest.py's sessions() plays the scenarios side by side from a thread
pool, and launch() puts a copy of the game in each scenario's folder. On
CI dist/ and /tmp are on different file systems, so os.link fails and
shutil.copy writes the copy. If another thread forks while that copy is
open for writing, its child holds the descriptor until it runs its own
program, and starting the copied game in that time fails with
OSError: [Errno 26] Text file busy. That failed all five obstacle tests
in CI run 40916137686 (PR #521).

launch() now copies the game and starts it under one lock, so no other
launch forks while a copy is open (Popen returns only once the child has
run the game). The forks of generate()'s tools in other threads are not
under the lock, so the start is tried up to five times, a tenth of a
second apart, when it fails with ETXTBSY; any other error is raised at
once.

Checked on Windows, where the scenarios skip without a Linux game: the
tests still collect and skip, and a stand-in for Popen showed the retry
(two busy failures, then started), giving up after five, other errors
raised at once, and eight threads each getting a whole copy. Not yet run
on Linux; CI's Linux job runs the scenarios.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The main-menu UI map has a live player but its root widgets belong to NONE,
so the per-player widget check incorrectly treated it as gameplay. Keep
physical navigation there while retaining per-player pause ownership.

Give custom layouts a separate Cycle Camera action in every gameplay camera
context. Keep timing and release guards in the remapper and pass the result
through the director's existing selection and client-permission checks.
Save v3 layouts with 26 actions; accept older layouts without overwriting
existing debug bindings. Default layouts and network contracts are unchanged.

Add production-code regression coverage for menu ownership, camera entry and
exit, rebinding, clearing, migration, timing, per-port state and lifecycle.
Keep current keyboard/mouse camera fixes and update their harness stub.
Register the integration suite in CI and update the controls documentation.

Reviewed the combined implementation, interfaces, state lifetime and tests.
On OpenCE testing: all 25 controller/HUD pytest cases and all 12 startup/
camera harness cases and negative controls passed with 32-bit clang.
Diff whitespace checks passed. The unchanged upstream web suite has Windows
path and POSIX-socket limitations; full platform validation remains for CI.
No physical-controller test.

Player-Note: Remapped controllers keep D-pad and Back working in the main menu.
Player-Note: Bind Cycle Camera to enter and leave flying-camera mode with custom controls.

(cherry picked from commit f1e6ec0)
A worn stick that drifts needs a larger dead zone than the game's own
(9000 of 32767, about 27%), and a player who wants finer aim a smaller
one. Each stick's dead zone is now a setting, input.left_stick_dead_zone
and input.right_stick_dead_zone: -1 (DEFAULT, the game's own) or 0% to
50% in steps of 5. A new STICK DEAD ZONES screen under Gamepad Setup sets
them, after BUTTON REMAPPING and BUTTON APPEARANCE.

- source/input/input_xbox.c reads each stick's dead range from
  halo_stick_dead_range (port/linux/src/xinput_sdl.c) instead of
  GAMEPAD_STICK_DEAD_RANGE, which stays the default.
- The check that the right stick, not the mouse, aims last uses the right
  stick's dead zone when it is set below that check's deflection.
- On Android and the web the touch controls' stick goes through the same
  path, so the left setting applies to it too.
- Gamepad Setup's screen is written again by tools/port_settings.py:
  eleven rows at its 27px step would put the last in the help, so it takes
  a 25px step and Video Setup's help place.

Nothing the machines send each other changes: the dead zone only shapes a
player's own input before it is predicted and sent.

Checked: the settings screens' generator and the menu files are the same
on testing as where this was written, so the generated XML is unchanged;
the Windows build; the harness against testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row being timed showed the same dash as one never probed, and a closed
or live game showed CLOSED or LIVE for the whole timing. p2p_listing
gains a probing flag, taken from the game's probing state in
game_listed, and the ping cell shows ".", ".." and "..." stepping every
300 ms for a game with no ping whose host is being timed. A known ping
stays shown while it is timed again, FAILED still comes first, and a
master server's region, CLOSED, LIVE and the dash show when the host is
not being timed. The dots have no colour.

The cell's text moves into browser_ping_text so that the harness test
tools/harness/tests/test_browser_ping_text.py can take it from the
source.

Checked: ninja linux builds and links; test_browser_ping_text.py passes
(8 tests, four of them negative controls each failing its own case).
update_probes walked the games in slot order, which has nothing to do
with the screen, so pings filled in scattered over the list. The browser
now tells the lobby the games it shows, in its order (p2p_lobby_shown,
each frame, after its sort, tab and Filters), and update_probes starts
the first of those that want a probe, then the next, with the same cap,
refusals, PROBE_REFRESH, token and master server checks as before.
Games the list hides (Filters, the other tab) are still timed, after
the games shown. A column title chosen mid-probe orders the hosts timed
next; probes under way finish where their rows moved to. There is no
ping column to sort by.

The order comes from the menu rather than from network.browser_sort
because the list is filtered before it is sorted: sorting the lobby's
games the same way put games the list hides ahead of the rows on
screen: on macOS against the live list, sorted by name, hidden "[C] ..."
games held all four probe slots for the first 15 s.

Each game is started at most once a pass: a refused game stays wanted,
so the top game would otherwise be picked until the refusals ran out
(always on the web, where p2p_probe_start refuses, and while joining a
listed game).

No probe starts while listings wait to be verified, so the first wave
after the browser opens follows the list, not the order the listings
were verified in; a listing verified in the pass the probes start is
not shown yet and goes last. tools/p2p_lobby_check.c checks the order,
a re-sort, a hidden game last, a refused host passed over, and the
wait.

Checked: ninja linux builds and links; p2p_lobby_check passes (0
failures), and each new check fails when the code it covers is removed.
REFRESH asked the hosts for their listings again and timed every ping
again, but with games listed nothing on screen changed: a known ping
stayed shown through its re-timing, came back the same on a steady
link, and the line below the games kept the chosen game's details. The
button looked dead.

REFRESH now marks each game it times again (refreshing, taken into
p2p_listing as probing is), and the mark comes off when that game's
probe ends or the game goes. A master server's game is not timed, so it
is not marked. A marked row shows the moving dots, uncoloured, until its
new ping lands, and the line below the games says "Refreshing..." while
a listed game is marked, for 10 s at most from the press. The re-timing
every two minutes is not marked, so its numbers stay shown.

"Refreshing..." is a browser word, added last to port_settings.py's
BROWSER_WORDS, and the join screen's menu file is written again with it.

Checked: ninja linux builds with no warnings in the files changed;
test_browser_ping_text.py passes (10 tests), its negative controls
failing their cases (dots for every probe fails "number", dots without
the mark fails "refresh"); p2p_lobby_check passes, and fails when
REFRESH marks nothing, marks a master server's or a password's game, or
the mark outlives its probe; test_linux_port.py with
test_browser_ping_text.py passes (25 passed, 4 skipped: they need the
maps).
A rocket launcher's melee was drawn at 30 Hz at any frame rate. The
first-person pose was blended between ticks only while no node moved more
than 0.25 world units relative to the camera in a tick, a limit meant to
tell another weapon's skeleton from this one moving; the rocket
launcher's melee (and other wide, fast animations) moves further, so the
whole pose snapped every tick of the swing.

The pose now remembers the weapon and the animation graph it was of, and
blends only from the same weapon's and graph's pose of the tick just
before, so a weapon switch, even between two frames of a tick, still snaps
while the weapon's own animations blend however far they move. A position
that is not a number still snaps the pose. A node whose two rotations are
about half a turn apart (the rocket launcher's barrels set back after a
shot) snaps alone, since which way it turned cannot be told; the other
nodes still blend.

Only what is drawn changes, nothing the machines send or agree on: no
network version change.

Checked: ninja windows; tools/harness/tests/test_first_person_interpolation.py
(8 cases, and 6 negative controls, the old 0.25 limit among them, that
fail); the whole harness on Windows fails the same 632 tests as testing
does (the Windows environment's) and passes the 14 new ones. Not built for
Android or the web here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Let each controller stick's dead zone be set, in percent
On a screen wider than 640 columns, cinematic_render moved each title's
box by its middle, scaled to the screen's width. Most chapter titles are
right-justified in a box that starts near the left edge, so on 16:9 the
text ended about 81% of the way across the screen, where the Xbox's 4:3
picture has it at about 93% (The Silent Cartographer's title, compared
with an Xbox capture).

The box now moves by the edge its text is justified to: the right edge
for right-justified titles, the left edge for left-justified ones, and
the middle for centered ones, as before. On 640 columns nothing moves.
Only the drawing changes, nothing the machines send or agree on, so
HALO_PORT_NETWORK_VERSION stays.

Checked in the game on Windows at 16:9: The Silent Cartographer's title
ends near the right side, as on the Xbox. test_cinematic_titles.py gains
cases for each justification on a 960-column screen and on 640 columns,
and negative controls that move the box by its middle again (not run
locally, on Windows; CI runs them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shader translator moves every vertex half a pixel right and down to
turn Direct3D's pixel centers into OpenGL's, half a pixel of the game's
480 lines (nv2a_vsh.c). On a screen drawn at more pixels than that, a
screen effect pass's quad then started that far inside its target's
left and top edges, and the pixels there were never drawn. Each pass of
a scope's zoom warp read their stale contents and drew them farther in:
a dark band down the left of the blur and along its top, which the
right and bottom edges, clamped to drawn pixels, did not have.

rasterizer_screen_effect_cover_target moves each pass's quad back by
that half pixel, a pixel of the viewport it draws into (the whole
surface for the second render-primary surface, the window for the
primary), and its texture coordinates with it, now floats: every pixel
samples where it did, and the quad covers the whole viewport. Only the
drawing changes, nothing the machines send or agree on, so
HALO_PORT_NETWORK_VERSION stays.

Checked: the new harness test screen_effect_cover (a 16:9 screen, the
Xbox's 640x480 and a four-player split screen, with four negative
controls) and the existing screen_effect_passes pass, built with clang
on Windows; the Windows release build compiles without warnings; in
the game on Windows, the scope's blur reaches the left and top edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hud_corrections.py resized the supplied 2x and 10x pictures to fill
their sprite cells ([0,0,31,24] and [0,24,43,48]), but the Xbox's
glyphs sit inside those with empty texels around them: 2x covers 21x14
texels and 10x 32x14 (alpha >= 128, measured in the Xbox maps), where
the high-res labels covered 30x18 and 42x18, a third larger than on the
Xbox.

The pictures are now placed on the stock glyph bounds, still resized
proportionally: 10x within one high-res pixel of them, 2x within three
(its supplied glyphs are a little wider for their height than the
stock's). Only sniper__hud_reticles_scope__0.png and its layout entry
change; the rocket reticle and the Custom Edition scope rebuild
unchanged. Custom Edition's own 2x is left as it is: its stock size was
not measured.

Checked: python tools/hud_corrections.py regenerates the picture;
tools/test_hud_corrections.py passes (5), with a new test of the
labels' visible bounds that the previous picture fails by 35 and 42
high-res pixels; in the game on Windows, the pistol's and the sniper
rifle's labels are the Xbox's size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…size

The Pelican's side light (vehicles\pelican\shaders\lights, an additive
decal) sank into the hull in slivers, where on the Xbox it is a whole
glow. A z bias of 8 is a polygon offset of 8 depth units and 2 times
the depth's slope across a pixel (D3DDevice_SetRenderState_ZBias), and
the native builds weakened both:

- With display.reversed_depth (the default on the desktop) the depth
  buffer holds floats, whose polygon offset units are a part of the
  depth itself, which reversed is small far away: at ten world units
  the 8 units moved a decal a hundredth of what the Xbox's 24-bit units
  did. The vertex programs now add the units in the Xbox's measure
  (depth_bias, each 1 / viewport_scale.z), and the polygon offset keeps
  only the slope.
- The slope term is the depth's change across a pixel, and a screen
  drawn at several pixels to the game's one has that many times less
  depth across each: at 1080 lines it was 2.25 times weaker, and on a
  surface seen at an angle it is most of the bias. The factor is now
  multiplied by the bound target's scale, as the Xbox took it across
  its own pixels.

At 640x480 with display.reversed_depth off nothing changes, and nothing
the machines send changes.

Checked: the Windows release build compiles without warnings; in the
game on Windows (RTX 5070 Ti, 1080 lines), the Pelicans' red lights in
a30's opening cutscene are whole glows, as in an Xbox capture (xemu),
where they were cut into slivers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pelicans' thrusters and the lights of a corridor blinked out for a
moment and came back, where on the Xbox they stay lit. The game tests
each flare at its place in the frame's list and reads the test back by
that place at the start of the next frame. The Xbox waited for the
tests there, so the place's test was the flare's; the native builds
read the counts two or more frames late without waiting (d3d8_gl.c,
visibility_read_batch), and the list changes as flares come into view
and go out of it, so a flare was read the count of the one at its place
frames before. A hidden flare's count puts a flare's visibility to none
at once, and it comes back over some ticks.

Each flare now keeps a slot, found by its own visibility
(lens_flare_occlusion_test_results_get), for as long as it goes on
being tested, and is read from there; a slot just taken is not read for
four frames, until the flare's own counts have come. A flare that finds
no slot is tested at its place in the list, past the slots, as before.
Only the drawing changes, nothing the machines send.

Checked: the new harness test test_lens_flare_occlusion.py (the real
code over a fake GPU that reads counts two frames late, the flares'
order changing each frame) passes, built with clang on Windows; the
code before this change fails it (a visible flare put out to 0), as do
three other faults; in the game on Windows (RTX 5070 Ti), the
thrusters in a30's opening cutscene and a corridor's lights stay lit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two faults met running a dedicated server on Linux with Custom Edition
maps copied from Windows:

- A custom map whose file has capitals (cmt_Snow_Grove.map) could not be
  played. The maps' list names each map in lower case, and the rotation's
  check then looked for custom_maps/cmt_snow_grove.map exactly, which a
  case-sensitive file system does not have ("there is no Custom Edition
  map cmt_snow_grove"). map_file now falls back to the folder's entry of
  that name in any case (posix_find_entry_case_insensitive), as the game's
  own platform_translate_path already does when it loads the map. This
  covers custom_maps, paths.custom_maps and Custom Edition's own folder.
- PLAY NOW on the page refused a rotation entry with many rules, such as
  custom_maps\tsce_multiplayerv1:team_slayer+ff=off+team_vehicles=custom+
  ...+vehicle_respawn_time=60 (about 170 characters), with "map is needed
  (no spaces)". The page sends such an entry whole as the map, and
  POST /api/rotation/play read it into 128 bytes, though an entry may be
  255 characters. The check is now rotation_play_line, which takes a
  rotation entry's length and says so when a map is longer. The protocol
  simulator and docs/dedicated_server.md say the same.

Nothing the machines send each other changes, so the network version
stays.

Checked: new harness cases resolve-case (test_server_rotation.py) and
rotation-play (test_server_http.py), each with a negative control
(custom-maps-case-sensitive, play-entries-short). On Windows the two
files' 161 tests passed before; now 164 pass and one is skipped:
custom-maps-case-sensitive, as NTFS finds a file in any case (it runs on
Linux). server_http.c and server_rotation.c compile
clean in the Windows build. The other tests that read these files
(test_server_toml.py, test_deploy_gametypes.py, test_server_sim.py) fail
the same way here before and after the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Driving a vehicle, its shadow's trailing side flickered all the time,
most of all a Ghost's. With Smooth Motion (display.interpolation) each
frame draws the world between the last two ticks, and the model drawn
into an object's shadow map is drawn so (object_get_node_matrices), but
the map was centered at the object's bounding sphere as the latest tick
left it (object_get_bounding_sphere). A fast vehicle stood up to a
tick's travel off its map's center, by as much as each frame's blend,
and the map's edge and corner fade cut its shadow's trailing side by
that much, frame by frame.

render_object_shadow_begin now moves the center to where the frame
draws the object (render_interpolation_object_point, as the nav points
do). With Smooth Motion off, or on a tick, nothing changes; only the
drawing changes, nothing the machines send.

Checked: the new harness test test_shadow_center.py passes, built with
clang on Windows, and fails with the center left at the tick; in the
game on Windows, a Ghost's shadow no longer flickers while driving. A
point of the shadow that now and then goes missing at speed does so
with Smooth Motion off too, and on the Xbox (checked in xemu): the
original's corner fade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Firing shook the view less at high frame rates, and the assault rifle
hardly at all. A shake or impulse just started is drawn at its full
scale once (its just-started flag is cleared by the draw), and each
draw counts its time left down by the ticks since the last; the Xbox
drew a frame a tick, so the full scale showed for a tick and each later
scale for a tick. With Smooth Motion the frames between ticks drew the
scale after the tick's, and the full scale only on the tick's first
frame: an eighth of the tick at 240 frames a second, and the rifle's
short kick was over before it could be seen.

player_effect_get_camera_effect_matrix now keeps each local player's
effect as the first frame after a tick made it, and the frames after it
until the next tick draw it again; the continuous shake looping sounds
add each frame is still used up, as those frames would. Its direction
was already the tick's (the seed made from the tick). With one frame a
tick nothing changes; only the drawing changes, nothing the machines
send.

Checked: the Windows release build compiles without warnings; in the
game on Windows (RTX 5070 Ti, Smooth Motion, high frame rate), firing
the assault rifle and the sniper rifle shakes the view as with Smooth
Motion off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Shade's and the Spirit's plasma contrails were shorter and weaker at
high frame rates. A contrail point steps through its states, and a
state that ends drops the rest of the step's time (contrail_update_
points), so on the Xbox, a frame a tick, each state lasted at least a
tick. The Shade's gun (the Spirit's fires the same projectile, vehicles\
c gun turret\c gun turret) has points of states of 0.01, 0.05 and 0.005
seconds: they lived four ticks, 133 ms, there, and aged every frame at
240 frames a second about half that, the trail half as long.

contrails_update ages the points on the first frame after each tick, for
all the time since, as it already emitted new points there; the texture
animation stays every frame. With one frame a tick nothing changes; only
the drawing changes, nothing the machines send.

Checked: the Windows release build compiles without warnings; in the
game on Windows (RTX 5070 Ti, high frame rate), the Shade's and the
Spirit's contrails are as long as the Xbox's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The capture the flag flag did not behave as on the Xbox at high frame
rates. Each step of its cloth (flag_update) moves every vertex to where
its neighbours hold it, a relaxation whose stiffness is in the number
of steps, and the native builds stepped it every frame: eight steps a
tick at 240 frames a second held it eight times as stiff, and it hung
and flapped unlike the Xbox's, which stepped a frame a tick. And the
cloth hangs from its object's markers where the tick left them, while
the frame draws the object between the ticks, so a running carrier's
flag rode ahead of its pole.

flags_update steps the cloth on the first frame after each tick, for
all the time since the last step, and flag_render_proper draws it (its
vertices and centroid) as far from there as its object is drawn
(render_interpolation_object_point). With one frame a tick nothing
changes; only the drawing changes, nothing the machines send.

Checked: the Windows release build compiles without warnings; in the
game on Windows (RTX 5070 Ti, high frame rate), a capture the flag
flag hangs and flaps as on the Xbox, standing and carried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no weapon in hand, your flashlight stepped at 30 a second while
the view moved every frame. A flashlight on a first-person weapon is
placed at the weapon's flashlight marker, posed against the drawn
camera each frame (first_person_weapon_center_flashlight); with none,
the light stayed where the tick left it. The port already moved another
player's beam, in a match, to the marker on the pose the frame draws,
but only in a network game.

Any flashlight not on a first-person weapon now shines from that
marker, in a local game too. Only the drawing changes, nothing the
machines send.

Checked: the Windows release build compiles without warnings; in the
game on Windows (RTX 5070 Ti, high frame rate), the flashlight with no
weapon in hand (The Pillar of Autumn's start) moves with the view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Aging the points on the first frame after each tick, for all the time
since, kept them as the Xbox's at 30 frames a second and above, where a
frame has one tick at most. Below 30 a frame can have two ticks, and the
points aged both in one step: a state that ends drops the rest of the
step's time, so they lived longer, the trail brighter, than the Xbox's.
They now age a step for each tick since the last aging, each a tick's
share of the time, as the Xbox's frames aged them, at any frame rate.

Checked: the Windows release build compiles without warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cloth is stepped on the first frame after a tick, and game_frame,
which steps it, reads the pose that frame draws (render_interpolation.
c): it hangs from its first attachment marker as that frame drew it.
The frames after it were drawn as far from there as the flag's object
is drawn from where the tick left it, which is the other way, and a
running carrier's cloth trailed its pole by about a tick's travel. It
is now drawn as far from there as that marker has moved since.

Checked: the Windows release build compiles without warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts commit 2a2dfdb. Below 30
frames a second the points age again in one step for all the ticks a
frame has, as before it; at 30 frames a second and above they still
age a tick at a time (2d1bc04).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… come

The review of the slots found two gaps. A slot just taken was left unread
for four frames, but the renderer keeps a slot's earlier count for as long
as the GPU is behind, and through the tests it drops with every batch
waiting to be read (d3d8_gl.c): after four frames a flare could still be
read the count of the flare that had the slot before it. And a flare that
found no slot was tested at its place in the list past the slots, which
brought back the mismatch the slots are for.

The renderer now numbers the tests made at each index as they end, dropped
ones too, and keeps with each index's count the number of the test it was
read from (xgpu_visibility_tests_ended, xgpu_visibility_test_counted): in
the desktop's batches, without queries, and with OpenGL ES's atomic
counters and its queries. A flare taking a slot notes the number its first
test there will have, and the slot is read only once a count of that test
or a later one has come. A flare that finds no slot is tested at an index
whose counts are never read, so that it is still drawn while on screen, and
keeps its visibility. With the occlusion tests off
(rasterizer_lens_flares_occlusion) no test is made, and every flare is read
1 as before.

Checked: test_lens_flare_occlusion.py has new cases for a slot reused while
the GPU is 12 frames behind and while it drops the new flare's first 20
tests, for every slot taken, for split screen, for a map change with the
old map's counts still to come, and for the occlusion tests off. The
previous revision fails the four for reuse, dropped tests, no slot and the
map change (a hidden flare shown at 64 five frames after taking a visible
one's slot; a visible flare put out to 0); eleven faults are caught.
test_visibility_readback.py's new case checks the numbering through the
real Begin, End and Get functions with a GPU three frames behind and a
dropped test; two more faults are caught. Built with clang on Windows
without warnings; d3d8_gl.c also compiled as the web and Android builds see
it (HALO_GLES, with Khronos's headers) with no diagnostics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each shot of a weapon fired faster than its sound lasts takes the last
shot's channel (a sound class allows one weapon_fire sound an object), and
Flush dropped the waveform from wherever it was to nothing: a click, and a
plasma rifle crackled. The packets still go back to the game at once (it
refills the channel, and may reuse their memory), but the next 5 ms of the
voice are mixed first, at its gains, faded out along half a cosine, and
played over the next mixes beside the new sound. What is left of a cut
still playing is kept under the next.

Tests: mixer.c "cut" (a tone cut at the top of its wave fades with no step
larger than its own, and is silent after), "cuts" (voices of every kind cut
at random, once and during a cut, with NaNs where no cut is left: nothing
but finite samples), with three negative controls. Checked by ear with the
plasma pistol: the clicks are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The game works out every 3D sound's velocity relative to the listener each
tick and gives it to DirectSound (SetVelocity), with the distance factor its
Doppler model takes (3.048 m a unit) and the Doppler factor left at its
default, 1; but the SDL mixer ignored the velocities: SetVelocity was a
stub.

Each 3D voice's resampling step is moved by DirectSound's ratio,
(c - listener speed away) / (c - source speed towards), the velocities in
meters a second. The ratio glides across each mix as the game updates it
every tick. Nothing holds it but the frequencies an Xbox voice plays at,
the XDK's 188 to 191983 Hz, so that something coming very fast sounds as it
should: as high as a voice plays from the speed of sound on, and as low as
one plays for a listener outrunning the sound. The resampler's low pass now
narrows for steps up to 4, the fastest a voice can play at 48 kHz. A head
relative voice has no shift. It works with either resampler, and on 5.1 as
on stereo: only the step moves.

A stereo sound in the world, which the port pans towards where it is on a
2D stream, is shifted too: the game gives the mixer how fast it comes
towards the listener, worked out as a 3D channel's velocity is
(dsound_port_set_channel_stereo_position). Without it a Pelican's wind held
its pitch while its engines' rose and fell around it.

The velocities the game gives are only as good as the listener's, which on
the native ports is wrong; the next commit fixes it.

Tests: mixer.c "doppler" (approaching, receding, the listener moving, across
the line, a 2D voice, a positioned stereo voice, more than an octave either
way, at and past the speed of sound, the glide across a mix), with seven
negative controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The game gives DirectSound each 3D sound's velocity less the observer's,
which on the native ports is a tick's motion divided by a frame's time (the
observer runs every frame): riding a Pelican at 144 frames a second, the
camera claimed nearly five times its speed, and while a cinematic forces
the camera it reads zero. A sound moving with the camera, the Pelican being
ridden or the Warthog being driven, then seemed to come at it, and rose in
pitch.

Where the camera follows a unit (first person, behind it, a dead player
watching a teammate: director_get_followed_unit), the listener now takes
the game's velocity for the camera's position as if it were fixed to that
unit: its ultimate parent's velocity and spin, v + w x r, as physics.c
works out its mass points' velocities (object_get_point_velocity). A sound
attached to an object gets the same for the point it is attached to, not
only the object's centre's. The Doppler effect takes velocities along the
line from a sound to the camera, and two points on one turning body move
alike along the line between them, so a vehicle's own sounds hold their
pitch for whoever rides it, in any seat and from a chase camera, through a
network client's corrections of the vehicle and a turret's swing, and
swinging the camera round adds nothing. A scripted or flying camera has no
unit: its velocity is measured from how far its focus moves from one tick
to the next (observer_get_focus_position), with none across a cut or a
teleport.

Checked in game with a build that silenced any sound shifted by more than
0.5% and logged it: on The Silent Cartographer's opening (the cinematic,
the Pelican ride, the landing), and in a Warthog in tight turns; only sounds moving
relative to the listener were shifted (footsteps left behind, the other
Pelican, enemies firing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#551)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The light bridges' lightning in Assault on the Control Room and 343 Guilty
Spark's tendrils flicker several times as fast at high frame rates as at 30
frames a second. lightning_submit draws a bolt's random offsets (its
markers', its points' jitter, its texture's) from the local random seed each
time it draws it. The Xbox drew a frame a tick, so a bolt took a new shape 30
times a second; the native builds draw every frame, and at 144 frames a
second it took a new shape 144 times a second.

The first frame of a tick to draw a lightning in a window keeps the local
random seed it started from, and the frames after it until the next tick
draw the bolt from that seed: the same shape, between its markers where the
frame draws them. They leave the seed as they found it, so the frame's other
effects get the numbers they would have. Each window of a split screen keeps
its own, and a lightning made where another was starts afresh.

Checked: the new harness test test_lightning_tick_shape.py (the real
lightning_submit, lightning_new and lightnings_initialize_for_new_map over a
fake renderer that keeps each bolt drawn) passes, built with clang on
Windows: a bolt keeps its shape through eight frames of a tick whatever the
other effects take from the seed, follows its markers when they move, takes
a new shape the next tick, and each window's is its own; four faults are
caught (a new shape each frame, the seed moved on, one seed for every
window, a new lightning drawing the old one's bolt). The game builds without
warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A far Covenant plasma turret's trail flickers at high frame rates and does
not on the Xbox. contrails_update makes and ages a contrail's points on the
first frame after each tick, for the frames' time since the last tick: at
144 frames a second four or five frames', 28 or 35 ms, where the Xbox's
frames were a tick each, a 29.97th of a second. The turret's points (a
point a tick, states of 0.01, 0.05 and 0.005 seconds, both ends of the trail
drawn faded) were made and aged by those uneven steps, so the fades along its
short trail changed from tick to tick, several times a second.

The points are made and aged by the ticks' own time, a 30th of a second each.
A game time gone back or jumping ahead (a new map, a checkpoint) counts as a
tick.

Checked: the new harness test test_contrail_tick_points.py (the real
contrails_update, contrail_add_points, contrail_update_points and
contrail_compute_new_point_count over a projectile moving a tick at a time,
with the turret's contrail) passes, built with clang on Windows: at 144 frames
a second, with uneven frames (5, 9 and 3 ms) and with the game's time going
back and jumping ahead, each tick shows the trail as a frame a tick does. The
code before this change fails it (0.164 of the trail shown where a frame a
tick shows 0.167, changing from tick to tick), and four faults are caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ashing

A dedicated server playing CTF on the Custom Edition map
coldsnap_20th_xl_rc5 crashed in hs_thread_main: the map's script ran
volume_test_object on a Warthog that had been deleted. A script keeps an
object's index after the object is gone, in a global or an object list, and
nothing between the script and object_get checked it. In a release build
datum_get reports the stale index and returns NULL, and the read through
that NULL faults. Halo PC plays this map.

The script runtime now turns such a value into none. hs_cast checks every
global read and every function's or script's result, before casting, so no
list is made of a deleted object. hs_arguments_evaluate checks the arguments
again before the call, because a later argument's static script can sleep
after an earlier one was evaluated. Lists collected in the same way count as
gone too. Walking an object list passes over deleted objects; list_count
still counts every entry, as it always has.

A script already gets none for a named object that doesn't exist, and nearly
every script function takes it. The functions with object parameters were
searched for a check of none, and those without one were read: unit_kill,
unit_kill_silent and scenery_get_animation_time crashed on none, so they now
do nothing for it.

HALO_PORT_NETWORK_VERSION stays the same: nothing sent changes, and scripts
need not agree between machines. Only the host runs them in co-op, and in
other games the host decides what they do to the game.

Checked with tools/harness/tests/test_script_stale_objects.py (the real
runtime and data arrays, with negative controls): 42 pass. On Windows the
full harness has the same 631 failures as testing and no new ones. The four
changed files compile for Windows; CI builds the other platforms. Not run on
the real map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-index

Give a script none for an object that no longer exists, instead of crashing
…y-now

Find custom maps in any case and play long entries from the server page
…interpolation

Blend the first-person weapon's own fast animations between ticks
…ile-busy

Start StageKit's games one at a time, and again when the binary is busy
Fix remapped menu navigation and add a camera cycle binding
Keep a lightning bolt's shape through the frames of a tick
Shine an empty-handed player's flashlight from the drawn pose
The flag cloth a tick at a time and draw it with its pole
Draw a tick's camera shake on every frame until the next tick
…nter

Center an object's shadow where the frame draws it between ticks
Size the Xbox scope's 2x and 10x to the stock glyphs, not their sprites
Keep chapter titles at the side for wide screens
Draw a scope's zoom blur to the left and top edges of the screen
Keep a z bias's depth units and slope as the Xbox's at any depth and size
…lots

Test each lens flare's occlusion in a slot of its own
Sound: fade out a voice the game cuts off instead of stopping it dead
Time server browser hosts in the order shown, with dots
…r physics steps a tick at a time (#546)

game_frame halves the frame's time while the players are double speed, and the ticks' time the points now take should be halved with it. The comment and the README row now say that the points' physics and fades step once a tick, not every frame. test_contrail_tick_points.py passes (7).
…ost (#441)

The probes waited while any listing was queued for its signature check, and anyone can send listings: a queue kept full held every ping back for good. They now wait at most PROBE_HOLD (1 s) after the browser opens or REFRESH, so the first wave still starts in the order shown. p2p_lobby_check.c checks the hold both ways and fails with the old gate; test_p2p_signatures_and_listings passes.
stream_flush now calls voice_cut, and the stream holds the cut's buffers (CUT_FADE_FRAMES), which test_sound_stream_flush.py's extract of dsound_sdl.c lacked: it no longer compiled. The fade itself is test_mixer.py's; here it is a stub. The audio harness tests pass (76).
@cybersecurity
cybersecurity merged commit fc9deb0 into main Nov 6, 2026
7 checks passed
cursor Bot added a commit to damiantw/halo-ce-web-multiplayer that referenced this pull request Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants