On Damnation two of three of the bots' path searches failed (1078 against
527 in 80 s), and the goal's floor was not found 357 times: a search had
to reach the goal's surface itself, and the floor under the goal was
looked for 3 world units down. As OpenCE's bots do (bots.c), a search now
goes to as near the goal as it reaches (the bot searches again from
there), and the floor is looked for 6 units down. A path that ends short
of a goal above the bot takes a ladder near it (now within 8 units).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bot whose way is not found twice running, or that is stuck, with the
foot of a ladder going up within 5 world units, walks to the foot, turns
to the ladder and walks into it (as a biped climbs a climbable surface,
bipeds.c), and on past its top a moment; then it finds its way anew. Ten
seconds without getting up, it gives that up for fifteen. The ladders are
the level's climbable upright surfaces, near ones facing alike merged,
found the first time one is wanted in a game. Reported: bots stuck in a
gap of Damnation's that only a ladder leads out of.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ringfall's side of it:
- The bots: OpenCE's own (bots.c, a local split screen game's) are not
used in VR builds (their lobby hook would take Ringfall's host bots
off, and their bots.count and bots.skill differ); Ringfall's are now
host_bots.c, so that OpenCE's bots.c merges as it changes. Each
platform's bots.count and bots.skill are its own (config_setting_index
takes this platform's of two of a name).
- HDR is never on in VR (d3d8_gl.c); the flat window's present is
OpenCE's, inside the VR port's branch for it.
- The VR frame begins where the events are pumped (sdl_platform.c), as
before, now that exit_after_check is apart.
- The message numbers: OpenCE's aim assist policy (90) before the VR
port's poses (240), which stay last.
- The visibility counters keep the VR port's snapshot ring (both eyes'
counts halved); OpenCE's tone mapping goes before the antialiasing, off
the VR eyes' pass.
- Join codes, the online list and OpenCE's probes all in the signalling
topics; the HUD's PNG helpers both ways; the disc import keeps the
language file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Previously the README did not name OpenCE, list what the port adds, or say what players need before they start. It now leads with that and moves the developer material to the end.
- An introduction naming OpenCE (Open Community Edition), with the icon, Download / Play in browser / Discord buttons and an updated screenshot of the latest version.
- The disc image comes first, then the downloads, with a Guide column linking each platform's README in place of the Platforms section.
- Multiplayer, Custom Edition maps (with Halo PC's own maps), mods, and a table of the graphics options and their defaults.
- Testing, optimisation profiles and profiling builds under "For developers".
- Credits naming the upstream projects and main contributors.
- A disclaimer that OpenCE is an unofficial fan project, as on the web site.
#319 put FRIENDLY FIRE in place of SETTINGS, written when co-op's pause
menu had no SETTINGS; testing now gives network co-op SETTINGS through the
campaign's pause menu, so as merged co-op lost it. pause_list_patch now
adds SETTINGS always, then END GAME for a multiplayer host or FRIENDLY FIRE
in network co-op (still two buttons, the four tags reserved), and
pause_settings_patch runs in co-op again. Six rows at the PC pitch need a
box 66 taller than the stock 159, which had no frame (only 195, for five),
so tools/port_settings.py draws the pause and objectives boxes at 225 too
(the same redraw), and pause_campaign_grow_box picks the frame by height.
Single-player and split-screen pause menus are unchanged.
Checked: menu_tags.c and menu_functions.c with -fsyntax-only for Linux,
the Android guest and the web; tools/harness and test_touch_menu.py pass.
On Xvfb, d40 with co-op forced in a local build showed the six rows and
the 225 box with the prompts under them, FRIENDLY FIRE switching
network.coop_own_friendly_fire; a10 still logs five rows (36 more, 195)
and bloodgulch SETTINGS. No network game was run.
"highest" chose level 4 for every model, and render_model_parts draws
nothing for a permutation without geometry there, so those parts
disappeared; and a10's cryotube's top level (a cutoff of 3000) is its
inside only, which "highest", and "native" at 4K, showed from outside.
With display.model_detail other than "stock", each permutation now draws
its most detailed level at or below the model's that has geometry, and a
level with a cutoff over 1000 only while the camera is inside the model's
bounding sphere (port/linux/game/model_detail.c), from eltanschauung's
#282. "stock" draws the model's level as before; so does
rasterizer_debug_model_lod.
Checked: python -m pytest -q tools/harness (#282's lod-* cases in the
transparent enclosure test and its interior, bounds and permutation cases
in test_model_detail.py, with negative controls); models.c, d3d8_gl.c and
model_detail.c compile (-fsyntax-only) with the Linux, Android guest and
web commands.
Such an effect draws from the game's random seed, which every machine must use alike; played in place of the projectile's own on this machine only, it would change how the seed is used there (on a host, for everyone). overshield_effect_hit_effect_allowed refuses it, with effects.c's flag bit named where its enum cannot be reached. Checked: overshield_effect.c compiles without warnings.
Seeing around the vehicle from behind it is an advantage the other players of a multiplayer game do not have (raised in the PR's review), so reference_seat refuses while a game engine runs. The README row and the menu help say so. Checked: passenger_camera.c compiles without warnings; the FOV and Viewmodels screen written again by port_settings.settings_files() is the one committed.
display.anisotropic_filtering defaulted to 4, which changes the stock look on every platform, phones included; the rules want a departure from stock to be chosen, and the author offered 0. The setting's comment, its README row and the TEXTURE FILTERING help (no longer "4X costs very little") say so. Checked: the Graphics screen written again by port_settings.settings_files() is the one committed.
With audio.channels "auto" by default, a desktop whose device reports six or more channels (an HDMI sink, a virtual 7.1 headset) changed its mix unasked; the rules want a departure from stock to be chosen, so the default is "stereo", and an unknown value plays stereo too. audio_start set audio_device_name twice. tools/harness/audio.py copies MAXIMUM_OUTPUT_CHANNELS, which the 5.1 mix's structures now use, so the sound stream tests compile again. Checked: dsound_sdl.c compiles without warnings; the harness's sound tests pass.
distributed_correct_own_unit now times the host's echo of a prediction for the latency meter (distributed_note_own_round_trip), which tools/harness/tests/elevator_correction.c, taking the function from the source, did not declare, so it did not compile. Checked: test_elevator_correction.py passes (14).
Co-op players choose their own friendly fire, and one away is hurt by all
Network version 32, which #257's policy raised and this has not been pushed apart from, also carries the friendly fire choice in a client's input (network_format.txt recorded again).
The policy is a new distributed message (90) that a client must not ignore, so machines without it cannot play with those that send it: HALO_PORT_NETWORK_VERSION goes from 31 to 32, with its line in NETCODE.md's versions and port/linux/network_format.txt recorded again. network_aim_assist.c called main_menu_is_active without interface/ui_widget.h, so C89 took it as returning an int. The setting's README row moves to the network.* rows. Checked: network_format.check() is clean, test_linux_port -k network_format and the aim assist harness tests pass, network_aim_assist.c compiles without warnings.
On testing, gametype_option_help also holds TEXT CHAT at OFF while the game is listed (TEXT_CHAT_SPINNER, TEXT_CHAT_OFF, p2p_hosting_listed), which the PR's harness test, written before it, did not declare, so coop_scaling_menu.c did not compile. Checked: python -m pytest -q tools/harness -k 'pickup or vehicle or coop or scaling or menu' passes (58).
The PR's Graphics screen was edited by hand: tools/port_settings.py did not name display.match_timer among the Graphics rows, so the generator put MATCH TIMER on Video Setup's own screen, and test_web_build's check that the menus match their generator failed. The setting gets its row in the settings table of port/linux/README.md. Checked: the screens written again by port_settings.settings_files() are the ones committed.
Since the check of the maps folder (e075fffa2), a game whose maps folder has no ui.map says so and quits, so every StageKit run stopped before its map loaded and CI's Linux test step failed (16 tests). The runner now writes the eight bytes the check reads ('head' and the Xbox's version 5); nothing in a run reads more of ui.map, as it had none before. Checked: python -m pytest -q tools/stagekit/tests with the --portable --release build passes (20), and fails the same 16 without this change.
SDL_GetCurrentVideoDriver is not among the SDL functions the Android guest bridges, so the llvmpipe warning stopped the guest image from linking. The warning points at the Linux README's NVIDIA on Wayland, so it now has the same guard as the rest of #275. Checked: ninja android, linux and web build.
progress_bar_download_display, progress_bar_download_end and
progress_bar_downloading were for a download screen that is not merged; they
are removed and progress_bar_eachframe is as it was. Without loading.tga the
texture is no longer filled with clouds the port made: it is left as
created, black, as the game leaves it, so that no invented picture ships.
The disc image importers' comments say so.
Checked: node --test "port/web/tests/*.test.js", pytest tools/harness and
tools/test_web_build.py; the changed C files compile (-fsyntax-only) with
the Linux, Android guest and web commands; XisoExtractor.java with javac.
The loading screen (interface/progress_bar.c: a light sweeping from left to
right over a picture, with a glowing trail) drew nothing: it uses the
fixed-function pipeline (SetVertexShader with 0), which d3d8_gl.c did not
have, and its picture, loading.tga, is on no retail disc.
- d3d8_gl.c draws fixed-function immediate-mode draws with a vertex shader
of its own (nv2a_fixed_function_glsl), positions transformed by the
world, view and projection matrices; and keeps a front buffer (a 640x480
render target taking the middle of the frame at BlockUntilVerticalBlank)
for GetBackBuffer with -1, the frame before, whose blur makes the light's
trail. The screen is drawn in the middle 640 columns, as the menus are on
a wide screen.
- After it, the rasterizer's caches of the device's states and of its
vertex shader are made the device's again (rasterizer_forget_hardware_state,
rasterizer_vertex_shader_runtime_forget): it sets them itself; and
SelectVertexShader (a packed program) always leaves the fixed-function
pipeline.
- On OpenGL ES (Android, the web) the fixed-function shader turns
Direct3D's clip space as the others do (HALO_GLES); without OpenGL
(debug.null_renderer) it is not compiled.
- The picture is the Xbox's own: the retail default.xbe holds it (320 by
240, runs of 4-bit greys after its width and height), and importing a disc
image now writes it into the maps folder as loading.tga: port/linux/src/
xiso.c, the Android app's XisoExtractor.java and the web page's
xiso-worker.js. Without one (a maps folder copied by hand) the light
sweeps over clouds the game makes. No game data is in the repository.
- progress_bar.c also has a mode a download screen over the menus can use
(a share given, from 0, drawn over the menus, and let go of).
- A picture that cannot be written whole is not left cut short (the
loading screen would read past it).
Checked: port/web/tests/xiso.test.js takes the picture out of made-up
executables (and an image's default.xbe); the Java decoder gives the same
bytes as the JavaScript one; xiso.c's picture from a Rev 2 disc image
matched a separate decoder's byte for byte; the Linux CI steps and the
web build and its tests in Docker; the Windows release build.
[The disc image parts (xiso.c, xiso-worker.js, XisoExtractor.java and
xiso.test.js) redone on the rewritten disc-image readers.]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 010110026e719c3e8c38d36b93d57cf67b616230)
The tool's documentation and comments compared an x86-64, x32 or arm64
build against the i686 one, builds the project does not have. They now
compare a build with a simulation, compiler or maths change against one
without, and the wrapper's example is a tracer rather than an emulator.
Copies outliving the tool: the copies ran in the tool's process group and
were stopped only by play()'s finally, so SIGTERM or SIGHUP to the tool
(which Python does not turn into an exception) left them running, and they
ignored SIGTERM. Each copy now runs in a session (a process group) of its
own, started with the stop signals blocked so that it is always noted as
running; the group is ended with SIGTERM, then SIGKILL two seconds on, on
every way out: returning, an exception, Ctrl-C, SIGTERM and SIGHUP (which
now raise), and atexit. Killed outright, the kernel ends the copies
(PR_SET_PDEATHSIG). Stopped by a signal, the tool says so and exits 128 plus
its number.
Exit status: the worst of the runs was max(), so an ERROR (2) in one run
hid a FAIL (1) in another. A FAIL is the finding and an ERROR only a gap in
it: any FAIL now gives 1, else any ERROR 2 (documented in the tool and
NETCODE.md).
--tolerance persist=3.5 raised a ValueError traceback; each value is now
checked (whole numbers, finite, not negative, drift_share at most 1) and
refused through argparse, as is an unknown name or a missing "=".
--window keeps the caller's DISPLAY or WAYLAND_DISPLAY (documented, and
--display names an X display instead); headless copies now get neither,
so that none can open a window on the caller's desktop. HALO_EXIT_AFTER, the
copies' safety stop, now counts --skip too, as it works headless.
Checked: tools/test_net_compare.py, 28 passed (20 new: the exit status, the
tolerances, the copy's folder, the displays, stop() ending a copy that
ignores SIGTERM and its child, and the tool stopped by SIGINT, SIGTERM,
SIGHUP or SIGKILL leaving no copy). With the maps: i686 (ninja linux)
against x86-64 (ninja linux_x86_64), bloodgulch and beavercreek with --swap:
4 of 4 games in step, exit 0 (drift 0.0%, 0.0%, 3.5%, 14.2%), with no copy
left running.
(cherry picked from commit a53d86ccc2216d0949a6c089f57f6e8bd534c93c)
The host's drift figures for net_compare.py (distributed_divergence in
network_distributed.c) were zeroed only as each second's line read them:
the last taken prediction's ticks and each player's latest input tick
stayed from the game before, so a game's first ticks could be compared
against the last game's, and before any game they were 0 rather than none.
network_distributed_new_game now clears them, the ticks to NONE.
The input's tick and the prediction's time are the client's word: a hostile
one near LONG_MIN made "newest - TICKS_PER_SECOND" and "time - 1" overflow
(undefined behaviour). Both are now guarded.
The harness's elevator prediction test (tools/harness) did not compile
since the drift figures came in (distributed_take_prediction notes them):
it now takes distributed_divergence's definition and
DIVERGENCE_APART_DISTANCE from network_distributed.c, as it takes the
functions, so it runs the real code.
Nothing the machines send or must agree on changed: the figures are the
host's log's only, so HALO_PORT_NETWORK_VERSION stays.
Checked: python -m pytest -q tools/harness, 293 passed (5 failed before);
ninja linux and ninja linux_x86_64 build; net_compare.py with both builds
(the next commit's message).
(cherry picked from commit dcfed92a3a7687755b50bcc075669addb9704c3e)
To show that a new build (the x86-64 one, an arm64 one) plays network
games with the i686 Linux build, and to keep checking it. It plays the two
together on the loopback, A hosting and B joining and with --swap the other
way round, each copy with a seeded bot, headless, and compares the per-
second logs of the network test: for every player, the host's view and the
client's at the same tick (positions, aim, vitality, weapons, rounds,
grenades, scores, life), within tolerances taken from how the netcode
predicts and corrects, and the host's drift figure (the previous commit).
It prints PASS, FAIL with the first divergent player, tick and field, or
ERROR, and exits 0, 1 or 2. It needs the retail maps, so it is a local or
self-hosted check: NETCODE.md ("Comparing two builds") describes it, the
log line's format and the calibration; README.md's new Testing section and
AGENTS.md list it. tools/test_net_compare.py tests its judging of logs
without the maps, in CI.
Calibrated with an i686 build against itself, 60-second games both ways
round on bloodgulch, beavercreek, hangemhigh, chillout, prisoner, ratrace,
longest and carousel, up to four games at once: 63 of 64 pass (one copy
crashed, reported as an error), and 12 of 12 with scripted hits and kills
(--events). A build of the same sources whose players run 0.5% or 2%
faster (a change made only for the test, not committed) fails all 16
games, at the first second compared. One whose gravity is 0.5% or 2%
stronger passes: it changes only the ticks in the air, which the drift
cannot tell from what identical builds show; NETCODE.md says so.
(cherry picked from commit 0adc286c6497bffe8edfe297181aa39c2b0bbb8e)
The network test's per-second line (debug.network_test) now ends with
"| drift <ticks> <apart> <mean> <largest>": on the host, the ticks in which
it moved a client's player one tick on from where it took the client's
last prediction, with the client's input of that tick, as the client did,
and how far apart the two moves were. Builds that simulate alike move the
player alike to the last bit, so this is what shows a build that simulates
otherwise; the positions the machines log agree either way, as the netcode
corrects every copy toward the host's. tools/net_compare.py (next commit)
judges two builds by it.
Only counters and the test's log line change: nothing sent between
machines, nothing a game does with them, so the network version stays.
Checked with i686 builds against themselves (0 to 13% of such ticks apart,
up to 42% on a loaded computer) and against one whose players run 0.5% or
2% faster (77 to 93%).
(cherry picked from commit 95b2ddf86d4abc279c4ed462789e0ff7a75a7881)
platform_pump_events returned at once when there was no window, and it is
where debug.exit_after (HALO_EXIT_AFTER) was checked and where SDL's quit
events, which SDL makes of SIGINT and SIGTERM, were read. So a game with
debug.null_renderer (the network tests, net_compare.py), or without a
display, never stopped by itself after debug.exit_after, ignored SIGTERM
and Ctrl-C, and never answered the updater's question with
debug.update_answer.
Without a window (debug.null_renderer, or after the window was asked for
and could not be made) it now checks debug.exit_after, reads the events for
a quit request and runs updater_poll when debug.update_answer answers it,
on the thread that first pumps (the game's, as each frame ends); the rest
of the events, of no window, are dropped. The time for debug.exit_after
starts there at the first frame. With a window nothing changes.
Checked with the null renderer (SDL_VIDEO_DRIVER=dummy, no DISPLAY) on
bloodgulch, ninja linux and ninja linux_x86_64: with HALO_EXIT_AFTER=8 both
builds exit with status 0 after 8.2 s ("exiting after debug.exit_after"),
where the builds before ran until killed at 25 s; SIGTERM at 10 s ends both
with status 0 in 0.1 s ("asked to quit (no window)"), where the build
before ran on.
(cherry picked from commit a5531f3e5774b29ce27602bad7c7c2468c2fc074)
The culling tests could be changed only with the console's
structure_culling, which did not last past the game's end. The setting
(HALO_STRUCTURE_CULLING) gives the starting bits, 31 (every test) by
default as before, read in render_initialize; the console command still
changes them while the game runs. Documented in the settings table.
Checked: tools/harness (640 passed, the structure culling tests among
them), test_linux_port's config section and menu settings tests, and the
changed files compiled with -fsyntax-only for Linux, the Android guest
and the web.
The change sent Mesa (radeonsi, Intel, the Steam Deck) and Intel's Windows
driver to read every query with glGetQueryObjectuiv, which Mesa's GL thread
makes wait for everything before it: that is why the counts were copied
into a buffer. Only AMD's proprietary driver (GL_VENDOR "ATI Technologies
Inc.") now reads them on the CPU; every other driver keeps the fenced
buffer. When the queries cannot be made, the game no longer exits: it logs
it and every visibility test reports fully visible. The new comments and
logs are written in the file's style, with the Mesa note restored.
Checked: d3d8_gl.c compiles with -fsyntax-only with the Linux, Android
guest and web commands from build.ninja (with -w removed), with no
diagnostics beyond those of the file before the change.
configure.py goes back to mode 100644, which the pull request changed by
accident. The help, messages and README lose their typos, AGENTS.md and
README.md mention --windows-cross beside `ninja windows`, and the Windows
README says that the SDK needs versioned Include and Lib folders, which a
plain xwin splat lacks. The StdDef.h shim is linked again when a dangling
link is left behind, where os.symlink raised before.
Checked with python configure.py --help and a configure run, and
py_compile of the changed scripts.
OpenCE moved from CC0 1.0 to AGPL-3.0-or-later (4b09168e7), and Ringfall:
Mjolnir VR moves with it: README's Legal section and THIRD_PARTY_NOTICES
say so (releases before this one stay CC0 1.0, LICENSES/CC0-1.0.txt).
Its disc image readers were written again from a description of the
format, extract-xiso's code and notice gone. The app's (XisoExtractor.java)
is OpenCE's new one with Ringfall's language folders (maps_es, maps_it)
added again; the Steam Frame's (steam_frame_xiso.py) is written anew the
same way, the language folders kept; the bundle and the release job no
longer carry extract-xiso's licence. Also in: bnunu/halo-1's latest
matches, the web build's WebRTC and loading fixes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
map_share_restart.c stands in for what download_finish calls, which now
names the downloaded map in custom_maps' list (downloaded_maps_mark);
test_server_teardown.py's negative control finds the grace period's guard
as it now reads, with HALO_WEB.
The Android guest's C library cannot link pthread_once (its cleanup
handlers are not there), so the picked log_address.c stopped the Android
build from linking. The salt is made once by the first thread to log an
address, the others waiting for it.
The picked netcode checks add DISTRIBUTED_TIME_WINDOW_TICKS, which
network_format.txt records, and they and the second copy's ports change
what the machines take from each other: version 31, not yet released,
says so in NETCODE.md.
Several of the fixes taken from the forks changed source/ without the
port: comment that marks a change from the original (the second copy's
datagram ports, the downloaded maps' refusals, the main menu's clock,
the one-magazine HUD states, the render lists' sizes and others), and a
comment still named a function of that fork's. Each change now says so.
A tick waits for an input update with an action for each player, and the
menus change who the local players are (signing profiles in and out, a
lobby's network game owning the queues). In a lobby, or once a client's
clock waited for a game, the main menu's scene stopped ticking; and a
screen flagged to pause game time (Mods, the map and gametype screens of
setting up a game) paused the clock that runs the scene, which then
started again from its beginning as the screen closed.
The main menu's scene now always ticks on this machine's clock
(game_time_menu_scene), its players idle where the queues have no input
for them, and at the main menu those screens no longer pause it. The
distributed netcode's per-tick work is not done for the scene: a lobby's
machines do not share it.
(cherry picked from commits 8444785804f8cfb413020da5a03fc5dd65f918e9, 6267d199117f87e56a3f41fc8932966a39a3feb2
and 3a4097bb48f34245b750028a5c0ae28e03bae037, made again on OpenCE's game_time_update,
which has no separate clock connection for the scene)
The high-res HUD draws a message's controller button by its place among
the HUD globals' button icons, but only once the map's message icons
bitmap had a picture at the button's sequence. Halo PC's message icons
have 13 sequences to the Xbox's 24, so on a Custom Edition map a prompt's
button past them was not drawn at all, and the rest only because the map
had some other picture there ("Hold [rocket launcher] to swap" on
coldsnap, with the high-res HUD off). The high-res button is now tried
first. With display.high_res_hud off, a Custom Edition map still draws
its own pictures.
Found in commit 4c3e74cf29341ff344aca09febc0fab7e1d19919, which instead
reads the Xbox's button pictures out of ui.map for every Custom Edition
map; that is not taken.
network_game_server_dispose keeps the Xbox's one-second grace period
(SleepEx) only for builds other than Windows, Android and Linux, whose
transports need none; the web build is no __linux__ to Emscripten, so it
still waited a second each time a hosted game ended. It is left out there
too.
(cherry picked from the server part of commit bea41d710988e9aeac6cec44f4fd69b5203906e2,
whose dispose change OpenCE already had for the native builds)
_data_packet_verify read an uninitialised field_size for a first field
outside the packet's version, and gave later ones the size of the field
before them. No field of the game's packets has a version limit, so no
packet changes; the size starts at 0.
(from commit c01c68537bcbda79f0e4def4211cad611dfdee74's data_packets.c
change; the rest of that commit, comments rewritten, is not taken)
A maps folder holding Halo PC's maps (one pointed at a Halo PC install's,
say) had its ui.map, a Custom Edition or retail Halo PC cache file, loaded
as the Xbox's: game_load() failed, and the player got a blue screen.
Before the window opens the desktop builds now read ui.map's header: when
it is Halo PC's, or there is no ui.map, the game says where the Xbox maps
go (the data root's maps folder, named in full) and that Custom Edition
maps go in custom_maps beside it, and quits. Halo PC maps in maps/ itself,
which play only from custom_maps, are named in a warning. Runs with no
window log it instead. The README's game data section says so.
(cherry picked from commit 223c4aaf16b2819c57cd31d470d5dd04c9daa4f7, made again for
OpenCE's custom_maps folder in place of that fork's maps/ce and md_maps,
and not on the web build)
A Halo PC map's animation graph may have more nodes than its model
(beavercreek_rev_beta's cyborg has a graph of 20 on a model of 19), which
Halo PC's engine posed past the biped's node matrices. Such a dead body
was left unrelaxed here. It is now posed in a copy of the biped's node
matrices with the graph's every node, the extra ones where their parents
are, and the model's taken back.
Reported by Sabriel.
(cherry picked from the limp body part of commit 5fdbccf6cdf4f5be4bcaf1b90f4fe712877ddb4b;
OpenCE's checker already let such a graph be, and its own check of the
nodes is kept for the counts and links)
The new profiles pick took its side of a conflict in port_config.c whole,
and with it game.move_old_map_folders, which moves that fork's Halo PC map
folders: OpenCE has no such folders, and nothing read the setting.
A map that map sharing downloaded runs only scripts that keep to the
player's own game: one that calls what changes the player's settings or
other players' games (player0_look_invert_pitch, sv_end_game, sv_say) or
sets a global that outlives the map is disabled, as for any map's script
that calls what no map's may, and debug.txt says which. Map sharing names
each map it downloads in custom_maps/downloaded_maps.txt, so they stay
held to this in later runs; game.downloaded_maps names more, or "*" for
every map. The campaign's game_speed and game_save_totally_unsafe stay
allowed.
The setting's entry came into port_config.c with the profile commit before
this one (a conflict's side of it); it is the one entry now.
(cherry picked from commits 7d02d46210ba672512e36b39ffd2c0b0d8e4f3b3 and
d3718e78d4889140a44f8050075b56143e4b7732, wired to OpenCE's map sharing in place of that
fork's map families)
Mbed TLS is built without locks of its own, and on Linux two threads use
it: the self-updater's (posix_update.c, its update check at start-up) and
internet play's (posix_dtls.c, its certificate and its handshakes with
browsers). Two threads setting up its PSA crypto or drawing on its random
generator at once could free a hash context under the other and crash.
The Linux build now defines MBEDTLS_THREADING_C and
MBEDTLS_THREADING_PTHREAD for Mbed TLS and its users, which makes its PSA
core and random generator take their own locks (Mbed TLS 3.6); its config
is left unchanged. The other builds use it on one thread.
Ported from commit 04fa0517b32cde1c52ead7a2f21d249d0c5459e2, which made
the threads take turns with one lock held over each whole request; here
the updater's download cannot hold internet play's thread up.
The Join Game server browser shows each ping coloured: green under 80 ms,
yellow under 160, red above. A text colour hook of the menus'
(pc_menu_text_color) gives the ping its colour; the rest keep their own.
(cherry picked from commit 335c6522e9fb7aa33762e3f2cf27c518ac0e48ce, without that fork's
Online Games screen, the thresholds and colours kept in menu_functions.c)
The PING column showed nothing, as a host was reached only on joining. The
browser now probes each listed host as a join would reach it: the tunnel
made with the listing's invite (a signalling request of its own token,
answered on its own topic), the shortest of three pings kept, and the
tunnel closed again. Hosts on any version answer, as it is the ordinary
join. A few are probed at once, each again every two minutes and on
REFRESH. The game sees nothing of a probe's peer, and a join of a host
being probed takes its tunnel up. A game with a password is not probed:
its listing hides its token. Direct Link's games show the open tunnel's
ping.
(cherry picked from commit 98889fc72bc8cfd765b765d6e86c973c2040f841, merged into OpenCE's
internet play: its retired sessions keep their identifier, and its
server browser's NAT advice is kept in the README)
SDL drives a DualSense or DualShock 4 itself through its hidraw device,
which only udev rules (Steam's) open to the player; without them the
controller was not found at all. With input.playstation_kernel_driver on,
where the kernel has its driver of them (hid-playstation, hid-sony), the
game leaves the controllers to it: their buttons, sticks, triggers and
rumble, through the input devices a player can read, but not the
lightbar or the motion sensors SDL's own has. Off by default.
(cherry picked from commit fa1ff85ebd46ce183522baa5a91c9a29c9006c9f, made a setting, off
by default, as it gives up what SDL's driver has)
In Join Game's server browser a click selects a game, its row in the
yellow-outlined picture Custom Edition draws for it, and a click on the
selected game, a double click, A, Enter or JOIN joins it; one click
joined it before. The keys move the selection.
(cherry picked from commit 472d232246568ed2e3ee56bc93d97a3530feea54, without that fork's
Online Games screen; OpenCE's rows already take the focus only from a
click, not from the pointer passing over them)
An old build sees none of the public games (each list leaves out other
network versions), and said only that there were none. The hosts heard
listing a game of a newer version are now counted (by their keys' hashes,
forgotten as a game is), and the server browser's empty list says so:
"2 games are on a newer version: update the game to join".
(cherry picked from commit 4a4a4134519b76a5bcfd11e0e47cf397d7a228a2, made again on OpenCE's
server browser, whose listings come through p2p_lobby.c; that fork's
counted its own game list's)
The debug build this port comes from unlocked every campaign level on
every difficulty for each new profile. Retail Xbox Halo does not: a new
profile starts at The Pillar of Autumn and each level opens when the one
before it is finished. New profiles now start that way.
- game.unlock_all_levels (off by default) keeps the old behavior for new
profiles, for those who want it.
- profile_reset_campaign in the console clears the current profile's
finished levels and last played level, after a confirmation
("profile_reset_campaign yes"). Profiles made by earlier versions had
every level open and cannot be told apart from a real full clear, so
nothing is changed automatically.
(cherry picked from commit 224d9a757575509efb0dedab094bd0d34df59e96, without its top README
table, which OpenCE does not have)
A busy multiplayer fight fills the Xbox's per-frame lists of what a view
draws: 512 particle runs and 128 dynamic lights. Past them plasma bolts,
charge glows and flashlights stop drawing. The desktop builds now draw
2048 and 512. These lists are rebuilt every frame, outside the game state,
and nothing of them reaches the network. Android's and the web build keep
the Xbox's.
(cherry picked from commit f1abebdd6e89de292569ea466496fa0484476e0e; OpenCE already drew
2048 transparent geometry groups on every build, so only the particle runs
and lights are taken, with OpenCE's guards for the builds that keep the
Xbox's sizes)
The first frame that drew each HUD picture decoded its PNG, which held that
frame up. On the desktop builds a map's HUD PNGs (and its sprite sheets')
are now decoded as the map loads, on a thread of their own, so that the
first frame only uploads them; what is decoded and not drawn is let go when
the map is unloaded. Android and the web build decode each when it is
first drawn, as before.
(cherry picked from commits afa4feaee4935b118dbc6d94140587842205cbfe and
27a22a2ded1bc78323f7f4bf35033dd843f7b956, made again on OpenCE's high-res HUD, its sprite
sheets queued too, and not on the web build)
The full reference list pick left a light's clusters out under
HALO_SERVER, its dedicated server's define, and the debug.txt roll's
header check under HALO_XBOX_CONSOLE; OpenCE defines neither, so both
were dead or always true. The comment no longer cites a dedicated
server.
A dedicated server's debug.txt grew 2 GB in 12 hours, almost all of it
"maximum light cluster references per map (8192) exceeded", written each
time a light could not join a cluster: thousands of times a second on a
busy map. A full reference list now says so the first time a map, and the
times after are counted and told when the map's lists are let go or made
anew. What fits in the lists is as before.
The dedicated server draws nothing, and a light's clusters are only used
to draw (the lights of an object or the map, and a unit's illumination,
which only debug_unit_illumination reads, from the lights drawn that
frame). The server (HALO_SERVER) no longer joins lights to clusters, so
it does none of that work each tick and never fills the lists.
(cherry picked from commit 50cc6274fb5f7697c6f3c25394e15e22f87106f2)
A line the same as the one before is counted rather than written again
("last line repeated N times", when a different line comes). When
debug.txt passes 64 MB it is renamed debug.old.txt (replacing the one
before) and a new one starts, with the header again for crash reports'
addresses. A game left running for days had no limit to its log.
(cherry picked from commit d4b46c829dcdaa53ee72d7d2901bc166115b0e69, without its dedicated
server README; the web build's copy of each line to the page's log is
kept)
The desktop builds now name a config.toml.txt beside the real file
(Windows hides the .txt an editor may add, so the file looks right and
does nothing), and Linux names a config.toml in the folder the game was
started from when that is not the executable's:
settings: .../config.toml.txt is not read: Windows saved it as
config.toml.txt; rename it to config.toml
(cherry picked from commit b4f73a749f62cc5ed28092b7155d04638f5bb84d, without its macOS
check, and not on the web build)
display.mode = "Windowed" was not "windowed", so the window followed
display.fullscreen instead, without a word. The settings that take one
of a few words (display.mode, resolution_scaling, anti_aliasing, menus,
player_names, the chat, voice, co-op and streamer words,
crash_reports.upload and others) now take them in any case, and one that
is none of them is logged once:
settings: display.mode = "Fullscreenn" is not "fullscreen",
"borderless", "windowed" or empty; using display.fullscreen's
(cherry picked from commits b1d3eacadbc4047f6294c324b7d8e23009d3f2c1 and
80196e309a8af878309151cbec6794b8386759a8, made again with OpenCE's settings' words and
the port's own case compare, which the Android guest has)
One TOML mistake, such as an unquoted word or a capitalized False, put
every setting back at its default. The line the parser stops at is now
skipped (in memory; the file is left as the player wrote it) and the
file read again, up to 16 times, so every other setting still counts.
Each skipped line is logged with its number and the likely fix:
config.toml line 87 cannot be read (invalid value), so it is skipped:
true and false are lowercase, high_res_text = false
config.toml line 100 cannot be read (invalid value), so it is skipped:
text needs quotes, menus = "pc"
(cherry picked from commit b3ee742ce7aef353e3f06af72b7cf83fff23544d, made again on
OpenCE's config_load: the file is not completed or rejoined while a line
of it is skipped, and the case compare is the port's own, which the
Android guest has)
A one-magazine weapon's second magazine, read as empty, still set off the
secondary states: the secondary trigger ready, flash, reload and "no ammo
for reload" crosshair states, and the draw states of its total and loaded
ammunition (flashing, disabled), which coldsnap's and hugeass's master HUD
draws as NO FUEL beside the weapon's own NO AMMO. These now apply only to a
weapon with a second magazine.
(cherry picked from commit 488bd83eafaabf26a7e392eaf8b240b1319e07c3; OpenCE already cleared
the state first, kept the overlays off and checked two of the crosshair
states, so only the rest is taken, and no CHANGELOG.md)
Only the 64-bit builds showed "In Menus" with the game's picture while not
hosting; the 32-bit ones cleared the activity, so Discord showed nothing.
(cherry picked from commit e69b5c9f48fb71282ef18efba8ae5c949ff55a7a)
A second game on one computer listens on another port and hears none of the
host's broadcast advertisements, and the host answered searches no more
than once each advertisement interval. Such a searcher is now answered
directly every time; the broadcast keeps its interval.
(cherry picked from commit b49c83d1c871a23f76825bdcdc52a228167f1c37)
The unreliable queue kept only a datagram's source address, so every
reader saw port 0. The host's answers to where a client's datagrams come
from (a second game on its computer, a direct answer to a game search) went
to port 0. The queue now keeps the port after the address.
(cherry picked from commit 781b98e9333c2cb85a85360267b24cc6656100c1)
Both games bound the client port (SO_REUSEADDR lets Windows share it and
hands each datagram to one of them), the host matched a distributed
datagram to the first machine at its address (its own), and sent every
client's per-tick datagrams to the fixed client port. The second game got
only the reliable messages: no units, no spawn, no co-op cutscene camera,
its camera left at the origin (the purple screen), and in PvP it waited
for space to clear.
A client now listens on any free port when another game has the client
port, the host takes a distributed datagram from the machine at its address
that isn't its own before its own, and answers each machine where its
datagrams come from. A game search from another port is answered there too.
(cherry picked from commit 0f4e71eba0787b8d3cd28c436cdb44ec6d79abda)
Hosting or joining with the intro movie still playing halted in
texture_cache_steal_memory (xbox_texture_cache.c #320): the movie holds
the texture cache's spare memory, which starting the map takes too. The
test now skips the movie, as a button press does, and waits for the menu
to settle again.
(cherry picked from commit 2d4a7deff6fda9bf3e8b9926a9353dd625be70b1)
(cherry picked from commit 47671d6b6ce24b693eb640c8398e78ef3e63a877)
A machine that asked to join as a game ended, before the server switched
to the pregame, was left waiting behind the one the server holds: the
server refused every later join with "network_game_add_player() failed"
and sat in the lobby for good. A host that starts its next game by itself
(network_test.c's host, which the dedicated servers run) never recovered.
Those joins are now dropped when the next game starts. Only the host's own
bookkeeping changes; nothing on the wire does.
From JoshRob297's halo-ce-touch (18a6fc58), reworked for our tree, which
has no network_game_server_port_set_minimum_players.
Co-authored-by: JoshRob297 <99092299+JoshRob297@users.noreply.github.com>
(cherry picked from commit 4582817b2bb4241b53bfa4c523f02aa267595feb)
(cherry picked from commit be7362f2d0b9983e3e12a25f5c76871be5e9132f)
A game with no brokers.txt beside its config.toml (one run from somewhere
the builds did not put it) had no brokers, so no invites and no listings.
Without the file, the game now uses the same list (a test keeps the two
alike); an empty file still means no brokers.
(cherry picked from commit e728f63b590affb701dfc0ad3bde803bedd3b1c1, without its dedicated
server, macOS and 64-bit build parts, which OpenCE does not have)
The picked log_address change declared the function inside
transport_address.c; the port's functions the game's sources call are
declared in halo_linux_source_fixups.h. Map sharing, which OpenCE added
since, logged a closed connection's address whole: it now goes through
log_address too. debug.log_addresses is in the settings table.
Players post debug.txt in public, and a host's log names everyone who
joined it. Every address the game logs now goes through one helper,
log_address: a public one is written as a tag (addr#3f2a9c), the same for
the same address all run long, so lines still match up, with its port as
it is. Local network, loopback and internet play's stand-in addresses stay
whole. debug.log_addresses in config.toml writes them all whole, for
debugging one's own network.
Display and logging only: nothing sent or compared changes.
(cherry picked from commit c76df1d00a88072d50af64cde39fb957fa5b842c, without its
64-bit-only xnet.c line)
(cherry picked from commit 8b64b14a13ddaa573b09bcf2006a8450af8d9a04, without that fork's 64-bit Windows
job; the four hashes checked against today's downloads)
The Android build's musl download and the Windows build's SDL3 package
are refused unless their SHA-256 matches the pinned one, and the Android
build's SDL3 clone unless its tag is the pinned commit.
(cherry picked from commit 11d25e2b1b7570ff1ae5da1537f886be66d5924e, without
its macOS build script, which OpenCE does not have; the hashes and the
commit checked against musl 1.2.5, SDL3-devel-3.4.16-VC.zip and
release-3.4.16 as downloaded today)
audio.buffer_frames sets the audio device's buffer in sample frames on the
desktop builds: 512 by default, as before (11 ms at 48 kHz), and up to
8192 for a machine whose sound cuts out when the mixer is late (2048 is
43 ms). Android keeps its 1024 and the web build SDL's own.
HALO_AUDIO_BUFFER_FRAMES overrides it.
(cherry picked from the audio part of commit
580fd78feeffba4e1ada8510f617a0103a49ce5c; OpenCE's windows already draw at
their size in pixels, and its macOS default and build changes do not
apply)
48 of the high-res HUD's textures have bitmaps Halo Custom Edition lays out
alike (the same sprites, the same size or twice it), compared against its
bitmaps.map (port/assets/hud/custom_edition.json). In a Custom Edition map,
such a bitmap that is Halo PC's own, read from bitmaps.map, is drawn with
the texture without the pixels' check, as the port's HUD looks; a map's own
HUD art, and the 21 sheets laid out otherwise (the meters, the reticles),
are drawn as they are.
(cherry picked from commit 503e78331a6885571af662037a79e7a59f5c4937, merged
into OpenCE's high-res HUD, which already had the loader's stock tag check
and a larger texture table)
Some Custom Edition maps' tools left shorts big-endian, as tag files store
them: Hornet's Nest's assault rifle muzzle flash light holds function
types of 256 where the stock light holds 1 ("zero"). The tag checker
reset such a value to 0 ("one"), so the light's functions read as one
where the map meant zero. A two-byte enum of a Custom Edition map whose
value is past its field's values now takes the value with its bytes
swapped when that is one of them, else 0 or none as before.
Ported from commit cc70ed67f86bc20a8e172ddf000eda0b1ca2e3e1, which made the
same repair at run time in periodic_functions.c; OpenCE's checker corrects
the value as the map loads, before the game reads it.
The per-second line averaged every count over its 60 frames, which hides
the frame that stalls. It also tells the textures uploaded in those
frames (how many, and their size: a texture cache filling up uploads
them again as the view turns), and the most draws and the longest time
of one frame.
(cherry picked from commit 68e58d36cb5321e95f87d9b30581458b1df0aaad)
Starting a system link game on Blood Gulch in the Windows build stopped
on two colour range asserts: rgb_colors_interpolate's result (a
structure decal's random tint) and a transparent shader stage's
animated constant colour. Both blend two valid colours by a factor in
[0, 1], and the native builds' x87 code can carry such values (and
periodic_function_evaluate's 255*(1.0f/255.0f)) at more than single
precision, a hair past 1. The blends are pinned to [0, 1] before the
asserts, and periodic functions return values in [0, 1].
(cherry picked from commit 5c1e49f189e09212d6bc9568649b1cdb7b1e005e, with
the changes marked port: in place of that fork's HALO_LINUX, which OpenCE
never defines, and without its README table)
The picked keep-alive fix moves the Xbox game's begin game, graceful exit
and pregame keep-alive field lists under their own messages and makes the
keep-alive a long, its size on the wire. No byte on the wire moves but the
keep-alive's last two, which were the sender's stack and are zero now
(tools/test_network_messages.py compares every message's bytes with the
old table's), but the change is one to what the machines send each other,
and network_format.txt records the table: version 31, recorded, and said
in NETCODE.md.
Several of the fixes taken from the forks changed source/ without the
port: comment that marks a change from the original, and three removed
the notes on the original's bugs without saying they were fixed. Each
change now says so.
The campaign's level list and the multiplayer map list moved on whenever
their first or last row had the focus, so a mouse resting on either end
scrolled the list every frame. They now move on only when the d-pad put
the focus there (list_scrolls_at_end), as every other list in the menus
does. The host screen's listing spinner also checks that its option
exists before reading whether it shows.
(cherry picked from part of commit c75850e945093518881d1ffa184dd145967422e1,
a larger tidy of that fork's menus; only these two fixes apply to OpenCE)
Some European discs (the Spanish/Italian one, for example) have no maps
folder at the root, only one per language (maps_es, maps_it, ...), so
setting up from such a disc image stopped with "the disc image has no maps
folder".
When there is no maps folder, the extractor now takes the language folder
for the system's language (the desktop's LC_ALL, LC_MESSAGES or LANG; the
Android device's locale; the browser's language), else maps_en, else the
first one, and copies it to maps as before. debug.txt says which one it
took.
(cherry picked from commit 6cb6dfa7332934f6d86c7b4feefb645e76f977bb, made
again on OpenCE's disc readers, and in the web build's xiso-worker.js with
a test)
The portable build's own SDL was configured without XInput 2.1's scroll
classes: SDL's check links a test program against the system root's
libX11, whose libxcb the system root has not got, so the check failed.
Built that way, SDL still drops the wheel's button events as emulated by
XInput 2 but reads no scroll valuators, so under X11 the wheel sent
nothing. CMake's checks now link with --allow-shlib-undefined, and
tools/linux_sysroot.py requires SDL_VIDEO_DRIVER_X11_XINPUT2_SUPPORTS_SCROLLINFO.
(cherry picked from commit 311f02bb50d4a0d2ffaf372a474ca0e8e98168c6, without
its 64-bit system root, which OpenCE does not have)
A binding names a key by where it sits (its scancode, named as on a US
keyboard), so WASD stays under the fingers on any layout, as Halo PC's
DirectInput keys did. The menus showed those US names, so on a German
keyboard the zoom key "Z" (the key right of the left Shift, labeled Y)
showed as Z, and pressing the key labeled Z to rebind it showed Y. The
menus now show each key by the character the keyboard's layout gives it
(SDL_GetKeyFromScancode); keys with no character of their own keep their
names. config.toml keeps the US names, so a config means the same keys on
any keyboard.
(cherry picked from commit 86bf862d0ebf47f252fb41a0a77fdd52f96615f1, without
its dedicated server stub, and with the settings table in
port/linux/README.md)
- An unknown setting whose name belongs to one setting says which section
it goes under (menus at the top of the file: under [display]).
- A file that cannot be parsed says every setting is at its default until
it is fixed, and that a word value needs quotes.
- port/linux/README.md says so.
(cherry picked from commit d474fac1feb9b7faa55229d9e48c91aadadfc5d2, without
its macOS check, and with its README note in port/linux/README.md's
settings)
test_xdk_headers_compile_for_the_game and test_p2p_signatures_and_listings
compile for i686 Linux, and the p2p one runs what it builds. On Windows they
failed whenever clang was on the PATH: no Linux system headers, and shlex
stripped the backslashes from ninja's command. CI runs them on Linux.
(cherry picked from commit 6aa077faf95dd270fb2cea1b4caa65ef67e96dca)
In a checkout whose path had a space, the portable Linux build failed in
SDL's configuration with errors that did not say why: CMake was given the
toolchain file's path unquoted, and SDL's build splits the system root's
include folders from pkg-config at their spaces. The paths are now quoted,
and the SDL step stops at once with a message saying to move the checkout;
port/linux/README.md says so too.
(cherry picked from commit 21e0aa6531e92547f0b8dc53120674da32207a1c)
debug.txt's gamepad line gives the pad's kind (ps5, xboxone, ...) and the device SDL reads it through: on Linux, a hidraw node when SDL's own driver has the pad (a DualSense's lights and rumble), an event node when the kernel's does. A player whose pad misbehaves can say which.
(cherry picked from commit ff238bfaae21b52a819de093e5141a028373e1f3)
"event handler function failed" said nothing of which: the gametype
editor logged it fifteen times. It names the function's number and the
widget's tag, taken before the function runs (it may delete the widget).
(cherry picked from commit 5f4494f42b1919dc1e38c6f8e168b646768e74f1)
"attempt to play a sound that was not a mono 22k compressed sound..."
now says which sound tag, and its compression, encoding and sample
rate, so a report of it can be traced to the map that has it.
(cherry picked from commit e859daf04476ad2b0608f130e9b63ac0cf7140a8)
(cherry picked from commit 9e9104b26907b748ace50e004fd5e9f2c0167b11)
The campaign's scripts print their progress and music cues ("mission
script is running", "levels\b30\music\b30_01", the dialog lines), which
the Xbox never showed. They are the game's chatter now, on screen when
config.toml's game.console_log is "all", like the rest of it; print and
inspect typed at the console still answer whatever the setting is.
(cherry picked from commit 114453cc1a84be4fc5622053d2afb57c1ae58e7f)
mark_written cleared a page's protected mark before making the page
writable. A second thread whose write to that page faulted in between
found the mark clear and took the fault for a crash: Windows passed it on
to the crash reporter, and Linux and Android reported a crash and put back
the previous handler, so the next watched write ended the game. The I/O
thread's platform_contiguous_write and the main thread's writes meet this
way on a shared page.
A page made writable by the watch is now marked so (2), and a fault on it
is retried; a page the watch does not know (0: freed, or not watched) is
still a crash.
(cherry picked from commit 57baaec22eef2332ee69b1b4a79686e3bb8f0e3d, made
again on OpenCE's watches without the fork's 64-bit and macOS code)
A build without a number, or one with update.auto off, now logs that it
does not look for a new version and why, rather than nothing.
(cherry picked from commit 05ee6d14c82009d0dd368adff01b96d63356f98b, with
OpenCE's conditions: a build number and update.auto)
The update question is SDL's message box, a system dialog, which crashes
the game under gamescope (the Steam Deck's Game Mode). There the game does
not ask: it writes the new version in the log, and the player updates from
the desktop or with debug.update_answer. gamescope is known by
GAMESCOPE_WAYLAND_DISPLAY, XDG_CURRENT_DESKTOP, or Steam's SteamDeck and
SteamGamepadUI together, which Wine passes on to the Windows build.
(cherry picked from commit 1339ca420f710882dacdf1151a63f96db6211c29)
The self-updater now refuses a download larger than it expects (the
release check at 1 MB, the zip at 256 MB, in place of one 512 MB limit for
both), stops unpacking a stored entry once it passes its stated size (an
inflated one already stopped), accepts only plain release file names, and
unpacks the zip from the one copy it read.
(cherry picked from commit 2045acc0b6d2fe2306d10c9cc66b4342ae8ef90e, without
its check of a release's version name: OpenCE reads a build number)
Players post their logs, and an invite lets anyone join while its host
runs. The hosting line and a new invite for a listed game now log the
link's first digits only; the whole link stays on the clipboard and in the
menus.
(cherry picked from commit e8c35a43bccc6047f1bf7494614876a0a6eb5d84, without
its macOS, startup-stash and dedicated server parts, which OpenCE does not
have)
It goes into a file's path on macOS and into the handshake; an id that is
not a number is ignored, as an empty one is.
(cherry picked from commit 56b38e49b7af6be0054f5c13eb446a4f36baaacc)
A STUN server whose name did not resolve was given all its attempts with
no address, and the only way back (attempts reset after a refresh's time)
needed an address. So a lookup that failed once, as when the game hosts or
joins before the network is up after a wake, left that server unused
until a restart: no public address was offered, and joins across NATs
failed unless UPnP forwarded a port. It is now looked up again every 25
seconds until found.
(cherry picked from commit 775aaf9adb92af58336b41010dc82cf397e67ade)
broker_sync_topics went on after a subscribe or unsubscribe whose send
closed the broker (a send error, or its output full), and recorded every
wanted topic as had. broker_close had just forgotten them, and nothing
on reconnecting does, so the next sync found nothing to do: the broker
stayed without the host, join, slot or query topics until they changed
again. It now stops when the broker is no longer ready.
(cherry picked from commit e4bc27dcf68ea0de841bb1c097465cd67009cfbc)
broker_publish_listing answered 1 even when the publish's send closed
the broker, so its caller recorded the listing's version as published.
Closing reset that version and the broker's will cleared the slot, but
the caller then set it back, and the listing was missing from that broker
until the next republish (up to 30 seconds) or a tombstone was skipped.
It now answers whether the broker is still ready.
(cherry picked from commit dfe3514d75a5ca79e0403796da45ad6dbf55dea9)
A browser took a retained listing only if its time was near its own, but a
live one at any age: anyone watching the brokers could publish an old, signed
listing of a host long gone again and fill browsers with dead games. A live
listing now has to be within an hour of this machine's clock, wide enough for
a host whose clock is off.
(cherry picked from commit fbdbd07300230e42ca92474b141eb8b78fae8683)
A joiner's offered addresses were all tried. Ones in 0.0.0.0/8, multicast,
reserved or broadcast, or with port 0, are now dropped before the host sends
anything to them. Loopback and LAN addresses stay, for two copies on one
machine or network.
(cherry picked from commit 69bd0d6a5669534a8b7fb3fa5d639af933c42381)
The picked change named the fork's file_contains and file_length, which
OpenCE's validator does not have: tag_validate_file_contains checks every
file of a Custom Edition map's offsets. The pixel data is moved to 0 only
once, so a map of no bytes cannot check its bitmaps forever.
A bitmap whose pixels its group's data offset put outside the map was given
the start of the file as a negative offset, which the bitmap's own check
then corrected again: the tags as corrected were not clean when checked
again. It is now given the group's data, moved to the start of the file
when the data starts at its end. Found by fuzzing.
(cherry picked from commit 6b38c3df8daf16ab134f81287404f94ba5b07f06)
The map check cut a gbxmodel's shaders to 32, the tools' maximum, and set
the parts that used the rest to shader 0, so they drew with the wrong
shader. The game reads a model's shaders only through their block, by its
parts' shader indices, which the check holds to the block; Halo PC's
engine took more (h3_foundry's spartan has 56). The block's maximum is
now the tools' (TAG_SCHEMA_TOOL_BLOCK), as for the other blocks Custom
Edition's tools went past.
Ported from commit accc904940b00dcdbea3418c3f34bea5748a9a1e, which made the
same change in that fork's own loader. Reported by Sabriel.
The packet table had begin game, graceful exit and keep-alive each on its
neighbor's field list (the Xbox game's table order is not their type
order), so a keep-alive's short was encoded and decoded as a long: the host
sent 2 bytes of stack past it, and a client wrote 2 bytes from the network
past it on its stack. The field lists now sit under the messages whose wire
they describe, and the keep-alive is a long. Not a byte on the wire moves:
begin game still carries a short (16 bits of its long), exit and keep-alive
a long, so OpenCE and earlier builds play as before; the keep-alive's 2
bytes that came off the stack are zero now. tools/test_network_messages.py
encodes every message under AddressSanitizer and compares the bytes with
upstream's table.
(cherry picked from commit ee32bdad42615402053291344e3c89f8bcc04161)
A checkpoint (game_save) waits for a safe moment and gives up after
240 ticks; it checks every 10. Those counts were advanced once a frame,
which on the Xbox was once a tick. At 60 frames a second or more a
checkpoint gave up in a few seconds or less, often before the fight it
waited for was over, so few checkpoints came: on Halo (a30) the player
could get only the first. The counts now advance once a game tick, as
the Xbox's did: 8 seconds at any frame rate.
tools/test_checkpoint_timing.py checks it at 30 to 990 frames a second.
(cherry picked from commit abc42998232b6261220a764e6de44641067ff2f5)
A screen's button bar (SELECT and BACK under a list) steps sideways, so
once the wheel scrolled past a list's last row onto the bar it went back
and forth between the two buttons until a row was chosen again with the
mouse. A touch drag already left the bar with up and down, as the d-pad
does; the wheel now does the same.
(cherry picked from commit 2ddad079fa5435a70a383d757de16b960c421505)
A tap on the touchscreen focuses a row and activates it in the same frame,
before the list's update has read which row has the focus, so New Game
started the level shown before (Pillar of Autumn on a new profile, as
players on Android saw), and the map and gametype lists took the entry
shown before too. Each list's choice is read again from its focus when it
is activated. A mouse hovers first, so the desktop builds were not seen to
do this.
(cherry picked from commit fba908137d3a3dfe8b61a5a84678f7c2bb4435b8)
Only the host runs the AI. An actor picks its unit's grenade type as it
gives it grenades, so on a client an AI unit kept no grenade type at all.
When the host's control told it to throw, unit_throw_grenade_begin found
no grenades of type NONE and the throw never began: a client saw no
grenade, then took the blast that the host's damage brought.
A client now gives an AI unit the type of the grenades it carries, as
the host's inventory says, when it has none or none of its type left,
and never in the middle of a throw.
(cherry picked from commit 92aaf6388fdfe37b640cacd162fd101dbae0ebe2)
debug_objects_unit_mouth_apeture placed its label at an uninitialized
marker when the unit had no head.
(cherry picked from commit a56597a9666a354a0a8f9e7f2e017cc418d4922a)
A cutscene that leaves the players their controls shows the black bars
without starting a cinematic; clients took the host's letterbox only while
a cinematic ran, so they had none.
(cherry picked from commit 43f646b586626fa8a154d87118abefe6d31d2ced)
A machine still downloading the map when the game started has its players held out of it until the download finishes (OpenCE's). One whose download did not finish before the game ended came back to the pregame without its players, as the reset dropped the held ones; they are put back in the game's list instead, as the other players are kept.
(cherry picked from commit 6aa90bfb59557b8c98e2c8543aa4c42d9e3918c1)
On a network client the overshield's drain arrives with the host's updates
after the HUD has taken its last shield value for the tick, so every frame
the shield looked a step lower than the HUD remembered and the shield
damage sound played for the whole overshield. The Xbox plays nothing.
The meter's flash already told the drain from a hit (a step of 0.05 or
less from past full); the sound now uses the same test, so a drain is
silent on host and client while a real hit on an overshield still flashes
and sounds.
(cherry picked from commit fd9c918313076ecc686209a0f4c75a6ef23340c4)
The kill announcement picked Running Riot whenever the killer's kills in a
row were a multiple of five, and zero is one. On the Xbox the killer had
just been credited with the kill, so it was never zero; a network client's
copy of the statistics can have the kill credited to no one or to someone
else until the host's statistics arrive, and announced Running Riot after
one or two kills.
A killer with no kills in a row now gets the plain kill message.
(cherry picked from commit 1b6cf950532f6d9590690c86c253def5395a132f)
build_game_variant_team_oddball left its variant uninitialized while
every other builder clears it, so the fields it does not set (the name
and the flags beyond those it sets among them) were whatever was on the
stack.
(cherry picked from commit 4c3e7664aab234e1b56a84a3b3dd3c8d1bebf58a)
A failed CreateVertexBuffer or CreateIndexBuffer may leave its output
unwritten, which was then tested uninitialized.
(cherry picked from commit 28f0a433093e4b7d6082123b45e19fd37beee262)
A ground contrail (or another type the renderer lacks) left its
triangle and vertex buffers locked and allocated, and the rasterizer's
lock operation set, for every frame it was drawn.
(cherry picked from commit b335386569e840c601eaf238d7585c73fa60516c)
(cherry picked from commit 56f5d99b1d6e81c30c4021c2437b2bd71475c83d)
A weapon's charging loop still on a channel as a network game ended
("still counted as playing ... let go", sound_cache_close) was finished
by the next map's sound_render against the next map's sound cache, which
halted the game as the next network game loaded (data.c: xbox sound index
is unused or changed). sound_dispose_from_old_map stops every channel
still holding a permutation, so its count is given back while the map's
sounds are there.
(cherry picked from commit 8e5b3f9f23beb9f015a0caf487caa82ada16c73e)
OpenCE's leak fix releases the surfaces the capture took; the device's
depth buffer may be none (D3DDevice_GetDepthStencilSurface's NOTFOUND),
and D3DResource_Release reads the resource it is given.
(cherry picked from commit ff654a774061c74d30f31ce986d7ebdefcafad74)
The virtual keyboard kept its keyboard tag from the main menu's map after
that map was unloaded, so Change Name in Settings from a game's pause
menu opened it on a stale tag and the game stopped. The keyboard is now
let go with the old map's interface, as its tags are; at the main menu it
is set up again, and in a game Change Name does nothing.
(cherry picked from commit 4c05c5b3237330669f55c2bdb123b7cde4e4e056)
When every player dies in network co-op, the host brings them back at
the last checkpoint without reverting. A revert restores the players'
all-dead flag from the saved game; this path left it set. With it set,
players_update_after_game neither respawns a player who dies nor ends
the game, so the next death, of one player or of all, left everyone
waiting for a respawn that never came. The flag is now cleared once a
player is back.
(cherry picked from commit 2e7c88b538b8aada54d0ce877740fb1be091e17d)
A client on the same elevator discarded a vertical error of any size; it now
keeps its floor's height only within 4 world units, the platform's travel
over a round trip, so a teleport or fall in height still corrects. Network
version 30: every machine now carries an elevator's riders by the same
tick's movement, simulation they must agree on. distributed_riding_elevator
(was distributed_riding_moving_elevator) is named for elevators at rest too
and looks the elevator up as a machine, as bipeds.c finds it; the source/
changes are marked port:, the original's one-tick-late carry noted as fixed,
and device_machines.c's new functions moved to its public code.
Checked: ninja linux (debug) and ninja android build; pytest tools/harness
passes (492, with a new case and negative control for the bound);
network_format.txt recorded and its test passes.
The Xbox's postgame map select wrappers already carry
main_menu_if_no_history, with which the stock event code calls
main_goto_main_menu() when Back closes the last screen. The PC menus'
connected_map_select_wrapper, which menu_tags.c opens in their place,
was given the same flag by tools/port_settings.py in 4301ca88d, so the
hard-coded names and the network_game_abort() call in
widget_instance_go_back_to_previous were not needed, and they changed
stock split-screen behaviour (a split-screen game is a local server).
widget_instance_go_back_to_previous is as it was before 7d128e1bd.
Checked: the harness and touch menu tests (491 passed), test_web_build
(11 passed), ui_widget.c compiled for Linux and the Android guest.
Without the keyboard focus, SDL_SetWindowRelativeMouseMode only sets
SDL_WINDOW_MOUSE_RELATIVE_MODE, so platform_mouse_capture(TRUE) at window
creation or when a menu closed while alt-tabbed went into the fallback
(hidden cursor, grab, warp) where relative mode works. It now releases the
mouse and clears the flag until SDL_EVENT_WINDOW_FOCUS_GAINED, whose call
then tests relative mode for real (SDL_SetWindowRelativeMouseMode returns
at once only when the flag is already set). The fallback needs no clearing
of its own: SDL clears the flag when relative mode fails.
The click that took the mouse back is removed: it undid F12's release on
any click, and the click also reached the game. The fallback does not need
it. The Linux README lists lib32-libxi.
Checked against SDL 3.4.16's SDL_video.c, SDL_mouse.c and SDL_keyboard.c;
sdl_platform.c compiles (-fsyntax-only) for Linux (debug and release),
Windows (clang, i686-pc-windows-msvc) and the web (emcc).
The first start now uses a disc image found next to the executable
without a file picker, and where no file picker can be shown it says to
put the image there and quits, instead of asking again without end. The
READMEs still described only the file picker. Checked against
platform_offer_game_data and data_find_image in sdl_platform.c: the
folder of the executable is searched, .iso and .xiso match in any case,
the question is still asked, and Windows runs the same code.
NETCODE.md and the comment in distributed_client_delete gave a release
host's unchecked assertions as the reason a host deletes a flag or ball;
that cause has not been found. They now say it is not known and that the
client follows the host's word whatever the reason. The notes on the
skipped assertions also say they cover every deletion of
distributed_client_delete, the client's own objects the host has not told
it of included, not only the host's word. Comments and documentation only.
Only apply the configured window mode when it changes so saving sensitivity or other settings preserves the F11 choice.
Checked with eight local regression assertions against the latest testing baseline. The original behavior and a negative control failed as expected. Live gameplay and a full build of this baseline were not checked.
Create and validate typed query objects, retain each batch until its results
are consumed, and publish completed counts in submission order with the
scale captured for each test. Read available queries directly on the CPU
on AMD and other vendors, avoiding the mapped delivery path that lost AMD
results. On NVIDIA, preserve throughput with disjoint batch storage and
zero-timeout polling of a fence placed after all query-buffer copies.
Keep previous counts while work is pending and quarantine failed batches.
Consolidate the renderer changes developed in GulchCE through
b0c5d017 and 95748a74ce13a77444a7bb3bfe236bf97cfbb21d, as validated at
e5347765275bf324513eca4d044b15b3f6fc12b8, onto OpenCE testing 0110c160.
Only d3d8_gl.c and gl.h change. No MCC or fork features, GLES behavior,
network protocol, build files, tests, or documentation are included.
Validation: all 18 external delayed-GPU and negative-control regression
cases pass against this upstream tree; Windows portable release builds.
The implementation matches the AMD-confirmed and NVIDIA-benchmarked
renderer after excluding the three MCC additions in the fork's file.
Android/web targets are unavailable on this Windows host; branch CI
provides those builds. Android GPU runtime confirmation remains pending.
The bots make room for a person who joins, and the server browser's
listing counted their slots as free, but the LAN advertisement and the
join's check still asked for a free slot: with 15 bots and the host a
lobby of 16 was advertised closed, and a Quest on the LAN was refused
("attempted to join a closed game"). Both now count a bot's slot in a
lobby as a person's, as the listing does (network_game_server_room_for_person).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A press pauses as before, now as it is let go; held 0.4 s, the scores
show as long as it is, as the Xbox's Back. In the menus it is Start at
once. The Quest's controls page says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat's lines go at the top left on Android, where the touch controls
keep the bottom left free; VR has none, so it lays them out as the desktop
does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The touch controls stay off in VR alongside the chat's on-screen
keyboard (xinput_sdl.c); the join codes and the online list beside the
server browser's listed hosts (p2p.c).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mixer outputs stereo or 5.1 (audio.channels: "auto", "stereo" or
"5.1"; HALO_AUDIO_CHANNELS; Linux and Windows). On 5.1 the speaker config
reports AC-3, as an Xbox set to Dolby Digital, so the game sends its 2D
voices to the center and back mix bins itself; the speaker mix bins 0-5
are SDL's 5.1 channel order. 3D voices pan with equal power between
adjacent speakers at the ITU-R BS.775 angles, spread out when close or
above, at the stereo pan's power; stereo voices positioned in the world
pan their middle the same way and keep their width on the front pair.
The voices' resampling, low passes and reverb send are the stereo mix's;
the reverb plays from the front and back pairs, the voice chat from the
front pair, and the limiter takes all six channels. Stereo output mixes
as before. Android and the web stay stereo.
"auto" is the Xbox's own behaviour: the game asked the console's speaker
setting and mixed for 5.1 when Dolby Digital was on.
Checked on Windows with a 6-channel device: "sound: 5.1 surround" is
logged and the device opens; with "stereo", and with no 6-channel
device, the output is the stereo mix as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Android the touch controls' overlay keeps every finger while it shows
(the look, outside its controls), so a tap on the keyboard never reached
the game. While the keyboard is up the overlay now hides, as it does for a
menu or a cinematic, and lets the fingers through: a tap on a key types it,
and a tap elsewhere closes the keyboard, as B and the other touch controls
are hidden then.
A tap's place is a fraction of the screen, which the viewport fills, so the
keys' place is now kept as a fraction of the viewport, not of the HUD's
window inside it, which is smaller on Android.
Checked on the x86 Android emulator (with #396's fix for its ARM
translation, not part of this change): the touch D-pad's Up, held as a
finger holds it, opened the keyboard at the bottom of the screen and hid
the touch controls; taps on h and i typed "hi", a tap on SEND said it
("chat: Player: hi"), the keyboard closed and the controls came back; a
tap away from the keys closed it too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A controller's D-pad Up opens an on-screen keyboard for a line to everyone
(the D-pad only repeats the left stick's movement in the game). The stick,
or D-pad Left and Right, moves between its keys, A types one, X deletes, Y
switches capitals, D-pad Up and Down turn the line to everyone, the team or
the vehicle, Start sends it and B closes the keyboard; its last row has
Aa, #+= (the symbols' page), space, DEL and SEND. It is drawn at the bottom
right, clear of the lines above the motion sensor; on Android at the bottom
in the middle, between the touch controls, as the lines are at the top
left there.
The touch controls' D-pad Up opens it too, and a tap on a key types it
(touch_input.c hands the taps outside the menus to it). While it is up the
game gets none of the first controller (xinput_sdl.c), nor the touch
controls' stick and look.
The automated tests press the controller's buttons through
test_input_press: debug.network_test_chat now has each client type
"gg ez" on the keyboard and send it to the team. A drawn Linux client typed
it and the host received "chat (team): Player 2: gg ez"; the frames show
the keyboard over the game, the key picked lit, the typed line growing.
The harness gains three cases and six negative controls for the keys'
characters, moving between them and a tap's key (64 pass); CI's Linux test
step passes (493, 6 skipped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A player's scoreboard menu (right-click, then the player) had Mute voice,
this machine's own choice. It is now Mute voice and chat: this machine
plays none of their voice and shows none of their lines, and tells no one.
It is offered whether or not voice chat is on.
The host's menu adds Mute for everyone / Unmute for everyone. It is the
chat's /mute for 20 minutes (every machine told "Milenko muted for 20
minutes"), and it now silences the player's voice too: the host relays
none of a muted player's machine's voice (voice_host_relay), as it relays
none of their lines. /mute from the chat mutes their voice the same way.
debug.network_test_chat now also has the host mute the last player for
everyone (network_chat_host_mute, as its scoreboard does) and unmute them,
and the last player's machine mute the host's for itself. On three Linux
machines over system link, with debug.voice_test's tone as open mic:
- the host's mute: no machine showed the player's line, they alone were
told the minutes left, and the host and the other client stopped hearing
their voice until the unmute (one second heard at the edge);
- the client's own mute: only that machine stopped showing the host's lines
and hearing the host's voice.
CI's Linux test step passes (484, 6 skipped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host's TEXT CHAT row (Server Setup > Teamplay Options; in co-op, Voice
and Voting; network.text_chat) is ON (the default: lines with links are
refused), WITH LINKS or OFF: one row, as Teamplay Options has room for one
more. A game listed in the server browser (p2p_hosting_listed: a public
internet game) never has the text chat; a private game, joined by its
invite, and a LAN game have it as chosen.
The host sends its settings to every client in a new message,
_distributed_message_chat_config (89, one byte of flags), as they change
and every 3 seconds. A client without them, as from a host without the
chat, has the chat off. Pressing T where it is off says "Text chat is off
in this game". A line the settings refuse is refused on the sender's
machine and again by the host, whatever the client knew, which tells the
sender why: a changed build gets no further. Older builds drop the new
kind, so the network version stays 29.
Links are found by chat_text_has_link: a scheme, "www.", four numbers and
three dots, or a name and a common top-level domain ("discord.gg/x",
"example.com"), with the dots also written "(dot)", "[dot]", "(.)" or "[.]";
"1.5", "e.g.", "U.S.A" and "wait.comrades" are not links.
TEXT CHAT on Settings > Audio > Voice and Text Chat (display.text_chat)
HIDEs the chat for purists and streamers: no lines, and T and Y open
nothing. /mute's minutes are 20 when none are given.
debug.network_test_chat now also has each machine say a line with a link
(a client's sent past its own check, network_chat_say_unchecked, for the
host to refuse), then the host turns the chat off and on again around one
more line each. On three Linux machines over system link: every machine
showed "Links aren't allowed in this game" and "Text chat is off in this
game" once, the clients' from the host, and no refused line reached anyone.
The settings screens were drawn and the TEXT CHAT row's help follows its
choice. The harness gains four cases and six negative controls (55 pass);
CI's Linux test step passes (484, 6 skipped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No change in behaviour:
- chat_message_new builds a line's message (header and line), which
chat_host_send, chat_host_notice and chat_send each did by hand.
- chat_send_to_clients sends a message to every client machine.
- chat_line_text turns the typed line into wide characters too, in place
of the drawing's own loop.
- chat_player_name_length is the one count of a name's characters, for the
line's name and its ASCII copy.
- CHAT_DRAWN_SIZE and the comments about the chat's old filter are gone;
testing's streamer mode keeps a word's length, so a line's buffers are
CHAT_TEXT_SIZE.
The name match keeps its own lower-casing (chat_lower): the console's
network_game_server_name_begins_with is static in network_server_manager.c,
and the harness takes chat_name_match without the game.
Checked: the Linux debug build, the chat harness (45), and the three-machine
system-link chat test (lines, channels, /mute, /unmute) as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testing's streamer mode (90dd65ca, text_filter.c) hides offensive words in
every string drawn, through draw_string.c, so it already covers the chat's
lines and players' names. The chat's own filter goes: chat_filter.c and
its harness tests, game.streamer_mode (which also shared
HALO_STREAMER_MODE with streamer.enabled) and the STREAMER MODE row on the
VOICE AND TEXT CHAT page. Settings > Network Setup > Streamer Mode is the
one toggle. The README's Streamer mode section now names the chat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts 4ac609f0, the revert of #395, so the chat is back as it was
merged: T, Y and Tab, team and vehicle lines, the host's /mute, the
VOICE AND TEXT CHAT settings page and the Android placement.
The one conflict is source/text/draw_string.c, where #395's streamer mode
and testing's (90dd65ca, text_filter.c) both hook the drawn text; testing's
hook is kept. #395's own filter (chat_filter.c, game.streamer_mode) is left
in place, unhooked, for the next commit to take out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the x86 Android emulator every automated network test (debug.network_test
"join", "browse" or "host:<map>") stopped the app with SIGILL a few seconds in,
in hud_calculate_point. That was not an assertion: logcat's ndk_translation
line names the instruction, fcvtzs s0, s0, which clang makes of
(short)fast_ftol(point.x) (convert in the SIMD register, store its low half).
The emulator's ARM translation has none of the scalar conversions from
floating point to an integer in a SIMD register (fcvt{a,m,n,p,z}{s,u} s/d,
with or without fraction bits); the general-register and vector forms work.
The guest image had 141 of them, the HUD's the first to run once a game is
drawn. Devices run them, so only the emulator was affected.
tools/android_asm_convert.py now writes each as fmov to the destination, then
the vector form on it: the copy clears the other lanes, which convert 0.0 to
0, so the register and FPSR end as the scalar form leaves them (the game
reads FPSR through _statusfp).
Checked: on the emulator, each replacement against the general-register
form, 315 cases (every rounding mode, signed and unsigned, single and
double, saturating, NaN, infinity, denormal): the same register and FPSR,
except that the translation's own vector fixed-point form leaves out the
invalid flag on overflow (the guest has no fixed-point conversions). The
rebuilt image has none of the scalar forms left. debug.network_test =
"host:bloodgulch" now hosts and plays on the emulator (game started,
player states logged for 45 s, no signal); "join" searches for 150 s
without stopping. tools/test_android_asm_convert.py (new cases, and clang's
output through the pipeline), ci_build android debug and
tools/test_touch_layout.py pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
testing's SOUND PROCESSING page and the chat's VOICE AND TEXT CHAT page
both split rows off Audio Setup in tools/port_settings.py. Both are kept,
as two category rows, sound processing first. The settings screens and
menus.json are regenerated from it, so they match testing's apart from the
chat's page, its row on Audio Setup and Controls Setup's COMMUNICATION
group.
Checked after the merge: the Linux debug build, the network format check,
the CI Linux test step (467 passed, 6 skipped), and the three-machine
system-link chat test, /mute and /unmute included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host types "/mute <player> [minutes]" in the chat (5 minutes when it
gives none, 1440 at most), or "/unmute <player>". The player is found as
the console's kick finds one: the whole name in either case, else the one
player whose name begins with what was typed; none, or more than one, is
said to the host alone. Every machine is told "Milenko muted for 5
minutes" and "Milenko unmuted", as a notice in the chat: a down line with
the new _chat_line_notice_bit, its text alone, drawn in a pale gold
(white in the white style).
The host keeps a mute by the player's name in ASCII, so one who leaves and
joins again under it is still muted, and its minutes by its own clock. It
drops a muted player's lines as they come up and tells that machine alone
"You are muted for N more minutes". The mute ends by itself. A client's
/mute is shown "Only the host can mute players" and never sent, and the
host takes a line that came up as a line, never as a command. The wire
format and the network version (29) are unchanged.
debug.network_test_chat now has the host mute the last player for a
minute, each machine say a line, the host unmute them and each say
another. On three Linux machines over system link the muted player's line
reached no one, they alone got the minutes left, every machine showed the
mute and the unmute, and the clients' own /mute was refused. A drawn
client showed the notices in the chat. The harness gains ten cases and
eleven negative controls for the command's parsing, the name match and
the mute's time (412 pass).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Android the touch controls' Down button and Move stick are at the bottom
left, where the chat lines went (above the motion sensor). There the lines
now start under the weapon's panel at the top left, in the same column, and
go down: the line being typed first, then the newest, the older ones below
it. At most four show at once, the typed one among them: a fifth would
reach the tops of the Up and Fire buttons. The other builds draw them as
before.
Checked on the x86 emulator (host:bloodgulch: the test's three lines, two
typed with the keyboard, a third being typed): the typed one and the three
newest show, clear of the touch controls. Linux debug build and the
harness (391) pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Streamer mode replaced the words of its list only in the chat's lines and
names, but a player named with a slur showed it on the scoreboard, above
their head, in the kills and in the lobby. Every text the game draws or
measures now goes through the filter at draw_string.c's start, both
draw_string and draw_unicode_string, so names are replaced wherever they
show and the text's width matches what is drawn. A text's format codes (a
| and the character after it) are kept, each run between them filtered on
its own, so a code never joins the words beside it ("|t" before "its").
The same strings are drawn every frame, so a cache of 48 keeps it cheap; a
text too long for its entries is drawn as it is rather than cut short.
The chat no longer filters its own lines (the draw does), so the line you
type shows replaced too. "hoe" now shows as "rake": "garden hoe" held the
word it replaced.
debug.network_test_name names a machine's player in an automated test
game, to test this.
Checked: tools/harness/tests/test_chat_filter.py (the format codes, the
cache, every replacement passed through the filter again unchanged, with
negative controls); a host on Xvfb, streamer mode on, named "fuckhead",
with a client named "n1gger": the chat showed "fudgehead" and the
scoreboard "nugget" and "fudgehead", while debug.txt kept the names as
they were; the CI Linux test step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PC version's chat could talk to everyone, the team or the vehicle.
While a line is typed, Tab now turns it to each in turn ("Say:", "Say to
team:", "Say to vehicle:"), as T and Y still start a line to everyone and
to the team. A vehicle's line goes to the players riding in the vehicle
the sender rides in: the host finds each player's outermost parent object
(a unit in a seat is its vehicle's child), so any map's vehicles count,
Custom Edition maps' too, and a sender on foot sees their line alone. The
host now always sends a line back to its sender's machine. The channel is
a second bit of the line's flags, so the wire format is unchanged.
debug.network_test_chat has the host seat the last two players in one
vehicle and every machine say a line to it.
Checked: tools/harness/tests/test_chat_logic.py (who hears a vehicle's
line, the channel through the flags, with negative controls); three
machines on system link, the two clients seated in one Warthog seeing
each other's vehicle lines and the host's, on foot, seeing only its own;
a host on Xvfb turning a line to the vehicle with Tab, and typing a line of
f's without meleeing; the CI Linux test step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
display.chat_style (CHAT TEXT on Audio Setup's VOICE AND TEXT CHAT page)
picks the chat's colours: "default", the player's name in their team's
colour and the text a pale blue, as the PC version drew them, or "white",
all of it white, as Custom Edition. The line typed follows it too.
Checked on Xvfb: a host's lines drawn white, and the page with its new
row; the CI Linux test step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Streamer mode needs a row a player reaches in a game, from the pause
menu's SETTINGS. Network Setup is not offered there and has no room on
desktop, and Audio Setup has none either, so its VOICE CHAT and VOICE
VOLUME rows move to a page of their own, VOICE AND TEXT CHAT, opened from
Audio Setup as Video Setup opens GRAPHICS, with STREAMER MODE under them.
The screens are port_settings.py's settings_files and menus.json lists the
new one, both written as tools/ce_menus.py writes them (it needs the PC
version's tags, so only these were written: the other screens come out
unchanged). Checked on Xvfb: both screens drawn, STREAMER MODE changed with
the keyboard and OK writing streamer_mode = true to config.toml; the CI
Linux test step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A player who streams needs the chat on screen to stay clean whatever the
others type. game.streamer_mode (off by default) shows the slurs, swearing
and sexual words in the chat's names and lines as harmless, funny ones
(fuck: fudge, cunt: chop stick, faggot: flurry), keeping their case and
fitting their endings (fucking: fudging, faggots: flurries). Only what
this machine shows changes: the lines go to every machine as typed, so the
network is unchanged, and debug.txt keeps them so
(port/linux/game/chat_filter.c).
It finds the words in the spellings that dodge a plain list: digits and
signs for letters (n1gg3r, c00n, $hit), l for i, q for g, k for c, v for u,
ph for f and * for a vowel; letters and endings typed more than once; a
word spelled out with separators (n i g g e r); and a few words inside
others (sonofabitch, thundercunt). Each word may stand alone, with endings,
at a word's start or anywhere, and harmless words that hold one are kept
as typed (Scunthorpe, snigger, Niger, assassin, cocktail, rapper, FAQ).
Checked: tools/harness/tests/test_chat_filter.py (8 cases with 11
negative controls); every word of the repository's documents and of an
English dictionary of 370,000 words (dwyl/english-words) run through the
filter, the harmless ones it changed kept, and a community-submitted list
of 2,800 entries used to find gaps (neither list is in the repository: the
community one has no licence); a host on Xvfb with streamer mode on,
lines typed on its keyboard shown replaced while the client logged them
as typed; and the CI Linux test step.
No Settings row yet: Network Setup has no room on desktop and is hidden
from the pause menu's SETTINGS, so where the toggle goes is still to be
chosen; config.toml and HALO_STREAMER_MODE set it meanwhile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The text drawing gives every string a hard shadow, a black copy a unit
down and right; the PC version's chat had a softer one, further out. The
chat lines now draw theirs as translucent black copies one to three units
down and right, which add up to a dark middle with a soft edge, and the
text's own shadow clear. Checked on a host drawn on Xvfb against the
reference screenshot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Network games had voice chat but no way to type to the other players, as
the PC version's chat did. T (controls.chat) types a line to everyone and
Y (controls.team_chat) one to the team; Enter sends it, Escape cancels it.
The lines are drawn above the motion sensor with the HUD's font: the
player's name in their team's colour (blue without teams), then the text
in a pale blue, and fade after ten seconds (port/linux/game/network_chat.c).
A client sends its line to the host (_distributed_message_chat_up), which
takes it only from the client's own stream and one of that machine's
players, five at once and one each 1.5 seconds after, its text cut to 63
characters of printable ASCII, and sends it on to the machines that hear
it (_distributed_message_chat_down): all, or a team's line those with a
player of the team. Older builds drop both kinds as unknown, so the
network version stays 29, as for the vote kick; network_format.txt
records the two kinds.
While a line is typed the keyboard drives no player, as with the console
(xinput_sdl.c), nor voice chat's push to talk or the debug keys. Controls
Setup gets a COMMUNICATION group with PUSH TO TALK, CHAT and TEAM CHAT
(the controls screen's spinner strings changed by hand to what
port_settings.py writes: ce_menus.py needs the PC version's tags).
Checked: tools/harness/tests/test_chat_logic.py (the text's cleaning, the
host's rate, who hears a team's line, the fade, with negative controls);
the CI Linux test step; three machines on system link with
debug.network_test_chat (new), every machine showing the lines to all in
one order and a team's line only on its team's machines; and a host drawn
on Xvfb with lines typed on its keyboard through xdotool (sent on Enter,
not on Escape, the player still while typing).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use the existing Monitor actor type to keep authored squad counts even when its encounter is hostile. Check the effective actor variant too, including starting-location overrides and major upgrades, before placing an extra actor. Ordinary placement and enemy squads retain their existing paths.
Ten focused regression checks pass, including negative controls for both guards. Only the host creates these actors; no message or protocol change is required.
The disc was placed for the weapons at the old fixed place (7 cm above
the hand, 22 cm ahead of it): with the pistol now held 20 cm further
back it sat at the muzzle. It moves as the weapon did (forward and up;
sideways the barrel places it, as before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first-person model hung from a camera at one fixed place from the
weapon hand's controller (18 cm behind, 10 cm left, 14 cm above), but
each weapon's animations hold the hand elsewhere from that camera: the
pistol's wrist came out 15 cm before the controller (22 cm before where
the free hand's wrist is, 7 cm behind its grip), the plasma pistol's 11,
the assault rifle's 8 cm aside and 10 cm low; the weapon arm looked
longer, reaching forward. Measured on the Quest 3 (2026-10-17).
Now the wrist as the weapon is held ready is kept for each weapon
(eased, the last eight weapons), and the camera put so that it lands
where the free hand's does: 7 cm behind the grip, along the aim. The
model, its barrel, the laser and the shots move with the camera, as
before. A weapon not yet seen is held at the old place for its first
frame. vr.weapon_hand_forward/_right/_down (0 by default) move the hand
on the weapon; vr.weapon_forward/_right/_down, the old fixed place, are
gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audio Setup had no room for another row (nine on the desktop, as Video and
Network Setup), so Reverb moves with the new Resampler row (SINC, the
default, or LINEAR) to a SOUND PROCESSING screen opened from Audio Setup,
as Video Setup's categories are; the devices and volumes stay on Audio
Setup. The setting is read again when it changes, so the TODO saying it
could only be set in config.toml goes, and the help, the setting's comment
and the README say what linear does: it lets more of the images above a
sound's rate through, which is what sounds brighter.
The mixer's harness test takes the resampler from dsound_sdl.c, which now
calls config_string: it stubs it, and a new case checks linear mode against
linear interpolation worked out in double precision at every rate (with a
negative control). Checked: the harness and touch menu tests, test_web_build,
ninja linux, android and web, and both screens drawn on Xvfb with
HALO_AUDIO_RESAMPLER=linear; the menus regenerated with tools/ce_menus.py
change only the two audio screens.
A mod's model can name a shader of any group the map has, but
shader_transparent_chicago_extended is Gearbox's addition for Halo PC and
the Xbox game has none: the note says to use a transparent chicago shader
instead, and that a Custom Edition map's chicago extended shaders are
converted to chicago shaders as the map loads.
The gametype's time limit ends a game, and its time left is drawn on the
scoreboard, but a game without one shows no time at all, and the
scoreboard is a button hold away. The clock players time the weapons by
was a stopwatch beside the television.
It counts up from the game's first tick, which is the tick the items'
respawn periods count from as well (game_engine_update_item_spawn's
game_time_get() % respawn_period), so an item is back on the map at every
multiple of its period on the clock: on Chill Out the overshield is up at a
minute, the rocket, camo and overshield at two. Elapsed rather than
remaining for the same reason: a game with a time limit is the one a
player most wants to time the rockets in, and its time left is on the
scoreboard already.
It is drawn at the bottom right, where the versions players know it from
put it, clear of the first person's weapon. The HUD's own text colour is
lost against a map as bright as it, so the clock is lifted toward white and
drawn whole. ticks_to_unicode_time_string leaves the minutes out below a
minute, which a countdown reaches only at its end but a clock counting up
starts at, so the digit is put back at the call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01194FWhCaedcdGjEksr1H8w
The match clock is opt-in: Custom Edition's HUD has no match clock, so the
HUD looks as it did unless a player asks for one. MATCH TIMER goes on the
graphics settings screen after GLOW INTENSITY. The screen is as
tools/ce_menus.py writes it with the row. Nothing draws the clock yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01194FWhCaedcdGjEksr1H8w
That disc has no maps folder but one for each language, maps_es and
maps_it, and the disc image setup stopped: "The disc image has no maps
folder: it is not a Halo disc." The Quest's (XisoExtractor.java) and
the Steam Frame's (steam_frame_xiso.py, RINGFALL_LANGUAGE or the
system's) now take the folder of the device's language, else English's,
else the first, and write its language into maps/language.txt. The game
takes that as the Xbox's language (XGetLanguage) while game.language is
unset, so the menus speak it too. (Reported for ChupathingyCE, another
OpenCE fork, in its issue 145; written anew here.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Carry riders by the current collision transform rather than the previous tick's movement. Preserve supported riders' height through stopped-device predictions and delayed client echoes without suppressing normal jumps, departures or authoritative teleports. Local history only; network messages and protocol version are unchanged.
Validation on upstream main: 379 CPU/menu/assembly tests, including 32 focused elevator cases and negative controls; all three changed C files compile for Windows x86. Earlier native integrated-fork probes passed on b40/c40 at simulated 100/200 ms RTT through reversals and phase snaps.
(cherry picked from commit a38b7db0f4eb4ccc66f5445a6e9c4eef2fb0114a)
A scope's zoom (the pistol's, the sniper rifle's 2x and 8x, any weapon whose
HUD has a convolution screen effect) draws a warp: copies of the screen a
radius apart, blended outside the scope's mask, in two full-screen passes on
the Xbox. To keep the copies from standing apart as ghosts at high
resolutions, f815e1cc split the radius into as many times the passes as the
screen's pixels to the Xbox's one, up to six: ten full-screen passes (and
their target copies) at 2160 lines. On an M4 Mac mini in Chrome (ANGLE on
Metal) at 3840x2160 that took a frame from 17-22 ms to 34 ms with the
pistol zoomed on b30 and from 16.7 ms to 22-56 ms with the sniper rifle on
a50, all of it GPU time: with the screen effect off, a zoomed frame took
what an unzoomed one did, though zoom raises the draws (the level's LOD and
culling: 230-350 draws to 470-640 on b30) nowhere near enough to matter.
It also narrowed the blur: N passes at a radius over N have 1/N of the
variance, under half the Xbox's at 2160 lines.
The Xbox's passes are now drawn as they were, at the effect's radius, and
two more, at a third and a ninth of it, put eight copies between each two:
up to nine screen pixels to the Xbox's one (2160 lines are 4.5) they stand
no farther apart than the Xbox's did in its own pixels, two more again up
to 81. The blur's variance is 6% more than the Xbox's. At 640x480 nothing
changes.
Checked at 3840x2160 (headless Chrome, device pixel ratio 2): b30 pistol
zoomed 17-22 ms, as unzoomed (was 34 ms); a50 sniper rifle 2x and 8x zoomed
16.7 ms, as unzoomed (was 22 ms, 56 ms in one view). New harness test
(tools/harness/tests/test_screen_effect_passes.py): the pass count and
radii, the gaps between the copies and the blur's width at screen scales
1 to 1000, with negative controls.
Co-authored-by: Bridge <agent@bridgedesktop.com>
xgpu_texture_cache_begin_frame now also reads the high-res HUD's
settings (#378), drops an entry's upscaled sheet (#380) and forgets the
recent lookups. The test's fake world gains those members and functions,
with settings that never change. Its 3 cases and 4 negative controls
pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict was in xgpu_texture_cache_begin_frame's declarations.
Main gained the high-res HUD's refresh of cached bitmaps (#378) and here
the sweep counts time (idle_since, now); both are kept, declarations
before statements.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With multisampling, the tone map reads the resolved back buffer and draws
into its multisampled renderbuffer, so it need not copy the texture first:
the copy is kept only for the case that draws into the texture it reads.
The shader takes each pixel with texelFetch, and the copy was a nearest
blit of the same format, so what is drawn is unchanged.
Checked in a 48-machine co-op at 2560x1440 with msaa4x, uncapped, in
alternating windows of one game: the tone map's GPU time falls from 236
to 224 us a frame, and with the presentation's resolve from 270 to 228 us.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict was the generated Graphics screen, which gained HIGH-RES
HUD on main (#380) and HDR and HDR DISPLAY here. It was resolved by
writing the screens again with tools/port_settings.py: the generator
gives the merged file byte for byte. tools/test_web_build.py's menu test
passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings > Gamepads > Button Layout gets a sixth choice, Bumper Jumper as
the Master Chief Collection has it for Halo: Combat Evolved: jump on the
left bumper (the Duke's white), melee on the right (its black), switching
grenades on A, action and reload on B and the flashlight on X; the
triggers, stick clicks, Back and Start are the default layout's. The Edit
Profile picture, which shows the Xbox's pictures of its five layouts,
keeps the row's own picture for this one, of which the Xbox has none.
A layout the build does not know (a later build's) now gets the standard
buttons. With no case for it, every control was on A, start and back
with it, and input_abstraction_update_local_player_preferences' assertion
stopped the game, release builds too. Builds before this one still stop
when they load a profile saved with Bumper Jumper.
The buttons are mapped on each machine before its players' actions are
sent, and profiles are not sent (only names and colours), so
HALO_PORT_NETWORK_VERSION is unchanged.
Checked: tools/harness/tests/test_button_presets.py (every layout keeps
start and back and gives each control a button of its own, Bumper
Jumper's mapping, an unknown layout is the standard one, and three
negative controls), built 64-bit on macOS; the settings screens written
again with port_settings.settings_files(), which changed only Gamepads,
and test_web_build.py's and test_linux_port.py's menu checks; the units
that include player_profile.h and menu_functions.c compiled for i686
Linux against the portable system root. Not yet played with a controller,
and not built for Windows, Android or the web.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recheck committed font settings before cached font selection, including when
high-resolution text was disabled at first use. At each frame boundary,
invalidate only cached HUD/title bitmaps affected by a changed setting so
both enabling and disabling rebuild the appropriate texture on next use.
Keep decoded replacements, font atlases, world textures and menu Apply/Cancel
semantics intact. This corrects shared cache behavior; it adds no MCC data,
map-family routing or networking/simulation changes.
Validation: Windows portable release build and 370 focused production-code,
HUD, menu and MCC checks passed. Tests cover repeated toggles, disabled text
at startup, font scale/cache reuse, selective bitmap refresh and Apply/Cancel.
Android and Web targets were attempted but are unavailable in this Windows
build graph. Live Custom Edition validation is limited by unavailable maps.
(cherry picked from commit ddeb650d82a73b570e182ff2bfd87e3cc3daa98d)
Match visible A/B/X/Y and supplied trigger/thumbstick artwork to the Xbox
bitmap bounds and center prompts around the visible glyph. Keep Black and
White unchanged. Preserve all three stick press frames and rename the
Menu Gems display label to Dark Gems without changing saved values.
Use the original HUD ONE/INVSRCALPHA blend for sticks and triggers, with
premultiplied asset edges, to avoid dimming them by prompt alpha twice.
Increase the existing high-res texture table so every new frame loads.
All changes remain in the existing high-res feature and menu wiring;
low-res fallbacks and unrelated icon indices retain their existing paths.
Checked: 358 focused HUD/menu/MCC tests on testing, Windows portable release
builds on testing and a clean upstream checkout, and hidden Xbox a10
captures for size, spacing and brightness. Android/Web targets are absent
from this Windows build graph. No protocol or simulation changes.
(cherry picked from commit c21ec87e6fbc6eb3370597dbdbc92cbe5464bb61)
Xbox button definitions override prompt text with full-opacity colors.
Honor that existing override for Classic, Gems and Menu Gems while
retaining text alpha for icons without it. Embedded artwork supplies RGB.
Validated original Xbox a10/Blood Gulch definitions, opaque asset
interiors, Windows portable release compilation, and production-function
checks for all three styles, full/partial alpha, fallback, and cleanup.
Android/Web targets were unavailable in the Windows configuration.
No MCC/fork code, tests, documentation, assets or workflow changes included.
(cherry picked from commit ce6cf74623912bf442efcfb2e0d6849a84f86011)
Use the supplied transparent A/B/X/Y artwork for Gems, fitted within the existing button bounds. Preserve the original menu redraws as Menu Gems and share their Black/White glyphs between both gem styles. Classic remains the default. Keep the source PNGs and deterministic asset conversion, and reserve enough placeholder entries for all styles across map changes.
Validated the Windows release build and 269 focused checks, including generated texture bounds, centering, repeatable output, all three native/MCC style transitions, disposal, and menu row spacing. Existing menu glyph PNGs remain unchanged.
(cherry picked from commit 5f66c51927f09c31095e224b0cbfbeb8aeef0bb8)
Default high-resolution gameplay A/B/X/Y/Black/White buttons to plain Xbox faces made from the existing redraws. Retain the shaded faces as Gems and keep glyph geometry unchanged. Show Button Style below High-Res HUD only while enabled, following pending edits and closing the row gap when hidden. Recreate owned glyph placeholders on style changes and dispose them on map unload.
Validated the Windows release build, hidden menu captures with both styles and HUD off, isolated Xbox gameplay, and 268 focused production-code and menu checks including repeated visibility/focus and glyph lifecycle changes. Android and Web builds were attempted but their targets are unavailable in this Windows configuration.
(cherry picked from commit e8f36b8aa2b906a738856ac198efbd591cc9bcc0)
The game's own updater (the Quest's) cannot on the Steam Frame: Lepton
installs the APK in the game's folder there at every start. So the
launcher does it: while the game runs it asks GitHub for the latest
release, fetches a newer one's Steam Frame bundle into update/, checks
it (its build.txt is the release's number), and puts the APK and the
launcher in place as the next start begins, before Lepton runs the APK.
update.auto = false in config.toml stops it, as on the Quest; a build
with no number (build.txt, which the bundle now carries and the
installer copies; 0 for local builds) never looks; without the internet,
or when anything fails, nothing changes. The in-game updater no longer
starts there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
32-bit libSDL3 loads libXi.so.6 and libxkbcommon.so.0 with dlopen at run time. Where a 64-bit distribution's 32-bit SDL3 package leaves those out (such as Arch Linux's lib32-sdl3), SDL falls back to X11/XWayland without XInput2 and SDL_SetWindowRelativeMouseMode fails with SDL_Unsupported(), or leaves SDL_WINDOW_MOUSE_RELATIVE_MODE set before the window has focus so non-relative motion is dropped.
When SDL_SetWindowRelativeMouseMode does not take, platform_mouse_capture now clears SDL_WINDOW_MOUSE_RELATIVE_MODE, hides the cursor, grabs the mouse in the window (which lets XWayland's xwl_seat_maybe_lock_on_hidden_cursor lock the pointer and emulate warps), and warps the pointer back to the middle of the window after motion.
Checked with ninja linux and ninja android.
On the Quest 3, in big online games (26-35 players), the picture was
doubled and shifted from the start as the head turned, nauseating; with
48 bots it was not, and with the eyes drawn one by one it was not online
either. Not the frame rate: one by one, online at 72 Hz, the compositor
repeated as many frames and nothing was seen. The Steam Frame had the
same doubling with multiview (2026-10-05) and draws them one by one
since. So both now do by default; debug.multiview (was vr.multiview, and
debug.multiview_steamvr) draws them at once, for finding what the shared
pass draws wrongly. The refresh rate's steps take up the extra cost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/stagekit writes a scenario's map in code (a floor in a box, props, players' starts) and plays it headless, a tick a frame, the same every time. Every player is logged each second and tests check what happened: walking, an obstacle, an elevator ridden up and down. 20 tests, about 2 s, CI-safe.
debug.fixed_step runs a game of one machine a tick a frame, each frame a tick's time, so it plays the same every time, faster than real time; debug.network_test_cues runs console lines at their ticks from the first tick every player is in; debug.network_test "log" logs a game of one machine, and the scripted player follows the game's clock. All off by default.
The first try stepped 90 to 80 Hz a few seconds into a 30-player game,
and no doubled picture was seen, but 2-5 stale frames a second were left
at 80: one late frame in 32 (was 16) takes it on to 72. The VR options
show the rate chosen and the one it runs at while lowered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In big online games a client's ticks cost 3 to 5 ms (twice what bots or
smaller games do), and the frames with a tick missed 90 Hz's 11.1 ms:
the headset showed them twice, a doubled picture when turning, with the
GPU at 60-80 %. While more than one frame in sixteen comes late, two
two-second windows running, the rate now steps down to the next the
headset offers (90 to 72 Hz); after ten windows in which hardly any
would be late at the rate above, it steps back up, never above
vr.refresh_rate. A load's long frames count for neither. On by default,
"Lower it when frames are late" on the VR options' MORE page; nothing
where the headset offers one rate (SteamVR).
Also the BUTTONS page's notes below its ten rows (the push to talk row
pushed the last one onto them).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Increase the existing Xbox faces from 20 to 26 HUD units, centre them on the text capital midpoint and compensate for the text renderer's following-run inset. Preserve their original artwork and split-screen scale. Checked the portable Windows OpenCE build and hidden 1280x720 gameplay captures; the Press X to prompt has balanced visible gaps. Retain the repository's rocket reticle unchanged at the user's request.
Upscale the original vehicle/passenger labels, warnings, reload dots and
messaging sheets without changing their glyph layout or sprite coordinates.
Use the existing Xbox controller redraws for A/B/X/Y/Black/White prompts;
other controller symbols retain the original Xbox art through upscaling.
Match the existing high-resolution vehicle meter contour to remove the
double wrench. Restore the rounded diamond sniper reticle, contain the
flashlight fill and assign discrete thresholds to whole ammunition bullets.
The HUD module owns interpolation and tag selection. A narrow texture-cache
hook is needed to obtain decoded original pixels and invalidate generated
textures with their source; the messaging hook selects the existing button
art. Both respect the High-Res HUD setting. MCC's separate draw/texture
paths remain intact. No MCC bitmap data is embedded; label interpolation
was retained at the user's request, with its limited sharpness acknowledged.
Validated Windows portable release build; 246 focused HUD tests; 15,360
real GPU ammunition checks; hidden Xbox HUD captures enabled/disabled;
Xbox/MCC/Xbox transitions at 30 and 120 FPS with no exceptions. Player health
and sniper zoom-label pixels outside the targeted regions are unchanged.
Android/web targets are unavailable in this Windows build configuration;
Custom Edition gameplay data is unavailable, so that path was inspected.
No simulation or network format changes; network version stays unchanged.
(cherry picked from commit 5398553e4a95d7d68376acf023e6ca671066a614)
Apply the existing campaign gradient to the three- and four-button online
pause artwork, retaining the translucent footer. Admit full-screen Xbox
network campaigns to the existing Settings insertion while keeping local
split-screen unchanged and preventing network Settings from pausing time.
Checked Windows release build, pause-art and campaign-context harnesses,
and hidden two-peer Xbox multiplayer and co-op sessions. The co-op client
can select and open Settings. Android/web builds are unavailable in this
Windows configuration; no wire-format or simulation contract changed.
(cherry picked from commit 04cd1e25e910c5234fee323033b1efb8c71e8e0a)
Upstream fixed the Android asm conversion's string bug independently
(d9669f6ee, also from a Steam Frame crash); its converter and tests
replace ours (c85cf8ece), which they cover and extend (quoted names
honour escapes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restore the pause header and footer bands while retaining Settings, the
campaign body gradient, and the Xbox footer alpha of 191/255. Initialize
the scripted timer scaling flags so stack data cannot shrink its digits.
Move Edit Gametypes and its divider inside the connected panel.
This implementation-only upstream contribution excludes MCC, regression
tests, documentation, optional regeneration commands and workflows. The
complete fixes and tests remain on GulchCE testing and the local validation
branch. Original cherry-picked changes below were combined and scoped to
OpenCE; authorship is retained.
Validated: portable release Windows builds on both bases; 25 upstream
art/timer tests and 52 GulchCE tests before upstream test exclusion; hidden
Xbox and MCC pause captures, both timer HUD settings, and multiplayer menu
captures. Final generator matches all committed pause SVGs. Android/web
targets are unavailable in this Windows configuration; online co-op was
not replayed.
(cherry picked from commit a0c20572f8bfa6af0d2f94c57abef32357750bf0)
(cherry picked from commit c76a3a596f7dbe8ced581b2e6c759aae0baababb)
(cherry picked from commit f0804e1e5c857db6dc84ff16c72d3f8b8abf95a2)
(cherry picked from commit bff920f6a5ec614caf98ff5e8555a0320b0a29f1)
(cherry picked from commit b1b59eb7237d901dba10aa0b5187b184f3f44a7b)
On Linux, SDL's file picker needs the desktop portal, which the 32-bit
game reaches through a 32-bit libdbus-1.so.3, or zenity. Where neither is
present, SDL reports an error, but the game took it for a cancelled
choice and asked the same question again, without end.
The game now looks first for a disc image (.iso or .xiso) next to the
executable, names it in the question and extracts it without a file
picker. When no picker can be shown, it says why (put the image next to
the game, or install zenity) and quits. Cancelling the picker still asks
again, and if extracting the image found fails, the next try opens the
picker.
The texture cache dropped a texture once it had gone 1800 frames without
being drawn, and checked every 600 frames: half a minute and ten seconds
at 60 frames a second. At 360 frames a second it is five seconds, so
looking away from part of a level for five seconds dropped its textures,
and turning back converted and uploaded them again; the faster the
display, the more often. On b30 at 357 frames a second, 105 textures were
dropped in 38 seconds of play.
The cache now sweeps every 10 seconds of time (SDL_GetTicks, which the
Android guest and the web have too) and drops the textures not drawn since
the sweep three sweeps before: 30 to 40 seconds after they were last
drawn, which is what it did at 60 frames a second. The frame counter still
orders the palette variants.
Checked with a harness test (tools/harness/tests/test_texture_idle.py)
that runs xgpu_texture_cache_begin_frame from the sources against a fake
clock: a texture last drawn 1 or 25 seconds in is dropped 30 to 40 seconds
later at 360 frames a second, and at 60 as before; on main the 360 cases
fail (dropped after 5.7 and 6.7 seconds) and the 60 one passes. Four
negative controls. Not measured: the time saved, which is small on an
RTX 4080 (each upload took 1.6 ms or less) and larger where conversion and
uploads cost more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
biped_check_discard erases a biped that has fallen 2000 units below a
map it has left. On a client the biped is often the host's, whose
delete network_objects_may_delete refuses, and the host stops sending
the state of objects outside the map, so the biped stays there, falling,
and the check logged "is in a bad place ... erasing" every tick: a
co-op client's debug.txt had 6122 such lines for a few flood carriers
in three minutes (up to 3942 a minute), each a localtime, a vsnprintf,
a write and a flush on the game thread. The check now leaves the host's
bipeds to the host, as the falling damage erase already does (49350528).
Checked with a harness test (tools/harness/tests/test_biped_discard.py)
that runs biped_check_discard and network_objects_may_delete from the
sources: the host's biped on a client is neither logged nor deleted over
30 ticks, a client's own biped and the host's are erased once, and one
above the limit is left; without the new condition the client case fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The postgame host map picker opens without menu history. Give it the
existing main_menu_if_no_history flag used by the lobby so leaving the
last screen restores the main menu instead of leaving only the background.
Update the generator and generated XML together. Preserve OpenCE's
existing handlers; no MCC code or regression-test files are included.
This local menu change does not require a network version bump.
Resolved the fork cherry-pick against OpenCE main 8102e711 by adding only
the flag override. Windows portable release build passed. Existing menu
teardown, touch and web checks returned 28 passes and 6 failures: missing
m.lib/pthread.h and Windows path separators in POSIX-oriented tests.
Android and web build targets were unavailable locally. Checked generator
round-trip stability and git diff --check. The two simple wrappers were
regenerated through ce_menus.widget_xml from their exported XML fields;
the original PC tag inputs were unavailable. No upstream runtime game
session or Linux build was run. Earlier scripted runtime verification
was performed on the GulchCE implementation, not this upstream revision.
(cherry picked from commit c811c7f527d70d5476578ed88ea0edcef2269967)
The index cache (a3277d94) keeps an index array drawn twice the same in
one 8 MB buffer, and draws from it every frame. An array was written into
that buffer the moment it was kept, while queued draws read the buffer:
WebGL on Metal (ANGLE, which Chrome and Safari use on a Mac) waits for the
GPU to finish everything queued before such a write, as #262 found for the
stream and index uploads. While a level is explored arrays are kept every
few frames, so play hitched again and again, worse the longer one played:
one WebGL call of 20 to 40 ms every few frames, and Chrome's GPU process in
-[MTLCommandBuffer waitUntilCompleted] (macOS sample). Resolution and
window size made no difference.
Now there is a buffer for each frame of the ring, drawn from only in that
frame, as #262's chunks are. An array kept is listed, and written into a
frame's buffer as that frame begins (index_cache_begin_frame), when the GPU
is done with it; until its frame's buffer has it, a draw of it uploads its
indices as before. Android and the native builds are unchanged.
Checked on an M4 Mac mini, Chrome 155, b30 with the scripted walking bot
(HALO_TEST_INPUT=bot:1), six 10-second windows from a fresh load each:
before: 60, 56.2, 57.6, 51.4, 52.6, 51.6 fps; frames over 25 ms
3, 43, 24, 61, 57, 89; longest 152 ms
after: 60, 59.9, 59.6, 60, 60, 60 fps; frames over 25 ms
2, 6, 3, 2, 3, 0 (the same as with the cache bypassed)
The cache still serves 80 to 99% of indexed draws, as before. ninja web
builds; d3d8_gl.c's changed lines compile without warnings under -Wall;
port/web/tests/*.test.js pass, and tools/test_web_build.py passes but for
test_the_sockets_inside_the_page, which fails the same without this change
on macOS (getentropy undeclared in web_net.c, compiled with the host's cc).
Co-authored-by: Bridge <agent@bridgedesktop.com>
Bridge-Chat: #60
A network co-op game's pause menu has FRIENDLY FIRE ON or FRIENDLY FIRE OFF
(network.coop_own_friendly_fire, on unless changed). Two players hurt each
other only if both have it on, and then as the host's FRIENDLY FIRE says.
A player with no input for over 70 seconds is hurt by every player,
whatever either chose, until their next input: the choice does not shelter
someone who has left the controls. Enemies and AI allies are as before, and
no other kind of game changes.
Only the host deals damage, so only the host keeps the choices. A client's
input says its player's in a byte that was padding, and the host counts
each player's ticks without a button pressed or let go, or a stick or the
trigger held. The network version is 30 for that byte (29 is the Custom
Edition map names'): an older host would ignore a player's choice.
The button is in the pause menu because a co-op game had no way to its
settings: it opens the campaign's pause menu, which gets SETTINGS only in
single player. pause_campaign_patch lays the menu out as it does single
player's with SETTINGS: five rows, 30 apart, in the taller box. Like
SETTINGS, the button needs the PC version's menus.
Checked on Windows in two games of two hidden copies on a30, one player
hitting the other every 10 seconds, keys pressed by a script, once with the
host's player hit and once with a client's. Hits took shields with FRIENDLY
FIRE ON; none did after switching it OFF; with the player left idle, the
hits from 70 seconds on took shields again; and none did after the next
key. A multiplayer game's pause menu, opened on a client, still has RESUME
GAME, SETTINGS and LEAVE GAME. Not tried with a gamepad or in split screen.
Built and run here on Windows only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A player's game froze for about 48 s at a time, in the menus too, and online each freeze ended in "connection failed"; a dump showed the frame's swap waiting inside the graphics driver. The log now says so whenever showing a frame takes 2 s or more, so such a report points at the driver without a dump.
A game the browser cannot reach shows "Could not reach <name>" for a few seconds. When this machine's NAT gives each destination its own port and no router forwards the tunnel's (what the log already reports), it now shows "Strict NAT, turn on UPnP: could not reach <name>", the advice first so that a long name is what gets cut.
tools/harness/tests/test_latency_meter.py takes latency_meter_color from
latency_meter.c and, from network_distributed.c, the round trip timed by
when its tick was sent, its smoothing, the host's silence on a client and
a client's on the host, with the constants they use, and checks them in a
fake world: the colours and bars at 79/80, 149/150 and 249/250 ms; a tick
no longer kept (or never sent) gives no round trip; at most 9999 ms, also
across the millisecond clock's wrap; the first sample as it is, then an
eighth of each difference; a connection problem after more than 2000 ms
of silence, never before the host's first message, after the game or on
the host. Eight negative controls (a bound moved by one, the stale-tick
check or the ceiling taken out, a quarter's smoothing, no game-over test)
each fail their case. Two source checks: the setting is off by default
and the netcode refreshes the meter only with it on, and the scoreboard
draws a known ping alone in its colour.
Checked: python -m pytest -q tools/harness tools/test_touch_menu.py (358
passed, 16 of them these).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucYZ9cvWn3nrnk7kK3S5X
With display.latency_meter on, the scoreboard's Ping column agrees with
the meter: where this machine times a player's round trip itself (the
host: every client's, its own players' 0; a client: its own players'), the
column shows that figure, and each known ping is drawn on its own in the
meter's colour for it (green, yellow, red). The other players' pings on a
client are the host's measure in ticks, as the pings message brings them.
With the setting off, the scoreboard is drawn as before.
Without this the two disagreed on one screen: on a LAN the host's measure
in ticks reads 0 to 4 ms while the meter, which counts each machine's wait
for its next tick, reads 25 to 35; with 80 ms of netem the scoreboard said
81 and the meter 107. Nothing more is sent: the pings message still
carries the host's measure in ticks.
Game source change (port: comment): game_engine.c's scoreboard takes a
player's ping from latency_meter_player_ping (distributed_player_ping with
the setting off) and draws a known one alone at the row's fourth tab stop.
Checked: Linux debug and release, web and Android debug and release
build. With netem's 40 ms each way, the host's scoreboard showed 0 and
108 with "Max ping 108 ms", the joiner's 0 (the host's) and 99 with "Ping
99 ms", in the meter's colours; with the setting off the scoreboard looks
as origin/main's (team colours, the host's measure).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucYZ9cvWn3nrnk7kK3S5X
display.latency_meter (HALO_LATENCY_METER, default false) shows this
machine's round trip to the host at the screen's top right while a network
game is played: "Ping 45 ms" with four bars drawn as quads, green below
80 ms, yellow below 150, red from 150. The host shows "Max ping", its
slowest client's. A client that has heard nothing from the host for two
seconds shows "Connection problem", pulsing; the host logs a client silent
as long. It is not drawn in the menus, after the game, over a co-op
cutscene (but a connection problem), or when the host plays alone. It is a
setting, off, because the stock HUD has no such meter; only in config.toml.
Nothing more is sent, so the network version stays 29. The meter reuses
the round trips the distributed netcode already takes: the host each
client's from the host tick its input names, a client its own from the
client tick the host's state of its player (or, driving, its vehicle)
names. Those are counted in ticks; the meter times the same echoes in
milliseconds by when this machine sent the tick they name (the last 64
ticks' send times kept), smoothed as TCP smooths its own. A client also
notes when the host's latest message came. All machines, system link,
internet play's tunnel and a browser's WebRTC channel alike, carry these
messages, so the meter reads the same over each. What is shown is
refreshed in the tick twice a second, and only with the setting on;
halo.log then has the round trips every ten seconds ("latency: ...") and
each connection problem as it starts and ends.
Ported from the Halo CE for PS Vita port (BirchWoodGod, contributed under
CC0), where it is on by default.
Checked: Linux debug and release, web and Android debug and release
build; tools/network_format.py: the wire format is the one recorded. A
system link host and joiner in a network namespace with netem's 40 ms each
way: the meter read 89-123 ms on the joiner and 94-128 ms on the host (80
ms of network and each machine's wait for its next tick); all traffic
dropped for 5 s: "Connection problem" on the joiner after
2247 ms, the host "Max ping 2980 ms" in red and its log "machine 1 silent
for 2190 ms", both heard again after the blackout. On loopback with no
delay: 25-35 ms. With the setting off, the game logs the same lines as
origin/main (numbers apart) and config.toml differs only by the new
setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucYZ9cvWn3nrnk7kK3S5X
tools/harness's bots_lobby takes network_game_server_port_bots from
network_server_manager.c, the players' functions from
network_game_manager.c and the bots' machines and names from bots.c, and
checks who joins a lobby, on which team, who leaves, and that no bot joins
a game other machines may join, another gametype, a game once started or a
lobby with no player. Each of its 8 negative controls (a guard or a team
rule taken out) fails its case.
tools/test_bots.py plays a local Slayer game with bots on Blood Gulch and a
Team Slayer game on Chill Out for 75 s each (debug.network_test "local:",
SDL's offscreen video driver) and checks that every bot walks, the bots
kill and die, the game ends cleanly with no assert, and that a system link
game gets no bot whatever bots.count asks. It needs the retail maps in
assets/maps and skips without them.
Checked: python -m pytest -q tools/harness tools/test_touch_menu.py, 361
passed; tools/test_bots.py, 3 passed in 4 minutes; test_linux_port.py and
the format tests, 214 passed, 8 skipped (Custom Edition maps, perfetto);
test_web_build.py, 10 passed. tools/test_death_timing.py does not compile
with clang 22 here, on origin/main too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucYZ9cvWn3nrnk7kK3S5X
Computer players for a game played on this computer alone: bots.count (0,
the default, to 15) of them join the lobby of a local split screen Slayer
game, as far as it has room, on the teams bots.teams asks for ("even", or
"against" the players), and follow the players' team changes there. A bot
is the one player of a machine of its own at the top of the machine indices
(113 up), which no real machine takes, so every machine of the game - only
this one - spawns, respawns and scores it as any player.
network_server_manager.c adds and removes them in the pregame;
players_update_before_game takes each bot's action of the tick from
port/linux/game/bots.c in place of its empty queue.
A bot looks for enemies (the AI's line of sight, its skill's reach and
field of view), reacts, turns at a limited speed with an aim error that
settles and leads by its weapon's projectile speed, fires in bursts or a
press a shot, throws grenades, strikes close up and strafes; otherwise it
goes where an enemy was last seen, to a better weapon lying near (the
action button once the game offers the swap), or about the map's starting
locations and item spawns. It walks the paths the campaign's AI finds:
path.c's searches over the walkable surfaces every stock multiplayer map
carries, on a copy of them less those with no room above (Longest's walls)
and those under scenery or machines its path's obstacle avoidance found no
way past (Sidewinder's blast door); stuck, it jumps, sidesteps and looks
again. The searches run in the tick, three a tick at most, so two runs of
the same input play the same. bots.skill sets reaction, sight, aim, turning
and bursts; debug.bot_trace logs each bot's state.
Nothing changes in system link or internet games: no bot joins a game that
accepts remote connections, and bots_update_actions returns unless a local
game runs, so nothing the machines send each other changes and the network
version stays the same. Other gametypes get no bots yet (their goals come
in a later change). The code is BirchWoodGod's, from the Halo CE for PS
Vita port's offline bots, contributed under CC0; this is their core.
Checked (Linux, debug and release builds, SDL's offscreen video driver,
debug.network_test "local:" with debug.test_input "bot:1" as the player):
- 150 s games on Blood Gulch (Slayer, 6 bots; Team Slayer, 7, legendary),
Chill Out (Team Slayer, 5, heroic), Longest (Slayer, 8), Sidewinder (Team
Slayer, 9), Hang 'em High (Team Slayer, 5, "against"), Damnation (Slayer,
6, easy) and Prisoner (Slayer, 15): every bot walked 125-280 units, the
bots killed 7-118 times and died, teams evened or all against the
player, and every game ended cleanly with no assert. With
debug.bot_trace, bots took up rocket launchers, shotguns and sniper
rifles, and debug.txt had none of the distributed netcode's idle action
lines.
- bots.count 0: a 60 s local game logs the same lines as origin/main with
only the network test's local mode (numbers apart), and config.toml
differs only by the new settings' lines (and the network test's
comment). bots.count 6 in a system link
game (host:) and in a local CTF game: no bot joins, nothing logged.
- ninja web and ninja android (Emscripten 6.0.10, NDK r27c) build, debug
and release.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucYZ9cvWn3nrnk7kK3S5X
debug.network_test "local:<map>[:<variant>...]" sets up the game as the
Xbox menus' Multiplayer > Split Screen does (split_screen_game_initialize:
a server that accepts no remote connections, its client, the split screen
pregame screen) instead of hosting a system link game, then picks the map
and variant, adds controller 1's player and starts it as "host:" does. A
game no other machine can join had no automated way to be played; the bots
of the next commit play in such games.
Checked: "local:bloodgulch:slayer" on a build of origin/main with this
change alone loads and plays the game (one player, debug.test_input
"bot:1"), logs every second as "host:" does and ends with debug.exit_after;
"host:" games are as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucYZ9cvWn3nrnk7kK3S5X
parse_unicode_string took the character after every '|' and only then
looked for the 'n' of "|n", so a string ending in '|' skipped its
terminator and was drawn on through whatever followed it, until a zero.
Players' names, server messages and a map's string lists are drawn this
way, so any player can send one. Any other '|' also ate the character
after it, and kept the result of the character before (on a string's
first character, the parser's uninitialized one). The next character is
now taken only when it is 'n'; any other '|' is a character, as
parse_string has it. "|n" is unchanged.
Checked: new harness test unicode_line_breaks takes parse_unicode_string
from draw_string.c; its cases are bar-n-ends-a-line, bar-is-a-character and
bar-at-the-end. On the old code the last two fail, and each of its 2
negative controls (the next character taken whatever it is, the '|' left
with the last result) fails its case. tools/harness and test_touch_menu:
347 passed. ninja linux (debug and release) and ninja web build with no
new warnings; Android not built here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ucYZ9cvWn3nrnk7kK3S5X
NVIDIA's OpenGL driver can treat SDL's popup fullscreen window as exclusive even when no display mode is selected, blanking the monitor on Alt+Tab. Clear popup and topmost styles for borderless windows before context creation and presentation, including after SDL restores them. Explicit fullscreen keeps its existing behavior.
Checked: Windows release build; hidden native window probe covering styles, bounds, focus, repeated application and mode isolation; git diff --check. Live Alt+Tab was not tested. Android and web targets were unavailable; pytest was not installed.
memory_watch.c's SIGSEGV handler is installed before main (for the crash
report with the null renderer), and the device's memory_watch_initialize
returned early because it was already active. A library that installed its
own SIGSEGV handler in between (a GL driver as the window opened, a sound
library) then took the faults of the pages memory_watch_protect made
read-only: a crash or a hang at the first write to a texture.
memory_watch_initialize now asks which handler is installed and, where it
is not this file's, installs it again, chained to the one found; the
renderer's frame (memory_watch_begin_frame) checks the same, as a library
can install one at any time. A handler that chains back to this one ends
in the default action instead of a loop.
The same report (signal, registers, stack, calls, to the terminal and
debug.txt) now covers SIGBUS, SIGILL, SIGFPE and SIGABRT. Each is handed on
to the handler before it, or to the default action: a fault runs its
instruction again under it, and a signal sent (kill, abort) is raised again;
a sent signal that was ignored stays ignored. The registers are read by
name on x86-64 too, so that the file compiles there for its test.
Checked: tools/test_memory_watch.py (new; memory_watch.c compiled as it is
with a window of the test's own memory) installs a library's handler over
this one, then initialize: the watched write is noted and let through, the
library's handler gets only an unrelated fault (after the report), and the
next frame puts this one back after a handler that jumped out. Each signal
is reported once and ends the process with that signal. The old file, with
the same main (-m32), leaves the library's handler in place and dies at the
watched write. ninja linux builds.
The SIGSEGV handler that writes the crash report (the faulting address
and the calls) to the terminal and debug.txt is memory_watch.c's, and
only the renderer's device installed it (d3d8_gl.c): with
debug.null_renderer, as the network tests run, no device is made, and a
crash said nothing, as a looping sound's crash in null-renderer network
games did. A crash before the device was made went unreported too.
The handler is now installed when the program starts, as the Windows
build's crash handler is.
Checked: a null-renderer game sent SIGSEGV wrote the report to its log and
debug.txt; the crashes of a forced looping sound crash in null-renderer
games were reported with the faulting address.
A carousel network game between two builds, null renderer on both
(the cross-build sync check's calibration), ended with the joining copy's
SIGSEGV. Its core dump (kept by systemd-coredump) shows
update_channel_for_looping_sound reading a NULL looping sound
(sound_manager.c:2912): the track's looping sound had been deleted by
process_looping_sounds (not refreshed since the last frame, around the
respawn), and the track had started in the same millisecond as the
frame (start_time 55674, render_time 55674, ticks_elapsed 0).
refresh_sound skips the tracking of a sound that has not started yet
(start_time >= render_time), and the tracking is what stops a track
whose looping sound is gone; so the track went on to update_channels.
That needs three frames in one millisecond: never on the Xbox, but the
null renderer runs up to 21 (measured in these games). The original's
bug; refresh_sound now stops such a track first.
Checked: a new asset-free test (tools/harness/tests/test_sound_orphan.py)
runs the real refresh_sound in frames in the same millisecond and later,
with a negative control (the test of the start time alone) failing it.
A build that skips every seventh frame's looping sound refreshes crashed
at the same line in 8 of 8 carousel games without the change, and
played 16 of 16 with it; 40+ unforced carousel games played through.
Sounds are each machine's own: nothing the machines send or agree on
changes.
A game's level name is the map's own (custom_maps\hugeass), so no screen or
server browser listing shows a hash, and the game record's map version is
the map's hash's first 32 bits, which a client finds its file by. Maps are
no longer renamed; a download beside another version of its name is named
<name>_<hash>.map and listed as "(2)"; maps the last build renamed get
their names back. Network version 29: the level name and the map version
mean something else.
A client on the Steam Frame, in an internet game on carousel, stopped on the
game's exception screen: "objects.c #829: !(weapon_is_flag(object_index))".
The host's object changes had told it to delete a weapon that on the client
is a flag or an oddball (weapon_is_flag reads the weapon tag's "must be
readied" bit, which in the maps only those two have), and
distributed_client_delete deleted it as it deletes any of the host's
objects. The game never deletes a flag or ball while a game type runs, and
asserts so twice: in object_delete_initial_recursive and in weapon_delete.
A release build's assertions only note it in debug.txt, so a release host
can delete one and tell its clients; a client built without --release then
halts, flat or VR alike.
A client follows the host's word: a flag or ball it deletes so is let go of
first (distributed_client_release_item takes it out of its carrier's hands,
as any weapon of the host's), the goal its game type showed at it is cleared
(game_engine_objective_weapon_deleted: CTF's flag and carrier goals,
oddball's ball goal), and the two assertions are not made for an object
distributed_client_delete deletes (network_objects_client_deleting). The
host and the game's own deletions keep them.
On spark, a host told its clients that each flag (one in the client's
player's hands) or the ball was deleted, as a host deleting one does, and
of it again the next tick. Before, a flat client and a VR client halted on
the same assertion as the Frame, in CTF and in oddball; now neither does,
the client's player lets go of the flag, the flag comes back when the host
tells of it again, host and client count the same items, and the player
is killed and respawns after it. A slayer game of deaths and respawns plays
as before.
Files:
port/linux/game/network_objects.c
source/objects/objects.c
source/items/weapons.c
source/game/game_engine.c, source/game/game_engine.h
port/linux/NETCODE.md
Every build sends one 20 ms frame per packet (voice_audio_encode). A packet
whose table of contents says otherwise, or that Opus cannot parse, is
damaged: voice_audio_play now drops it before the decoder sees it
(opus_packet_get_nb_samples), and the next frame makes up for it as lost.
Cheap defence in depth: Opus's decoder already refuses such packets, and the
crash in decode_pulses came from a valid packet and the guest's garbled
pulse cache, fixed in the assembly conversion.
The arm64 and Android guests are compiled for arm64_32-apple-watchos and
their Darwin assembly converted to ELF (tools/android_asm_convert.py). The
converter took a quoted .ascii/.asciz operand for a quoted symbol name and
dropped a leading "_" from it, as it does from symbols; and its quoted-name
pattern ended at an escaped quote (\"), so the "_" of a word after one was
dropped too. Clang writes byte tables as .ascii strings, so a table holding
95 ("_") lost that byte and everything after it moved down by one.
In Opus that table was CELT's pulse cache (modes.c, cache_bits50): the bits
a band may have are read off it, and shifted by a byte it lets a band have
more pulses than the PVQ tables hold. Decoding a frame from another player
then read past CELT_PVQ_U_ROW in decode_pulses (n 32, k 240) and crashed: a
client on the Steam Frame, in a CTF game on the internet. The guest's own
encoder could crash likewise in encode_pulses. The same build of Opus,
driven with the game's settings and with clean, corrupt, truncated and
random packets and voice_audio_play's loss handling, crashed within a few
thousand frames (and identically at -O0); with the fix it ran 4.8 million
decodes clean, and its encoded bytes and decoded samples are identical to a
native 64-bit build's.
The same loss hit other strings and tables of the guest: musl's iswalpha,
iswpunct and towctrans tables and iconv's charmaps; tomlc17's "_0123..."
digit sets (TOML numbers with "_"); the attract mode's "_de"/"_fr" names,
"_lan_", "_lit" and "_%s"; the progress bar's compressed monk tone and the
DirectSound effects image; and assertion and error texts. All are now
assembled as compiled.
String directives now go through unchanged, and quoted names honour
escapes. tools/test_android_asm_convert.py checks both, and a table of
every byte value through clang, the conversion and the assembler.
Use the natural C implementation from punpckhdq/halo 745c66b8. All 621 object fingerprints and inherited exact verdicts are unchanged; the single asm-implemented park is retired. Fresh full checkpoint: 1663 tests pass, 5 skipped, 159 subtests. Current scoring already counted the assembly: no additional function or byte credit.
Video Setup's Graphics screen gets a MODEL DETAIL row, which sets
display.model_detail. Its values are stock, native and highest. Its XML
is written by tools/port_settings.py's settings_files(), as
tools/ce_menus.py writes it; no other screen changes.
Checked: tools/test_web_build.py's test that the menus match their
generator; the screen opened with debug.menu_open shows the row with the
setting's value, above the help.
Video Setup's Graphics screen gets a TEXTURE FILTERING row, which sets
display.anisotropic_filtering. Its values are 0 (off, as on the Xbox),
2, 4, 8 and 16. Its XML is written by tools/port_settings.py's
settings_files(), as tools/ce_menus.py writes it; no other screen
changes.
Checked: tools/test_web_build.py's test that the menus match their
generator; the screen opened with debug.menu_open shows the row with the
setting's value, above the help.
The game chooses a model's level of detail by its size in pixels on the
Xbox's 480 lines, whatever the resolution, so at high resolutions distant
models drop to their simpler levels long before it would show.
display.model_detail (HALO_MODEL_DETAIL): "stock" (the default) chooses as
the Xbox did; "native" by the size on the display's lines (without
supersampling's factor); "highest" draws the models native does, each at its
most detailed level. The first-person weapon and the sky are unchanged.
Android draws 480 lines, so there native is stock.
Checked: the same b30 view at 854x480 and 2400x1315 gave the same sizes in
stock; at 1080p native measures 2.25 times as much and moves the corpses and
weapons up a level, highest puts everything drawn at its top level. The
level chosen is used only to draw (render_model's callers are the objects',
the first-person weapon's and the sky's drawing), so nothing a network game
agrees on changes; stock multiplies by exactly 1. Not checked with ssaa2x or
on a 4K display, nor built for Android or the web.
The Xbox game never asks for anisotropic filtering: every sampler is linear
or point with an anisotropy of 1, so floors, walls and terrain seen at a
grazing angle blur into their small mip levels. display.anisotropic_filtering
(HALO_ANISOTROPIC_FILTERING) samples the map's mipmapped, linearly filtered
textures (base, detail, bump and environment maps) along the angle, at most
2, 4, 8 or 16 times and at most the GPU's limit; 0 filters as the Xbox did.
Textures the game renders itself are left alone: at 16x the water's ripple
map turned the far ocean into noise. Lightmaps have no mip levels and are
unchanged.
Checked: a10's cryo bay floor and b30's beach, off, 4x and 16x on the same
frozen frame (grates and sand stay sharp, the water is unchanged); about
+0.08 ms a frame on a10 at 1080p; tools/harness passes. Android and the web
apply it only where GL_EXT_texture_filter_anisotropic exists; not built here.
Push to talk becomes an action of the BUTTONS page (vr.button_talk),
on an eighth button there, the free hand's grip (its default): it can
trade places with any other action (the move stick's click, say, so
that a player holding a weapon in both hands can talk).
The free hand's grip as a button is pressed once held 0.15 s, at the
threshold halo_vr_two_hand takes the foregrip at, and not at all while
it holds the weapon's foregrip or the Warthog's wheel: taken in that
time, the press is theirs until the grip opens. Before, the runtime's
grip button (which closes before the foregrip's threshold) could open
the microphone for a frame or two as the hand took the weapon.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
android_asm_convert.py renamed identifiers in every directive's operands,
string data included: a string starting with "_" was taken for a quoted
symbol and lost the "_", and one with \" in it was split wrongly, so a
"_" starting a word in it was dropped too. 26 of the guest's objects had
strings or tables cut short. Among them Opus's pulse cache (cache_bits50,
one byte short from entry 338): its encoder picked more pulses than its
tables hold for some bands and crashed (the Steam Frame's crashes in
online games, with push to talk). Also musl's wide character tables,
map sharing's hashed names ("_%s"), the "_lan_" menu check, libtiff's
and the sound code's tables.
.ascii, .asciz and .string are now passed on unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client that joins a lobby on a Custom Edition map it does not have
downloads it by map sharing. When the host started the game meanwhile, the
host held the client's players out, as it should, but the client, whose
download began in the lobby, did not know the game had started and asked
for its player again; the host took the request as a late joiner's and
started it at once. The client then loaded a map it did not have yet:
cache_files_precache_map_begin's damaged disc error, which stops the game
for good. Stock build-201 does the same with its own host.
- The host (network_server_manager.c): a player asked for by a machine
still downloading the map waits with those held at the start
(network_game_server_hold_requested_player), and joins with them, the
machine started as a late joiner, once its download is done
(network_game_server_release_map_fetchers).
- The client (network_client_manager.c): a start that comes while the
map is still being downloaded, from a host of an earlier build, is put
off until the map is there (network_game_client_begin_game,
network_game_client_idle_pregame), and dropped if the game it was for is
over by then (another map, or another game).
- NETCODE.md's "Map sharing" says what happens when a host starts during a
download (it said the host waited, which it has not since version 28).
Nothing sent changes: no network version.
Checked: the Linux CI steps, the web build and its tests, and the Windows
release build. End to end on Linux in Docker, the host starting the lobby
game at once while the client downloads (the case that stopped the game):
this build both ends (the host held the player and started it at game tick
296 once the map was there); build-201 hosting this build's client (the
start put off until the map was there); this build hosting build-201's
client (held, then played). Each client played the game.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the "pc" menu style, a LAN game whose players were all on the host's
computer could not start a local splitscreen game. This patch fixes that
check.
START NOW only starts a countdown for a game that takes no remote connections, has two machines, or
is "alone" (server_alone). server_alone only passed if there was literally one player, making splitscreen play impossible.
Note the "xbox" menu style did not have this bug since its splitscreen
didn't accept remote connections at all.
server_alone becomes server_players_on_one_machine: TRUE if there is at
least one player and every player is on the same machine.
One player still qualifies. Machines that have joined without a player are not
counted, so a lone host's countdown is not stopped when one connects (the
per-tick server_ok_to_countdown check).
The countdown is fully controlled by the host machine, so there is no
change to the network protocol.
There's an existing test failure on test_elevator_prediction which also
exists on main, ignore.
Video Setup gets a third category, EXTRA EFFECTS, beside Graphics and FOV
and Viewmodels: a screen for the visual modifiers the stock game does not
have, so Graphics keeps to how the game is drawn. Shield Glow and Glow
Intensity move to it from Graphics; the settings themselves, their
defaults and their names are unchanged.
The screens were written again with tools/port_settings.py; only Video
Setup, Graphics and the new screen changed, and menus.json lists the new
screen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
On the developer's Quest 3 the picture doubles when turning with every
build back to 2026-10-06's, at 90 and 72 Hz, even when the compositor
reports no stale frames; that Quest runs Horizon OS from the Public Test
Channel. "medium" is smooth there, so it is the default until a Quest
outside PTC has tried "high" (roadmap). An unknown vr.graphics is
"medium" too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the Quest 3 the shader worker took away the long frames, but a 15-bot
game on Blood Gulch still had seconds at a time of 9-13 ms of GPU a frame
(GPU% 0.91-0.94, 4-37 stale frames a second, the picture doubled), between
spells of 5-7 ms; "medium" held 90 Hz.
- vr.graphics "auto", the new default (the VR options' Graphics row has
Auto): "high", less what the GPU's time asks to leave out. host_xr.c
reads XR_META_performance_metrics' app/gpu_frametime each frame (the
metrics are now on wherever the runtime has them; logged only with
vr.log_performance), handed to the guest by host_xr_gpu_time.
- vr_graphics.c: past four fifths of the frame (8.9 ms at 90 Hz, scaled
with the rate) for three quarters of a quarter second's frames, the next
steps are left out, as many as their known costs say the excess takes:
the shield's glow, object shadows, reflections, specular light, lens
flares, bump maps, the rest of "low". Steps that leave out nothing
drawn (the glow without display.shield_glow) are passed over. A step's
cost is learnt from the GPU time before it and the second after it
settled. An effect comes back once the GPU time plus its cost stays
under seven tenths of the frame for 3 s; an effect that had to go again
within 10 s of coming back makes the wait twice as long, up to a minute,
back to 3 s after a minute without leaving anything out. Each change is
logged with the GPU time.
- Where the headset does not tell the GPU's time (SteamVR: the Steam
Frame) "auto" draws everything, as "high" did.
- shield_glow.c draws no glow while "auto" leaves it out.
- debug.vr_graphics_measure leaves each effect out in turn (1.5 s on, 1.5 s
off) and logs its cost by the GPU time, with means every round.
Checked: the APK builds, android_abi_check finds nothing, the desktop and
web paths compile; a host simulation of vr_graphics.c with a lagging
synthetic GPU time (heavy spells of 12 ms, effects of 0.3-2 ms) left out
two or three steps within 0.6 s of a spell and brought them back 3 s
after it, longer after a second spell; the measure mode recovers the
simulated costs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A menu screen's first frame decoded its PNG pictures on the game's thread,
about 15 ms each ("7 menu pictures in 108 ms"): in VR a frame shown late.
Now a picture not yet decoded is asked of a decoding thread and drawn
transparent for the frames that takes; the game's thread only uploads the
decoded texels. Forgotten art's results are dropped by generation.
Decoding them all ahead was not done: 165 pictures, about 350 MB decoded
with their mipmaps.
Checked: the APK builds; desktop and web keep decoding as before (the
thread is the app's only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shader cache took most programs from their binaries the second time a
map was played, but the user still saw doubled pictures in VR: 29 new
programs in one frame 22 s into a match (794 ms), single ones at 18-64 ms,
and draws of 254 ms and 74 ms with nothing compiled at all (most likely
the driver making a program's variants at its first draw).
- port/android/host/host_gl_worker.c: a thread with an EGL context that
shares the game's (surfaceless, or on a 1x1 pbuffer) makes the programs
asked for: from the file's binary, or compiled and linked from the
sources (its binary handed back for the file). It sets the samplers'
units, draws once with each program into a small target of the eyes'
kinds (RGBA8 with depth and stencil, plain and two-layer multiview) and
puts a fence after it. The game uses a program only once the fence has
passed (glClientWaitSync, never waiting). Urgent requests (a draw needs
the program) go before the rest and can hurry a queued one along.
- d3d8_gl.c: a draw whose program is being made is left out for the frames
it takes (draws_skipped_waiting), instead of the frame waiting; a lit
program's wait no longer marks the lighting failed. A program's uniform
setup is its own function, run when the program arrives.
- xgpu_program_cache.c: jobs by key in place of the ready table. As a map
loads, its own list is asked of the worker, then every map's programs
(shader_cache/all.txt, a union kept as any map draws), eight a frame, so
a map first played or a weapon first fired mostly finds its programs
made. The worker's compiled programs' binaries are written two a frame.
Without the worker (display.shader_worker false, or no shared context)
the map's programs are made on the game's thread as before.
- debug.shader_warm_up: "worker" (default), "load" (the map's programs
waited for, up to 3 s, and drawn with on the game's thread as the map
loads, for comparing) or "off".
- vr.log_performance: draws are timed (the CPU's counter); a long frame's
line gives the draw calls' time, the slowest draw with its vertex
program and program key, and the draws left out waiting; the 5 s report
counts the draws left out.
Checked: the APK builds, android_abi_check finds nothing, the desktop and
web paths compile; a host harness with a mock worker (first run, second,
a first-played map, driver change, no worker, "load"); on the emulator
(SwiftShader, which refuses kept binaries, so all compiled there) the
worker runs on a pbuffer, the menus' load asked the other maps' 63
programs, Blood Gulch found 69 of 73 made, and 4-bot games ran with the
worker, with "load" and without the worker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the Quest 3 (graphics "high", 90 Hz, multiview, a 15-bot game on Blood
Gulch) the app's GPU time was 5.5-6.4 ms with foveation off, low, medium
and high alike: the GPU is not what makes the frames late. "high" also
drew a grey rectangle below the player. vr.foveation is now "off" by
default with "low" and "medium" left to try; an unknown value is off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each Custom Edition (English, French, German, Italian, Spanish, Japanese,
Korean, Chinese) has the PC version's menu string lists in its language
in loc.map, by the tags' names. The menus showed the English of
strings.xml whatever edition the maps came from. Now each <strings> names
its PC tag (map=) and each <string> that is the PC version's its index
there; menu_tags.c takes those strings from the loc.map in custom_maps
(or the Custom Edition install's maps folder), read through a
bounds-checked reader in custom_edition_cache.c. The strings the port
adds or rewrites have no index and stay English. A setting,
display.menu_language ("maps", "english"), keeps English for whoever
wants it.
strings.xml is written by ce_menus.py as before; port_settings.py's
OVERRIDE_ORIGINALS maps the lists the port rewrites to the PC strings
they keep. The text without the new attributes is unchanged.
The text is drawn by text_hires.c, as the English is. The pictures with
words (titles, the main menu's items) are still English. CJK editions'
characters are not in Overpass and fall back to the map's font.
Checked: the Windows build has no warnings. With an Italian Custom
Edition's loc.map, all 1164 strings with an index come from it, and the
Multiplayer, Settings and Item Options screens show Italian, accents
included (window hidden, debug.menu_open). With "english" the same
screens are as before (without a loc.map the code takes the same path,
not run). test_linux_port.py's menu tests pass. Android and the web were
not built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The emulator's GL (ANGLE under gfxstream) lists ANGLE's binary format but
hands out binaries of format 1, so every kept binary was passed over before
the driver saw it. The driver checks a binary itself (it made it, the same
driver by the hash); the first three it refuses are logged with the format,
size, GL error and info log, and the cache's start names the driver's
format.
Checked on the emulator with SwiftShader: programs kept (4 MB for the menus
and Blood Gulch), the binaries refused there (gfxstream's do not outlive
the process), so the map's programs were compiled from the kept sources as
it loaded, up to the two seconds, the rest as drawn; a 4-bot game ran.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Quest's driver compiles and links a shader program the first time it
is drawn with, 10 to 30 ms each, which the frame waits for: a long frame
at a map's start (14 programs, 256 ms) and near its end (61 in 780 ms),
seen in VR as stale frames.
- port/linux/src/xgpu_program_cache.c: a linked program is written to
shader_cache in the data folder, named by its sources' hash, with its
binary (glGetProgramBinary), the driver that made it (GL_RENDERER and
GL_VERSION) and both sources. Asked for again it is loaded with
glProgramBinary when the driver, the sizes, the checksum and the sources
agree; otherwise compiled as before and written again. A refused binary
only falls back to compiling.
- d3d8_gl.c makes its shaders with their sources and compiles them only
when a program of them must be linked (shader_make, program_make), so a
program from the cache costs no compile. Each program entry keeps its
cache key and lists itself once per map.
- Each map keeps the programs it drew with (shader_cache/maps/<map>.txt,
written every 20 s while it grows and as the map goes). As a map loads
(game_initialize_for_new_map, a port: hook) its programs are loaded from
their binaries; after a driver update compiled again from the kept
sources, in the background with GL_KHR_parallel_shader_compile where the
driver has it (polled each frame, binaries written two a frame), else
for at most two seconds of the load.
- display.shader_cache (true) turns it off; the folder is emptied past
three quarters of 192 MB or 12000 files at the start, and may be deleted.
- VR's long frame line now also says how many programs came from the cache
and what textures were uploaded (and decoded from DXT: the Quest has no
S3TC), menu pictures decoded and render targets made, and in how long,
for the menus' long frames that compile nothing.
Checked: the APK builds; the desktop and web paths compile; a host harness
with a mock driver went through the first run, a second run (all loaded),
a driver change (compiled from the kept sources, with and without
background compiling), a corrupt and a truncated file (compiled again) and
a missing list. On the emulator (no binary formats) the cache logs itself
off and a 4-bot game on Blood Gulch runs as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Quest 3 at 90 Hz with graphics "high" spends 5-9 ms of GPU a frame
on the eyes and shows stale frames (ASW, doubled pictures as the head
turns); "medium" holds, so the frame is short of GPU time. The eyes are
now drawn at full detail about each lens's centre and coarser towards
their edges with GL_QCOM_texture_foveated (the Quest's Adreno 740), where
the lenses blur them anyway.
- vr.foveation "off", "low", "medium" (the default) or "high", a row
on the VR options' MORE page (dim where the GPU has no foveation, as the
Steam Frame's zink); read as it changes.
- The focal point of each eye is its lens centre, tangent 0, in the
picture's coordinates: off the middle, the Quest's views being
asymmetric. Full detail within sqrt(1 + area) / gain of it (0.69, 0.58,
0.43 of the half width), down to a least density of 1/2, 1/4, 1/8.
- Only the eyes' targets: the multiview layers, which nothing else draws
into; one eye at a time, the back buffer only from the eye's camera to
its copy into the headset's image, so the HUD, menus, scope, crosshair
and names drawn into it after stay at full detail. A texture is first
foveated when the setting is not off (it cannot be undone); "off"
afterwards sets the gain to 0, full detail everywhere.
- The GL ES log line names foveation, GL_KHR_parallel_shader_compile and
the program binary formats the driver has; the first foveated target
logs the extension's features and focal points, vr.c the level and the
lens centres.
Checked: the APK builds; the desktop and web paths compile (no foveation
code outside HALO_ANDROID). Not tried on a headset.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
XR_META_performance_metrics was asked for its counters with room for 16,
and the Quest 3 has more, so the call failed and the metrics stayed off
("xr: performance metrics off (0 counters)"). The counters are now
enumerated with the two-call idiom (up to 48 kept), sampled every third
frame and logged every five seconds as mean/most, named by their last two
path parts (app/gpu_frametime, compositor/dropped_frame_count), so the
app's GPU time and the stale frames show beside the game's own report.
They are enabled only with vr.log_performance (host_main.c passes it on as
HALO_XR_LOG_PERFORMANCE), as is the copies' timing that came with them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's base vertex extension (#273) changed the same lines of gl.h and
gl_initialize as this branch's half float render targets. Both are kept:
the extension's optional entry points and base vertex detection, then the
half float check, and the log line names both.
Checked: the tree is the same as this branch rebased on main; the Windows
release build has no warnings; a 20-second bloodgulch run with HDR and GL
debugging had no GL errors; the menu generator test passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge upstream/main and preserve its map-sharing includes and updates.
Use message aim-assist policy 87, clear of upstream map-sharing IDs 83-86. Raise network version 28 to 29 because this feature adds a required wire message; record the new format and update its documentation.
Tests compile production message IDs and reject collisions. 293 CPU/menu regressions and three menu/wire-format checks pass.
A frame two of the headset's frames long logs its input, ticks, camera,
draw and present times and the shaders compiled or linked in it, so the
hitches seen as stale frames can be told apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nv2a_psh.c translated the DOT_ZW texture stage to a dot product and dropped the depth the stage replaces the fragment's depth with. Every z-sprite particle (a bullet impact's puff, for one) was depth tested as its flat card, so the surface it stands on cut it in half along a straight line through the hit point.
The game's effect shader (rasterizer_xbox_transparent_geometry.c, the z-sprite branch) feeds stages 2 and 3 so that their quotient is the depth of a point up to the sprite's radius nearer than its card, shaped by the second map, in the depth buffer's units (16777215 for the 24-bit buffer). The translated shader now writes that quotient over 16777215 to gl_FragDepth. Only shaders with a DOT_ZW stage change their text.
Checked on a macOS fork of this translator (OpenGL and Metal, a30, assault rifle fired at the lifeboat's floor): puffs are whole, and decals and hit positions are unchanged. Not built or run on this tree's Linux, Windows, Android or web builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KLToxkJE32PPuVNUQEe9a
Offer Per Player, Static Multiplier and None for authored campaign weapons, grenades, health kits, overshields and camouflage. Share amount rows with extra enemies, cap pickups at 8x, retain ammunition and checkpoint counts, and only place copies on clear nearby ground. Use ordinary host-created replicated objects without changing the wire protocol.
(cherry picked from commit 6f3a75507690a03382759f0f1b70c13e38abc851)
Scale extra growth by the integer ratio 2/3 in both modes before rounding down. Default 50% Per Player now yields the first extra vehicle with four players. Update setting descriptions and count expectations. All 25 focused vehicle/menu tests and the Windows build pass.
(cherry picked from commit c1881dd5e73fa49b45c3b29b7eb74b9ba9e08b7c)
Apply a flat 0.5 modifier to additional vehicles in per-player and static modes. Update generated setting descriptions and documentation. Verified all 25 focused vehicle/menu tests, including counts from 1 to 128 players, lifecycle checks and a negative control for the modifier. The combined Windows build and isolated startup test pass.
(cherry picked from commit c3a54123390e56603b4001fbba2ae94f450981c6)
Offer Per Player, Static Multiplier and None. Register authored campaign vehicles, anchor delivered cargo after ground contact, and replace player-driven vehicles after both bounding spheres clear the home with a 15 percent buffer. Keep host authority, pad collision checks, allocation limits and checkpoint budgets. Replicate ordinary vehicles without changing the wire protocol.
(cherry picked from commit 75abf60ed676b0b5b8a20de4f82068c9a3ba281f)
With display.overshield_effect (Video Setup > Graphics > Overshield, off
by default: the game's own), this machine draws the overshield's flare
rising up the body from the feet as it charges (2 seconds; 3 from a
shield that was down), then lit, whole, for as long as it lasts, its
energy at 30% of its speed, in its own color (the powerup's, 253, 78,
116) whatever shield color its player chose; its glow rises and stays
with it, and with display.viewmodel_shield it comes up the first-person
arms. A hit on it plays what the projectile plays on an Elite's shield,
where that only shows and sounds. The rise is a height fade in the vertex
shader (d3d8_gl_set_height_fade).
Reworked after review: the setting the earlier version removed is back,
off by default, so the stock overshield is unchanged unless a player
turns it on. Only drawing and sounds change; no network change (the wire
format check passes). Checked: the changed units compile for Linux; menus
regenerated (only Graphics changed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
Conflicts:
- rasterizer.h: upstream raised the transparent geometry groups to 2048
as we had (halo_port_capacity.h); theirs.
- d3d8_gl.c: the ES capabilities' log with upstream's base vertex and our
multiview.
- p2p.c, p2p_internal.h, p2p_signal.c: upstream's several joins and
reconnect, with our join codes' state and the codes' and public list's
updates each pass.
- network_server_manager.c: a machine downloading the map is not refused,
and the bots' room logic stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The record read only network_*.c, so the map sharing messages in
map_share.c went unrecorded, and it took each struct sent whole by the text
of its declaration, so a change to a constant that sizes one of its arrays
(MAXIMUM_ENTRIES_PER_MESSAGE, DISTRIBUTED_INPUT_HISTORY and the others)
changed the message without failing the check. It now reads map_share*.c
too, and records each such constant by its definition, following the names
that uses, and the DISTRIBUTED_ sizes in the enum of no name (a unit state's
and a relayed action's least and largest sizes). A size with no definition
found fails the check rather than passing it.
Checked: the items only grow (35 added, none changed), so version 28 is
recorded again; changing MAXIMUM_ENTRIES_PER_MESSAGE in network_actors.c
fails the check as a change.
Cutscene skipping in co-op restores a checkpoint. The legacy reset assumed player queue slot zero existed and dereferenced NULL when it was empty. Iterate the rebuilt live queues and restore each update counter, including remote players. Empty arrays are safe. The old reset fails six regression cases; all 278 standalone CPU tests pass with the fix. Message formats and network version are unchanged.
The testing branch is at version 28, whose changes (holding back the
players of a machine still downloading the map, the multiplayer scripted
BSP, lights past the Xbox limits, readying a second weapon) change no
message, kind, bit or struct the record holds; only its version changes.
Network version 28: the host now holds a downloading machine's players out
of the game, and keeps a multiplayer map's first scripted BSP. Dynamic lights
and the flashlight stay on past the Xbox limits, and a second weapon picked
up while firing is readied. Machines of version 27 would disagree on these.
- A large co-op game's respawn fallback no longer picks a teammate who is in
the air, outside the BSP, or riding an AI vehicle.
- A machine still downloading the map times out when it goes silent.
- Held players join through the late-join queue, so they get the same checks.
A machine with no held players is started instead of being refused.
- The co-op teleport uses world positions, which are right for seated players.
- Overshield drain no longer cuts off a hit's flash, and hits over 0.02 flash.
- The menu XML is the generator's (Custom Edition's layout) again.
- port: markers on two new declarations.
Checked: ninja linux, android and web build; tools/harness,
test_touch_menu and test_web_build pass.
In large games, bodies on clients were drawn twisted, hanging in the air,
piled up or inside walls. There were two causes, both in how a client
takes the host's object states.
A body that comes to rest lies limp (bipeds.c): its nodes stay where they
are in the world and are no longer recomputed from the object. The game
stops this when something pushes the body (biped_accelerate). On a client,
that push comes from the damage aftermath, which is sent only to the
machines that see the body's player (network_damage.c). It never comes for
older bodies (a player's dead_unit_index is their latest) or for world
damage. When the host then moves the body, the client's copy moves with
the host's corrections and its own physics, but its pose stays where it
came to rest. A client now stops a body lying limp before it puts the body
somewhere and whenever the host says it is moving. units.c makes it lie
limp again, every tick, once it is at rest on the ground.
A client also takes the host's word that a body is at rest while its own
copy is still in the air, a little behind. Rest stops its physics, and
only its physics clears airborne, so the body kept the pose of its fall
(its root node about 0.36 above the ground) and never lay limp. A client
now clears airborne on a body the host has at rest: the host's is on the
ground, because it comes to rest only when it is not airborne.
Nothing sent changes, so the network version stays the same.
Checked:
- New harness test limp_body_moves takes the code from network_objects.c
and bipeds.c. Its cases are pushed, landed, moved, blended and living.
Each of its 5 negative controls (each line of the fix taken out) fails
its case.
- The whole harness, run natively on Windows: 234 passed, and the
failures are the environmental ones main also has on this machine.
- The Windows build has no warnings.
- local system link games on one machine (debug.network_test, bots and a
kill every 2 seconds), with temporary instrumentation: each machine logs
its bodies every second, and the host sets off a frag grenade's
explosion under up to 4 resting bodies every 5 seconds. Result: bodies
at rest drawn more than 0.15 from where the host draws them:
4 machines, 120 s: 195 of 573 pairs before, 0 of 1221 after;
32 machines, 150 s: 19479 of 96439 pairs before (20%), 157 of 71237
after (0.2%), counting only pairs where the host and the client
agree on the body's position. The rest are bodies still in their
dying animation, a few ticks apart, at the same rate as on the
host.
Copies at rest but left airborne: 195 samples before and 0 after on 4
machines, 10718 before and 0 after on 32.
- Not checked here: the Android and web builds (they don't build on this
Windows machine), Linux.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rasterizer_debug_model_vertices declares 2048 debug vertices of 64 bytes
on the stack. render_model calls it for each part it draws, and the
release build inlines it there, so render_model's frame was 136,304
bytes, and the Windows build probed it a page at a time (_chkstk, 33
pages) for every model drawn, although the debug display is off. Its
vertices are static now: only the vertices below debug_vertex_count are
read, each written when it is added, and the function runs on the
render thread only, so nothing it draws changes.
Measured on Windows (7800X3D, RTX 4080, release build, vsync off), b30
co-op with 32 times the enemies and a bot, 75 s a run, five runs of each
build alternated: render_model's frame is 5,360 bytes (the PDB's
S_FRAMEPROC); _chkstk fell from 2.0% to 0.5% of the game thread's
samples; fps over windows with 2000 draws a frame or more, origin/main
128, 141, 139, 134, 107 (median 134), this change 140, 139, 144, 141,
132 (median 140). Linux, Android and the web build do not probe the
stack, so the change only shortens their frame. They were not built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md asks for HALO_PORT_NETWORK_VERSION to be raised with any change
to what the machines send each other; nothing checked it, and #95, #99 and
and 18 caught up after them).
tools/network_format.py takes the wire format from the sources: the game's
message numbers, sizes and fields, the distributed netcode's message kinds
and flag bits, its DISTRIBUTED_ constants and the structs it sends whole, as
hashes. port/linux/network_format.txt records them for the version, and
test_linux_port.py fails when the sources differ: an item changed or removed
asks for the version to be raised (--record refuses it until then), an item
added (a new kind or bit, which older builds may ignore) only to be recorded.
It also fails when NETCODE.md says nothing of the version.
Checked by recording each merge's parent and checking the merge: #95, #99
and #106 fail as changed structs; #91, votes to kick, voice chat and the
integrated light bits as additions; #101, which raised the version, passes
once recorded. A comment, or a struct the netcode keeps to itself, changes
nothing.
The versions' list stopped at 21. 22 to 25 as their commits give them:
Custom Edition maps named custom_maps\<name> (4e8ed2f1), their blocks kept
past the Xbox tools' limits (34e2d4fd), the gametype's PC vehicle set
(11117d7c) and a unit's integrated light in its object state (6b55f5cc).
Every transparent thing a frame draws takes a group: each transparent
part of a model, each widget (light volumes, antennas, flags, glows),
each batch of dynamic geometry (particles, contrails), each transparent
surface of the structure. The Xbox's 384 were a limit the native builds'
larger pools of objects, widgets, effects and particles could pass: a
frame past it was drawn without the rest, and debug.txt said "too many
transparent geometry groups". A part refused a group has no sort record
(models.c checks the group's index), so nothing else changes.
Checked on Windows in b30 co-op with eight times the enemies and a bot,
75 s a run: origin/main overflowed in 3 runs of 4; a build counting the
groups asked for showed up to 464 in a frame, while the dynamic triangle
and vertex buffers peaked at 123 and 328 of their 1024 and the dynamic
triangles at 10272 of 49152, so they are left as they are. With this
change, the same count showed 391 in a frame and no overflow; two more
runs had none. The pool is 2048 * 160 bytes, malloc'd; the model order
pass keeps 14 bytes a group on the stack (28 KB). Harness: the
transparent_plane_order tests pass at the new size (natively on Windows;
the other 44 failures there are the same on origin/main, POSIX headers).
Android and web builds not built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every draw compares its pixel shader key (256 bytes) with the recent and
cached keys, and its uniforms' inputs (77 words) with the last draw's.
The Windows build's memcmp (vcruntime) compares them a byte or a word at
a time and was the game thread's largest single function: 7.9% of its
samples in a b30 co-op game with 32 times the enemies (about 3900 draws
a frame), 5.3% from fragment_shader_get and 2.2% from the uniforms.
words_equal has no early exit, so that clang compares several words at
once (four 512-bit compares a key with -march=native).
Measured on Windows (7800X3D, RTX 4080, release build, vsync off), five
runs of each build alternated, 75 s each, b30 co-op x32 with a bot:
fps over windows with 2000 draws a frame or more, origin/main 123, 139,
135, 123, 122 (median 123); this change 142, 147, 138, 149, 131 (median
142). A sampled run of this change shows memcmp gone and words_equal at
1.2%. The keys are memset before they are filled and have no padding,
so word equality is byte equality. Android and web builds not built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wait only counted teammates the map called grounded, so jumping, riding, or standing on a beach left it with nobody to use. A late joiner after a checkpoint uses the same wait when nobody is safe to spawn beside.
An asset-free test (tools/harness) of the real cinematic_render, taken
from cinematics.c with its structures, over a fake scenario with one
title for the whole game, as Extinction's: it is drawn in the game, not
while a menu is up and again once the menu is gone, and its time goes on
under a menu, so it ends when it would have. Two negative controls (the
title drawn over a menu, its time stopped under one) each fail their
case.
Checked: in archlinux:latest with CI's packages (clang 23.1.1,
lib32-glibc), python -m pytest -q tools/harness passes (271); the new
cases fail on origin/main's cinematics.c ("the title was drawn over a
menu in frame 0").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scenario's titles (cutscene titles, their text from the scenario's
ingame_help_text) are drawn with the screen's overlays, which come after
the players' windows and so after their menus. Some Custom Edition maps
show their name with one for the whole game: Extinction's "Extinction",
at the bottom right, was drawn over the pause menu and over the profile
screen's CANCEL button. cinematic_render already leaves out the letterbox
while a menu is up; it now leaves out the titles too. Their time goes on
as before, so a title still fades out when it would have.
Checked: the Windows build (clang 23.1.3, full LTO) without warnings;
Extinction from the Italian Custom Edition in a hidden window, START
pressed by a local test input after 14 seconds: the title is drawn in
the game and not over the pause menu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The high-res text sizes Overpass by the font tag's capitals and centres
each glyph on the tag's character width, and Overpass was chosen for the
Xbox maps' widths. A Custom Edition loc.map's fonts are narrower: the
Italian one's large_ui and small_ui are the same font, its capitals 10
units tall and its letters 87% as wide as Overpass's at that height
(against 95 to 98% for the Xbox maps' interstate, large_ui and small_ui),
so the menus' and HUD's letters ran into each other. Each font is now
narrowed until its letters A to Z and a to z are, summed, as wide as the
tag's; the Xbox maps' fonts are narrowed by 2 to 5 hundredths.
The fonts were also remembered by the tag's name, and the Xbox maps and a
loc.map have tags of the same names with other measures, and by the font
tag's address, which the next map's tags may reuse: they are remembered
by their measures too, and forgotten with the map's tags.
Checked: the fonts' widths measured from the Italian loc.map and the Xbox
ui.map against Overpass's advances; the Windows build (clang 23.1.3, full
LTO) without warnings; Extinction from the Italian Custom Edition, whose
large_ui is now drawn 87% wide, its letters apart, and the main menu's
small_ui 98% wide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hosts send players who join without the game's Custom Edition map the map
and the resource maps it needs; the other players help, torrent-style.
Network version 27: new map sharing messages, and players of an internet
game now reach each other.
The web build reads the desktop settings, so display.hdr_output would
ask a browser's canvas for floating-point pixels it does not have. The
web build now keeps the window 8-bit, as Android does; the renderer
already keeps its scRGB path off under HALO_GLES.
With NVIDIA's driver on Windows, an OpenGL window with floating-point
pixels in fullscreen or borderless turns black, then oversaturated,
unless the NVIDIA Control Panel's "Vulkan/OpenGL present method" is
"Prefer layered on DXGI Swapchain". The README says so.
Checked: the Windows build; display.hdr_output on an HDR OLED display
with that setting. The web build was not built here (no Emscripten).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
display.hdr and display.hdr_output could be set only in config.toml or
the environment. Two rows on the Graphics screen, after Glow Intensity,
set them as the screen's other rows set theirs; HDR DISPLAY is shown on
the desktop platforms only, as the setting applies there only. The
category's help names HDR.
Checked: the two generated screens are the only files that change, and
tools/test_web_build.py's check that the menus match their generator
passes; the Windows build opens the screen (debug.menu_open) with both
rows above the help.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The settings table gains display.hdr, display.hdr_exposure,
display.hdr_tone_mapping and display.hdr_output; "What operates" gains
"HDR", which says what the back buffer holds, where the tone mapping
runs and what the HUD and the menus keep; the game source changes name
the tone mapping in render/render.c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The back buffer and its multisampled renderbuffer become GL_RGBA16F
(GL_HALF_FLOAT on Android) when display.hdr is on, so blended light past
white is kept instead of clipped. A tone mapping pass over each window's
3D view, before the anti-aliasing and the HUD, brings it back into range:
neutral (identity below 0.8, a hue-preserving shoulder above), ACES
(Narkowicz) or extended Reinhard, after display.hdr_exposure. It writes
colour only, leaving the game's destination alpha alone.
The display blit becomes a draw that clamps to 1, or, with
display.hdr_output on desktop, writes scRGB to a floating-point window
up to the display's HDR headroom (SDL's window HDR properties); a driver
without such a window falls back to 8 bits. Android turns HDR on only
with ES 3.2 or GL_EXT_color_buffer_(half_)float and caps MSAA at the
samples RGBA16F allows. Settings are read between frames; toggling
display.hdr recreates the back buffer's storage.
Checked: the Windows build (clang, no warnings); bloodgulch and a30 with
MSAA, FXAA and SMAA, toggling each of the settings while playing with
HALO_GL_DEBUG, without GL errors; the frame rate unchanged, the tone
mapping pass 0.04 ms at 1080p; neutral leaves the picture as without
HDR below 0.8; display.hdr_output on an HDR OLED display. The Android
guest's sources pass clang -fsyntax-only for arm64_32; neither Android
nor the web build was run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NVIDIA's EGL draws into a Wayland window through egl-wayland (or
egl-wayland2), which distributions ship 64-bit only: Arch's
lib32-nvidia-utils has libEGL_nvidia and libGLX_nvidia, and no package has
a 32-bit libnvidia-egl-wayland. SDL 3.4 prefers Wayland where the
compositor has fifo-v1 (KDE Plasma 6, GNOME), and its OpenGL there is EGL,
so the 32-bit game fell back to Mesa's llvmpipe: drawn on the processor,
a flickering window that never finished loading. On X11 (XWayland) SDL's
OpenGL is GLX, which the 32-bit driver has.
With NVIDIA's module loaded (/sys/module/nvidia), a Wayland session, and
neither libnvidia-egl-wayland.so.1 nor libnvidia-egl-wayland2.so.1
loadable by this process (the names NVIDIA's EGL loads them by,
/usr/share/egl/egl_external_platform.d), SDL now tries x11 before
wayland, and the log says so. SDL_VIDEO_DRIVER, when set, still decides.
OpenGL on llvmpipe or softpipe is also logged as drawn on the processor,
and port/linux/README.md has "NVIDIA on Wayland".
Only the 32-bit Linux build: the arm64 build's host is 64-bit.
The settings table has display.viewmodel_shield, first_person_legs and the
shield's glow and color after the game settings, so the defaults the game
wrote had a second [display] after [game]. TOML refuses a table defined
twice: every start since logged "table defined more than once; using the
defaults", and no change to the file took effect. Only a file written
fresh since those settings came in has it; an older one gets them added
to its own [display] (config_add_missing).
The defaults now put each section once, with all of its settings, in
whatever order the table has them. A file that has a section twice gets
the later parts joined to the first when it is read, and is written back
that way once it parses.
tools/test_linux_port.py builds port_config.c on its own and checks both.
The vertex and index mirror draws with glDrawElementsBaseVertex, which the
renderer enabled only on OpenGL ES 3.2. ANGLE on Metal and many Android
drivers report ES 3.0 or 3.1 and offer the function as
GL_EXT_draw_elements_base_vertex or GL_OES_draw_elements_base_vertex, so
they rebased and uploaded each index array at every draw.
base_vertex is now on for ES 3.2, or when the context lists either extension
and its entry point loads; glDrawElementsBaseVertex then points at
glDrawElementsBaseVertexEXT or glDrawElementsBaseVertexOES. A function that
cannot be loaded leaves the existing path. The capabilities log line says
which source is used. The two suffixed names are in a new optional list in
gl.h (never a load failure); android_gl_stubs.py generates guest stubs for
them, and the host resolves them like the other hostgl_ imports.
Affects Android and ANGLE-based Apple ports. The web build is unchanged: it
does not load the optional functions and WebGL 2 lists neither extension.
Checked: ninja android (guest and host) and ninja web build. On an iPad Pro
(M2, ANGLE on Metal, ES 3.0) through an iOS port that runs this Android
guest, the log reads "base vertex 1 (GL_EXT_draw_elements_base_vertex)",
Blood Gulch plays, the draw counts are unchanged and the vertex data
streamed each frame falls by about a third (e.g. 148 KB to 95 KB, 52 KB to
35 KB at the same spawn points). Not run on Android hardware.
When the process is suspended (a phone app in the background) or the
machine sleeps (a laptop), the p2p thread's wait lasts far longer than it
asked for. The NAT's mapping of the tunnel may have lapsed and the brokers'
TCP connections may be gone, but nothing notices until each one's silence
timeout, so joins and listings fail for a while after the game resumes.
When a wait lasts longer than its length plus PEER_TIMEOUT, the thread now
discards what the sockets reported, asks the STUN servers for the public
address again, and has every broker close and connect again at once
(p2p_signal_reconnect). A wait of normal length changes nothing.
Nothing sent over the network changes, so HALO_PORT_NETWORK_VERSION stays.
Checked: the Android guest and the web build compile with no new warnings.
The same change, together with an iOS host fix for the sockets iOS takes
from a suspended app, made server browser joins work on an iPad Pro after
about five minutes in the background (they failed until a relaunch
without both). Not run on Linux, Windows or Android.
WebGL on Metal (ANGLE, which Chrome and Safari use on a Mac) waits for the
GPU to finish everything queued before it writes a buffer that a queued
draw reads, unless its shadow buffers are on, and Chrome leaves them off.
The renderer wrote its stream and index buffers after each draw, some 2000
times a frame, so each frame waited for the GPU as many times. On an M1
Pro in Chrome the Silent Cartographer ran at 7 to 9 frames each second and
Blood Gulch at about 22, at any resolution (640x480 too): Chrome's GPU
process spent half its time in -[MTLCommandBuffer waitUntilCompleted]
(macOS sample), and --enable-angle-features=useShadowBuffersWhenAppropriate
alone tripled the rate.
Now, on the web, each draw's vertices and indices go into a buffer that no
queued draw reads: web_chunk_get takes one from pools of power-of-two sizes
(4 KB to 32 MB), one set for each frame of the ring, and a buffer is written
again only when its frame comes round again. A draw counter (draw_serial)
tells stream_reserve and index_upload when the current buffer may be read
by a draw. Android is unchanged.
Checked on an M1 Pro, Chrome 155, 3024x1626, uncapped, A/B against
e0b581b1: b30 6.1 to 170 frames each second, Blood Gulch 57 to 595; with
vsync, 60 throughout. Screenshots of Blood Gulch before and after are the
same. ninja web builds without warnings; tools/test_web_build.py and
port/web/tests/*.test.js pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fast setup and the network test's join opened ui.map's pregame screen by name, where ui_widget.c opens the port's lobby; a ui.map of only the menus' tags hosts and joins with both.
The merge left the pages job two if: keys, which made the workflow
invalid; it is off (if: false) as the web job is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- render_objects.c: upstream's first-person legs and our VR body share one
node_matrices (ours no longer shadowed it); upstream's legs never in VR
(first_person_legs_wanted: set back behind a flat view's eye, they would
stand behind the player; the VR body is vr.body "full").
- AndroidManifest.xml: upstream's ':game' process left out (the Steam
Frame's Lepton waits for the app's process to end; the headsets have no
Java heap over the Xbox window); HaloApplication kept, idle without it.
- xinput_sdl.c: the touch controls and the web port's, never in VR; the
controllers' rumble in VR before the phone's.
- d3d8_gl.c: multiview's uniforms and upstream's blend constant, both.
- build.yml: Android only; upstream's web and pages jobs off.
- README.md: ours; upstream's in docs/OPENCE_README.md.
- menu_tags.c: our VR pause-list fit dropped: upstream now spaces the rows
30 apart and moves the button hints below them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every pushed branch built (and mailed its failures); the branches are tried
on the headsets from local builds, and only main makes releases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
msvc_comdat.c defines __inline as weak on Windows, which a static function cannot be; the accessor is __inline__. Checked by compiling msvc_comdat.c for i686-pc-windows-msvc.
Let hosts block optional mouse magnetism without overwriting local preferences or altering controller, touch or bullet autoaim. Replicate a validated reliable policy on ready and change; raise the network version to 27. Validation: 9 focused CPU tests, Windows compilation, native two-peer policy changes and Player Options persistence/Cancel.
test_linux_port.py takes a stand-in machine through a join (join request,
settings request, player, precache, loaded, game update) and checks each
message's number against enum network_game_message_type and its size
against the fields network_messages.c decodes, so a change to a message
fails CI rather than every machine's join. It fails on the script before
the previous commit (a join request of 80 bytes where 112 are decoded).
Since the join request carries the joining machine's hardware id (32 hex
digits after the join token, for bans and kick votes), the host refuses
one without it ("server failed to decode a message_client_join_game_request
packet") and closes the connection, so tools/system_link_bots.py could not
join any machine. Each machine now sends its own number as its id, so the
host tells them apart.
Checked on build 177: 14 machines join a Blood Gulch game (16 players with
the host's and a client's), spawn and play it out; with the script before
this, every machine is refused.
The timer was leaving the named vehicle on the pad and creating another on top of it. The home now follows that named vehicle, and a pad that already has one does not get a second.
Video Setup > Graphics > Shield Glow, off by default; Glow Intensity:
DEFAULT or LIGHT SHOW (three times as far and as bright).
As a unit's shield flares (hit, charging, falling), it lights what is around
it, and itself, as a plasma bolt does: a Spartan's gold, an Elite's blue,
any unit's its shield shader's own edge color. The glow's strength is the
flare's, the intensity the shield plasma is drawn with (its intensity
source, an object function, to its exponent), so it rises and falls with
the flare seen on the body, and fades with active camouflage.
port/linux/game/shield_glow.c chooses up to 8 units within 25 world units of
the camera each frame. object_lights.c gains port glows: ordinary dynamic
lights attached to an object's node, kept alive, colored and sized each
frame by their maker (light_port_glow_set) and gone a moment after it stops,
drawn with a light definition the map already has (one without a lens flare
or a flashlight's flags, a plasma one where there is one): no new assets.
Only the drawing changes: nothing the game simulates, nothing sent between
machines. Checked by compiling the changed units.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
Adding Settings centred the last row onto the B and A prompts. The rows now use that list's pitch, and the box grows so the prompts stay under the last row.
Video Setup > FOV and Viewmodels > Shield Color: DEFAULT (the shield's own)
or one of the 18 multiplayer armor colors (player_profile.c's table: white,
black, red, blue, gray, yellow, green, pink, purple, cyan, cobalt, orange,
teal, sage, brown, tan, maroon, salmon), whatever the armor's color.
No new assets: the shield's own plasma shader is drawn, its perpendicular
and parallel colors replaced by the chosen color at their own brightness
(their brightest channel), so its noise, animation and intensity are the
shader's. A local player's unit is drawn with a copy of its change colors as
the modifier's colors (render_object_list); rasterizer_plasma_energy_draw
knows that copy again and replaces the shader's colors only for it. Plasma is
added to what is behind it, so black is drawn as a faint grey.
Only this machine's drawing of its own players' shields changes: others see
your shield as their own settings draw it, and nothing sent between machines
changes. Checked by compiling the changed units.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
Video Setup > FOV and Viewmodels > Arm Shields, off by default. The stock
game draws a unit's shield flare (its modifier shader, the cyborg's shield
plasma, as its shield is hit, charges and falls) on the body only; the
first-person arms never showed it. With Arm Shields on, the hands model is
drawn with the unit's own modifier shader and the unit's own function values
and change colors, as render_object_list draws the body: the same flare, on
the arms. The first-person weapon is drawn without it.
The flare is a first-person transparent group, so it is drawn in the
viewmodel's projection (display.viewmodel_fov) and lines up with the arms.
The setting is read in view_fov.c with the other viewmodel settings.
Only the drawing changes. Checked by compiling the changed units.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
Video Setup > FOV and Viewmodels > Legs, off by default. In first person the
player's own body, which the game animates as everyone else sees it but does
not draw for its own player, is drawn from the waist down: looking down,
your legs walk, run, strafe and crouch as the body's animation moves them.
How it is drawn (port/linux/game/first_person_legs.c):
- A copy of the body's pose as drawn: everything but the hips and legs (by
node name: pelvis, thigh, calf, foot, toe) folded into a lid over the
waist, the root kept.
- The body set back behind the eye, each frame, as far as keeps the waist's
open rim beyond the bottom plane of the view (the way Halo 2's fp_body
keeps its open top out of sight): looking ahead, 4 cm; straight down at
105 degrees, about 67 cm. The legs are seen as they swing forward.
- Drawn with the world's camera, so it keeps its place and size at any FOV;
not in mirrors, not in the shadow pass, without what it carries.
The waist's rim is found from the model's own triangles: object_mesh.c reads
an object's drawn mesh (its most detailed geometry, in the permutations it
shows) from the vertex and index buffers the game draws it from, as
powerup_render_bounds.c reads them; its cache is forgotten on a new map
(objects_initialize_for_new_map).
Only the drawing changes: the body, its hit boxes, markers, the camera and
everything sent between machines are the game's. Checked by compiling the
changed units; the fold and set-back were checked against a capture of the
cyborg's mesh and pose and played on Windows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
Fixed read past a buffer issue and
untied previous solution from reverb.
Also added config variable audio.resampler
(but no UI implementation since currently
there is no more space for that option).
Ringfall: Mjolnir VR ships the Android app (Quest, Steam Frame) only. The
desktop jobs stay in the workflow, off (if: false), so that upstream's
changes to them still merge; the release and its notice wait on Android
alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Moving, the hips and legs are no longer turned to the torso's heading:
they face as the game faces the body, so the game's own choice of
animation (forward, back, aside, as for any player, by the way it moves
against the way it faces) runs along the way the player moves; the spine
turns the torso. Standing, the hips turn to the torso's heading again,
eased (0.12 s). Walking off, the feet ease from where they stood into the
walk (0.2 s); stopping, they stay and step into place.
- The feet rest on the ground: as they are put down a ray down under the
body and one under each foot (one more as a foot steps; none walking)
find the ground, read only; each foot keeps the animation's height above
it, tilts with the slope, and a foot's ground below lowers the hips, up
to 45 cm, eased in and out. Nothing found: level.
Checked flat (debug.vr_body): strafing, backwards and diagonally with the
touch stick, the game's sideways and backwards walk; on a Blood Gulch base's
ramp the feet 15 cm up and 10 cm down, both on it; a two-minute bot game
without errors. 0.02 to 0.1 ms a pose in the emulator, a few rays a second
at most. Roadmap: the new behaviour and its headset tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What was built, its settings (vr.body "full", debug.vr_body), what the
emulator showed, and the headset test list for the Quest 3 and the Steam
Frame, with the branch's push to talk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The body is moved down for its neck to be where the head's pivot puts
it (its model has the neck 4 cm higher standing, 9 cm crouched by the
button: its shoulders came up by the eyes); the physical crouch still
sends the hips back.
- Feet more than 80 cm from their planted place (a teleporter) are put
down at once rather than stepping across.
- A mirror's view no longer decides the frame's pose.
- debug.vr_body moves the flat eyes round the neck as the head pitches,
as a real head's go.
Checked flat: standing and crouched by the button (touch Crouch), looking
down: shoulders below the eyes, torso upright standing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The neck's own skin stayed around its root, 20 cm below and 14 cm behind
the eyes: its open collar could be seen into looking down. Shrunk onto its
root with the head, the collar closes (checked flat, debug.vr_body back and
down: no stretched skin).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The player's own biped is drawn in first person among the world's objects
(it sorts against the world by depth; the first-person weapon and arms,
drawn first into the stencil, stay over it), posed from a copy of its node
matrices once a frame (port/linux/game/vr_body.c): turned to the torso's
heading, the neck below and behind the eyes as the head turns about it, the
hips down with the physical crouch and part of the way with a lean, the
spine upright to the neck, the feet planted while standing and stepping
after the body as it turns, the knees bent by the arms' two-bone IK. The
head and the third-person arms are shrunk to nothing, the weapon and what
the biped carries left out; the first-person arms hang from the body's
shoulders (mirrored with the weapon left-handed). A passenger in an open
vehicle sits as the seat's animation has it; at a wheel or a gun the body
is drawn as before, in a closed seat none. Nothing of the game's own pose,
hit boxes or network messages changes.
The neck pivot, the planted feet and the leg's clamped reach follow
HaloCE-Quest-VR (CC0), rebuilt for this port's skeleton and caches.
vr.body gets "full" (VR options, Body: "Full body"); "arms" stays the
default. vr.log_performance reports the body's pose. debug.vr_body shows
the body flat (the emulator): from the eyes, looking down, or from the
front, side or back, the head turning, crouching or leaning on its own.
Checked in the emulator: the cyborg's 19 nodes found, the body posed
standing, crouched, leaning and turning (feet stepping), 0.006 ms a pose;
two-minute bot games on Blood Gulch with it on and off, no errors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reticles of the PC HUD sheet's drawing that match the Xbox's exactly
(14 of them, in HUD2_left_reticles/xbox_reticle_matches.md) take the
place of the reticles' redraws: each copied unchanged, at the size and
place that match its Xbox sprite best (tools/hud_assets.py reticles; a
likeness of 0.97 to 1.00). They also give high-res textures to two
bitmaps that had none: the rocket launcher's and wraith's reticle, and the
sniper rifle's, whose bitmap holds its zoom labels too; no redraw has the
Xbox's 2x and 10x, so they are set in Overpass 900 (of the project's
fonts the one that fits them best), sized, placed and spaced to match.
The warthog's and the scorpion's keep their redraws: only variants of
them are on the sheet. The build places a redraw at any scale, and the
layout step runs again (a name hid its match function).
AUTHORS.md credits the high-res HUD's redraws to MrBruh, and the reference
photos they are based on to Aerocatia.
Checked: each texture beside the Xbox bitmap; a build reproduces the
PNGs; ninja linux and android; in a game on Blood Gulch the plasma
pistol's reticle draws from its new texture, and all 71 HUD textures are
found there.
Upstream's voice chat talks while controls.push_to_talk is held, a key. In
VR the free hand's grip is push to talk while it holds neither the weapon's
foregrip nor the Warthog's wheel (it is no button of the game's there).
The VR options' PLAY page gets Voice chat: push to talk (grip), open mic or
off (audio.voice_chat); the controller page names the grip's talking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no player profile, Settings opened nothing and showed "event handler
'new campaign decision' failed", where Campaign, Multiplayer and Profiles
open the new profile's name screen. Reported on Android; the menus' code is
every build's, and the Windows build did the same.
Settings runs the PC version's "profile set edit begin", which only failed
with no profile. The screens its <conditional> then opens run the game's
"new campaign decision", and that makes a profile only once "new campaign
chosen" has asked for its name: the other items' "new game if no plyr
profiles" asks, and "profile set edit begin" did not.
- "profile set edit begin" with no profile, in the main menu, asks for the
name (the game's "new campaign chosen") and fails as before, so that the
same screens make the profile. A game's pause menu runs the function too
and has no such screens: there it only fails, as before.
- "new campaign decision" is the port's for the menus' widgets: it runs
the game's, and for Settings' screen then begins editing the profile
made. The PC version's screens open Settings with nothing more run, and
Settings closed at once with no profile being edited ("close if not
editing profile"). For the other items' screens it is the game's alone.
The menus' files are as tools/ce_menus.py writes them: nothing in them
changes, nor in the game's sources.
Checked on a Windows build, by keys posted to the game's hidden window, on
save folders with no profile: Settings opens the name screen, then
"Creating player profile", then Settings with the new profile being
edited; the name screen left without a name goes back to the main menu
with no profile made (and the game's "new campaign decision" failure shown,
as for the other items); Profiles makes its profile and opens the profile
list as before. With a profile, Settings opens at once. The pause menu's
Settings with no profile does nothing, as before. The menus' checks of
tools/test_linux_port.py pass. Not run on Android or Linux.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
custom_edition_maps_look_for listed custom_maps and the install's maps
folder with find_files_start and find_files_next, which keep their place
in one global (find_files_globals). As the game starts, the menus' thread
for the file system's checks (filesystem_initialization_thread_proc in
ui_widget.c) makes the default gametypes' folders with
directory_create_or_delete_contents, which deletes a folder's files with
the same two functions. A map list looked for meanwhile takes that
listing's place, and the thread then deletes the files of the folder being
listed: the player's Custom Edition maps.
The menus take no input until that thread is done, so it takes a start
that asks for the maps at once. Seen with debug.menu_open set to the
Select Map screen
(main_menu/multiplayer_type_select/mp_map_select/mp_map_select_screen) on
a Windows build of c9e4f13f, with six files in custom_maps: one was left.
Each folder is now listed with FindFirstFileA and FindNextFileA on a
handle of the function's own. The same start, twice, left the six files
and listed the three maps among them. Not run on Linux or Android.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Splitting the window's slice off ART's large object space restores it with
MADV_DODUMP; on a space already marked don't-dump the split does nothing,
the read fails, and the restore then cleared the flag on the slice and
split ART's mapping for good. The space's VmFlags are now read first, and
such a space is left as it is. A slice with swap of its own is reported as
in use (objects are there), and each failure says what failed.
debug.halo.art_overlap takes -nopagemap (pagemap treated as refused) and
-swapped (the stand-in's objects paged out with MADV_PAGEOUT), so the
mincore fallback and the slice's swap can be tested on any device.
Checked on a Pixel 9 Pro XL: a normal start, and idle, busy, idle-swapped,
idle-nopagemap, busy-nopagemap, idle-nopagemap-swapped (the reclaim goes
ahead: swap in the space, none in the window) and busy-nopagemap-swapped.
On Linux, a space marked don't-dump stays one mapping with its flag.
Needed on a device whose ART large object space (765bd000-965bd000, 512 MB)
covers the window, that refuses /proc/self/pagemap and has some of that
space swapped out: build-175 could not tell whether the swap was in the
window, and did not start.
(cherry picked from commit c8fa616c0c28250546b1c2ef4204f3a393020d81)
Upstream puts SETTINGS into the single-player pause menu's list; a list
with room grows longer and is centred, which put its last row (SAVE AND
QUIT) on the line of the button hints below it (B Cancel, A Select). In VR
its rows now go closer within the span they had, as a list without room's
do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before 4.17 the kernel puts the mapping elsewhere when something is in
the way; reserve() then set errno to EEXIST but returned before the
reclaim, so it was never tried there. memory_map.txt now also names the
attempt each reason came from (at start-up, or when the image loaded),
where it listed them twice.
Checked on a Pixel 9 Pro XL: a normal start, debug.halo.art_overlap=idle
and =busy behave as before, and the busy report shows both attempts.
On some handhelds (the AYN Thor, Retroid devices), ART's large object
space covers the fixed Xbox window, and the reclaim from upstream #19 found
pages of it in use, or could not read /proc/self/pagemap, so the game did
not start.
- The game runs in a process of its own (":game"), with a fresh Java heap:
nothing the launcher, a disc image import or an earlier game allocated is
left in the large object space.
- HaloApplication loads libmain.so first thing in that process, and its
JNI_OnLoad reserves the window and the image range before the Java side
allocates anything large.
- Whether the slice over the window is idle is read from pagemap, or, where
the device refuses pagemap, from mincore and the space's swap total. The
log now tells "in use" (how many pages, where) from "cannot tell".
- When the window cannot be had, the game says so plainly and writes the
mappings below 4 GB to memory_map.txt in its data folder and to logcat.
- The debug.halo.art_overlap system property (idle or busy) puts a stand-in
for ART's space over the window, to test the reclaim on any device.
Ported by hand: our host_memory.c had moved on (the page-hash write
tracking), and README.md and host.h took both sides. The commit's change
to the "game data was not found" message is left out.
Checked on a Pixel 9 Pro XL (Android 17): the game runs as
com.halo.decomp:game and reserves the fixed ranges from JNI_OnLoad;
debug.halo.art_overlap=idle reclaims the stand-in and the game starts,
=busy leaves it alone and shows the message.
(cherry picked from commit c71d7f8b8ea475564f8f70ab08ea585af8f8c681)
The map's letters do not sit alike on every icon: on the message icons'
sheet, X's and Y's are about 1.7 texels lower on their ovals than the
redraws put them. tools/button_assets.py layout now moves each icon's
letter (and Y's shadow, given the id "shadow") to where it best matches
the map's, and buttons.json keeps the move; the menus' large buttons move
by under 0.03 texels, the message icons' by up to 1.7. The PNGs are drawn
again, the sheet with all 256 levels of alpha rather than 32.
hud_hires_sprites_drawable read display.high_res_hud and high_res_text
only once, and hud_hires_sprite_bitmap kept its answer for the bitmap's
address, so turning either setting off or on left the message icons as
they were: both check again when the settings change, as
hud_hires_override_find does. ui_widget.c declares hud_hires_sprite_bitmap
with the port's other functions instead of in the block that calls it.
AUTHORS.md credits saulob for the redraws.
Checked with button_assets.py check against ui.map and side by side with
the map's icons; ninja linux and ninja android build, and the harness and
test_linux_port pass.
248 OpenCE commits (network version 25): touch controls and touch menus for
phones, single-player in-game Settings, opt-in FOV/viewmodel settings, voice
chat and votes to kick, lens flare culling, memory leak fixes (render
targets, gamepads, the Android audio stream), profiling builds, the
HALO_ANDROID split into HALO_ARM64_GUEST / HALO_GLES / HALO_ANDROID.
Conflicts and how they were resolved:
- README.md: ours kept whole. Upstream's README changes (portable build,
profiling builds) went into docs/OPENCE_README.md: upstream's new text
with our header note, links rewritten to ../ and our "made from your own
copy" wording.
- port/android/README.md: both merged; upstream's touch, WSL and save
notes taken, with our application id in every adb path; our VR section,
signing.properties and wording kept; the stale "How it works" list
dropped as upstream dropped it.
- Launcher icons (mipmap-*/ic_launcher_foreground.png, art/android-icon.png):
ours (the Ringfall art). Upstream's helmet monochrome layers,
drawable/ic_launcher_background.xml and its ic_launcher.xml were left out;
our ic_launcher.xml, values/ic_launcher_background.xml and
tools/android_icon.py stay. (port/assets/icon and tools/app_icon.py come
in for the desktop builds' window icon only; do not run app_icon.py, it
would write the helmet into the Android resources.)
- port/android/host/host_main.c: both config helpers kept, and upstream's
emulator write tracking (native_bridge_active); upstream's display width
block dropped in favour of ours (640 in VR) and its display density for
the touch controls moved into our flat (non-VR) branch.
- port/linux/game/menu_functions.c: platform_text_field(typing, password)
from upstream; the VR keyboard launch for text fields kept after it.
- port/linux/game/network_distributed.c: upstream's profile_scope wrappers,
with network_vr_tick and bots_tick kept in their places.
- port/linux/src/dsound_sdl.c: both (our debug.audio_log, upstream's
audio.output_device).
- port/linux/src/port_config.c: our temp-and-rename config write kept,
under upstream's HALO_ARM64_GUEST (it is guest file I/O).
- port/linux/src/sdl_platform.c/.h: upstream's (Android menu pointer from
the touchscreen, Steam keyboard, scoreboard pointer); our quit marker for
the Steam Frame launcher, clipboard_seen and VR frame hooks kept. Our
flat-mouse menu pointer (an emulator aid) is replaced by upstream's touch
pointer, which also drives the menus on the emulator.
- port/linux/src/d3d8_gl.c: halo_ui_pointer_update is upstream's, with the
VR controller ray taking over while VR is active (the touchscreen then
is not the pointer); our persistent-buffer writes kept for stream and
index uploads, now writing upstream's exact length and under HALO_GLES
like the code around them (stream_reserve, index_upload, mirror_refresh
too); multiview uniforms stay under HALO_ANDROID, the counter binding
under HALO_GLES. Visibility counters: ours kept (the snapshot ring with
multiview views, host_gl_counters_snapshot) over upstream's per-frame
glCopyBufferSubData into counter_snapshots: ours reads finished frames
without stalling and counts both eyes' samples once; upstream's
desktop query batches come in unchanged.
- port/linux/src/xinput_sdl.c: both (VR controllers and touch controls).
In VR the touch controls neither show nor act (touch_input_gamepad,
touch_input_controls, touch move/look skipped); rumble goes to the
controllers in VR, to the phone otherwise.
- source/interface/first_person_weapons.c: both (VR scope pass check and
display.viewmodel_visible).
- source/interface/ui_widget.c: upstream's reworked menu targets taken
(ui_mouse_fit_button_targets, merged rows, widened values), which do
what our icon_x0 key picking and the 12-unit value arrows did; our VR
label distance (25) kept, and a VR ray counts as a coarse pointer, so it
gets the taller legend areas and the widened values (not the merged
rows: a row's label still presses A). Our join code and VR options
panels kept; their early return now forgets the frame's targets too.
The on-screen keyboard: upstream's virtual_keyboard_click for taps and
the mouse, ours (hover highlights the key) for the VR ray only.
- source/main/main.c: both (VR phases around upstream's profile scopes).
VR guards for new upstream features:
- view_fov.c: display.fov, display.viewmodel_fov and
display.viewmodel_visible do nothing while VR is active (the headset
gives each eye's frustum; the weapon is in the hand).
- Touch controls: off in VR (xinput_sdl.c), the menus' touch pointer too
(d3d8_gl.c); the overlay stays hidden as the game never reports a scene.
- Lens flare culling (744040c8, b1a71709) uses global_window_parameters'
frustum, which in VR is each eye's (per-eye passes) or the union frustum
of the multiview centre camera: no change needed.
- In-game Settings: VR options still open with Y on the main and pause
menus (checked flat with debug.vr_options).
- The back button change (enableOnBackInvokedCallback=false) keeps the
legacy KEYCODE_BACK path, which targetSdk 35 already had: no change on
Quest or Frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md is a guide for coding agents working in the repository: where things are, how to build and test each port, the defines (and that HALO_LINUX and HALO_CUSTOM_EDITION are never set), the rules for the game's sources, the SDK declarations, the netcode's version, the menus, art and credit, Custom Edition maps, settings, vendored code and licences, and how commits and pull requests are made, with an index of the other documents. port/assets/menus/README.md says how tools/ce_menus.py writes the menu files again, which it points to.
Every push built the profiling flavour as a build of its own, which costs runner minutes for a check few changes need. It now builds only in a run started by hand (workflow_dispatch) with its new profile box ticked; tools/ci_build.py linux profile still builds it locally.
SETTINGS goes before REVERT TO SAVED found by its tag
(restart_at_save_point_button: it opens its confirmation, so no function
names it), not at the second row, so that a map whose list has it elsewhere
gets it in its place, and one that has it not is left alone. The rows are
spaced over their own span only when the list grew for it, and the list
then keeps its height; a list with room keeps the centring pause_list_patch
now gives it.
Gamepads' OK no longer copies the changed bytes into the player's profile
itself: saving the profile makes it the player's (#227). The edited
profile's index has its comment, and the README says what the list does.
Add in-game Settings menu
Conflicts resolved: the campaign's pause menu goes before main's
menus_pc_chosen, which the early return keeps using (display.menus in any
case), and pause_campaign_patch passes pause_list_patch the growth it now
returns.
CI builds the profiling flavour too (tools/ci_build.py linux profile: a
debug build with configure.py --profile), so a change that breaks it is
seen; nothing else compiles it. profile_sections.h, which only the port's
builds use, moves from source/cseries to port/linux/include, and the hooks
in the game's sources (the map load, the console, the main loop's exit)
and their includes say they are the port's. The build scripts' import
lines are wrapped as the files' others.
Add a profiling build: CPU recordings for Perfetto and a report tool
Conflicts resolved: CI runs main's tests and test_profile.py; the build
scripts take both main's opus and target flags and the PR's
configuration_defines; port_config.c keeps debug.memory_watch before the
profile settings; main.c and network_distributed.c keep voice chat, and
network_votekick_host_tick stays its own statement after the game state's
profile scope, not under its if.
render_fov.c, viewmodel_fov.c and viewmodel_visibility.c are one unit,
port/linux/game/view_fov.c, with a header comment as the others have, real
for float, _pi for its own pi, the rasterizer's target by name, and no
isfinite shim in math.h (the angles' range test already refuses NaN). The
game code's hooks are marked "port:", their include at the end of each
block, and rasterizer_lights.c's loops declare before they act (C89).
The first-person weapon's lens flares are not culled by the world's
frustum (main's new off-screen cull): they are
drawn at the weapon's own projection, which display.viewmodel_fov can make
wider than the world's.
HALO_VIEWMODEL_VIS is HALO_VIEWMODEL_VISIBLE; the settings' descriptions and
help lines say "stock" view and fit their boxes; Video Setup's XML is
written again by ce_menus.py; the README's field of view text is a section
of its own.
embed_assets.py still wrapped the SMAA shader and textures in #ifndef HALO_ANDROID while xgpu_post.c now uses them under #ifndef HALO_GLES, and wsl_desktop_check.sh took out only -DHALO_ANDROID, so its desktop branches would have been checked with the guest's and the ES renderer's defines still set. The Android README's doubled blank line goes too.
Split HALO_ANDROID into HALO_ARM64_GUEST, HALO_GLES and HALO_ANDROID
Conflict resolved: main's render-target eviction and mip composites are kept, with the PR's HALO_GLES guard on the ES blit fallback after them.
Five or more players come back with that group, beside someone there who is safe to stand next to. A fight in the whole group waits ten seconds. Fewer players still follow the host.
The raised limits' and the walk's comments are marked as the port's, with the unverified HAC2 and MCC comparison left out; the walk's says why the index is compared whole, and the once-only log of a frame reaching the limit gets a comment of its own.
The helpers' comment is marked as the port's and says plainly what the short holds; their definitions move to the file's private code, and the plane the item is moved onto is indented inside its new surface check.
The sleep's comment is marked as the port's and says what the spin cost (a core held busy, not system calls: the clock is read through the vDSO on Linux and Android), and the precache test the sleep no longer depends on is gone rather than cast to void.
The new helpers sat between widget_takes_events_of_controller's comment and the function, so the comment now heads it again; pc_menu_split_players is declared with the file's other port prototypes rather than as an extern among the functions, the players' count is read with players_port_local_player_count (safe before the players' globals are made), the comment names process_ui_widgets, and the test widget_takes_any_controller already makes is not made twice.
Halo PC reads a Custom Edition map's tags unchecked, so maps made for it
can hold an index past a block's end that never mattered there. OpenCE
already gives such an index the empty data and logs it, as it does for a
tag data's offset, but a debug build still stopped at the block's
assertions first (foundation@ce, 13 seconds in). With a Custom Edition map
loaded they are skipped, as tag_data_get_pointer's are; Xbox maps keep
their checks.
Only the assertions: OpenCE had the rest of the commit's change, made
another way (tag_empty_data).
(cherry picked from commit c953c65409ff9f3f230849bc6fc287727e3e3213)
The debug camera controls applied the height variable's hyper-scale
setting to every variable. Camera debugging only.
(cherry picked from commit 3db16e4499c7aa8ee9c82f28c3454009f877bccc)
(cherry picked from commit a5074861b2f9682e791adfd587c3a2b43e2f6daf)
The unit activation check compared the last vehicle's index where it
meant the time the unit left it, so it could report a false "unit
activation logic error" just after a vehicle exit. Messages only.
The actors.c line only; the commit's other fix, the movement point's y
printed as z, came with "Log and error messages print what their formats
ask for".
(cherry picked from commit e3089220a6bfa8479ba2e334a0070f97c97dd851)
The AI profiler's meters took the oldest sample out of their running sum
but never put the new one in, so their averages were wrong; the actor
line's units also lacked their tab and a separator.
The ai_profile.c fixes only, from a commit that is otherwise a rewording
of the AI code's comments.
(cherry picked from commit 95b838d13e276e41aa37f4646ce4acf5d18b8639)
"PC" or "Pc" fell back to the Xbox menus without a word. Any case now
works, and a value that is neither "xbox" nor "pc" is named in the log.
With csstrcasecmp, as the port's other files compare names, in OpenCE's
check (which also skips a multiplayer map with no pause menu).
(cherry picked from commit f12bb1ba0c3eb6b83d58850821b525fedbcf5e7a)
android:enableOnBackInvokedCallback="false" keeps them KEYCODE_BACK, which
SDL passes to the game as B. With predictive back, the default once the
app targets Android 16, the system would take them and the game would not
see them.
(cherry picked from commit f83c1711c3581963d6167321e1157bda60f7a132)
(cherry picked from commit 40063dcb519e6a46515c7c93cc9142fe51a53bc7)
A release's download and the app extracted from it stop at 256 MB (a
release is about 35 MB), so a wrong or hostile answer cannot fill the
phone's storage.
The size cap only: OpenCE's updater reads its build number from a
"build-N" tag with Integer.parseInt, so the version check the commit adds
for its own "v" tags is not needed here.
(cherry picked from commit fc92683b63ab6ca2725d0bfce0abeb8264c15a7c)
Their attachment's movement was truncated to whole units before the
test, so it took two units of movement in one update, not one, to move
the vertices along with it. Visual only.
(cherry picked from commit 99c2db2d4da3d6d66b6ec28d459c756aeab55a64)
(cherry picked from commit eb0667bcc81ed1d970422c7856162e8aadff6fd7)
Three engine checks no longer stop the game on data Halo PC tolerated:
HUD sounds with no tag, dialogue variants with no dialogue, and non-model
shaders under camouflage (an environment shader on a first-person weapon:
not drawn while camouflaged rather than misread as a model shader).
These three only: OpenCE already had the commit's other repairs (protected
maps, node trees, scripts that fail to load, HUD meters and digits, object
functions, collision edges, weapon lists, more than 16 dialogue variants),
some made another way, and has no 64-bit build for its script stacks.
(cherry picked from commit d15b6ca913266b984966897e1be4e24c69a75b30)
Halo PC's pause list (five rows, which Custom Edition maps keep two of)
has room for SETTINGS and END GAME: the buttons are centred in it rather
than added below, which pushed the column down the box. The Xbox's two-row
list grows as before.
The menu_tags.c part only, written for OpenCE's tags (no XBOX_POINTER);
the weapon HUD's magazine overlays the commit also fixed came with #180.
(cherry picked from commit 91566224a8bd493a2a5e4c211a033e0aba66cff2)
A list whose focus reached its first or last row scrolled on and moved the
focus a row in; the pointer hovering that row took the focus back the next
frame it moved, so the list ran through everything and the row under the
pointer never stayed lit (the game types, the lobby, the internet lobby).
A list now scrolls on at its end only when the d-pad (keys, controller,
the wheel) put the focus there (ui_widget_port_pointer_focused).
(cherry picked from commit e74f5bbb541a480a72871adeb719472908607d12)
Particles of one kind are drawn together, at most 8 bitmaps' worth. A Halo
PC map's particle may have a bitmap a frame: beavercreek_rev_beta's fluid
bursts have up to 56. A debug build halted ("a build_sprites_begin call can
accomodate at most 8 bitmaps") when such particles were in view; a release
build drew the first 8 bitmaps' sprites and not the others. The builds that
play Halo PC's maps now do as a release build does in a debug build too, and
log it once. The Xbox's maps' particles stay within 8, and the Xbox build is
unchanged.
Without its #ifdef HALO_CUSTOM_EDITION, which OpenCE never defines (it
compiled to nothing): the log is for Custom Edition maps
(custom_edition_cache_tags_loaded), the assertion stays for the Xbox's.
(cherry picked from commit afa6413f1e922621cc04e7953e730d5571b3aae6)
Some maps' sounds have gain modifiers above 1 (foundation's Reach sniper
rifle fire, 1.5; ten on Hornet's Nest). The game asserted on them (a halt
in a debug build; in a release build the volume went on to assert again in
dsound_volume_from_gain); it now clamps the gain to 1, as Halo PC plays
them.
Written for OpenCE's dsound_channel_set_properties: the original change
only joined a 32-bit build to its 64-bit build's clamp, which OpenCE does
not have.
(cherry picked from commit 312960464ca0868746bfea19eb004635a5acbb8e)
The voice messages give a packet's length in a byte, but a frame could be
encoded up to 400 bytes: at 48 and 64 kbps, a frame past 255 bytes (a loud
one, as Opus's rate varies) had its length cut short, and the host, finding
it unequal to what came, dropped it. The packet's limit is now 255, which
the encoder fits a louder frame to; 64 kbps's frames are 160 bytes.
The voice_audio.h part only (not the commit's d3d8_gl.c comment removal).
(cherry picked from commit 003da0187e1bbd52baad8c3f9c3061c2a61ba55b)
The scope's screen effect (a warp: copies of the screen scaled a little
from the center, blended outside the scope's mask) spreads its copies by a
radius in the Xbox's pixels. At 640x480 the few copies blended into a
radial blur; at several screen pixels to the Xbox's one they stood apart as
sharp ghosts. The native builds keep the spread and split it into that
many times the passes (halo_screen_scale), each a step of it.
(cherry picked from commit b7fa7f742a72924514eef69d50daf31ef7f7f836)
(cherry picked from commit d1b40a6fc74ab46641313a6e0095e596494a1e7b)
Without its #ifdef HALO_LINUX, which OpenCE's builds never define (it compiled to nothing), and with the copied parameters declared with the others (gnu89).
(cherry picked from commit 763567dcc7ad8e6a851fec6bb5cb9f9e10452579)
rasterizer_xbox_bitmap_rebuild_hardware_format pads a linear bitmap's rows
to 64 bytes, rounding up; the texture header rounded its pitch down, so a
Custom Edition bitmap whose rows were not a whole multiple was drawn
skewed. The header now rounds up too (Xbox maps' rows always are a
multiple, so they are unchanged).
The texture header's pitch only: OpenCE never had the misalignment log the commit's custom_edition_bitmaps.c part removes.
(cherry picked from commit 90a057778f76eb96d45a112b95be2a0c1e1d69a2)
The host's object states now carry a unit's integrated light, with a bit saying it was sent ("Netcode: carry units' integrated lights in the object states"): a client of version 24 would keep a missed or earlier press's light the wrong way, while one of 25 sets it from every state.
A machine whose address or hardware id is in bans.txt, or that was dropped
for cheating, was told the game is not open when it tried to join again;
it is told it is kept out (_rejection_code_blacklisted_machine), as at the
ban itself.
(cherry picked from commit 73c78610c67cd3827592673f1b06a1d38babe4ac)
Adapted: without the dedicated server's drop function it was declared
beside (in a server/ folder, not taken), and a machine kept out by a
vote (OpenCE's network_votekick.c) is told it is kept out too.
(cherry picked from commit c0191dd9beb4bfd26dd73ba0923cb7edeea44d49)
game_speed takes only finite speeds from 0 to 100.
(cherry picked from commit 4c6e68a6021204563aedb1f91e9a10d90744b5d8)
(cherry picked from commit bdbb182d0fc653960d1baf7a8b46360537eafe32)
The header's name is read only within its field.
(cherry picked from commit d4ff4cc226e25d315ca1db853ebef14953521691)
(cherry picked from commit f0dd7c9fa533a95e8e653e203064a985965f4007)
A range with no previous permutation (NONE) set the bit NONE as a byte
shifted to, 255.
(cherry picked from commit a35ddbe6bfcd31d5aa0c501dac52721fc295fa50)
(cherry picked from commit cd87b861b1203df530e22e777219255c21408984)
The filter was chosen for every byte. Each row now runs one loop for
its filter; the pixels are the same (all 261 HUD and title PNGs
compared byte for byte).
Decoding the 261 PNGs: 659 ms -> 309 ms (M4, without the GL upload).
b30, the frame where the HUD first shows (18 textures decoded): 123 ms
-> 84 ms of decoding, that frame 122 ms -> 82 ms.
(cherry picked from commit 1213d2475bb9d139e3aeb47426da9fc6190c8de9)
(cherry picked from commit 02e53d4aa030745162e53877fda910d95353c94a)
A router whose internet address is a private one (another NAT beyond
it) was reported with that address in the error text, which goes to the
log and can be shown. Addresses are not shown; the error says what it
means without it.
(cherry picked from commit e59c03e8b399412fb4d4ed6ba7eea1e925451f5b)
(cherry picked from commit 2e6e1270f2b7788a09b7e2e3b76878c88f36e281)
A stream's cursor is the place in its first packet not yet played.
stream_flush completed a partly played head packet, which leaves the
cursor where it was, and let go of the mixer's lock for the game's
callback. A mixer pass in that time took the next packet at the old
cursor: it played from the middle, or was marked finished at once, and
the flush then reported it played (SUCCESS, its whole size) rather than
flushed, so the game queued more as it stopped the stream. Completing a
packet that was not played to its end now resets the cursor.
(cherry picked from commit 3582e112b99d6f47f131ff6ab3439b0439e93c88)
(cherry picked from commit dcbffc6a200b525150c7ea25d9091e3f9cffbfdc)
game_engine_predict_resources and game_engine_remap_vehicle took the
globals' multiplayer vehicles 0, 1 and 2 (warthog, ghost, scorpion)
without looking at the block's count. Every retail map has them; a Custom
Edition map's globals may not, and the game then stopped on
tag_block_get_element's assertion or, with assertions off, read past the
block and predicted or compared whatever was there. A missing one is now
NONE: not predicted, and matching no placed vehicle. Maps with all three
play as before.
(cherry picked from commit f1eb47cf3da8cf3e34ad2b6c2e304f994540ab46)
(cherry picked from commit 2f282f1aa2342af9619d5b7fcf0d92d1962b9ee8)
Deleting a saved game file moves the files after it down the memory
unit's list, but the players kept their profiles' old indices. A profile
deleted before the one in use left that one's index naming the next file,
often the next profile made: the profile in use was then saved over it,
taking its name in the list, while its folder kept the new name, which
then read as in use ("a profile with this name already exists") with no
such profile shown. The indices the players and the profile editor hold
now follow their files down the list, and one naming the file deleted is
none.
Fixes#148
(cherry picked from commit 4575f6e6ec7010de1036a3a639ee2794181874d8)
(cherry picked from commit 4bbd4965afbbaa22cb3a655a181c3770382bc505)
A network game's pause menu pauses nothing, and the controller that moved
about it also moved its player: the D-pad and sticks walked and turned
them as they chose an item. While a menu of a player's own is up
(ui_widgets_active_for_local_player), the player gets no input, as when
the director holds it, and the buttons still held as the menu closes are
let go of first, as before (ui_widget_delete).
Fixes#119
(cherry picked from commit 771a257f25ecf64fe52d84d0dbd77dc11b2a8680)
(cherry picked from commit b5fc042ca61cdfeb9a27693b2cb3f65bf93e2c0c)
clock_gettime's wall clock imported GetSystemTimePreciseAsFileTime, which
KERNEL32 has only from Windows 8, so Windows 7 refused to load halo.exe.
It is looked up at run time, with GetSystemTimeAsFileTime where it is
missing. It was the only import newer than Windows 7 in halo.exe; SDL3.dll
has none.
Fixes#77
(cherry picked from commit 8b862537ef55592ae496c2b9dde6f840baa8ebcf)
(cherry picked from commit affe58156dfe9ce6fd4ae46765f50ee43560dfac)
The console runs what it is sent as scripts. A browser can be made to send
an HTTP request to a local port; such a client is now dropped at its first
line instead of having its lines run.
(cherry picked from commit faa2a331383f4dedce63487d813b7348c00b6f26)
(cherry picked from commit 40a6262e9be05db11317522f9ee9424405124025)
The scan kept each local player's blip count and the scan order in one
array of four, the counts first and the order from 2, and had two camera
positions: all the Xbox's two player co-op needed. With three or four
local players in split screen they were written past their ends and into
the function's stack guard, which halted the game as it started drawing
the HUD ("corrupt stack at 0!"). The counts and the order are now arrays of
their own, and there is a camera position for every local player.
(cherry picked from commit 266a556967273bfcf096c1e6009acdb7a5ee045d)
(cherry picked from commit c02cb986c2c5acc6426fcb043f39d1062617f7f7)
(cherry picked from commit ee9cbf17d1422f2b57b5ca07c98010ece1bcbad6)
The maps folder copied out of a disc image takes only plain file names
(printable, no path or drive characters), and on the desktop ports is
never written through a link.
(cherry picked from commit 7deed0430bb01510e4df431fae89a5c9675010d2)
(cherry picked from commit 106f1c6eb6e98646f36c9774a7c1750c78662cfe)
25 notes ("left unassigned only by this default arm ... approval pending,
2026-09-27 audit") argued that a variable was never read unset. Where the
arm ends in system_exit they were right and simply go. A release build's
assertions return, though, so where the arm only asserts the variable now
starts with a value (a Custom Edition map's HUD data can reach hud_draw's).
A failed map precache reads as failed.
rasterizer_debug_drawing_begin had two declarations, one with an argument
its definition never takes; it now has one, and the port's macro papering
over the mismatch is gone.
(cherry picked from commit 74f8a048e4c3cc7dbc4b050dd41a8bae912bb367)
Uninitialised locals get values (debug vertex, datastore result, index
buffer, map tool direction, AI entry points, escape alignment, grenade aim
from a vehicle, game options descriptions, unreachable switch arms), the
AI's squad and platoon records move whole, a speech cause point is read,
the geosphere's out-of-memory path frees itself, a console cond clause
without a result is refused, and the crash reporter rejects truncated map
files. Gameplay the original game has, kept so it plays the same, is noted
in a line rather than a page.
(cherry picked from commit 75afc4a1e57af2ba697e8be0e33b2afeee7639f8)
Each of the 25 appends now goes through profile_append, which bounds and
terminates; a 150-tick frame could run past the 512-byte buffer.
(cherry picked from commit ecba1a0cecc7ff5d60d26654bdd50b7330d43f65)
Arguments a format never read are dropped, missing ones passed (the
migration speech type, DirectSound's result), a real printed as %f, a
target AI's name printed for it rather than the source's twice, and two
messages built from saved-game names printed through %s.
(cherry picked from commit dbcf4ab91c2e42c19f57ca436f1d4f2b41964582)
A unit's light is only toggled by presses, so a client that missed one,
or joined after it, kept a vehicle's (or biped's) light the wrong way and
unit_get_current_flashlight_state with it. The host's states now carry it,
with a bit saying so, so older hosts' zeros are not read as "off". What
the client's own player drives is left to its own presses.
(cherry picked from commit 0392b4639e663faa632c928301ff89a425bb0cd0)
objects_can_see_object and objects_can_see_flag on player0 tested one player only, the first with a unit, so the Pillar of Autumn tutorial's panels (look at each light in turn) never lit for anyone else. In network co-op a test of the players now passes when any player looks, as volume_test_objects_all already does.
(cherry picked from commit 1e8e0f9affe212d3e30817b768d3124027b678f7)
Seven of the script engine's conversions and comparisons wrote only the
low byte or word of a long and returned the rest uninitialized, which
the scripts' stacks and globals, and so the saved games, carried along.
Every reader takes only the low byte or word (the script type sizes), so
the scripts' results are unchanged.
(cherry picked from commit bb239e2207668593679eea5de45eda8e5f635b34)
(cherry picked from commit d6811ee3264b2082ba6b943087ce26293f59768a)
The average radius of a group whose particles were all culled divided
zero by zero, leaving a NaN in the effect shader's secondary map radius.
(cherry picked from commit 11128fd78ade85c50a9a3a5a292bd2bb33bebf30)
(cherry picked from commit b52d79541dc2143de41943367542ae63ad4f384f)
bitmap_2d_get_pixel's out-of-range block messages printed a short where
they say lod=%f, and a compressed format it can't decode returned an
uninitialized pixel (release builds carry on past the assertion). Object
lighting is its only caller.
(cherry picked from commit e1169e05a374071e74b3da3d285762c45f1c2a77)
(cherry picked from commit 9d827c0dd9164196da54b10a2fbfdd63bf72a0cd)
A trail's first draw asks the texture cache for its bitmap without
waiting and is skipped until the read finishes, so a weapon's first shots
could show no trail (the sniper rifle's most of all). A weapon created or
readied now asks the cache, without waiting, for the bitmaps of its
projectiles' contrails. Only the texture cache sees it: nothing in the
game state or on the network changes.
From pfista's halo-og (4e66af91), the source/items/weapons.c part only;
that commit's spawn inventory and teleport prediction changes are not
taken, as they change what a host and its clients exchange.
Co-authored-by: Michael Pfister <pfista@gmail.com>
(cherry picked from commit 7fff24d4843758ddcb6468c9f1febecfbce68aa5)
(cherry picked from commit 965f48f5c5e2063ba025766f1276467708e8112c)
While searching for games the client wrote to its connection before it had a socket (Winsock error 10038); that marked the connection closed, and when the player then picked a game every write of the join request failed, so the first join to a host hung and gave up. Connecting now clears that, and any half-sent data. A second try worked only because it made a new connection.
(cherry picked from commit 4b18b99af6d977707203b304d104c84fb23fecb4)
A send that fails with an error the transport doesn't name became endpoint_io with nothing to say why (a first join to a host that then failed). The error is logged now, once for each. The connect wait treats a select that times out as still connecting, and sets its timeout each time (select may leave what is left of it).
(cherry picked from commit 5b02133ce00edcb7d9fd788db459a77cd98e9ac4)
Rotational sprites draw their edge-on view from the sequence after the state's. A particle bitmap on foundation has only one sequence, which halted the game in particle_system_render. Without that sequence the face-on sprite is drawn alone.
(cherry picked from commit d436a0c0c3abaf069dd34e4b8d761b49569311e6)
A structure BSP's lens flare markers are submitted for every visible
cluster, and each gets an occlusion test every frame, including those
behind the camera or off the screen, whose tests count no pixels (so
they are never drawn). On the Xbox a test was nearly free; here each is
a draw and a query of its own, and a level of many lights (Medical
Block) tested about 460 a frame (debug.gpu_trace_heavy). A flare whose
test could not reach the screen, by the frustum and a sphere taking in
the test's offset point and size, is no longer submitted.
(cherry picked from commit e020bbc58dd08b4bcd609d8cf561c94b87cf4580)
terminal_printf started its variable arguments into a char *, which is
what va_list is on the Xbox, 32-bit x86 and Apple silicon, but not on
x86-64, where va_list is a one-element array of a register-save
structure. On an Intel Mac (or under Rosetta) va_start wrote through an
uninitialised pointer and vsnprintf read garbage, so the first terminal
line printed with arguments crashed the game.
Choosing an empty profile slot is such a line: "player profile begin
editing" fails, as it does on the Xbox, and console_warning reports it
through terminal_printf, so setting up a profile crashed on Intel Macs.
(cherry picked from commit 024e41b8cb1553d7ad9b96233860848d49e5ad13)
With input.touch_aim_assist false, walking with the touch stick still let the view follow a target: the touch controls now count as aiming without the assist until a stick aims again (halo_linux_touch_aiming), as the mouse does. The stick's diagonal is full on both axes, as a controller's square stick gives. The gyroscope reaches the game apart from the swipe (nativeGyro), so the profile's invert look turns only the swipe.
The overlay shows once the game has been in play for 200 ms, so it does not flash at a level's first frame. The options dialogs are counted, so a dialog opened from another keeps the controls released, and the layout is saved when the last one closes (a slider moved without a finger was not). A floating stick rests where the thumb lands, even near an edge, and not in the bottom gesture zone. A finger's width is at most 34 logical units, so the D-pad's buttons stay apart on a small dense display. A held button that takes over the swipe from another finger starts from where it is, not from where it went down. The system bars coming and going no longer release every finger. Hide stops the gyroscope.
The libtiff comments are corrected: the screenshot_count and screenshot_size globals also reached tiff_export, but read back-buffer memory the port never draws into, so they never had the picture. Stale comments are fixed and the source exclusion test covers folders.
They showed for a moment as the game started: until the main menu's widgets were up nothing said a menu was. They now show only while a game is played (touch_game_playing): not while the game starts, between maps, or at the main menu, whose ui.map is a game too.
The stick moves the player through the game's input state (keyboard_controls_update), as the keys do, so it moves with a southpaw or legacy stick layout too, where it was the left stick. The buttons are named by the profile's button layout, which the game sends the overlay as it changes (host_touch_bindings): with Swap triggers the right trigger's button says Grenade.
No control is smaller than a finger (48 dp across). The overlay's opacity can be set; a floating move stick (off by default) goes where the thumb lands in the lower left. Hide is remembered, and Hide and Options act as the finger lifts on them, so a swipe that starts on them aims rather than opens the options mid-fight. A label too long for its button is drawn smaller.
TouchControls, TouchLayout, GyroscopeAim and HaloActivity's touch layout files in the repository's style; the layout file is version 3 (opacity, floating stick), and versions 1 and 2 still load. CI runs the layout tests.
The touch controls' swipe was added after the aim assist, through the mouse's hook: no friction over a target and no adhesion to a moving one, which a controller has. It is now a turn made the stick's rate before the magnetism (halo_linux_touch_look, one port: hook in player_control.c), so the zoom, the stun and the magnetism act on it as on the stick's, and it counts as aiming for them; it follows the profile's invert look as the stick does (input_abstraction_port_look_inverted). input.touch_aim_assist (default true) turns the aim assist off. The mouse's hook is as before the touch controls.
HALO_ANDROID meant three things at once. The port's code now names each:
HALO_ARM64_GUEST for the ILP32 AArch64 guest's code paths (the inline
assembly's C, the bss pragmas, the CRT's), HALO_GLES for the OpenGL ES
renderer (its GL header and function list, shaders and capabilities), and
HALO_ANDROID for the app alone (its display, input, files and lifecycle).
The Android build defines all three, so nothing changes for it, nor for
Linux and Windows, which define none. A build for another platform can
then take one part without the others: the ES renderer, or the guest's
ABI, without the Android app.
port/android/README.md also no longer says that the widescreen changes are
in #ifdef HALO_ANDROID: none have been since 50eae19b.
With display.interpolation off the game holds itself to the Xbox's 30
frames a second in main_rasterizer_throttle, which spun on the clock
until the next vertical blank. On the ports each look at the clock is a
system call: a tester's device spent half its game thread there. The loop
sleeps a millisecond between looks now, as it already did while a map
precached (Windows sets its timer to 1 ms, win32_posix.c).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each controller's events went only to its own player's screens, and the
main menu is the first player's: a gamepad on any port but the first
did nothing, and a phone can list a device of its own (its touch
controls) before the gamepad. With one person playing, the first
player's screens now read and take every controller's events; with two
or more, each controller keeps to its own player's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving the profile being edited wrote it to the disk only: a local
player playing with that profile kept its old button and stick layouts,
sensitivity and inversion until the profile was next chosen (the next
mission, the next start). Now each local player whose active profile it
is takes the saved one at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An overlay over the game, from JoshRob297's halo-ce-touch, which ported
theLlamaNet's halo-ce-android: a stick, the controller's buttons with word
labels, swipe aiming, optional gyroscope aiming, the phone's vibration, and
an editor to move, resize, hide, copy, export and import the buttons. It
is player 1's controller, merged into port 0's state as the keyboard is.
Changes from the fork:
- The controls show only in a game. In the menus and during cinematics
they hide and let the fingers through, so HiIAmMoot's menu touch
(upstream #50) and its tap to skip work under them. The game tells the
overlay through a new host import, host_touch_scene.
- They hide while a controller with a stick is connected, such as a
handheld's built-in one, and never show without a touchscreen (a TV).
The new setting input.touch_controls ("auto", "on", "off"; default
"auto") overrides this. Hiding with a controller follows diegotorres00's
upstream #58.
- Swipes that start in Android's edge-gesture zones do not turn the view,
and a press shorter than a frame is held long enough for the game to see
it, as HiIAmMoot suggested in the review of #58.
- The swipe is not the mouse's aiming, so it keeps the controller's aim
assist.
- The phone vibrates from port 0's XInputSetState, so the game's own
vibration setting applies and no game source changes. It vibrates only
while the touch controls show.
- The fork's cheats menu and its built-in cheat buttons are left out.
Co-authored-by: JoshRob297 <99092299+JoshRob297@users.noreply.github.com>
Co-authored-by: theLlamaNet <107216990+theLlamaNet@users.noreply.github.com>
(cherry picked from commit 7bd7481bdede370558a3c93d286e37a9c24237bd)
(cherry picked from commit 5911e2312b6960de58dfa1cad2fefce1a17b97f7)
A finger's targets: a tap on a setting row's label reaches its value, and the values are wider. The mouse clicks the labels and values where CE's menus have them, as before the PR.
Operate the Android menus by touch
Conflicts resolved: the pointer's window-to-menu mapping takes main's centering (the scoreboard's) and the PR's missing-back-buffer check, and is shared; the scoreboard's pointer stays the desktop's (an Android stub); FOCUS_LOST drops both the scoreboard's pointer and the touch; main's Steam on-screen keyboard and the PR's Android pointer each keep their side of the #ifndef; CI runs test_touch_menu.py with the harness.
Its only user, tiff_export, wrote the Xbox's movie recorder's frames, which nothing starts: the builds leave out source/bitmaps/libtiff/ and tiff_file.c, and port/linux/game/tiff_file.c's tiff_export says it writes none. port.json's exclusions may name a folder (ending in /).
ORION's icon (port/assets/icon, credited in AUTHORS.md) is the Android launcher's adaptive icon (the render as its foreground, its background's gradient behind it, the armour as its themed layer), halo.exe's icon (port/windows/halo.rc, compiled by llvm-rc) and the window's icon on the desktop builds (SDL_SetWindowIcon). tools/app_icon.py makes them all, in place of tools/android_icon.py and its emblem.
Zero credit: remove 51 guard-only headers, 50 project-list entries, four useless includes and two unused enum definitions. Preserve source assertion locations and both repositories existing READMEs. Clean rebuild, unchanged 8252-row strict snapshot, unchanged code/data credit and admissions, 1663 tests plus 159 subtests pass. Disclose 76 compiler-local label renumberings and two warning source-line shifts; runtime sections and relocations remain identical. No private assets or scratch tooling published.
render_model_parts now follows a link whose previous part is 0, which retail did not: of the 10862 parts of the 24 stock maps' models none has one (the needler's 2 and 3 are their only links), so it changes nothing there. The comments say what the links are for, and the long conditions are wrapped.
The 320x240 colour target the reflections and active camouflage draw into gets the screen's pixels a unit, as the screen-sized targets do; with resolution_scaling "original" and on Android it is as before. The PR's depth texture of the colour's size is left out: GL draws into attachments of different sizes (their intersection), main already pairs this target with the screen's depth, and it would have bypassed bind_targets' multisampling.
Draw the reflection at the display's pixel density
d3d8_gl.c is main's in this merge: its render target and depth code have changed since (multisampling in bind_targets); the PR's change follows on main's code.
The Android guest has none of the SDL calls the PNG takes (SDL_CreateDirectory, SDL_SavePNG...), so its build did not link: the screenshot is written on the desktop builds only, and the key does nothing on Android. Its test, which CI did not run and which no longer built against main's headers, is dropped.
Add rebindable PNG Screenshot action (default F10)
Conflicts with voice chat's push to talk resolved: SCREENSHOT is after PAUSE MENU and before PUSH TO TALK, in the actions' enum, their settings and Controls Setup alike.
The enhancements are on unless config.toml's game.enhanced_animations (HALO_ENHANCED_ANIMATIONS) turns them off; the setting is not in the menus. unit_preprocess_node_orientations sets model_node_count again before the fitting checks use it, as it did: the PR had removed it, which left it unset for every unit. models.h for model_finds_node, and the PR's indentation and spacing as the files'.
A Wayland touch screen's keyboard types text events, which the menus' fields do not read: text input is started only for Steam's keyboard (SDL_ENABLE_STEAM_SCREEN_KEYBOARD). The sysroot's extraction writes nothing through a link that leads out of it. The three SDL versions (Windows, Android, the Linux sysroot) name each other. #222's test stubs the screen keyboard and passes the text field's new password argument.
A completed fade comes back at full gain only to a second query in the same frame, which the game never makes: update_channels stops a voice whose fade out reaches 0, and sound_start_fade starts a new fade from it. Keeping the endpoint made every new voice's gain depend on fade_interpolation_end being set where it is created. That part is left out, and its test cases with it; replaced looping components are still retired.
The Xbox debug keyboard's End and Escape stop the movie and restart the map; this keyboard reaches the game only through the console and the menus' text boxes, so they only ever did so by accident (End in a text box too). They are left out, and with them the keys the console consumed on closing and the test and CI steps that extracted them; Escape still closes the console, and the held-key guard still keeps it from pausing.
Fix Escape restarting the level from the console
Conflict with #222's typing guard resolved: the held keys are kept over a switch of the menus, typing or the console.
A scripted first-person camera looks along its unit's aim, which turns once a tick: placed by the observer and the object's move alone, it turned in 30 Hz steps in a vehicle; it is left to its snapshots' blend.
usnprintf terminated every string it wrote, so an exact fit lost its last character on Windows (a score limit of 100 shown as 10); the strings the PRs bound are terminated where they are written, as the rest of the game's are. The title copies are ustrncpy again (title_string[79] is terminated after). A map's format takes flags, then at most three digits of width and three of precision, so %5-d, %1.2.3d and huge widths are not passed to Windows's printf.
0.97 is safely below player_control.c's 0.98 cutoff, diagonals too, with no one-float-step margin to guard; the test that extracted the gate by its braces, and the harness flags only it used, are dropped.
main_menu.xml is generated from CE's tags; with the keyboard's typing mode ended as it closes, Enter is A again and CE's own Start handler can stay. Its test is dropped with it.
Menus: no save file read each frame
Conflicts with the Custom Edition map lists resolved: with no last_used, the list is still the Xbox's maps and then the Custom Edition maps (level_count), not the Xbox's 13.
The side-by-side debug build's Gradle and manifest changes are left out: UpdateProvider's authority stays hard-coded, so its updater would not match them.
Slot 0 was the tests' scratch query and taken for slot 1, so the game's lens flare 0 read flare 1's visibility; a query of its own is the scratch (#150's full batch too), and every slot is a test's. The query buffer is unbound after it is mapped, so a fallback read is not taken for an offset into it. #171's comment as port: comments are.
A copy of each lens flare's result right after its test made the GPU wait
for that test, hundreds of times a frame in The Library (51-61 fps with
drops to 20 on an RX 6700 XT). Each frame's results are now copied
together at the next present, when they are long done, so the GPU never
waits (160-213 fps). Two batches of query objects alternate so a query
is not reused before its count is copied. Flares react a frame later.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A function a map's widget may not run (#203) failed as an invalid one does, so the handler opens and closes no screens after it. The four-taps resampler's difference from tap by tap (#221) is measured at -115 to -126 dB, not -140.
The in-game list of who talks colours each name as the names above players' heads are (hud_player_name_color, now shared): allies in the HUD's blue, enemies red, this machine's own players white; in co-op every other player is an ally.
limit looked through its 64 frames ahead for the smallest gain they need
on every output frame, even while nothing is loud enough to need any
(the menus, most of a game). It now counts the frames that need a gain
under 1; while there are none the smallest is 1 without looking.
The output is bit for bit the same (test_mixer.py's scene, whose voices
go over full scale). With no voices playing the mix costs 4.8 instead of
16.9 ns a frame on the desktop bench, 2.7 instead of 20.5 on arm64.
The mixer's audio thread spent about 70% of its time in the resampler's
low pass (perf on a Steam Deck in b30 and d20): 48 taps a voice and output
frame, each blending two phase rows, read one stereo frame at a time from a
ring buffer and added one by one, for both channels even of a mono voice.
Now each voice's history keeps each channel apart and each frame twice,
RESAMPLER_HISTORY apart, so the frames a low pass reaches are side by side
however the ring wraps; a table of each phase row's step to the next makes
the blend a multiply and an add; the taps are summed four at a time in
vector lanes (SSE on x86, NEON on ARM, the compiler's vector extensions,
with a plain loop for any other compiler or with RESAMPLER_SCALAR); a mono
voice works out one channel; and the voice's low pass states stay in
locals while it is mixed rather than in the stream, which the output's
stores could alias. The filter itself (24 zero crossings, cutoff 0.96,
Kaiser 6.5) is unchanged.
Audio thread CPU (per-thread /proc, % of one core):
- Steam Deck, b30 with a bot (30 s): 14.7 -> 9.0, the baseline 15.6-15.8
over three 90 s runs;
- desktop (Ryzen AI Max+ 395, the game under Xvfb, SDL's dummy audio
driver, b30 with a bot, 90 s): 6.0 -> 2.0 and 5.5 -> 1.8;
- the mixer bench (tools/harness/tests/test_mixer.py bench, 24 voices, 16
out of earshot, indoors): desktop x86 32-bit 753 -> 368 ns a frame,
arm64 (Apple A18 Pro, NEON, as the Android build's) 696 -> 330 ns.
Only the order of the additions changes. Built tap by tap
(RESAMPLER_SCALAR) the mixer's output is bit for bit the old one's, for
every test voice and a whole scene's mix (reverb and limiter); with
vectors it differs from it by at most -126 dB of the peak (-132 to -138
dB RMS), float's rounding. Against the low pass worked out in double
precision both are at -105 dB; a 22 kHz voice is flat to 0.001 dB to 8
kHz, -0.47 dB at 10 kHz, the rest 79 to 89 dB down; a 44.1 kHz voice the
same to 20 kHz. test_mixer.py checks the vector and tap-by-tap mixers
agree.
tools/harness/tests/test_mixer.py compiles dsound_sdl.c's mixer (its
resampler, voices, reverb and limiter) against a fake world and checks it,
so a faster mixer can be shown to sound the same:
- the resampler against the same Kaiser-windowed sinc worked out in double
precision tap by tap, on noise and an impulse at 22, 32, 44.1 and 48 kHz
and pitched up and down (17 to 96 kHz): within -100 dB (it is at -105 dB,
the table's interpolation);
- sines as heard: a 22 kHz voice flat within 0.5 dB to 10 kHz and a 44.1 kHz
one to 20 kHz, everything else that comes out at least 70 dB down (80 to
89 dB in the band); a tone a voice pitched up takes past 24 kHz does not
come back as an alias (over 60 dB down);
- a voice out of earshot, which is not resampled, moves on as one heard does
and sounds the same once it is in earshot, also as its packets run out;
- a mono voice is heard as a stereo one with both channels the same.
Three faults (taps one frame off, half the phase rows, no narrowing of the
low pass for a voice played fast) must each fail a case. The harness links
libm for it.
mixer.c also writes voices and a whole scene's mix as raw floats, to compare
one mixer with another (MIXER_SOURCE), prints the figures ("metrics") and
times a game's worth of voices ("bench").
A player talking in voice chat has the speaker just left of their name above their head, centred on its capitals, at the name's scale and fade; only where the name itself is shown (display.player_names, and the gametype's motion tracker rules).
A player who leaves during a vote against them is kept out for network.votekick_ban_minutes, as if the vote passed, and while a vote runs against them the host refuses them joining again, so no one dodges a vote by leaving.
Voice chat's speaker and muted speaker are Lucide's volume-2 and volume-x (1.53.0, ISC: port/assets/icons/lucide, credited in AUTHORS.md, its licence shipped beside the game), drawn white into the menus' bitmap voice/speaker by tools/ce_menus.py and tinted, in place of the speaker made of rectangles. In the lobby, on the scoreboard and in the list of who talks, the icon is centred on the middle of the text's capitals (draw_unicode_string_capital_middle).
Nothing said which controllers the game had found, so a controller that
did nothing (Steam Input's virtual pad not seen, a pad SDL does not know)
could not be told from one the game ignored. The log now has a line for
each gamepad SDL opens, with SDL's name for it. Android's guest reaches SDL
only through host_imports.list, which has no SDL_GetGamepadName, so it
opens its pads as before.
On a Steam Deck in Game Mode, under Steam Input's default template, the
game sees one pad, Steam's virtual one, which SDL names for the controller
behind it ("gamepad: Steam Deck Controller"); Steam keeps SDL off the Deck's
own controller (SDL_GAMECONTROLLER_IGNORE_DEVICES), so its buttons arrive
once.
The README said only how to add the game to Steam. A "Steam Deck" section
now says what to set up in Desktop Mode and what to expect in Game Mode:
the screen (borderless, the 3D view and HUD at the panel's 16:10
resolution), the controls under Steam Input's default template (the right
trackpad as the right stick; the back buttons unbound), text entry (the
game's own keyboard for names, Steam's for the menus' text fields), the
frame rate (one frame each refresh, following Quick Access's refresh rate
and frame limit) and sleep (the game's clocks stop with the Deck; a
network game does not wait).
It suggests no lower display.resolution to save power: on the Deck (b30
with a bot, 60 Hz, 90 s) 960x600 measured 7.8 W from the battery against
8.1 to 8.8 W at the native 1280x800, about the difference between two runs
at the same settings, with the GPU 22% busy against 23 to 26%: the game is
not limited by its pixels there, and the picture is softer.
Checked on a Steam Deck LCD (SteamOS 3.8.16, Game Mode): the game fills
the 1280x800 panel ("screen: 768x480 drawn at 1280x800"); Steam selects
controller_neptune_gamepad_fps ("Gamepad With Joystick Trackpad") for it;
with display_framerate, it shows 60 frames a second at 60 Hz and 40 at
40 Hz (gamescopectl debug_set_fps_limit 40); after the process was stopped
for 8 seconds (SIGSTOP), its sound and frames came back at once.
The menus' text fields (Server Setup's name and password, the password a
listed game asks for) take only a keyboard's keys: on a Steam Deck in Game
Mode, with no keyboard, there was no way to type into them but to bring up
Steam's keyboard by hand (Steam + X). The game's own on-screen keyboard
(profile and gametype names) works with the controller and is unchanged.
While a field is typed into, the platform layer now runs SDL's text input
where the system has an on-screen keyboard that text input brings up:
Steam's, which Steam asks for with SDL_ENABLE_STEAM_SCREEN_KEYBOARD in Big
Picture and Game Mode (SDL 3.4 opens steam://open/keyboard, and
steam://close/keyboard when the field ends), or a Wayland touch screen with
no keyboard. The field is single-line: Enter on Steam's keyboard ends it, as
a keyboard's Enter does, and closes it. A password's field asks for a
hidden password's keyboard (SDL_TEXTINPUT_TYPE_TEXT_PASSWORD_HIDDEN; Steam's
is the same). Elsewhere text input stays off, so that no input method takes
the keys the field reads. SDL is called from the window's thread (the event
pump), whichever thread the menus run on.
Steam does not say when its keyboard goes (its Enter, or closed by hand),
so SDL holds it to be up while text input runs. Each field begun brings it
up again, even when the last field ended in the same frame, as the password
screen's does after a wrong password (its field ends and begins again): text
input still running is stopped, which closes Steam's keyboard, and started
half a second later, which opens it (Steam takes each as a URL, each
through a process of its own, which could otherwise arrive the other way
round). A keyboard closed by hand comes back when the field is begun again.
On a Steam Deck (SteamOS 3.8.16, Game Mode, the game started from Steam),
the password screen (debug.menu_open) brings Steam's keyboard up at the
bottom of the screen, below the field; keys pressed on it type into the
field, and its Enter ends the field (the game tries to join) and the
keyboard goes (Steam's log: open/keyboard ... Mode=0, then close/keyboard).
The Linux release did not start on a Steam Deck: it was linked against the
build machine's glibc (Arch's, so it asked for GLIBC_2.43; SteamOS 3.8 has
2.41) and needed a 32-bit SDL3, which SteamOS, whose system is read-only,
and most distributions do not have.
The portable build (configure.py --portable, which tools/ci_build.py and so
every GitHub Actions build uses) is now compiled and linked against the
32-bit glibc 2.31 of Debian 11, the Steam Runtime 3 ("sniper") baseline,
and brings its own SDL 3.4.16, libSDL3.so.0 beside the executable, which
finds it there first: a DT_RPATH of $ORIGIN (--disable-new-dtags), which
comes before LD_LIBRARY_PATH, as Steam sets one for the games it starts.
tools/linux_sysroot.py downloads the Debian packages and SDL's source
release into build/linux/third_party, from a ninja edge, so only a Linux
build fetches them. Each package comes from archive.debian.org, else
deb.debian.org, else snapshot.debian.org (a permanent URL, at a time when
the package was in the archive); SDL from its GitHub release, else
libsdl.org. Each URL is tried three times, a moment apart, and each file
checked against its SHA-256. tools/linux_sysroot.cmake builds SDL against
the same system root, with X11, Wayland, libdecor (Debian 12's, for
GNOME's window decorations), PipeWire, PulseAudio, ALSA, udev and D-Bus
loaded when it starts, and the build stops if SDL lacks one of them or
would link to libdecor. A build without --portable is as before. The
workflow installs CMake, pkgconf and wayland-scanner instead of lib32-sdl3,
and caches the downloads; the release zip gains libSDL3.so.0 and its
license (SDL3-LICENSE.txt), and the self-updater deletes their .old copies.
With --pgo=train, the instrumented build's copy of libSDL3.so.0 is made
before it plays.
The release build needs GLIBC_2.29 at most (halo and libSDL3.so.0, by
readelf -V), and links only to libSDL3.so.0, libm, libpthread, libdl,
libgcc_s and libc; libSDL3.so.0 only to libm, libdl, libpthread and libc
(libdecor-0.so.0 is a name it loads, not a dependency). It should start on
Debian 11, Ubuntu 20.04, Fedora 32 and later, untested: there, the 32-bit
Wayland libraries are older than the 1.20 that SDL's Wayland driver needs,
so SDL uses X11 (XWayland in a Wayland session). On a Steam Deck (SteamOS
3.8.16, Game Mode, started as Steam starts a non-Steam game), the upstream
release stops at load time (libSDL3.so.0 not found; GLIBC_2.43 not found);
this one loads b30 on the GPU (radeonsi, OpenGL 4.6) and exits with status
0 at debug.exit_after, with its own libSDL3.so.0 though LD_LIBRARY_PATH
names a folder holding another. Debug and release builds from an empty
build folder (every download), and tools/test_linux_port.py,
tools/harness and tools/test_light_storage.py, pass in an Arch container
with the workflow's packages.
The README and posix_trace_marker.c said the Steam Deck runs the same
gpu-trace service and Mesa as the Steam Frame, so the refusal would help
there too. It runs the same service (tracing on; the trace buffer takes
some 800,000 writes a second), but the Deck's Mesa, 25.3 in both its
32-bit and 64-bit libraries, has no trace marker code, so it writes none
and the refusal changes nothing.
Measured on a Steam Deck LCD (SteamOS 3.8.16, Game Mode, b30 with a bot,
90 s at 60 Hz): with HALO_GPU_TRACE_MARKERS=1, 16.67 ms frames, 0% late,
4.14 W on the APU; refused, 16.69 ms, 0.02% late, 4.12-4.35 W.
Co-op's Server Setup has CO-OP OPTIONS (friendly fire, the extra enemies, player collisions) and VOICE AND VOTING (voice chat and vote kick, as multiplayer's Teamplay Options has them) in screens of their own, as the gametype's options are, each choice with its help; their spinners set config.toml's settings on OK, and the co-op game starts from them.
Opus voice chat (port/third_party/opus 1.5.2, port/linux/src/voice_audio.c, port/linux/game/network_voice.c), relayed by the host, in the lobby too. The host decides in Server Setup > Teamplay Options: lobby voice (on by default), the game's mode (off, team near, anyone near, team, team and enemies near: the default), the near distance, and the quality, 8 to 64 kbps (24). Each player picks push to talk (V), open mic or off, the voices' volume, and the output and input devices in Settings > Audio, and mutes others from the scoreboard's menu. A speaker icon marks who talks in the lobby, on the scoreboard and in a list in game. The host takes a machine's frames only with the key it gave it over its stream, within the quality's size, 50 a second and 8 talkers at once, and sends voices only to machines that asked for them, so the network version stays.
Any player of a network game can start a vote to kick another, on any team, by picking their name on the scoreboard (right-click while it is open frees the mouse) or with the console's votekick; the host's own scoreboard also has Kick and Ban (Ban picked twice). The host alone counts (port/linux/game/network_votekick.c): votes only from each voter's stream, one per real address and again per hardware id (which only removes votes), time played on the host before starting (network.votekick_minutes) and voting, more than half of everyone with the target counted and at least two, one vote at a time with cooldowns for a failed vote's starter and target, and a passed vote keeps the player out for network.votekick_ban_minutes. Builds without it drop the new messages, so the network version stays.
Conflicts:
- d3d8_gl.c, xgpu.h: upstream's own snapshot ring for the visibility
counters left out for ours (host_gl_counters_snapshot, the same idea,
tried on both headsets, which also halves multiview's counts; its copy
already has the memory barrier upstream added); both declarations kept.
- rasterizer_xbox.c: the mirrored skinning's winding and upstream's
many-node matrices, both.
- main.c, network_server_manager.c: both (bots' machines and a lone host
may count down).
- android_build.py: the OpenXR loader and upstream's SDL mouse patch
(another SDL file than ours), both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client makes its copy of a unit the host spawns with object_new, and unit_new gives it the unit tag's grenades (a cyborg's four frags), where the host's game engine gives a player's unit the game's own (none on wizard slayer). The host's inventory, which corrects them, comes a tick or two later, so a client's player spawning with the throw button held began a throw with a grenade its unit did not have: the client saw it thrown and go off, and reported its hits, while the host's copy, with no grenade, never threw, and refused every report as damage the player could not deal. A client's copy of a host's unit now starts with no grenades, and takes the host's when its inventory comes.
In the network test (1v1 slayer on wizard, staged hits and the bots' own fire and grenades, with #209's shock wave fix in both builds), with no added latency over 8 games the client threw 41 grenades to the host copy's 39, and the host refused all 5 reports of the 2 explosions it never saw thrown (11 of 2959 reports refused in all); now 55 throws to 55, no explosion refused for want of a throw, and 2 of 2749 reports refused. At 50 ms a receiver (8 games before: 66 throws to 64, 7 reports of 2 explosions refused; 4 games after) 10 throws to 10 and none of 1396 reports refused. Every unseen throw was in the client's first tick or two alive, with four grenades there and none on the host. Kills and deaths stay balanced.
When the host's teleporter sent a player, a client's copy that had not stepped on the pad itself was put at the far end by the host's word, landing on the teleporter there with no note of it (the game's own teleporter notes the one it lands a player on). The client's teleporter sent it straight back, every tick the host's word put it there, until the player stepped half a world unit off: the copy stood on the pad left behind, 24 world units off, for 6 to 9 ticks after most such teleports and for as long as the player stood still (up to 143 ticks in earlier runs), and the client's hits on them there were refused. A player's unit the host's word moves further than a step at once is now on the teleporter it lands on, as one the teleporter sends is.
1v1 slayer on Wizard, two bots shooting, 16 runs of 12 minutes each way at 0 and 50 ms: the client's teleporter sending the host's player back 85 ticks in 18 episodes before, none after; the copy over 5 world units from the host's for 3 ticks or more 12 times (82 ticks, up to 9) before, none after. Hits refused 3.7% before and 4.0% after, the view error (p50 0.03, p95 0.08 world units) the same.
A frag grenade's explosion deals two damage effects, its explosion and its shock wave (a plasma grenade's too), and a client reports each hit of each. The host took the first of them against the throw it had seen and spent the throw; the explosion's other hits came with it only when they were of the same damage, so every hit of the other damage found no throw left and was refused as damage the player could not deal. An explosion of the player's this tick is now one of the grenade, of any damage its tags deal, so its other damage takes no throw of its own (and is still paid for at the grenade's rate).
In the network test (1v1 slayer on wizard, staged hits and the bots' own fire and grenades, 8 games each) the host refused 4.4% of the client's reports with no added latency (134 of 3072, 104 of them a grenade's other damage) and 4.9% at a 100 ms round trip (153 of 3107); now 0.4% (11 of 2959) and 0.8% (25 of 2986). What it still refuses it should: grenades it never saw thrown, targets gone before the report came, and hits on a player the client still had at the teleporter they had left.
The multiplayer messages, scores and saved games' names are formatted
with map strings, and one whose conversions differ from the arguments
passed (a string where the game gives a number) made the game read an
argument as a type it is not. Each site now passes its arguments'
signature to ustring_format_checked: a format whose conversions are not a
prefix of it gives an empty message, logged once. The shipped strings
all pass.
unicode_string_list_get_string terminates a string at its last
character, size / 2 - 1, so a string whose data is one byte long had its
terminator written two bytes before its data. Strings shorter than one
character are now not used.
The scenario's tag name was copied with strcpy into the game state
header's 256-byte map_name, a tag's name was formatted with sprintf into
the out of objects message's 512 bytes, and csprintf bounded its output
at MAXIMUM_STRING_SIZE even when it formats into temporary, which is 256
bytes and where most callers put tag names. All three are bounded by
their buffers' sizes now.
The game types' score names (format_score_name) copied a map string into
the callers' 256-character score_string with ustrcpy, and the scoreboard
formatted map strings and player names into row_string with usprintf,
neither bounded. They now use ustrncpy_terminated and usnprintf with the
buffers' sizes, and the title string's copies are terminated.
An item's pickup message is a map string that the HUD used as the format
of an unbounded usprintf into a 256-character buffer. It is now used as a
format only if its one conversion is the count the game passes
(ustring_format_takes), with usnprintf, and shown as plain text
otherwise. usnprintf now also terminates a string it cuts short, and
ustrncpy_terminated is a bounded copy that always terminates.
The validator let a tag's or a reference's name be any length, but the
game copies and formats tag names into buffers of 256 bytes (the game
state header's map name, objects.c's messages). A name that does not end
within 256 bytes is now given the empty name, as one outside the tags is.
stream_upload and index_upload rounded the size up to 16 bytes and read
that many from the caller's data, so a draw whose vertices or indices
were not a multiple of 16 bytes read past their allocation (a quad
list's indices on every frame with transparent geometry). Only the
data's own bytes are uploaded now; the room it takes stays rounded.
lookup1_values divided by a code book's dimensions, which a stream may
give as 0, and cast powers past an int's range to int; both are undefined.
A code book of no dimensions or entries now has no values (the stream is
refused), and the powers are compared without the cast.
wide_format copied a conversion's flags and width digits into a fixed
buffer as it read them, with no bound, and a large width padded for a
long time. Formats include map text (ui strings, HUD messages), so the
specification is now built from the parsed values, width and precision
are capped at 4096, and a count conversion's argument is passed over
rather than written.
A widget's event handlers name their function by its index in the table,
which nothing checked, so a game map's widget could run the main menu's
profile functions (deleting and saving player and playlist profiles, the
demos) and the port's own menu functions (writing config.toml, quitting,
connecting), on its created event too. The shipped maps' widgets run none
of these. Now only the port's own menu tags run the port's functions, and
only the main menu runs its profile functions; a refusal is logged once.
saved_game_file_remember_last_used_multiplayer_map wrote 256 bytes from
the caller's name, which the menus' map list keeps in 64, so whatever
followed it in memory went into lastmpmp.txt. The name is now copied into
a zeroed buffer of the written size first.
A host's map name of con, nul, com1 and the like made a client on Windows
open that device as the map, which could hang it. Such names are now
refused with the other invalid map names.
A client refused a host's map name with any character outside letters,
digits and _ - . space and backslash, so a game on a Custom Edition map
named like [H2]_Lockout could not be joined. These five characters mean
nothing to a path and are now accepted too.
An environment variable that only had to be set took effect whatever its
value, so HALO_NO_VSYNC=0 turned vsync off, HALO_NO_AUDIO=0 turned audio
off and HALO_MOUSE_INVERT=false inverted the mouse. Such a variable that
is empty, 0, false, no or off now counts as not set, as a boolean
setting's value already reads.
read_body took strtoull's answer for a chunk's size line, and 0, the
last chunk's size, is also what it answers for a line that is not a
number. A broken or cut response then ended as a complete download, cut
short (the update's zip then failed its check, or an update check's JSON
failed to parse). A size line without a hexadecimal number is now an
error.
posix_hardware_id_source (Windows) checked that a structure's four-byte
header was within the firmware table, not its formatted part: a table cut
short in a system information structure (type 1) had its UUID read from
up to 20 bytes past the table's end, beyond the buffer. A structure whose
formatted part does not fit now ends the walk.
#191's inverse-distance gain replaces the linear fade for a positioned stereo sound rather than adding to it, so it is as loud as a mono sound at the same distance (both curves together left it some 7 dB quieter at 40 units and 15 at 60), and it is the fade the reverb send takes back out, so the reverb follows the 3D room rolloff as a mono sound's does. A 3D sound is not cut off at the tag's maximum distance either: none starts past it.
A stereo channel's positioned sound is now given what a 3D channel's is: the same occlusion, obstruction and underwater listener (as the 3D call passes them) and its class's wet gain make its I3DL2 source, so the mixer muffles its direct path and sends it to the reverb as it does a 3D voice. The room send rolls off with distance as a 3D voice's does, the game's own fade of the stereo voice's volume taken back out of it. Unpositioned stereo stays unpanned and dry.
A sound with a position plays on a 3D channel, but those are mono: a stereo one (a Custom Edition map's gunfire, a bullet's impact) fell back to a stereo channel, which the Xbox never panned, so it was only faded with distance and came from nowhere. update_channels now gives a stereo channel's sound in the world a pan towards it, as the mixer pans a 3D voice; the mixer pans the channels' middle and keeps their difference as wide as the far ear's gain. Unpositioned stereo plays as before, and no channel pools change.
Our version of #147. With audio.loose_sounds (off by default), each of a map's sounds that has a Halo PC sound tag file of its name in the data root's tags folder plays from it (port/linux/game/loose_sounds.c); loose_sounds_reload and loose_sounds at the console read them again or give the map's back. The files' samples are made the Xbox ADPCM this build plays through the Custom Edition converter, so no channel pools change and stock maps play as before. The override is written into the map's definition in place, keeping the counters the game updates; a change stops every sound and flushes the sound cache first, and old samples are freed once no cache block holds them. A split sound's length is its chain's.
The sound cache is HALO_PORT_SOUND_CACHE_SIZE: 16 MB on desktop, the Xbox's 4 MB on Android, with the debug view's buffer sized to match.
A cinematic's camera point relative to an object (a30's lifepod, a
dropship), or a unit's eyes in one, is placed from the object as it
stands after the last tick. It was blended from its own snapshots, taken
at the first frame after each tick, while the object is blended from its
own, or steps from tick to tick when its pose snaps, so the camera ran
ahead of or behind the object as drawn and showed outside the lifepod's
hull.
Such a camera is now drawn where the observer has it, moved by the
object's drawn root node minus its root node as of the last tick, so it
stays put in the object.
Measured on a30's opening at 144 frames a second, the camera's position
in the lifepod's frame from one frame to the next:
- the lifepod's descent (85 ticks, about 400 frames): 52.6 cm median,
200 cm at the 95th percentile, now 0.0 cm and 8.5 cm; off a straight
line through the frames around it, 68 cm median, now 1.8 cm
- the shot before it: 52 cm at the 95th percentile and up to 244 cm, now
1.4 cm and up to 23 cm
The camera is blended between snapshots of the observer, taken at the
first frame after each tick, and the objects between their last two
ticks. After a long frame (several ticks before it), the camera's
previous snapshot was several ticks old, so for the rest of the tick the
camera moved several ticks' worth while the world moved one: in a
Pelican, out through its hull for a frame.
The previous snapshot is now placed one tick back from the latest, on a
steady move between the two, so the camera moves with the world. A cut
is still a cut: the test is made on the snapshots as taken.
Measured on b30 at 144 frames a second with a 100 ms stall every 50
frames (3 to 4 ticks in the next frame), first person in the Pelican:
on the frame after a stall, the camera moved in the Pelican's frame by
23.6 cm (median) and up to 208 cm; now 0.06 cm, and 0.5 cm at the 99th
percentile.
#186's comment over the constants goes, and the resampling section says again what the low pass does in figures: a 22 kHz voice 0.5 dB down at 10 kHz, its images 80 dB down but for those of the band's last few hundred hertz, about 20 dB.
#180 gave a weapon without a second magazine the secondary's overlays at
rest (default and always) and states at rest (not disabled). The Xbox
never set those overlays, and an empty magazine's total ammunition state
is disabled, so the stock HUDs' secondary numbers and icons, which never
showed, drew over the campaign HUD's ammunition and grenade panels.
Such a weapon now leaves the secondary overlays unset, as the Xbox did,
and its states read the cleared, empty magazine. #180's fix stays: the
secondary's overlays no longer write over the primary's.
Text boxes take a string index from 0x5000 as a spinner's extra item's
description (kills_to_win_extra_descriptions). Custom Edition multiplayer
maps' display indices now run from 0x4000 to 0x6000 (8192 maps), so a
list of more than 4096 showed the next maps' names as kills-to-win
descriptions. The descriptions move to 0x7000, which custom_edition_maps.c
already keeps every map's display index below.
The HUD's text takes '|' and the character after it as one escape (draw_string.c, parse_unicode_string), so a map's message ending in '|' had its drawing read past the terminator. sv_say leaves '|' out of what it shows.
ES 3.1 makes atomic counter writes visible to a buffer copy only after glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); Zink and Turnip, where #122 was measured, add the barrier themselves, other drivers need not. glMemoryBarrier joins the Android GL list (a driver without it is only called with atomic counters, which need ES 3.1).
#152's fallback gets a port: comment and includes cseries/errors.h, as the rest of source/ does, and clamps t with PIN; a pitch not a number still samples the first point.
The trace_marker interposer read a mode whenever any bit of O_TMPFILE was set, which O_DIRECTORY's bit is, so opening a directory read an argument never passed. It tests as glibc's __OPEN_NEEDS_MODE does. The comment no longer says the Steam Deck was measured.
Custom Edition maps' scripts call these Halo PC functions (lookout_classic
and the Halo Kart maps show players messages with sv_say), which did
nothing here. sv_say now shows the message in each player's HUD, and
sv_end_game ends the game on the host, as its time running out does.
They go at the end of the function table, so Xbox maps, which call
functions by their place in it, are unaffected; Custom Edition maps call
them by name (custom_edition_scripts.c).
sv_end_game adapted from ChupathingyCE (4e89e01f), by MrMilenko.
A particle state names its bitmap's sequence, which a Custom Edition map's
bitmap may not have, or may have with no sprites (Hornets Nest's). The
draw read past the bitmap's sequences, then took the sprite index modulo
a sprite count of zero. Such a particle is now skipped.
Found by ChupathingyCE (MrMilenko).
render_weapon_hud set the secondary magazine's overlay flags into slots 0
and 1, over the primary's, where its states and numbers use 4 and 5: a
weapon of one magazine showed its ammunition overlays as the empty second
magazine's (coldsnap's flamethrower read NO FUEL with a full tank).
A weapon of one magazine also read a second it doesn't have:
weapon_build_weapon_interface_state fills only the weapon's own, into
unset stack memory, so the HUD's secondary flash, reload and fired-empty
warnings followed whatever was there. The state is now cleared first, and
a weapon of one magazine keeps its secondary states and overlays at rest.
coldsnap_20th_xl's scripts start an object_name global as none, which
this compiler refused ("none: this is not a valid object name."), so on
a Custom Edition map the scripts using it were dropped. none is now no
object: the object_name type's own default, which every lookup takes.
Adapted from ChupathingyCE (92342517), by MrMilenko.
The #ifdef from #111's cherry-picked commit goes at column 0, as the rest of port/linux/src puts its directives, and its comment says what the setting is and what 1024 frames costs in latency.
The powerup bounds of #117 move from source/models/models.c to port/linux/game/powerup_render_bounds.c, called from scenario_tags_load beside the other tags-loaded hooks, so the game's models.c no longer takes <xtl.h> and D3D buffer locks. It uses square_root and a relative margin in place of sqrtf and nextafterf, which strict gnu89 does not declare, and the objects.h scale change is marked as the port's. tools/test_powerup_bounds.py is dropped: it redeclares the engine's types by hand and CI does not run it.
On bloodgulch the active camouflage's render radius grows from 0.1 to 0.23 and the overshield's from 0.1 to 0.21; no other equipment changes.
hud_hires_override_point_threshold is bounded as hud_hires_override_coverage is, by hud_hires_asset_count (which caps at MAXIMUM_TEXTURES), and is described in hud_hires.h. tools/test_hud_thresholds.py is dropped: it needs a hidden OpenGL 4.5 window, SDL3, scipy and a 64-bit lld build, so CI cannot run it.
A tree patched by another version of sdl-relative-mouse.patch (an older checkout, or CI's cached third_party, whose key now includes the patches) has the listener put back as SDL has it before the patch is applied, rather than failing the apply and turning the Android build off. The patch is regenerated with git diff, with its context lines.
The cache is forgotten before every game state load (beside cluster_partitions_port_forget) and at each new map, after the declarations, rather than at the top of four functions.
#173's PC vehicle set is a new vehicle set value the host sends with the
gametype, and every machine places the map's vehicles by it: a client of
version 23 would place none of a set it does not know while the host places
them all.
#151 clamps a radius below zero where point physics takes it. The tag
checks now correct each field it is made of when the map loads, as with
other bad values: particle radius bounds, particle system radius variables
and multipliers, contrail widths, weather particle radius bounds, and the
radius bounds of effects' and breakable surfaces' particles
(tag_validate_non_negative). The clamp stays for radii the game computes.
Fixed: Weapons whose trigger had flag "analog rate of fire" now read how far the trigger is pulled: fully for mouse/keyboard, the actual pressure for a controller trigger. Before they never fired.
source/input/input_abstraction.c
source/input/input_abstraction.h
source/game/player_control.c
Fixed: Weapon spawn orientation on Custom Edition maps. Multiplayer weapons and items now spawn facing the way the map places them, as in Halo PC.
source/game/game_engine.c
Added: PC vehicle set. There's a new PC option in the vehicle set list, between Gun Turrets and Custom. A team set to PC gets every vehicle the map places, as in Halo PC. On Custom Edition maps that means every placement whose spawn flags name the gametype.
port/assets/menus/ce/strings.xml
port/linux/game/menu_functions.c
source/game/game_engine.h
source/game/game_engine.c
Added: Start LAN, internet and split screen games alone. A game with one machine and one player can now start. Pressing start alone begins the countdown, others can join while the game is in progress, and the lobby no longer says it's waiting for another machine or player.
source/networking/network_server_manager.c
source/interface/ui_widget_game_data_input_functions.c
With no gametypes listed (the built-in ones could not be made: a save
root that cannot be written, or its lock timed out), the list's update
set the chosen row to MIN(focused, count - 1), which is -1 and passes
the chosen < count checks: gametypes[-1] was shown, and A or X began
editing or deleting it. The chosen row is now at least 0, as
gametype_edit_read already keeps it, which with no gametypes chooses none.
vertex_shader_get kept a failed compile's 0, which it also takes to mean
not compiled yet, so a vertex program the driver refused was translated
and compiled again at every draw that used it, with its whole GLSL logged
each time: a stall every frame, and a debug.txt that grows without end.
Each of a program's four shaders is now tried once, as pixel shaders and
programs already are.
objects_port_set_activating_cluster takes the cluster a co-op host sends
in its presentation (network_coop.c), and refused only NONE and indices
past the BSP's clusters. Any other negative value (activating_cluster is
sent plus one, so a host sending -5 or 0x8001) was stored, and the client
then read the cluster PVS before the start of its array. Any negative
index now means none, as NONE already did.
The engine's real functions, taken from the sources and compiled against a
fake world, so a fix can carry a test CI runs without game assets. A test is
tests/<name>.c (fake world and cases, CHECK) and tests/test_<name>.py (takes
the code, enums and constants from the sources, builds, runs each case under
pytest). Each test has negative controls: the code with a deliberate fault
must fail a named CHECK. CI runs pytest on tools/harness, so new tests need
no workflow change.
- test_object_bounds_cache: collision's loop over a crowded fake world gathers
the same features with the cache, as objects move and after invalidation;
a stale cache is detected.
- test_widget_pool: widgets.c's pool fills to its size and refuses (64 slots
against 128 players' rifles, #145), frees and reuses, and frees a failed
widget's slot.
A collision query (collision_get_features_in_sphere) reads the bounding
sphere of every collideable object in every cluster it touches, to pass
over the far ones. Every biped queries each tick, so a crowded cluster
(co-op's extra enemies, a carrier burst) costs the square of the crowd in
scattered object reads.
object_bounds_cache.c keeps the spheres packed by absolute index, written
by their only writer (object_compute_node_matrices). The loop runs the same
point_in_sphere on the copy, so the same objects are passed over and the
same features gathered, in order. game_state.c invalidates the copy when
the game state is replaced (revert, loaded core or game, new map).
Deterministic offline crowd (c20, legendary, extra enemies x8, 24 carriers
burst, seed pinned): game state identical tick for tick; collision queries
4.2 -> 3.0 us (-28%), object updates -15%.
#154 keeps blocks of a Custom Edition map that version 22 cut to the Xbox
tools' limits (scenario vehicles, animations, seats), so a host and a client
of the two versions would place different objects on such a map; #155's map
version check also only guards clients that have it.
The game's free (debug_free) reads a header before the pointer, so NULL
faults at 0xffffffe0. A Custom Edition sound that failed to decode freed
its frames with none made, which crashed Linux on loading CMT's a30 with
Custom Edition's later sounds.map; the decoded sounds, model data and
scratch buffer are now freed only when they were made.
The co-op host's console command bringto teleports every other player
beside the host's player, or beside one who already came when there is
no room there, and says how many came. A client is told it is the host's
command.
mix_voice stops mixing a voice once more than 2 * width frames of silence
have been taken (its packets ran out). The centre then never moves, so the
history stays full, take_frame is never called again, and silence, which
only a frame taken resets, stays over the limit. stream_process starts a
stream over only when it has no packets, but the game queues its next
packet before DirectSoundDoWork has completed the finished ones, so the
stream still has some, and the voice stays stopped with its new packets
unplayed. A streamed sound, the music, stopped for good after a hitch
longer than its queued packets, such as a level load.
A voice stopped this way now starts over (resampler_reset, as
stream_process does) as soon as a packet not finished has frames. Packets
with none left are marked finished on the way, as take_frame marks them,
so DirectSoundDoWork completes them.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jhszivGTnJoXcpPBKX3E1
mirror_refresh writes a page uploaded for the first time through an
unsynchronized mapping (no queued draw can read it) and a page uploaded
again with glBufferSubData. On Mali, a glBufferSubData into a buffer that
queued draws read gives the buffer new storage at once and copies the old
contents into it on the GPU's timeline. An unsynchronized write made in the
meantime goes into the new storage and is then overwritten by that copy.
After a level change the next level's load does both in the same segments:
it uploads some pages again (the earlier level's) and some for the first
time, and some of the new level's vertices were drawn as the earlier
level's. A level loaded first uploads no page again, so it never showed
there.
Each segment now records device.frame at its latest glBufferSubData. A
first-time page goes in unsynchronized only once STREAM_BUFFER_RING frames
have passed since then (Present waits for each frame's fence when it
reuses that frame's stream buffer), and with glBufferSubData otherwise.
The frames are compared by unsigned subtraction, which keeps a short age
right across the counter's rollover; only an age of a whole counter period
or more could alias, and only towards glBufferSubData.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jhszivGTnJoXcpPBKX3E1
The gametype's vehicle respawn time put back only the first 64 vehicles a
map placed (MAXIMUM_VEHICLE_HOMES): a map places up to 80, and a Custom
Edition map more, and those past the 64th, destroyed or left empty, never
came back. It now keeps up to 1024.
A client ran biped_falling_damage's outside-the-world erase on the host's
AI bipeds too: object_delete refuses them there (network_objects_may_delete),
so nothing was erased, but each was logged as erased. On a co-op client of a
crowded game that was hundreds of false warnings. The host erases them, and
its delete reaches the clients.
EXTRA ENEMIES' PER PLAYER grew each squad by its size for every player past
the first at 100%, held back only by the actor pool: in a game of 65
players each squad of six grunts had about 378 more. The host's ticks
stalled (every client froze, then its players jumped), the clients ran out
of objects (enemies the host had and they had not, which killed players
unseen) and past their renderer's limits, and the enemies with no free
ground left were placed off the map, where the clients' copies fell. A
squad now grows to COOP_ENEMIES_MAXIMUM_GROWTH (8) times its size at most.
find_position_for_ball returned an uninitialized point when the map has no
oddball ball spawn flag, as a Custom Edition map may not: the original's
assertion there is fatal, but a release build carries on past it, so the
host created the ball wherever the stack left it. It now goes to the
map's first player starting location, or the origin when there is none.
The multiplayer menu's state was initialized positionally, and the fields
added before maximum_players_index since made its initializer fill the
gametype list instead: the index started at 0, so MAX PLAYERS opened at 2
and a host who left it there started a game no third player could join.
A client joining a game on a Custom Edition map checked only that it had a
file of that name. With another version of the map (an older release, a
re-edit), its tags are not the host's: the objects, weapons and vehicles the
host sends are other things there, and the game desyncs or crashes.
The host now sends its map file's header checksum as the game record's map
version, which the Xbox game always left 0, and a client whose copy has
another checksum leaves with an error saying so and where to copy the
host's. The record's layout is unchanged: a host that sends 0 (an older
one, or the game's own maps, whose builds of other regions play together)
is not checked, and older clients ignore the field.
The tag checks cut every block to its schema's maximum, and some maxima are
the Xbox editing kit's, not the length of any array of the game's: the game
takes those blocks as long as they are. Custom Edition maps' tools go past
them, and the cut took coldsnap 20th XL's last 20 vehicle placements and
bigass_v3's player graph's 42 last animations and 12 last seats (their
references made none). Such a block (_tag_schema_tool_maximum_bit: an
animation graph's animations and unit seats, a scenario's vehicles) is
kept whole on a Custom Edition map; the graph check walks as many
animations as a short indexes. Xbox maps are checked as before.
A Custom Edition map's linear bitmap rows need not be whole 64-byte steps:
its pixels are padded to them as they load (custom_edition_bitmaps.c). The
texture cache refused one that wasn't (it was never drawn), and since the
tag checks came in, the validator also made it swizzled, which drew a
non-power-of-two bitmap scrambled: sanctuary's kill icons, 24 bitmaps.
Both now take the padded pitch for a Custom Edition map; the Xbox maps'
rows are whole steps, as before.
On the Steam Frame the game stopped right after a melee: the VR log of the
first-person skeleton's nodes looked up the player's weapon, which had been
deleted since the tick (object #93 unused), before the game's own check
forgets such a weapon ("deleted unexpectedly"). It now skips a weapon
that is gone, and the grenade drawn in the free hand checks its unit and
grenade the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client joining a game on a map it does not have (a Custom Edition map
not in custom_maps, or one of the game's own not in maps) now leaves with
an error naming the map and where to copy it, instead of the damaged disc
error. It also says when the map cannot run there or the host names a
Custom Edition map as other versions do. The port's error texts wrap to
their dialog, in the smaller font when they would not fit.
Network version 22: hosts now name Custom Edition maps custom_maps\<name>,
which older clients cannot load or explain; they are told to update.
Conflicts: port_config.c (both sides' debug settings), hud_weapon.c (the
VR crosshair filter before upstream's state index check),
first_person_weapons.c (the VR arms kept; upstream's animation node check
added, and its node count limit applied to the VR arms too).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Custom Edition maps are loaded from the custom_maps folder, as level
custom_maps\<name>, apart from the game's own maps of the same name. The
map lists get CUSTOM SINGLEPLAYER and CUSTOM MULTIPLAYER, by the scenario
type in the map: a custom singleplayer map plays as a campaign level, or
as network co-op from the Map screen; a custom multiplayer map plays as a
multiplayer map. A map with an OpenSauce header that asks for nothing of
OpenSauce's runs as stock Custom Edition runs it.
Found reviewing the Xbox and Custom Edition map loaders before servers
let clients download maps:
- the Custom Edition sound converter sized a resampled buffer from a
stream's own rate, which could wrap the 32-bit size and overflow the
heap; every frame buffer is now bounded
- script constants not parsed again must be of their own type, and the
pass-through forms' arguments of the type they are returned as, as the
compiler emits them; a cache's script source is no longer recompiled
- model index buffer data may not lie in a tag's bytes, parts' own
geometry blocks are emptied, and a lightmap buffer of no vertices is
dropped
- Custom Edition models have their local nodes made before any is
verified, and a resource sound's header must lie in the map's tag data
- bitmap pixel ranges are checked with the size the game reads, and HUD
message spans past their text are cut
A plasma bolt is drawn by its light volume, a widget, and both pools were
still the Xbox's: 64 widgets and 256 light volumes. Every assault rifle
holds a light volume for its flashlight beam from when it is made, held or
dropped. In network co-op the rifles left lying about are kept as garbage
in proportion to the players, and garbage that any player can see is never
collected, so a large game fills the widget pool and keeps it full; a client
that joins a game in progress makes all the rifles at once and takes every
slot. From then on widgets_new finds the pool full and skips the widget, so
every bolt flies and hits unseen.
- widgets 64 -> 2048 (HALO_PORT_MAXIMUM_WIDGETS)
- light volumes 256 -> 2048 (HALO_PORT_MAXIMUM_LIGHT_VOLUMES); with only the
widgets grown, the light volumes run out next
At 128 players a game can hold about 1,000: the rifles in hand, the ones on
the ground (garbage collection starts at 400 and keeps those in view) and the
bolts and needles in the air. The CPU part of the game state grows by 38,144
bytes, to 18,242,828 (2,466,548 free). As with any pool size, saved games of
earlier builds no longer load.
Improve grenade throw and vehicle rider animations
Keep the legs moving during grenade throws, blended by speed and direction, with smooth stops
Stay crouched when throwing while crouched
Use the floating jump pose when throwing in mid-air
Keep Warthog and Scorpion riders seated when throwing
Release the Scorpion grip IK during throws and reloads
Borrow the standing reload for Scorpion seats that have none
Let players turn their body while throwing (bipeds.c)
From TwistedFlog's PR #104: Android GPUs without atomic counters (and the desktop when the result buffer cannot be mapped) said a test was incomplete, and the game spun until the GPU caught up. The slot's last known count stands until its query is available.
The server and LAN browsers name each visible row's map every frame
(map_display_name), and the lobby shows its map every frame
(lobby_map_show). Both went through ui_widget_port_multiplayer_maps,
which also reads the map used last from z:\lastmpmp.txt. Until a game has
been hosted there is no such file, and each failed read also writes two
lines to debug.txt. The map used last is now looked up only when the
caller asks for it (last_used not NULL), as the menus that open a map
list do.
profile_name_show read the last-used profile at most once a second when
the read worked, but read one that could not be read again every frame.
That one is now tried again a second later too.
Co-Authored-By: Claude <noreply@anthropic.com>
A player who quits gives up their slot once their body is gone, and the next player to join takes it (network_game_spawn_player). The game types keep scores by slot, and nothing cleared them, so the new player started with the quitter's score (in free for all also the team's, as the team is the slot), and one who left and came back could find their old score again. Each game type's player_added now clears the slot: slayer's and oddball's scores, king's time and hill, race's laps and flags.
- README.md: what the mod is (a VR mod for Halo: Combat Evolved), what is
needed (a disc image of one's own, purchased copy), simple installation
for the Quest 3 / 3S (SideQuest) and the Steam Frame (Desktop Mode, the
installer), playing, reporting problems, network use, credits (OpenCE,
the decompilation, HaloCE-Quest-VR, HaloCE-VR, extract-xiso) and the
legal notes (unofficial, not affiliated with Microsoft and others).
- THIRD_PARTY_NOTICES.md: the libraries and fonts and their licenses.
- Releases: the APK itself (RingfallMjolnirVR.apk) for sideloading, notes
that say which file is for which headset, the project's name as title.
- An issue template (headset, build, log; no game files).
- docs/OPENCE_README.md marked as the base port's; the disc images'
wording everywhere: made from one's own copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Garbage (bodies, dropped weapons) is kept while a player may see it: in any player's potentially visible clusters, near them or in a 45 degree cone of where they aim, however far. With many players spread over a level and many bodies (co-op's extra enemies), every body was seen by someone, nothing could go, and the whole collection (each garbage object's visibility, the object memory pool compacted) ran again every tick on the host, which also wrote 'garbage collection critical' to debug.txt each tick while short of memory. A player now sees garbage no further than 30 world units off, a collection of active garbage that left too much is tried again only after a second, nothing collected compacts nothing, and the critical line is logged as often as the warning. Running short of memory or objects still collects at once.
Any player crossing a loading zone used to pull the whole team to them, with a two-thirds vote to go back to a BSP already visited: one player running ahead or doubling back teleported everyone, and teams ended up several BSPs back. Now only the host's players switch the BSP (anyone's while none of the host's are alive), the team is brought to the host who crossed (not to a client waiting on the trigger), the stranded and the respawning come back beside the host first, and a client standing on a loading zone is told it waits for the host. Clients still never switch on their own, and the host still waits for every machine to load the new BSP.
The killing blow went once, unreliably, while the units' states, sent every tick, killed a client's copy of a player the moment they said dead, so a late or lost blow left the body to die without its death animation, push or kill message. The host now also sends each killing blow reliably (a client replays one blow of a unit only; the reliable copy is not dropped as overtaken by the damage sent since), and a client waits half a second for it before a player's unit dies without it, as an actor's already did.
The host sent an object coming to rest once, unreliably. One lost left a falling body hanging on a client until its turn in the round of objects at rest, four a tick: in a large game, many seconds. It is now sent three times over half a second. A client also takes the host's word on rest from every state, not only from one far enough off to move its copy, so a copy left at rest while the host's fell no longer hangs; a vehicle the client drives takes it only from a correction, as before, so it is not stopped as it sets off.
Co-op lets a script's wait for every player in a volume pass on one player, so the Maw's lift left with whoever stepped in first and the rest could never follow: it only goes up. When a script waits on volume_test_objects_all of the players (the condition of a sleep_until) and one player is inside, the others are now brought beside that player first. A test of the volume in an if or a not moves no one (hs_runtime_waiting_on_call).
Holding BACK (or Tab) in network co-op shows the multiplayer scoreboard as it is, with its panel, columns and scrolling, but with no game engine it lists the players as they joined, with only their names and pings: no places, kills or scores.
A network co-op respawn spawned the player at the level's starting location and then teleported them beside a living teammate. When the teleport found no room (a teammate in or by a vehicle), the player was left at the start, outside the loaded BSP in later parts of a level, and the stranded rescue took them to the last checkpoint: the host, who can only respawn beside a client, came back there. The respawn now spawns the player behind the teammate it is for (on the teammate where behind them is outside the BSP), and the teleport places them beside them as before.
Revert of c2aceb64: on the Quest 3 the tap did not toggle the flashlight.
The flashlight's button stays; the on-foot vignette that came after it is
kept. The roadmap and README no longer name it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the Frame Desktop Mode runs inside Gaming Mode's session: closing Steam
to write shortcuts.vdf ended the session, and the installer with it, before
it wrote the shortcut (the library kept "Halo VR"). Steam now adds the
game itself (steam://addnonsteamgame, from a desktop file beside the game,
as SteamOS's "Add to Steam" does) and picks its id, which the installer
reads back for the library pictures. An old "Halo VR" shortcut is named
for the player to remove.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Steam's client on SteamOS is not always a process named steam: the
installer may have written the shortcut while Steam ran, and Steam wrote its
own list back when it quit (the library kept "Halo VR"). Steam is now
found by ~/.steam/steam.pid or its ubuntu12_32/steam process, given time
after it ends, and the shortcut checked once written; what the installer
does goes to install.log beside the game.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The product no longer carries Halo in its name; the game it plays is named
only to say which disc image it needs.
- The app: "Ringfall: Mjolnir VR", application id
io.github.cesarus85.ringfall (the Java classes keep the decompilation's
package), its update provider's authority from the id; the launcher's,
updater's, host's and VR options' texts; the OpenXR application name.
- New icon and Steam library pictures, a tilted, broken ring with a falling
streak (tools/ringfall_art.py, from shapes and Overpass; the helmet and
the HALO logo gone).
- The Steam Frame: ~/Documents/RingfallMjolnirVR, RingfallMjolnirVR.apk,
ringfall-mjolnir-vr.sh, Install-Ringfall-Mjolnir-VR.desktop and
ringfall-mjolnir-vr-steam-frame.zip; the installer renames the folder of
the builds before (~/Documents/HaloCE) and replaces their "Halo VR"
shortcut and pictures.
- Releases: only the Android builds, as ringfall-mjolnir-vr-android-*.zip
(the updater's names), and the Frame's installer; the repository
Cesarus85/ringfall-mjolnir-vr.
- The signing key's default alias ringfall.
- README.md is the project's own; the port's moved to docs/OPENCE_README.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Buttons remapped, the flashlight by tapping the head, finer turning and
vignette steps, the transparent geometry groups raised: what they do, the
settings, what the emulator showed and the headset tests for the Quest 3
and the Steam Frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A Steam Frame installer for players, opened in Desktop Mode
(Install-Halo-VR.desktop, a release asset): it downloads the latest
release's bundle (tools/steam_frame_bundle.py) and runs
steam_frame_install.sh, with zenity dialogs and no keyboard: the APK and
launcher in ~/Documents/HaloCE, the maps copied out of a disc image it
finds or is given (steam_frame_xiso.py, the app's XisoExtractor in
Python), and Halo VR in the Steam library with its pictures and OpenVR
set. Running it again updates the game.
- The release job adds the bundle and the desktop file to each release.
- build.gradle signs with port/android/signing.properties (ignored) when
the workflow's key is not given, so local builds and releases install over
each other.
- On the Frame the launcher and host take ~/Documents/HaloCE whenever it can
be written, not only once it holds maps: a disc image imported in the app
is no longer emptied with Lepton's folders.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The VR options get a fourth page, COMFORT (PLAY, COMFORT, BUTTONS, MORE;
the tabs beside the title share its width): Turning, Snap turn, Smooth
turn speed and the vehicle vignette move there from PLAY, joined by the
on-foot vignette and its strength. PLAY keeps the hands, laser,
crosshair, motion tracker, body, fingers and the Warthog's wheel.
- Snap turns step through 10 to 45 degrees by 5, then 60, 75 and 90 (were
30, 45, 60, 90); smooth turning through 30 to 150 degrees a second by
15, then to 300 by 30 (were 60 to 180 by 30); the vehicle vignette
through Off, 15, 25, 35, 50, 60, 70, 85 and 100% (were Off, Light 35%,
Medium 70%, Strong). The old values are among the steps; the defaults
(snap 30, 120 a second, vignette 70%) are unchanged.
- vr.foot_vignette ("off" default, "turning", "moving") and
vr.foot_vignette_strength (0.7): on foot the eye images darken from
their edges while the turning stick turns smoothly and, with "moving",
while the move stick moves, as far as the sticks are pushed beyond
their dead zone (fully at three quarters), eased in and out as the
vehicle vignette is. Not in a vehicle's seat (whether or not its view
is first person), not in the menus; snap turns have none. The stronger
of the two vignettes is shown. Off by default: nothing changes.
Checked in the emulator (debug.vr_options): the four tabs and the rows
without overlaps, each row's steps, the values kept over a restart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Either hand brought to the side or the top of the head and away again, as
a tap, toggles the flashlight (vr.flashlight_gesture, default true; a row
on the VR options' BUTTONS page); the flashlight's button keeps working.
After HaloCE-Quest-VR (CC0), whose off hand near the head's middle
toggles it, made stricter against false toggles:
- The zone is in the head's axes: beside the head (7 to 24 cm out, from
10 cm below the eyes to 18 cm above) or over it (within 12 cm of its
middle line, 8 to 30 cm up), never more than 6 cm in front of the eyes,
so a scope held to the eye, a two-handed weapon, a punch and the wrist's
motion tracker, all in front of the face, are outside it.
- A tap: the hand comes from 35 cm or more from the head's middle, enters
slower than 3 m/s (a throw past the ear is faster), stays 40 to 600 ms
(adjusting the headset takes longer), alone (both hands at the head is
the headset adjusted) and leaves 4 cm past the zone's edges; toggles are
at least a second apart.
- None while the menus are up, while recentring, while a grenade is held
or for 0.6 s after one is thrown, within 0.7 s of a melee swing, with
both hands on the weapon, with the weapon hand while the scope is drawn,
or with a hand on a Warthog's wheel; such a hand must leave the head
before it taps.
- A toggle presses the flashlight's button (white) for 100 ms, as B's tap
does, and gives the tapping hand a short rumble.
The tap's state machine was run off the headset in a small harness over
taps beside and over the head, a turned head, a second tap within the
cooldown, a long dwell, both hands, a scope at the eye, the scope flag, a
fast throw past the ear, a held grenade and a two-handed grip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A BUTTONS page in the VR options (between PLAY and MORE) puts jump, the
flashlight (held: switch grenades), action/reload, switch weapon, crouch,
zoom and melee on any of seven buttons: A, B, X, Y, the move and turning
sticks' clicks and the weapon hand's grip. Moving an action to a button
swaps it with the action there, so every action always has a button;
"Reset buttons to defaults" puts them back. The triggers, the free hand's
grip (both hands on the weapon), the menu button, the Steam Frame's
bumpers, D-pad and View button keep what they do, and the menus keep
their buttons.
Settings vr.button_jump, _light, _action, _weapon, _crouch, _zoom and
_melee ("a", "b", "x", "y", "move_stick", "turn_stick", "grip"; the
defaults are today's layout), named as right-handed. vr.c reads them with
the live settings and, after the left-handed mirroring
(controllers_mirrored), reads each action's button and hands it on as the
button that action has by default (controls_remapped), so xinput_sdl.c's
table is unchanged and the defaults change nothing. Because the mapping
sits after the mirroring it works the same on the Quest's Touch and the
Steam Frame's controllers (mirrored on the Frame the left D-pad is A, B,
X, Y), and a mirrored player's swaps are the mirror image of the
right-handed ones. The guest/host ABI is unchanged.
- The melee's aim and the weapon's jab (melee_update) follow the melee's
button, not the grip as such; the grips holding a Warthog's wheel press
nothing whatever action the grip has (moved from xinput_sdl.c).
- The controller buttons page (now on the BUTTONS page) shows each action
under the controller and name of its button, for both layouts and the
mirrored hands; its ON FOOT tab no longer draws the MENUS tab's notes
over its own.
- debug.vr_options (Android, false): the VR options panel off the
headset, for checking its pages in the emulator.
Checked in the emulator (debug.vr_options): the rows, swaps, reset, the
controls page right- and left-handed, the settings kept over a restart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every transparent thing a window draws is a group of its own: a model's
transparent part (shields, plasma, glass), each particle system, contrail
and light volume, a window's or water's surface. The Xbox had room for 384
(rasterizer_transparent_geometry_new_group); past that new_group returns
NULL and the submitter drops the geometry, logging "### ERROR too many
transparent geometry groups" once (rasterizer_xbox_models.c,
rasterizer_xbox_widgets.c; environment and dynavobgeom drop it silently).
render_window submits the objects first, then the structure's transparent
geometry, weather, particles, particle systems and contrails, so in games
of 30 and more players the late ones vanish: water and glass surfaces,
explosions, muzzle flashes, smoke, plasma glows, contrails.
The groups are a heap array (0xA0 each), a short array of sorted indices
and a bit vector of pending flags, all sized by the constant; indices are
shorts (sorted_index, presorted indices, the draw loop), good to 32767.
Nothing else depends on the number (the debug view's "all groups" index
1000 only colours groups in a debug mode). Upstream (halo-ce-universal)
still has 384.
2048 groups take 320 KB (61 KB before). A one-line log tells when a window
passes 384 and every 128 beyond, for checking big games in the headset.
In the emulator, a 36-player bot game on Blood Gulch had up to 134 groups
a window from a standing player, using under 8 KB of the rasterizer's
768 KB memory pool (the models' queued matrices and lighting), so the
pool is not the next limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows build takes the Linux platform layer's sources but those it replaces; the trace_marker refusal is glibc's open(), which Windows does not have.
SteamOS keeps kernel tracing on for its GPU performance captures
(gpu-trace.service), and its Mesa, built with gpuvis, then writes a marker
into the trace buffer for each traced driver function. On the Steam Frame
that is some 480,000 writes a second: the driver's thread spent its time in
them, and the game ran at about 50 frames a second instead of the headset's
72 (#71 has the same for the arm64 build). The Steam Deck runs the same
gpu-trace service and the same Mesa, so it needs this too.
The executable now defines open() and openat() (and their 64-bit names),
exported so that the driver's calls reach them: they refuse trace_marker
and pass everything else on. gpuvis then skips its markers.
HALO_GPU_TRACE_MARKERS=1 lets the driver write them, to capture with gpuvis.
The link takes libdl for dlsym, which glibc before 2.34 keeps there.
A config.toml pushed by adb belongs to the shell and the app cannot open it
for writing, so the VR options' changes (handedness, graphics, refresh rate,
resolution) lasted only until a restart. Writing a temporary file and
renaming it over the old one replaces it all the same (the folder is the
app's), and a write cut short leaves the old file whole.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The layout a draw needs follows from its vertex declaration and the streams it has, so each declaration keeps the vertex array its draws last used. Looking it up hashed the whole layout, about 390 bytes, on every draw: 3.8% of the main thread on a Steam Frame.
The mirrored first-person model leaves its geometry mirrored; a numeric
display drawn mirrored (rasterizer_set_model_skinning's winding flag) now
shows each digit's bitmap turned over in u (1-u on the first map's texture
transform) and swaps the digits' order across the display (the quad of the
n-th digit from the right shows the n-th from the left; digit count from
the counter limit, the compass's single digit unchanged). Only for 2d,
non-screen-space first maps of numeric chicago/generic shaders, only when
mirrored: right-handed and flat output unchanged.
debug.mirror_first_person (Android, default false): off the headset the
first-person weapon is mirrored across the camera's forward/up plane as
the left hand's is in VR, for checking such things in the emulator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The assault rifle's ammo display, mirrored back, vanished: its part's
winding was judged by its first node alone, which may be the mirrored gun,
so its triangles were turned over and culled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Walking up from a node to the display through parent_node_index never
ended where the root names itself as its parent: the game hung loading a
level left-handed (Steam Frame). Each step now goes only to a parent
before its child, as vr_skeleton_build does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its node ("... display") and what hangs from it are mirrored back about
the display's own centre after the pose is mirrored: it stays where the
mirrored gun has it, reads as in the right hand, and its triangles keep
their winding (rasterizer_xbox.c decides per skinned part). After
HaloCE-Quest-VR (CC0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The panel's second page (MORE) gets "Graphics" (vr.graphics: Low,
Medium, High, Max), "Refresh rate" and "Resolution" rows, with a note
of what they do. The refresh rate steps through the rates the runtime
offers (XR_FB_display_refresh_rate: host_xr_refresh_rates, new host
imports with host_xr_set_refresh_rate) and changes at once (vr.c asks
for it when vr.refresh_rate changes); where the runtime offers none
(SteamVR on the Steam Frame, whose rate is SteamOS's) the row shows the
rate it runs at, from its frames' period, and stays as it is. The
resolution (vr.resolution_scale, 70 to 120%) is read as the app starts:
the row says when a restart is needed. vr.log_performance's late frames
follow the rate as it changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
port/linux/game/vr_graphics.c (after HaloCE-Quest-VR's, CC0) sets the
game's own effect switches each frame before drawing: "high" (the
default) draws everything as before; "medium" no object shadows,
environment reflections or lens flares; "low" nor specular light, bump
maps, detail objects (grass), the screen's fog layer, contrails, weather
or camouflage's second pass; "max" is "high" with per-pixel model
lighting and 512x512 shadow maps (d3d8_gl.c asks halo_vr_graphics). A
switch the game changes (scripts, console) is taken as its wish and
combined with the preset's. A client in another's game keeps grass, lens
flares and the fog layer as the host's rules have them; cheats.c leaves
the environment parts the preset may keep off there (shadows, specular,
reflections).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The game's light (right) motor rumbles the weapon hand, the heavy one the
free hand (halo_vr_weapon_hand), as right-handed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The VR options panel gets a "Weapon hand" row (vr.handedness) and a
"Mirrored controls" row (vr.mirror_controls, dim when right-handed). Its
rows now go on two pages with tabs beside the title (PLAY, MORE; up and
down run on across them, a tab clicked shows its page). The controller
buttons page is built from the hands' jobs: the weapon hand's trigger
and grip, the free hand's, the move and turn sticks and the face
buttons (Quest X/Y for A/B; Steam Frame D-pad for A, B, X, Y) on the
sides the settings put them; the wrist, wheel and aiming notes name the
hands. network_vr.c's notes name the weapon and the free hand.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wrist layer goes on the free hand's wrist: halo_xr_submit.wrist_hand
(new, after wrist_down); host_xr.c keeps both grips and places the layer
on the right wrist as the mirror image of the left one's, read the right
way round.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Left-handed, in play, vr.c hands xinput_sdl.c the controllers with the
weapon hand's trigger and grip as the right side's (fire, melee, the
wheel's throttle) and the free hand's as the left's (grenade, foregrip,
brake). vr.mirror_controls (default true) mirrors the sticks (move on
the right, turn and steer on the left), their clicks, the bumpers and
the face buttons too: on the Quest X/Y for A/B; on the Steam Frame, whose
A, B, X and Y are all on the right controller, the left D-pad takes them
as their mirror image (down A, left B, right X, up Y) and they take the
D-pad, so every function stays reachable. The menus keep the buttons.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vr.handedness = "left" puts the weapon in the left hand: aim, muzzle,
laser, crosshair, scope and turret aim follow the left controller; the
grenade, the foregrip and the fingers' closing round a grenade move to
the right hand, the grip's melee to the left. The first-person model is
posed as the right hand's in a mirror image of the room ("model space":
vr.c mirrors the controllers into it, first_person_weapons.c the body)
and the posed skeleton mirrored back, after HaloCE-Quest-VR (CC0); a
model whose node matrices mirror has its triangles' winding turned over
(rasterizer_set_model_skinning -> d3d8_gl.c, ended with the model and
each transparent group). halo_vr_left_hand is now halo_vr_free_hand.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The elbow could not be lifted out to the side: it bent only towards the
fixed pole (down, out, back). An elbow lifted out rolls the controller's
top in, so the pole now leans towards the hand's underside (the weapon's
for the right arm, the free left controller's for the left; the hand on
the foregrip keeps the fixed pole). With the free arm solved before its
hand is turned it looked right on the Quest: vr.arm_ik defaults to true.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As HaloCE-Quest-VR does it: the free arm is bent to the controller's grip
from the animated pose (the hand free to swing with it), and only then is
the hand turned to the controller and the roll shared along the forearm.
Turning the hand first left the solve to swing it on, which stretched the
arm's skin like a rubber band (Quest, 2026-10-06). vr.arm_ik stays off by
default until it is seen right.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On OpenGL ES 3.1 and later, D3DDevice_GetVisibilityTestResult read the lens
flares' atomic counters with a blocking map: the CPU stopped every frame
until the GPU had finished the draws that counted, which halved the frame
rate on drivers that queue frames (Turnip, Zink).
Now the GPU copies the counters into the frame's snapshot buffer at the end
of each frame (a ring of three, with the stream buffers), and the frame's
tests are listed with it, each a result slot and its counter. Once the
frame's fence has passed, two frames on, the CPU reads the snapshot once
and gives each listed slot its count. A result is the latest count the GPU
has finished, as with the desktop's query buffer.
host_gl_read_buffer_word becomes host_gl_read_buffer, which reads a range;
glCopyBufferSubData joins the ES function list.
Second Quest test: with the elbow out to the side the right elbow's skin
turned inwards: the shortest turn of the solve left the upper arm's hinge
where it was. vr_align_elbow now rolls the upper arm about its length so
the animation's bend lies in the plane the arm bends in, and the upper
arm takes no share of the hand's roll any more. The open sleeve end could
still be seen: the shoulders are 5 cm higher (20 cm below the neck).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First Quest test: the free left arm's skin stretched like a rubber band
with the new elbow IK, so vr.arm_ik now defaults to false (the arm's shape
as before, pointed at the shoulder); and the open end of the right
sleeve came into view looking down to the side: the shoulders now stand
4 cm behind the neck instead of 3 cm in front of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each (players), (vehicle_riders ...) and the like makes an object list,
and a tick's lists are freed only after all its scripts have run. The
Xbox's 48 lists were too few for coldsnap_20th_xl, whose scripts test
(players) in dozens of places each tick (49 lists in a tick with one
player, 69 once a fighter-bomber's destruction ran): the game halted
("object list header index #65535 is unused or changed") as soon as its
scripts loaded.
The native builds now have 1024 lists and 8192 listed objects (the
Xbox's 48 and 128) in their game state, and a list that cannot be made
is an empty one rather than a halt. Lists are the scripts' own and never
reach the network. A campaign checkpoint saved by an earlier build does
not load in this one (the game state's allocation check refuses it, as
with each change of these pools).
Reported by ugoboom.
(cherry picked from commit 288afc20077e972e531856cc1eb14a8ce02e1bd4)
GetTickCount and QueryPerformanceCounter counted from the host's boot; the
Xbox's count from power-on, and the game keeps times in signed 32-bit
variables. Past 2^31 ms of uptime (24.8 days), a hosted game timed out its
own client after about 15 s ("timeout in network_connection_idle", "the
game host went down"). Both clocks now count from when the game started,
beginning at 10 s.
- vr.body = "arms" (default): arms and hands as before; "hands": the arms'
bones shrunk to a point 3.5 cm behind each wrist along the hand's line
so the cuff closes behind the hand (vr_hide_arm, after HaloCE-Quest-VR's
vr_hide_forearms, CC0), no arm IK solved; "off": the hands model is not
drawn (one model draw less an eye) and nothing of the arms posed; the
weapon, its aim with both hands, barrel, laser and crosshair stay.
- vr.c reads vr.body with the live settings (halo_vr_body_mode).
- The VR options panel gets "Body" (Arms and hands / Hands only / Weapon
only) and "Fingers" (Tracked / Off) rows; its twelve rows are 22
pixels apart (were 26) so the panel keeps its box.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- vr_arms.c: vr_finger_nodes, vr_pose_finger (after HaloCE-Quest-VR, CC0,
nearly verbatim): each finger's three joints found by name under the
hand, posed by absolute directions in the palm's frame (a little bent
at rest, a fist at full curl; the thumb up the handle or across the
palm), each segment's subtree turned with its length kept. Its touch
of the world (rays per finger) is left out for the Frame's processor.
- The free left hand takes its controller's curls (halo_vr_finger_curls:
index from the trigger, the others from the grip, the thumb from where
it rests; round a held grenade). On the foregrip the hand keeps the
weapon's pose.
- The hand on the weapon keeps its grip; only its index finger is lifted
part way off the trigger while the controller says it is (touch known
and the finger off it), never where touch is unknown.
- Joints found once per skeleton; the cost is a few dozen node turns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- port/linux/game/vr_arms.c: vr_solve_arm and vr_distribute_arm_twist,
after HaloCE-Quest-VR (CC0), nearly verbatim: the shoulder reaches 8 cm
and the arm stretches up to 18% out of reach (further, the shoulder
follows the held hand, as this port had it, so the arm stays whole),
bends no further than 145 degrees, the elbow's swing eased per arm
(vr_limb_history), the hand's roll shared by forearm (85%) and upper
arm (25%) within 110 degrees, continuous across half a turn. The
skeleton is read through the graph's index; the nodes under each node
come from one pass a frame (vr_skeleton_build) rather than walking
ancestors per node; time is the headset's frame's (halo_vr_pose_time).
- Both arms use it; the right hand and a left hand on the foregrip stay
where the weapon puts them (hand_stays), so shots, barrel and laser do
not move.
- The free left hand: its wrist and fingers are set on the controller as
before, and the arm now bends to it from the shoulder instead of being
carried rigidly. The ribbons of e377e4dc did not come from skin on the
root (3a.0: none hangs there), so no triangle filter is needed; the
rigid arm stays behind vr.arm_ik = false, read live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- vr.c keeps the torso's heading in LOCAL space (torso_update, after
HaloCE-Quest-VR's vr_body_heading, CC0): the head's heading, steady at
any pitch, drawn halfway towards the hands when both are ahead (at most
45 degrees), followed beyond a 15 degree cone, eased at 10 a second;
reset on a recentre or a gap. In LOCAL space the stick's snap and
smooth turns carry it at once (HaloCE-Quest-VR's "arm IK lags as the
body turns").
- halo_vr_body_frame gives the torso's heading and the neck the head
turns about (9 cm behind the eyes); the shoulders hang from the neck,
square to the torso, so turning the head no longer swings them (as the
head faces the torso, where they were before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- host_xr: two actions, the thumb's touch (stick, buttons, thumb rest,
the Frame's D-pad and View) and the index finger's (trigger), bound
for the Touch, Index and Steam Frame profiles (paths after
HaloCE-Quest-VR, CC0, and Valve's "Steam Frame Input" page) as optional
bindings: a profile refusing them is suggested again without them.
- ABI: HALO_XR_BUTTON_THUMB_TOUCH, _INDEX_TOUCH and _TOUCH_KNOWN (the
runtime gives the thumb's touch at all) in the buttons' free bits; the
structures keep their size (android_abi_check: none unsafe).
- vr.c: each hand's thumb, index, middle and last two fingers, curled
from those (the thumb half curled where touch is unknown, the hand
round a held grenade), eased by HaloCE-Quest-VR's critically damped
spring; halo_vr_finger_curls() hands them to the game; vr.fingers
(default on) turns it off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
suggest() copied a profile's bindings into an array of 32 and silently
dropped the rest; the Steam Frame's profile already has 26, so a few
more would have lost bindings without a word. It takes 64 now and logs
an error past that, logs the runtime's result when a profile is
refused, and suggest_bindings() takes optional bindings besides: should
the runtime refuse the profile with them (one unknown path refuses the
whole suggestion), it is suggested again without them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Posed from the camera at the right controller, the first-person weapon
and the arms are the same for both eyes and the scope's view, yet were
built (animation, interpolation, arm IK) for each: twice a frame on
SteamVR, three times zoomed in. The pose is kept for the rest of the
headset's frame (halo_vr_frame_serial) once built from the controller,
for the same weapon; posed from an eye's own camera (the hand not
tracked), each eye still builds its own. vr.log_performance counts the
builds and the poses kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- vr.log_performance reports how long the first-person pose takes a frame
(its animation and the arms), how often it is built, and the arms' part
of it (halo_vr_timing_start/_end).
- Once per hands model it logs how many triangles hang from each node and
how many of those from the skeleton's root ('frame bone24'), read from
the part's own buffers (rasterizer_model_buffer_data, after
HaloCE-Quest-VR, CC0). In the emulator the cyborg's hands have none on
the root: the ribbons of e377e4dc did not come from root skin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Valve's Mesa in Lepton writes a kernel trace marker around every flush
and context switch; its descriptors on trace_marker are pointed at
/dev/null once the GL context and the OpenXR session exist (after
astromaddie/HaloCE-VR, CC0)
- host_gl_counters_snapshot/_read no longer ask GL for the copy bindings
(each glGet waits for Mesa's GL thread): they are left unbound
- port/android/VR_ROADMAP.md: what is done and the agreed next steps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: d3d8_gl.c (multiview variants kept beside the per-pixel lit
shaders: shader[4] and lit_shader[4], vertex_shader_get takes both;
multiview targets before multisampling; the picture read resolves both),
nv2a_vsh.c/xgpu.h (lighting and multiview parameters), render.c (no
anti-aliasing pass on VR eye passes), dsound_sdl.c, p2p.h.
VR: anti-aliasing off (the eyes go to the headset from their textures);
the VR online list skips password games (the server browser asks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Video Setup has Anti-Aliasing (Android's choices its own), Shadow Resolution and Per-Pixel Lighting, its rows closer and the help lower for them to fit; Audio Setup has Reverb.
display.anti_aliasing, off by default: "fxaa" or "smaa" (a pass over the
3D view before the HUD and menus; SMAA vendored from port/third_party/smaa,
MIT), "ssaa2x" (the screen's targets at twice the resolution, at most the
GPU's largest target) and "msaa2x" to "msaa8x". Multisampled framebuffers
have every attachment multisampled, so the mirror's view with the back
buffer's depth buffer is too, and a target's texture and renderbuffer stay
coherent. The setting is read again between frames. Android has FXAA for
SMAA and no supersampling.
Based on #38 and #39 by Tyberious.
display.per_pixel_lighting (off by default) lights the models for each
pixel by the same ambient, distant and point lights the model lighting
programs (9, 10, 17 and 27) sum for each vertex. The normal and position
are found in each program's instructions and the program is checked to
light as the pixel shader does; the lights come in a small uniform of
their own, and a shader that fails falls back to per-vertex lighting.
Based on #48 by Tyberious.
display.shadow_resolution (128 by default, as on the Xbox; 256, 512 or
1024) draws the two shadow maps larger. The blur's taps move to half a
texel of the larger map and two passes more for each doubling widen it
to the Xbox's tent, so the shadows keep their softness.
Based on #47 by Tyberious.
Based on #44 and #46 by Tyberious. 3D voices send to an I3DL2 reverb of the camera's sound environment, and obstruction and occlusion muffle their direct path above the HF reference (occlusion their send too). Both use the fixed distance; a maximum distance under the minimum is held at it. A change of environment crossfades the delays and gains over 85 ms, the filters' coefficients ramp over each mix, the diffusers, lines and filters flush denormals, and the reverb stops while it is quiet. audio.reverb (default true) is read again when the settings change.
Fixup for #42: a voice at the output rate or slower blends two rows of weights by its phase instead of working out each tap's (the same low pass, a third of the work), and a voice turned all the way down only moves on.
Fixups for #99 and #112: the version raised for the object message's
shader permutation, NETCODE.md brought up to date, a networked glass
break's sound placed where it broke, a frame scratch pool that runs out
logged once, and the new comments marked as the port's.
With many enemies (co-op's extra enemies) object memory runs low and the
garbage collector prints "garbage collection critical" and "removing
objects" at every pass, filling the screen for the host and every client.
They are the game's own chatter, so they show as game.console_log says, as
the sound and texture caches' already do; the log keeps every line.
The host's notice of a broken surface carries no damage effect. The client filled a damage record with zeros and then read tag 0, which is the scenario, and halted. Shards still spawn. They skip the kick from an effect this machine never received.
The frame scratch pool was still the Xbox size after the particle pool grew eight times, so a busy level spent it every frame. It now grows with that particle pool, and a frame that still runs out is logged once.
A console line longer than the buffer halted inside the error printer, before the reason was written to the log. Long lines are cut to fit, and the reason is written first.
With both eyes drawn at once (multiview) the eyes' pictures did not fuse
on the Steam Frame, now and then: each eye alone sharp, both together
doubled as the head turned, in the menus too. With vr.multiview = false
the user saw no doubling, at the same frame time (the Frame's time goes
to the GL driver either way). debug.multiview_steamvr brings multiview
back there for finding the cause; the Quest keeps it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts commit 24e583c0. With the images acquired before xrWaitFrame
the frames came fast (about 8 ms of work) but the picture doubled again
on the Steam Frame as the head turned (181 of 3753 frames reprojected);
without it (673b32b1) it did not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lens flare is projected on the CPU (w 1, its depth in z) and drawn
without the depth test, which multiview took for something at infinity,
as the sun's flares are. A weapon's own lights have flares too, at its
markers half a metre from the eyes (the plasma pistol's at its prongs,
the plasma rifle's at its muzzle): each eye saw its flare where infinity
puts it, a green ball at the barrel the eyes could not fuse. While flares
are drawn (rasterizer_lens_flares_draw), each eye's is now placed by the
flare's depth, as its occlusion test already was; the sun's are far
enough for infinity either way. Whether a flare shows at all depends on
its occlusion test, which is why the balls came and went, and showed on
the Frame more than on the Quest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the copies deferred the wait only moved: acquiring the images took
8-10 ms a frame, the left eye's 6-8 of it. SteamVR's acquire (or its wait
for the image) waits for the GL work queued before it, the eyes' whole
drawing when the left eye's copy asked, the HUD's for the overlay. The
images the last frame filled are now acquired before xrWaitFrame, when
little is queued and the frame would wait for the headset anyway; one a
frame needs that was not acquired ahead is acquired as before. The
performance log shows the time acquiring ahead apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every texture the renderer binds asks whether it is a menu bitmap
standing for its PNG (menu_art_texture), in a game too, where the PC
menus' bitmaps are loaded for the pause menu's settings: a walk over the
150-odd registered bitmaps for each texture of each draw. Their textures
lie together, so one lowest/highest check answers for the map's own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Frame's game thread spent most of each 13.9 ms frame waiting, not
working: the left eye's copy into its swapchain took 4.5-7 ms, the right
one's right after it 0.7, each later copy 0.7-3. Every copy read the GL
state back to restore it (glGet*, glIsEnabled, the source's swizzle) and
released its image at once; with Mesa's GL thread (zink) each read waits
for the thread to run everything queued, each release for the GPU, so the
thread stood still for the eyes' whole drawing mid-frame, and frames came
late enough for SteamVR to reproject 2-8% of them (the doubled picture
when turning the head).
Deferred copies (SteamVR by default; debug.xr_deferred_copies 0 or 1 says
otherwise; the Quest's path is unchanged):
- nothing read back: a copy leaves GL as it drew and returns 2; vr.c has
the renderer set its state again (d3d8_host_state_changed: the state
cache invalidated, its vertex arrays bound again)
- no swizzle round trip: copies read render targets, which have none
- the images kept until the frame ends and released together before
xrEndFrame; the eyes' work flushed to the GPU once they are copied
- the performance log times acquiring and releasing apart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixups for #102, #103 and #105-#107: the version raised for the glass,
scenery, cutscene cluster and revert messages, NETCODE.md brought up to
date, co-op's followers' object lists freed as soon as they are used, and
extra enemies that find no free ground placed on the rings they used before
rather than left out (at 32x a quarter of them were), with the places found
taken not tried again for the rest of the squad, and the way to a place
looked along level.
- vr.log_performance: frames with game ticks and without, how many late,
and their time by part (input, ticks, camera, draw, present)
- with XR_META_performance_metrics: per frame, each layer copy's time, the
waits for swapchain images and xrEndFrame
- debug.gl_thread: Mesa's GL thread on or off (mesa_glthread) for trying
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Silent Cartographer with 32x co-op enemies (484 actors) went from 53
to about 97 fps on the Linux PC, with the same game and the same picture:
cluster list removals start where the reference was put, full data arrays
are not walked again, object lighting keeps each cluster's drawn lights
and passes over far ones without their distance, the per-texture profile
timer only runs while something reads it, and the renderer binds streams
once per draw, caches its texture, target and shader lookups, and no
longer makes Mesa's GL thread wait on large uploads or visibility tests.
What the followers of player0 do (teleport, suspend, get out, board) is a
function each in coop_scripts.c, so every engine hook is one call. The
cutscene skip reverts only in a local game or on a co-op host, written as
two plain branches; a non-co-op network host no longer reverts alone.
coop_enemies_spot_free makes every check of a spot (way, floor, headroom,
nobody there) in one place, each constant has its own comment, and the
file comment's wrapping is fixed.
Breakable surfaces (glass) are broken by each machine's own projectiles
with its own random damage, so a pane could shatter on the host and stay
whole, still blocking, on a client. In co-op the host now sends each break
(and a few again every refresh, for late joiners), and a client breaks the
same surface with its shards.
Destructible scenery and devices (lights, panels) are the map's, at the
same index on every machine, but only damage to units was synced: a
client's shots never damaged them, and what the host broke didn't break
or play its effects on the clients. A client now reports its hits on them
like any other, the host checks and deals them, and every client deals the
host's damage to its own copy, which breaks, plays its effects and is
destroyed as the host's was.
Both are new message kinds that a client without them ignores, so the
network version is unchanged.
The pause menu's revert (main_revert_map) reverted a network client's own
game state, leaving it out of step with the host. A client now ignores it,
and the cutscene skip's local revert, which a client only reaches outside
the co-op vote, is the host's or a local game's alone.
The scripts check game_safe_to_save before some cutscenes (the Maw's
bridge). The game's test fails while any enemy sees any player, so with
players spread out a fight elsewhere held up the cutscene a player had
reached. For the scripts in co-op, one player alive, on the ground and
unseen is enough; checkpoints keep the game's own test.
A spot's ground was looked for up to 2.6 m below it, so an extra enemy
could land under a ledge or catwalk, and only walls were in the way, so
it could stand inside a crate or a machine.
A spot now needs a floor within 0.5 m of the starting location's, nothing
in the way from beside the starting location (level, scenery, machines,
vehicles), and 1.6 m of room above its floor.
Without XR_EXT_performance_settings (SteamVR in Lepton) the scheduler may
put the game's busy thread on the 2.3 GHz cores; frames then come late to
the compositor and the picture doubles as the head turns, now and then,
at a steady 72 fps. On SteamVR the thread that ends the frames is kept on
the cores no slower than 90% of the fastest (HALO_XR_ANY_CORE: not); the
metrics line says which core it is on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every five seconds, where the runtime offers them (SteamVR on the Steam
Frame): to tell frames the compositor showed late or reprojected from the
game's own pace.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The campaign scripts only run on the host, against every player's unit,
but some of their habits assumed split-screen:
- volume_test_objects_all on the players waited for every player in the
volume at once: the Maw's bridge cutscene, a30's Pelican pickups. In
co-op one player arriving is enough now.
- Scripts name players as player0 and player1, so everyone past the
second was left out of what they did: Pelican loads (a30's pickups,
insertions), getting out, cutscene teleports and freezes, starting
profiles. Those players now follow player0 (coop_scripts.c).
- A cutscene filmed far from every player (b30's sword Elite at the
shaft door) wasn't active on clients: the cluster the scripts force
active (object_pvs_activate) now rides in the presentation's spare
bytes, and what the host's camera films is sent to clients as often as
what is next to their players.
- A mission the scripts fail (d40's timer, a50's Keyes) only respawned the
players, leaving them in the failure cutscene for good. It now reverts
like the cutscene skip does; everyone dying still respawns at the last
checkpoint. The scripts' game_revert reverts the same way.
The network version is unchanged: the presentation keeps its size, and a
host without the active cluster sends zeroes, which read as none.
An extra enemy was placed on a ring around its starting location, trying
one spot per ring, and checked only against walls. When the 8 tries failed
(tight corridors, the Maw's interiors) it went on the starting location
itself, on top of the squad's own actor, and nothing stopped it landing on
another starting location's actor a metre away.
Now every spot of each ring is tried (a ring holds more spots the further
out it is), and a spot needs ground under it, no wall in the way and no
biped within 0.6 m or vehicle around it. A starting location with no room
passes its enemy to the squad's next one, and with no room anywhere the
enemy isn't placed. Dropship riders released beside the ones who jumped
out are spread the same way.
- a way is clear only at the knees and the chest (a rail with room under
it, a slit in a wall), and for a shortcut along a path at the shoulders
too (a door frame)
- stuck below a near point (a step, a ramp's lip): a jump on towards it
- a thing to pick up it comes no nearer to for 2.5 s is given up
- stuck three times in five seconds: the goal given up, somewhere away
- at the point it walks to: arrived, not stuck
Emulator, Blood Gulch, 15 bots: CTF stuck events 264 -> 153, mid-field
26% -> 32%, kills 26 -> 33; team slayer stuck events 860 -> 560.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- a thing to pick up that it stands by and is not offered (on a ledge,
out of reach) is given up for 20 s; up to four remembered, in the
fight too (the crowds jumping at the base's weapons)
- stuck: the side step goes to the open side
- goals: each bot one of the three nearest enemies, and beside them while
far, so a team spreads out
- holding a spot: stops near it, and one it cannot reach falls back to
the place's centre
Emulator, Blood Gulch, 15 bots: CTF stuck events 1793 -> 264; team slayer
stuck windows 32% -> 9-15%, time by the bases 85% -> 68-73%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bots fill the game up to its maximum; a machine joining the lobby
keeps its place (no bot added for it) and its player takes the last
bot's; the game lists as open while a bot could leave.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- bots: no more than the game's MAXIMUM PLAYERS has room for beside the
people (instead of raising it); the host options show and change the
bots there are
- VR options hint on the PC main menu drawn whole (left-aligned)
- the online and host panels: a click beside them or on their key's B
closes them, on the online panel's X enters a code
- spinners' arrows (drawn just outside them) clickable
- Android flat (the emulator): the mouse drives the menus' pointer, for
testing the menus' clicks with adb shell input mouse
- Frame launcher: Lepton's logcat of the game's container in logcat.txt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- menu_tags.c builds the PC menus in VR too unless vr.menus = "xbox"
- their text fields (Server Setup's game name) typed on the Xbox's
on-screen keyboard in VR
- join code / online panel on the PC Server Browser (X or click its hint),
host options (Y or click) and join code on the PC lobby, VR options on
the PC main menu
- bots: room made for them beside the people (Server Setup's maximum
players), none in co-op
- player_name_valid accepts trailing spaces (Xbox menus pad new profile
names, "New001 ", which the PC lobby refused)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream's online co-op (network_actors.c, network_coop.c) ticks beside the
VR port's VR relay and bots; its message types end at 77, below the VR
port's from 240. The look and move flags the action test reads moved into
player_control_action_test_note: the head's turning goes there too. The
vertex shader's screen-space undo is upstream's on every platform now, with
the multiview eye offset after it on Android. The APK carries upstream's
brokers.txt beside the FDM layer stub; the updaters keep the VR port's own
repository (upstream's now points at OpenCommunityEdition/OpenCE).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The launcher gives Lepton what Steam gives an Android title of its own (a
compat data path, which the app's data links to after an install, and the
shader path), keeps a container id of its own, tries a start that ends at
once again (a new APK's first start only installs it), and stops when the
game was quit. Steam's "Exit game" cannot reach the game in Lepton's
container: the VR options get "Quit Halo" (asked twice), which on Android
now ends the process and leaves quit_requested.txt for the launcher. The
library's pictures are in port/android/steam_frame_grid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An actor variant sets its unit's forced shader permutation after the unit
is made, and only the host runs the AI, so on clients Elite majors and
commanders drew the minor's armor (bitmap 0 of the cube maps instead of 1
and 2). The object creation message carries it in its spare byte, which
builds without this send as 0; a client applies only one that isn't 0, so
the message keeps its size and games hosted on builds without this keep
each tag's own.
Going back to a BSP the team has been in waits for two thirds of the team,
and a player on the trigger was never told so. They now see "Waiting for
your team to go back: 1 of 2 here", at once and every three seconds while
they wait. Each machine works it out for its own players with the count the
host decides by, now one function, so nothing new goes over the network.
Two thirds of the players had to be in the loading zone or within 4 world
units of whoever was in it. A team walking back through a doorway in a line
is longer than that, so going back almost never switched: the first player
walked on past the loaded level, fell and was brought back to the team.
15 world units fits a team moving together and still not a player who ran
back alone.
The host's switch and its players' new positions travel separately, so a
client could hear where the host moved its player, in the new BSP, before
the switch itself, and drop through the floor until the BSP loaded. A
client now keeps its player on the floor of the BSP it has until the
host's position is somewhere it has loaded. Nothing changes on the wire.
Lepton runs the APK in a container of its own, kept between starts, and
installs it anew whenever the APK is newer; the script starts it a second
time after a new APK's first start, which only installs it. With the APK in
~/Documents/HaloCE, updating the game is replacing that file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Players in big sessions ended up on different BSPs:
- The host told clients about a switch only every half second, and not
reliably, so a client could predict its player for a while in a BSP it
hadn't loaded. A switch is now sent at once, reliably, ahead of the
players' new positions.
- A client still loading reports so: its input carries the BSP it has
loaded. Until it has the host's, the host takes none of its movement
(which falls there with no floor) and none of its loading zones.
- After a switch, by a trigger, a script or a skipped cutscene, no trigger
can switch again until every machine has loaded the new BSP (at most
ten seconds), so no client falls two switches behind.
- A player the host can't place beside whoever crossed, when a big team
runs out of room there, is placed beside another teammate instead of
being left where they were.
Clients only had a kill path for bipeds, so a Ghost, Banshee or Wraith the
host destroyed stayed intact on clients. A vehicle the host's state says
is dead is now destroyed there too, with its explosion.
A loading zone brings the whole team to whoever crosses it. Into a new part
of the level that's what you want, but it let one player drag everyone
back. A switch back to a BSP the team has been in now waits until every
living player is in the trigger or near the player in it.
The scripts make allegiances (ai_allegiance player human) and only the host
runs them, so on clients marines stayed enemies of the players: red reticles
and the wrong team. The host sends its ally and friend tables with the
presentation each tick.
The test compiles main.c's and players.c's death handlers against a fake
world; their network co-op branches call functions it had none of, so it
no longer compiled. Stubbed off, it covers the local game it was written
for, and passes.
- A body the host's unit got back up from (a Flood combat form feigning
death) gets up on the clients too, instead of its ragdoll being dragged
about. The resurrection is one function both use (unit_port_resurrect).
- The scripts' unit_open and unit_close go to the clients, so dropships'
doors aren't left open there.
The host sends each device's position and power when they change (and with
the resent state), and a client puts its device there when the host's is at
rest and they differ, or when they drift apart while it moves. Clients only
had the device groups' values, which aim a device: a door could stop half
open, and an elevator be somewhere else on a client, so its players fell.
- Crossing a BSP loading zone moves every other player to whoever crossed,
as split screen does. Moving only some players left the rest where the
next switch threw them back to a hallway, or under a floor.
- The rescue for a player outside the loaded BSP waits two seconds and
only takes someone falling: a player riding an elevator through its
shaft is left alone.
- AI units' teams go with their states, so friendlies aren't red on
clients' reticles.
- A skipped cutscene's music and dialogue stop on the clients.
- Winning a level returns every machine to the lobby with the next level
set up, instead of leaving them on its last screen.
- Help text carries whether the host shows it, so no prompt is dropped.
Hosting a co-op level from the Map screen (CO-OP CAMPAIGN, a level and its
difficulty, then Server Setup) makes a network game on the campaign map
with no game engine, which is what co-op is. Only the host runs the
scripts and the AI; port/linux/game/network_coop.c sends clients what the
scripts do (cutscenes, camera, fades, HUD, sounds, devices, named objects,
animations), and network_actors.c drives the host's AI units on every
machine. Players spawn beside each other, respawn beside a safe teammate,
and spectate while dead or waiting. Skipping a cutscene is a vote.
Players can join a game in progress at any time.
An antenna's chain is a simulation of the Xbox's own step, an update a tick,
and the port was stepping it once a frame with the frame's own time. Its
springs, its carry and the points' physics then acted against the frames'
movement of it, so how far the chain moved per update depended on the frame
rate, and a vehicle at speed swung it far wider than a tick's step does.
- game.c steps the chain in the tick, beside the effects, which are already
stepped there.
- antenna.c records the shape each tick leaves, relative to the point the
vehicle's marker carries the base by. A frame drawn between two ticks is the
part of the way between those two shapes that it is, carried to where the
marker is now, so the chain stays on the vehicle it hangs from instead of
lagging a tick behind it. The gate that left a chain that had not been drawn
for five updates unstepped counted frames; it counts ticks now, which is
what it counted on the Xbox.
- widgets.c no longer steps the chain from the widget update, which ran once a
frame.
- game_time.c draws the per-frame effects and widgets that hang off objects
(game_frame) with the pose between the last two ticks, the pose the renderer
draws with, so what they place on an object is drawn where they put it.
- objects.c hands markers the matrices the renderer draws with, so an effect,
a particle system or a contrail attached to a moving object follows it
between the ticks too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hosting over LAN or the internet, the Map screen's list first offers CO-OP
CAMPAIGN or MULTIPLAYER. CO-OP CAMPAIGN offers CAMPAIGN or CUSTOM CAMPAIGN,
then a level and its difficulty. MULTIPLAYER offers VANILLA MAPS or CUSTOM
MAPS, then the maps, and goes on to the gametypes as before. B steps back
through the choices and leaves the screen from the first. Split screen
starts at VANILLA MAPS / CUSTOM MAPS.
The custom lists stay empty and a co-op level cannot be hosted yet: network
co-op and Custom Edition maps come in their own pull requests, which fill
them in.
Backing out of a hosted game keeps its server: the lobby's last local
player leaving pauses it (netgame_unjoin_player). Choosing Create again
threw the host's client away, but network_game_start_new_server makes and
joins a server only when there is none, so the new client never joined the
one left over. The lobby then had nobody in it: START added no player, B
could not leave (not in pregame), and the server's map changes failed to
reach the client that was gone.
Create now disposes the client and server first, so it always makes a new
game and joins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
network_game_server_is_bot_machine (cea7abfc, Bots as players of the
host) was written into the HALO_ANDROID block of network_server_manager.c
while bots.c calls it on every platform, and so was the array it reads;
both move out of the guard (on the desktop nothing marks a bot machine,
so the check is always false there, the writes stay Android-only).
glClearDepthf (dc21f4b4, Play in VR on OpenXR headsets) is ES-only:
GL_FUNCTIONS declares it for Android's loader and the define at the top
maps glClearDepth to it, but the desktop context links against GL's
glClearDepth. The clear now calls glClearDepth, which the Android define
turns into glClearDepthf as before.
The Xbox draws the mirror into a 320x240 target
(RASTERIZER_TARGET_RENDER_SECONDARY_WIDTH/HEIGHT), a quarter of the
picture's linear resolution at 1080p, and gives its shaders that size:
that is the softness the reflection has always had. The game's size
stays - its viewport, its projection, and the 320.0f/240.0f it puts in
c[-84] for the mirror's projective texture coordinate are untouched, so
nothing about the reflection's mapping moves - and the texture it is
drawn into gets the display's own pixels, the viewport and the clear
following it (target_pixel).
A colour and its depth must be the same size in GL, and the game pairs
the reflection's target with the screen's depth (the reflection's target
has no Z of its own, so rasterizer_set_target uses the primary's), so
draw into a depth of the colour's own size: the game clears the depth of
every target it draws the scene into, so one of its own behaves as the
shared one did.
The mirror's mapping was checked over a play session - the surface's
plane, the mirrored camera it gives, and the surface's own screen
position all come out exact - so the reflection is where it was, in more
pixels.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6c3f4ce2 (VR: draw both eyes in one pass) changed prepare_draw to pass
targets_multiview to vertex_shader_get, but the variable is declared and
kept inside the HALO_ANDROID guard (L887): Linux and Windows failed to
compile with 'use of undeclared identifier targets_multiview', Android
was fine. On the desktop there are no multiview layers to stand in for
the back buffer, so the desktop call passes FALSE.
The runtime offers only the rate SteamOS is set to. At 72 Hz the game holds
it with multiview (about 11.6 ms a frame); at 90 Hz it cannot, and the
frames SteamVR makes up showed as a doubled picture when the head turned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shot fired from the muzzle of the weapon in the hand was kept inside the
level by the game's test of the line from the eyes to it, which in VR runs
down through the player's own body: the test hit it and moved the shot up
towards the eyes, from where it flew parallel to the aim, a few centimetres
above the crosshair and the laser (measured: up to 7 cm at 3 m). In VR the
line is tested against the level alone (a hand through a wall still does
not fire through it); measured after: the impacts on their rays, on
average within a millimetre.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gamescope (the Steam Deck's Game Mode) makes the window the size of the
display but never confirms the fullscreen request, so SDL leaves
SDL_WINDOW_FULLSCREEN unset. platform_screen_mode then reported a window,
the game drew the Xbox's 640x480, and gamescope stretched it to the
screen, soft and 4:3. A window that was asked to be fullscreen and covers
its display now counts as fullscreen too, so the game draws at the
display's resolution and shape (1280x800 on the Deck).
An antenna's chain is a simulation of the Xbox's own step, an update a tick,
and the port was stepping it once a frame with the frame's own time. Its
springs, its carry and the points' physics then acted against the frames'
movement of it, so how far the chain moved per update depended on the frame
rate, and a vehicle at speed swung it far wider than a tick's step does.
- game.c steps the chain in the tick, beside the effects, which are already
stepped there.
- antenna.c records the shape each tick leaves, relative to the point the
vehicle's marker carries the base by. A frame drawn between two ticks is the
part of the way between those two shapes that it is, carried to where the
marker is now, so the chain stays on the vehicle it hangs from instead of
lagging a tick behind it. The gate that left a chain that had not been drawn
for five updates unstepped counted frames; it counts ticks now, which is
what it counted on the Xbox.
- widgets.c no longer steps the chain from the widget update, which ran once a
frame.
- game_time.c draws the per-frame effects and widgets that hang off objects
(game_frame) with the pose between the last two ticks, the pose the renderer
draws with, so what they place on an object is drawn where they put it.
- objects.c hands markers the matrices the renderer draws with, so an effect,
a particle system or a contrail attached to a moving object follows it
between the ticks too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SteamVR offers the Frame's controllers by their own profile
(XR_VALVE_frame_controller_interaction); without bindings for it, it mapped
the Quest's onto them, and X and Y landed on the left one's D-pad. Now: the
right one's A, B, X, Y and menu button are the game's A, B, X, Y and Start
(the Xbox's layout), the left one's View is Back and its D-pad the D-pad,
the bumpers switch grenades and the flashlight. The VR options' page of
buttons shows the Frame's layout there. The runtime's extensions and the
controllers' profile are in the log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screen-space conversion that the Xbox's vertex shaders do is undone by
multiplying the screen position by w again, which is lossy near the camera
plane: the conversion takes the reciprocal of w (rcc), which clamps there, and
1/w rounds differently on each GPU.
Worse, where w is zero or not a number the reconstruction leaves the vertex at
the clip-space origin. That point is inside the frustum, so nothing clips the
triangle away, and OpenGL's zero divided by zero puts the vertex in the middle
of the screen: the triangle is then drawn from wherever its other vertices are
out to there. On the first-person weapon, whose pose follows the camera and so
reaches the camera plane, that is a streak across the whole view. The Xbox's
own divide sends such a vertex to infinity and its clipper discards the
triangle.
Compute the final position from the clip-space position that the shader keeps
whenever it takes the reciprocal of w, so that nothing is reconstructed by
dividing; and where w is zero or not a number, put the vertex behind the
camera (w = -1), which the clipper also discards, as the Xbox did.
Both paths were behind the Android build's #ifdef, so desktop GL only ever
used the lossy reconstruction. The y flip and depth range that follow remain
per platform.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SteamVR draws a quad's texels by their alpha alone, and the game's menus,
HUD and text have none in their additive parts: on the Frame nothing showed.
On SteamVR (the runtime's name says) the copy into the quads' images takes
their alpha from their colour where it is higher; HALO_XR_QUAD_ALPHA
(colour, keep) overrides, for trying. The Frame's notes updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lepton empties the app's own folders (and uninstalls the app) whenever it
starts again; Documents is the SteamOS user's and kept. When
/home/steamos/Documents/HaloCE holds the maps and is writable, the launcher
and the game take it as the data folder (maps, config.toml, saves, logs).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lepton has no clipboard service, and SDL3's activity died taking one for
granted: the build now patches SDL's clipboard handler to do without.
The Frame lists an implicit OpenXR layer for Vulkan apps whose library an
app cannot load, which failed xrCreateInstance (and the Android loader
reads no variable to turn it off): the app now carries a pass-through
layer under that library's name. With both, the game runs on the Frame
and its OpenXR session starts (SteamVR, 1728x1728 an eye, 72 Hz).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream's server browser of public internet games (p2p_lobby.c) and the VR
port's own public list share the brokers: its topics are a table now, and
the VR port's (join codes' and public games') are entries of it; the VR
port's retained publishes carry MQTT 5's empty properties. The VR port's
public toggle lists the game in the server browser too, and the VR online
panel shows the server browser's games (PC, phones, VR) first, joined by
their invites, then its own list's that it has not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Driving a jeep or a scout, a bot looks ahead (farther the faster) along the
way it turns: a wall or a rock there (not ground it can drive up) is passed
on the most open side nearest its way, slower, and boxed in it backs off.
Stuck is now making no way along its heading for a second and a half, which
spinning wheels no longer hide. A teammate in the way is gone round slowly
on the other side instead of waited for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The triangle the game draws over each teammate goes into the name's slot
over the head (shown in the world there, not on the HUD before the eyes,
where it hung distorted), under the name, or alone when names are off. On
the screen where controllers join by pressing A, a click is the pad's A. On
the screen of games to join, the online games' hint goes into the key bar's
free left and the join status under it, clear of the map's picture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A choice the stick moved to (a map among the tiles) is no longer taken back
by the pointer resting on another, until the pointer moves well away; and a
click beside a list's slots or a value's box, along the way it steps (where
its arrows are drawn), steps it back or on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Just in, a bot driver waits up to six seconds while a bot goes for its
vehicle or a person of its team stands near it with a gun's seat free; it
brakes (backing, if fast) for a teammate on foot in its way. A bot takes a
vehicle only when the nearest enemy (or the objective) is a long way off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A turret near a bot (15 world units) is manned while an enemy is within 35
of it, and left once there has been no one to shoot for ten seconds; at it
the bot aims and fires as at a vehicle's gun. The map's vehicles are named
in the log once a game.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server browser cleans each game's name as a host cleans the names
of the players and machines that join it, and leaves out a game whose
name has nothing left that names it. A host lists a Latin letter with
a mark as its plain letter, as player names are read.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In multiplayer a second weapon is picked up into the empty slot on
walking over it and readied, as in campaign; a client readies the
weapon the host says it picked up once it has it. A swap drops the
weapon the player selected, not the one still in hand mid-switch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A player with no weapon, whose biped has no blow of its own, borrows the
melee of the globals' first multiplayer weapon. A campaign map's globals
list no multiplayer weapons, and a10 starts the player unarmed, so a melee
in the cryo tube's tutorial asked for element 0 of an empty block and
halted in tag_groups.c. With no multiplayer weapon there is no blow to
borrow, and the melee does what the game's always did there: nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release build no longer has a console, and the platform layer's log
(platform_log to stderr: invite links, the data root, crash reports)
went only there. When stderr goes nowhere, halo.log next to halo.exe
gets it instead.
The release halo.exe was a console-subsystem PE, so Windows and Wine
open an empty console window next to the game. Under Proton in
gamescope the console can take focus and leave a black screen. Nothing
reads the console (the log is debug.txt), so release builds now link as
GUI with mainCRTStartup; debug builds keep the console.
A bot whose way is long takes an empty vehicle it can drive near it (a
Warthog, a Ghost, a Scorpion), walking to the driver's door and standing
there until the game offers the seat. Driving, it follows a vehicle's path
(from the vehicle's surface, wider, its steps further apart), turning a
Warthog or a Ghost by facing where it goes and pushing a Scorpion there from
where its gun faces; it runs an enemy down, circles one with a gunner
aboard, fires a Ghost's and a Scorpion's guns, backs off what it is stuck
on, roams between the map's spawn points, and gets out at the objective,
overturned, stuck again and again, or with an enemy close while it stands.
In a game of teams a bot mans the gun of a teammate's vehicle, a person's
first (or rides along with a person), aims and fires it, and gets out once
no one drives. Overturned vehicles near it are turned upright.
In a race a bot heads for the flag it is to touch next
(game_engine_race_next_flag), in a vehicle if it can have one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A panel of the VR settings one may change while playing (turning, snap
angle, smooth speed, the vehicles' comfort vignette, the Warthog's wheel,
the weapon laser, the crosshair, the motion tracker on the wrist), written
to config.toml, which vr.c reads again as they change; and a page of what
the controllers' buttons do, on foot, in vehicles and in the menus. The
pointer works both, and its hint opens the panel. The menus' screens are
named in the log as they come up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream's PC menus are kept for the desktop; in VR the Xbox's stay, which
the VR port's join codes, game list, host options and keyboard pointer are
built into. config_write_integer now writes through upstream's config_write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The game type editor's kills to win (Slayer and Team Slayer) went up to
50, which a game of many players reaches in moments: it now also offers 75,
100, 150, 200, 250 and 500. The spinner's items are its string list's
(ui.map's 5, 10, 15, 25, 50) and then strings of the port's own, each with
its description; the options list's descriptions count only a spinner's
items of the tag's own, so those of the rows after it are where they were.
A spinner with extra items is a little wider, for three digits.
Holding a Warthog's wheel, the triggers drive as the left stick pushed up
or down would (backwards once stopped), unless the stick is pushed further;
they neither fire nor hold a grenade then.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At an open vehicle's wheel, a hand that grips near it (where the driver's
drawn hands hold it) takes hold, and the hands turn it about its column:
both by the line between them, one about the wheel's middle. Turned
vr.vehicle_wheel_degrees (90) either way it steers fully; let go, the right
stick steers as before. Holding the wheel, the right grip is no melee.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The menus' mouse handling took the pointer's clicks each frame before the
keyboard asked for them, so the keyboard never saw one: with the keyboard
up the pointer is now the keyboard's alone. Clicking the hints under it
(B = BACK, A = ENTER, in its picture) cancels or finishes as Done does.
The pointer counts as moved past 3 pixels, not a hand's tremor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The key under the controller's pointer is picked and a click types it.
In the menus the triggers no longer reach the game as the gamepad's (they
click the pointer; on the keyboard they also moved its cursor).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key's label is a text box often far wider than its text, reaching over
the next key (Back pressed Delete). Of the keys under the pointer, the one
whose icon begins nearest on its left is taken: each key from its icon to
the next one's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screen keys' margin in VR was wide enough to reach the next key (Back
pressed Delete): now 2 pixels to the sides and 4 above and below, with a
label at most 25 pixels off. A click is taken where the controller
pointed as its trigger began to move (past a tenth), not a fixed time
before, which could still be on the key it just left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A click is taken where the controller pointed a few frames before the
trigger went down (pulling it tips the controller), and in VR a screen
key's button takes a label up to 40 pixels off and a margin of 6 about it,
so the keys under the menus (A, B, Back) are hit as readily as items.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop builds' mouse in the menus (halo_ui_pointer.h) now comes, on
Android in VR, from a controller: where its aim's line meets the menus'
screen fixed in the room is the pointer, the trigger clicks (the hand
pressed last points; a click ticks its rumble), and a ringed dot shows
where it points. The item under it takes the focus and a click presses A,
as with the mouse; the online games and host options panels take it too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each bot has a direction of its own (the golden angle on from the last):
far from a shared goal (the enemy's flag, the ball, the hill) it comes by
a way of its own, out to its side of the straight way, straight only for
the last 10 world units; holding a place it takes a spot of its own: on
the hill, about its flag's stand (every other guard ahead of it towards
the enemy's), about a teammate it escorts. With no way to its spot it
takes the point itself. A bot with an objective no longer leaves it in a
fight for a weapon close by.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a game of teams a bot holds its fire while a teammate stands in the
line of it (and steps aside for a clear shot), takes on first the enemies
near its teammates (people above all), escorts a teammate carrying the
enemy's flag or the ball a few world units off on its own side, and, with
nothing to do, every other bot keeps near the nearest person on its team.
A goal it finds no way to three times running (a person up where only a
teleporter goes, a thing out of reach) is given up for a while, and a
roaming bot then goes somewhere near at random.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The HUD in VR is a screen at one distance before the eyes, where the names
upstream draws on it hung. Each name is now drawn into a slot of a region
of the back buffer before the HUD (up to eight, one under another), taken
into a names layer, and shown on a quad of its own over its player's head,
facing the eyes, as wide as it would look on the HUD: in depth, for both
eyes. Who is named, and when, is as upstream has it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In VR the HUD is a screen at a fixed distance before the eyes, so the
names upstream draws on it hung there instead of over the players' heads.
Off until they are drawn into each eye's picture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Independently verified donor c694a43d4e3343eedebac48fe6f2b6052f679778 under Q-UP21 after the separately gated helper restoration. Fresh clean full gate against 09255f0e: cumulative +3 functions, +1238 meaningful / +1264 padded bytes, zero exact losses; 7483 of 7573 Halo functions, 1603071 of 1770166 code bytes, data 3311591 and objects 396 unchanged, parks 59 valid. Tools tests: 1663 passed, 5 skipped, 159 subtests. Full /W3 and unit /W4 multisets unchanged. All twenty cast ablations and layout controls passed; helper identity, real-call schedule and 284 ordered provider-link probes passed. The disclosed residual caller changes 4144 to 4128 bytes.
Retain the narrow per-function parentheses ruling, inferred scopes and collision_result placement, S6 correction, single-variable alternative and compiler-version caveat. No general hold, object admission, research candidate, scorer/verifier or compiler flag change. Fresh canonical receipts and source-policy boundaries are recorded in docs/object_matching_logs/biped_avoidance_canonical_reconciliation_20261002.md. Unrelated README changes and research preserved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Independently reconciled donor 41d3b992a5e5c0fbbb5f76c476d27f9c68031e82 without candidate F. Fresh clean full gate: no additional gains or losses, all prior function sections identical, one new uncredited 48-byte SELECT_ANY point_from_line3d copy identical to January, unchanged real-call schedule, warnings, data and object credit. Current-compiler cast ablation and layout controls pass, 284 ordered provider-link probes pass, 1663 tools tests pass. Six inferred casts and three byte-inert per-site parentheses are disclosed; all other actor_moving holds stay in force.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Independently reconciled donor 82ea0d5e9bcee8a1a48775aab6f0c351f5ee5e2a after the biped packet. Fresh clean full gate: zero additional gains or losses, all normalized objects unchanged, parks valid, warnings unchanged and 1663 tools tests passed. Current-compiler cast ablation is byte-inert; nine layout/alignment checks pass and a deliberately false control fails. Both current selected-provider link orders pass. Cast spelling remains inferred; no new parenthesis or general view-cast exception.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Independently verified donor edbdbf729e9b7c0c09557f1ccb45f94ad6c8a06a against canonical 09255f0e: fresh clean full build, strict whole-board sweep with zero losses, parks 61 to 59, unchanged data/object credit and warnings, 1663 tools tests passed. Folded helper identity and all selected-provider pair links passed. Per-site parentheses, descriptive constants and coupled jumping form remain narrowly disclosed as inferred; no general hold lifted. Unrelated canonical biped_accelerate shape and README edit preserved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The disc sits 7 cm over the hand, where a rifle's or pistol's scope is;
over the rocket launcher, carried higher on the shoulder, it was inside
the tube. A weapon may raise it (halo_vr_set_scope_raise): the rocket
launcher by 11 cm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Frame interpolation blends each node between its last two tick snapshots.
Blending two snapshots of different poses node by node stretches vertices
across the screen for a tick, then they snap back:
- the first-person pose blended from whatever was drawn last, which after
being zoomed, in a vehicle, dead or in a cinematic is seconds old, and
after a weapon switch may be another skeleton with as many nodes;
- an object's pose was only checked by its root node's move (10 units), so
the game changing a pose at once (an actor waking from dormancy, a model
swapped) was swept through poses it never had.
Now the first-person pose blends only from the tick just before, and not
when a node moved more than 0.25 units in the camera's frame; and an object
snaps when a node moved more than 0.4 units in its root node's frame (in
that frame, so the whole object turning or moving counts for nothing).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each changed vertex constant register takes the next serial, and a program
uploads the registers changed since the serial it last saw. The serials
were 32-bit; a skinned model's draw changes up to 132 registers, so at a
high frame rate they wrapped within minutes to an hour. After a wrap, the
log walk (serial <= constants_serial) and the full scan (register serial >
program's serial) both found nothing changed, and every program's next
draw used the registers it last uploaded: often another object's node
matrices, so its vertices flew across the screen for a frame. The serials
are 64-bit now and never wrap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Approved coherent packet 41640b4d, independently clean-built after transport and plane-helper reconciliation. +538 meaningful / +544 padded bytes; main 95/95. Replace the invented packed aggregate with January's separate storage, add exactly eight private symbol rows and retire the frame-rate park. Names, six static scopes and exact main_game_render placement remain disclosed as inferred. Full stable sweep, parks, admission, fake scan and 1663 tools tests pass; no new warnings, data, objects or providers. Cumulative reconciliation +5 functions, +2121 meaningful / +2144 padded bytes, zero exact losses. Preserve dirty README and unrelated research. No PCH, scorer, verifier, status or held-source changes. Detailed independent receipts and limits recorded in docs/object_matching_logs/upstream_tmp_reconcile_20261002.md.
Approved coherent packet 78294dd3 (Q-UP1), independently gated on canonical. Remove only outer helper parentheses, preserving /Od argument order, together with the attested float argument repair in item_accelerate. Items object remains identical. +1126 meaningful / +1136 padded bytes; data/object/provider sets unchanged. Fresh full build, stable sweep, admission, fake scan (26), parks and tests (1663 passed, 5 skipped, 159 subtests) pass; no new warnings. Retire only two exact parks. Remeasure four residuals on canonical, preserving previous measurements/rejection history: frustum 88.8082->90.11173; pill 97.21691->98.086395; polygon clip 80.76382->82.37186; lens flares 99.29274->99.198944 (disclosed decrease). Do not import unrelated lane fidelity changes or held parentheses/DD2/DD3/casts.
Independently reconciled lane e191c0f1 under Q-UP1. Header and definition return long together; January startup-store and cleanup topology retained. Retire only the two verified parks. Fresh clean build and stable sweep: +457 meaningful / +464 padded bytes, 0 losses, unchanged data and object status. Affected /W4 warning counts unchanged; inherited WSA narrowing moves to initialization. Admission/parks/fake scan pass; tools 1663 passed, 5 skipped, 159 subtests. No new providers or section/data changes beyond the two intended functions. README and unrelated research preserved.
The game gives positions and each sound's minimum and maximum distances in
world units, and sets DirectSound's distance factor to 3.048, the meters in
a unit (10 feet). The mixer multiplied the distance by that factor before
the rolloff, but not the minimum distance, so every 3D sound was heard 3
times as far away as it was: up to 10 dB quieter against the 2D sounds,
and panned as if farther. The distance factor is DirectSound's for Doppler;
the rolloff is in the units the distances are given in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mixer resampled each voice to 48 kHz by linear interpolation, which
left the images of a 22 kHz voice's band only 3 to 21 dB down (an 8 kHz
tone's image at 14 kHz was 10 dB under it) and dulled the band itself
(-4 dB at 8 kHz): voices sounded gritty and low-fi. It also held a packet's
last frame instead of reading on into the next packet. Now each output
sample is band-limited interpolation of the frames around it (a Kaiser-
windowed sinc over 16 zero crossings, J. O. Smith's method): flat to 8 kHz
at 22 kHz, the images 66 to 98 dB down, the low pass narrowed for voices
played faster than the output rate, and the frames taken from the packets
in turn, so it reads across their boundaries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Xbox ADPCM block's header holds its first sample, and its nibbles code
the 63 after it; the 64th nibble only pads the block. The decoder dropped
the header's sample and decoded the padding in its place, so every 64th
sample was wrong: a buzz at 344 Hz under every 22 kHz sound, voices most
of all. In the maps' sounds the last nibble is always 0, and the next
block's header follows the 63rd nibble's sample by one ordinary step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The voices sum at their full level, as the Xbox's mix bins did, and the
mixer bent every sample over 0.8 with tanh. When loud voices piled up, as
dialogue over gunfire does, that distorted the sound: a 2x full scale sine
came out with 23% harmonic distortion. A look-ahead limiter now turns the
whole mix down for as long as it would go over -1 dBFS, both channels
alike, and brings it back over 100 ms. The output is 1.3 ms later, so the
gain comes down before each peak; below full scale nothing changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve main README unchanged. Source/config/verification tree equals canonical 5e55d8f8; two exact Halo gains, zero regressions, +1998 meaningful bytes. No object or data-credit changes.
Cleaned punpck donor reconstructions; strict bytes and all relocations match January. Fresh cold whole-board gate: 2 gains, 0 losses; 1663 tests pass. Retire the two parks. Disclose six HUD /W3 C4244 narrowing warnings and six identical shared-inline helper copies; provider links pass both orders. No object or data credit changes; inherited README and research excluded.
A widget background bitmap whose bounds cover the whole 640x480 design
space (the pause menu dim, for example) stopped at the edges of the
centered 640 column on widescreen displays, leaving the side bands
undimmed. Widen the bounds and the clip symmetrically to the full
screen width, mirroring the existing fade_to_black special case in
render_ui_widgets().
Tested on a Retroid Pocket Flip2 (854x480 render target).
configure.py hardcoded the NDK's linux-x86_64 toolchain folder, so the
Android build could not find the NDK on a Mac. Pick the host's folder
(darwin-x86_64, windows-x86_64 or linux-x86_64), or the only one the NDK
has.
On a case-insensitive file system, the game's <StdDef.h> shim found itself
again through its own <stddef.h> inclusion and recursed. Use include_next,
as the port's other header shims do.
2026-09-29 20:51:24 -06:00
1747 changed files with 311971 additions and 8987 deletions
printf '**Ringfall: Mjolnir VR** is an unofficial VR mod for Halo: Combat Evolved. It needs a disc image of your own, purchased Xbox copy of the game; no game files are included.\n\n'
printf '| Headset | Download | How to install |\n| --- | --- | --- |\n'
printf '| Meta Quest 3 / 3S | `RingfallMjolnirVR.apk` | [Install on Quest](%s#install-on-meta-quest-3--3s) |\n' "$readme"
printf '| Steam Frame | `Install-Ringfall-Mjolnir-VR.desktop` (open it in Desktop Mode) | [Install on Steam Frame](%s#install-on-steam-frame) |\n\n' "$readme"
printf 'The other files are for the built-in updater and the Steam Frame installer.\n\n'
This repository ports the Halo: Combat Evolved decompilation (Xbox build
01.01.14.2342, `cachebeta.exe`) to Linux, Windows, Android and the web. The game's C
sources are the decompilation; the port adds a platform layer that
implements the Xbox APIs the game calls, and features the Xbox game did not
have (a new netcode, internet play, the PC version's menus, Custom Edition
maps, touch controls). The project is published as OpenCE.
Start with [README.md](README.md). The other documents are listed at the
end.
## Layout
| Path | Contents |
| --- | --- |
| `source/` | The game: 466 C files of the decompilation, compiled as they are with few changes (each marked, below) |
| `port/linux/src/` | The platform layer the three ports share: Direct3D 8 on OpenGL (`d3d8_gl.c`), DirectSound and XInput on SDL3, files, threads, memory, sockets, settings (`port_config.c`), the mods folder (`port_mods.c`), internet play (`p2p*.c`) |
| `port/linux/game/` | The port's game-side code, compiled as the game's sources are: the distributed netcode (`network_*.c`), co-op, map sharing (`map_share*.c`), the menus (`menu_tags.c`, `menu_functions.c`), Custom Edition maps (`custom_edition_*.c`, `cache_file_formats.c`), the tag validator (`tag_validate.c`, `tag_schema_*.c`), mods (`mods.c`, `mod_*.c`), streamer mode's word filter (`text_filter*.c`), touch, FOV and more |
| `port/linux/include/` | The port's headers: the limits (`halo_port_limits.h`, with `HALO_PORT_NETWORK_VERSION`), capacities (`halo_port_capacity.h`), the prefix header, `halo_linux_source_fixups.h` (port functions the game's sources call) |
| `port/include/xdk/` | Stand-ins for the Xbox SDK's headers (declarations only) |
| `port/windows/` | The Windows build's own files (`win32_*.c`, headers) |
| `port/android/` | The Android app: the guest runtime, the host library (`host/`), the Java app (`app/`), host imports (`host_imports.list`) |
| `port/web/` | The web build: its runtime (`src/`: entry, sockets inside the page, memory watch), the page and its service worker (`site/`), the SDL port, tests |
| `port/assets/` | What the builds embed or ship: high-res HUD (`hud/`), fonts, titles, menus (`menus/`), icons, network brokers |
| `port/third_party/` | Vendored libraries, each with a README naming its upstream, version and checksum |
| [port/linux/README.md](port/linux/README.md) | The Linux build, controls, menus, every setting, updates, frame rate, field of view, system link, internet play, voice chat, the server browser, map sharing, security, map checks, how the port operates, the game source changes |
| [port/linux/NETCODE.md](port/linux/NETCODE.md) | The distributed netcode, network versions, joining a game in progress, map sharing, transport, testing |
| [port/windows/README.md](port/windows/README.md) | The Windows build, its headers, inline functions, crash reports |
| [port/android/README.md](port/android/README.md) | The Android build, the guest and host, touch controls, Android settings, finding problems |
| [port/web/README.md](port/web/README.md) | The web build, the page, the game data in the browser, limits, how it operates, hosting on GitHub Pages |
| [port/include/xdk/README.md](port/include/xdk/README.md) | How the SDK declarations were written and are checked |
| [docs/custom_edition_caches.md](docs/custom_edition_caches.md) | Loading and converting Custom Edition maps, and the evidence for each layout |
| [docs/mods.md](docs/mods.md) | Mods: folders of Halo PC tag files whose sounds, bitmaps, text and models play over a map's on one machine |
| [port/assets/text_filter/README.md](port/assets/text_filter/README.md) | Streamer mode's words: Surge AI's list, the review, the allowed words |
| [port/assets/menus/README.md](port/assets/menus/README.md) | The menu files' format and how to write them again |
| [port/assets/menus/NON_HANDDRAWN.md](port/assets/menus/NON_HANDDRAWN.md) | Menu pictures that are not redraws |
| [port/assets/menus/UNWIRED.md](port/assets/menus/UNWIRED.md) | Menu functions that do nothing yet |
| [port/assets/fonts/README.md](port/assets/fonts/README.md) | The fonts and their licences |
Ringfall: Mjolnir VR is released under the GNU Affero General Public
License, version 3 or later ([LICENSE](LICENSE)); releases before build 312
were released under [CC0 1.0](LICENSES/CC0-1.0.txt). It includes or builds
on the following, under their own licenses; each license's full text is in
the file named.
## Code
| Component | Used for | License | Text |
| --- | --- | --- | --- |
| [OpenCE](https://github.com/OpenCommunityEdition/OpenCE) and the decompilation ([bnunu/halo-1](https://github.com/bnunu/halo-1), [punpckhdq/halo](https://github.com/punpckhdq/halo)) | the game's code, the port | AGPL 3.0 or later (OpenCE from 19 October 2026, punpckhdq/halo from 17 October 2026; CC0 1.0 before); bnunu/halo-1: CC0 1.0 | [LICENSE](LICENSE), [LICENSES/CC0-1.0.txt](LICENSES/CC0-1.0.txt) |
| [HaloCE-Quest-VR](https://github.com/moistman42069/HaloCE-Quest-VR) | the arm IK, forearm twist and finger poses (`port/linux/game/vr_arms.c`) | CC0 1.0 | (its repository) |
| [Khronos OpenXR loader](https://github.com/KhronosGroup/OpenXR-SDK) | VR runtime access | Apache 2.0 | (in the loader's package) |
| [musl](https://musl.libc.org) | the C library of the game's code on Android, its maths | MIT | [port/third_party/musl-math](port/third_party/musl-math/README.md) |
| [Expat](https://libexpat.github.io) | the PC menus' definitions | MIT | [port/third_party/expat/COPYING](port/third_party/expat/COPYING) |
| [Mbed TLS](https://www.trustedfirmware.org/projects/mbed-tls/) | HTTPS for the desktop builds' updater (not in the Android app) | Apache 2.0 | [port/third_party/mbedtls/LICENSE](port/third_party/mbedtls/LICENSE) |
| [miniupnpc](https://miniupnp.tuxfamily.org) | router port forwarding for online games | BSD 3-Clause | [port/third_party/miniupnpc/LICENSE](port/third_party/miniupnpc/LICENSE) |
| [KCP](https://github.com/skywind3000/kcp) | reliable transport for online games | MIT | [port/third_party/kcp/LICENSE](port/third_party/kcp/LICENSE) |
| [Monocypher](https://monocypher.org) | signing public games' listings (Ed25519); online play's other cryptography is the port's own | CC0 1.0 or BSD 2-Clause | [port/third_party/monocypher/LICENCE.md](port/third_party/monocypher/LICENCE.md) |
| [tomlc17](https://github.com/cktan/tomlc17) | reading `config.toml` | MIT | [port/third_party/tomlc17/LICENSE](port/third_party/tomlc17/LICENSE) |
| [stb](https://github.com/nothings/stb) | drawing the game's text sharply (stb_truetype) | MIT or public domain | [port/third_party/stb/LICENSE](port/third_party/stb/LICENSE) |
| [SMAA](https://www.iryoku.com/smaa/) | anti-aliasing (flat screens; off in VR) | MIT | [port/third_party/smaa/LICENSE](port/third_party/smaa/LICENSE) |
The disc image readers (`XisoExtractor.java`, `steam_frame_xiso.py`,
`xiso.c`) are written from a description of the Xbox's disc file system
([xboxdevwiki's XDVDFS page](https://xboxdevwiki.net/XDVDFS)), not from
extract-xiso, which earlier releases followed.
## Fonts
| Font | Used for | License | Text |
| --- | --- | --- | --- |
| Overpass (Red Hat, Delve Withrington) | the game's text, the library pictures | SIL Open Font License 1.1 | [port/assets/fonts/Overpass-OFL.txt](port/assets/fonts/Overpass-OFL.txt) |
| OpenCE (after Newtown) | the menus' titles | SIL Open Font License 1.1 | [port/assets/fonts/OpenCE-OFL.txt](port/assets/fonts/OpenCE-OFL.txt) |
| Web | [halocombatevolved.com](https://halocombatevolved.com/) (or [halo-web-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-web-release.zip) for your own server) | [halo-web-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-web-debug.zip) |
Use the release build to play. The debug build stops at the first failed
assertion and writes it to the log. Use the debug build to find and report
problems.
The game updates itself. At start-up it looks for a newer release, and asks
if you want to install it. Refer to "Updates" in
[port/linux/README.md](port/linux/README.md#updates). The web site offers
each new build when it is out.
Each build of the `main` branch that passes on all four platforms is a new
release, and the web site then plays it. The [Releases](https://github.com/OpenCommunityEdition/OpenCE/releases)
page keeps the last five releases. If the latest build has a problem, get
an older build from that page.
### Arch-based Linux Distributions
The above Linux binary release is available on the [AUR](https://aur.archlinux.org/packages/opence-bin).
## Game data
The port does not include the game data. Download an Xbox disc image
(`.xiso` or `.iso`) of Halo: Combat Evolved. All versions of the game
operate. The maps of the European (PAL) version were made for a slower
console. The port changes them to play as the North American (NTSC) maps do,
so players of the two versions can play together.
1. Start the game.
2. At the first start, the game asks for the disc image. Select it.
3. The game extracts the `maps/` folder. Then the game starts.
On Linux and Windows, the game puts `maps/` next to the executable. On
Android, copy the disc image to the phone first. The app puts `maps/` in its
data folder. Refer to [port/android/README.md](port/android/README.md). On
the web, the page copies `maps/` out of the disc image into the browser's
storage for the site; nothing is uploaded. Refer to
[port/web/README.md](port/web/README.md).
## Mods
Mods are folders of Halo PC tag files, as the Halo Editing Kit and Invader
write them, in `mods/` next to `maps/`: texture, sound, text and model packs
that play over the maps' on your machine only, in any game. Each has a
`mod.toml`. Every mod in the folder is on; the main menu's Mods turns them
off and on and sets the order they load in.
Refer to [docs/mods.md](docs/mods.md).
## Platforms
Each platform has its own instructions:
| Platform | Instructions |
| --- | --- |
| Linux (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/linux/README.md](port/linux/README.md) |
| Windows (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/windows/README.md](port/windows/README.md) |
| `ninja web` | `build/web/site/`, the web site (`python tools/web_serve.py` serves it) |
If you enter `ninja` without a target, ninja builds the game for the
computer that you use.
`tools/ci_build.py` makes the same builds as GitHub Actions. For example,
enter `python tools/ci_build.py linux release`.
### Build options
Give these options to `configure.py`:
| Option | Result |
| --- | --- |
| (none) | A debug build. A failed assertion stops the game. |
| `--release` | A release build. The game does not examine assertions, as in the retail game. |
| `--portable` | The Linux and Windows builds operate on all x86-64 processors. The Linux build also operates on older distributions and on SteamOS: refer to "Portable build" in [port/linux/README.md](port/linux/README.md#portable-build). Use this option for builds that you give to other persons. |
| `--lto=thin`, `--lto=off` | Less link-time optimization. The link is faster. |
| `--pgo=off` | No profile-guided optimization. |
| `--pgo=train` | Records a new optimization profile. Refer to "Optimization profiles". |
| `--profile` | A profiling build. It records CPU times. Refer to "Profiling builds". |
| `--web-emcc PATH` | The Emscripten `emcc` for `ninja web` (else the one on the PATH, or `~/emsdk`'s). |
Without `--portable`, the Linux and Windows builds use all the instructions
of the processor that builds them (`-march=native`). Such a build does not
always start on a different computer.
### Optimization profiles
The builds use profiles of the game to optimize the code:
-`pgo/halo_linux.profdata` for Linux and Android.
-`pgo/halo_windows.profdata` for Windows.
The profiles need clang 22 or later. With an older clang, the builds do not
use the profiles.
To record a new profile:
1. Delete the profile.
2. Enter `python configure.py --pgo=train`.
3. Enter `ninja linux` or `ninja windows`.
The build then plays the main menu and the first minute of each campaign
level. This procedure continues for approximately 15 minutes. The game
data must be in `assets/`.
### Profiling builds
A profiling build records where the game spends its time.
1. Enter `python configure.py --profile`, then build as usual.
2. Start a recording. Enter `profile_record` in the console (or the telnet
console), or set `debug.profile_record = true` in `config.toml`. To
record each game of a session, also set
`debug.profile_record_when = "game"`.
3. Stop the recording. Enter `profile_stop`, load another map, or quit.
The game writes numbered `.part<n>.json` files under `profiles/` in its data root
and logs their paths. Parts are retained after the recording stops. Logs go to
standard error; a Windows release build without standard error writes them to
`halo.log` next to `halo.exe`.
`tools/net_report.py` is a helper for developers. Enter
`python tools/net_report.py <recording.part1.json>` to read a recording
as tables. The command also writes `<recording>.summary.txt`, a short text
file.
| Command | Result |
| --- | --- |
| `profile_record [seconds]` | Starts a recording. With a number of seconds, the recording stops after that time. |
| `profile_stop` | Stops the recording. |
`debug.profile_memory` sets the memory of a recording in MB (default 256,
from 4 to 1024). A recording has no length limit:
the game writes a part each time the memory is full. Refer to "Settings" in
[port/linux/README.md](port/linux/README.md).
On Android, the files are in `/sdcard/Android/data/com.halo.decomp/files/profiles/`.
Enter `adb pull` to copy the recording parts to a computer.
A profiling build plays with normal builds. It does not use
`--pgo=train`.
## Licence
OpenCE is free software: you can give it to others and change it under the
terms of the GNU Affero General Public License as published by the Free
Software Foundation, version 3 of the License or (at your option) any later
version (`AGPL-3.0-or-later`). It comes with no warranty. The full text is in
[LICENSE](LICENSE).
- **From the change.** The AGPL applies from the commit that made the change,
and to each release built from that commit or a later one. Earlier
releases, and the source at every earlier commit, were released under
[CC0 1.0](LICENSES/CC0-1.0.txt) and stay that way.
- **What OpenCE builds on.** The decompilation of
[punpckhdq/halo](https://github.com/punpckhdq/halo) is under the AGPL 3.0
from 17 October 2026, and under CC0 1.0 before that date.
[bnunu/halo-1](https://github.com/bnunu/halo-1) is under CC0 1.0.
- **Contributions** are under `AGPL-3.0-or-later`. A change taken from a
project under GPL 3.0 keeps that licence and its notices; the GPL 3.0 and
the AGPL 3.0 let the two be combined (section 13 of each).
- **Hosts and servers.** If you change the game and let other persons play
with your version over a network (for example, as the host of an internet
game, or on a web site), the AGPL asks you to offer them the source of
your version (section 13). The game as released has its source here, and
each release's source is its tag (`build-<n>`).
- **Third-party code, fonts and art** keep their own licences, which are
next to them (`port/third_party`, `port/assets/fonts`,
`port/assets/icons`, `port/assets/text_filter`). The release downloads and
the web site's `licenses.txt` carry their notices.
- **Halo is not covered.** The licence covers the work of OpenCE's authors.
It gives no rights in Halo, its maps or anything else that Microsoft owns.
| Tag index (0x28 bytes, instances at 0x28, model data as file offsets) and tag instance (0x20 bytes, resource-map flag at 0x18) | same file (`s_cache_tag_header`, `s_cache_tag_instance`) |
| OpenSauce's header at 0x70 begins with the `yelo` signature, where Custom Edition's header has padding, and has its flags (memory upgrades, mod data files, protected, game state upgrades, compression parameters) at 0x76 | OpenSauce `YeloLib/Halo1/cache/cache_files_structures_yelo.hpp` (`s_cache_header_yelo`) |
| Tag cache at `0x40440000`, 23 MB; file size limits | OpenSauce `cache_constants.hpp`, `saved_game_constants.hpp` |
| Resource map header and entries | OpenSauce `data_file_structures.hpp` |
| Groups held by resource maps: bitmaps, sounds, fonts, unicode string lists, HUD message text | OpenSauce `blamlib/Halo1/cache/cache_files.cpp` (`cache_file_data_load`) |
| Structure BSP reference (0x20), header (0x18), structure, lightmap and material layouts | OpenSauce `scenario_definitions.hpp`, `structure_bsp_definitions.hpp`; this build's `structure_bsp_definitions.h` agrees |
| Bitmap, sequence, sprite, bitmap data layouts; pixels in `bitmaps.map` flag | OpenSauce `bitmaps/bitmap_group.hpp` |
| Gbxmodel layout: the part (0x84 bytes) is this build's 0x68-byte part followed by a local node count and a 22-entry node table | OpenSauce `models/model_definitions.hpp` (`gbxmodel_geometry_part`) |
| Where a part's strip and vertices lie in the model data (triangle count, strip offset, vertex count and offset in the buffer fields; 68-byte vertices) | Reclaimer `Blam/Halo1/GbxmodelTag.cs` (`ReadPCMeshes`) |
| Shader types: *transparent chicago extended* inserted at 7, before water, glass, meter and plasma; its two map sets | OpenSauce `shaders/shader_definitions.hpp` |
| Script syntax nodes: 19001 in the stock engine | OpenSauce `hs_constants.hpp` |
| HUD message text layout | OpenSauce `interface/hud_messaging_definitions.hpp` |
| Font (156 bytes) and unicode string list layouts | BlamLib `Blam/Halo1/Tags/Definitions/Misc.cs`, `Resources.cs` |
| Bitmap tags in `bitmaps.map` by index, pixels by absolute offset | Reclaimer `Blam/Halo1/CacheFile.cs`, `BitmapTag.cs`, `BitmapsAddressTranslator.cs` |
| The header checksum: CRC-32 of the structure BSPs (packed from 0x800), the model data and the tag data, not inverted | OpenSauce `cache_files_yelo.cpp` (`CalculateChecksumFromMemoryMap`), `memory_interface_base.cpp` (`CRC`); reproduces the stored checksum of 23 of the 24 sample caches |
### Observed (not stated by the sources; established on the sample maps)
| Fact | Evidence |
| --- | --- |
| Structure BSPs load at the top of the tag cache (`address + size` = `0x41B40000`) | all 24 caches (those with OpenSauce's memory upgrades at the top of its larger tag cache) |
| Bitmaps, fonts, unicode string lists and HUD text held by resource maps: the tag's address field is the entry index, the entry's name is the tag's path, the entry is the whole tag with addresses counting from its start | 9,550 bitmaps, 68 fonts, 2,248 string lists, 23 HUD texts across the sample |
| Sounds held by `sounds.map`: the map keeps the 0xA4-byte header, whose pitch range block has a count and no address; the entry named by the tag path holds the header again, then the pitch ranges and permutations, whose addresses count from the first pitch range | 7,397 sounds, 21,870 sample ranges |
| The map's copy of a resource-held sound's header has its compression and longest permutation length zero, and its encoding and sample rate zero too; the entry's copy has them as the Xbox has them | in `bloodgulch.map` and `beavercreek_halo3.yelo`, the two copies of all 527 resource-held sounds differ only in those four fields, in pointers, and in the map's own promotion sound reference; for the 315 sounds the Xbox `bloodgulch.map` shares, the entry's sample rate, encoding and the runtime fields after the longest permutation length equal the Xbox's; its compression does except for the 38 that Custom Edition has as Ogg Vorbis, and its longest permutation length for 273 (the others differ by a few milliseconds or more, among them the Ogg Vorbis ones); its pitch ranges' runtime fields all do |
| Sound permutations and resource-held bitmaps name tags of another map (the one the resource map was built with) | 632 of 676 bitmaps of `bloodgulch.map`; every resource-held permutation differs from the Xbox's own sound handle |
| Model parts: the strip and vertex data are in the model data, never in the part's blocks; the triangle buffer is a precompiled strip and the vertices uncompressed; the part flag 2 is set on exactly the parts that have a node table | all 24 maps (10,934 parts) |
| Converting a part's vertices with the game's compressor reproduces the Xbox's | for the 335 parts of the 60 models the Custom Edition and the Xbox `bloodgulch.map` share: identical strips; positions equal to within float rounding; texture coordinates and node weights exactly what the compressor makes of the Custom Edition values (clamped to ±1, as the Xbox also has them: `plant_broadleaf_short` has coordinates up to 3.0 in both); node indices the Custom Edition ones times 3, except that the Xbox writes an unweighted second node as -1 where the compressor writes the node Custom Edition names; normals within 0.002 |
| Every node index of a vertex names a node of its part's table (in models whose parts have local nodes) or of its model, every table entry a node of the model, and every vector is within ±1.0001 | all 24 maps |
| Structure BSP materials: environment vertices uncompressed (type 0), with lightmap vertices (as many as the environment ones) exactly in lightmaps that have a bitmap, and no compressed vertices | all 24 maps (the lightmap vertex type field is 0 or 2 regardless) |
| Custom Edition shader type values are 7 to 11 for `scex`, `swat`, `sgla`, `smet`, `spla`; the Xbox's are 8, 9, 10 for `sgla`, `smet`, `spla` | every shader of the 24 maps, and of the Xbox `bloodgulch.map` |
| Chicago shaders with extra layers: none in the stock maps or the Xbox `bloodgulch.map`; 2 in `beavercreek_halo3.yelo`, 2 in `celer_exile_odst_v2.yelo`, 4 in `extinctionrevanepic2.map`, 1 in `fy_killzone.yelo` | every `schi` and `scex` of the sample |
| Compressed color plates are left out of caches: size kept, address 0 | every bitmap in `bitmaps.map` |
| Font style references in `loc.map` are all `NONE` | all 3 fonts |
| Sound compression value 3 is Ogg Vorbis, value 1 is Xbox ADPCM | in `sounds.map`, all 106 value-3 permutations start with `OggS`; all 1,365 value-1 permutations are whole 36-byte blocks |
| `bitmaps.map` bitmaps are not swizzled, and cube maps hold each level's six faces together | all 1,467; 38 of 40 cube maps match that order when checked against their next level (one is uniform, one does not match) |
| Multipurpose maps: Custom Edition's red, green, blue and alpha hold what the Xbox's alpha, green, red and blue do | the multipurpose maps the Custom Edition and the Xbox `bloodgulch.map` share, decoded and compared channel by channel (the cyborg's, the warthog's, the boulders') |
| HUD meters: Custom Edition's alpha holds the Xbox's color (the fill order), and its color the Xbox's alpha (the shape) | `hud_ammo_meters`: Custom Edition's 512×512 A8R8G8B8 averaged down to the Xbox's 256×256 A8Y8 differs from the Xbox's luminance by 6.6 on average in alpha and 23 in color, and from its alpha by 12.3 in color and 23.4 in alpha; `hud_unit_meters` has its sprites rearranged, and shows the same swap when viewed. January's meter shader reads the fill order from color and discards texels without alpha |
| HUD elements flagged *use high resolution scale* draw bitmaps twice the Xbox's size; others the same size | every unit and weapon HUD placement of `bloodgulch.map` whose tag the Xbox map also has (the motion sensor's foreground, unflagged, uses a 128×128 bitmap in both) |
| Vehicle placements: the byte at 0x58 is the multiplayer team and the word at 0x5A the multiplayer spawn flags, default bits 0 to 3 (slayer, capture the flag, king of the hill, oddball) and allowed bits 8 to 11 | `hugeass.map`'s 78 vehicle placements: the byte is 1 on exactly the blue team's; the word is 0x0303 on warthogs, tachikomas and pelicans, 0x0F0F on ghosts, 0x0404 on 16 unnamed jets, 0x0808 on the night jet and 0 on vehicles its scripts create; OpenSauce leaves these bytes unnamed |
| Script function and engine global tables: Halo PC's have entries this build's lacks, so a compiled index names another entry from some point on; value types are numbered alike | the compiled scripts of the sample, each call's function index against the name its first child keeps, and each engine global's index against its name: `timberland.map` 23 calls shifted (`player_effect_start` by 29), `ui.map` 11 (`camera_set` by 1), `hugeass.map` 3 calls and 1 global (`rider_ejection`, 158 against 147), `extinctionrevanepic2.map` 9 calls, and 4 of a function this build lacks; no other stock map has scripts; each call's value type is the type of its function here (1,485 of 1,485 in `hugeass.map`), or one the call site casts to |
| Bitmaps the Xbox keeps in monochrome formats and Custom Edition as 32-bit color at the same size keep their channels (A8Y8 as A8R8G8B8 with red the luminance and alpha the alpha) | 24 of the 26 such bitmaps of `bloodgulch.map` decode to exactly the Xbox's values; the other 2 have other content |
### Assumptions (not verified)
- **Placement of resource-held tags.** They are placed after the tag data,
aligned to 4 bytes, as OpenSauce's unimplemented loader outlines
(`s_cache_file_data_load_state`); how Custom Edition itself places them is
not known, and nothing found depends on it.
- **Which chicago extended maps.** The four-stage maps are kept, as the
renderer draws four stages; that Custom Edition's two-stage maps are a
fallback for older hardware is an inference (BlamLib's exporter makes the
same choice).
- **Centroid nodes of local-node parts** are taken to be the model's nodes:
they are within the model's node count in every map, and they are only
used to sort transparent parts.
- **Editing-kit pointers are cleared.** The definition pointers of relocated
blocks and data are set to 0: they refer to nothing in the game process.
- **Name comparison** is exact (case-sensitive), which matched every sample
sound.
## Limits and remaining work
- **What the validator corrects is lost.** A community map's data that the
validator corrects (an index past its block, an enum past its values) is
what the game then runs; Halo PC read past such data unchecked.
- **Ogg Vorbis sounds** are decoded when the map loads (stb_vorbis, with
added bounds checks: `port/third_party/stb_vorbis`) and encoded again as
Xbox ADPCM in memory (`port/linux/game/custom_edition_sounds.c`); one that
cannot be decoded is silenced, and still logs when played.
- **Models of 44 nodes or more** are drawn a part's nodes at a time, given
local nodes when they have none; one whose part's vertices name more
nodes than a part holds is refused.
- **Linear bitmaps whose rows are not a multiple of 64 bytes**, and
textures larger than 4096 pixels a side, are not drawn.
- **Halo PC's HUD meters' minimum alpha** (where the Xbox's meters have an
overlays block) is dropped, and **its weapon functions "primary firing on"
and "secondary firing on"** are the Xbox's primary and secondary firing.
- **Button prompts on replaced icon sheets** show what the map's icon bitmap
has where the Xbox's buttons are.
- **Channel orders are per texture**: multipurpose maps and meters also drawn
another way keep Halo PC's channels.
- **Scripts that use what this build does not have**: a call of a function
this build lacks, or a read of an engine global it lacks, does nothing
(a constant of its type's harmless value, logged by name:
`custom_edition_scripts.c`); only one that gives a script's index refuses
the map. Maps that need OpenSauce's runtime features are refused; a map
that only carries its tags (`project_yellow`) runs without them, as on
stock Custom Edition.
- **Halo PC behaviours** some maps were made around (Chimera's map list,
`custom_edition_behaviours.inc`) are followed where this build can, and
each is logged.
- **The conversions are one-way and in memory**: nothing is written to the
map files.
### How far Custom Edition tags are from the Xbox tags
`tools/custom_edition_tag_footprints.py` compares the tags a Custom Edition
cache and the Xbox cache of the same level both keep (same group, same name),
without any tag definitions. A tag's *footprint* is the distance from its
address to the next tag's (its structure and the block data after it); its
*structure size* is estimated as the distance to the lowest address in the
footprint that the footprint points at (its first child block). Equal
footprints, level after level, suggest a group's layout is unchanged; they do
not prove it.
The Xbox caches available are the retail ones, build 01.10.12.2276, not the
01.01.14.2342 build this repository reconstructs: they stand in for it, and
differences between the two Xbox builds are not measured. Run on the 14
levels both sets have (13 multiplayer levels and `ui`):
A mod is a folder of Halo PC tag files that the game plays over a map's own
tags on your machine, as a resource pack does: sounds (a high-res audio
pack), bitmaps (a texture pack, a HUD redraw), text (a translation) and
models (higher-detail weapons, vehicles and scenery). Only the machine that
has a mod plays it. Nothing of a mod is sent to another machine, and a mod
changes nothing that the machines of a network game must agree on, so you
can play with mods in any game, with players who have other mods or none. (A host's mod sounds of other lengths change how long
the host's scripts and AI wait for dialogue to end; the host decides those
for every machine, so they still agree.)
The game's code is `port/linux/game/mods.c` (with `mod_sounds.c`,
`mod_bitmaps.c`, `mod_strings.c` and `mod_models.c`, one for each kind of
tag) and `port/linux/src/port_mods.c` (the mods folder and its files).
## A mod's folder
The mods are in the `mods` folder of the data root (the folder with
`maps/`; on Android the app's data folder, on the web the browser's storage
for the site). Each mod is a folder in it, with a `mod.toml` and a `tags`
folder:
```
mods/
hd_audio/
mod.toml
tags/
sound/sfx/weapons/assault rifle/fire.sound
sound/dialog/...
german/
mod.toml
tags/
ui/hud/hud_item_messages.unicode_string_list
```
The files in `tags` have the paths of the tags they replace: a tag's name,
then its group's extension, as the Halo Editing Kit and Invader keep them.
`tags/sound/sfx/weapons/assault rifle/fire.sound` replaces the sound tag
`sound\sfx\weapons\assault rifle\fire` of every map that has one. Names are
not case-sensitive. The game only looks up the names of the map's tags among
a mod's files: a mod cannot add tags, and no tag name of a map makes a path
outside the mod.
`mod.toml` says what the mod is. Every key is optional:
```toml
name="High-res audio"# what the Mods menu shows (else the folder's name)
id="hd_audio"# what other mods name it by (else the folder's name)
version="1.0"
authors=["Someone","Someone else"]# or one string
description="The game's sounds at 44 kHz"
maps=["a10","bloodgulch"]# the maps it is for, by their files' names (else every map)
load_after=["hd_textures"]# mods it plays over: they load before it
load_before=["fixes"]# mods that play over it: they load after it
incompatible=["other_audio"]# mods it cannot be played with
format=1# the mods' format it is made for
```
`load_after`, `load_before` and `incompatible` name other mods by their `id`
or their folder's name, not case-sensitive. A mod they name that is not in
the mods folder is passed over. `maps = []` is every map.
A folder without `mod.toml` is not a mod. A mod made for a later `format`
than the build's, or whose `maps` names no map, is not played. The game
logs anything else wrong in `mod.toml` (an unknown key, a value of the
wrong type) and uses the rest; a `mod.toml` that is not TOML at all is
logged, and the mod played without what it says.
The game's log is its output: the terminal on Linux, `halo.log` beside
`halo.exe` on Windows, logcat on Android, and the page's log on the web.
Limits: 256 mods, folder names of 63 bytes at most, 262144 files in all, 24
folders deep under `tags`, and 64 maps in a mod's `maps`.
## The load order
Every mod in the mods folder is on until you turn it off. The mods load in
an order, and where two mods have a file for the same tag, the one loaded
later plays.
- The default order is the folders' names in alphabetical order (not
case-sensitive), except that the mods a mod must load after (those it
asks to load after, and those that ask to load before it) are taken to
just before it. Mods that ask in a ring (a after b, b after a) load
alphabetically, and the game's log says so.
- The main menu's **Mods** (in the PC version's menus, `display.menus =
"pc"`, the default) is laid out as the game lobby is: the mods on the
left as a resource pack list has them, the one at the top playing over
all those below it (the load order upside down: the mod loaded last is at
the top), and the chosen mod's details in the frame on the right (its
name, what is wrong with it, whether it is on, its priority, 1 the top,
and its version, authors, folder, maps and description). Choosing a mod
(A, or a click on the chosen one: the first click chooses it) turns it on
or off (ON, OFF); left and right, or **Move up** and **Move down**, move
the chosen mod (up: over one more mod). **OK** keeps the order and plays
it at once (on a map that is loaded, too); **Cancel** leaves it as it was.
- A warning sign beside a mod means it is on the wrong side of a mod it
asks to load before or after (both on): a mod that loads after another is
above it in the menu. A red cross means a mod that is on says it cannot be
played with this one, or that it cannot be played at all (made for a later
build, or its `maps` names none): it stays off. The details say which.
The menu keeps the order, and the mods turned off, in `mods/load_order.toml`,
by the mods' folders' names:
```toml
order = ["german", "hd_audio", "hd_textures"]
disabled = ["hd_textures"]
```
A mod added to the folder later is put where the default order puts it,
after the mod before it there (but on the side it asks for of the mods it
names), and is on. A mod gone from the folder is left out. When the file
cannot be written, the game says so in its log and keeps the order until it
quits.
### Mods that cannot be played together
When a mod that is on names another that is on as `incompatible`, the game
asks as it starts, before the window opens:
- **Play anyway** plays them all (closing the box does too);
- **Turn off ...** turns off the mods named as incompatible (if mod A says
it cannot be played with mod B, B is turned off), and keeps that in
`load_order.toml`;
- **Quit** closes the game.
The Mods menu turns them on again. On the web, **Quit** starts the page
again. A run nobody watches (`debug.hidden_window`, `debug.null_renderer`,
`debug.exit_after` or `debug.network_test`) is not asked: it plays them all,
and the game's log says which conflict.
At the console:
| Command | What it does |
| --- | --- |
| `mods_list` | Lists the mods in the mods folder in their load order, and which are on, then how many of the map's string lists, bitmaps, sounds and models the mods play. |
| `mods_reload` | Finds the mods again and reads their files again: a changed file is read again (one that now fails a check leaves what was played before), a new one is played, and the map's tag comes back for a file that is gone. If a sound changed, all sounds stop. It does nothing while `mods_enable false`. |
| `mods_enable false` | Plays the map's own tags until `mods_enable true` or the next map. |
These commands work in any game, a client's too: they change nothing of the
game.
### Getting a mod onto each platform
- **Linux, Windows**: put the mod's folder in `mods/` next to `maps/`.
- **Android**: put it in `mods/` in the app's data folder,
`/sdcard/Android/data/com.halo.decomp/files/mods`, for example with
The game shows its graphics with OpenGL ES 3. It plays sound through SDL3
(AAudio). It accepts input from game controllers, for example a PlayStation
5 DualSense on Bluetooth. The app needs Android 9 (API 28) or later. It
operates on 64-bit-only devices, for example the Pixel 9 Pro XL.
5 DualSense on Bluetooth, and from the touchscreen (refer to "Controls").
The app needs Android 9 (API 28) or later. It operates on 64-bit-only
devices, for example the Pixel 9 Pro XL.
The Android build uses the platform layer of the Linux build
(`port/linux/src`). Refer to [port/linux/README.md](../linux/README.md).
Physical mice use the same control bindings. During play, F12 toggles mouse
capture; captured mode hides Android's pointer and uses relative movement for
continuous aiming, while releasing capture restores normal pointer behavior.
## Requirements
@@ -28,6 +34,36 @@ ninja) and these items:
1.2.5 and SDL 3.4.16 to `build/android/third_party`. Gradle downloads the
Android Gradle Plugin.
On Windows, build in WSL2 (Ubuntu 24.04). The Android build does not run
on Windows itself. Enter
`wsl -d Ubuntu -- bash tools/wsl_setup_android.sh` one time: it installs
the tools and the Android SDK and NDK in WSL (apt asks for your
password). Then enter
`wsl -d Ubuntu -- bash tools/wsl_build_android.sh` for each build. The
script copies the repository into WSL (`~/halo-build/src`) with LF line
endings (a Windows checkout has CRLF line endings, which the build's text
tools do not accept), builds there, and puts the APK in
`dist/android/app-debug.apk`. Only the changed files are copied, so the
next build is incremental. Install the APK with the adb of Windows.
A build that you make yourself has a different signature from the builds
of GitHub Actions. Android installs it only after you remove the app, and
removing the app deletes its data folder (`maps/`, `save/` and
`config.toml`). Thus a change between the builds of GitHub Actions and your
own builds loses the saved games, unless you copy them first (below).
Your own builds all have the same signature (the debug key of WSL,
`~/.android`), so they install over each other and keep the data
(`adb install -r`).
To keep the saved games before you remove the app, enter
`adb pull /sdcard/Android/data/io.github.cesarus85.ringfall/files/save/u` (the profiles).
After the new install, before the first start of the app, enter
`adb push u /sdcard/Android/data/io.github.cesarus85.ringfall/files/save/u`. The app reads
and overwrites what adb pushes. adb cannot add files to `save/u` or `save/z` once the app made them, or
change files that the app wrote, so push before the first start. adb cannot read
some files that the app writes (the `blam.lst` files of `save/z`), so `adb pull`
of the whole `save` folder stops there.
## Build and install the app
1. Go to the root folder of the repository.
@@ -41,8 +77,8 @@ ninja) and these items:
## Game data
The game needs the `maps/` folder from an Xbox disc image (`.xiso` or
`.iso`) of any version of the game. The app extracts `maps/` from the disc
image. The app keeps the data in `/sdcard/Android/data/com.halo.decomp/files`.
`.iso`) made from your own copy of the game, any version. The app extracts `maps/` from the disc
image. The app keeps the data in `/sdcard/Android/data/io.github.cesarus85.ringfall/files`.
To install the data with the app:
@@ -57,16 +93,19 @@ To install the data with the app:
To install the data from a computer:
1. Start the app one time. The app makes its folders.
2. Enter `adb push <folder>/. /sdcard/Android/data/com.halo.decomp/files/`.
2. Enter `adb push <folder>/. /sdcard/Android/data/io.github.cesarus85.ringfall/files/`.
| Item | Location in `/sdcard/Android/data/com.halo.decomp/files` |
| Item | Location in `/sdcard/Android/data/io.github.cesarus85.ringfall/files` |
| --- | --- |
| Saved games (`z:\` and `u:\`) | `save` |
| Mods (`docs/mods.md`) | `mods`: `adb push <mod folder> /sdcard/Android/data/com.halo.decomp/files/mods/`; the main menu's Mods orders them and turns them off |
`adb pull /sdcard/Android/data/io.github.cesarus85.ringfall/files/save/u` (the profiles).
An `adb pull` of the whole `save` folder stops at the `blam.lst` files of
`save/z`, which adb cannot read.
## Controls
@@ -92,17 +131,91 @@ with the positions on the Xbox controller:
| Create | back | |
The controller gets the rumble. The back gesture of Android is the B
button. A Bluetooth or USB keyboard operates as on Linux. The screen does
not accept touch input.
button. A Bluetooth or USB keyboard operates as on Linux.
The touchscreen operates the menus: tap an item to select it (on a
setting with values, tap its left or right half), tap a button of the key
at the bottom of a screen (for example "B = Back") to push it, and drag to
scroll a list (down or right steps back, up or left steps forward). A drag
stops at the first and the last item, and it does not change a setting's
value. Touches that start in the edge-gesture zones of Android do not tap
or scroll at the sides, and do not scroll at the top and bottom, because the
first swipe from an edge in full screen only shows the system bars.
A tap during a cinematic that can be skipped skips it, as A does. On the
on-screen keyboard, tap a key to press it, "B =BACK" to cancel and
"A =ENTER" to accept the name.
### Touch controls
In a game, the app shows touch controls over the picture. They are a
controller for player 1. With the default buttons of the profile:
| Control | Function in the game |
| --- | --- |
| stick (lower left) | move |
| swipe on the screen away from the buttons | look |
| Fire | right trigger |
| Grenade | left trigger |
| A / Jump, B / Melee, X / Reload, Y / Weapon | A, B, X, Y |
| Crouch, Zoom | left and right stick clicks |
| Light, Gren. type | white, black |
| Pause, Back | start, back |
| Up, Down, Left, Right | D-pad (in a network game, Up opens the text chat's keyboard) |
The names on the buttons follow the profile's "Button layout" (Settings >
Gamepads): with "Swap triggers", the right trigger's button says "Grenade".
The stick moves the player with every "Stick layout", southpaw too.
A finger that holds a button can also swipe to look, so you can fire and
aim with one thumb. The controls are a finger wide (48 dp) or larger, but
on a small screen of high density they stay apart rather than reach that
size. A short tap reaches the game even when it is shorter than one frame.
Swipes that start in the edge-gesture zones of Android do not turn the
view. The swipe aims as the controller's stick does, not as a mouse: the
aim slows over a target and follows a moving one, as with a controller,
and it follows the profile's "invert look" (the gyroscope turns the view
as the phone turns, never inverted). The setting `input.touch_aim_assist`
turns the aim assist off. "Look sensitivity" sets how far a swipe turns;
the mouse settings do not apply.
The touch controls show only in a game. In the menus and during
cinematics they hide, and the touchscreen operates the menus as described
above. They also hide when a controller is connected, for example the
built-in controller of a handheld. A device without a touchscreen (a TV)
never shows them. The setting `input.touch_controls` changes this (refer
to "Settings").
The buttons at the top of the screen:
- "Hide" removes the controls (and stops the gyroscope aiming and the
vibration) until you push "Touch"; the app remembers it.
- "Options" opens these items:
- "General": the phone's vibration (on by default; it follows the
vibration setting of the game's profile), aiming with the gyroscope
(off by default), a floating move stick (off by default: the stick goes
where your thumb lands in the lower left of the screen), the opacity of
the controls, "Hide or add buttons" (hide a button, add a copy of a
button) and "Edit buttons size".
- "Edit buttons layout": drag the controls to new positions, then push
"Save". "Export" and "Import" write and read a layout file with the
file picker of the system.
- "Look sensitivity".
"Hide" and "Options" act when the finger lifts on them: a swipe that
starts on them turns the view instead.
The app keeps the layout in its own preferences, not in `config.toml`.
Removing the app's data or the app removes the layout.
## Settings
The settings are in `config.toml` in the data folder of the app. To change
them:
1. Enter `adb pull /sdcard/Android/data/com.halo.decomp/files/config.toml`.
1. Enter `adb pull /sdcard/Android/data/io.github.cesarus85.ringfall/files/config.toml`.
2. Change the file.
3. Enter `adb push config.toml /sdcard/Android/data/com.halo.decomp/files/`.
3. Enter `adb push config.toml /sdcard/Android/data/io.github.cesarus85.ringfall/files/`.
At the first start, the game writes the file with the default values. To
get the default values again, delete the file.
@@ -113,8 +226,13 @@ These settings are only for Android:
| Setting | Function |
| --- | --- |
| `input.touch_aim_assist` | `true` (the default): the touch controls' swipe aiming gets the aim assist of a controller (the aim slows over a target and follows a moving one). `false`: none, as with a mouse; the bullets' own autoaim stays. |
| `input.touch_controls` | The touch controls in a game. `"auto"` (the default): shown on a touchscreen while no controller is connected. `"on"`: also shown with a controller. `"off"`: never shown. A device without a touchscreen never shows them. The menus take taps in any case. |
| `display.screen_width` | The number of columns of the 480-line picture. `0` (the default): the shape of the display (1068 on a 20:9 phone). `640`: the 4:3 shape of the Xbox. |
| `display.shader_cache` | `true` (the default): the linked shader programs are kept in `shader_cache` in the data folder (their binaries, the driver that made them and their sources), and each map's list of the programs it drew with; as a map loads, the programs on its list are made ready, so that its first frames do not wait for the driver to compile them. A driver update compiles them again from the kept sources. `false`: each program is compiled the first time it is drawn. The folder may be deleted at any time. |
| `display.shader_worker` | `true` (the default): the shader programs are made on a thread of their own, whose GL context shares the game's (loaded from the shader cache, or compiled), and drawn with once there (`debug.shader_warm_up`: `"worker"`, `"load"` on the game's thread as a map loads, or `"off"`); a draw whose program is not made yet is left out for the frames it takes, and the frame does not wait. As a map loads, its own programs and then every map's (`shader_cache/all.txt`) are asked for. `false`: each is made on the game's thread when first drawn with. |
| `debug.sample_seconds` | Refer to "Find problems". |
| `debug.memory_watch` | `true` (the default): the app notices the game's writes to textures and vertices by page protection. `false`: it compares page contents once a frame instead, which is slower. Refer to "Limits". |
## Internet play
@@ -148,16 +266,24 @@ select "Yes":
1. The app downloads the new version.
2. The package installer of Android opens. At the first update, Android asks
you to let Halo install apps. Allow it.
you to let the game install apps. Allow it.
3. Select "Update". Android replaces the app.
4. Select "Open" to start the new version.
To install over the previous version, each build must have the same
signature. GitHub Actions signs each build with the key in the
`ANDROID_KEYSTORE_BASE64` and `ANDROID_KEYSTORE_PASSWORD` secrets of the
repository. If you installed a build that has a different signature, remove
repository. A computer that builds for its own devices signs with the same
key when `port/android/signing.properties` (not in the repository) names it:
storeFile=/path/to/ringfall-release.jks
password=<the keystore's password>
keyAlias=ringfall
Keep the keystore and its password safe: a lost key means every player must
remove the app before the next version. If you installed a build that has a different signature, remove
that build before you install a new build. Removing the app deletes its data
folder: first make a copy of `maps/` and `save/`.
folder: first make a copy of `maps/` and `save/u`.
## Widescreen
@@ -168,7 +294,10 @@ the Xbox:
- The HUD stays at the edges of the screen.
- The menus, the loading bar and the screens after a game have 640
columns, at the center of the screen.
- Black bars and fades cover all of the screen.
- Black bars and fades cover all of the screen, and so do the menus' dims
and backgrounds (the pause menu's dim, dialogs, the menus' gradient).
All the ports draw this way.
## VR
@@ -181,7 +310,7 @@ instead.
Controls in play (in the menus the buttons are the Xbox's by position):
| Controller | Halo |
| Controller | Game |
| --- | --- |
| right trigger / left trigger | fire / throw grenade |
| right grip / left grip | melee / switch weapon |
@@ -229,7 +358,7 @@ through a shader that decodes to linear, and the stored bytes are the
CC0): their ideas, rebuilt into this port's own structure, with the source
named where code is taken over.
## Done (in `main`)
| Area | What |
| --- | --- |
| Headsets | Quest 3; Steam Frame (Lepton): its controllers' layout, a Steam library tile that installs, starts, updates and quits the game, data kept in `~/Documents/RingfallMjolnirVR` |
| Rendering | The eyes drawn one by one (both at once, multiview, only with `debug.multiview`: its eyes did not always fuse, see the open points); lens flares at their depth in stereo; HUD, menus, scope, crosshair and names as layers of their own |
| Aiming | The weapon in the hand, its shots from the muzzle, laser and crosshair options; each weapon held so that its model's hand lands on the real one, as the free hand's (it learns where each weapon's animations hold the hand; the pistol's had been 15 cm before the real hand, the assault rifle's 8 cm aside), the scope's disc moved with it; `vr.weapon_hand_*` to adjust (2026-10-17) |
| Body | Physical crouch (`vr.crouch_limit`) beside the crouch button, leaning (`vr.lean_limit`), melee by swinging, grenades thrown with the left hand, two-handed weapons |
| Vehicles | The Warthog's wheel turned with the hands, right trigger throttle, left brake; first-person seats; turrets with handles |
| Scores | The menu button held shows the scores on the Quest (a press pauses, as it is let go); the Steam Frame's View button as before; the text chat's lines where the desktop has them (2026-10-18) |
| Comfort | Snap or smooth turning, a comfort vignette in vehicles, VR options in the main and pause menus (Y), a page of every controller button |
| Menus | The PC version's menus in VR (`vr.menus`), clicked with a laser; text typed on the on-screen keyboard |
| Multiplayer | Up to date with OpenCE (network version 32, 2026-10-20; HDR kept off in VR): cross-play, its server browser, co-op campaign, password lobbies; join codes and the VR online list |
| Bots | Bots in multiplayer (`host_bots.c`; OpenCE's own, `bots.c`, are local split screen games' and off in VR): on foot (paths, items, objectives), in vehicles and at turrets; they make room for a joining person, on the LAN too (2026-10-18); they climb ladders (Damnation's) and take paths as near their goal as a search reaches, as OpenCE's do: on Damnation 0 searches of 1747 failed, 1078 of 1605 before (2026-10-20). Later: OpenCE's bots' aiming (lead, bursts, taps for the pistol and sniper rifle, field of view), learnt obstacles and map fixes into ours |
| Voice chat | Push to talk on any button (`vr.button_talk`, BUTTONS page; the free hand's grip by default, sending nothing while it reaches for the foregrip or the wheel), open mic, off (2026-10-13) |
| Builds | The Android build's assembly conversion (`tools/android_asm_convert.py`) keeps string data as it is: strings and tables starting with "_" or holding \" lost a "_" (Opus's pulse cache among them: the Steam Frame's crashes in online games while talking), 2026-10-13 |
| Languages | The European disc in Spanish and Italian (a maps folder for each, `maps_es` and `maps_it`, none called `maps`) imported on the Quest and the Steam Frame: the headset's language's, its language the game's and the menus' (`maps/language.txt`, unless `game.language` is set), 2026-10-17; not yet tried with that disc |
| Controls | Left-handed mode (`vr.handedness`): the weapon in the left hand, the arms and grips mirrored, sticks and buttons mirrored on both controllers (`vr.mirror_controls`), the assault rifle's counter read the right way round (2026-10-06) |
| Graphics | Presets low / medium / high / max (`vr.graphics`), refresh rate and resolution in the VR options, all kept over a restart (2026-10-06) |
| Arms and hands | Both arms by IK from shoulders that follow the torso, the elbow led by the hand's roll; fingers from trigger, grip and touch; `vr.body` arms / hands / off, `vr.fingers` (2026-10-06) |
## Next, in order
| # | Step | State |
| --- | --- | --- |
| 1 | Steam Frame CPU time: Mesa's trace markers off (after astromaddie/HaloCE-VR), no `glGet` stalls in the visibility counters' copies | **done** (in `main` 2026-10-06): markers quieted, no problems; the frame's work about unchanged (11-12.5 ms of 13.9 in a bot game) |
| 3a | Visible arms and hands: the arms by IK to the controllers, fingers by trigger and grip | **done** (in `main` 2026-10-06; Quest 3 and Steam Frame good; 0.02 ms a frame) |
| 2 | Controls and comfort: left-handed mode, buttons remapped, flashlight by tapping the head, finer turning and vignette choices, graphics presets and refresh rate in the VR options | first part **done** (in `main` 2026-10-06; Quest 3 and Steam Frame good): left-handed mode, graphics presets, refresh rate and resolution in the VR options. Second part **done** (in `main` 2026-10-06; Quest 3 good): buttons remapped, finer turning and vignette choices; the flashlight by tapping the head taken out (did not work) |
| 3b | The whole body: torso from head and hands, legs and feet following in the room; full / arms only / hands only | **done** (in `main` 2026-10-10; Quest 3 good): `vr.body` "full" ("Full body" in the VR options; "arms" still the default), legs walking the way the player moves, feet on the ground. Also in: voice chat's push to talk on the free hand's grip. Steam Frame: its cost still to measure |
| 3c | Weapons held physically, holsters at shoulder and hip (the buttons stay) | |
| 3d | Other VR players shown with their heads, hands and bodies (today their bodies move as flat players' do: only their aim and throws come from their hands) | |
| 4 | Later: disc image import in the launcher; moving by swinging the arms (in place of the stick, never faster than it: online too), and a sprint by swinging them harder (faster than the game allows: offline only) | import **done** (in `main` 2026-10-06): the app's import (tried on the Quest 3: all 24 maps; on the Frame kept in `~/Documents/RingfallMjolnirVR`), a Steam Frame installer from Desktop Mode (`STEAM_FRAME.md`; tried on the Frame: the old folder renamed, the shortcut added by Steam), a release signing key (CI and local builds the same). Arm swinging and its offline sprint still open |
| 5 | Later: multiview again (both eyes in one pass, half the drawing's CPU cost): find why its eyes do not fuse in big online games on the Quest 3 (and now and then on the Steam Frame), then turn it back on | open: off by default since 2026-10-16 (`debug.multiview`); findings in the open points |
| R | Release as **Ringfall: Mjolnir VR** (no "Halo" in the product's name): app name, id `io.github.cesarus85.ringfall`, icon and library pictures (`tools/ringfall_art.py`), Frame folder and installer, release assets, README; the repository renamed | **done** (in `main` 2026-10-06; Quest 3 and Steam Frame good) |
## Step 2, first part (done)
Left-handed mode and graphics presets, after HaloCE-Quest-VR (CC0): its
mirrored first-person model, its graphics switches.
Left-handed (`vr.handedness = "left"`):
- The weapon in the left hand: aim, shots, muzzle, laser, crosshair,
scope disc, turret aim. The first-person model is posed as the right
hand's in a mirror image of the room ("model space", `halo_vr.h`) and
mirrored back; a model whose node matrices mirror has its triangles'
winding turned over (`rasterizer_set_model_skinning`, `d3d8_gl.c`). The
arms reach to the other shoulders, the foregrip is gripped with the
right hand, the right hand's fingers follow its controller.
- Grenades held and thrown with the right trigger and hand; melee by the
left grip (a swing of either hand, as before); the motion tracker on the
right wrist (`halo_xr_submit.wrist_hand`); the rumble's light motor in
the weapon hand.
- The left trigger fires, the right one throws; at the Warthog's wheel the
left trigger accelerates, the right one brakes.
- With `vr.mirror_controls` (default): the right stick moves, the left
turns and steers; their clicks, the bumpers and the face buttons change
sides (Quest: X jumps, Y light / held grenades, A action and reload, B
switch weapon; Steam Frame: the left D-pad does A, B, X, Y as their
mirror image, down jump, left light, right action, up switch weapon,
and A, B, X, Y do the D-pad's). The menus keep their buttons; the
laser pointer works from either hand as before.
- Other machines see the shots leave the left hand (the network message
is unchanged: aim and muzzle from the camera).
- The assault rifle's ammo counter and compass read the right way round
on the mirrored model (2026-10-06): their geometry stays mirrored, each
digit's bitmap is drawn turned over in u and the digits' order across
the display swapped (numeric shaders drawn mirrored,
`rasterizer_xbox_transparent_geometry.c`). Checked flat in the emulator
with `debug.mirror_first_person` (the first-person weapon mirrored off
the headset): 60, 48, 36 ... 06 read correctly. Steam Frame good
(2026-10-06).
- Kept as they are (accepted 2026-10-06): the small static icons under the
counter (round and "x") stay mirror images (a texture atlas, not
numeric). The game's prompts still name the Xbox's buttons.
- Settings kept: on Android `config.toml` is written beside itself and
renamed over (`port_config.c`), so one pushed by adb (the shell's, which
the app could not open for writing) no longer loses the VR options'
changes at a restart.
- Headset tests 2026-10-06: Quest 3 and Steam Frame good (left hand,
controls mirrored and not, counter, presets, refresh rates, resolution,
settings kept over a restart).
Graphics (`vr.graphics`, `port/linux/game/vr_graphics.c`), set each frame
| `vr.mirror_controls` | `true` (default): sticks and buttons mirrored when left-handed; `false`: as right-handed (triggers and grips still follow the hands) |
| `debug.vr_body` | `"off"` (default); off the headset (the emulator) the body posed from the flat camera: `"eye"`, `"down"` (looking down 60 degrees, 90 degrees wide), `"front"`, `"side"`, `"back"` (the camera 2.6 m away); after a colon the head's motion: `"cycle"` (turning, crouching, leaning, 5 s each), `"still"`, `"turn"`, `"crouch"`, `"lean"`, `"raw"` (the game's pose, only shrunk). Logs its pose once a second |
Checked in the emulator (`debug.vr_body`): the cyborg's 19 nodes (bip01
the body standing, crouched physically and by the button, leaning,
turning with the feet stepping; looking down, the shoulders below the eyes
and no head or collar; two-minute bot games on Blood Gulch with it on and
off, deaths and respawns, nothing in the log. Then the legs by the way of
moving (strafing, backwards, diagonally with the touch stick: the game's
sideways and backwards walk) and the feet on the ground: on the ramp of a
Blood Gulch base, the upper foot 15 cm up, the lower 10 cm down, both on
it; stairs not reached in the emulator (Prisoner's spawn was level). Three
rays when the feet are put down, one a step, none walking. Screenshots in
`build/body-shots/` (local).
Push to talk is built on the same branch: voice chat's talking on the free
hand's grip, while it holds neither the weapon's foregrip nor the Warthog's
wheel (`audio.voice_chat`, VR options PLAY page "Voice chat": push to talk
(grip), open mic, off).
To try in the headset (Quest 3 and Steam Frame), with `vr.body = "full"`
(VR options, Body: Full body) and `vr.log_performance = true`:
1. Standing, looking down: torso, hips, legs and feet, the shoulders below
the eyes, no head, collar or helmet in view, the chest not in the way;
the arms from the shoulders, lined up with the body, the weapon as
before.
2. Turning the head alone: the body stays within about 15 degrees, then
follows; turning further, the feet step after it, one at a time, and
settle. Snap and smooth turns: the body turns, the feet step.
3. Physical crouch: the knees bend, the feet stay; kneeling deep, nothing
of the body in the eyes. Leaning forward and aside: the torso tilts, the
feet stay. Crouch button: the game's crouch, shoulders below the eyes.
4. Walking, running, jumping by the stick: the legs walk with the game's
animation, no sliding when starting or stopping. Strafing left and
right, walking backwards and diagonally, also with the head turned away
from the way of walking: the legs step the way the body moves (sideways,
backwards), the torso facing as the head and hands have it; stopping,
the hips turn back under the torso and the feet step into place.
4a. Ground: standing still on a slope (Blood Gulch's hills and the bases'
ramps), across it and along it: both feet on the ground, tilted with
it, the lower knee straighter, the hips down with the lower foot; on
stairs (Prisoner, Chill Out, Damnation), one foot a step higher than
the other, turning in place there: each step lands on its stair. At a
ledge, one foot over it: level, nothing odd.
5. Left-handed (`vr.handedness = left`, mirrored or not): the arms from
the body's shoulders, the weapon in the left hand, the body as
right-handed.
6. Vehicles: Warthog driver and gunner as before (body with its hands on
the wheel or gun); Warthog passenger: seated legs and torso, the
first-person arms from its shoulders; Ghost, Banshee, Scorpion: no body.
7. Dying and respawning, a teleporter, a cutscene (campaign), the menus
and pause menu, the scope, camouflage, a game with 15 bots: nothing
broken, no body where it should not be.
8. Body "Arms and hands", "Hands only", "Weapon only": as before.
9. Performance: `first person: ... body ... ms` in `adb logcat -s halo`;
the Quest holds 90 Hz in a bot game; the Steam Frame's working time
against "arms" (about 12 ms of 13.9 before).
10. Push to talk (Voice chat: push to talk), in a network game with
another player: holding the free grip away from the weapon talks;
nothing while the free hand holds the foregrip or the Warthog's wheel;
"Open mic" talks without the grip; "Off" sends nothing (others still
heard).
## Frame hitches and GPU time (built: branch `fix/vr-frame-hitches`)
Measured on the Quest 3 (2026-10-12; 90 Hz, multiview, graphics "high",
full body, a 15-bot game on Blood Gulch): the game's CPU work 5-6 ms a
frame; the app's GPU time 5.5-6.4 ms (`xr: metrics`), the same with
foveation off, low, medium and high: **the GPU is not the bottleneck**.
The doubled pictures come from single long frames: shader programs compiled
in a frame (29 new ones in a 794 ms frame 22 s into a match, single ones
18-64 ms), draws of 254 ms (the first frame in a map) and 74 ms with
nothing compiled (most likely the driver making a program's variants at
its first draw), and the menus' PNG pictures (7 in 108 ms).
What the branch has:
| Part | What |
| --- | --- |
| Long frames | `VR: long frame N ms: input, ticks, camera, draw, present; shaders compiled or linked, programs from the cache; textures uploaded, menu pictures, render targets; draw calls N ms, the slowest N ms (vertex program, program key); N draws left out waiting for their programs`. The 5 s report counts the draws left out |
| GPU time | XR_META_performance_metrics on with `vr.log_performance`: `xr: metrics (mean/most) app/gpu_frametime=... compositor/dropped_frame_count=...` |
| Foveation | `vr.foveation` "off" (default) / "low" / "medium" on the MORE page (GL_QCOM_texture_foveated, the eyes only, lens centres); "high" taken out (it drew a grey rectangle below the player). Kept for later, no gain measured |
| Shader cache | `display.shader_cache`: programs kept in `shader_cache/` (binary, driver, sources); each map's list (`maps/<map>.txt`) and every map's (`all.txt`) |
| Worker | `display.shader_worker` (default true): programs made on a thread with a GL context sharing the game's (`gl worker: running`), from the cache's binary or compiled; a draw whose program is not made yet is left out for those frames, the frame never waits. As a map loads, its programs and then every map's are asked for |
| Warm-up | `debug.shader_warm_up`: "worker" (default: each program drawn with once on the worker into a small target of the eyes' kinds), "load" (the map's programs drawn with on the game's thread as it loads), "off" |
| Menus | The PC menus' pictures decoded on a thread of their own, drawn transparent for the frames that takes |
Second Quest test (latest build): the worker works, no long frame after the
map's first frames (245 ms and 72 ms there, each one draw of 67-75 ms with
vertex program 4). But doubled pictures in spells of seconds with the GPU
at 9-13 ms a frame (GPU% 0.91-0.94, 4-37 stale frames a second), 5-7 ms in
between: "high" is too much for 90 Hz in heavy scenes, "medium" holds.
Hence:
| Part | What |
| --- | --- |
| Graphics "auto" | `vr.graphics = "auto"`, the new default (Graphics row: Auto): "high" while the GPU keeps up. Past 4/5 of the frame (8.9 ms at 90 Hz) for a quarter second it leaves out the next steps (shield glow, shadows, reflections, specular light, lens flares, bump maps, the rest of "low"), as many as their learnt costs say; brings one back once GPU time + its cost stays under 7/10 of the frame for 3 s (longer if it had to go again within 10 s). Logs `VR graphics auto: shadows left out (step 2): the GPU 10.2 ms a frame of 11.1`, `... saved 1.9 ms`, `... back`. On the Steam Frame (no GPU time from SteamVR) "auto" is "high" |
| Measuring | `debug.vr_graphics_measure = true`: each effect left out in turn (1.5 s on, 1.5 s off), `VR graphics measure: shadows 2.10 ms (...)` and means each round |
What the effects cost, from the code (to be measured with the option above):
object shadows are drawn once a frame with multiview (the scene is drawn
once), twice one eye at a time; each shadow-casting object (every bot, its
weapon, vehicles) renders its map, blurs it and projects it onto the level
beneath: a pass of fill on both eyes per object. Reflections and specular
light are passes over the level's geometry. The shield's glow
(`display.shield_glow`, off by default) is a dynamic light per flaring unit
(at most 8), each lighting the level's surfaces in its reach once more, not
a pass per object; upstream's shield colour on the arms only changes a
colour. The whole body adds the player's own model and its shadow.
Not done, and why:
- Drawing the eyes straight into the swapchain: moot now (GPU not the
bottleneck); it needs GL_EXT_sRGB_write_control, the vignette as a pass
of its own and array swapchains acquired mid-frame.
- Decoding all menu pictures ahead: 165 pictures, about 350 MB decoded with
their mipmaps.
- Later: XR_KHR_visibility_mask.
### Headset test, graphics "auto" (Quest 3)
1. VR options, MORE: Graphics **Auto** (a `config.toml` written before
still says "high"), Foveation Off; `vr.log_performance = true`.
2. Log at start: `xr: performance metrics on (... the app's GPU time read)`,
`VR: graphics auto`.
3. 15-bot match on Blood Gulch, a few minutes, also into the busy middle:
- `VR graphics auto: ... left out (step N): the GPU X ms` within a
quarter to half a second of a heavy spell, `... saved Y ms`, and
`... back` a few seconds after it.
- VrApi: `Stale=` 0 nearly every second (at most a few in the second a
spell begins), no `ASW=1` spells; App GPU under about 9 ms.
- Look for effects that pop: shadows going and coming back. If they
change more than every ten seconds or so, note the log lines.
4. Costs: a second match with `debug.vr_graphics_measure = true` (Graphics
setting ignored meanwhile; stand somewhere busy, a few minutes):
`VR graphics measure: means (ms of GPU a frame): shadows ...;
reflections ...`. The steps' order should follow the largest costs.
5. The first frames of a map (245/72 ms, vertex program 4): try
`debug.shader_warm_up = "load"` once and compare those lines.
### Headset test, shader worker (Quest 3)
Before: `adb logcat -G 16M` (again after any reboot); in `config.toml`
`vr.log_performance = true`, 90 Hz, graphics "high", multiview, full body;
Foveation Off (VR options, MORE: the config may still say "medium"). After
each test: `adb logcat -d -s halo VrApi > test.txt`. The shader cache from
[ -x "$LEPTON"]|| fail "Lepton, which runs Android games on the Steam Frame, was not found.\n\nStart any Android game from the Steam store once, then run this installer again."
for file in "$APK_NAME""$LAUNCHER" steam_frame_xiso.py grid/library.png;do
[ -f "$BUNDLE/$file"]|| fail "The installer is incomplete ($file is missing). Download it again."
done
# the builds before the name: their folder becomes the game's (settings,
# saves and game data kept), without their APK and launcher
if[ -d "$OLD_DIR"]&&[ ! -e "$GAME_DIR"];then
log "renaming $OLD_DIR to $GAME_DIR"
mv "$OLD_DIR""$GAME_DIR"|| fail "Could not rename $OLD_DIR to $GAME_DIR."
ask "$NAME is installed already. Update it to this version?\n\n(Your settings, saves and game data stay.)" yes ||exit0
else
ask "This installs $NAME on your Steam Frame.\n\nYou need your own disc image of Halo: Combat Evolved for the original Xbox (an .iso or .xiso file, any version), for example in Downloads.\n\nContinue?" yes ||exit0
fi
mkdir -p "$GAME_DIR/maps"|| fail "Could not create $GAME_DIR."
# (the game runs in Lepton's container as a user of its own)
if ask "Use this disc image?\n\n$candidate" yes;then
image="$candidate"
break
fi
done
fi
if[ -z "$image"];then
if[${#images[@]}=0];then
info "No disc image was found in Downloads, Documents, on the Desktop or a memory card.\n\nChoose it in the next window."
fi
image="$(choose_image)"|| fail "No disc image was chosen. Run the installer again once it is on the Frame (for example in Downloads)."
fi
log "extracting $image"
extract_maps "$image"
chmod -R a+rwX "$GAME_DIR/maps"
image_note="\n\nThe disc image itself is no longer needed:\n$image"
fi
# --- Steam
if have_shortcut;then
log "the Steam shortcut is there"
elif unattended;then
echo"info: the Steam shortcut is missing (not added unattended)"
elif ! steam_running;then
info "Steam is not running, so $NAME could not be added to your library.\n\nStart Steam and run the installer again."
else
add_shortcut || fail "Steam did not add $NAME to your library. Run the installer again; if it happens again, add\n$GAME_DIR/$NAME_FILE.desktop\nwith Steam's \"Add a Non-Steam Game\"."
fi
if unattended;then
echo"info: installed in $GAME_DIR"
exit0
fi
if have_shortcut_to "$OLD_DIR/halo-vr.sh";then
info "The library still shows the old \"$OLD_NAME\" from before the game's new name. It no longer starts: remove it in Steam (its gear or Manage > Remove non-Steam game)."
fi
if ask "$NAME is installed.${image_note:-}\n\nSwitch to Gaming Mode now? Start $NAME from your library there (its pictures may show once Steam starts again)." yes;then
Loaded 100 of 1747 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.