- 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>
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>
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>
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.
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>
A path the game finds but cannot steer round the objects on it (the other
players among them: obstacle avoidance failed) is walked as smoothed,
instead of none (which had bots walk straight into walls); a step aside
settles a bump. A bot goes on to the next point of its path when it is
in plain view on its own level, walks the last 3 world units straight
only when nothing is in the way, and counts itself stuck when it makes no
way to its point for 6 ticks (only while it walks one): then it steps
aside, finds its way anew, skips a corner's point it cannot reach, jumps
at 12 and goes elsewhere later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Within 10 world units a bot goes for a weapon or powerup worth at least 4
while it fights. Strong weapons are worth a longer way (worth squared over
distance, falling off over 15 world units), and with two weapons one is
worth what it betters the worse by and 1.5 more, so rocket launchers and
sniper rifles are taken, not only the shotgun close by. The pistol counts
for a little less up close, the assault and plasma rifles more.
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>
The flags and balls (weapons that are flags) are gathered each tick with
where they are and who carries them. In CTF a bot carrying the enemy's
flag runs it to its own stand; the rest hunt whoever carries their flag,
touch it home where it lies dropped, or go for the enemy's (every third
guards its own). In king of the hill they go to the hill (the game's goal)
and keep moving in it. In oddball they go for the ball, taken as a weapon
is, hunt its carrier, and carrying it keep away. Carrying, a bot does not
fire, and looks where it runs. The last 3 world units to a goal are
walked straight, over what the path goes round (a flag on its stand).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Out of a fight a bot looks every second for what lies within 25 world
units worth having: a weapon better than the worse of its two (it changes
to that one and holds the swap while the game offers the weapon), ammo for
an empty weapon it carries, an overshield, camouflage, a health pack when
hurt, grenades when it has few. In a fight it takes the weapon that suits
the distance (shotgun near, sniper rifle far, no rocket up close), at
most every two seconds, changes from an empty one, and now and then lobs
a grenade at an enemy 6 to 20 world units off.
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>
Y in a hosted game's lobby opens a panel to make the game public, choose
how many bots join (none by default, up to 15) and how well they play
(bots.skill: easy, normal, hard; hard is the first bots' play, normal a
little slower to notice, turn and aim). Integer settings can now be
written to config.toml as booleans could.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The settings file is written in the table's order, so settings of another
section between network.* entries wrote [network] twice, and the whole
file was then refused (defaults used). They now follow the network
settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bot walks to its goal along a path from ai/path.c (the maps' pathfinding
surfaces, as the campaign's actors use them), found again every half
second: in a fight towards its target, weaving; out of sight hunting the
nearest enemy, or roaming near someone; looking where it goes. With no
path it walks straight at the goal, and when stuck picks another.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bots.count bots are added in the lobby as players of machines of the
host's own that nothing connects to (four a machine, from the top slot
down): new connections skip their slots, and they count as machines for
the countdown, so a game of the host and bots starts. Every machine sees
them as players; each tick the host thinks for them (bots.c) and takes
the result as their input through update_server_handle_distributed_input.
The first brain finds the nearest visible enemy, turns at a player's rate
with some aim error, walks at it weaving and fires on target, and else
wanders, jumping and turning when stuck. Every multiplayer map has
pathfinding surfaces for the next phase.
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>
A host can make its game public (Y in the lobby, or network.public_game):
it then announces name, map, game type, players and join code every 10 s,
retained, on hcvr/1/g<network version>/<id> through the signalling
brokers, and clears it when it stops. On the system link list, X opens an
online games panel listing the public games heard lately (stale ones
dropped after 45 s) and a row to type a code; A joins by the game's code.
Announcements carry no addresses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
p2p_join_state tells the menus whether the host is being reached, was
reached (its game is in the list), did not answer, could not be reached
(both routers too strict: allow UPnP) or was lost; the join screen shows
it under the code's status.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
1351 changed files with 236769 additions and 4599 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 and Android. 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`), 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, 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`), 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/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, 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, 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/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 |
| [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's own code is released under [CC0 1.0](LICENSE.md).
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 | CC0 1.0 | [LICENSE.md](LICENSE.md) |
| [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 include this acknowledgement, as extract-xiso's
license asks: *This product includes software developed by in
<in@fishtank.com>.*
## 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) |
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". |
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
| 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, public
domain) 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`):
`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 +130,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 |
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 +225,11 @@ 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. |
| `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 +263,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 +291,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 +307,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 +355,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 | Both eyes in one pass (multiview) on the Quest; one by one on SteamVR, where multiview's eyes did not fuse; 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 |
| 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 |
| 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 25, 2026-10-10): cross-play, its server browser, co-op campaign, password lobbies; join codes and the VR online list |
| Bots | Bots in multiplayer: on foot (paths, items, objectives), in vehicles and at turrets; they make room for a joining person |
| 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, sprint by arm swing (offline) | 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). Sprint by arm swing still open |
| 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
[ -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)
fail "Could not write $GAME_DIR/$APK_NAME (is the storage full?)."
# --- the game data
if[ ! -f "$GAME_DIR/maps/ui.map"];then
image="${RINGFALL_IMAGE:-}"
if[ -z "$image"];then
mapfile -t images < <(find_images)
for candidate in "${images[@]}";do
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 1351 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.