Compare commits

...
Author SHA1 Message Date
Stefan MaierandClaude Opus 5.5 7a10a413c8 Merge feature/vr-refresh-auto: the eyes drawn one by one (multiview's doubled pictures online), the refresh rate stepped down while frames come late
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:57:18 +02:00
Stefan MaierandClaude Opus 5.5 5ce39c27a7 Roadmap: multiview to look into later; refresh rate steps in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:57:17 +02:00
Stefan MaierandClaude Opus 5.5 67d738451c Draw the eyes one by one by default; multiview only with debug.multiview
On the Quest 3, in big online games (26-35 players), the picture was
doubled and shifted from the start as the head turned, nauseating; with
48 bots it was not, and with the eyes drawn one by one it was not online
either. Not the frame rate: one by one, online at 72 Hz, the compositor
repeated as many frames and nothing was seen. The Steam Frame had the
same doubling with multiview (2026-10-05) and draws them one by one
since. So both now do by default; debug.multiview (was vr.multiview, and
debug.multiview_steamvr) draws them at once, for finding what the shared
pass draws wrongly. The refresh rate's steps take up the extra cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:48:27 +02:00
Stefan MaierandClaude Opus 5.5 91bdb7e67c Refresh rate: step down at one late frame in 32, show the rate it runs at
The first try stepped 90 to 80 Hz a few seconds into a 30-player game,
and no doubled picture was seen, but 2-5 stale frames a second were left
at 80: one late frame in 32 (was 16) takes it on to 72. The VR options
show the rate chosen and the one it runs at while lowered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:44:18 +02:00
Stefan MaierandClaude Opus 5.5 bf778b6d60 Step the refresh rate down while frames come late (vr.refresh_rate_auto)
In big online games a client's ticks cost 3 to 5 ms (twice what bots or
smaller games do), and the frames with a tick missed 90 Hz's 11.1 ms:
the headset showed them twice, a doubled picture when turning, with the
GPU at 60-80 %. While more than one frame in sixteen comes late, two
two-second windows running, the rate now steps down to the next the
headset offers (90 to 72 Hz); after ten windows in which hardly any
would be late at the rate above, it steps back up, never above
vr.refresh_rate. A load's long frames count for neither. On by default,
"Lower it when frames are late" on the VR options' MORE page; nothing
where the headset offers one rate (SteamVR).

Also the BUTTONS page's notes below its ten rows (the push to talk row
pushed the last one onto them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:38:59 +02:00
Stefan MaierandClaude Opus 5.5 d8a786f5a1 Merge merge/upstream-oct16: OpenCE's string conversion fix, voice packets checked, the postgame host screen's menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:34:03 +02:00
Stefan MaierandClaude Opus 5.5 7c294c9c39 Merge OpenCE upstream: its fix of the string conversion (taken over ours), voice packets checked, the postgame host screen's menu
Upstream fixed the Android asm conversion's string bug independently
(d9669f6ee, also from a Steam Frame crash); its converter and tests
replace ours (c85cf8ece), which they cover and extend (quoted names
honour escapes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:05:24 +02:00
bnunu f90d955527 Merge pull request #374 from chestahh/opence-multiplayer-menu-fix
Restore the main menu when leaving the postgame host screen
2026-10-16 00:13:42 -04:00
bnunu 085676d827 Merge pull request #346 from startupfoundry/arm64-guest-strings
arm64 and Android guests: string literals kept byte for byte (fixes a voice chat crash)
2026-10-15 23:37:14 -04:00
chestahh 4301ca88d6 Restore the main menu when leaving the postgame host screen
The postgame host map picker opens without menu history. Give it the
existing main_menu_if_no_history flag used by the lobby so leaving the
last screen restores the main menu instead of leaving only the background.
Update the generator and generated XML together. Preserve OpenCE's
existing handlers; no MCC code or regression-test files are included.
This local menu change does not require a network version bump.

Resolved the fork cherry-pick against OpenCE main 8102e711 by adding only
the flag override. Windows portable release build passed. Existing menu
teardown, touch and web checks returned 28 passes and 6 failures: missing
m.lib/pthread.h and Windows path separators in POSIX-oriented tests.
Android and web build targets were unavailable locally. Checked generator
round-trip stability and git diff --check. The two simple wrappers were
regenerated through ce_menus.widget_xml from their exported XML fields;
the original PC tag inputs were unavailable. No upstream runtime game
session or Linux build was run. Earlier scripted runtime verification
was performed on the GulchCE implementation, not this upstream revision.

(cherry picked from commit c811c7f527d70d5476578ed88ea0edcef2269967)
2026-10-16 02:51:54 +01:00
Stefan MaierandClaude Opus 5.5 b5cdd9268c Merge merge/upstream-net29: OpenCE network version 29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:26:16 +02:00
Stefan MaierandClaude Opus 5.5 34b61fba31 Roadmap: OpenCE network version 29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:06:41 +02:00
Stefan MaierandClaude Opus 5.5 87fe9fc1ad Merge OpenCE upstream: network version 29, Custom Edition maps keep their names, map sharing hardened
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:04:54 +02:00
MrBruh 8102e711c3 Merge pull request #353 from OpenCommunityEdition/testing
Merge testing into main
2026-10-14 23:21:30 +13:00
MrBruh 3e0ba55111 Keep Custom Edition maps' own names: the version goes in the game record
A game's level name is the map's own (custom_maps\hugeass), so no screen or
server browser listing shows a hash, and the game record's map version is
the map's hash's first 32 bits, which a client finds its file by. Maps are
no longer renamed; a download beside another version of its name is named
<name>_<hash>.map and listed as "(2)"; maps the last build renamed get
their names back. Network version 29: the level name and the map version
mean something else.
2026-10-14 23:08:39 +13:00
MrBruh 37ded3287c Fix harness tests for clang 23 2026-10-14 21:17:03 +13:00
Paul Hontz 9c70f5f5e3 Voice chat: a packet that is not one 20 ms frame is dropped before decoding
Every build sends one 20 ms frame per packet (voice_audio_encode). A packet
whose table of contents says otherwise, or that Opus cannot parse, is
damaged: voice_audio_play now drops it before the decoder sees it
(opus_packet_get_nb_samples), and the next frame makes up for it as lost.
Cheap defence in depth: Opus's decoder already refuses such packets, and the
crash in decode_pulses came from a valid packet and the guest's garbled
pulse cache, fixed in the assembly conversion.
2026-10-13 22:13:44 -04:00
Paul Hontz d9669f6eef The guest's assembly conversion keeps string literals byte for byte
The arm64 and Android guests are compiled for arm64_32-apple-watchos and
their Darwin assembly converted to ELF (tools/android_asm_convert.py). The
converter took a quoted .ascii/.asciz operand for a quoted symbol name and
dropped a leading "_" from it, as it does from symbols; and its quoted-name
pattern ended at an escaped quote (\"), so the "_" of a word after one was
dropped too. Clang writes byte tables as .ascii strings, so a table holding
95 ("_") lost that byte and everything after it moved down by one.

In Opus that table was CELT's pulse cache (modes.c, cache_bits50): the bits
a band may have are read off it, and shifted by a byte it lets a band have
more pulses than the PVQ tables hold. Decoding a frame from another player
then read past CELT_PVQ_U_ROW in decode_pulses (n 32, k 240) and crashed: a
client on the Steam Frame, in a CTF game on the internet. The guest's own
encoder could crash likewise in encode_pulses. The same build of Opus,
driven with the game's settings and with clean, corrupt, truncated and
random packets and voice_audio_play's loss handling, crashed within a few
thousand frames (and identically at -O0); with the fix it ran 4.8 million
decodes clean, and its encoded bytes and decoded samples are identical to a
native 64-bit build's.

The same loss hit other strings and tables of the guest: musl's iswalpha,
iswpunct and towctrans tables and iconv's charmaps; tomlc17's "_0123..."
digit sets (TOML numbers with "_"); the attract mode's "_de"/"_fr" names,
"_lan_", "_lit" and "_%s"; the progress bar's compressed monk tone and the
DirectSound effects image; and assertion and error texts. All are now
assembled as compiled.

String directives now go through unchanged, and quoted names honour
escapes. tools/test_android_asm_convert.py checks both, and a table of
every byte value through clang, the conversion and the assembler.
2026-10-13 22:13:44 -04:00
Stefan MaierandClaude Opus 5.5 238f3e2391 Merge fix/frame-voice-crash: the Android asm conversion keeps string data (the Steam Frame's voice chat crash), push to talk on any button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:45:46 +02:00
Stefan MaierandClaude Opus 5.5 e18c2b37d6 Push to talk on any button, never sent by a grip reaching for the weapon
Push to talk becomes an action of the BUTTONS page (vr.button_talk),
on an eighth button there, the free hand's grip (its default): it can
trade places with any other action (the move stick's click, say, so
that a player holding a weapon in both hands can talk).

The free hand's grip as a button is pressed once held 0.15 s, at the
threshold halo_vr_two_hand takes the foregrip at, and not at all while
it holds the weapon's foregrip or the Warthog's wheel: taken in that
time, the press is theirs until the grip opens. Before, the runtime's
grip button (which closes before the foregrip's threshold) could open
the microphone for a frame or two as the hand took the weapon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:45:40 +02:00
Stefan MaierandClaude Opus 5.5 c85cf8ece1 Keep string data as it is in the Android asm conversion
android_asm_convert.py renamed identifiers in every directive's operands,
string data included: a string starting with "_" was taken for a quoted
symbol and lost the "_", and one with \" in it was split wrongly, so a
"_" starting a word in it was dropped too. 26 of the guest's objects had
strings or tables cut short. Among them Opus's pulse cache (cache_bits50,
one byte short from entry 338): its encoder picked more pulses than its
tables hold for some bands and crashed (the Steam Frame's crashes in
online games, with push to talk). Also musl's wide character tables,
map sharing's hashed names ("_%s"), the "_lan_" menu check, libtiff's
and the sound code's tables.

.ascii, .asciz and .string are now passed on unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:06:59 +02:00
Stefan MaierandClaude Opus 5.5 01076509dd Merge fix/vr-frame-hitches: OpenCE network version 28, a shader worker and cache, graphics "medium" by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:04:19 +02:00
Stefan MaierandClaude Opus 5.5 0f6aa62eda VR: graphics "medium" by default; the PTC Quest's doubled pictures noted
On the developer's Quest 3 the picture doubles when turning with every
build back to 2026-10-06's, at 90 and 72 Hz, even when the compositor
reports no stale frames; that Quest runs Horizon OS from the Public Test
Channel. "medium" is smooth there, so it is the default until a Quest
outside PTC has tried "high" (roadmap). An unknown vr.graphics is
"medium" too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:04:03 +02:00
Stefan MaierandClaude Opus 5.5 5696244b1c VR roadmap: graphics "auto", what the effects cost, and its headset test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:18:04 +02:00
Stefan MaierandClaude Opus 5.5 1c7adac6e5 VR: graphics "auto", effects left out while the GPU falls behind and brought back after
On the Quest 3 the shader worker took away the long frames, but a 15-bot
game on Blood Gulch still had seconds at a time of 9-13 ms of GPU a frame
(GPU% 0.91-0.94, 4-37 stale frames a second, the picture doubled), between
spells of 5-7 ms; "medium" held 90 Hz.

- vr.graphics "auto", the new default (the VR options' Graphics row has
  Auto): "high", less what the GPU's time asks to leave out. host_xr.c
  reads XR_META_performance_metrics' app/gpu_frametime each frame (the
  metrics are now on wherever the runtime has them; logged only with
  vr.log_performance), handed to the guest by host_xr_gpu_time.
- vr_graphics.c: past four fifths of the frame (8.9 ms at 90 Hz, scaled
  with the rate) for three quarters of a quarter second's frames, the next
  steps are left out, as many as their known costs say the excess takes:
  the shield's glow, object shadows, reflections, specular light, lens
  flares, bump maps, the rest of "low". Steps that leave out nothing
  drawn (the glow without display.shield_glow) are passed over. A step's
  cost is learnt from the GPU time before it and the second after it
  settled. An effect comes back once the GPU time plus its cost stays
  under seven tenths of the frame for 3 s; an effect that had to go again
  within 10 s of coming back makes the wait twice as long, up to a minute,
  back to 3 s after a minute without leaving anything out. Each change is
  logged with the GPU time.
- Where the headset does not tell the GPU's time (SteamVR: the Steam
  Frame) "auto" draws everything, as "high" did.
- shield_glow.c draws no glow while "auto" leaves it out.
- debug.vr_graphics_measure leaves each effect out in turn (1.5 s on, 1.5 s
  off) and logs its cost by the GPU time, with means every round.

Checked: the APK builds, android_abi_check finds nothing, the desktop and
web paths compile; a host simulation of vr_graphics.c with a lagging
synthetic GPU time (heavy spells of 12 ms, effects of 0.3-2 ms) left out
two or three steps within 0.6 s of a spell and brought them back 3 s
after it, longer after a second spell; the measure mode recovers the
simulated costs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:17:41 +02:00
Stefan MaierandClaude Opus 5.5 2df0c9bd12 VR roadmap: the Quest's measurements, the shader worker and its headset test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:02:52 +02:00
Stefan MaierandClaude Opus 5.5 482a5ecf3a Android menus: pictures decoded on a thread of their own
A menu screen's first frame decoded its PNG pictures on the game's thread,
about 15 ms each ("7 menu pictures in 108 ms"): in VR a frame shown late.
Now a picture not yet decoded is asked of a decoding thread and drawn
transparent for the frames that takes; the game's thread only uploads the
decoded texels. Forgotten art's results are dropped by generation.
Decoding them all ahead was not done: 165 pictures, about 350 MB decoded
with their mipmaps.

Checked: the APK builds; desktop and web keep decoding as before (the
thread is the app's only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:58:33 +02:00
Stefan MaierandClaude Opus 5.5 d396eedb61 Android: shader programs made on a worker thread; every map's programs asked for as a map loads
The shader cache took most programs from their binaries the second time a
map was played, but the user still saw doubled pictures in VR: 29 new
programs in one frame 22 s into a match (794 ms), single ones at 18-64 ms,
and draws of 254 ms and 74 ms with nothing compiled at all (most likely
the driver making a program's variants at its first draw).

- port/android/host/host_gl_worker.c: a thread with an EGL context that
  shares the game's (surfaceless, or on a 1x1 pbuffer) makes the programs
  asked for: from the file's binary, or compiled and linked from the
  sources (its binary handed back for the file). It sets the samplers'
  units, draws once with each program into a small target of the eyes'
  kinds (RGBA8 with depth and stencil, plain and two-layer multiview) and
  puts a fence after it. The game uses a program only once the fence has
  passed (glClientWaitSync, never waiting). Urgent requests (a draw needs
  the program) go before the rest and can hurry a queued one along.
- d3d8_gl.c: a draw whose program is being made is left out for the frames
  it takes (draws_skipped_waiting), instead of the frame waiting; a lit
  program's wait no longer marks the lighting failed. A program's uniform
  setup is its own function, run when the program arrives.
- xgpu_program_cache.c: jobs by key in place of the ready table. As a map
  loads, its own list is asked of the worker, then every map's programs
  (shader_cache/all.txt, a union kept as any map draws), eight a frame, so
  a map first played or a weapon first fired mostly finds its programs
  made. The worker's compiled programs' binaries are written two a frame.
  Without the worker (display.shader_worker false, or no shared context)
  the map's programs are made on the game's thread as before.
- debug.shader_warm_up: "worker" (default), "load" (the map's programs
  waited for, up to 3 s, and drawn with on the game's thread as the map
  loads, for comparing) or "off".
- vr.log_performance: draws are timed (the CPU's counter); a long frame's
  line gives the draw calls' time, the slowest draw with its vertex
  program and program key, and the draws left out waiting; the 5 s report
  counts the draws left out.

Checked: the APK builds, android_abi_check finds nothing, the desktop and
web paths compile; a host harness with a mock worker (first run, second,
a first-played map, driver change, no worker, "load"); on the emulator
(SwiftShader, which refuses kept binaries, so all compiled there) the
worker runs on a pbuffer, the menus' load asked the other maps' 63
programs, Blood Gulch found 69 of 73 made, and 4-bot games ran with the
worker, with "load" and without the worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:58:33 +02:00
Stefan MaierandClaude Opus 5.5 d46e3fcb38 VR: foveation off by default, its "high" level taken out
On the Quest 3 (graphics "high", 90 Hz, multiview, a 15-bot game on Blood
Gulch) the app's GPU time was 5.5-6.4 ms with foveation off, low, medium
and high alike: the GPU is not what makes the frames late. "high" also
drew a grey rectangle below the player. vr.foveation is now "off" by
default with "low" and "medium" left to try; an unknown value is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:34:57 +02:00
Stefan MaierandClaude Opus 5.5 8b7857c070 Android shader cache: no format check of its own, and a refused binary logged
The emulator's GL (ANGLE under gfxstream) lists ANGLE's binary format but
hands out binaries of format 1, so every kept binary was passed over before
the driver saw it. The driver checks a binary itself (it made it, the same
driver by the hash); the first three it refuses are logged with the format,
size, GL error and info log, and the cache's start names the driver's
format.

Checked on the emulator with SwiftShader: programs kept (4 MB for the menus
and Blood Gulch), the binaries refused there (gfxstream's do not outlive
the process), so the map's programs were compiled from the kept sources as
it loaded, up to the two seconds, the rest as drawn; a 4-bot game ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:52:48 +02:00
MrBruh 6d64abdb42 Fix map sharing restart overflow and harden map validation 2026-10-13 07:43:41 +13:00
Stefan MaierandClaude Opus 5.5 0989fe8b91 VR roadmap: frame hitches and GPU time (foveation, metrics, shader cache), with a headset test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:35:32 +02:00
Stefan MaierandClaude Opus 5.5 a79dab53d1 Android: a program binary cache, and a map's programs made ready as it loads
The Quest's driver compiles and links a shader program the first time it
is drawn with, 10 to 30 ms each, which the frame waits for: a long frame
at a map's start (14 programs, 256 ms) and near its end (61 in 780 ms),
seen in VR as stale frames.

- port/linux/src/xgpu_program_cache.c: a linked program is written to
  shader_cache in the data folder, named by its sources' hash, with its
  binary (glGetProgramBinary), the driver that made it (GL_RENDERER and
  GL_VERSION) and both sources. Asked for again it is loaded with
  glProgramBinary when the driver, the sizes, the checksum and the sources
  agree; otherwise compiled as before and written again. A refused binary
  only falls back to compiling.
- d3d8_gl.c makes its shaders with their sources and compiles them only
  when a program of them must be linked (shader_make, program_make), so a
  program from the cache costs no compile. Each program entry keeps its
  cache key and lists itself once per map.
- Each map keeps the programs it drew with (shader_cache/maps/<map>.txt,
  written every 20 s while it grows and as the map goes). As a map loads
  (game_initialize_for_new_map, a port: hook) its programs are loaded from
  their binaries; after a driver update compiled again from the kept
  sources, in the background with GL_KHR_parallel_shader_compile where the
  driver has it (polled each frame, binaries written two a frame), else
  for at most two seconds of the load.
- display.shader_cache (true) turns it off; the folder is emptied past
  three quarters of 192 MB or 12000 files at the start, and may be deleted.
- VR's long frame line now also says how many programs came from the cache
  and what textures were uploaded (and decoded from DXT: the Quest has no
  S3TC), menu pictures decoded and render targets made, and in how long,
  for the menus' long frames that compile nothing.

Checked: the APK builds; the desktop and web paths compile; a host harness
with a mock driver went through the first run, a second run (all loaded),
a driver change (compiled from the kept sources, with and without
background compiling), a corrupt and a truncated file (compiled again) and
a missing list. On the emulator (no binary formats) the cache logs itself
off and a 4-bot game on Blood Gulch runs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:34:37 +02:00
Stefan MaierandClaude Opus 5.5 52ca15dcf0 VR: fixed foveated rendering of the eyes on the Quest (vr.foveation)
The Quest 3 at 90 Hz with graphics "high" spends 5-9 ms of GPU a frame
on the eyes and shows stale frames (ASW, doubled pictures as the head
turns); "medium" holds, so the frame is short of GPU time. The eyes are
now drawn at full detail about each lens's centre and coarser towards
their edges with GL_QCOM_texture_foveated (the Quest's Adreno 740), where
the lenses blur them anyway.

- vr.foveation "off", "low", "medium" (the default) or "high", a row
  on the VR options' MORE page (dim where the GPU has no foveation, as the
  Steam Frame's zink); read as it changes.
- The focal point of each eye is its lens centre, tangent 0, in the
  picture's coordinates: off the middle, the Quest's views being
  asymmetric. Full detail within sqrt(1 + area) / gain of it (0.69, 0.58,
  0.43 of the half width), down to a least density of 1/2, 1/4, 1/8.
- Only the eyes' targets: the multiview layers, which nothing else draws
  into; one eye at a time, the back buffer only from the eye's camera to
  its copy into the headset's image, so the HUD, menus, scope, crosshair
  and names drawn into it after stay at full detail. A texture is first
  foveated when the setting is not off (it cannot be undone); "off"
  afterwards sets the gain to 0, full detail everywhere.
- The GL ES log line names foveation, GL_KHR_parallel_shader_compile and
  the program binary formats the driver has; the first foveated target
  logs the extension's features and focal points, vr.c the level and the
  lens centres.

Checked: the APK builds; the desktop and web paths compile (no foveation
code outside HALO_ANDROID). Not tried on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:34:22 +02:00
Stefan MaierandClaude Opus 5.5 aa7a490d1f VR: the compositor's performance counters logged with vr.log_performance
XR_META_performance_metrics was asked for its counters with room for 16,
and the Quest 3 has more, so the call failed and the metrics stayed off
("xr: performance metrics off (0 counters)"). The counters are now
enumerated with the two-call idiom (up to 48 kept), sampled every third
frame and logged every five seconds as mean/most, named by their last two
path parts (app/gpu_frametime, compositor/dropped_frame_count), so the
app's GPU time and the stale frames show beside the game's own report.
They are enabled only with vr.log_performance (host_main.c passes it on as
HALO_XR_LOG_PERFORMANCE), as is the copies' timing that came with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:26:59 +02:00
Stefan MaierandClaude Opus 5.5 57beb4c2a7 VR: log long frames with their phases and shader compile time
A frame two of the headset's frames long logs its input, ticks, camera,
draw and present times and the shaders compiled or linked in it, so the
hitches seen as stale frames can be told apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:03:09 +02:00
MrBruh 52e33a391e Harden map sharing and Custom Edition map loading 2026-10-12 22:26:25 +13:00
Stefan MaierandClaude Opus 5.5 9e1eef683a VR roadmap: up to date with OpenCE, network version 28
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:23:08 +02:00
Stefan MaierandClaude Opus 5.5 7c6fa07cb5 Merge upstream 2026-10-12: network version 28, map sharing, pregame lobby, body and BSP fixes
Conflicts:
- rasterizer.h: upstream raised the transparent geometry groups to 2048
  as we had (halo_port_capacity.h); theirs.
- d3d8_gl.c: the ES capabilities' log with upstream's base vertex and our
  multiview.
- p2p.c, p2p_internal.h, p2p_signal.c: upstream's several joins and
  reconnect, with our join codes' state and the codes' and public list's
  updates each pass.
- network_server_manager.c: a machine downloading the map is not refused,
  and the bots' room logic stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:23:08 +02:00
MrBruh b1ef7f7e7a Merge pull request #311 from OpenCommunityEdition/testing
Merge testing into main
2026-10-12 21:20:11 +13:00
MrBruh c6a2f9004b Record the map sharing messages and the sizes of the wire format's arrays (#253)
The record read only network_*.c, so the map sharing messages in
map_share.c went unrecorded, and it took each struct sent whole by the text
of its declaration, so a change to a constant that sizes one of its arrays
(MAXIMUM_ENTRIES_PER_MESSAGE, DISTRIBUTED_INPUT_HISTORY and the others)
changed the message without failing the check. It now reads map_share*.c
too, and records each such constant by its definition, following the names
that uses, and the DISTRIBUTED_ sizes in the enum of no name (a unit state's
and a relayed action's least and largest sizes). A size with no definition
found fails the check rather than passing it.

Checked: the items only grow (35 added, none changed), so version 28 is
recorded again; changing MAXIMUM_ENTRIES_PER_MESSAGE in network_actors.c
fails the check as a change.
2026-10-12 21:07:30 +13:00
MrBruh 6578c3b84a Merge pull request #274 from startupfoundry/config-sections 2026-10-12 20:46:50 +13:00
MrBruh 5587c856f8 Merge pull request #273 from bmanturner/opence/base-vertex-extension 2026-10-12 20:46:50 +13:00
MrBruh dda756582e Merge pull request #272 from bmanturner/opence/p2p-resume-after-stall 2026-10-12 20:46:49 +13:00
MrBruh 785e865a87 Merge pull request #302 from hkmodd/fix/limp-body-corrections 2026-10-12 20:46:49 +13:00
MrBruh 5930a46f23 Merge pull request #294 from hkmodd/fix/titles-under-menus 2026-10-12 20:46:49 +13:00
MrBruh bbe00a4049 Merge pull request #286 from hkmodd/fix/hires-text-fit 2026-10-12 20:46:49 +13:00
MrBruh 3cd1a6622e Merge pull request #305 from hkmodd/perf/debug-model-vertices-static 2026-10-12 20:46:49 +13:00
MrBruh e71e6514f3 Merge pull request #304 from hkmodd/fix/transparent-geometry-groups 2026-10-12 20:46:49 +13:00
MrBruh 9fbbf6a628 Merge pull request #303 from hkmodd/perf/shader-key-compare 2026-10-12 20:46:49 +13:00
MrBruh 3d67397cdd Record the wire format at network version 28 (#253)
The testing branch is at version 28, whose changes (holding back the
players of a machine still downloading the map, the multiplayer scripted
BSP, lights past the Xbox limits, readying a second weapon) change no
message, kind, bit or struct the record holds; only its version changes.
2026-10-12 20:35:23 +13:00
MrBruh 5d9081e91e Merge pull request #253 from addisonbair/network-format
# Conflicts:
#	port/linux/NETCODE.md
2026-10-12 20:35:16 +13:00
MrBruh ce5e382b37 Merge pull request #262 from machinsk/web-metal-buffer-waits 2026-10-12 20:34:15 +13:00
MrBruh 065d7f2b1d Merge pull request #263 from addisonbair/pregame-lobby 2026-10-12 20:34:15 +13:00
MrBruh 5a7c655a60 Testing branch review fixes
Network version 28: the host now holds a downloading machine's players out
of the game, and keeps a multiplayer map's first scripted BSP. Dynamic lights
and the flashlight stay on past the Xbox limits, and a second weapon picked
up while firing is readied. Machines of version 27 would disagree on these.

- A large co-op game's respawn fallback no longer picks a teammate who is in
  the air, outside the BSP, or riding an AI vehicle.
- A machine still downloading the map times out when it goes silent.
- Held players join through the late-join queue, so they get the same checks.
  A machine with no held players is started instead of being refused.
- The co-op teleport uses world positions, which are right for seated players.
- Overshield drain no longer cuts off a hit's flash, and hits over 0.02 flash.
- The menu XML is the generator's (Custom Edition's layout) again.
- port: markers on two new declarations.

Checked: ninja linux, android and web build; tools/harness,
test_touch_menu and test_web_build pass.
2026-10-12 20:01:49 +13:00
hkmoddandClaude Opus 5.5 e791cf9ae0 Bodies the host moves are no longer drawn floating or in walls on clients.
In large games, bodies on clients were drawn twisted, hanging in the air,
piled up or inside walls. There were two causes, both in how a client
takes the host's object states.

A body that comes to rest lies limp (bipeds.c): its nodes stay where they
are in the world and are no longer recomputed from the object. The game
stops this when something pushes the body (biped_accelerate). On a client,
that push comes from the damage aftermath, which is sent only to the
machines that see the body's player (network_damage.c). It never comes for
older bodies (a player's dead_unit_index is their latest) or for world
damage. When the host then moves the body, the client's copy moves with
the host's corrections and its own physics, but its pose stays where it
came to rest. A client now stops a body lying limp before it puts the body
somewhere and whenever the host says it is moving. units.c makes it lie
limp again, every tick, once it is at rest on the ground.

A client also takes the host's word that a body is at rest while its own
copy is still in the air, a little behind. Rest stops its physics, and
only its physics clears airborne, so the body kept the pose of its fall
(its root node about 0.36 above the ground) and never lay limp. A client
now clears airborne on a body the host has at rest: the host's is on the
ground, because it comes to rest only when it is not airborne.

Nothing sent changes, so the network version stays the same.

Checked:
- New harness test limp_body_moves takes the code from network_objects.c
  and bipeds.c. Its cases are pushed, landed, moved, blended and living.
  Each of its 5 negative controls (each line of the fix taken out) fails
  its case.
- The whole harness, run natively on Windows: 234 passed, and the
  failures are the environmental ones main also has on this machine.
- The Windows build has no warnings.
- local system link games on one machine (debug.network_test, bots and a
  kill every 2 seconds), with temporary instrumentation: each machine logs
  its bodies every second, and the host sets off a frag grenade's
  explosion under up to 4 resting bodies every 5 seconds. Result: bodies
  at rest drawn more than 0.15 from where the host draws them:
    4 machines, 120 s:  195 of 573 pairs before, 0 of 1221 after;
    32 machines, 150 s: 19479 of 96439 pairs before (20%), 157 of 71237
      after (0.2%), counting only pairs where the host and the client
      agree on the body's position. The rest are bodies still in their
      dying animation, a few ticks apart, at the same rate as on the
      host.
  Copies at rest but left airborne: 195 samples before and 0 after on 4
  machines, 10718 before and 0 after on 32.
- Not checked here: the Android and web builds (they don't build on this
  Windows machine), Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:46:27 +02:00
hkmoddandClaude Opus 5.5 eea1c3641a Keep the debug model vertices off the stack
rasterizer_debug_model_vertices declares 2048 debug vertices of 64 bytes
on the stack. render_model calls it for each part it draws, and the
release build inlines it there, so render_model's frame was 136,304
bytes, and the Windows build probed it a page at a time (_chkstk, 33
pages) for every model drawn, although the debug display is off. Its
vertices are static now: only the vertices below debug_vertex_count are
read, each written when it is added, and the function runs on the
render thread only, so nothing it draws changes.

Measured on Windows (7800X3D, RTX 4080, release build, vsync off), b30
co-op with 32 times the enemies and a bot, 75 s a run, five runs of each
build alternated: render_model's frame is 5,360 bytes (the PDB's
S_FRAMEPROC); _chkstk fell from 2.0% to 0.5% of the game thread's
samples; fps over windows with 2000 draws a frame or more, origin/main
128, 141, 139, 134, 107 (median 134), this change 140, 139, 144, 141,
132 (median 140). Linux, Android and the web build do not probe the
stack, so the change only shortens their frame. They were not built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:18:12 +02:00
Addison Bair fab098a7bd Record the wire format of the network version
AGENTS.md asks for HALO_PORT_NETWORK_VERSION to be raised with any change
to what the machines send each other; nothing checked it, and #95, #99 and
and 18 caught up after them).

tools/network_format.py takes the wire format from the sources: the game's
message numbers, sizes and fields, the distributed netcode's message kinds
and flag bits, its DISTRIBUTED_ constants and the structs it sends whole, as
hashes. port/linux/network_format.txt records them for the version, and
test_linux_port.py fails when the sources differ: an item changed or removed
asks for the version to be raised (--record refuses it until then), an item
added (a new kind or bit, which older builds may ignore) only to be recorded.
It also fails when NETCODE.md says nothing of the version.

Checked by recording each merge's parent and checking the merge: #95, #99
and #106 fail as changed structs; #91, votes to kick, voice chat and the
integrated light bits as additions; #101, which raised the version, passes
once recorded. A comment, or a struct the netcode keeps to itself, changes
nothing.
2026-10-12 01:09:55 +00:00
Addison Bair 9180bd37c8 NETCODE.md: network versions 22 to 25
The versions' list stopped at 21. 22 to 25 as their commits give them:
Custom Edition maps named custom_maps\<name> (4e8ed2f1), their blocks kept
past the Xbox tools' limits (34e2d4fd), the gametype's PC vehicle set
(11117d7c) and a unit's integrated light in its object state (6b55f5cc).
2026-10-12 01:09:44 +00:00
hkmoddandClaude Opus 5.5 2c6c85cf38 Raise the transparent geometry groups a frame from 384 to 2048
Every transparent thing a frame draws takes a group: each transparent
part of a model, each widget (light volumes, antennas, flags, glows),
each batch of dynamic geometry (particles, contrails), each transparent
surface of the structure. The Xbox's 384 were a limit the native builds'
larger pools of objects, widgets, effects and particles could pass: a
frame past it was drawn without the rest, and debug.txt said "too many
transparent geometry groups". A part refused a group has no sort record
(models.c checks the group's index), so nothing else changes.

Checked on Windows in b30 co-op with eight times the enemies and a bot,
75 s a run: origin/main overflowed in 3 runs of 4; a build counting the
groups asked for showed up to 464 in a frame, while the dynamic triangle
and vertex buffers peaked at 123 and 328 of their 1024 and the dynamic
triangles at 10272 of 49152, so they are left as they are. With this
change, the same count showed 391 in a frame and no overflow; two more
runs had none. The pool is 2048 * 160 bytes, malloc'd; the model order
pass keeps 14 bytes a group on the stack (28 KB). Harness: the
transparent_plane_order tests pass at the new size (natively on Windows;
the other 44 failures there are the same on origin/main, POSIX headers).
Android and web builds not built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:55:40 +02:00
hkmoddandClaude Opus 5.5 1068da0c3a Compare shader keys and draw uniform inputs a word at a time
Every draw compares its pixel shader key (256 bytes) with the recent and
cached keys, and its uniforms' inputs (77 words) with the last draw's.
The Windows build's memcmp (vcruntime) compares them a byte or a word at
a time and was the game thread's largest single function: 7.9% of its
samples in a b30 co-op game with 32 times the enemies (about 3900 draws
a frame), 5.3% from fragment_shader_get and 2.2% from the uniforms.
words_equal has no early exit, so that clang compares several words at
once (four 512-bit compares a key with -march=native).

Measured on Windows (7800X3D, RTX 4080, release build, vsync off), five
runs of each build alternated, 75 s each, b30 co-op x32 with a bot:
fps over windows with 2000 draws a frame or more, origin/main 123, 139,
135, 123, 122 (median 123); this change 142, 147, 138, 149, 131 (median
142). A sampled run of this change shows memcmp gone and words_equal at
1.2%. The keys are memset before they are filled and have no padding,
so word equality is byte equality. Android and web builds not built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:27:50 +02:00
bnunu 173248fd8e Merge pull request #299 from innerhat-dev/overshield-drain-testing
An overshield's drain is not a shield hit, and a second weapon is readied
2026-10-11 20:02:20 -04:00
bnunu 5181d33b49 Merge pull request #298 from innerhat-dev/menu-row-spacing-testing
Menu rows keep one vertical step
2026-10-11 20:02:09 -04:00
bnunu a33bf2d537 Merge pull request #297 from innerhat-dev/coop-respawn-fallback-testing
Respawn a large co-op fight beside the group after ten seconds
2026-10-11 20:01:58 -04:00
bnunu caad6e772a Merge pull request #296 from innerhat-dev/multiplayer-sky-bsp-testing
A multiplayer sky script no longer reloads the level
2026-10-11 20:01:43 -04:00
bnunu 4f59a20a01 Merge pull request #295 from innerhat-dev/multiplayer-flashlight-testing
A multiplayer match keeps the flashlight on
2026-10-11 20:01:21 -04:00
bnunu 9633a7c34a Merge pull request #293 from innerhat-dev/coop-grav-lift-testing
Bring a co-op team inside with a scripted teleport
2026-10-11 20:00:40 -04:00
justinfilip 2598dbe8b6 An overshield's drain is not a shield hit, and a second weapon is readied. 2026-10-11 18:34:17 -05:00
justinfilip 679bade466 Menu rows keep one vertical step. 2026-10-11 18:34:16 -05:00
justinfilip 0b4a03561b A large co-op fight respawns beside the group after ten seconds.
The wait only counted teammates the map called grounded, so jumping, riding, or standing on a beach left it with nobody to use. A late joiner after a checkpoint uses the same wait when nobody is safe to spawn beside.
2026-10-11 18:34:15 -05:00
justinfilip b71bd6a0fa Keep a multiplayer map's first scripted BSP switch so the host's game type chooses the sky. 2026-10-11 18:34:14 -05:00
justinfilip c3365b1c69 A multiplayer script that switches BSP to cycle the sky no longer reloads the level. 2026-10-11 18:34:14 -05:00
justinfilip 417de7e2ad A multiplayer match keeps the flashlight past the Xbox player and vehicle limits. 2026-10-11 18:34:13 -05:00
hkmoddandClaude Opus 5.5 5e5cb08820 Test that a scenario's titles stay under the menus
An asset-free test (tools/harness) of the real cinematic_render, taken
from cinematics.c with its structures, over a fake scenario with one
title for the whole game, as Extinction's: it is drawn in the game, not
while a menu is up and again once the menu is gone, and its time goes on
under a menu, so it ends when it would have. Two negative controls (the
title drawn over a menu, its time stopped under one) each fail their
case.

Checked: in archlinux:latest with CI's packages (clang 23.1.1,
lib32-glibc), python -m pytest -q tools/harness passes (271); the new
cases fail on origin/main's cinematics.c ("the title was drawn over a
menu in frame 0").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:30:09 +02:00
justinfilip d6df944886 Bring a co-op team inside with a scripted teleport.
Campaign scripts only name two players, so a large group was left on the beach when Truth and Reconciliation's grav lift switched into the ship.
2026-10-11 18:27:24 -05:00
bnunu 0b2c6759bb Merge pull request #289 from innerhat-dev/late-join-same-checks
Adding a player after a map download uses the same checks as any other late join
2026-10-11 18:27:47 -04:00
justinfilip baab0780f6 Adding a player after a map download uses the same checks as any other late join. 2026-10-11 17:25:11 -05:00
bnunu 5fccdda2b1 Merge pull request #288 from innerhat-dev/lobby-map-for-testing
A downloading player's body waits until they join, and custom_maps is created when missing
2026-10-11 18:08:02 -04:00
justinfilip 05dd9ccdea Create the custom_maps folder on startup when it is missing. 2026-10-11 17:03:37 -05:00
justinfilip 67f2214219 A player still downloading the map no longer spawns until that player joins. 2026-10-11 17:03:37 -05:00
hkmoddandClaude Opus 5.5 13ddcf802a Keep a scenario's titles under the menus
A scenario's titles (cutscene titles, their text from the scenario's
ingame_help_text) are drawn with the screen's overlays, which come after
the players' windows and so after their menus. Some Custom Edition maps
show their name with one for the whole game: Extinction's "Extinction",
at the bottom right, was drawn over the pause menu and over the profile
screen's CANCEL button. cinematic_render already leaves out the letterbox
while a menu is up; it now leaves out the titles too. Their time goes on
as before, so a title still fades out when it would have.

Checked: the Windows build (clang 23.1.3, full LTO) without warnings;
Extinction from the Italian Custom Edition in a hidden window, START
pressed by a local test input after 14 seconds: the title is drawn in
the game and not over the pause menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:37:59 +02:00
hkmoddandClaude Opus 5.5 7a6fc70eb9 Fit the high-res text's fonts to the widths of a Custom Edition loc.map's letters
The high-res text sizes Overpass by the font tag's capitals and centres
each glyph on the tag's character width, and Overpass was chosen for the
Xbox maps' widths. A Custom Edition loc.map's fonts are narrower: the
Italian one's large_ui and small_ui are the same font, its capitals 10
units tall and its letters 87% as wide as Overpass's at that height
(against 95 to 98% for the Xbox maps' interstate, large_ui and small_ui),
so the menus' and HUD's letters ran into each other. Each font is now
narrowed until its letters A to Z and a to z are, summed, as wide as the
tag's; the Xbox maps' fonts are narrowed by 2 to 5 hundredths.

The fonts were also remembered by the tag's name, and the Xbox maps and a
loc.map have tags of the same names with other measures, and by the font
tag's address, which the next map's tags may reuse: they are remembered
by their measures too, and forgotten with the map's tags.

Checked: the fonts' widths measured from the Italian loc.map and the Xbox
ui.map against Overpass's advances; the Windows build (clang 23.1.3, full
LTO) without warnings; Extinction from the Italian Custom Edition, whose
large_ui is now drawn 87% wide, its letters apart, and the main menu's
small_ui 98% wide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:16:04 +02:00
bnunu 86be005acc Merge pull request #283 from innerhat-dev/lobby-countdown
A player joining or leaving the lobby no longer restarts the countdown
2026-10-11 16:36:27 -04:00
MrBruh a2aad18f65 Custom Edition map sharing
Hosts send players who join without the game's Custom Edition map the map
and the resource maps it needs; the other players help, torrent-style.
Network version 27: new map sharing messages, and players of an internet
game now reach each other.
2026-10-12 08:07:14 +13:00
justinfilip 71ceb363f1 Players who already have the map no longer wait for a machine that is still downloading it. 2026-10-11 13:37:40 -05:00
justinfilip 2059b6cef5 A player joining or leaving the lobby no longer restarts the countdown. 2026-10-11 13:37:40 -05:00
Paul Hontz 1e7946659c config.toml with each section once, and a file with [display] twice read again
The settings table has display.viewmodel_shield, first_person_legs and the
shield's glow and color after the game settings, so the defaults the game
wrote had a second [display] after [game]. TOML refuses a table defined
twice: every start since logged "table defined more than once; using the
defaults", and no change to the file took effect. Only a file written
fresh since those settings came in has it; an older one gets them added
to its own [display] (config_add_missing).

The defaults now put each section once, with all of its settings, in
whatever order the table has them. A file that has a section twice gets
the later parts joined to the first when it is read, and is written back
that way once it parses.

tools/test_linux_port.py builds port_config.c on its own and checks both.
2026-10-11 13:57:23 -04:00
Brendan Turner dc9b842ce2 Draw with a base vertex on OpenGL ES 3.0 and 3.1 with the extension
The vertex and index mirror draws with glDrawElementsBaseVertex, which the
renderer enabled only on OpenGL ES 3.2. ANGLE on Metal and many Android
drivers report ES 3.0 or 3.1 and offer the function as
GL_EXT_draw_elements_base_vertex or GL_OES_draw_elements_base_vertex, so
they rebased and uploaded each index array at every draw.

base_vertex is now on for ES 3.2, or when the context lists either extension
and its entry point loads; glDrawElementsBaseVertex then points at
glDrawElementsBaseVertexEXT or glDrawElementsBaseVertexOES. A function that
cannot be loaded leaves the existing path. The capabilities log line says
which source is used. The two suffixed names are in a new optional list in
gl.h (never a load failure); android_gl_stubs.py generates guest stubs for
them, and the host resolves them like the other hostgl_ imports.

Affects Android and ANGLE-based Apple ports. The web build is unchanged: it
does not load the optional functions and WebGL 2 lists neither extension.

Checked: ninja android (guest and host) and ninja web build. On an iPad Pro
(M2, ANGLE on Metal, ES 3.0) through an iOS port that runs this Android
guest, the log reads "base vertex 1 (GL_EXT_draw_elements_base_vertex)",
Blood Gulch plays, the draw counts are unchanged and the vertex data
streamed each frame falls by about a third (e.g. 148 KB to 95 KB, 52 KB to
35 KB at the same spawn points). Not run on Android hardware.
2026-10-11 12:26:14 -05:00
Brendan Turner 0a5a235f5e Ask STUN and the brokers again after the p2p thread was stopped
When the process is suspended (a phone app in the background) or the
machine sleeps (a laptop), the p2p thread's wait lasts far longer than it
asked for. The NAT's mapping of the tunnel may have lapsed and the brokers'
TCP connections may be gone, but nothing notices until each one's silence
timeout, so joins and listings fail for a while after the game resumes.

When a wait lasts longer than its length plus PEER_TIMEOUT, the thread now
discards what the sockets reported, asks the STUN servers for the public
address again, and has every broker close and connect again at once
(p2p_signal_reconnect). A wait of normal length changes nothing.

Nothing sent over the network changes, so HALO_PORT_NETWORK_VERSION stays.

Checked: the Android guest and the web build compile with no new warnings.
The same change, together with an iOS host fix for the sockets iOS takes
from a suspended app, made server browser joins work on an iPad Pro after
about five minutes in the background (they failed until a relaunch
without both). Not run on Linux, Windows or Android.
2026-10-11 12:09:43 -05:00
Jonathon MachinskiandClaude Opus 5.5 568f8c1fe7 Web draws' uploads put in buffers no queued draw reads, so Metal does not wait
WebGL on Metal (ANGLE, which Chrome and Safari use on a Mac) waits for the
GPU to finish everything queued before it writes a buffer that a queued
draw reads, unless its shadow buffers are on, and Chrome leaves them off.
The renderer wrote its stream and index buffers after each draw, some 2000
times a frame, so each frame waited for the GPU as many times. On an M1
Pro in Chrome the Silent Cartographer ran at 7 to 9 frames each second and
Blood Gulch at about 22, at any resolution (640x480 too): Chrome's GPU
process spent half its time in -[MTLCommandBuffer waitUntilCompleted]
(macOS sample), and --enable-angle-features=useShadowBuffersWhenAppropriate
alone tripled the rate.

Now, on the web, each draw's vertices and indices go into a buffer that no
queued draw reads: web_chunk_get takes one from pools of power-of-two sizes
(4 KB to 32 MB), one set for each frame of the ring, and a buffer is written
again only when its frame comes round again. A draw counter (draw_serial)
tells stream_reserve and index_upload when the current buffer may be read
by a draw. Android is unchanged.

Checked on an M1 Pro, Chrome 155, 3024x1626, uncapped, A/B against
e0b581b1: b30 6.1 to 170 frames each second, Blood Gulch 57 to 595; with
vsync, 60 throughout. Screenshots of Blood Gulch before and after are the
same. ninja web builds without warnings; tools/test_web_build.py and
port/web/tests/*.test.js pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:12:42 -06:00
Addison Bair 59a74b0c4b The pregame screen opened through the port's menus everywhere
Fast setup and the network test's join opened ui.map's pregame screen by name, where ui_widget.c opens the port's lobby; a ui.map of only the menus' tags hosts and joins with both.
2026-10-11 14:08:51 +00:00
Stefan MaierandClaude Opus 5.5 3c0d860c10 VR roadmap: arm swinging (online too) and its sprint (offline), the 2048 groups in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:30:52 +02:00
Stefan MaierandClaude Opus 5.5 9f084155d0 CI: the web pages job off without a second condition
The merge left the pages job two if: keys, which made the workflow
invalid; it is off (if: false) as the web job is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:13:47 +02:00
Stefan MaierandClaude Opus 5.5 6fdee4fabb Merge merge/upstream-2026-10-11: OpenCE network version 26, its first-person legs off in VR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:12:19 +02:00
Stefan MaierandClaude Opus 5.5 74bdffb957 Merge upstream 2026-10-11: network version 26, first-person legs and shield flare, web port, fixes
Conflicts:
- render_objects.c: upstream's first-person legs and our VR body share one
  node_matrices (ours no longer shadowed it); upstream's legs never in VR
  (first_person_legs_wanted: set back behind a flat view's eye, they would
  stand behind the player; the VR body is vr.body "full").
- AndroidManifest.xml: upstream's ':game' process left out (the Steam
  Frame's Lepton waits for the app's process to end; the headsets have no
  Java heap over the Xbox window); HaloApplication kept, idle without it.
- xinput_sdl.c: the touch controls and the web port's, never in VR; the
  controllers' rumble in VR before the phone's.
- d3d8_gl.c: multiview's uniforms and upstream's blend constant, both.
- build.yml: Android only; upstream's web and pages jobs off.
- README.md: ours; upstream's in docs/OPENCE_README.md.
- menu_tags.c: our VR pause-list fit dropped: upstream now spaces the rows
  30 apart and moves the button hints below them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:04:08 +02:00
Stefan MaierandClaude Opus 5.5 7566f49f5e CI: builds for main only
Every pushed branch built (and mailed its failures); the branches are tried
on the headsets from local builds, and only main makes releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:50:56 +02:00
MrBruh e0b581b157 Web: disclaimer footer 2026-10-11 23:03:29 +13:00
MrBruh 17a57832d8 Windows: tag block accessor builds again
msvc_comdat.c defines __inline as weak on Windows, which a static function cannot be; the accessor is __inline__. Checked by compiling msvc_comdat.c for i686-pc-windows-msvc.
2026-10-11 22:45:33 +13:00
MrBruh 5cd65a0cb0 Web: meter blend in the shader
WebGL refuses blendFunc(CONSTANT_ALPHA, CONSTANT_COLOR), so the meters (ammo counters) drew with the previous blend and a GL error each frame.
2026-10-11 22:38:11 +13:00
MrBruh a3277d94dc Fewer draws and GL calls
Decals and light volumes batched, web index and vertex array caches, no FULL_ES3. Prisoner, 15 players: 115 to 172 FPS (A/B).
2026-10-11 22:28:34 +13:00
MrBruh 6399ed9d4a Text drawn a string at a time, web immediate draws packed
Scoreboard text on the web: 1.41 to 0.41 ms a frame. The network test's browse joins the fullest game that is not co-op.
2026-10-11 16:16:35 +13:00
MrBruh 01afcb654a First-person shield flare kept close only with display.viewmodel_shield (#254) 2026-10-11 15:13:49 +13:00
MrBruh 3fa1f348a9 Merge pull request #254 from chestahh/codex/opence-viewmodel-shield-fit 2026-10-11 15:05:51 +13:00
MrBruh f1c31076ff Merge pull request #252 from addisonbair/bots-hardware-id 2026-10-11 15:05:51 +13:00
MrBruh 0bfe211289 Web: stable pages hashed less often, fewer clock reads
18-player Blood Gulch in Chrome at 1080p: 135 to 225 FPS.
2026-10-11 14:59:19 +13:00
chester 2e45259bea Delete tools/test_viewmodel_shield.py
not required for upstream commit
2026-10-11 01:59:40 +01:00
chestahh 248210cedb Keep first-person shield flares close to the arms 2026-10-11 01:52:48 +01:00
MrBruh 19f1bd9f5d Web: pointer lock taken back on a click, wheel notches 2026-10-11 13:47:22 +13:00
MrBruh c89eb80d3a Web: no return addresses, SIMD page hash
Blood Gulch in Chrome: 43 to 242 FPS.
2026-10-11 13:21:14 +13:00
MrBruh 6dc423fdbf Internet play with browsers
Network version 26: signalling says each machine's WebRTC.
2026-10-11 12:46:28 +13:00
MrBruh 887f40bd2c Web opaque canvas 2026-10-11 12:46:28 +13:00
MrBruh 47b7eac9a8 Web rooms over WebRTC 2026-10-11 12:46:28 +13:00
MrBruh 4a9e515d54 Web touch controls 2026-10-11 12:46:28 +13:00
MrBruh 532407588e Web loading indicator 2026-10-11 12:46:28 +13:00
MrBruh a55a182086 Web deploy, tests, docs 2026-10-11 12:46:28 +13:00
MrBruh acfa9a2a4d Web build 2026-10-11 12:46:28 +13:00
MrBruh 095973133e Shield color on the first-person arms too (#244) 2026-10-11 12:25:51 +13:00
MrBruh 6ac7e3c3d7 Merge pull request #244 from jcompto95-tech/shield-color 2026-10-11 12:19:34 +13:00
MrBruh 1f48f0555d Shield glow: one light per unit in split screen, light show not clamped (#247) 2026-10-11 12:18:47 +13:00
MrBruh 79bcc86066 Merge pull request #247 from jcompto95-tech/shield-glow 2026-10-11 12:14:25 +13:00
MrBruh f735ca152d First-person legs: one mesh kept, its waist forgotten on a new map (#243) 2026-10-11 12:11:51 +13:00
MrBruh 03d054aa6e Merge pull request #243 from jcompto95-tech/first-person-legs 2026-10-11 12:06:18 +13:00
MrBruh 21db5f9065 Vehicle homes: no other home's vehicle taken, no ridden one deleted (#249) 2026-10-11 12:05:13 +13:00
MrBruh 098ca66a72 Merge pull request #249 from innerhat-dev/vehicle-teleporter-name 2026-10-11 11:59:05 +13:00
MrBruh ebb1f093ae Campaign pause rows 30 apart, as the PC version's (#246) 2026-10-11 11:58:34 +13:00
MrBruh e04079b4bd Merge pull request #246 from innerhat-dev/campaign-pause-spacing 2026-10-11 11:55:27 +13:00
MrBruh fb97e52663 Stale player handles forgotten in the port's loop (#250) 2026-10-11 11:54:50 +13:00
MrBruh dcb06d83e3 Merge pull request #250 from eltanschauung/fix/coop-stale-player-handle 2026-10-11 11:51:38 +13:00
MrBruh d03f6e2777 Merge pull request #248 from jcompto95-tech/viewmodel-shield
Energy shield effects visible on view models (on a toggle)
2026-10-11 10:45:31 +13:00
MrBruh 2c4b216a8b Merge pull request #251 from innerhat-dev/hud-digit-bitmaps
Draw a custom HUD digit from the bitmap it names
2026-10-11 10:34:24 +13:00
MrBruh a5b9ddda6a Merge pull request #238 from bertoaldo/coop-elevators
Fix co-op clients falling through moving elevators
2026-10-11 10:33:48 +13:00
MrBruh 0fe3f13e43 Merge pull request #240 from bnunu/halo-ce-universal/settings-new-profile
The main menu's Settings with no player profile asks for a new one's name
2026-10-11 10:33:20 +13:00
MrBruh e6c31e3404 Merge pull request #239 from bnunu/halo-ce-universal/custom-maps-listing
List the Custom Edition map folders through a handle of their own
2026-10-11 10:32:57 +13:00
Addison Bair 0f9a8904cd Test the stand-in machines' messages against network_messages.c
test_linux_port.py takes a stand-in machine through a join (join request,
settings request, player, precache, loaded, game update) and checks each
message's number against enum network_game_message_type and its size
against the fields network_messages.c decodes, so a change to a message
fails CI rather than every machine's join. It fails on the script before
the previous commit (a join request of 80 bytes where 112 are decoded).
2026-10-10 20:35:30 +00:00
Addison Bair 77794aa1b7 Stand-in machines send the join request's hardware id
Since the join request carries the joining machine's hardware id (32 hex
digits after the join token, for bans and kick votes), the host refuses
one without it ("server failed to decode a message_client_join_game_request
packet") and closes the connection, so tools/system_link_bots.py could not
join any machine. Each machine now sends its own number as its id, so the
host tells them apart.

Checked on build 177: 14 machines join a Blood Gulch game (16 players with
the host's and a client's), spawn and play it out; with the script before
this, every machine is refused.
2026-10-10 20:35:30 +00:00
justinfilip a3c139ec53 A custom HUD digit that is its own bitmap no longer trips the check that every digit shares one bitmap. 2026-10-10 15:33:11 -05:00
eltanschauung 6ea142ceee Fix co-op stale player handle crash 2026-10-10 15:30:31 -04:00
justinfilip af7ad15d3c Keep a respawned vehicle on the name its teleporter uses.
The timer was leaving the named vehicle on the pad and creating another on top of it. The home now follows that named vehicle, and a pad that already has one does not get a second.
2026-10-10 13:25:53 -05:00
jcompto95-techandClaude Opus 5.5 1463c87090 Energy shields glow as they flare, in their own color (display.shield_glow)
Video Setup > Graphics > Shield Glow, off by default; Glow Intensity:
DEFAULT or LIGHT SHOW (three times as far and as bright).

As a unit's shield flares (hit, charging, falling), it lights what is around
it, and itself, as a plasma bolt does: a Spartan's gold, an Elite's blue,
any unit's its shield shader's own edge color. The glow's strength is the
flare's, the intensity the shield plasma is drawn with (its intensity
source, an object function, to its exponent), so it rises and falls with
the flare seen on the body, and fades with active camouflage.

port/linux/game/shield_glow.c chooses up to 8 units within 25 world units of
the camera each frame. object_lights.c gains port glows: ordinary dynamic
lights attached to an object's node, kept alive, colored and sized each
frame by their maker (light_port_glow_set) and gone a moment after it stops,
drawn with a light definition the map already has (one without a lens flare
or a flashlight's flags, a plasma one where there is one): no new assets.

Only the drawing changes: nothing the game simulates, nothing sent between
machines. Checked by compiling the changed units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
2026-10-10 13:56:38 -04:00
justinfilip 2af955e1d6 Space the campaign pause rows like the profile settings list.
Adding Settings centred the last row onto the B and A prompts. The rows now use that list's pitch, and the box grows so the prompts stay under the last row.
2026-10-10 11:46:10 -05:00
jcompto95-techandClaude Opus 5.5 8664d2f24b Your energy shield in a multiplayer armor color (display.shield_color)
Video Setup > FOV and Viewmodels > Shield Color: DEFAULT (the shield's own)
or one of the 18 multiplayer armor colors (player_profile.c's table: white,
black, red, blue, gray, yellow, green, pink, purple, cyan, cobalt, orange,
teal, sage, brown, tan, maroon, salmon), whatever the armor's color.

No new assets: the shield's own plasma shader is drawn, its perpendicular
and parallel colors replaced by the chosen color at their own brightness
(their brightest channel), so its noise, animation and intensity are the
shader's. A local player's unit is drawn with a copy of its change colors as
the modifier's colors (render_object_list); rasterizer_plasma_energy_draw
knows that copy again and replaces the shader's colors only for it. Plasma is
added to what is behind it, so black is drawn as a faint grey.

Only this machine's drawing of its own players' shields changes: others see
your shield as their own settings draw it, and nothing sent between machines
changes. Checked by compiling the changed units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
2026-10-10 10:30:19 -04:00
jcompto95-techandClaude Opus 5.5 8ee3806792 The energy shield's flare on the first-person arms (display.viewmodel_shield)
Video Setup > FOV and Viewmodels > Arm Shields, off by default. The stock
game draws a unit's shield flare (its modifier shader, the cyborg's shield
plasma, as its shield is hit, charges and falls) on the body only; the
first-person arms never showed it. With Arm Shields on, the hands model is
drawn with the unit's own modifier shader and the unit's own function values
and change colors, as render_object_list draws the body: the same flare, on
the arms. The first-person weapon is drawn without it.

The flare is a first-person transparent group, so it is drawn in the
viewmodel's projection (display.viewmodel_fov) and lines up with the arms.
The setting is read in view_fov.c with the other viewmodel settings.

Only the drawing changes. Checked by compiling the changed units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
2026-10-10 10:30:18 -04:00
jcompto95-techandClaude Opus 5.5 d36c15c313 Your own legs in first person, seen looking down (display.first_person_legs)
Video Setup > FOV and Viewmodels > Legs, off by default. In first person the
player's own body, which the game animates as everyone else sees it but does
not draw for its own player, is drawn from the waist down: looking down,
your legs walk, run, strafe and crouch as the body's animation moves them.

How it is drawn (port/linux/game/first_person_legs.c):
- A copy of the body's pose as drawn: everything but the hips and legs (by
  node name: pelvis, thigh, calf, foot, toe) folded into a lid over the
  waist, the root kept.
- The body set back behind the eye, each frame, as far as keeps the waist's
  open rim beyond the bottom plane of the view (the way Halo 2's fp_body
  keeps its open top out of sight): looking ahead, 4 cm; straight down at
  105 degrees, about 67 cm. The legs are seen as they swing forward.
- Drawn with the world's camera, so it keeps its place and size at any FOV;
  not in mirrors, not in the shadow pass, without what it carries.

The waist's rim is found from the model's own triangles: object_mesh.c reads
an object's drawn mesh (its most detailed geometry, in the permutations it
shows) from the vertex and index buffers the game draws it from, as
powerup_render_bounds.c reads them; its cache is forgotten on a new map
(objects_initialize_for_new_map).

Only the drawing changes: the body, its hit boxes, markers, the camera and
everything sent between machines are the game's. Checked by compiling the
changed units; the fold and set-back were checked against a capture of the
cyborg's mesh and pose and played on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtk9vPUKbWd48oBMzXuzd
2026-10-10 10:30:16 -04:00
Stefan MaierandClaude Opus 5.5 e09af359f3 CI: Android only, the Linux and Windows jobs off
Ringfall: Mjolnir VR ships the Android app (Quest, Steam Frame) only. The
desktop jobs stay in the workflow, off (if: false), so that upstream's
changes to them still merge; the release and its notice wait on Android
alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:03:06 +02:00
Stefan MaierandClaude Opus 5.5 7d3533e7b9 Merge feature/vr-voice-and-body: the whole body in VR, push to talk on the free hand's grip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:41:48 +02:00
Stefan MaierandClaude Opus 5.5 bb2f2b19a1 VR roadmap: step 3b, the whole body, done on the Quest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:41:45 +02:00
Stefan MaierandClaude Opus 5.5 486f182fec VR body: the legs walk the way the player moves, the feet on the ground
- 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>
2026-10-10 13:07:47 +02:00
Stefan MaierandClaude Opus 5.5 313668e0da VR roadmap: step 3b, the whole body, built on the branch
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>
2026-10-10 12:34:55 +02:00
Stefan MaierandClaude Opus 5.5 22f9d03742 VR body: the neck at its height, the stance reset after a jump, mirrors
- 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>
2026-10-10 12:34:07 +02:00
Stefan MaierandClaude Opus 5.5 de509fb1a2 VR body: the neck shrunk with the head, the collar closed below the eyes
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>
2026-10-10 12:25:34 +02:00
Stefan MaierandClaude Opus 5.5 378f8bf897 VR: the whole body, vr.body "full": torso, hips and legs under the arms
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>
2026-10-10 12:22:55 +02:00
MrBruh 9da2a45ffa The weapons' reticles drawn from the PC HUD sheet's exact matches
The reticles of the PC HUD sheet's drawing that match the Xbox's exactly
(14 of them, in HUD2_left_reticles/xbox_reticle_matches.md) take the
place of the reticles' redraws: each copied unchanged, at the size and
place that match its Xbox sprite best (tools/hud_assets.py reticles; a
likeness of 0.97 to 1.00). They also give high-res textures to two
bitmaps that had none: the rocket launcher's and wraith's reticle, and the
sniper rifle's, whose bitmap holds its zoom labels too; no redraw has the
Xbox's 2x and 10x, so they are set in Overpass 900 (of the project's
fonts the one that fits them best), sized, placed and spaced to match.
The warthog's and the scorpion's keep their redraws: only variants of
them are on the sheet. The build places a redraw at any scale, and the
layout step runs again (a name hid its match function).

AUTHORS.md credits the high-res HUD's redraws to MrBruh, and the reference
photos they are based on to Aerocatia.

Checked: each texture beside the Xbox bitmap; a build reproduces the
PNGs; ninja linux and android; in a game on Blood Gulch the plasma
pistol's reticle draws from its new texture, and all 71 HUD textures are
found there.
2026-10-10 22:46:32 +13:00
Stefan MaierandClaude Opus 5.5 53e13fc69d VR: voice chat's push to talk on the free hand's grip, a Voice chat row
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>
2026-10-10 11:29:32 +02:00
Stefan MaierandClaude Opus 5.5 697a85f1cb Merge merge/upstream-2026-10-10: OpenCE network version 25, in-game settings, touch controls (off in VR), FOV options (off in VR)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:11:50 +02:00
Jonas VolmanandClaude Opus 5.5 3dcacc9530 The main menu's Settings with no player profile asks for a new one's name
With no player profile, Settings opened nothing and showed "event handler
'new campaign decision' failed", where Campaign, Multiplayer and Profiles
open the new profile's name screen. Reported on Android; the menus' code is
every build's, and the Windows build did the same.

Settings runs the PC version's "profile set edit begin", which only failed
with no profile. The screens its <conditional> then opens run the game's
"new campaign decision", and that makes a profile only once "new campaign
chosen" has asked for its name: the other items' "new game if no plyr
profiles" asks, and "profile set edit begin" did not.

- "profile set edit begin" with no profile, in the main menu, asks for the
  name (the game's "new campaign chosen") and fails as before, so that the
  same screens make the profile. A game's pause menu runs the function too
  and has no such screens: there it only fails, as before.
- "new campaign decision" is the port's for the menus' widgets: it runs
  the game's, and for Settings' screen then begins editing the profile
  made. The PC version's screens open Settings with nothing more run, and
  Settings closed at once with no profile being edited ("close if not
  editing profile"). For the other items' screens it is the game's alone.

The menus' files are as tools/ce_menus.py writes them: nothing in them
changes, nor in the game's sources.

Checked on a Windows build, by keys posted to the game's hidden window, on
save folders with no profile: Settings opens the name screen, then
"Creating player profile", then Settings with the new profile being
edited; the name screen left without a name goes back to the main menu
with no profile made (and the game's "new campaign decision" failure shown,
as for the other items); Profiles makes its profile and opens the profile
list as before. With a profile, Settings opens at once. The pause menu's
Settings with no profile does nothing, as before. The menus' checks of
tools/test_linux_port.py pass. Not run on Android or Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:40:27 -07:00
Jonas VolmanandClaude Opus 5.5 a845bbf55d List the Custom Edition map folders through a handle of their own
custom_edition_maps_look_for listed custom_maps and the install's maps
folder with find_files_start and find_files_next, which keep their place
in one global (find_files_globals). As the game starts, the menus' thread
for the file system's checks (filesystem_initialization_thread_proc in
ui_widget.c) makes the default gametypes' folders with
directory_create_or_delete_contents, which deletes a folder's files with
the same two functions. A map list looked for meanwhile takes that
listing's place, and the thread then deletes the files of the folder being
listed: the player's Custom Edition maps.

The menus take no input until that thread is done, so it takes a start
that asks for the maps at once. Seen with debug.menu_open set to the
Select Map screen
(main_menu/multiplayer_type_select/mp_map_select/mp_map_select_screen) on
a Windows build of c9e4f13f, with six files in custom_maps: one was left.

Each folder is now listed with FindFirstFileA and FindNextFileA on a
handle of the function's own. The same start, twice, left the six files
and listed the three maps among them. Not run on Linux or Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:37:01 -07:00
MrBruh c9e4f13fdb Never change a don't-dump ART mapping, and test the mincore fallback anywhere
Splitting the window's slice off ART's large object space restores it with
MADV_DODUMP; on a space already marked don't-dump the split does nothing,
the read fails, and the restore then cleared the flag on the slice and
split ART's mapping for good. The space's VmFlags are now read first, and
such a space is left as it is. A slice with swap of its own is reported as
in use (objects are there), and each failure says what failed.

debug.halo.art_overlap takes -nopagemap (pagemap treated as refused) and
-swapped (the stand-in's objects paged out with MADV_PAGEOUT), so the
mincore fallback and the slice's swap can be tested on any device.

Checked on a Pixel 9 Pro XL: a normal start, and idle, busy, idle-swapped,
idle-nopagemap, busy-nopagemap, idle-nopagemap-swapped (the reclaim goes
ahead: swap in the space, none in the window) and busy-nopagemap-swapped.
On Linux, a space marked don't-dump stays one mapping with its flag.
2026-10-10 21:17:44 +13:00
Milenko e90a097b84 Android: ART's large object space taken back when only other parts of it are swapped out (the range's own swap read by splitting it off for a moment)
Needed on a device whose ART large object space (765bd000-965bd000, 512 MB)
covers the window, that refuses /proc/self/pagemap and has some of that
space swapped out: build-175 could not tell whether the swap was in the
window, and did not start.

(cherry picked from commit c8fa616c0c28250546b1c2ef4204f3a393020d81)
2026-10-10 21:17:44 +13:00
Stefan MaierandClaude Opus 5.5 6f43e4f915 VR: the campaign's pause menu keeps its list's height with SETTINGS
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>
2026-10-10 10:17:28 +02:00
MrBruh bb5471b6a2 Retry the ART reclaim on kernels that take MAP_FIXED_NOREPLACE as a hint
Before 4.17 the kernel puts the mapping elsewhere when something is in
the way; reserve() then set errno to EEXIST but returned before the
reclaim, so it was never tried there. memory_map.txt now also names the
attempt each reason came from (at start-up, or when the image loaded),
where it listed them twice.

Checked on a Pixel 9 Pro XL: a normal start, debug.halo.art_overlap=idle
and =busy behave as before, and the busy report shows both attempts.
2026-10-10 20:28:08 +13:00
Milenko 1c4a64c284 Android: memory_map.txt says why the fixed addresses could not be had, and on what device
(cherry picked from commit aaa6130565fb44fc1dd041d2e245fbb55899a685)
2026-10-10 20:27:58 +13:00
Milenko 0c8f4183f1 Android: start on devices whose Java runtime covers 0x80000000
On some handhelds (the AYN Thor, Retroid devices), ART's large object
space covers the fixed Xbox window, and the reclaim from upstream #19 found
pages of it in use, or could not read /proc/self/pagemap, so the game did
not start.

- The game runs in a process of its own (":game"), with a fresh Java heap:
  nothing the launcher, a disc image import or an earlier game allocated is
  left in the large object space.
- HaloApplication loads libmain.so first thing in that process, and its
  JNI_OnLoad reserves the window and the image range before the Java side
  allocates anything large.
- Whether the slice over the window is idle is read from pagemap, or, where
  the device refuses pagemap, from mincore and the space's swap total. The
  log now tells "in use" (how many pages, where) from "cannot tell".
- When the window cannot be had, the game says so plainly and writes the
  mappings below 4 GB to memory_map.txt in its data folder and to logcat.
- The debug.halo.art_overlap system property (idle or busy) puts a stand-in
  for ART's space over the window, to test the reclaim on any device.

Ported by hand: our host_memory.c had moved on (the page-hash write
tracking), and README.md and host.h took both sides. The commit's change
to the "game data was not found" message is left out.

Checked on a Pixel 9 Pro XL (Android 17): the game runs as
com.halo.decomp:game and reserves the fixed ranges from JNI_OnLoad;
debug.halo.art_overlap=idle reclaims the stand-in and the game starts,
=busy leaves it alone and shows the message.

(cherry picked from commit c71d7f8b8ea475564f8f70ab08ea585af8f8c681)
2026-10-10 20:27:58 +13:00
Stefan MaierandClaude Opus 5.5 2548dd4a54 Desktop updater: the repository's new name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:10:48 +02:00
MrBruh 775015e879 The buttons' letters placed where the map's are, and their settings read again (#56)
The map's letters do not sit alike on every icon: on the message icons'
sheet, X's and Y's are about 1.7 texels lower on their ovals than the
redraws put them. tools/button_assets.py layout now moves each icon's
letter (and Y's shadow, given the id "shadow") to where it best matches
the map's, and buttons.json keeps the move; the menus' large buttons move
by under 0.03 texels, the message icons' by up to 1.7. The PNGs are drawn
again, the sheet with all 256 levels of alpha rather than 32.

hud_hires_sprites_drawable read display.high_res_hud and high_res_text
only once, and hud_hires_sprite_bitmap kept its answer for the bitmap's
address, so turning either setting off or on left the message icons as
they were: both check again when the settings change, as
hud_hires_override_find does. ui_widget.c declares hud_hires_sprite_bitmap
with the port's other functions instead of in the block that calls it.
AUTHORS.md credits saulob for the redraws.

Checked with button_assets.py check against ui.map and side by side with
the map's icons; ninja linux and ninja android build, and the harness and
test_linux_port pass.
2026-10-10 20:03:40 +13:00
Luke 8dad0c759a Co-op elevators: the host takes a riding client's player only across, so they no longer fall through going up 2026-10-10 17:57:19 +11:00
Stefan MaierandClaude Opus 5.5 e005b4ef3f VR roadmap: up to date with OpenCE, network version 25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:51:53 +02:00
Stefan MaierandClaude Opus 5.5 b6a943f1a6 Merge upstream 2026-10-10: network version 25, touch controls, in-game settings, FOV options, voice chat, votekick
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>
2026-10-10 08:51:46 +02:00
MrBruh 1a0045edda Merge pull request #56 from saulob/feature/high-res-ui-buttons
Add high-res UI controller buttons
2026-10-10 19:17:43 +13:00
MrBruh bd98c8fb20 AGENTS.md, and how the menus are written again
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.
2026-10-10 18:03:32 +13:00
MrBruh 5e8ab023c1 Merge pull request #237 from innerhat-dev/coop-majority-respawn
Respawn beside the largest group in a large campaign game
2026-10-10 17:41:58 +13:00
MrBruh 231708d2e0 CI's profiling build only when a run asks for it (#223)
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.
2026-10-10 17:33:53 +13:00
MrBruh c44930abbe Single-player SETTINGS: placed by REVERT TO SAVED's tag, main's centring kept (#67)
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.
2026-10-10 17:13:14 +13:00
MrBruh 9a264e9230 Merge pull request #67 from saulob/feature/in-game-settings-menu
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.
2026-10-10 16:46:51 +13:00
MrBruh 085512fe41 Profiling build: built in CI, its header with the port's, port marks (#223)
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.
2026-10-10 16:40:43 +13:00
MrBruh 66dfd1367c Merge pull request #223 from HiIAmMoot/profiling-cpu
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.
2026-10-10 16:28:45 +13:00
MrBruh b1a71709ef FOV and viewmodel settings: one unit, port marks, the lens flare cull (#234)
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.
2026-10-10 16:24:04 +13:00
MrBruh a8178af24a Merge pull request #234 from innerhat-dev/fov-viewmodel-controls
Add opt-in FOV and viewmodel controls to Video Setup
2026-10-10 15:52:26 +13:00
MrBruh 867425241c HALO_GLES for the embedded SMAA data, the desktop check without all three (#123)
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.
2026-10-10 15:51:47 +13:00
MrBruh 400d62bb76 Merge pull request #123 from startupfoundry/split-halo-android
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.
2026-10-10 15:41:32 +13:00
justinfilip 8618c2919a Respawn a large co-op game beside the largest group of teammates.
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.
2026-10-09 21:41:04 -05:00
MrBruh b5a4870d05 Saved profile's controls: the comment wrapped as the file's (#227) 2026-10-10 15:23:16 +13:00
MrBruh 30771b1b2c Structure triangle limit: port comments (#232)
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.
2026-10-10 15:20:36 +13:00
MrBruh a58fd479e5 Item resting surface: a port comment, its helpers with the private code (#231)
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.
2026-10-10 15:14:51 +13:00
MrBruh 691532327e Frame throttle sleep: a port comment, no unused precache test (#229)
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.
2026-10-10 15:09:24 +13:00
MrBruh 124cdd0b17 Any controller's menus: helpers placed and declared as the file's (#228)
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.
2026-10-10 15:06:52 +13:00
MrBruh f2cb0916dc Merge pull request #232 from innerhat-dev/structure-triangle-limit
Let a frame draw 32768 structure triangles
2026-10-10 15:00:00 +13:00
MrBruh da24e4c726 Merge pull request #231 from innerhat-dev/item-rested-surface
Keep an item's resting surface past 32767
2026-10-10 15:00:00 +13:00
MrBruh 96ee110671 Merge pull request #229 from thelinkin3000/fix/throttle-sleep
Sleep in the frame throttle instead of spinning
2026-10-10 15:00:00 +13:00
MrBruh ef87ea5ae1 Merge pull request #228 from thelinkin3000/fix/menus-any-controller
Let any controller drive the menus while one person plays
2026-10-10 15:00:00 +13:00
MrBruh 010d7520e4 Merge pull request #227 from thelinkin3000/fix/profile-controls-on-save
Apply a saved profile's controls to the player using it
2026-10-10 15:00:00 +13:00
MrBruh 7124534a4b Memory leaks: render targets, gamepads, Android audio stream 2026-10-10 14:42:05 +13:00
xshxdex98 5ed36d17f2 CE maps: an index past a tag block's end no longer stops a debug build
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)
2026-10-10 13:59:04 +13:00
Milenko 90f3fdf25f Director debug controls: each variable's own hyper scale
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)
2026-10-10 13:59:04 +13:00
Milenko 42cfa397f7 AI debugging checks: the vehicle exit time
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)
2026-10-10 13:59:04 +13:00
xshxdex98 234d4c2295 AI profiler: meters averaged right
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)
2026-10-10 13:59:04 +13:00
Milenko 03a5891a2b Menus: display.menus takes "PC" in any case, and says what it needs
"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)
2026-10-10 13:59:04 +13:00
Milenko 20e678f757 Keep the back button and gesture for the game on Android
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)
2026-10-10 13:59:04 +13:00
Milenko 985cb7c14d Android updater: download bounded in size
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)
2026-10-10 13:59:04 +13:00
Milenko f2a0f91e48 Antennas and cloth flags follow a jump of more than one unit
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)
2026-10-10 13:59:04 +13:00
xshxdex98 5d5b5dd35e Custom Edition maps: HUD sounds, dialogue variants and camouflage on odd tag data
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)
2026-10-10 13:59:04 +13:00
xshxdex98 02eae329d0 Pause menu centred on Custom Edition maps
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)
2026-10-10 13:59:04 +13:00
xshxdex98 14ebfae230 Menus: the pointer no longer runs a list through all its items
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)
2026-10-10 13:59:03 +13:00
Milenko fee00e9c79 Particles: a Halo PC map's particle of more bitmaps than a draw takes
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)
2026-10-10 13:59:03 +13:00
xshxdex98 c875cbda5d Sound: play a Custom Edition sound louder than a channel at full gain
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)
2026-10-10 13:59:03 +13:00
xshxdex98 137d15b95f Voice chat: no packet longer than its length's byte
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)
2026-10-10 13:59:03 +13:00
lantos1618 f815e1ccba Zoom effect: no ghosting at high resolutions
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)
2026-10-10 13:59:03 +13:00
xshxdex98 b5229d7ad5 Custom Edition: linear bitmaps with uneven rows draw with the right pitch
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)
2026-10-10 13:59:03 +13:00
MrBruh 6b55f5cca8 Network version 25
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.
2026-10-10 13:59:03 +13:00
Milenko 2db3174366 Server: a banned machine's join is refused as banned
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)
2026-10-10 13:59:03 +13:00
Milenko eb0f6e07a7 Game speed: a number from 0 to 100
game_speed takes only finite speeds from 0 to 100.

(cherry picked from commit 4c6e68a6021204563aedb1f91e9a10d90744b5d8)
(cherry picked from commit bdbb182d0fc653960d1baf7a8b46360537eafe32)
2026-10-10 12:53:23 +13:00
Milenko acb71c2f88 Cache file header: its name ends within its field
The header's name is read only within its field.

(cherry picked from commit d4ff4cc226e25d315ca1db853ebef14953521691)
(cherry picked from commit f0dd7c9fa533a95e8e653e203064a985965f4007)
2026-10-10 12:53:23 +13:00
Milenko f7ea5db0e9 Sounds: a range's permutation mask without shifts past its bits
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)
2026-10-10 12:53:23 +13:00
Milenko f38d526e83 High-res HUD: unfilter each PNG row by its filter
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)
2026-10-10 12:53:23 +13:00
Milenko 89f83cf08d UPnP: no router address in the error
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)
2026-10-10 12:53:23 +13:00
Milenko 56db20924c Audio: a flushed stream's next packet plays from its start
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)
2026-10-10 12:53:23 +13:00
Milenko d978f66373 Multiplayer: a map with fewer than three multiplayer vehicles
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)
2026-10-10 12:53:23 +13:00
Milenko 412c5224e1 Profiles: a deleted profile's name can be used again
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)
2026-10-10 12:53:23 +13:00
Milenko 129d3483dc Multiplayer: the pause menu's controller no longer moves the player
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)
2026-10-10 12:53:23 +13:00
Milenko 8fd4f06a90 Windows: the game starts on Windows 7
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)
2026-10-10 12:53:23 +13:00
Milenko 0fb9b57605 Telnet console: a client sending an HTTP request is dropped
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)
2026-10-10 12:53:23 +13:00
thelinkin3000 1ac8186d6c Scan the motion sensor for up to four local players
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)
2026-10-10 12:53:23 +13:00
Milenko d2256722ca Disc image setup: only plain file names written
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)
2026-10-10 12:53:23 +13:00
xshxdex98 7df9be7785 Give unset locals values, and drop the decompiler's dated audit notes
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)
2026-10-10 12:53:22 +13:00
xshxdex98 60cd1254d6 Fix the decompiled code's latent bugs, and say plainly what is kept
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)
2026-10-10 12:53:22 +13:00
xshxdex98 e970305724 Profiler: frame descriptions stay inside their buffer
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)
2026-10-10 12:53:22 +13:00
xshxdex98 65e690321d Log and error messages print what their formats ask for
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)
2026-10-10 12:53:22 +13:00
xshxdex98 767a4edb0b Netcode: carry units' integrated lights in the object states
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)
2026-10-10 12:53:22 +13:00
xshxdex98 07bb950064 Co-op: the scripts' look tests pass for any player
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)
2026-10-10 12:53:22 +13:00
Milenko 953b0298d4 Scripts: boolean and short results are whole values, not part uninitialized
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)
2026-10-10 12:53:22 +13:00
Milenko 84dd8a2c32 Particles: a fully culled group stores no NaN in its shader
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)
2026-10-10 12:53:22 +13:00
Milenko 794cbb92c5 Bitmap sampling: the compressed-block messages print the lod; an unknown format reads black
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)
2026-10-10 12:53:22 +13:00
Milenko 712d1ebe36 Projectile trails: their bitmaps read before a weapon's first shot
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)
2026-10-10 12:53:22 +13:00
xshxdex98 ab3c388c15 Joining: a connection made is open, whatever came before it
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)
2026-10-10 12:53:22 +13:00
xshxdex98 ff8fc7a654 Network: log the Winsock error behind a failed send; the connect wait's select
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)
2026-10-10 12:53:22 +13:00
xshxdex98 d5dedf19e0 Rotational particles: don't read past a bitmap's last sequence
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)
2026-10-10 12:53:22 +13:00
xshxdex98 744040c870 Lens flares out of view are not tested or drawn
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)
2026-10-10 12:53:22 +13:00
Milenko 7ac5978158 terminal_printf: take its arguments as a va_list
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)
2026-10-10 12:53:22 +13:00
justinfilip f75c6dc90d Call the original weapon view Default.
The menu labeled that choice SAME. A chosen angle already sets that field of view.
2026-10-09 18:16:14 -05:00
MrBruh 6a74eab5ad Touch controls review fixes
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.
2026-10-10 12:09:19 +13:00
MrBruh 91a49f898c Touch controls hidden until a game is played
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.
2026-10-10 11:19:06 +13:00
MrBruh fc61004479 Touch controls: the profile's buttons and sticks, finger sizes, opacity
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.
2026-10-10 11:19:06 +13:00
MrBruh 141e5984f5 Touch aiming with the stick's aim assist and invert
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.
2026-10-10 11:19:06 +13:00
justinfilip acae172b57 Keep the weapon at its original view when the world view is wide.
SAME was drawing the arms and gun with that wide view. Video Setup also packs each platform's own rows, so a hidden setting does not leave a gap.
2026-10-09 12:43:22 -05:00
justinfilip b980207235 Let a frame draw 32768 structure triangles.
Retail stops the list at 16384. The count is a long so it can hold 32768, and the dynamic triangle buffer keeps room for the rest of the frame.
2026-10-09 11:38:27 -05:00
justinfilip d084721fcf Keep an item's resting surface past 32767.
The number stays in the same 16-bit field, and a match can end on a map whose collision surfaces run past that.
2026-10-09 11:02:48 -05:00
saulo benigno c463fabed4 Add in-game Settings to single-player campaign 2026-10-09 12:00:18 -03:00
Paul Hontz fb9f03cf2c Split HALO_ANDROID into the guest's ABI, the ES renderer and the app
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.
2026-10-09 09:05:44 -04:00
eltanschauung cc41b768bb Add opt-in FOV and viewmodel settings 2026-10-09 08:18:29 -04:00
thelinkin3000andClaude Opus 5.5 e799d08149 Sleep in the frame throttle instead of spinning
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>
2026-10-09 12:00:34 +00:00
thelinkin3000andClaude Opus 5.5 59c8d54409 Let any controller drive the menus while one person plays
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>
2026-10-09 12:00:34 +00:00
thelinkin3000andClaude Opus 5.5 ee1e169f9b Apply a saved profile's controls to the player using it
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>
2026-10-09 12:00:34 +00:00
Milenko d90f84faa3 Add on-screen touch controls for the game on Android
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)
2026-10-10 00:59:59 +13:00
MrBruh 2aa3897189 Setting rows merged and values widened for a finger only (#50)
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.
2026-10-10 00:59:22 +13:00
MrBruh f56e120e16 Merge pull request #50 from HiIAmMoot/touch-menus
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.
2026-10-10 00:55:30 +13:00
saulo benigno 3ff762a32c Merge branch 'main' into feature/in-game-settings-menu 2026-10-09 08:34:54 -03:00
saulo benigno bfe1d0f2ba Merge branch 'main' into feature/high-res-ui-buttons 2026-10-09 08:33:39 -03:00
MrBruh 42a97d4f07 libtiff left out of the builds
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 /).
2026-10-10 00:33:22 +13:00
MrBruh f479e34914 OpenCE icon for Android, Windows and the desktop window
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.
2026-10-10 00:13:07 +13:00
MrBruh 4505a43e53 Overshield links' comments and lines as the files', part 0 checked (#116)
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.
2026-10-09 22:34:33 +13:00
MrBruh 801d2e72db Merge pull request #116 from eltanschauung/fix/overshield-glass-compositing
Fix overshield brightness changes caused by transparent draw order
2026-10-09 22:24:40 +13:00
MrBruh c92b9defc2 The secondary target at the screen's scale, on main's targets (#83)
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.
2026-10-09 22:24:30 +13:00
MrBruh 1f63e34e8c Merge pull request #83 from FDervish/fix/mirror-target-resolution
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.
2026-10-09 22:24:11 +13:00
MrBruh 5fb7737797 Screenshot key desktop only, without its test (#109)
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.
2026-10-09 22:23:29 +13:00
MrBruh 01dbb36693 Merge pull request #109 from eltanschauung/feature/screenshot-action
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.
2026-10-09 22:18:42 +13:00
MrBruh 08d91f82fa Animation enhancements behind game.enhanced_animations, node count kept (#144)
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'.
2026-10-09 21:48:56 +13:00
MrBruh 31435ab6cc Merge pull request #144 from RockBooch13624/cyborg-animation-enhances
Improve grenade throw and vehicle rider animations
2026-10-09 21:37:30 +13:00
MrBruh 0982cdb93e Steam's on-screen keyboard only, sysroot links kept in, SDL pins noted (#218)
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.
2026-10-09 21:27:38 +13:00
MrBruh a2b4c8c5be Merge pull request #218 from startupfoundry/steam-deck
Linux: the release runs on a Steam Deck (SteamOS) and older distributions
2026-10-09 21:14:46 +13:00
MrBruh f6c7f93f98 Looping sounds' replaced components only, no fade endpoints (#187)
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.
2026-10-09 21:14:40 +13:00
MrBruh 6051e598d2 Merge pull request #187 from eltanschauung/fix/looping-audio-lifetime
Preserve looping sound fade endpoints and retire replaced components
2026-10-09 21:13:35 +13:00
MrBruh d518f54a6f No debug keyboard End or Escape restart outside the editor (#178)
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.
2026-10-09 21:13:08 +13:00
MrBruh 989913d252 Merge pull request #178 from eltanschauung/fix/console-escape-capture
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.
2026-10-09 21:10:19 +13:00
MrBruh 073baa3c69 Scripted first-person cameras blended as before (#189)
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.
2026-10-09 21:09:26 +13:00
MrBruh 5a05aefb30 Merge pull request #189 from startupfoundry/camera-interpolation-fixes
High refresh rates: cutscene cameras stay inside the lifepod and dropships, and no camera jump after a hitch
2026-10-09 21:09:01 +13:00
MrBruh 65cd4c29d8 usnprintf as MSVC's again, map formats' widths bounded (#197-#199)
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.
2026-10-09 21:06:25 +13:00
MrBruh 5e08c09e78 Merge pull request #199 from ChupathingyCE/upstream-fix/map-format-arguments
Map strings used as formats only with the game's own arguments (with #197 and #198, which it stacks on)
2026-10-09 21:05:46 +13:00
MrBruh db9be6a002 Maps named com or lpt are not devices (#206)
Only con, prn, aux and nul are devices by their three letters; com and lpt are with a digit after them.
2026-10-09 21:05:07 +13:00
MrBruh 7cd56c95e4 Merge pull request #206 from ChupathingyCE/upstream-fix/map-name-devices
Refuse a host's map name that is a Windows device name
2026-10-09 21:05:02 +13:00
MrBruh 66d34b9e65 Keyboard crouch walk at 0.97, without its test (#190)
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.
2026-10-09 21:04:26 +13:00
MrBruh b24fb497c3 Merge pull request #190 from eltanschauung/fix/keyboard-crouch-walk
Fix keyboard crouch walking
2026-10-09 21:04:19 +13:00
MrBruh 515e6a0ab6 Profiles' Start as CE's again, comment as port's (#222)
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.
2026-10-09 21:03:57 +13:00
MrBruh cb66f9ce72 Merge pull request #222 from eltanschauung/fix/profile-keyboard-routing
Fix Profiles opening Multiplayer after profile creation
2026-10-09 21:03:42 +13:00
MrBruh 1254c1661c Merge pull request #142 from kirklandsig/menus-no-save-file-read-each-frame
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.
2026-10-09 21:03:21 +13:00
MrBruh eb1b48992c Android quit only, comments as port's (#183)
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.
2026-10-09 21:02:55 +13:00
MrBruh b292483f35 Merge pull request #183 from FernandolDev/fix/android-main-menu-quit
Android: fix main menu quit
2026-10-09 21:02:50 +13:00
MrBruh c5e4f87b54 Merge pull request #195 from ChupathingyCE/upstream-fix/update-chunk-size
Updater: fail a download whose chunk size is not a number
2026-10-09 20:48:18 +13:00
MrBruh e8b3e07371 Merge pull request #194 from ChupathingyCE/upstream-fix/smbios-bounds
Windows: read an SMBIOS structure only within the firmware table
2026-10-09 20:48:18 +13:00
MrBruh 91351a603d Merge pull request #193 from ChupathingyCE/upstream-fix/env-switch-values
HALO_NO_VSYNC=0 and similar switches no longer turn their setting on
2026-10-09 20:48:18 +13:00
MrBruh f89a6fad45 Merge pull request #192 from ChupathingyCE/upstream-fix/map-name-brackets
Join games on maps whose names have [ ] ( ) or +
2026-10-09 20:48:18 +13:00
MrBruh 772e3423ae Merge pull request #176 from ChupathingyCE/upstream-fix/vertex-shader-once
Compile a vertex shader that fails only once
2026-10-09 20:48:18 +13:00
MrBruh ce31f22e6b Merge pull request #175 from ChupathingyCE/upstream-fix/coop-cluster-negative
Take no co-op activating cluster below zero from the host
2026-10-09 20:48:18 +13:00
MrBruh 496fc4438f Merge pull request #174 from ChupathingyCE/upstream-fix/gametype-edit-none
Choose no gametype in Edit Gametypes when there are none
2026-10-09 20:48:18 +13:00
MrBruh b2d325efac Merge pull request #137 from eltanschauung/feat/mouse-sensitivity-pr
Make low mouse sensitivity increments less restrictive
2026-10-09 20:48:18 +13:00
MrBruh 46c0a8dbfb Merge pull request #138 from eltanschauung/fix/pickup-glass-floor-order-pr
Fix powerup rendering over transparent glass floors
2026-10-09 20:48:18 +13:00
MrBruh 7d821587bf Visibility tests' own scratch query, help text comment
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.
2026-10-09 20:09:43 +13:00
MrBruh f1b5c32216 Merge pull request #49 from HiIAmMoot/emulator-tools
Run the Android build on the x86 emulator, build it from WSL, add debug.solo_game
2026-10-09 20:02:39 +13:00
MrBruh 4fe5406c41 Merge pull request #185 from xshxdex98/upstream-spinner-descriptions-index
Spinner descriptions' text indices above the Custom Edition maps'
2026-10-09 20:02:39 +13:00
MrBruh 556841cc18 Merge pull request #171 from innerhat-dev/help-text-custom-icon
Skip a help text custom icon instead of halting on a missing button icon
2026-10-09 20:02:39 +13:00
MrBruh 9342ee83c8 Merge pull request #184 from eltanschauung/fix/audio-retirement-flush
Prevent audio refill after voice retirement or stream cancellation
2026-10-09 20:02:39 +13:00
MrBruh ee93ab9c82 Merge pull request #166 from kirklandsig/pr/dry-voice
Sound: a stream that ran dry plays again once it has data
2026-10-09 20:02:39 +13:00
TwistedFlogandClaude Opus 5.5 a27c205c32 Visibility test results are copied a frame later, all at once (#150)
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>
2026-10-09 20:02:39 +13:00
MrBruh 94e91c5dc2 Merge pull request #165 from kirklandsig/pr/mirror-unsync
Android renderer: no unsynchronized mirror write while a buffer copy is pending
2026-10-09 20:02:39 +13:00
Mootjuh a510083b49 Add a profiling build: CPU recordings for Perfetto and a report tool 2026-10-09 08:00:11 +02:00
eltanschauung 051b3aa695 Suppress held Enter across typing mode changes 2026-10-09 01:56:44 -04:00
MrBruh 378cb0d427 Refused widget functions fail, resampler comment's figure
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.
2026-10-09 18:52:31 +13:00
eltanschauung b22dacb026 Fix Profiles routing after creating a profile 2026-10-09 01:51:37 -04:00
MrBruh a43f2de050 Merge pull request #221 from startupfoundry/audio-mixer-perf
Mixer: resampler four taps at a time, same output
2026-10-09 18:46:28 +13:00
MrBruh 82afea59f4 Merge pull request #220 from startupfoundry/gamepad-log
Linux: the log names each gamepad the game opens
2026-10-09 18:46:28 +13:00
MrBruh 1c7f5406b7 Merge pull request #219 from startupfoundry/trace-marker-deck-note
Linux: the trace_marker refusal does nothing on the Steam Deck (docs)
2026-10-09 18:46:28 +13:00
MrBruh cfa1a488ec Merge pull request #216 from eltanschauung/fix/create-game-teardown-delay
Remove redundant 1000ms hang on Create Game options
2026-10-09 18:46:28 +13:00
MrBruh f30db75e49 Merge pull request #211 from startupfoundry/grenade-throw-sync
Network: a client no longer throws grenades it doesn't have right after spawning
2026-10-09 18:46:28 +13:00
MrBruh 86ddd22b29 Merge pull request #210 from startupfoundry/teleport-remote-player
Network: a player who used a teleporter no longer appears stuck on the pad they left
2026-10-09 18:46:28 +13:00
MrBruh cbc8da75f1 Merge pull request #209 from startupfoundry/hit-report-rejections
Network: a client's grenade shock waves are no longer refused (about half its grenade damage)
2026-10-09 18:46:28 +13:00
MrBruh dfc6ac5f9f Merge pull request #207 from ChupathingyCE/upstream-fix/lastmpmp-buffer
lastmpmp.txt written from a buffer of the size written
2026-10-09 18:46:28 +13:00
MrBruh 04f2610f8b Merge pull request #205 from ChupathingyCE/upstream-fix/gl-upload-bytes
GL: vertex and index uploads read only their own bytes
2026-10-09 18:46:28 +13:00
MrBruh dc9b3973f0 Merge pull request #204 from ChupathingyCE/upstream-fix/stb-vorbis-codebooks
stb_vorbis: refuse code books of no dimensions, compare large powers as floats
2026-10-09 18:46:28 +13:00
MrBruh 3bb4866fbf Merge pull request #203 from ChupathingyCE/upstream-fix/widget-menu-functions
Widgets: a map's widgets don't run the main menu's profile or settings functions
2026-10-09 18:46:28 +13:00
MrBruh 9407db4bc3 Merge pull request #202 from ChupathingyCE/upstream-fix/string-list-size
String lists: a one-byte string is not terminated before its data
2026-10-09 18:46:28 +13:00
MrBruh c45f6aefc7 Merge pull request #201 from ChupathingyCE/upstream-fix/tag-name-length
Validator: tag names no longer than a tag path
2026-10-09 18:46:28 +13:00
MrBruh 71f7fd7664 Merge pull request #200 from ChupathingyCE/upstream-fix/tag-name-copies
Bounded copies and formats of tag names
2026-10-09 18:46:27 +13:00
MrBruh f1b3829152 Merge pull request #196 from ChupathingyCE/upstream-fix/wide-format-bounds
Wide printf: bound conversion specifications
2026-10-09 18:46:27 +13:00
MrBruh f5fc3d76ef Scoreboard speaker just right of the name 2026-10-09 18:00:38 +13:00
MrBruh e7e5777a88 Voice chat list in name tag colours
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.
2026-10-09 17:55:00 +13:00
Paul Hontz d80590c930 Limiter: look for the smallest gain only while limiting
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.
2026-10-09 00:51:21 -04:00
Paul Hontz 81b198b881 Resample four taps at a time
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.
2026-10-09 00:51:21 -04:00
Paul Hontz c916c1c838 Test the mixer on known signals
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").
2026-10-09 00:51:07 -04:00
MrBruh 348f8ad4f8 Speaker beside talking players' name tags
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).
2026-10-09 17:44:55 +13:00
MrBruh b08a95b3ba Smaller voice chat list
The in-game list of who talks is drawn at 0.8 times the HUD's text, its speakers with it.
2026-10-09 17:36:47 +13:00
MrBruh b2990d420d Leaving during a votekick passes it
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.
2026-10-09 17:32:15 +13:00
MrBruh a7c8eac379 Lucide speaker icons for voice chat
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).
2026-10-09 17:11:42 +13:00
Paul Hontz 988786d2c2 Linux: the log names each gamepad the game opens
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.
2026-10-08 23:24:48 -04:00
Paul Hontz 063c60381a Linux README: playing on a Steam Deck
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.
2026-10-08 23:21:35 -04:00
Paul Hontz 444877f06d Linux: Steam's on-screen keyboard comes up for the menus' text fields
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).
2026-10-08 23:21:35 -04:00
Paul Hontz 041211229a Linux: the portable build starts on SteamOS and older distributions
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.
2026-10-08 23:21:35 -04:00
Paul Hontz f39f7df586 Linux: the trace_marker refusal does nothing on the Steam Deck
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.
2026-10-08 23:03:30 -04:00
MrBruh 75811cc2a3 Co-op setup options in categories
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.
2026-10-09 15:44:49 +13:00
MrBruh 887e2c11c4 Vote kick in Server Setup
Teamplay Options has a VOTE KICK row (on by default) under the voice chat rows, which sets network.votekick for the host's games, as they set theirs.
2026-10-09 15:01:36 +13:00
MrBruh 84cf2f8f19 Include stdio.h for snprintf in the voice code 2026-10-09 14:36:14 +13:00
MrBruh f97b33391b Voice chat
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.
2026-10-09 14:20:57 +13:00
eltanschauung 3a6e146fdb Remove redundant 1000ms hang on Create Game options 2026-10-08 17:25:37 -04:00
MrBruh 3b20ef879a Votes to kick
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.
2026-10-09 10:07:51 +13:00
Stefan MaierandClaude Opus 5.5 d18339be66 VR roadmap: up to date with OpenCE, network version 24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:29:34 +02:00
Stefan MaierandClaude Opus 5.5 e28767b5e9 Merge merge/upstream-2026-10-08: OpenCE network version 24, Custom Edition maps, positional stereo sound
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:29:34 +02:00
Stefan MaierandClaude Opus 5.5 a167de3873 Merge upstream 2026-10-08: network version 24, Custom Edition maps, positional stereo sound, fixes
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>
2026-10-08 21:01:08 +02:00
Paul Hontz 1f87b4ff63 A client's copy of a unit carries no grenades until the host says
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.
2026-10-08 13:29:28 -04:00
Paul Hontz 22fe5628b6 A remote player the host teleported stays where they landed
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.
2026-10-08 13:23:09 -04:00
Paul Hontz 792711ca57 A grenade's shock wave is dealt with its explosion
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.
2026-10-08 11:42:32 -04:00
Milenko 40bb5ea4a6 Map strings used as formats only with the arguments the game gives
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.
2026-10-08 08:36:36 -04:00
Milenko 5ec67a8db4 String lists: a string of one byte is not terminated before its data
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.
2026-10-08 08:36:10 -04:00
Milenko ea79111994 Bounded copies and formats of tag names
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.
2026-10-08 08:35:50 -04:00
Milenko bcb0e5e632 Scoreboard: bounded copies of map strings into its buffers
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.
2026-10-08 08:35:42 -04:00
Milenko c1694f607f HUD item messages: map text formatted only when it takes the count, and bounded
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.
2026-10-08 08:35:30 -04:00
Milenko debf9ca7da Validator: tag names no longer than a tag path
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.
2026-10-08 08:34:36 -04:00
Milenko 764044d4f2 GL: vertex and index uploads read only their own bytes
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.
2026-10-08 08:34:10 -04:00
Milenko 33804e0fe6 stb_vorbis: refuse code books of no dimensions, and compare large powers as floats
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.
2026-10-08 08:33:44 -04:00
Milenko 99257442f7 Wide printf: bounded conversion specifications
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.
2026-10-08 08:33:28 -04:00
Milenko 6f0f2992cb Widgets: a game map's run none of the main menu's profile functions
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.
2026-10-08 08:33:20 -04:00
Milenko 22bb651e68 Write the last multiplayer map's name from a buffer of the size written
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.
2026-10-08 08:31:13 -04:00
Milenko 5aad51ac18 Refuse a host's map name that is a Windows device name
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.
2026-10-08 08:30:56 -04:00
Milenko 9fa925c3b0 Join games on maps whose names have [ ] ( ) or +
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.
2026-10-08 08:29:26 -04:00
Milenko cc820fd846 Switches such as HALO_NO_VSYNC=0 no longer turn their setting on
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.
2026-10-08 08:29:04 -04:00
Milenko 2b89960b0a Fail a download whose chunk size is not a number
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.
2026-10-08 08:28:30 -04:00
Milenko a0761c4fd1 Read an SMBIOS structure only within the firmware table
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.
2026-10-08 08:28:30 -04:00
MrBruh 73dc01d09a Stereo sounds in the world fade as mono ones do
#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.
2026-10-08 23:35:08 +13:00
MrBruh 274282c184 Merge pull request #191 from meowsandstuff/audio/upstream-distancefade-fix 2026-10-08 23:31:48 +13:00
meowsandstuff 0e220b8831 Fix positional stereo audio distance attenuation 2026-10-08 21:19:46 +11:00
Mootjuh 0ca45fb4e7 Build the Android port from WSL 2026-10-08 11:36:39 +02:00
Mootjuh 98a791a7d0 Track writes by page hashes under ARM translation 2026-10-08 11:36:39 +02:00
MrBruh 2d2348cd7d Reverberate and muffle stereo sounds in the world
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.
2026-10-08 22:21:13 +13:00
MrBruh a14401ac7d Pan stereo sounds towards where they are
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.
2026-10-08 21:35:40 +13:00
MrBruh 71bf206bd0 Sound tags from files over a map's, and a 16 MB sound cache
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.
2026-10-08 20:22:16 +13:00
eltanschauung f502268853 Fix keyboard crouch walking 2026-10-08 03:12:19 -04:00
Paul Hontz 33c2baa286 Interpolation: a scripted camera that moves with an object drawn with the object
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
2026-10-08 01:13:17 -04:00
Paul Hontz aa7b107571 Interpolation: the camera's previous snapshot a tick back after a long frame
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.
2026-10-08 01:13:02 -04:00
MrBruh 544ee497b6 Resampler comments as the file writes them
#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.
2026-10-08 17:19:18 +13:00
MrBruh 895d4cb59e Merge pull request #186 from meowsandstuff/audio/sinc-passband-pr 2026-10-08 17:14:34 +13:00
eltanschauung b3a6490f39 Order transparent models next to planar BSP glass 2026-10-08 00:11:54 -04:00
eltanschauung c7c4feab41 Use native transparent part links for enclosed energy at map load 2026-10-07 23:49:07 -04:00
MrBruh 5b3282d465 Merge pull request #188 from xshxdex98/upstream-hud-no-secondary-elements 2026-10-08 16:46:20 +13:00
xshxdex98 57038d51c9 Weapon HUD: no secondary magazine drawn for a weapon without one
#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.
2026-10-07 22:27:32 -05:00
meowsandstuff 9ec3d47cea Preserve source treble in the native sinc resampler 2026-10-08 13:55:24 +11:00
xshxdex98 fc616b8c23 Spinner descriptions' text indices above the Custom Edition maps'
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.
2026-10-07 21:45:46 -05:00
eltanschauung 3174d332fe Retire replaced looping voices and preserve completed fade endpoints 2026-10-07 22:44:06 -04:00
eltanschauung 78566f5bf6 Stop retired audio voices and cancel SDL packets without refill 2026-10-07 22:36:43 -04:00
MrBruh 6d840473da sv_say: leave out '|'
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.
2026-10-08 15:35:54 +13:00
MrBruh 11b937653c Merge pull request #182 from xshxdex98/upstream-script-sv-say-end-game 2026-10-08 15:34:13 +13:00
MrBruh 66541c670d Merge pull request #181 from xshxdex98/upstream-particle-missing-sequence 2026-10-08 15:34:12 +13:00
MrBruh 1588addbb1 Merge pull request #180 from xshxdex98/upstream-hud-secondary-magazine 2026-10-08 15:34:10 +13:00
MrBruh 3ef06395be Merge pull request #179 from xshxdex98/upstream-script-none-object 2026-10-08 15:34:08 +13:00
MrBruh a1fdbacb8c Merge pull request #157 from xshxdex98/upstream-menu-max-players 2026-10-08 15:34:05 +13:00
Fernando dc71f6820b Fix Android main menu quit 2026-10-07 23:30:29 -03:00
MrBruh 81be9a5dfa Barrier before copying the visibility counters
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).
2026-10-08 15:05:30 +13:00
MrBruh e6c970a099 Merge pull request #122 from startupfoundry/es-visibility-snapshots 2026-10-08 15:04:33 +13:00
MrBruh 8fca9ef427 Mark the short camera track fallback as the port's
#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.
2026-10-08 15:04:27 +13:00
MrBruh 078901a907 Merge pull request #152 from innerhat-dev/following-camera-track 2026-10-08 15:02:13 +13:00
MrBruh 388cb86aa9 Read open's mode only when it is passed
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.
2026-10-08 15:02:09 +13:00
MrBruh 63a758658c Merge pull request #128 from startupfoundry/steamos-trace-marker 2026-10-08 15:00:54 +13:00
MrBruh f4bad008dc Merge pull request #127 from startupfoundry/perf-guest-memcmp 2026-10-08 15:00:54 +13:00
xshxdex98 3fc953136f Scripts: Halo PC's sv_say and sv_end_game for Custom Edition maps
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.
2026-10-07 20:55:11 -05:00
xshxdex98 1f7c5c9d5e Particles: a bitmap without the sequence drawn draws nothing
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).
2026-10-07 20:52:23 -05:00
xshxdex98 f7301fcd06 Weapon HUD: the secondary magazine's overlays and warnings are its own
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.
2026-10-07 20:51:44 -05:00
Milenko a601ad7119 Scripts: take none as an object name, as Halo PC's compiler does
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.
2026-10-07 20:49:27 -05:00
MrBruh d0b9049e2d Android audio buffer: preprocessor at column 0
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.
2026-10-08 14:29:23 +13:00
Daniel 423677a14b Give Android audio callback more scheduling headroom
(cherry picked from commit f158218aa6daf8c2cb71852ca1f82e36a4a5ee11)
2026-10-08 14:28:15 +13:00
MrBruh 65a6bb7edd Grow the powerups' render spheres in a port file
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.
2026-10-08 14:15:15 +13:00
eltanschauung 6694d6180e Fix Escape restarting the level from the console 2026-10-07 21:13:27 -04:00
MrBruh ec23cfcf24 Merge pull request #117 from eltanschauung/fix/powerup-render-bounds 2026-10-08 14:08:53 +13:00
MrBruh ebe6917924 Bound the HUD's threshold lookup by its asset count
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.
2026-10-08 14:08:19 +13:00
MrBruh 563f630ea3 Merge pull request #114 from eltanschauung/fix/health-meter-thresholds 2026-10-08 14:06:40 +13:00
MrBruh a6337494d3 Re-patch SDL's mouse listener when the patch changes
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.
2026-10-08 14:06:37 +13:00
MrBruh 0cbad22fc7 Merge pull request #156 from FernandolDev/fix/android-relative-mouse 2026-10-08 14:04:04 +13:00
MrBruh 4646aab649 Merge pull request #118 from ChupathingyCE/pr/uptime-24-days 2026-10-08 14:04:04 +13:00
MrBruh f59c4c15d1 Merge pull request #120 from ChupathingyCE/pr/script-object-lists 2026-10-08 14:04:04 +13:00
MrBruh bad6534e0c Forget the object bounds cache in the game state load procs
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.
2026-10-08 13:46:02 +13:00
MrBruh 7e7095b046 Merge pull request #169 from addisonbair/asset-free-tests 2026-10-08 13:42:01 +13:00
MrBruh 11117d7c28 Network version 24
#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.
2026-10-08 13:22:39 +13:00
MrBruh ffa4a8ad04 Merge pull request #173 from TwistedFlog/custom-edition-maps
Custom Edition Discrepancy Fixes
2026-10-08 13:18:11 +13:00
Milenko 8d4fe32c0c Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	port/linux/include/halo_port_capacity.h
2026-10-07 20:15:42 -04:00
MrBruh ff47e47ad6 Correct negative particle radii as maps load
#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.
2026-10-08 13:00:37 +13:00
MrBruh 9e9a1bbf89 Merge pull request #148 from meowsandstuff/error-screen-build-info
Identify native builds and show recent errors on halt screen
2026-10-08 12:46:36 +13:00
MrBruh 760bc907af Merge pull request #151 from innerhat-dev/point-physics-radius
Treat a point physics radius below zero as zero
2026-10-08 12:46:34 +13:00
TwistedFlog 6b1d81c7b1 Custom Edition Map Discrepancy Fixes
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
2026-10-07 17:45:15 -06:00
Milenko 57404d5685 Choose no gametype in Edit Gametypes when there are none
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.
2026-10-07 19:28:16 -04:00
Milenko 577b45d50c Compile a vertex shader that fails only once
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.
2026-10-07 19:27:56 -04:00
Milenko 44da50e411 Take no co-op activating cluster below zero from the host
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.
2026-10-07 19:26:59 -04:00
justinfilip 626c42df60 Skip a help text custom icon instead of halting on a missing button icon. 2026-10-07 17:53:37 -05:00
Addison Bair 8ebca844a0 Asset-free regression tests: tools/harness
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.
2026-10-07 22:11:58 +00:00
Addison Bair 04713926d1 Pass over far objects on a packed copy of their bounding spheres
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%.
2026-10-07 22:11:47 +00:00
MrBruh 34e2d4fdd8 Network version 23
#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.
2026-10-08 11:02:06 +13:00
MrBruh 6e015a75ad Merge pull request #168 from xshxdex98/upstream-coop-bringto
Co-op: bringto brings every player to the host
2026-10-08 10:56:51 +13:00
MrBruh bfe812fe10 Merge pull request #164 from xshxdex98/upstream-vehicle-homes
Bring back every vehicle a map places, not the first 64
2026-10-08 10:56:51 +13:00
MrBruh 1617f43fbc Merge pull request #163 from xshxdex98/upstream-client-outside-world
Leave the host's falling bipeds to the host on a client
2026-10-08 10:56:50 +13:00
MrBruh bad4bf455b Merge pull request #162 from xshxdex98/upstream-coop-enemies-cap
Grow a co-op squad to 8 times its size at most, per player
2026-10-08 10:56:49 +13:00
MrBruh 5bf891fd80 Merge pull request #158 from xshxdex98/upstream-oddball-no-spawn
Place the oddball at a starting location on a map with no ball spawn
2026-10-08 10:56:48 +13:00
MrBruh db9b2f4a5b Merge pull request #155 from xshxdex98/upstream-ce-map-version
Refuse a Custom Edition map of another version than the host's
2026-10-08 10:56:47 +13:00
MrBruh 2e8a609e99 Merge pull request #154 from xshxdex98/upstream-ce-tool-limits
Custom Edition maps: blocks past the Xbox tools' limits kept whole
2026-10-08 10:56:47 +13:00
MrBruh 36411f5dea Merge pull request #153 from xshxdex98/upstream-ce-linear-pitch
Custom Edition maps: linear bitmaps of any row length drawn
2026-10-08 10:56:46 +13:00
MrBruh f41c8e977a Merge pull request #145 from addisonbair/widget-pools
Fix invisible plasma bolts in large network games
2026-10-08 10:56:45 +13:00
MrBruh ad26d16a28 Merge pull request #121 from innerhat-dev/coop-follow-camera
Keep a co-op follow camera valid when the host looks up or down
2026-10-08 10:56:44 +13:00
MrBruh 314f576515 Never free NULL in the Custom Edition loader
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.
2026-10-08 10:20:47 +13:00
xshxdex98 508a9a644b Co-op: bringto brings every player to the host
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.
2026-10-07 15:42:41 -05:00
Kirk LazarusandClaude 17b63c8ac3 Sound: a stream that ran dry plays again once it has data
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
2026-10-07 16:16:16 -04:00
Kirk LazarusandClaude 343eef15cc Android renderer: no unsynchronized mirror write while a buffer copy is pending
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
2026-10-07 16:16:14 -04:00
xshxdex98 8b5671b16e Bring back every vehicle a map places, not the first 64
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.
2026-10-07 15:07:23 -05:00
xshxdex98 4935052857 Leave the host's falling bipeds to the host on a client
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.
2026-10-07 14:59:38 -05:00
xshxdex98 6b4200e189 Grow a co-op squad to 8 times its size at most, per player
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.
2026-10-07 14:59:03 -05:00
xshxdex98 70a28f42ee Place the oddball at a starting location on a map with no ball spawn
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.
2026-10-07 14:20:02 -05:00
xshxdex98 955bbabd83 Start Server Setup at the most players, not two
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.
2026-10-07 14:19:35 -05:00
Fernando 87c953cfd1 Pass captured relative mouse input on Android 2026-10-07 15:49:13 -03:00
xshxdex98 9d309f8d75 Refuse a Custom Edition map of another version than the host's
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.
2026-10-07 13:13:29 -05:00
xshxdex98 9f57f77ed2 Custom Edition maps: blocks past the Xbox tools' limits kept whole
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.
2026-10-07 12:54:25 -05:00
xshxdex98 303aa43912 Custom Edition maps: linear bitmaps of any row length drawn
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.
2026-10-07 12:54:25 -05:00
justinfilip 8fccabed25 Keep a following camera valid when a track has fewer than four control points. 2026-10-07 11:01:36 -05:00
justinfilip f90a8f3bfd Treat a point physics radius below zero as zero. 2026-10-07 10:54:34 -05:00
Stefan MaierandClaude Opus 5.5 ff9cb2b55a VR roadmap: up to date with OpenCE, network version 21
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:55:36 +02:00
Stefan MaierandClaude Opus 5.5 1109660bbe Merge merge/upstream-2026-10-07: OpenCE network version 21, co-op and netcode fixes, a VR crash fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:55:26 +02:00
Stefan MaierandClaude Opus 5.5 c1b2d34fce VR: no first-person lookups of a weapon or grenade deleted since the tick
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>
2026-10-07 15:50:47 +02:00
saulo benigno d412c14f18 Merge branch 'main' into feature/in-game-settings-menu 2026-10-07 09:56:21 -03:00
Codex 48342f528f Identify native builds and show recent errors on halt screen 2026-10-07 23:34:01 +11:00
MrBruh 4e8ed2f196 Tell a joining player which map they are missing
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.
2026-10-07 23:56:33 +13:00
Stefan MaierandClaude Opus 5.5 448a5d5353 Merge upstream 2026-10-07: network version 21, co-op and netcode fixes, renderer speed-ups, hardening
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>
2026-10-07 12:42:13 +02:00
MrBruh ce41b41d78 List Custom Edition maps as custom singleplayer and multiplayer maps
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.
2026-10-07 22:40:09 +13:00
MrBruh c3657ae91b Harden map loading against hostile maps
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
2026-10-07 20:54:35 +13:00
MrBruh 483a465a5b Remove OpenSauce map support and refuse OpenSauce maps 2026-10-07 19:48:43 +13:00
RockBooch 1860a94813 All animation fixes only applied to cyborg and cyborg_mp
All animation fixes only applied to cyborg and cyborg_mp
2026-10-07 02:16:40 -04:00
eltanschauung 2875b505fc Merge remote-tracking branch 'upstream/main' into resolve/pr117-2026-10-07
# Conflicts:
#	source/cache/cache_files.c
2026-10-07 02:09:33 -04:00
MrBruh 6641c4570c Check Custom Edition maps in map_validate 2026-10-07 19:02:06 +13:00
MrBruh 30cd63678a List Custom Edition maps in the menus 2026-10-07 19:02:06 +13:00
MrBruh f823a18d59 Load, check and run Halo Custom Edition and OpenSauce maps 2026-10-07 19:02:01 +13:00
MrBruh f66885861a Give the desktop builds a 512 MB memory window and texture cache 2026-10-07 19:00:29 +13:00
Addison Bair 35f643f376 Grow the widget and light volume pools for large games
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.
2026-10-07 05:23:34 +00:00
RockBooch d07ef9d92b Merge remote-tracking branch 'upstream/main' into cyborg-animation-enhances 2026-10-06 23:14:16 -04:00
RockBooch a2166da352 Update units.c
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)
2026-10-06 22:53:03 -04:00
MrBruh 76b1898ee1 Visibility tests read on the CPU no longer stall for the GPU
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.
2026-10-07 15:52:23 +13:00
MrBruh f876a526df Rebuild the water's mip texture only when a level changed
From TwistedFlog's PR #104: the ripple map's levels were copied and the rest mipmapped on every bind, more than once a frame on a30.
2026-10-07 15:47:42 +13:00
MrBruh 43dc11f224 Skip a cloaked model's skinned draw without node matrices
From TwistedFlog's PR #104: with the rasterizer's memory pool full, a partially cloaked model was skinned from NULL node matrices.
2026-10-07 15:27:17 +13:00
MrBruh eb74c9f792 Merge pull request #126 from startupfoundry/perf-fewer-gl-calls
About 40% fewer GL calls a frame
2026-10-07 15:20:19 +13:00
MrBruh 5f1abf161c Survive and report a broken lights array 2026-10-07 15:05:11 +13:00
Kirk LazarusandClaude 7e8c8b1d56 Menus: no save file read each frame
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>
2026-10-06 21:14:15 -04:00
MrBruh 07302a866f Co-op-only garbage throttle, network version 21 2026-10-07 13:43:50 +13:00
MrBruh e7d8b9fbe3 Merge PR #136 2026-10-07 13:38:30 +13:00
MrBruh 929ae2d5af Merge PR #135 2026-10-07 13:38:30 +13:00
MrBruh 93846725e5 Merge PR #134 2026-10-07 13:38:30 +13:00
MrBruh 633492e4d0 Merge PR #133 2026-10-07 13:38:30 +13:00
MrBruh e4c83398f2 Merge PR #132 2026-10-07 13:38:30 +13:00
MrBruh 54447c7877 Merge PR #131 2026-10-07 13:38:30 +13:00
MrBruh 28dabab5ba Merge PR #130 2026-10-07 13:38:30 +13:00
MrBruh c69c8101ed Merge PR #129 2026-10-07 13:38:30 +13:00
MrBruh 54abd34493 Third hardening round 2026-10-07 13:20:54 +13:00
MrBruh d7efd3934a Validate map tags before loading 2026-10-07 12:01:09 +13:00
eltanschauung 1fe6836960 Add finer mouse sensitivity steps 2026-10-06 18:44:11 -04:00
Stefan MaierandClaude Opus 5.5 c3e8220792 README: what can be played (campaign, co-op, LAN, online, bots, maps, cross-play)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:22:12 +02:00
xshxdex98 7e07506327 Multiplayer: a player joining in progress starts at zero, not with the score of the slot's last player
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.
2026-10-06 16:14:31 -05:00
Stefan MaierandClaude Opus 5.5 2670b3589f Merge feature/release-ready: README for players, third-party notices, release assets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:06:46 +02:00
Stefan MaierandClaude Opus 5.5 a950de4b2b Release-ready: README for players, third-party notices, issue template
- 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>
2026-10-06 23:05:29 +02:00
xshxdex98 b214dc1728 Host performance: the garbage collection no longer runs every tick with many enemies
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.
2026-10-06 15:59:43 -05:00
Stefan MaierandClaude Opus 5.5 1984996d04 VR roadmap: comfort, buttons, import, installer and the new name done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:55:46 +02:00
xshxdex98 f20689db44 Co-op: only the host's crossing switches the BSP, and brings the team along
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.
2026-10-06 15:55:44 -05:00
xshxdex98 4d35b9cb10 Netcode: killing blows reach every client, and a player's body dies as the host's did
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.
2026-10-06 15:55:43 -05:00
xshxdex98 9d8e471f78 Netcode: a body come to rest reaches every client, and none hangs in the air
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.
2026-10-06 15:55:43 -05:00
xshxdex98 95bf07a774 Co-op: a gate one player passes brings the rest of the team
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).
2026-10-06 15:55:43 -05:00
Stefan MaierandClaude Opus 5.5 93b96cf5b8 Merge feature/rename-ringfall: Ringfall: Mjolnir VR, comfort and buttons, Frame installer, release signing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:55:25 +02:00
xshxdex98 531e48ce13 Co-op: the campaign has the scoreboard, of names and pings
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.
2026-10-06 15:55:09 -05:00
xshxdex98 0c9f2fcd81 Co-op: a respawn starts behind its teammate, not at the level's start
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.
2026-10-06 15:54:55 -05:00
Stefan MaierandClaude Opus 5.5 f68db3b03e VR: the flashlight by tapping the head taken out again
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>
2026-10-06 22:53:48 +02:00
Stefan MaierandClaude Opus 5.5 bd08baacac Steam Frame installer: the shortcut added by Steam itself
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>
2026-10-06 22:32:49 +02:00
Stefan MaierandClaude Opus 5.5 f39126e019 Steam Frame installer: Steam found by its pid file, an install log
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>
2026-10-06 22:28:50 +02:00
Stefan MaierandClaude Opus 5.5 f649bd1a3e Release as Ringfall: Mjolnir VR
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>
2026-10-06 22:16:31 +02:00
Stefan Maier 719563a48c Merge branch 'feature/release-install' into test/comfort-release 2026-10-06 22:08:41 +02:00
Stefan MaierandClaude Opus 5.5 d4c1fbe9a7 Release: Steam Frame installer, release signing, Frame import kept
- 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>
2026-10-06 22:06:21 +02:00
Paul Hontz 7809af0864 Windows: posix_trace_marker.c left to Linux, as the other posix_*.c
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.
2026-10-06 15:20:12 -04:00
Paul Hontz db44dba833 Linux: the GPU driver kept from the kernel's trace_marker (HALO_GPU_TRACE_MARKERS)
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.
2026-10-06 15:10:09 -04:00
Paul Hontz 1d3b8184c3 Renderer: a declaration's vertex array kept for its streams, not looked up each draw
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.
2026-10-06 14:46:35 -04:00
Paul Hontz b34e56fa0c Guest runtime: memcmp eight bytes at a time 2026-10-06 13:27:06 -04:00
Paul Hontz ef6ea26fd8 Renderer: a draw's changed texture units bound in one call (desktop GL) 2026-10-06 13:26:57 -04:00
Paul Hontz 9c6ed2481e Renderer: a vertex array object for each vertex layout (desktop GL) 2026-10-06 13:26:42 -04:00
Paul Hontz bf4d5d8faf Renderer: a sampler object for each sampler state, bound rather than changed 2026-10-06 13:26:17 -04:00
saulo benigno d2e3fcfea6 Merge branch 'main' into feature/high-res-ui-buttons 2026-10-06 12:58:15 -03:00
saulo benigno ec28d223ff Merge branch 'main' into feature/in-game-settings-menu 2026-10-06 12:49:08 -03:00
justinfilip 140f0298d7 Keep a co-op follow camera valid when the host looks up or down.
The upright direction is not square to that look, and the camera check halted the game.
2026-10-06 10:42:57 -05:00
Paul Hontz 3ddec63920 The visibility tests' counts on ES without waiting for the GPU
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.
2026-10-06 11:41:39 -04:00
Milenko cdfb64e831 Scripts: room for a tick's object lists of a heavily scripted map
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)
2026-10-06 09:46:24 -04:00
Milenko e9746abbee Hosting on machines up for more than 24.8 days
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.
2026-10-06 09:29:39 -04:00
MrBruh 76addf661f Second hardening round 2026-10-07 00:01:28 +13:00
MrBruh 7ea610770c Gamepad layout preview in profile settings 2026-10-06 22:41:37 +13:00
MrBruh 22249cf192 Windows crash reports 2026-10-06 20:32:36 +13:00
RockBooch abae0d0928 Update bipeds.c
Turn around while throwing grenade no longer locks until animation is over
2026-10-06 02:47:45 -04:00
RockBooch fd7f2d30b6 Update units.c
Blend leg directions and ease the stop during grenade throws
2026-10-06 02:27:29 -04:00
eltanschauung 16b5436796 Test powerup mesh bounds, fallback cases and frustum edge sweeps 2026-10-06 01:29:53 -04:00
eltanschauung 77efb01b00 Grow culling spheres with enlarged objects 2026-10-06 01:29:48 -04:00
eltanschauung 201874d783 Derive conservative render spheres from rigid powerup meshes 2026-10-06 01:29:42 -04:00
eltanschauung 859e423cbc Derive powerup render bounds when loading map tags 2026-10-06 01:29:35 -04:00
RockBooch d25fa0fbaf Merge remote-tracking branch 'upstream/main' into cyborg-animation-enhances 2026-10-05 23:40:45 -04:00
eltanschauung 9990227153 Merge branch 'main' into fix/health-meter-thresholds 2026-10-05 23:22:44 -04:00
RockBooch 29a2a1945a Merge remote-tracking branch 'upstream/main' into cyborg-animation-enhances 2026-10-05 23:13:09 -04:00
RockBooch 102e093436 Update units.c
Keep legs moving during grenade throws
2026-10-05 22:20:57 -04:00
eltanschauung be69b5abcb Generate categorical health thresholds and test real GPU rendering 2026-10-05 20:57:53 -04:00
eltanschauung a56488379c Store exact segment thresholds separately from health artwork 2026-10-05 20:57:19 -04:00
eltanschauung c40d7b22fc Read discrete health thresholds independently of filtered HUD artwork 2026-10-05 20:56:45 -04:00
eltanschauung 0db37662aa Test rebindable screenshot input and real GL-to-PNG capture 2026-10-05 14:42:15 -04:00
eltanschauung 8d2d81179e Document Screenshot controls and timestamped PNGs 2026-10-05 14:41:35 -04:00
eltanschauung 15566ec76b Define the rebindable Screenshot action 2026-10-05 14:41:08 -04:00
eltanschauung c6959530d5 Expose Screenshot below Pause Menu in Controls Setup 2026-10-05 14:40:23 -04:00
eltanschauung 58a6bd2f5e Add rebindable PNG Screenshot action, default F10 2026-10-05 14:39:42 -04:00
saulo benigno 423d0104d5 Merge branch 'main' into feature/in-game-settings-menu 2026-10-04 23:17:48 -03:00
saulo benigno 3f2795a8ee Merge branch 'main' into feature/high-res-ui-buttons 2026-10-04 22:57:59 -03:00
Fadi DerouicheandClaude Opus 5 f2aec7bee3 Draw the reflection at the display's pixel density
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>
2026-10-04 21:32:34 +01:00
Mootjuh 55c43670a4 Merge upstream main 2026-10-04 08:41:04 +02:00
Mootjuh 1b585ba499 Keep the mutation script out of the PR 2026-10-04 08:39:14 +02:00
Mootjuh d1da19316e Trim comments to the repository's style 2026-10-04 08:39:12 +02:00
Mootjuh 38a85a9a1a Let a touch drag leave a PC screen's button bar 2026-10-04 08:04:07 +02:00
Mootjuh 124a3cc584 Stop a touch drag at a PC list's first and last usable row 2026-10-04 00:50:13 +02:00
Mootjuh 25bc2f5ae7 Merge upstream main 2026-10-04 00:20:59 +02:00
saulo benigno 31e54eba1b Add in-game settings menu 2026-10-03 19:07:13 -03:00
Mootjuh f48031f15f Correct two comments on the list band's hover and click 2026-10-03 22:55:26 +02:00
Mootjuh 349c110d74 Step a full-screen list by the band beside its slots 2026-10-03 22:41:04 +02:00
Mootjuh b8ac7b4b8d Settle tap targets once a frame; stop legend strips at targets below 2026-10-03 22:22:49 +02:00
saulo benigno c72962a73d Add high-resolution UI controller buttons 2026-10-02 21:10:04 -03:00
Mootjuh 7ad54bb012 Keep a widened setting's split at its centre; widen against the noted boxes 2026-10-02 12:33:02 +02:00
Mootjuh 2ee4fd2cc2 Double the width of each setting 2026-10-02 11:16:52 +02:00
Mootjuh c4cf55458e Merge a setting row only when it holds one value; fit legends first 2026-10-02 10:35:11 +02:00
Mootjuh 8fda769765 Let a tap on a setting row reach its value 2026-10-02 10:24:55 +02:00
Mootjuh 430f3d8287 Read the gesture insets at every finger down 2026-10-02 09:11:15 +02:00
Mootjuh f32a6a791e Ignore touches in the system gesture zones 2026-10-02 09:11:15 +02:00
Mootjuh 4712923c67 Add debug.touch_targets 2026-10-02 09:11:15 +02:00
Mootjuh 798f388732 Skip cinematics with a tap 2026-10-02 09:11:15 +02:00
Mootjuh 3373a3556d Operate the Android menus by touch 2026-10-02 09:11:15 +02:00
Mootjuh 74ec92e787 Add the menus' touch gesture unit 2026-10-02 09:11:14 +02:00
874 changed files with 179113 additions and 5203 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Files that a Linux shell runs directly must keep LF line endings in a
# Windows checkout (core.autocrlf): a CRLF shebang line does not start.
*.sh text eol=lf
port/android/gradlew text eol=lf
+38
View File
@@ -0,0 +1,38 @@
name: Problem report
description: Something does not work as it should
body:
- type: markdown
attributes:
value: |
Thanks for reporting! Please do **not** ask for, link to or attach game files or disc images; such issues are closed and deleted.
- type: dropdown
id: headset
attributes:
label: Headset
options:
- Meta Quest 3
- Meta Quest 3S
- Steam Frame
- Other
validations:
required: true
- type: input
id: build
attributes:
label: Build number
description: The release you installed, e.g. "build 180".
validations:
required: true
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected and what happened instead.
validations:
required: true
- type: textarea
id: log
attributes:
label: Log
description: |
Attach `debug.txt`. Quest: `Android/data/io.github.cesarus85.ringfall/files/debug.txt` (SideQuest's file manager). Steam Frame: `Documents/RingfallMjolnirVR/debug.txt`, and `logcat.txt` and `install.log` beside it.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: How to install
url: https://github.com/Cesarus85/ringfall-mjolnir-vr#install-on-meta-quest-3--3s
about: Step-by-step installation for the Quest 3 / 3S and the Steam Frame.
+248 -22
View File
@@ -1,29 +1,41 @@
name: Build
# Debug and release builds of the native ports (Linux, Windows, Android) for
# every pushed commit, uploaded as artifacts. tools/ci_build.py does each
# build; see it for the options.
# Debug and release builds of the native ports (Linux, Windows, Android) and
# the web port for every pushed commit, uploaded as artifacts.
# tools/ci_build.py does each build; see it for the options.
#
# Kept cheap:
# - one job per platform builds both configurations, so its set-up (tools,
# downloads, caches) is paid once;
# - compiles go through ccache, whose cache carries over from run to run, so
# a commit only recompiles what it changed; downloads (SDL, musl, Gradle)
# are cached too;
# a commit only recompiles what it changed; downloads (SDL, musl, Gradle,
# the Linux build's system root) are cached too;
# - a newer push to the same branch cancels a run still going;
# - pushes that only change documentation do not build;
# - Windows, whose minutes cost twice Linux's, builds only Windows;
# - Ringfall: Mjolnir VR builds only Android (the VR app): the Linux and
# Windows jobs are kept, off (if: false), so that upstream's changes to
# them still merge;
# - artifacts are kept for three days.
#
# A successful build of main is also published as a GitHub release, the
# newest marked latest, so that
# https://github.com/<repository>/releases/latest/download/<file> always
# gives the latest build; the last five are kept.
# gives the latest build; the last five are kept. Its web build is then the
# site on GitHub Pages (https://halocombatevolved.com/, port/web/README.md).
on:
# (Ringfall: Mjolnir VR: main only; its branches are tried on the headsets
# from local builds)
push:
branches: [main]
paths-ignore:
- "**.md"
workflow_dispatch:
inputs:
profile:
description: "Also build the profiling flavour (configure.py --profile) on Linux"
type: boolean
default: false
concurrency:
group: build-${{ github.ref }}
@@ -40,8 +52,14 @@ env:
jobs:
linux:
# (off: Ringfall: Mjolnir VR ships the Android app only)
if: ${{ false }}
# Arch Linux has what the build needs as packages: clang 22 (for the
# profile-guided optimisation in pgo/) and 32-bit SDL 3.
# profile-guided optimisation in pgo/), and CMake, pkg-config and
# wayland-scanner for the SDL 3 that the portable build brings. The build
# links against Debian 11's 32-bit glibc (tools/linux_sysroot.py), not
# Arch's, so that it starts on SteamOS and older distributions; Arch's
# 32-bit glibc is for the tests.
runs-on: ubuntu-24.04
container: archlinux:latest
timeout-minutes: 40
@@ -50,7 +68,7 @@ jobs:
run: |
printf '\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n' >> /etc/pacman.conf
pacman -Syu --noconfirm --needed git zstd python python-pytest ninja ccache \
clang lld gcc lib32-glibc lib32-gcc-libs lib32-sdl3
clang lld gcc lib32-glibc lib32-gcc-libs cmake pkgconf wayland
- uses: actions/checkout@v4
@@ -60,15 +78,28 @@ jobs:
key: ccache-linux-${{ github.sha }}
restore-keys: ccache-linux-
# the system root and SDL's source, which the build downloads
- uses: actions/cache@v4
with:
path: build/linux/third_party
key: linux-third-party-${{ hashFiles('tools/linux_sysroot.py') }}
- name: Debug
run: python tools/ci_build.py linux debug
# (the profiling build, configure.py --profile, which no other step
# compiles: only when a run started by hand asks for it, as it costs a
# build of its own)
- name: Profile
if: github.event_name == 'workflow_dispatch' && inputs.profile
run: python tools/ci_build.py linux profile
- name: Release
run: python tools/ci_build.py linux release
- name: Test
run: |
python -m pytest -q tools/test_linux_port.py
python -m pytest -q tools/test_linux_port.py tools/test_android_asm_convert.py tools/test_host_watch_hash.py tools/test_touch_menu.py tools/harness tools/test_profile.py
python tools/test_light_storage.py
- name: Compile cache
@@ -112,7 +143,7 @@ jobs:
- uses: actions/cache@v4
with:
path: build/android/third_party
key: android-third-party-${{ hashFiles('tools/android_build.py') }}
key: android-third-party-${{ hashFiles('tools/android_build.py', 'port/android/patches/**') }}
- uses: actions/cache@v4
with:
@@ -147,6 +178,10 @@ jobs:
HALO_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: python3 tools/ci_build.py android release
# the touch controls' layout and gyroscope maths (the runner's JDK)
- name: Test the touch layout
run: python3 tools/test_touch_layout.py
- name: Compile cache
run: ccache --show-stats
@@ -163,6 +198,8 @@ jobs:
retention-days: 3
windows:
# (off: Ringfall: Mjolnir VR ships the Android app only)
if: ${{ false }}
# The runner image has LLVM, Python and Visual Studio's x86 libraries and
# Windows SDK.
runs-on: windows-2025
@@ -220,12 +257,105 @@ jobs:
path: dist/halo-windows-release
retention-days: 3
# the PDBs, for crash reports' backtraces (tools/ci_build.py)
- uses: actions/upload-artifact@v4
with:
name: halo-windows-debug-symbols
path: dist/halo-windows-debug-symbols
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: halo-windows-release-symbols
path: dist/halo-windows-release-symbols
retention-days: 3
web:
# (off: Ringfall: Mjolnir VR ships the Android app only)
if: ${{ false }}
# Emscripten (emsdk, the version below, cached) builds the web port's
# site (port/web/README.md); the tests check its graph, the page's
# importer and service worker, its sockets, that it starts in a
# headless Chromium (with no game data, as far as the menus' map), and
# that two browsers join a room through a local MQTT broker.
runs-on: ubuntu-24.04
timeout-minutes: 40
env:
EMSDK_VERSION: 6.0.10
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/emsdk
key: emsdk-${{ env.EMSDK_VERSION }}-${{ hashFiles('port/web/halo_sdl3.py') }}
- name: Install the toolchain
run: |
if [ ! -x ~/emsdk/upstream/emscripten/emcc ]; then
rm -rf ~/emsdk
git clone --depth 1 https://github.com/emscripten-core/emsdk.git ~/emsdk
~/emsdk/emsdk install "$EMSDK_VERSION"
fi
~/emsdk/emsdk activate "$EMSDK_VERSION"
sudo apt-get update -q
sudo apt-get install -y -q ninja-build ccache python3-pytest mosquitto
- uses: actions/cache@v4
with:
path: .ccache
key: ccache-web-${{ github.sha }}
restore-keys: ccache-web-
- name: Debug
run: |
source ~/emsdk/emsdk_env.sh
python3 tools/ci_build.py web debug
- name: Release
run: |
source ~/emsdk/emsdk_env.sh
python3 tools/ci_build.py web release
- name: Test
run: |
source ~/emsdk/emsdk_env.sh
node --test "port/web/tests/*.test.js"
python3 -m pytest -q tools/test_web_build.py
- name: Test in Chromium and Firefox
run: |
npm install --no-save --prefix "$RUNNER_TEMP/playwright" playwright@1.58
"$RUNNER_TEMP/playwright/node_modules/.bin/playwright" install --with-deps chromium firefox
NODE_PATH="$RUNNER_TEMP/playwright/node_modules" node port/web/tests/smoke.mjs dist/halo-web-release
NODE_PATH="$RUNNER_TEMP/playwright/node_modules" node port/web/tests/rooms.mjs dist/halo-web-release
# (the native builds' WebRTC, port/linux/src/p2p_webrtc.c, with both)
NODE_PATH="$RUNNER_TEMP/playwright/node_modules" node port/web/tests/webrtc_native.mjs chromium firefox
- name: Compile cache
run: ccache --show-stats
- uses: actions/upload-artifact@v4
with:
name: halo-web-debug
path: dist/halo-web-debug
include-hidden-files: true
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: halo-web-release
path: dist/halo-web-release
include-hidden-files: true
retention-days: 3
release:
# The six builds as a release, tagged build-<run number> and marked latest
# The eight builds (and the Windows builds' PDBs) as a release, tagged
# build-<run number> and marked latest
# (every file keeps its name from release to release, for the README's
# latest/download links); then all but the newest five build-* releases
# go, tags and all. Only for main, and only when every platform built.
needs: [linux, android, windows]
needs: [android]
if: ${{ github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
@@ -237,6 +367,13 @@ jobs:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
port/android
port/third_party/extract-xiso
tools
- uses: actions/download-artifact@v4
with:
pattern: halo-*
@@ -245,10 +382,18 @@ jobs:
- name: Package
run: |
mkdir release
for build in artifacts/*/; do
name="$(basename "$build")"
(cd "$build" && zip -qr "$GITHUB_WORKSPACE/release/$name.zip" .)
# (Ringfall: Mjolnir VR publishes its Android builds only, under
# its own name: Updater.java looks for them; the desktop ports are
# built to keep them building)
for config in debug release; do
(cd "artifacts/halo-android-$config" &&
zip -qr "$GITHUB_WORKSPACE/release/ringfall-mjolnir-vr-android-$config.zip" .)
done
# the Steam Frame's installer (port/android/STEAM_FRAME.md): its
# bundle with the release APK, and the desktop file that fetches it
python3 tools/steam_frame_bundle.py artifacts/halo-android-release/app-release.apk release "$GH_REPO"
# the APK itself, for sideloading on the Quest (SideQuest, adb)
cp artifacts/halo-android-release/app-release.apk release/RingfallMjolnirVR.apk
ls -l release
- name: Publish
@@ -260,12 +405,50 @@ jobs:
run: |
tag="build-$GITHUB_RUN_NUMBER"
subject="$(printf '%s' "${MESSAGE:-manual run}" | head -n 1)"
printf '%s\n\nCommit: %s\nBuild: %s\n\nThe release builds are for playing; the debug builds stop at the first failed assertion and log it.\n' \
"$subject" "$COMMIT_URL" "$RUN_URL" > notes.md
gh release create "$tag" release/*.zip --target "$GITHUB_SHA" --latest \
--title "Build $GITHUB_RUN_NUMBER (${GITHUB_SHA:0:7})" --notes-file notes.md
readme="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
{
printf '%s\n\n' "$subject"
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'
printf 'Commit: %s\nBuild: %s\n' "$COMMIT_URL" "$RUN_URL"
} > notes.md
gh release create "$tag" release/* --target "$GITHUB_SHA" --latest \
--title "Ringfall: Mjolnir VR, build $GITHUB_RUN_NUMBER (${GITHUB_SHA:0:7})" --notes-file notes.md
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# The Windows builds' executables and PDBs to Sentry, which turns the
# crash reports of these builds (port/windows/src/win32_crash.c) into
# backtraces with function names and lines. Needs the SENTRY_AUTH_TOKEN
# secret: an organization auth token of the Sentry organization, which
# names the organization and its region itself. The SENTRY_PROJECT
# variable can name the project (its slug), else the id in the DSN of
# win32_crash.c; SENTRY_ORG and SENTRY_URL (https://de.sentry.io) are
# for a personal token instead. A failed upload does not stop the
# release.
- name: Upload Windows symbols to Sentry
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT || '4512207917744208' }}
ORG: ${{ vars.SENTRY_ORG }}
URL: ${{ vars.SENTRY_URL }}
SENTRY_CLI_VERSION: 2.46.0
run: |
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
echo "::warning::no SENTRY_AUTH_TOKEN secret: crash reports of this build will have no function names"
exit 0
fi
if [ -n "$ORG" ]; then export SENTRY_ORG="$ORG"; fi
if [ -n "$URL" ]; then export SENTRY_URL="$URL"; fi
curl -fsSL -o sentry-cli "https://github.com/getsentry/sentry-cli/releases/download/$SENTRY_CLI_VERSION/sentry-cli-Linux-x86_64"
chmod +x sentry-cli
./sentry-cli debug-files upload --wait \
artifacts/halo-windows-release artifacts/halo-windows-release-symbols \
artifacts/halo-windows-debug artifacts/halo-windows-debug-symbols
- name: Keep the last five
run: |
gh release list --limit 100 --json tagName,createdAt \
@@ -274,12 +457,47 @@ jobs:
gh release delete "$tag" --cleanup-tag --yes
done
pages:
# (off: Ringfall: Mjolnir VR ships the Android app only)
if: ${{ false }}
# The release's web build to GitHub Pages, which serves it at
# https://halocombatevolved.com/ (port/web/README.md, "Hosting"). Only
# main, only once every platform built and the release is out (the site
# and the release are one build), and a deployment is never cancelled
# halfway. The repository's Pages source must be GitHub Actions.
needs: [release]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/download-artifact@v4
with:
name: halo-web-release
path: site
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
- id: deploy
uses: actions/deploy-pages@v4
notify:
# A Discord embed for the run, to the DISCORD_WEBHOOK secret (nothing
# without it): green when every platform built, red when one did not,
# with each platform's result, this run's release (main only), and links
# to the latest release's builds.
needs: [linux, android, windows, release]
needs: [android, release]
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -290,6 +508,9 @@ jobs:
LINUX: ${{ needs.linux.result }}
ANDROID: ${{ needs.android.result }}
WINDOWS: ${{ needs.windows.result }}
WEB: ${{ needs.web.result }}
PAGES: ${{ needs.pages.result }}
SITE_URL: https://halocombatevolved.com/
RELEASE_TAG: ${{ needs.release.outputs.tag }}
SERVER: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
@@ -312,15 +533,18 @@ jobs:
*) echo "➖ Skipped" ;;
esac
}
if [ "$LINUX" = success ] && [ "$ANDROID" = success ] && [ "$WINDOWS" = success ]; then
if [ "$ANDROID" = success ]; then
title="Build succeeded"; color=5763719
else
title="Build failed"; color=15548997
fi
latest="$SERVER/$REPOSITORY/releases/latest"
downloads="[Linux]($latest/download/halo-linux-release.zip) · [Windows]($latest/download/halo-windows-release.zip) · [Android]($latest/download/halo-android-release.zip) ([all builds]($latest))"
downloads="[Quest]($latest/download/ringfall-mjolnir-vr-android-release.zip) · [Steam Frame]($latest/download/Install-Ringfall-Mjolnir-VR.desktop) ([all builds]($latest))"
if [ -n "$RELEASE_TAG" ]; then
release="[$RELEASE_TAG]($SERVER/$REPOSITORY/releases/tag/$RELEASE_TAG)"
if [ "$PAGES" = success ]; then
release="$release, on [the site]($SITE_URL)"
fi
else
release="None (only a successful build of main makes one)"
fi
@@ -330,6 +554,7 @@ jobs:
--arg description "[\`${SHA:0:7}\`]($COMMIT_URL) $subject — $ACTOR ($BRANCH)" \
--arg release "$release" --arg downloads "$downloads" \
--arg linux "$(status "$LINUX")" --arg windows "$(status "$WINDOWS")" --arg android "$(status "$ANDROID")" \
--arg web "$(status "$WEB")" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{embeds: [{
title: $title, url: $url, color: $color, description: $description, timestamp: $timestamp,
@@ -337,6 +562,7 @@ jobs:
{name: "Linux", value: $linux, inline: true},
{name: "Windows", value: $windows, inline: true},
{name: "Android", value: $android, inline: true},
{name: "Web", value: $web, inline: true},
{name: "Release", value: $release},
{name: "Latest release builds", value: $downloads}
]
+5
View File
@@ -30,3 +30,8 @@ original/
# CI build outputs (tools/ci_build.py)
dist/
# The app signing key's local settings and keys (port/android/app/build.gradle; never commit)
port/android/signing.properties
*.jks
*.keystore
+225
View File
@@ -0,0 +1,225 @@
# Notes for agents
This repository ports the Halo: Combat Evolved decompilation (Xbox build
01.01.14.2342, `cachebeta.exe`) to Linux, Windows, Android and the web. The game's C
sources are the decompilation; the port adds a platform layer that
implements the Xbox APIs the game calls, and features the Xbox game did not
have (a new netcode, internet play, the PC version's menus, Custom Edition
maps, touch controls). The project is published as OpenCE.
Start with [README.md](README.md). The other documents are listed at the
end.
## Layout
| Path | Contents |
| --- | --- |
| `source/` | The game: 466 C files of the decompilation, compiled as they are with few changes (each marked, below) |
| `port/linux/src/` | The platform layer the three ports share: Direct3D 8 on OpenGL (`d3d8_gl.c`), DirectSound and XInput on SDL3, files, threads, memory, sockets, settings (`port_config.c`), internet play (`p2p*.c`) |
| `port/linux/game/` | The port's game-side code, compiled as the game's sources are: the distributed netcode (`network_*.c`), co-op, map sharing (`map_share*.c`), the menus (`menu_tags.c`, `menu_functions.c`), Custom Edition maps (`custom_edition_*.c`, `cache_file_formats.c`), the tag validator (`tag_validate.c`, `tag_schema_*.c`), touch, FOV and more |
| `port/linux/include/` | The port's headers: the limits (`halo_port_limits.h`, with `HALO_PORT_NETWORK_VERSION`), capacities (`halo_port_capacity.h`), the prefix header, `halo_linux_source_fixups.h` (port functions the game's sources call) |
| `port/include/xdk/` | Stand-ins for the Xbox SDK's headers (declarations only) |
| `port/windows/` | The Windows build's own files (`win32_*.c`, headers) |
| `port/android/` | The Android app: the guest runtime, the host library (`host/`), the Java app (`app/`), host imports (`host_imports.list`) |
| `port/web/` | The web build: its runtime (`src/`: entry, sockets inside the page, memory watch), the page and its service worker (`site/`), the SDL port, tests |
| `port/assets/` | What the builds embed or ship: high-res HUD (`hud/`), fonts, titles, menus (`menus/`), icons, network brokers |
| `port/third_party/` | Vendored libraries, each with a README naming its upstream, version and checksum |
| `port/tools/` | Stand-alone tools (`cache_file_report.c`) |
| `tools/` | Build scripts (`linux_build.py`, `windows_build.py`, `android_build.py`, `web_build.py`, `ci_build.py`, `web_serve.py`), generators (`ce_menus.py`, `port_settings.py`, `hud_assets.py`, `title_assets.py`, `embed_assets.py`, `xdk_headers.py`) and tests |
| `docs/` | Longer design notes (`custom_edition_caches.md`) |
| `pgo/` | Profile-guided optimisation profiles |
| `assets/` | Game data for local runs (gitignored: `maps/`, `custom_maps/`) |
| `.github/workflows/` | CI: builds every platform on each push, publishes releases from `main` |
## Building
```sh
python configure.py # debug build; add --release, --portable, --profile, --pgo=off, --lto=off
ninja linux # build/linux/halo
ninja android # the Android guest image and native libraries
ninja android_apk # the APK (Gradle)
ninja web # build/web/site (Emscripten: port/web/README.md)
ninja windows # on Windows only
python tools/ci_build.py linux release # what CI builds
```
- `configure.py` writes `build.ninja`; `configure_args` at its top records
the options it was run with. Restore them after trying other options.
- The Linux and Windows builds are 32-bit x86 with clang, in MSVC's ABI
(`-fms-extensions -fshort-wchar -malign-double -fcommon`), C89 (`gnu89`):
declarations come before statements.
- Every native build compiles without fused multiply-add and with the
vendored musl maths (`halo_math.h`), so that every machine of a network
game computes the same results. Do not call the system's maths functions.
- Android: the game is an ILP32 AArch64 guest (`arm64_32`) in a 64-bit app.
Guest code can call only the host imports declared in
`port/android/host_imports.list` and `port/android/guest/runtime/guest_host.h`,
and only the SDL functions `guest_sdl.c` bridges. Run `ninja android`
after changing anything it compiles: `port/linux/src`, `port/linux/game`,
`port/android`, `source/`, the build scripts.
- Web: the game is `wasm32` (also ILP32), run on a Web Worker; calls must
match their callee's type exactly (the link fails on a mismatch), and
WebGL 2 is ES 3.0. Run `ninja web` after changing what it compiles (as
for Android, and `port/web`). Refer to [port/web/README.md](port/web/README.md).
### Defines
| Define | Set by | Meaning |
| --- | --- | --- |
| `HALO_ANDROID` | Android | The app: its display, input, files and lifecycle |
| `HALO_GLES` | Android, web | The OpenGL ES renderer |
| `HALO_ARM64_GUEST` | Android | The guest's ABI (ILP32 AArch64) |
| `HALO_WEB` | Web | The browser: its frames, storage, sockets and WebGL 2's limits (with `HALO_GLES`, not `HALO_ANDROID`) |
| `HALO_WINDOWS` | Windows (`halo_windows_prefix.h`) | The Windows build |
| `HALO_RELEASE` | `--release` | No assertions checked |
| `HALO_PROFILE` | `--profile` | The profiling build; a normal build must be unchanged by it |
`HALO_LINUX` and `HALO_CUSTOM_EDITION` are **not** defined by any build:
code guarded by them compiles to nothing. Custom Edition support is a
setting (`game.custom_edition`), checked at run time with
`custom_edition_cache_tags_loaded()`.
## Testing
```sh
python -m pytest -q tools/harness tools/test_touch_menu.py # the engine's functions in a fake world
python -m pytest -q tools/test_linux_port.py # the build, the maps' checks (needs assets/maps)
python -m pytest -q tools/test_cache_file_formats.py tools/test_bmp_files.py tools/test_profile.py
python tools/test_touch_layout.py # the Android touch layout (JDK 17+)
python tools/test_light_storage.py
python tools/test_death_timing.py
python -m pytest -q tools/test_web_build.py # the web build's graph, sockets, menus
node --test "port/web/tests/*.test.js" # the web page's importer and service worker
node port/web/tests/webrtc_native.mjs chromium firefox # the native builds' WebRTC with browsers (Playwright)
```
`tools/harness/README.md` explains how to add an asset-free test: the code
under test is taken from the sources, not copied.
Running the game without a person at it:
- `debug.network_test` (`HALO_NETWORK_TEST=host:<map>` or `join`) hosts or
joins a game without the menus and logs each player's state every second;
several copies play on one machine on different loopback addresses
(`HALO_NET_ADDRESS`, `HALO_NET_BROADCAST`). `NETCODE.md` describes the
test settings.
- `debug.exit_after`, `debug.hidden_window`, `debug.null_renderer`,
`debug.menu_open` (start on one menu screen), `debug.telnet_console`.
- Use a virtual display (Xvfb, with `SDL_VIDEO_DRIVER=x11`) for windowed
runs, never the desktop of the person you work for, and stop only the
processes you started.
## Rules of the project
### The game's sources
- Change `source/` as little as possible, and mark each change from the
original with a comment that starts `port:`. Write like the surrounding
code: its naming, its comment density, and the project's plain,
descriptive comment style.
- A port function the game's sources call is declared in
`halo_linux_source_fixups.h` or included with a note naming its file
(`#include "view_fov.h" /* port: port/linux/game/view_fov.c */`).
- Notes such as `BUG (original, preserved for exact matching)` mark the
original's bugs. Fixing one is allowed; say so in a `port:` comment.
### The Xbox SDK
`port/include/xdk` was written without the SDK's headers, from the January
build's debug information and public documentation. Never read the SDK's
headers or anything derived from them, never copy from them, and do not
read `xbox/include`. To add a name, follow `port/include/xdk/README.md`.
### Networking
- Raise `HALO_PORT_NETWORK_VERSION` (`port/linux/include/halo_port_limits.h`)
with any change to what the machines send each other, or to anything they
must agree on, and say why in the commit. Machines of different versions
do not play together.
- Only the host decides; clients predict their own players. Read
`port/linux/NETCODE.md` before changing the netcode.
- Internet play is the same code in every build, the web's too: a browser's
tunnel is a WebRTC data channel (`port/web/src/web_p2p.c`), which the
native builds take on their tunnel's socket (`p2p_webrtc.c`, `posix_dtls.c`).
Check a change with both (`port/web/tests/webrtc_native.mjs`, and with the
maps `port/web/tests/internet.mjs`).
- Anything another machine sends is untrusted: check sizes, indices and
rates, as the existing handlers do. `debug.network_corrupt` tests this.
### Menus
- The menus must match Halo Custom Edition's. Their XML (`port/assets/menus/ce/`)
is generated by `tools/ce_menus.py` from the PC version's tags; the
settings screens come from `tools/port_settings.py`. Do not edit the XML
by hand: change the generator and run it (the command is in
`port/assets/menus/README.md`, "Writing them again"), then check that
`git status` shows only the screens meant to change.
- Functions the menus call but the port has not wired are listed in
`port/assets/menus/UNWIRED.md`.
### Art and credit
- Replacement HUD, title and menu art is made of hand-drawn redraws.
- Prefer an existing redraw (the ui-svg-handmade set), then a new redraw. A
Custom Edition picture that cannot reasonably be redrawn (a 3D render, a
screenshot, a logo) may be used only if it is listed in
`port/assets/menus/NON_HANDDRAWN.md`.
- `AUTHORS.md` credits art only.
- Fonts: Overpass and OpenCE (Newtown, respaced) only; see
`port/assets/fonts/README.md`. Never ship the maps' commercial typefaces.
### Custom Edition maps
Custom Edition maps live in the data root's `custom_maps` folder with
`bitmaps.map`, `sounds.map` and `loc.map`; their level names are
`custom_maps\<name>`. OpenSauce features are not supported and must not be
added: a map that needs them is refused, and one that only carries
OpenSauce's header runs as stock Custom Edition runs it. Map files are
untrusted input: read them through bounds-checked readers. See
`docs/custom_edition_caches.md`.
### Settings
A setting is an entry in `port/linux/src/port_config.c`, with a default, an
environment variable and a comment that `config.toml` gets. Document it in
the settings table of `port/linux/README.md` (and the Android README for an
Android-only one). A departure from the stock game's look or behaviour
should be a setting, off or stock by default, unless the project decides
otherwise.
### Third-party code and licences
The repository is CC0. Vendored code goes in `port/third_party/<name>`,
copied unchanged, with a README naming the upstream, the version or commit,
the checksum and what uses it. GPL code (OpenSauce, Reclaimer) may be read
as documentation of file formats, never copied.
### Commits and pull requests
- Commit subjects are plain sentences saying what changed; the body says
why and what was checked.
- A pull request is merged with a merge commit (`Merge pull request #N from
<owner>/<branch>`), and follow-up changes go in separate commits whose
subject ends with `(#N)`.
- A commit taken from a fork keeps its author and is made with
`git cherry-pick -x`.
## Documents
| Document | About |
| --- | --- |
| [README.md](README.md) | Downloads, game data, building, build options, profiling builds |
| [port/linux/README.md](port/linux/README.md) | The Linux build, controls, menus, every setting, updates, frame rate, field of view, system link, internet play, voice chat, the server browser, map sharing, security, map checks, how the port operates, the game source changes |
| [port/linux/NETCODE.md](port/linux/NETCODE.md) | The distributed netcode, network versions, joining a game in progress, map sharing, transport, testing |
| [port/windows/README.md](port/windows/README.md) | The Windows build, its headers, inline functions, crash reports |
| [port/android/README.md](port/android/README.md) | The Android build, the guest and host, touch controls, Android settings, finding problems |
| [port/web/README.md](port/web/README.md) | The web build, the page, the game data in the browser, limits, how it operates, hosting on GitHub Pages |
| [port/include/xdk/README.md](port/include/xdk/README.md) | How the SDK declarations were written and are checked |
| [docs/custom_edition_caches.md](docs/custom_edition_caches.md) | Loading and converting Custom Edition maps, and the evidence for each layout |
| [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 |
| [tools/harness/README.md](tools/harness/README.md) | Asset-free regression tests |
| `port/third_party/*/README.md` | Each vendored library |
| [AUTHORS.md](AUTHORS.md) | Art credits |
+20
View File
@@ -0,0 +1,20 @@
# Authors
## Art
- **The game's icon** (the Android app's launcher icon, `halo.exe`'s icon
and the desktop windows' icon): by ORION (`port/assets/icon`).
- **The controller's A, B, X and Y buttons** (the menus' button icons and
the HUD messages'): redraws by saulob (`port/assets/buttons`).
- **The high-res HUD** (its meters, icons, scopes and messages, and the
weapons' reticles): redraws by MrBruh (`port/assets/hud`), based on
reference photos by Aerocatia. The sniper rifle's zoom labels (2x, 10x)
are set in Overpass (`port/assets/fonts`).
## Third-party art
- **Voice chat's speaker icons** (who talks, and who is muted, in the lobby,
on the scoreboard and in the game): `volume-2` and `volume-x` from
[Lucide](https://lucide.dev) 1.53.0, copyright (c) 2026 Lucide Icons and
Contributors, under the ISC licence
(`port/assets/icons/lucide/LICENSE`).
+163 -102
View File
@@ -1,140 +1,201 @@
# Halo: Combat Evolved for Linux, Windows and Android
<p align="center">
<img src="port/android/steam_frame_grid/library.png" width="640" alt="Ringfall: Mjolnir VR">
</p>
[![Join our Discord](https://invidget.switchblade.xyz/9gqcHyr5km)](https://discord.gg/9gqcHyr5km)
# Ringfall: Mjolnir VR
This project is a port of the Halo: Combat Evolved decompilation to Linux,
Windows and Android. The decompilation is of the Xbox build 2342
(`cachebeta.exe`, SHA-256
`4cc87b45f721270392a96f1674ed2b5cd4a7bb4355faeab4531d1cf1884d9520`).
**A VR mod for Halo: Combat Evolved** (the original Xbox game) on the
**Meta Quest 3 / 3S** and the **Steam Frame**. Play the campaign and
multiplayer in full 6DoF virtual reality, with your hands in the game.
<img width="1289" height="995" alt="The game on Linux" src="https://github.com/user-attachments/assets/0d3ad50f-f8b8-46cf-aef8-e3661da2a7d7" />
Ringfall: Mjolnir VR runs on your headset by itself, without a PC. It
brings no game files: it uses a disc image of **your own, purchased copy**
of the game.
The port starts from the decompilation of [bnunu/halo-1](https://github.com/bnunu/halo-1).
That project is a fork of [punpckhdq/halo](https://github.com/punpckhdq/halo).
> **Unofficial fan project.** Ringfall: Mjolnir VR is a free,
> non-commercial fan project. It is not affiliated with, endorsed,
> sponsored or approved by Microsoft, Xbox Game Studios, Halo Studios or
> Bungie. See [Legal](#legal).
## Download
## Contents
GitHub Actions builds the game for each commit. These links download the
builds of the latest release:
- [What you get](#what-you-get)
- [What you can play](#what-you-can-play)
- [What you need](#what-you-need)
- [Install on Meta Quest 3 / 3S](#install-on-meta-quest-3--3s)
- [Install on Steam Frame](#install-on-steam-frame)
- [Playing](#playing)
- [Problems and questions](#problems-and-questions)
- [Credits](#credits)
- [Legal](#legal)
| Platform | Release | Debug |
| --- | --- | --- |
| Linux | [halo-linux-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip) | [halo-linux-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-debug.zip) |
| Windows | [halo-windows-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-windows-release.zip) | [halo-windows-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-windows-debug.zip) |
| Android | [halo-android-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-android-release.zip) | [halo-android-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-android-debug.zip) |
## What you get
Use the release build to play. The debug build stops at the first failed
assertion and writes it to the log. Use the debug build to find and report
problems.
- **Full VR**: stereo 6DoF at your headset's refresh rate (72 to 120 Hz);
the HUD, menus, scope and crosshair float as layers of their own.
- **Your hands in the game**: the weapon sits in your hand and fires
where you point it; arms and fingers follow your controllers;
two-handed weapons, melee by swinging, grenades thrown with your hand,
crouching and leaning with your body.
- **Vehicles**: steer the Warthog by turning its wheel with both hands;
turrets with handles.
- **Comfort**: snap or smooth turning in fine steps, comfort vignettes,
left-handed mode, buttons remapped freely, graphics presets, refresh
rate and resolution, all in the in-game VR options (**Y**).
- **The PC version's menus**, clicked with a laser pointer, and an
on-screen keyboard for names, codes and passwords.
The game updates itself. At start-up it looks for a newer release, and asks
if you want to install it. Refer to "Updates" in
[port/linux/README.md](port/linux/README.md#updates).
## What you can play
Each build of the `main` branch that passes on all three platforms is a new
release. The [Releases](https://github.com/OpenCommunityEdition/OpenCE/releases)
page keeps the last five releases. If the latest build has a problem, get
an older build from that page.
- **The whole campaign**: all ten missions, on every difficulty, saved as
you go.
- **Co-op campaign** with friends, online or on your local network, with
settings for friendly fire and extra enemies per player.
- **LAN multiplayer** (system link) with everyone on your network.
- **Online multiplayer**, no server of your own needed: host a game and
share its join code, find public games in the server browser, or protect
a lobby with a password. Up to 128 players.
- **Bots** to fill a game, on foot, in vehicles and at turrets; they make
room when a person joins.
- **All 13 multiplayer maps** and the Xbox game's game types: Slayer,
Capture the Flag, King of the Hill, Oddball and Race.
- **Cross-play**: VR players on the Quest and the Steam Frame, together with
players of the PC and phone builds of
[OpenCE](https://github.com/OpenCommunityEdition/OpenCE).
## Game data
## What you need
The port does not include the game data. Download an Xbox disc image
(`.xiso` or `.iso`) of Halo: Combat Evolved. All versions of the game
operate. The maps of the European (PAL) version were made for a slower
console. The port changes them to play as the North American (NTSC) maps do,
so players of the two versions can play together.
- A **Meta Quest 3 / 3S** or a **Steam Frame**.
- A **disc image** (`.iso` or `.xiso`) of **Halo: Combat Evolved for the
original Xbox**, made from **a disc you own**. Any region and version
works.
1. Start the game.
2. At the first start, the game asks for the disc image. Select it.
3. The game extracts the `maps/` folder. Then the game starts.
This project does not include, link to or help find game files. Please
do not ask for or share disc images in issues or discussions; such posts
are deleted.
On Linux and Windows, the game puts `maps/` next to the executable. On
Android, copy the disc image to the phone first. The app puts `maps/` in its
data folder. Refer to [port/android/README.md](port/android/README.md).
## Install on Meta Quest 3 / 3S
## Platforms
You install the game once with a computer (Windows, macOS or Linux); after
that it updates itself.
Each platform has its own instructions:
1. **Turn on Developer Mode** for your Quest, once: in the Meta Horizon
app on your phone, open your headset's settings, then *Developer
Mode* (Meta asks you to create a free developer account first).
2. **Install [SideQuest](https://sidequestvr.com/setup-howto)** (the
"Advanced Installer") on your computer and connect the Quest with a
USB cable. In the headset, allow USB debugging for this computer.
3. **Download `RingfallMjolnirVR.apk`** from the
[latest release](../../releases/latest) and drag it onto the SideQuest
window. It installs the game.
4. **Copy your disc image** to the Quest's `Download` folder: in
SideQuest, open the file manager (the folder icon), go to `Download`
and drag the `.iso` file in.
5. **In the headset**, open your *Library*, choose *Unknown Sources* and
start **Ringfall: Mjolnir VR**. Select **Choose disc image** and pick
your file in `Download`. The game copies what it needs (about 1.8 GB,
a minute or two) and starts.
| Platform | Instructions |
| --- | --- |
| Linux (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/linux/README.md](port/linux/README.md) |
| Windows (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/windows/README.md](port/windows/README.md) |
| Android (arm64 app, OpenGL ES 3, SDL3) | [port/android/README.md](port/android/README.md) |
You can delete the disc image from `Download` afterwards. When a new
version comes out, the game asks you at start whether to update.
The Linux README also gives the controls, the settings and the multiplayer
functions. These are almost the same on all platforms.
## Install on Steam Frame
## Multiplayer
No computer and no keyboard needed.
The game can play system link games on a local network and on the internet:
1. **Switch to Desktop Mode**: press the Steam button, then *Power* >
*Switch to Desktop*.
2. **Put your disc image** in the `Downloads` folder (for example from a
USB stick or memory card with the file manager, Dolphin).
3. **Download `Install-Ringfall-Mjolnir-VR.desktop`** from the
[latest release](../../releases/latest) with the web browser.
4. **Open it**: in Dolphin, open `Downloads` and double-click the file.
If you are asked whether to run it, choose to continue.
5. **Answer the dialogs.** The installer finds your disc image, copies
what the game needs, adds **Ringfall: Mjolnir VR** to your Steam
library and offers to switch back to Gaming Mode.
6. Start **Ringfall: Mjolnir VR** from your library (under *Non-Steam*).
- A system link game can have up to 128 players on up to 128 machines.
- Linux, Windows and Android machines can play in the same game.
- An invite link lets a machine join a game on the internet. No server of
this project is necessary.
- The netcode is new. Each machine moves its own player at once,
and the host makes the decisions for the game. Refer to
[port/linux/NETCODE.md](port/linux/NETCODE.md).
To update, open the same file in Desktop Mode again; your settings,
saves and game data stay. More about the Steam Frame:
[port/android/STEAM_FRAME.md](port/android/STEAM_FRAME.md).
## Build the game
## Playing
You do not need the Xbox SDK. The port supplies the SDK declarations that
the game uses. Refer to [port/include/xdk](port/include/xdk/README.md).
- **Y** opens the VR options in the main and pause menus: turning,
comfort, weapon hand, buttons, graphics, refresh rate and resolution.
*Quit the game* is on its last page.
- In the menus, point with a controller and pull the trigger to click.
- The controller layout is on the VR options' *Controller buttons* page.
- **Voice chat** in network games: hold the grip of the hand without the
weapon to talk (not while it holds the weapon or the Warthog's wheel).
The *BUTTONS* page can put push to talk on another button.
To build the game:
## Problems and questions
1. Install Python and [ninja](https://ninja-build.org/).
2. Install the tools for your platform. Refer to the README for the
platform.
3. In the root folder of the repository, enter `python configure.py`.
4. Enter `ninja` with the target for the platform:
Please open an [issue](../../issues) and include:
| Target | Result |
| --- | --- |
| `ninja linux` | `build/linux/halo` |
| `ninja windows` (on Windows) | `build/windows/halo.exe` and `SDL3.dll` |
| `ninja android_apk` | `port/android/app/build/outputs/apk/debug/app-debug.apk` |
- your headset and the build number of the release you installed;
- what you did and what happened;
- the game's log, `debug.txt`:
- Quest: `Android/data/io.github.cesarus85.ringfall/files/debug.txt`
(SideQuest's file manager);
- Steam Frame: `Documents/RingfallMjolnirVR/debug.txt` (and
`logcat.txt` and `install.log` beside it).
If you enter `ninja` without a target, ninja builds the game for the
computer that you use.
What is done and what comes next: [port/android/VR_ROADMAP.md](port/android/VR_ROADMAP.md).
`tools/ci_build.py` makes the same builds as GitHub Actions. For example,
enter `python tools/ci_build.py linux release`.
**Network use.** The game connects to the internet only for what you
use: the update check (GitHub), online games (to other players
directly), and join codes and lobby lists through public MQTT brokers
(listed in [brokers.txt](port/assets/network/brokers.txt)). There is no
telemetry.
### Build options
## Credits
Give these options to `configure.py`:
Ringfall: Mjolnir VR stands on the work of others:
| 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. 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". |
- **[OpenCE](https://github.com/OpenCommunityEdition/OpenCE)** (Open
Community Edition): the port of the decompiled game to Linux, Windows
and Android, with its renderer, netcode, PC menus and multiplayer, that
this project builds on and follows.
- **The decompilation**: [bnunu/halo-1](https://github.com/bnunu/halo-1),
a fork of [punpckhdq/halo](https://github.com/punpckhdq/halo).
- **[HaloCE-Quest-VR](https://github.com/moistman42069/HaloCE-Quest-VR)**
by moistman42069 (CC0): the arm IK, the forearm's twist and the
fingers' poses are taken from it (`port/linux/game/vr_arms.c`); the
left-handed mode and the graphics presets follow its ideas.
- **[HaloCE-VR](https://github.com/astromaddie/HaloCE-VR)** by astromaddie
(CC0): the Steam Frame work, whose findings (such as quieting Mesa's
trace markers) are used here.
- **[extract-xiso](https://github.com/XboxDev/extract-xiso)**: the disc
image readers follow its format handling.
- The libraries and fonts in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
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.
Thank you all.
### Optimization profiles
## Building it
The builds use profiles of the game to optimize the code:
`python3 configure.py --release`, then `ninja android_apk` (Android SDK
and NDK needed). See [port/android/README.md](port/android/README.md) for
the details and [docs/OPENCE_README.md](docs/OPENCE_README.md) for the
port it is built on.
- `pgo/halo_linux.profdata` for Linux and Android.
- `pgo/halo_windows.profdata` for Windows.
## Legal
The profiles need clang 22 or later. With an older clang, the builds do not
use the profiles.
Ringfall: Mjolnir VR is an unofficial, free, non-commercial fan project.
It is not affiliated with, endorsed, sponsored or approved by Microsoft
Corporation, Xbox Game Studios, Halo Studios or Bungie. Halo, Halo: Combat
Evolved and Xbox are trademarks of Microsoft Corporation; they are named
here only to say which game this mod works with. Meta Quest is a trademark
of Meta Platforms, Inc.; Steam and Steam Frame are trademarks of Valve
Corporation.
To record a new profile:
No game data, art, music or other content of the game is included or
distributed. You need your own, legally purchased copy of the game. If
you enjoy this mod, please support the creators of the original by buying
their games.
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/`.
This project's own code is released under [CC0 1.0](LICENSE.md), as the
decompilation it builds on. Third-party code and fonts keep their own
licenses: see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
+46
View File
@@ -0,0 +1,46 @@
# Third-party notices
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) |
| [HaloCE-VR](https://github.com/astromaddie/HaloCE-VR) | Steam Frame findings | CC0 1.0 | (its repository) |
| [SDL 3](https://libsdl.org) | windows, input, sound | zlib | (in SDL's source, `LICENSE.txt`) |
| [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) |
| [extract-xiso](https://github.com/XboxDev/extract-xiso) | reading disc images (followed by `XisoExtractor.java`, `steam_frame_xiso.py`, `xiso.c`) | BSD-style (in@fishtank.com) | [port/third_party/extract-xiso/LICENSE.TXT](port/third_party/extract-xiso/LICENSE.TXT) |
The disc image readers include this acknowledgement, as extract-xiso's
license asks: *This product includes software developed by in
&lt;in@fishtank.com&gt;.*
## 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) |
| Newtown (Roger White) | OpenCE's source | public domain | [port/assets/fonts/Newtown-LICENSE.txt](port/assets/fonts/Newtown-LICENSE.txt) |
See [port/assets/fonts/README.md](port/assets/fonts/README.md) for where
each font is used.
## Not included
No file of Halo: Combat Evolved (maps, sounds, art, music, fonts or
executables) is part of this repository or its releases. The game reads
them from the player's own disc image.
+28 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# root build script: writes build.ninja for the native ports (Linux, Windows,
# Android)
# Android) and the web port
import argparse
import io
@@ -12,7 +12,8 @@ from types import SimpleNamespace
from tools import ninja_syntax
from tools.android_build import android_configure_inputs, generate_android_build
from tools.linux_build import generate_linux_build, linux_configure_inputs
from tools.linux_build import check_profile_options, generate_linux_build, linux_configure_inputs
from tools.web_build import generate_web_build, web_configure_inputs
from tools.windows_build import generate_windows_build, windows_configure_inputs
# arguments
@@ -31,7 +32,13 @@ parser.add_argument(
parser.add_argument(
"--release",
action="store_true",
help="release builds (Linux, Windows, Android): assertions are not checked",
help="release builds (Linux, Windows, Android, web): assertions are not checked",
)
parser.add_argument(
"--profile",
action="store_true",
help="profiling builds (Linux, Windows, Android): CPU scopes recorded on a "
"console command or launch setting (README, \"Profiling builds\"); not with --pgo=train",
)
parser.add_argument(
"--lto",
@@ -44,7 +51,11 @@ parser.add_argument(
"--portable",
action="store_true",
help="x86 builds (Linux, Windows): code for any x86-64 processor (SSE2) rather than for this "
"machine's (-march=native, the default); use it for builds that run on other computers",
"machine's (-march=native, the default); use it for builds that run on other computers. On Linux it also "
"builds against Debian 11's glibc 2.31 rather than this machine's, and brings its own SDL 3 (libSDL3.so.0, "
"beside the executable), so that it starts on SteamOS and older distributions: the first build downloads "
"the Debian packages and SDL's source (build/linux/third_party, tools/linux_sysroot.py), and needs CMake, "
"pkgconf and wayland-scanner, not the 32-bit SDL 3",
)
parser.add_argument(
"--pgo",
@@ -73,7 +84,16 @@ parser.add_argument(
type=str,
help="clang with the arm64_32 target for the Android guest (default: clang)",
)
parser.add_argument(
"--web-emcc",
type=str,
help="Emscripten's emcc for `ninja web` (default: emcc on the PATH, then EMSDK's or ~/emsdk's)",
)
args = parser.parse_args()
try:
check_profile_options(args.profile, args.pgo)
except ValueError as error:
parser.error(str(error))
# the settings the builds read
sln = SimpleNamespace(
@@ -81,12 +101,14 @@ sln = SimpleNamespace(
linux_cc=args.linux_cc,
compiler_launcher=args.compiler_launcher,
port_release=args.release,
port_profile=args.profile,
port_lto=args.lto,
port_portable=args.portable,
port_pgo=args.pgo,
port_pgo_profile=args.pgo_profile,
android_ndk=args.android_ndk,
android_guest_cc=args.android_guest_cc,
web_emcc=args.web_emcc,
)
@@ -112,6 +134,7 @@ n.newline()
generate_linux_build(n, sln)
generate_android_build(n, sln)
generate_windows_build(n, sln)
generate_web_build(n, sln)
n.comment("Reconfigure on change")
n.rule(
@@ -129,6 +152,7 @@ n.build(
*linux_configure_inputs(),
*android_configure_inputs(),
*windows_configure_inputs(),
*web_configure_inputs(),
],
)
n.newline()
+196
View File
@@ -0,0 +1,196 @@
> This is the README of [OpenCE](https://github.com/OpenCommunityEdition/OpenCE),
> the port Ringfall: Mjolnir VR is built on, kept for its build and
> development notes. Its download links are OpenCE's. For the VR mod, see
> the [main README](../README.md).
# Halo: Combat Evolved for Linux, Windows, Android and the web
[![Join our Discord](https://invidget.switchblade.xyz/9gqcHyr5km)](https://discord.gg/9gqcHyr5km)
This project is a port of the Halo: Combat Evolved decompilation to Linux,
Windows, Android and web browsers. The decompilation is of the Xbox build 2342
(`cachebeta.exe`, SHA-256
`4cc87b45f721270392a96f1674ed2b5cd4a7bb4355faeab4531d1cf1884d9520`).
<img width="1289" height="995" alt="The game on Linux" src="https://github.com/user-attachments/assets/0d3ad50f-f8b8-46cf-aef8-e3661da2a7d7" />
The port starts from the decompilation of [bnunu/halo-1](https://github.com/bnunu/halo-1).
That project is a fork of [punpckhdq/halo](https://github.com/punpckhdq/halo).
## Download
GitHub Actions builds the game for each commit. These links download the
builds of the latest release:
| Platform | Release | Debug |
| --- | --- | --- |
| Linux | [halo-linux-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip) | [halo-linux-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-debug.zip) |
| Windows | [halo-windows-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-windows-release.zip) | [halo-windows-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-windows-debug.zip) |
| Android | [halo-android-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-android-release.zip) | [halo-android-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-android-debug.zip) |
| Web | [halocombatevolved.com](https://halocombatevolved.com/) (or [halo-web-release.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-web-release.zip) for your own server) | [halo-web-debug.zip](https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-web-debug.zip) |
Use the release build to play. The debug build stops at the first failed
assertion and writes it to the log. Use the debug build to find and report
problems.
The game updates itself. At start-up it looks for a newer release, and asks
if you want to install it. Refer to "Updates" in
[port/linux/README.md](../port/linux/README.md#updates). The web site offers
each new build when it is out.
Each build of the `main` branch that passes on all four platforms is a new
release, and the web site then plays it. The [Releases](https://github.com/OpenCommunityEdition/OpenCE/releases)
page keeps the last five releases. If the latest build has a problem, get
an older build from that page.
## Game data
The port does not include the game data. Make an Xbox disc image from your own copy
(`.xiso` or `.iso`) of Halo: Combat Evolved. All versions of the game
operate. The maps of the European (PAL) version were made for a slower
console. The port changes them to play as the North American (NTSC) maps do,
so players of the two versions can play together.
1. Start the game.
2. At the first start, the game asks for the disc image. Select it.
3. The game extracts the `maps/` folder. Then the game starts.
On Linux and Windows, the game puts `maps/` next to the executable. On
Android, copy the disc image to the phone first. The app puts `maps/` in its
data folder. Refer to [port/android/README.md](../port/android/README.md). On
the web, the page copies `maps/` out of the disc image into the browser's
storage for the site; nothing is uploaded. Refer to
[port/web/README.md](../port/web/README.md).
## Platforms
Each platform has its own instructions:
| Platform | Instructions |
| --- | --- |
| Linux (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/linux/README.md](../port/linux/README.md) |
| Windows (32-bit x86 executable, OpenGL 4.5, SDL3) | [port/windows/README.md](../port/windows/README.md) |
| Android (arm64 app, OpenGL ES 3, SDL3) | [port/android/README.md](../port/android/README.md) |
| Web (WebAssembly, WebGL 2, SDL3; desktop Chrome, Edge and Firefox) | [port/web/README.md](../port/web/README.md) |
The Linux README also gives the controls, the settings and the multiplayer
functions. These are almost the same on all platforms.
## Multiplayer
The game can play system link games on a local network and on the internet:
- A system link game can have up to 128 players on up to 128 machines.
- Linux, Windows and Android machines and browsers can play in the same
game. On a local network, the native builds play system link; browsers
play each other in rooms, over WebRTC
([port/web/README.md](../port/web/README.md)).
- An invite link, or the server browser, lets a machine join a game on the
internet, browsers too: a native build takes a browser's WebRTC
([port/linux/README.md](../port/linux/README.md), "Browsers"). No server of
this project is necessary.
- The netcode is new. Each machine moves its own player at once,
and the host makes the decisions for the game. Refer to
[port/linux/NETCODE.md](../port/linux/NETCODE.md).
## Build the game
You do not need the Xbox SDK. The port supplies the SDK declarations that
the game uses. Refer to [port/include/xdk](../port/include/xdk/README.md).
To build the game:
1. Install Python and [ninja](https://ninja-build.org/).
2. Install the tools for your platform. Refer to the README for the
platform.
3. In the root folder of the repository, enter `python configure.py`.
4. Enter `ninja` with the target for the platform:
| Target | Result |
| --- | --- |
| `ninja linux` | `build/linux/halo` |
| `ninja windows` (on Windows) | `build/windows/halo.exe` and `SDL3.dll` |
| `ninja android_apk` | `port/android/app/build/outputs/apk/debug/app-debug.apk` |
| `ninja web` | `build/web/site/`, the web site (`python tools/web_serve.py` serves it) |
If you enter `ninja` without a target, ninja builds the game for the
computer that you use.
`tools/ci_build.py` makes the same builds as GitHub Actions. For example,
enter `python tools/ci_build.py linux release`.
### Build options
Give these options to `configure.py`:
| Option | Result |
| --- | --- |
| (none) | A debug build. A failed assertion stops the game. |
| `--release` | A release build. The game does not examine assertions, as in the retail game. |
| `--portable` | The Linux and Windows builds operate on all x86-64 processors. The Linux build also operates on older distributions and on SteamOS: refer to "Portable build" in [port/linux/README.md](../port/linux/README.md#portable-build). Use this option for builds that you give to other persons. |
| `--lto=thin`, `--lto=off` | Less link-time optimization. The link is faster. |
| `--pgo=off` | No profile-guided optimization. |
| `--pgo=train` | Records a new optimization profile. Refer to "Optimization profiles". |
| `--profile` | A profiling build. It records CPU times. Refer to "Profiling builds". |
| `--web-emcc PATH` | The Emscripten `emcc` for `ninja web` (else the one on the PATH, or `~/emsdk`'s). |
Without `--portable`, the Linux and Windows builds use all the instructions
of the processor that builds them (`-march=native`). Such a build does not
always start on a different computer.
### Optimization profiles
The builds use profiles of the game to optimize the code:
- `pgo/halo_linux.profdata` for Linux and Android.
- `pgo/halo_windows.profdata` for Windows.
The profiles need clang 22 or later. With an older clang, the builds do not
use the profiles.
To record a new profile:
1. Delete the profile.
2. Enter `python configure.py --pgo=train`.
3. Enter `ninja linux` or `ninja windows`.
The build then plays the main menu and the first minute of each campaign
level. This procedure continues for approximately 15 minutes. The game
data must be in `assets/`.
### Profiling builds
A profiling build records where the game spends its time.
1. Enter `python configure.py --profile`, then build as usual.
2. Start a recording. Enter `profile_record` in the console (or the telnet
console), or set `debug.profile_record = true` in `config.toml`. To
record each game of a session, also set
`debug.profile_record_when = "game"`.
3. Stop the recording. Enter `profile_stop`, load another map, or quit.
The game writes numbered `.part<n>.json` files under `profiles/` in its data root
and logs their paths. Parts are retained after the recording stops. Logs go to
standard error; a Windows release build without standard error writes them to
`halo.log` next to `halo.exe`.
`tools/net_report.py` is a helper for developers. Enter
`python tools/net_report.py <recording.part1.json>` to read a recording
as tables. The command also writes `<recording>.summary.txt`, a short text
file.
| Command | Result |
| --- | --- |
| `profile_record [seconds]` | Starts a recording. With a number of seconds, the recording stops after that time. |
| `profile_stop` | Stops the recording. |
`debug.profile_memory` sets the memory of a recording in MB (default 256,
from 4 to 1024). A recording has no length limit:
the game writes a part each time the memory is full. Refer to "Settings" in
[port/linux/README.md](../port/linux/README.md).
On Android, the files are in `/sdcard/Android/data/com.halo.decomp/files/profiles/`.
Enter `adb pull` to copy the recording parts to a computer.
A profiling build plays with normal builds. It does not use
`--pgo=train`.
+656
View File
@@ -0,0 +1,656 @@
# Halo Custom Edition maps
The native builds (Linux, Windows, Android) load and run Halo Custom Edition
caches (`.map`, cache version 609), with the Custom Edition resource maps
`bitmaps.map`, `sounds.map` and `loc.map`. A cache that needs OpenSauce
(its OpenSauce header asks for memory upgrades, mod data files or the like)
is refused, and `.yelo` files are never looked for; a `.map` that only
carries OpenSauce's header and its `project_yellow` tags (SPV3's
backwards-compatible releases) is run as stock Custom Edition runs it,
neither read. The loader and the conversions come from DamnationCE
(xshxdex98/DamnationCE), whose documentation this file grew from; in this
build a Custom Edition map's tags are also checked by the tag validator, as
the Xbox maps' are, before anything reads them
(`port/linux/game/tag_validate.c`, [Checks](#checks)).
## Running a map
Custom Edition maps run when the `game.custom_edition` setting is on, as it
is by default (`custom_edition = true` under `[game]` in `config.toml`, or
`HALO_CUSTOM_EDITION`). Put the maps and Custom Edition's `bitmaps.map`,
`sounds.map` and `loc.map` in the data root's `custom_maps` folder, beside
its `maps` folder, which holds the game's own maps only; or set
`paths.custom_edition` to a Halo Custom Edition install, whose `maps` folder
(the Xbox drive `h:\`) is looked in after `custom_maps`.
A Custom Edition map's level name is `custom_maps\<name>`
(`custom_edition_cache.h`, `CUSTOM_EDITION_LEVEL_NAME_PREFIX`), which none
of the game's own levels has: only such a name is loaded from the Custom
Edition folders, and never from `maps`. So a map named as one of the game's
own levels (CMT's `a30.map`, Custom Edition's `bloodgulch.map`) is played as
itself, apart from the level of its name, in the menus, the loader and
network games, and is never taken for one of the campaign's levels (saved
games, progress, the next level). The game engine keeps 63 characters of a
level name, so a map whose file name is longer than 51 is not listed.
Maps keep their names. A network game's level name is the map's own
(`custom_maps\hugeass`), and its game record's map version is the first 32
bits of the map's hash (`port/linux/game/map_share_files.c`), so a client
plays its file of that name and version, whatever else it has of the name
(`custom_edition_map_find_version`); a player without it downloads it
(`port/linux/NETCODE.md`, "Map sharing"). A download beside another version
of its name is named `hugeass_0e2b3ee4.map`, the first 8 hexadecimal digits
of its hash after `_`, and the menus show it as "Hugeass (2)". A level name
without the hash (a saved last map, a test's) finds the one map of that
name in `custom_maps`, and one with it (an earlier build's, which named
every map so) the map given its name back. Maps in a Custom Edition
install's folder are found and shared as `custom_maps`' are.
A map needs only the resource maps that hold its tags: its tag index says
which (`cache_file_resource_maps_needed`, `cache_file_formats.c`: a tag
whose entry's word at 0x18 is set is held whole by `bitmaps.map` for a
bitmap, `sounds.map` for a sound, `loc.map` for a font, string list or HUD
message text). A map without such tags of a kind plays without that
resource map; one that lacks a resource map it needs is refused, saying
which.
### In the menus
The map lists (New Game's, and the Map screen of Create Game's INTERNET and
LAN and of split screen) have four kinds in their first row's chooser:
SINGLEPLAYER (the campaign's levels), MULTIPLAYER (the Xbox's maps), and
CUSTOM SINGLEPLAYER and CUSTOM MULTIPLAYER, the Custom Edition maps of the
custom maps folders by the scenario type their files give (solo or
multiplayer). A map is played as its kind says
(`port/linux/game/menu_functions.c`):
- **New Game**: a CUSTOM SINGLEPLAYER map is played as the campaign's
levels are, at the difficulty chosen next (alone, or with split screen
co-op's two players); a CUSTOM MULTIPLAYER map is walked around alone, as
MULTIPLAYER's are.
- **The Map screen**: a CUSTOM SINGLEPLAYER map lists the difficulties and
is hosted as a network co-op game (Server Setup, then the lobby), as the
campaign's levels are; a CUSTOM MULTIPLAYER map goes on to the gametypes,
as MULTIPLAYER's do. Split screen hosts only the multiplayer kinds.
The Xbox menus' multiplayer level list offers the Custom Edition multiplayer
maps after the thirteen Xbox levels. The pregame lobby shows a Custom
Edition map by its name and picture, multiplayer or campaign, and the game
lists by its name (`port/linux/game/custom_edition_maps.c`). The folders are
looked in once, and again each time a map list opens, which then shows maps
added since. A map is listed under its file's name, `the_bay_of_pigs.map` as
"The Bay Of Pigs". Two optional files beside the map give it what the Xbox
levels have:
- `<name>.bmp`, its picture: an uncompressed 24-bit or 32-bit Windows
bitmap, up to 8192 pixels a side; the middle of it is shown with the
shape of the level pictures, 140 by 114 (`port/linux/game/bmp_files.c`,
which reads it as untrusted input).
- `<name>.txt`, its description: plain text, its lines shown as written.
Without one, the map is described as "Halo Custom Edition map".
A machine joining a network game on a map it has not (a Custom Edition map
not in its `custom_maps`, or one of the game's own not in `maps`) leaves it
with an error that names the map and the folder to copy it into, rather
than the damaged disc error precaching a missing map gives
(`cache_files_map_present`, called as the host's settings arrive): also when
the map cannot run there (Custom Edition maps turned off, the file not a
Custom Edition cache, or `bitmaps.map`, `sounds.map` and `loc.map` missing),
when its copy of a Custom Edition map is another version than the host's
(the host sends its map's hash's first 32 bits as the game record's map
version, which the Xbox left 0: `cache_files_map_version`; a host that sends
0 is not checked), and when the host names a Custom Edition map as other
versions of this port do (`levels\test\<name>\<name>`) and the file is in
`custom_maps`. The
error's text is wrapped to its dialog, in the menus' smaller font when it
would not fit; `debug.txt` has the details.
A Custom Edition campaign map has no next level: winning it ends the game
as the campaign's last level does (alone) or plays it again (network
co-op), and it is not the campaign's saved game.
### From the console
`map_name` takes the level name: `custom_maps\<name>` for a Custom Edition
map (`custom_maps\hugeass` plays `custom_maps\hugeass.map`). A multiplayer
scenario needs a game variant, or no starting location qualifies and no
player spawns:
```
game_variant slayer
map_name custom_maps\hugeass
```
## What the native builds do
- **With `game.custom_edition` on**, the platform reserves the address
window Custom Edition tag data is linked to, `0x40440000`-`0x41B40000`
(`port/linux/src/xbox_memory.c`, at start-up; 23 MB of address space,
backed as it is touched), and a Custom Edition map is loaded into it,
converted as below, checked, and run. It is read in place, never copied
to the cache partition: every read the game makes of it (structure BSPs,
bitmap pixels, sound samples) is served from the map, `bitmaps.map`,
`sounds.map` or the sounds decoded at load, according to where its offset
falls in their combined offset space (`custom_edition_cache_read`). A map
that fails any check is logged in `debug.txt` and not loaded: the game
goes back to its menus.
- **With it off**, a Custom Edition cache is named and refused, instead of
being rejected as "an old version" of this build's caches; so is one put
in `maps`, which is told to go in `custom_maps`.
- **Xbox caches** take the path they took before.
The desktop builds' Xbox memory window is 512 MB (Android's 128 MB), in
which the texture cache is 256 MB: Custom Edition maps draw more texture
than the Xbox's 22 MB cache holds, and their geometry is made into
Direct3D buffers in the same memory (`port/linux/include/halo_port_capacity.h`).
## Loading and conversion
### The loader (`cache_file_formats.c`)
`port/linux/game/cache_file_formats.c` (`cache_file_formats.h`) holds the
format code: standalone C with fixed-width types and no game dependencies,
compiled into the native builds (as every `port/linux/game` unit is) and
into the report tool. Map files are untrusted input: every field is read
little-endian from a bounds-checked buffer, never through a structure laid
over the file, and every offset, count and size is checked before use, with
overflow-safe arithmetic. `port/tools/cache_file_report.c` reports what can
be done with each file:
```sh
clang --target=i686-pc-windows-msvc -fuse-ld=lld -Iport/linux/game \
port/linux/game/cache_file_formats.c port/tools/cache_file_report.c -o cache_file_report.exe
cache_file_report [--maps DIRECTORY] [--dump-tags FILE] FILE...
```
Resource maps are looked for next to the cache (or in `--maps`):
`bitmaps.map`, `sounds.map` and `loc.map`. `--dump-tags` writes the converted
tags to a file, as they would sit at `0x40440000`.
Loading, step by step (`custom_edition_cache_load`):
1. The header: signatures, version 609, terminated strings, file length (0,
as Invader leaves it, for the whole file) within the file and the size
limit (`0x30000000`: Halo PC's was `0x18000000`, and Invader builds
larger maps, which Chimera runs), no compression, tag data within the
file and the tag cache (23 MB). A cache with OpenSauce's header (its
`yelo` signature at offset `0x70`, which Custom Edition leaves as
padding) that sets any of its flags (memory upgrades, mod data files and
the rest) is refused; one that sets none is a Custom Edition cache.
2. The tag data is read to the start of the tag cache, which stands for
`0x40440000`.
3. The tag index (`tags` signature), every tag instance (its handle must
match its position, its name must lie in the tag data and be terminated,
its address must lie in the tag data; only structure BSPs may have none;
only bitmaps, sounds, fonts, unicode string lists and HUD message text may
be held by resource maps; OpenSauce's `project_yellow` and
`project_yellow_globals`, which nothing reads, are left as they are), the
model data range, and the scenario tag.
4. Every structure BSP: its tag, its range in the file, its place in the tag
cache (above the tag data, within the tag cache), its header (`sbsp`
signature, no Xbox vertex buffers, its structure pointer inside it), and
its lightmaps and their materials: every block within the BSP and aligned,
every material's uncompressed environment vertices (56 bytes) and
lightmap vertices (20 bytes) within the BSP and of the size their counts
give, at most 64000 of each (the game's limit, to which the validator
cuts a material's counts), lightmap vertices exactly when the lightmap
has a bitmap, and every normal, binormal, tangent and incident radiosity
within ±1.005 (the game's vertex compressor asserts on anything further
out).
5. The header checksum is computed as OpenSauce computes it, before anything
changes the tag data; a mismatch is reported, not refused.
6. The tags held by resource maps are read into the tag cache after the tag
data, below the lowest structure BSP, and their pointers relocated. A
sound's header in the map lacks its sample rate, encoding, compression
and longest permutation length; they are taken from the header's copy in
`sounds.map` ([Evidence](#observed-not-stated-by-the-sources-established-on-the-sample-maps)).
7. Every bitmap, sound, font, unicode string list, HUD message text, model
(`mod2`) and transparent chicago shader (`schi`, `scex`) is walked through
its blocks: every block and data pointer must lie within the loaded tags,
every bitmap's pixels and sound's samples within their file, and every
model part's strip and vertices within the model data, of the kinds Custom
Edition writes (a precompiled strip, uncompressed 68-byte vertices), with
at most 65535 vertices and at most 22 local nodes.
Then `custom_edition_cache_convert` changes what only needs its bytes
changed:
- **Shader types.** Custom Edition inserted *transparent chicago extended*
as shader type 7, so its water, glass, meter and plasma shaders are types
8 to 11 where this build has 7 to 10 (and would draw a Custom Edition glass
shader as meter, and so on, unnoticed). Every shader gets this build's
number for its group; a type field saying otherwise (as map protection
leaves them) is counted and given the group's.
- **Transparent chicago extended shaders** (`scex`) become transparent
chicago shaders (`schi`): the two layouts agree up to the maps, of which
`scex` has two sets, for four and for two texture stages; the four-stage
maps are kept (this build draws four stages), or the two-stage ones when
there are no others, and the extra flags move to where `schi` has them.
- **Bitmaps and sound permutations** get the state of ones not yet drawn or
played, and name their own tags: `bitmaps.map` and `sounds.map` hold the
tag handles of whatever map they were built with.
- **Sounds this build cannot decode** (Custom Edition's Ogg Vorbis) are made
unplayable, by emptying their pitch ranges, which the game skips.
- **Animation overlays naming animations that do not exist** are made to
name none, which the game skips. Maps built with the editing kit can have
them; Custom Edition reads past the graph's animations there, and this
build's debug builds stop on it.
- **HUD elements drawn from double-resolution bitmaps.** Halo PC added a
third scaling flag to HUD placements, *use high resolution scale*
(OpenSauce `hud_definitions.hpp`), and draws a flagged element at half the
size of its bitmap. Custom Edition's HUD bitmaps are made for that: in
`bloodgulch.map` every flagged element draws a bitmap exactly twice the
size of the one its Xbox counterpart draws (256×64 for 128×32, 512×512 for
256×256), with the same scale, and every unflagged one a bitmap of the same
size ([Evidence](#observed-not-stated-by-the-sources-established-on-the-sample-maps)).
This build ignores the flag, so it drew them twice too large; the
placements of the unit, weapon and grenade HUD interfaces and the HUD
globals' messages that have it get half their scale, and lose the flag (32
in `bloodgulch.map`). Halo PC reads the flag only on statics, meters and
numbers (Chimera's `hud_bitmap_scale.cpp`): crosshair and overlay items
keep their scale. A number's scale is not its
digits', so a flagged number keeps the flag and `hud_draw_numbers` draws
its digits at half their size, spaced as the digits tag says. A bitmap may ask the
same of every element that draws it, with Halo PC's bitmap flags *half hud
scale* and *force hud use highres scale* (Invader's `bitmap.json`): an
element drawing such a bitmap (a static or meter, its bitmap after its
placement; a crosshair's or overlay's items, their crosshair's or
overlay's) gets half its scale too. None of the 18 Custom Edition maps on
hand, nor `bitmaps.map`, sets either.
- **Maps made around Halo PC's own behaviour.** Chimera fixes Halo PC to draw
as the Xbox does, and keeps a list of the maps made around Halo PC's way
instead, by map name (in lower case) and tag data checksum, with the
behaviours each relies on (`map_hacks_config.json`, by SnowyMouse; here
`port/linux/game/custom_edition_behaviours.inc`, generated from it). This
build draws as the Xbox does, so for a listed map it follows these where it
can, and logs each: HUD multitexture overlays' blend functions in Halo PC's
order (`gearbox_multitexture_blend_modes`: Halo PC picks its shader by the
Xbox's value from shaders in alphabetical order), overlays not drawn
(`block_multitexture_overlays`), the HUD digits' metrics halved and every
number's digits drawn at half size (`hud_number_scale`), bitmaps' HUD scale
flags cleared (`disable_bitmap_hud_scale_flags`), and model shaders'
detail after reflection flag flipped (`invert_detail_after_reflection`).
Not yet: Halo PC's fixed-function meters (`gearbox_meters`), its
transparent chicago multiply, bump attenuation and environment shader
types, the old widescreen HUD and embedded Lua.
- **The score hint.** String 100 of `ui\multiplayer_game_text` is Halo PC's
`Hold "%s" for score`, which Halo PC fills in with its score key; this
build copies it as it is (`game_engine.c`, the press-back-for-score
message), so `"%s"` becomes `BACK`, the button this build reads for the
score, in the same four characters.
### In the game (`custom_edition_cache.c`, `custom_edition_geometry.c`, `custom_edition_bitmaps.c`)
- **Models.** Every `mod2` part is given this build's part layout (the first
0x68 bytes of the 0x84-byte Custom Edition part are the same fields; the
parts are repacked in place), its vertices are compressed from the map's
model data by the game's own `rasterizer_geometry_compress_vertices`, its
node indices made the model's when its model's parts have local nodes, and
its strip copied unchanged; the game's own `rasterizer_vertex_buffer_new`
and `rasterizer_triangle_buffer_new` make its buffers, and the tags become
`mode` tags. Before any of that, every index a part holds is checked
against what it names (shaders, nodes, parts, vertices). This build's
renderer skins at most 43 nodes at once, so a model of more is drawn a
part's own nodes at a time: a model of more whose parts have no local
nodes is given them, each part the nodes its vertices name, and is refused
only when a part's vertices name more than a part holds.
- **Structure BSPs.** When the game loads a BSP, every material's vertices
are compressed the same way (environment and lightmap vertices), and the
material gets buffers and the compressed vertices the game reads for
visibility, collision and lights (`scenario_structure_bsp_load` in
`cache_files.c` calls `custom_edition_structure_bsp_load`).
- **Bitmaps.** Every bitmap must pass the game's own `bitmap_verify` and be
of a kind the texture cache and the swizzling code handle (a format with a
hardware texture, a compressed flag that agrees with the format,
power-of-two sizes unless linear, square cube maps, all its pixels
present). Halo PC draws a 2D bitmap of any size, so an uncompressed one
whose sides are not powers of two is made linear as the map loads, and
drawn from its first level (birdcage-plus's 3840 by 64 needler plasma).
Its pixels are laid out for Halo PC, levels as the tag stores them,
unswizzled; as they arrive, the game's own
`rasterizer_xbox_bitmap_rebuild_hardware_format` lays them out as an Xbox
cache would (swizzled, cube maps face by face, padded).
- **Chicago extra layers.** January's transparent chicago shader draws its
extra layers in a loop that never advances
(`rasterizer_xbox_transparent_geometry.c`, marked as a preserved bug). No
Xbox map has such layers, so January never hung on it, but Custom
Edition's editing kit can make them: the native builds advance the loop.
- **Channel orders.** Halo PC keeps two kinds of texture in other channels
than this build reads them from (`enum custom_edition_channel_order`,
[Evidence](#observed-not-stated-by-the-sources-established-on-the-sample-maps)):
a model shader's *multipurpose map*, whose auxiliary (detail) mask,
self-illumination, specular and color change masks Halo PC keeps in red,
green, blue and alpha, where this build's model shaders read specular from
red, self-illumination from green, color change from blue and the
auxiliary mask from alpha; and a *HUD meter*, whose shape Halo PC keeps in
color and fill order in alpha, where this build's meter shader
(`rasterizer_xbox_dynavobgeom.c`) compares the color with the meter's value
and discards what has no alpha. Reordering the pixels would mean
decompressing them, which the texture cache has no room for, so the game
finds the bitmaps drawn as multipurpose maps (by model shaders) and as
meters (by unit and weapon HUD interfaces) when the map loads, and tells
the renderer where each bitmap's pixels arrive and in which order their
channels are; the renderer samples them with each channel taken from where
Halo PC keeps it (`port/linux/src/xbox_textures.c`, a texture swizzle,
which leaves them compressed). A bitmap also drawn another way (a
multipurpose map that is also a base map, say) keeps its channels, since
the renderer has one order for each texture, and is logged: 3 in
`bloodgulch.map`, all multipurpose maps.
- **Reads.** The renderer write-protects the memory it has made textures of
and learns of changes from the faults writes take; the kernel fails a read
into such memory instead. Every read of the map is therefore made into a
64 KB staging buffer and copied, as the platform's file layer does.
- **Texture memory.** A frame of `Elite_Alpha_Siege.map` draws more than the
22 MB of textures the Xbox texture cache holds, and textures that did not
fit were drawn as the default one. The desktop builds' texture cache is
256 MB, in a 512 MB memory window (`halo_port_capacity.h`), for every map;
Android's is the Xbox's.
- **Scripts.** A compiled script names each function it calls, and each
engine global it uses, by its index in the engine's tables, and Halo PC's
tables have entries this build's do not, in among the ones both have: from
some point on, an index names another entry here
([Evidence](#observed-not-stated-by-the-sources-established-on-the-sample-maps)).
`hugeass.map`'s day and night script would call `playback` instead of
`switch_bsp`. A compiled script also keeps every name, in its string data,
so every call and every engine global is found again by name with the
game's own `hs_find_function_by_name` and `hs_find_global_by_name`
(`custom_edition_scripts.c`); one this build does not have does nothing
and is logged by name (Limits, below). The value types are numbered
alike in both builds.
- **Multiplayer vehicle placement.** This build places a multiplayer game's
vehicles by type: only the first three of the globals' multiplayer
vehicles are created, and of those the ones the variant's vehicle set
names (`game_engine_remap_vehicle`; the built-in variants name the first).
Custom Edition maps are made for retail Halo's rules: a vehicle placement's
multiplayer spawn flags name the game types it is placed in by default,
and a script may create any vehicle. `hugeass.map` depends on that (above).
For a Custom Edition map in slayer, capture the flag, king of the hill or
oddball, the native builds place the vehicles whose spawn flags name the
game type by default and let any vehicle be created
(`custom_edition_objects.c`, called from `object_types_place_all` and
`game_engine_remap_vehicle`); a variant without
vehicles still has none, and race, which has no spawn flag, keeps this
build's rule.
- **Multiplayer vehicles.** `game_engine_predict_resources` takes the three
multiplayer vehicles Xbox globals always have; a Custom Edition map's
globals can list fewer, and oddball stopped on such a map. With fewer than
three, the native builds predict none (`game_engine.c`).
## Checks
The loader (`cache_file_formats.c`) checks the container: the header, the
tag index, every structure BSP, every resource-held tag and its relocated
pointers, every bitmap pixel and sound sample range, every model part's
geometry in the model data. Then, once the loader and the conversion of
bytes have made the tags this build's layout, the tag validator checks
every tag as it checks an Xbox map's (`tag_validate_custom_edition_tags`,
called by `custom_edition_cache_tags_load`): every block and data within
the loaded tags and overlapping no other's, counts cut to the game's
capacities, indices, enums and tag references corrected, runtime values
reset, then the graph checks (collision BSPs, node trees, scripts' syntax),
and a tag name that lies in any tag's block or data is emptied (what the
game writes there could take away its end). A map whose pointers cannot be
trusted is refused; what can be corrected is, and logged. What differs
from an Xbox map:
- the tag header has no vertex or index buffers, and the tag cache is the
Custom Edition one (23 MB);
- bitmap pixels and sound samples are in several files: their offsets must
lie in the map, the sounds decoded at load, `bitmaps.map` or `sounds.map`;
- models are gbxmodels (`mod2`), whose parts keep their geometry in the
map's model data and a table of local nodes, which are checked against
the model's (`tag_schema_custom_edition_groups`, in `tag_schema_models.c`);
the game takes them as models;
- a structure BSP's material vertices are uncompressed, and are checked
against its uncompressed vertex data.
Only after the validator has passed the tags are the scripts given this
build's function and global indices, the bitmaps checked for the texture
cache, and the models and each structure BSP (checked as it loads, as an
Xbox map's) given compressed vertices and buffers. The script function
allowlist (`hs.c`) applies to Custom Edition maps as to every map.
`build/linux/map_validate` runs the loader, the conversion of bytes and the
validator on maps outside the game, with the resource maps beside each map
or in `--maps`; `--fuzz` changes words of a map's file at random.
## Tested
### Automated tests
- `python -m pytest tools/test_cache_file_formats.py` (130 tests; needs
clang): synthetic Custom Edition caches and resource maps, caches that
need OpenSauce refused and those that only carry its data loaded, every
check of the loader and the conversion, and seeded random corruptions,
through `port/tools/cache_file_report.c`. With `HALO_CUSTOM_EDITION_MAPS`
naming a folder of maps, four more check real maps: against DamnationCE's
recorded results (which are of its copies of the stock maps), and that any
`.yelo` map among them is either refused or a Custom Edition cache.
- `python -m pytest tools/test_bmp_files.py` (41 tests): the map pictures'
reader.
- `tools/test_linux_port.py`: with `HALO_CUSTOM_EDITION_MAPS` (or the
gitignored `assets/custom_edition`), every map is loaded and checked and
none refused, and three are fuzzed (100 runs each); the retail Xbox maps
still need no correction.
### Maps
Two folders were checked with `map_validate`: the 20 stock Custom Edition
maps (the halopc-restored r220 copies) and 19 community maps (`area53`,
`bigass`, `deltaruins`, `Elite_Alpha_Siege`, `extinction`, `Fission_Point`,
`Floodgulch5.1`, `hugeass`, `immure`, `tactics`, `the_bay_of_pigs`,
`the_rise_of_asis`, `WW_battlefield` and copies of stock levels). None is
refused. The stock maps need one correction each, a character of
`loc.map`'s gamespy font 5 pixels narrower than nothing; the community maps
some more, their own data's mistakes (an enum past its values, a region
index past its block, a particle state of a sequence with no sprites).
Fuzzing, 200 runs of each of those 39 maps (7,800 runs): no crash, no hang,
and every map let through clean when checked again.
Run in the game (Linux release build, 40 to 50 seconds each, slayer):
`hugeass`, `bigass`, `extinction`, `deltaruins`, `Elite_Alpha_Siege`,
`the_rise_of_asis`, `area53`, `tactics`, `Floodgulch5.1`, `immure`,
`WW_battlefield`, `the_bay_of_pigs` and `Fission_Point` load, are checked,
converted and played to the end of the run without a crash; `hugeass`'s
hangar, HUD and first-person weapon were seen drawn.
Two campaign maps were checked with `map_validate`: CMT's `a30.map` (2006,
"Halo"; 13 corrections) and CMT SPV3's backwards-compatible `a50.map` (2012,
The Truth and Reconciliation, with OpenSauce's header setting no flags and
its two `project_yellow` tags; 43 corrections); `a30` was also run. From the
menus (on a virtual display): New Game's CUSTOM SINGLEPLAYER `a30` played as
a campaign level at the difficulty chosen; the Map screen's (LAN) listed its
difficulties, set up co-op in Server Setup and showed it in the lobby as
co-op. With `debug.network_test`, two machines on loopback played
`custom_maps\a30` as network co-op and `custom_maps\hugeass` as slayer; a
joining machine with a maps folder missing the host's Xbox map was shown the
error and left the game. A joining machine without `hugeass` (or without
`sounds.map` and `loc.map` too) downloaded them and played (map sharing,
`port/linux/NETCODE.md`): in the lobby, into a game in progress, from the
host and another player at once with a second machine downloading beside it
(on loopback, and through internet play's tunnels with a local broker), each
file the same as the host's; with `network.map_download` off, or a host with
`network.map_share` off, it was shown the error and left.
### Not tested
- **Playing**: nobody played the maps through.
- **Android** builds, and was not run.
## Evidence
### Sources
- **OpenSauce** (GPL-3.0, Kornner Studios): the `OpenSauce-master.zip`
archive of the upstream repository (SHA-256
`f1097c9cf5975a208506c78c3af47d6b893f998a9f47dddf89c9bbcc1f4b7c73`; newest
entry dated 2016-01-15; `yelo_version.hpp` names OpenSauce 4.0.0).
- **Reclaimer** (GPL-3.0, Gravemind2401): the `Reclaimer-master.zip` archive
(SHA-256 `72fd5270abbdf662ff07a54e9cce80a6158e479c51ecfdcd979f7f9fbd6c2d03`;
newest entry dated 2026-09-20).
- **The Xbox maps of retail build 01.10.12.2276** (`bloodgulch.map`,
decompressed): not the 01.01.14.2342 build this repository reconstructs,
but their model parts have its 0x68-byte layout and their shader types its
numbering, which makes them a check on what the conversion produces.
OpenSauce and Reclaimer are GPL-3.0 and this repository is CC0: no code from
either was copied. They were read as documentation of the file formats, and
every layout used was then checked against the sample maps.
### Documented facts (source, checked against the sample maps)
| Fact | Source |
| --- | --- |
| Cache header layout, version 609, `head`/`foot` signatures | OpenSauce `blamlib/Halo1/cache/cache_files_structures.hpp` (`s_cache_header`) |
| 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` |
| Sound definition, pitch range, permutation layouts; samples-in-`sounds.map` flag | OpenSauce `sound/sound_definitions.hpp` |
| Gbxmodel layout: the part (0x84 bytes) is this build's 0x68-byte part followed by a local node count and a 22-entry node table | OpenSauce `models/model_definitions.hpp` (`gbxmodel_geometry_part`) |
| Where a part's strip and vertices lie in the model data (triangle count, strip offset, vertex count and offset in the buffer fields; 68-byte vertices) | Reclaimer `Blam/Halo1/GbxmodelTag.cs` (`ReadPCMeshes`) |
| Shader types: *transparent chicago extended* inserted at 7, before water, glass, meter and plasma; its two map sets | OpenSauce `shaders/shader_definitions.hpp` |
| Script syntax nodes: 19001 in the stock engine | OpenSauce `hs_constants.hpp` |
| HUD message text layout | OpenSauce `interface/hud_messaging_definitions.hpp` |
| Font (156 bytes) and unicode string list layouts | BlamLib `Blam/Halo1/Tags/Definitions/Misc.cs`, `Resources.cs` |
| Bitmap tags in `bitmaps.map` by index, pixels by absolute offset | Reclaimer `Blam/Halo1/CacheFile.cs`, `BitmapTag.cs`, `BitmapsAddressTranslator.cs` |
| The header checksum: CRC-32 of the structure BSPs (packed from 0x800), the model data and the tag data, not inverted | OpenSauce `cache_files_yelo.cpp` (`CalculateChecksumFromMemoryMap`), `memory_interface_base.cpp` (`CRC`); reproduces the stored checksum of 23 of the 24 sample caches |
### Observed (not stated by the sources; established on the sample maps)
| Fact | Evidence |
| --- | --- |
| Structure BSPs load at the top of the tag cache (`address + size` = `0x41B40000`) | all 24 caches (those with OpenSauce's memory upgrades at the top of its larger tag cache) |
| Bitmaps, fonts, unicode string lists and HUD text held by resource maps: the tag's address field is the entry index, the entry's name is the tag's path, the entry is the whole tag with addresses counting from its start | 9,550 bitmaps, 68 fonts, 2,248 string lists, 23 HUD texts across the sample |
| Sounds held by `sounds.map`: the map keeps the 0xA4-byte header, whose pitch range block has a count and no address; the entry named by the tag path holds the header again, then the pitch ranges and permutations, whose addresses count from the first pitch range | 7,397 sounds, 21,870 sample ranges |
| The map's copy of a resource-held sound's header has its compression and longest permutation length zero, and its encoding and sample rate zero too; the entry's copy has them as the Xbox has them | in `bloodgulch.map` and `beavercreek_halo3.yelo`, the two copies of all 527 resource-held sounds differ only in those four fields, in pointers, and in the map's own promotion sound reference; for the 315 sounds the Xbox `bloodgulch.map` shares, the entry's sample rate, encoding and the runtime fields after the longest permutation length equal the Xbox's; its compression does except for the 38 that Custom Edition has as Ogg Vorbis, and its longest permutation length for 273 (the others differ by a few milliseconds or more, among them the Ogg Vorbis ones); its pitch ranges' runtime fields all do |
| Sound permutations and resource-held bitmaps name tags of another map (the one the resource map was built with) | 632 of 676 bitmaps of `bloodgulch.map`; every resource-held permutation differs from the Xbox's own sound handle |
| Model parts: the strip and vertex data are in the model data, never in the part's blocks; the triangle buffer is a precompiled strip and the vertices uncompressed; the part flag 2 is set on exactly the parts that have a node table | all 24 maps (10,934 parts) |
| Converting a part's vertices with the game's compressor reproduces the Xbox's | for the 335 parts of the 60 models the Custom Edition and the Xbox `bloodgulch.map` share: identical strips; positions equal to within float rounding; texture coordinates and node weights exactly what the compressor makes of the Custom Edition values (clamped to ±1, as the Xbox also has them: `plant_broadleaf_short` has coordinates up to 3.0 in both); node indices the Custom Edition ones times 3, except that the Xbox writes an unweighted second node as -1 where the compressor writes the node Custom Edition names; normals within 0.002 |
| Every node index of a vertex names a node of its part's table (in models whose parts have local nodes) or of its model, every table entry a node of the model, and every vector is within ±1.0001 | all 24 maps |
| Structure BSP materials: environment vertices uncompressed (type 0), with lightmap vertices (as many as the environment ones) exactly in lightmaps that have a bitmap, and no compressed vertices | all 24 maps (the lightmap vertex type field is 0 or 2 regardless) |
| Custom Edition shader type values are 7 to 11 for `scex`, `swat`, `sgla`, `smet`, `spla`; the Xbox's are 8, 9, 10 for `sgla`, `smet`, `spla` | every shader of the 24 maps, and of the Xbox `bloodgulch.map` |
| Chicago shaders with extra layers: none in the stock maps or the Xbox `bloodgulch.map`; 2 in `beavercreek_halo3.yelo`, 2 in `celer_exile_odst_v2.yelo`, 4 in `extinctionrevanepic2.map`, 1 in `fy_killzone.yelo` | every `schi` and `scex` of the sample |
| Compressed color plates are left out of caches: size kept, address 0 | every bitmap in `bitmaps.map` |
| Font style references in `loc.map` are all `NONE` | all 3 fonts |
| Sound compression value 3 is Ogg Vorbis, value 1 is Xbox ADPCM | in `sounds.map`, all 106 value-3 permutations start with `OggS`; all 1,365 value-1 permutations are whole 36-byte blocks |
| `bitmaps.map` bitmaps are not swizzled, and cube maps hold each level's six faces together | all 1,467; 38 of 40 cube maps match that order when checked against their next level (one is uniform, one does not match) |
| Multipurpose maps: Custom Edition's red, green, blue and alpha hold what the Xbox's alpha, green, red and blue do | the multipurpose maps the Custom Edition and the Xbox `bloodgulch.map` share, decoded and compared channel by channel (the cyborg's, the warthog's, the boulders') |
| HUD meters: Custom Edition's alpha holds the Xbox's color (the fill order), and its color the Xbox's alpha (the shape) | `hud_ammo_meters`: Custom Edition's 512×512 A8R8G8B8 averaged down to the Xbox's 256×256 A8Y8 differs from the Xbox's luminance by 6.6 on average in alpha and 23 in color, and from its alpha by 12.3 in color and 23.4 in alpha; `hud_unit_meters` has its sprites rearranged, and shows the same swap when viewed. January's meter shader reads the fill order from color and discards texels without alpha |
| HUD elements flagged *use high resolution scale* draw bitmaps twice the Xbox's size; others the same size | every unit and weapon HUD placement of `bloodgulch.map` whose tag the Xbox map also has (the motion sensor's foreground, unflagged, uses a 128×128 bitmap in both) |
| Vehicle placements: the byte at 0x58 is the multiplayer team and the word at 0x5A the multiplayer spawn flags, default bits 0 to 3 (slayer, capture the flag, king of the hill, oddball) and allowed bits 8 to 11 | `hugeass.map`'s 78 vehicle placements: the byte is 1 on exactly the blue team's; the word is 0x0303 on warthogs, tachikomas and pelicans, 0x0F0F on ghosts, 0x0404 on 16 unnamed jets, 0x0808 on the night jet and 0 on vehicles its scripts create; OpenSauce leaves these bytes unnamed |
| Script function and engine global tables: Halo PC's have entries this build's lacks, so a compiled index names another entry from some point on; value types are numbered alike | the compiled scripts of the sample, each call's function index against the name its first child keeps, and each engine global's index against its name: `timberland.map` 23 calls shifted (`player_effect_start` by 29), `ui.map` 11 (`camera_set` by 1), `hugeass.map` 3 calls and 1 global (`rider_ejection`, 158 against 147), `extinctionrevanepic2.map` 9 calls, and 4 of a function this build lacks; no other stock map has scripts; each call's value type is the type of its function here (1,485 of 1,485 in `hugeass.map`), or one the call site casts to |
| Bitmaps the Xbox keeps in monochrome formats and Custom Edition as 32-bit color at the same size keep their channels (A8Y8 as A8R8G8B8 with red the luminance and alpha the alpha) | 24 of the 26 such bitmaps of `bloodgulch.map` decode to exactly the Xbox's values; the other 2 have other content |
### Assumptions (not verified)
- **Placement of resource-held tags.** They are placed after the tag data,
aligned to 4 bytes, as OpenSauce's unimplemented loader outlines
(`s_cache_file_data_load_state`); how Custom Edition itself places them is
not known, and nothing found depends on it.
- **Which chicago extended maps.** The four-stage maps are kept, as the
renderer draws four stages; that Custom Edition's two-stage maps are a
fallback for older hardware is an inference (BlamLib's exporter makes the
same choice).
- **Centroid nodes of local-node parts** are taken to be the model's nodes:
they are within the model's node count in every map, and they are only
used to sort transparent parts.
- **Editing-kit pointers are cleared.** The definition pointers of relocated
blocks and data are set to 0: they refer to nothing in the game process.
- **Name comparison** is exact (case-sensitive), which matched every sample
sound.
## Limits and remaining work
- **What the validator corrects is lost.** A community map's data that the
validator corrects (an index past its block, an enum past its values) is
what the game then runs; Halo PC read past such data unchecked.
- **Ogg Vorbis sounds** are decoded when the map loads (stb_vorbis, with
added bounds checks: `port/third_party/stb_vorbis`) and encoded again as
Xbox ADPCM in memory (`port/linux/game/custom_edition_sounds.c`); one that
cannot be decoded is silenced, and still logs when played.
- **Models of 44 nodes or more** are drawn a part's nodes at a time, given
local nodes when they have none; one whose part's vertices name more
nodes than a part holds is refused.
- **Linear bitmaps whose rows are not a multiple of 64 bytes**, and
textures larger than 4096 pixels a side, are not drawn.
- **Halo PC's HUD meters' minimum alpha** (where the Xbox's meters have an
overlays block) is dropped, and **its weapon functions "primary firing on"
and "secondary firing on"** are the Xbox's primary and secondary firing.
- **Button prompts on replaced icon sheets** show what the map's icon bitmap
has where the Xbox's buttons are.
- **Channel orders are per texture**: multipurpose maps and meters also drawn
another way keep Halo PC's channels.
- **Scripts that use what this build does not have**: a call of a function
this build lacks, or a read of an engine global it lacks, does nothing
(a constant of its type's harmless value, logged by name:
`custom_edition_scripts.c`); only one that gives a script's index refuses
the map. Maps that need OpenSauce's runtime features are refused; a map
that only carries its tags (`project_yellow`) runs without them, as on
stock Custom Edition.
- **Halo PC behaviours** some maps were made around (Chimera's map list,
`custom_edition_behaviours.inc`) are followed where this build can, and
each is logged.
- **The conversions are one-way and in memory**: nothing is written to the
map files.
### How far Custom Edition tags are from the Xbox tags
`tools/custom_edition_tag_footprints.py` compares the tags a Custom Edition
cache and the Xbox cache of the same level both keep (same group, same name),
without any tag definitions. A tag's *footprint* is the distance from its
address to the next tag's (its structure and the block data after it); its
*structure size* is estimated as the distance to the lowest address in the
footprint that the footprint points at (its first child block). Equal
footprints, level after level, suggest a group's layout is unchanged; they do
not prove it.
The Xbox caches available are the retail ones, build 01.10.12.2276, not the
01.01.14.2342 build this repository reconstructs: they stand in for it, and
differences between the two Xbox builds are not measured. Run on the 14
levels both sets have (13 multiplayer levels and `ui`):
```
python tools/custom_edition_tag_footprints.py --blocks assets/custom_edition "<Xbox 2276>/maps"
```
- **48 groups have the same footprint for every shared tag in every level**:
`Soul actv ant! bipd colo cont deca eqip flag fog font foot grhi hmt hud#
hudg itmc jpt! lens lifi ligh lsnd mach metr mgs2 mply part pctl phys pphy
scen schi senv sgla sky smet snde soso spla ssce str# swat trak udlg unhi
vcky vehi wind`. The shader groups among them have the same layout, but
not the same type values (above).
- **Groups whose footprints differ, but whose structures match**: UI widgets
(`DeLa`, 464 of 657 footprints equal, 423 of 423 structures), animations
(`antr`, 213/239), collision models (`coll`, 331/345), effects (`effe`,
4140/4166), projectiles (`proj`, 197/210), weapon HUDs (`wphi`, 196/222),
weapons (`weap`, 39/182, structures 182/182), globals (`matg`, 1/14,
structures 14/14) and scenarios (`scnr`, 0/14, structures 13/14), plus, in
`ui` only, bitmaps, sounds and string lists. These differ in their block
data. The flamethrower's tags account for every `effe`, `proj` and `wphi`
difference and all but one `coll` difference; 11 of the 14 weapons (every
player weapon and both vehicle guns) differ in every level.
- **What differs in those blocks** (`--blocks`: the top-level blocks of each
differing tag, found without definitions and compared): almost always
the number of elements, or whether a block has any, which is content:
weapons 143 count and 26 presence differences, UI widgets 158 and 63,
scenarios 36 and 13, projectiles 13, weapon HUDs 78 presence, collision
models 26 count. Every weapon difference checked in Blood
Gulch lies in a predicted-resources list (8-byte elements, weapon
offset `0x4E4`; OpenSauce `weapon_definitions.hpp`), or, for the
flamethrower, also in its attachments; the globals differ in their
cheat powerups (16 against 14). The rest (flamethrower effects 26,
globals 13, animations 39, one collision model, and 3 in `ui`) keep their element counts but
span a different amount of data up to the next block, which this method
cannot split into element size and nested content: for the globals, the
block in question (`player_info`, 0xF4-byte elements without blocks of
their own) spans more than its elements, so other data sits in the span.
No difference found has to be a layout change, and the two maps run did
not show one.
- **Groups only Custom Edition has**: `mod2` (models), `scex` (a transparent
shader type), `devc` and `tagc`, in all 14 levels; **only the Xbox has**:
`mode` (models) and `sotr` (a transparent shader type), besides the groups
Custom Edition keeps in resource maps.
+222 -31
View File
@@ -1,16 +1,22 @@
# Android
The app of Ringfall: Mjolnir VR (application id `io.github.cesarus85.ringfall`;
its Java classes keep the decompilation's package, `com.halo.decomp`).
`ninja android` builds the game for 64-bit ARM Android (arm64-v8a).
`ninja android_apk` makes an app from it:
`port/android/app/build/outputs/apk/debug/app-debug.apk`.
The game shows its graphics with OpenGL ES 3. It plays sound through SDL3
(AAudio). It accepts input from game controllers, for example a PlayStation
5 DualSense on Bluetooth. The app needs Android 9 (API 28) or later. It
operates on 64-bit-only devices, for example the Pixel 9 Pro XL.
5 DualSense on Bluetooth, and from the touchscreen (refer to "Controls").
The app needs Android 9 (API 28) or later. It operates on 64-bit-only
devices, for example the Pixel 9 Pro XL.
The Android build uses the platform layer of the Linux build
(`port/linux/src`). Refer to [port/linux/README.md](../linux/README.md).
Physical mice use the same control bindings. During play, F12 toggles mouse
capture; captured mode hides Android's pointer and uses relative movement for
continuous aiming, while releasing capture restores normal pointer behavior.
## Requirements
@@ -28,6 +34,36 @@ ninja) and these items:
1.2.5 and SDL 3.4.16 to `build/android/third_party`. Gradle downloads the
Android Gradle Plugin.
On Windows, build in WSL2 (Ubuntu 24.04). The Android build does not run
on Windows itself. Enter
`wsl -d Ubuntu -- bash tools/wsl_setup_android.sh` one time: it installs
the tools and the Android SDK and NDK in WSL (apt asks for your
password). Then enter
`wsl -d Ubuntu -- bash tools/wsl_build_android.sh` for each build. The
script copies the repository into WSL (`~/halo-build/src`) with LF line
endings (a Windows checkout has CRLF line endings, which the build's text
tools do not accept), builds there, and puts the APK in
`dist/android/app-debug.apk`. Only the changed files are copied, so the
next build is incremental. Install the APK with the adb of Windows.
A build that you make yourself has a different signature from the builds
of GitHub Actions. Android installs it only after you remove the app, and
removing the app deletes its data folder (`maps/`, `save/` and
`config.toml`). Thus a change between the builds of GitHub Actions and your
own builds loses the saved games, unless you copy them first (below).
Your own builds all have the same signature (the debug key of WSL,
`~/.android`), so they install over each other and keep the data
(`adb install -r`).
To keep the saved games before you remove the app, enter
`adb pull /sdcard/Android/data/io.github.cesarus85.ringfall/files/save/u` (the profiles).
After the new install, before the first start of the app, enter
`adb push u /sdcard/Android/data/io.github.cesarus85.ringfall/files/save/u`. The app reads
and overwrites what adb pushes. adb cannot add files to `save/u` or `save/z` once the app made them, or
change files that the app wrote, so push before the first start. adb cannot read
some files that the app writes (the `blam.lst` files of `save/z`), so `adb pull`
of the whole `save` folder stops there.
## Build and install the app
1. Go to the root folder of the repository.
@@ -41,8 +77,8 @@ ninja) and these items:
## Game data
The game needs the `maps/` folder from an Xbox disc image (`.xiso` or
`.iso`) of any version of the game. The app extracts `maps/` from the disc
image. The app keeps the data in `/sdcard/Android/data/com.halo.decomp/files`.
`.iso`) made from your own copy of the game, any version. The app extracts `maps/` from the disc
image. The app keeps the data in `/sdcard/Android/data/io.github.cesarus85.ringfall/files`.
To install the data with the app:
@@ -57,16 +93,18 @@ To install the data with the app:
To install the data from a computer:
1. Start the app one time. The app makes its folders.
2. Enter `adb push <folder>/. /sdcard/Android/data/com.halo.decomp/files/`.
2. Enter `adb push <folder>/. /sdcard/Android/data/io.github.cesarus85.ringfall/files/`.
| Item | Location in `/sdcard/Android/data/com.halo.decomp/files` |
| Item | Location in `/sdcard/Android/data/io.github.cesarus85.ringfall/files` |
| --- | --- |
| Saved games (`z:\` and `u:\`) | `save` |
| Log | `debug.txt` |
| Settings | `config.toml` |
To make a copy of the saved games, enter
`adb pull /sdcard/Android/data/com.halo.decomp/files/save`.
`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,13 @@ These settings are only for Android:
| Setting | Function |
| --- | --- |
| `input.touch_aim_assist` | `true` (the default): the touch controls' swipe aiming gets the aim assist of a controller (the aim slows over a target and follows a moving one). `false`: none, as with a mouse; the bullets' own autoaim stays. |
| `input.touch_controls` | The touch controls in a game. `"auto"` (the default): shown on a touchscreen while no controller is connected. `"on"`: also shown with a controller. `"off"`: never shown. A device without a touchscreen never shows them. The menus take taps in any case. |
| `display.screen_width` | The number of columns of the 480-line picture. `0` (the default): the shape of the display (1068 on a 20:9 phone). `640`: the 4:3 shape of the Xbox. |
| `display.shader_cache` | `true` (the default): the linked shader programs are kept in `shader_cache` in the data folder (their binaries, the driver that made them and their sources), and each map's list of the programs it drew with; as a map loads, the programs on its list are made ready, so that its first frames do not wait for the driver to compile them. A driver update compiles them again from the kept sources. `false`: each program is compiled the first time it is drawn. The folder may be deleted at any time. |
| `display.shader_worker` | `true` (the default): the shader programs are made on a thread of their own, whose GL context shares the game's (loaded from the shader cache, or compiled), and drawn with once there (`debug.shader_warm_up`: `"worker"`, `"load"` on the game's thread as a map loads, or `"off"`); a draw whose program is not made yet is left out for the frames it takes, and the frame does not wait. As a map loads, its own programs and then every map's (`shader_cache/all.txt`) are asked for. `false`: each is made on the game's thread when first drawn with. |
| `debug.sample_seconds` | Refer to "Find problems". |
| `debug.memory_watch` | `true` (the default): the app notices the game's writes to textures and vertices by page protection. `false`: it compares page contents once a frame instead, which is slower. Refer to "Limits". |
## Internet play
@@ -148,16 +265,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
@@ -171,6 +296,8 @@ the Xbox:
- 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
On a VR headset with an OpenXR runtime (Meta Quest 3 and 3S; the Steam
@@ -182,7 +309,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 |
@@ -230,7 +357,7 @@ through a shader that decodes to linear, and the stored bytes are the
game's. Camera shake is off in VR.
A file named `xr_test` in the data folder
(`adb shell touch /sdcard/Android/data/com.halo.decomp/files/xr_test`)
(`adb shell touch /sdcard/Android/data/io.github.cesarus85.ringfall/files/xr_test`)
starts a self-test instead of the game, which needs no game data: each eye
in its own colour (red while the right trigger is held), a checkerboard
screen in the room (its top-left square red), and the controllers' buttons
@@ -240,12 +367,6 @@ Not done yet: aiming with the controller and the weapon in the hand, the
HUD's parts on the controllers, zoom through the scope, a first-person view
in vehicles, and a separate depth for the HUD's reticle.
## How it works
The changes are in `#ifdef HALO_ANDROID` in `rasterizer_xbox.c`, `render.c`,
`ui_widget.c`, `cinematics.c`, `main.c` and
`rasterizer_xbox_screen_effect.c`.
## How the port operates
### ILP32 code
@@ -267,7 +388,9 @@ The guest is the game, the platform layer and a small runtime:
AArch64 target of clang. The options `-U__APPLE__` and
`-fno-define-target-os-macros` hide the Darwin environment.
2. `tools/android_asm_convert.py` changes the Mach-O assembly to ELF
assembly.
assembly. It renames symbols, but keeps string literals (`.ascii`,
`.asciz`) byte for byte: they hold tables as well as text
(`tools/test_android_asm_convert.py`).
3. The AArch64 assembler makes the objects.
4. `ld.lld` links the objects with `guest/guest.ld` to a static image,
`build/android/halo_guest.elf`, at a fixed address above the Xbox memory
@@ -288,7 +411,7 @@ and supplies the thread pointer and TLS.
- Reserves the address space of the guest below 4 GB: the Xbox memory at
`0x80000000`, the image, and pools for the memory of the guest
(`host/host_memory.c`).
(`host/host_memory.c`). Refer to "The fixed addresses".
- Loads the image and fills its import table (`host/host_loader.c`).
- Starts the `main` of the game and each guest thread on a stack in guest
memory, because ILP32 code keeps stack addresses in 32-bit registers
@@ -296,8 +419,9 @@ and supplies the thread pointer and TLS.
- Gives the audio callback of SDL to a thread with a guest stack
(`host/host_sdl.c`).
- Does the calls of the guest: system calls (`host/host_syscall.c`), SDL
(`host/host_sdl.c`), OpenGL ES (`host/host_gl.c`), and the file and socket
functions of `port/linux/src/posix_*.c`.
(`host/host_sdl.c`), OpenGL ES (`host/host_gl.c`), and the file, socket,
UPnP and DTLS functions of `port/linux/src/posix_*.c` (DTLS with Mbed TLS,
for internet play with browsers: `posix_dtls.c`).
The guest calls the host through stubs (`tools/android_imports.py`). The
two ABIs use the same registers for 32-bit integers, floats and pointers.
@@ -305,6 +429,49 @@ two ABIs use the same registers for 32-bit integers, floats and pointers.
`port/linux/src/gl.h`. `tools/android_posix_stubs.py` makes the stubs of the
`posix_*` functions, which copy the `errno` of the host.
### The fixed addresses
The guest needs the Xbox memory at `0x80000000` to `0x88000000` and the
image above it, to `0x8c000000`. The cache files contain pointers to these
addresses. The Java runtime of Android (ART) also reserves its spaces below
4 GB. On some devices, for example handhelds with a large Java heap (the
AYN Thor, the Retroid Pocket), ART's large object space covers
`0x80000000`. ART fills that space from its bottom, so the part at
`0x80000000` is usually empty.
Thus:
1. The game operates in a process of its own (`:game`), with a fresh Java
heap. The launcher, the import of a disc image and an earlier game do
not leave objects there.
2. At the start of that process, `HaloApplication` loads `libmain.so`. Its
`JNI_OnLoad` reserves the fixed addresses before the Java side
allocates large objects.
3. If ART's large object space is in the way, the host takes back only the
part that covers the fixed addresses, and only if no page of it is in
use (`/proc/self/pagemap`, or `mincore` and the swap of that part if
the device refuses `pagemap`). The host never takes the other spaces of
ART.
If the addresses are not available, the game shows a message, and writes
the mappings below 4 GB to `memory_map.txt` in the data folder and to the
log.
To test the reclaim on any device, set a system property before you start
the game. The app then puts a stand-in for ART's large object space over
the fixed addresses:
- `adb shell setprop debug.halo.art_overlap idle`: the stand-in is empty
at `0x80000000`. The log shows `reclaimed idle ART range`, and the game
starts.
- `adb shell setprop debug.halo.art_overlap busy`: a page at `0x80100000`
is in use. The game shows the message and writes `memory_map.txt`.
- Add `-nopagemap` to either value (`idle-nopagemap`) to test as on a
device that refuses `/proc/self/pagemap`. Add `-swapped`
(`idle-nopagemap-swapped`) to also move the objects of the stand-in to
swap; with `busy`, the page in the fixed addresses too.
- `adb shell setprop debug.halo.art_overlap ""`: normal operation.
### OpenGL ES
The renderer (`port/linux/src/d3d8_gl.c`) uses OpenGL ES 3.0, and some
@@ -322,7 +489,11 @@ functions of OpenGL ES 3.2 if they are available:
changes the indices.
- On OpenGL ES 3.1 and later, the visibility tests (lens flares) count
samples with an atomic counter, as the NV2A did. OpenGL ES 3.0 tells only
if a sample is visible.
if a sample is visible. The GPU copies the counters at the end of each
frame, and the CPU reads the copy two frames later, when the frame's fence
has passed: a result is the latest count the GPU has finished, as with
the query buffer of desktop OpenGL. A read of the counters themselves
waits for the GPU, which halved the frame rate on Turnip (Zink).
### Calling conventions
@@ -345,9 +516,17 @@ floating-point contraction, as on x86.
### Game source changes
The port's code tells three things apart, each its own macro, which the
Android build defines all of (`tools/android_build.py`):
- `HALO_ARM64_GUEST`: the guest's ABI (ILP32 AArch64 code in a 64-bit
process);
- `HALO_GLES`: the OpenGL ES renderer;
- `HALO_ANDROID`: the app (its display, input, files and lifecycle).
The x86 inline assembly is replaced by C (refer to
[port/linux/README.md](../linux/README.md#game-source-changes)).
These changes are in `#ifdef HALO_ANDROID`:
These changes are in `#ifdef HALO_ARM64_GUEST`:
- Seven `#pragma bss_seg(".bss")` lines are removed. The Darwin target does
not accept them.
@@ -374,13 +553,25 @@ assembly of the port is necessary:
at this interval. This finds hangs on devices without root access.
- Set `gl_debug = true` in `[debug]` of `config.toml`. The log then shows
the OpenGL ES errors.
- The log says which write tracking the app uses: "page protection"
(devices) or "page hashes" (the emulator, or `debug.memory_watch = false`).
## Limits
- Bink video is not available. The game skips the movies.
- The device must let the app reserve the fixed guest addresses, from
`0x80000000` to approximately `0x89000000`. If the addresses are not
available, the app shows a message.
- The game does not accept touch input. Use a controller or a keyboard.
`0x80000000` to `0x8c000000`. If ART uses them, the app shows a message
(refer to "The fixed addresses").
- Touch operates the menus and skips cinematics. In the game, the touch
controls appear when no controller is connected; their size follows the
height of the screen (larger on a tablet than on a phone), never smaller
than a finger.
- Kernels with 16 KB pages (a developer option of Android 15) do not
operate. The Xbox memory uses 4 KB pages.
- The x86 Android emulator runs the app through its ARM translation. The
translation cannot deliver the page faults that the renderer uses to
notice changed textures and vertices. On the emulator the app compares
page contents once a frame instead ("write tracking: page hashes" in the
log). This is slower, and a write shows one frame later, so moving
geometry can glitch briefly. `debug.memory_watch = false` does the same
on a device.
+51 -12
View File
@@ -4,6 +4,43 @@ The same APK as for the Quest runs on the Steam Frame, in Lepton (SteamOS's
Android, a Waydroid fork: Android 11, arm64), with SteamVR's OpenXR runtime.
What differs is found at run time, not by a build of its own.
## Installing it (players)
On the Frame, in Desktop Mode (Steam menu > Power > Switch to Desktop):
1. Put your own disc image of Halo: Combat Evolved for the original Xbox (`.iso` or
`.xiso`, any version) in Downloads (or Documents, the Desktop, a memory
card).
2. Download `Install-Ringfall-Mjolnir-VR.desktop` from the latest release and open it
(double-click; confirm running it).
3. Answer the dialogs: the installer finds the disc image (or asks for it),
copies the game data out of it, adds "Ringfall: Mjolnir VR" to the Steam library
and switches back to Gaming Mode. Start it from the library there.
Opening the same file again updates the game (settings, saves and game data
stay).
The desktop file (`steam_frame_installer.desktop`) downloads the latest
release's `ringfall-mjolnir-vr-steam-frame.zip` (`tools/steam_frame_bundle.py`, made by
the release job of `.github/workflows/build.yml`) and runs its
`install.sh` (`steam_frame_install.sh`), with zenity dialogs:
- `~/Documents/RingfallMjolnirVR`: the launcher (`ringfall-mjolnir-vr.sh`)
and the release APK (`RingfallMjolnirVR.apk`); the folder of the builds
before the name, `~/Documents/HaloCE`, renamed to it, and their "Halo VR"
shortcut replaced;
- the maps folder out of the disc image (`steam_frame_xiso.py`, the app's
`XisoExtractor.java` in Python), unless `maps/ui.map` is there;
- the Steam shortcut, unless it is there: added by Steam itself
(`steam://addnonsteamgame`, as SteamOS's "Add to Steam" does) from
`Ringfall Mjolnir VR.desktop` beside the game, then the library pictures
under the id Steam gave it. Steam is not closed: on the Frame, Desktop
Mode runs inside Gaming Mode's session, and closing Steam ends it (and the
installer with it). An old "Halo VR" shortcut is left for the player to
remove (Steam would write it back);
- what it did in `install.log` beside the game.
`HALO_VR_UNATTENDED=1` (and `HALO_VR_IMAGE=<image>`) runs it without
dialogs, for testing over adb; it leaves Steam and its shortcuts alone.
## The device
- Steam Settings > System > Enable Developer Mode; then start "Lepton
@@ -15,7 +52,7 @@ What differs is found at run time, not by a build of its own.
connected as well.
- Install with `adb install -r --no-streaming`. It prints "Lepton post
installation failed due to APP_PATH being unset", but installs. Start the
game with `am start -n com.halo.decomp/.LauncherActivity`.
game with `am start -n io.github.cesarus85.ringfall/com.halo.decomp.LauncherActivity`.
- Lepton empties `/sdcard/Android/data` whenever it starts again: the game's
data must live elsewhere (stage 2). `/sdcard/Documents` and
`/sdcard/Download` are the SteamOS user's folders and are kept.
@@ -33,7 +70,7 @@ What differs is found at run time, not by a build of its own.
| # | Stage | State |
| --- | --- | --- |
| 1 | Base: the fixes above on the current main | done |
| 2 | The game's data where Lepton keeps it | done: `/home/steamos/Documents/HaloCE` |
| 2 | The game's data where Lepton keeps it | done: `/home/steamos/Documents/RingfallMjolnirVR` |
| 3 | Menus, HUD and text visible (quad layers) | done: their alpha from their colour |
| 4 | The freeze at the cryo bay's calibration | not seen again (2026-10-04) |
| 5 | The Frame's controllers | done: their own profile, the Xbox's layout |
@@ -43,10 +80,10 @@ What differs is found at run time, not by a build of its own.
## Starting it on the Frame
A button in the Steam library ("Halo VR", a non-Steam game) runs
`~/Documents/HaloCE/halo-vr.sh` (`port/android/steam_frame_launch.sh`),
A button in the Steam library ("Ringfall: Mjolnir VR", a non-Steam game)
runs `~/Documents/RingfallMjolnirVR/ringfall-mjolnir-vr.sh` (`port/android/steam_frame_launch.sh`),
which starts the game as Steam starts an Android title of its own:
`lepton waitforexitandrun -- ~/Documents/HaloCE/HaloVR.apk`, in a container
`lepton waitforexitandrun -- ~/Documents/RingfallMjolnirVR/RingfallMjolnirVR.apk`, in a container
of its own (`steamlaunch-4290000001`) that Lepton installs the APK into
anew whenever the file is newer. Updating the game is replacing that APK.
@@ -61,7 +98,7 @@ What the script does that Steam would for an Android title:
quit (it writes `quit_requested.txt`).
Steam's "Exit game" does not reach the game (its process is the container's,
not Steam's): quit it with the VR options' "Quit Halo" (Y in the main or
not Steam's): quit it with the VR options' "Quit the game" (Y in the main or
pause menu, twice). A container ended within 30 s of its start makes
Lepton drop the app's install, so the next start installs it again.
@@ -75,13 +112,15 @@ beside Lepton's `<frame-ip>:5555`.
Lepton uninstalls the app and empties its folders whenever it starts again.
The game's data lives in the SteamOS user's Documents instead: when
`/home/steamos/Documents/HaloCE` holds `maps/ui.map` and is writable, the
launcher and the game take it as their data folder (config.toml, saves and
debug.txt go there too). Once:
`/home/steamos/Documents` can be written, the launcher makes
`/home/steamos/Documents/RingfallMjolnirVR` and the launcher and the game take it as
their data folder (config.toml, saves and debug.txt go there too), so a
disc image imported in the app stays as well. The installer above fills it;
by hand, once:
adb -s <frame-ip>:5555 shell mkdir -p /home/steamos/Documents/HaloCE/maps
adb -s <frame-ip>:5555 push assets/maps/. /home/steamos/Documents/HaloCE/maps/
adb -s <frame-ip>:5555 shell chmod -R 777 /home/steamos/Documents/HaloCE
adb -s <frame-ip>:5555 shell mkdir -p /home/steamos/Documents/RingfallMjolnirVR/maps
adb -s <frame-ip>:5555 push assets/maps/. /home/steamos/Documents/RingfallMjolnirVR/maps/
adb -s <frame-ip>:5555 shell chmod -R 777 /home/steamos/Documents/RingfallMjolnirVR
After a Lepton restart only the APK is installed again. (Use the path under
/home, not /sdcard/Documents: Android 11's scoped storage stands between the
+315 -43
View File
@@ -1,6 +1,6 @@
# VR roadmap
What the VR port (Quest 3, Steam Frame) has, and what comes next, in the
What Ringfall: Mjolnir VR (Quest 3, Steam Frame) has, and what comes next, in the
agreed order. Updated with every step: a step is **done** once the user has
tried it in the headset and it is merged into `main`; **built** means in a
branch, not yet tried in the headset.
@@ -16,15 +16,17 @@ named where code is taken over.
| 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/HaloCE` |
| 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 |
| Headsets | Quest 3; Steam Frame (Lepton): its controllers' layout, a Steam library tile that installs, starts, updates and quits the game, data kept in `~/Documents/RingfallMjolnirVR` |
| Rendering | The eyes drawn one by one (both at once, multiview, only with `debug.multiview`: its eyes did not always fuse, see the open points); lens flares at their depth in stereo; HUD, menus, scope, crosshair and names as layers of their own |
| Aiming | The weapon in the hand, its shots from the muzzle, laser and crosshair options |
| 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 20): cross-play, its server browser, co-op campaign, password lobbies; join codes and the VR online list |
| Multiplayer | Up to date with OpenCE (network version 29, 2026-10-14): 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 |
| Voice chat | Push to talk on any button (`vr.button_talk`, BUTTONS page; the free hand's grip by default, sending nothing while it reaches for the foregrip or the wheel), open mic, off (2026-10-13) |
| Builds | The Android build's assembly conversion (`tools/android_asm_convert.py`) keeps string data as it is: strings and tables starting with "_" or holding \" lost a "_" (Opus's pulse cache among them: the Steam Frame's crashes in online games while talking), 2026-10-13 |
| 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) |
@@ -35,11 +37,13 @@ named where code is taken over.
| --- | --- | --- |
| 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 **built** (branch `feature/vr-comfort`, headset test pending): buttons remapped, flashlight by tapping the head, finer turning and vignette choices |
| 3b | The whole body: torso from head and hands, legs and feet following in the room; full / arms only / hands only | |
| 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) | |
| 4 | Later: disc image import in the launcher; moving by swinging the arms (in place of the stick, never faster than it: online too), and a sprint by swinging them harder (faster than the game allows: offline only) | import **done** (in `main` 2026-10-06): the app's import (tried on the Quest 3: all 24 maps; on the Frame kept in `~/Documents/RingfallMjolnirVR`), a Steam Frame installer from Desktop Mode (`STEAM_FRAME.md`; tried on the Frame: the old folder renamed, the shortcut added by Steam), a release signing key (CI and local builds the same). Arm swinging and its offline sprint still open |
| 5 | Later: multiview again (both eyes in one pass, half the drawing's CPU cost): find why its eyes do not fuse in big online games on the Quest 3 (and now and then on the Steam Frame), then turn it back on | open: off by default since 2026-10-16 (`debug.multiview`); findings in the open points |
| R | Release as **Ringfall: Mjolnir VR** (no "Halo" in the product's name): app name, id `io.github.cesarus85.ringfall`, icon and library pictures (`tools/ringfall_art.py`), Frame folder and installer, release assets, README; the repository renamed | **done** (in `main` 2026-10-06; Quest 3 and Steam Frame good) |
## Step 2, first part (done)
@@ -115,7 +119,8 @@ restart. The controller buttons page shows the layout as set.
| --- | --- |
| `vr.handedness` | `"right"` (default), `"left"` |
| `vr.mirror_controls` | `true` (default): sticks and buttons mirrored when left-handed; `false`: as right-handed (triggers and grips still follow the hands) |
| `vr.graphics` | `"low"`, `"medium"`, `"high"` (default), `"max"` |
| `vr.graphics` | `"auto"` (default, branch `fix/vr-frame-hitches`), `"low"`, `"medium"`, `"high"`, `"max"` |
| `vr.foveation` | `"off"` (default), `"low"`, `"medium"` (the Quest only; branch `fix/vr-frame-hitches`) |
| `vr.refresh_rate` | Hz, as offered (Quest 3: 72, 80, 90, 120) |
| `vr.resolution_scale` | 0.7 to 1.2, on restart |
@@ -136,22 +141,27 @@ To try in the headset (Quest 3 and Steam Frame):
in a bot game; Max on the Quest at 90 Hz; refresh rates 72/80/90/120
on the Quest; the Frame's row.
## Step 2, second part (built)
## Step 2, second part (done)
On branch `feature/vr-comfort`, not yet tried in the headset. The VR
In `main` 2026-10-06; tried on the Quest 3 (with the rename): good. The flashlight by tapping the head (after HaloCE-Quest-VR)
did not work in the headset and was taken out again; the button stays.
The VR
options (Y) have four pages: PLAY ("Weapon hand", "Mirrored controls",
laser, crosshair, motion tracker, body, fingers, the Warthog's wheel),
COMFORT (turning, the vignettes), BUTTONS (the actions' buttons, the
flashlight's tap, "Reset buttons to defaults", the controller buttons
page) and MORE (graphics, refresh rate, resolution, Quit).
COMFORT (turning, the vignettes), BUTTONS (the actions' buttons,
"Reset buttons to defaults", the controller buttons page) and MORE (graphics, refresh rate, resolution, Quit).
Buttons remapped (BUTTONS page; `vr.c` `controls_remapped`):
- Jump, the flashlight (held: switch grenades), action/reload, switch
weapon, crouch, zoom and melee each go on one of seven buttons: A, B,
X, Y, the move stick's click, the turning stick's, the weapon hand's
grip. Moving an action to a button swaps it with the action there, so
none is ever left without one.
- The triggers, the free hand's grip, the menu button, the Steam Frame's
weapon, crouch, zoom, melee and push to talk each go on one of eight
buttons: A, B, X, Y, the move stick's click, the turning stick's, the
weapon hand's grip, the free hand's grip. Moving an action to a button
swaps it with the action there, so none is ever left without one.
- The free hand's grip as a button (`free_grip_down`): pressed once held
0.15 s at the foregrip's threshold, and not at all while it holds the
weapon's foregrip or the Warthog's wheel (taken in that time, the press
is theirs until the grip opens).
- The triggers, the menu button, the Steam Frame's
bumpers, D-pad and View button keep theirs; the menus keep the buttons
as they are.
- Applied after the left-handed mirroring, on the gamepad's sides: the
@@ -163,19 +173,6 @@ Buttons remapped (BUTTONS page; `vr.c` `controls_remapped`):
wheel press nothing, whatever the grip does.
- The controller buttons page shows each action where its button is.
Flashlight by tapping the head (`vr.c` `flashlight_gesture_update`,
after HaloCE-Quest-VR, CC0): either hand to the side or the top of the
head and away, as a tap, toggles it; the button still does. A zone in
the head's axes beside or over the head, never more than 6 cm in front
of the eyes (a scope at the eye, a two-handed weapon, a punch and the
wrist's tracker are in front of the face); the hand comes from 35 cm
away, slower than 3 m/s, stays 40 to 600 ms, alone, and leaves; a second
between toggles. None in the menus, with a grenade held or just thrown,
just after a melee, with both hands on the weapon, the weapon hand while
the scope is drawn, a hand on the wheel. A short rumble in the tapping
hand. Its logic was run in a harness off the headset; the feel is the
headset's to judge.
Comfort (COMFORT page): snap turns 10 to 45 degrees by 5, 60, 75, 90;
smooth turning 30 to 150 degrees a second by 15, then to 300 by 30; the
vehicle vignette Off, 15 to 100%; the on-foot vignette (off by default)
@@ -195,7 +192,6 @@ below).
| `vr.button_crouch` | `"move_stick"` (default) |
| `vr.button_zoom` | `"turn_stick"` (default) |
| `vr.button_melee` | `"grip"` (default) |
| `vr.flashlight_gesture` | `true` (default): a tap of the head toggles the flashlight |
| `vr.foot_vignette` | `"off"` (default), `"turning"` (smooth turns), `"moving"` (and moving) |
| `vr.foot_vignette_strength` | 0.15 to 1, `0.7` (default) |
| `debug.vr_options` | `false` (default); `true` shows the VR options off the headset (the emulator) |
@@ -216,17 +212,11 @@ To try in the headset (Quest 3 and Steam Frame):
the wheel. Reset: all back.
3. The same left-handed with mirrored controls (on the Frame the left
D-pad), and with `Mirrored controls: Off`.
4. Flashlight tap with either hand, beside the head and over it; the
rumble; the flashlight button still works. No toggle while: aiming
through the sniper's and the pistol's scope, holding the weapon with
both hands, holding and throwing a grenade (overhand past the ear),
punching and swinging, putting on or adjusting the headset with both
hands or one, looking at the wrist's tracker, at the wheel.
5. COMFORT: snap 10 and 90, smooth 30 and 300; vehicle vignette 15% and
4. COMFORT: snap 10 and 90, smooth 30 and 300; vehicle vignette 15% and
100%; on-foot vignette "Smooth turns" (nothing with snap turns),
"Moving, turning", strength 15% and 100%; no vignette in a vehicle
from the on-foot one, none in the menus.
6. A game of 30 and more players: effects, glass and water stay; the log
5. A game of 30 and more players: effects, glass and water stay; the log
(`adb logcat -s halo`) says `rasterizer: N transparent geometry groups
in a window` once a window passes 384; frame time as before.
@@ -283,16 +273,298 @@ After the first Quest tries (2026-10-06):
Not in it (3a.7): the glove fitted to each weapon's grip (gun anchor) and
the left hand animated through reloads.
## Step 3b (done)
On `feature/vr-voice-and-body`, not yet tried in the headset. The neck
pivot, the planted feet with their steps and the leg's clamped reach follow
HaloCE-Quest-VR (CC0), its full body, rebuilt in
`port/linux/game/vr_body.c` on this port's skeleton (`vr_arms.c`).
- `vr.body = "full"` (VR options, Body: "Full body") draws the player's own
biped under the first-person arms and weapon: its torso, hips and legs, in
the map's own model and colours. "arms" stays the default until the
headsets have tried it.
- Drawn among the world's objects, so it sorts against the world by depth;
the first-person weapon and arms, drawn before the world into the
stencil, stay over it, as before. In both eyes (multiview on the Quest,
each eye on SteamVR), posed once a frame for every eye and the scope.
- The torso: the torso's heading (the head's beyond 15 degrees, drawn
towards the hands, the stick's turns at once), upright from the hips to a
neck 14 cm behind and 20 cm below the eyes as the head turns about it
(looking down, the eyes go forward and the chest stays behind them). The
aiming's twist and bend of the third-person torso are taken out.
- The hips: down with the physical crouch (and back a little, the torso
leaning forward), a third of the way with a lean (the spine tilts the
rest). The body's model has its neck higher than the eyes' pivot: it is
moved down to it, so the shoulders stay below the eyes, crouched by the
button too.
- The legs: the game's own animation (walking, running, jumping, crouched
by the button). Moving, the hips and legs stay as the game faces them, so
its 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, and the spine turns the torso; standing, the hips turn to
the torso's heading (eased, 0.12 s). Walking off, the feet ease from
where they stood into the walk (0.2 s); stopping, they stay where the
walk left them and step into place. Standing still, the feet stay
planted and step, one at a time (0.22 s, 6 cm lifted), when the body has
turned 35 degrees from them or their place moved 18 cm; the knees bend by
IK to them as the hips go down. Moved 80 cm at once (a teleporter), they
are put down there.
- The ground: put down, each foot rests on the ground under it (a slope, a
stair), its height above the ground as the animation has it, tilted with
the slope (no steeper than 45 degrees), up to 45 cm from the ground under
the body; a foot's ground below it lowers the hips as far. Rays straight
down, read only, cast only as the feet are put down (three: under the
body and each foot) and as one steps (one), none while walking, where
the animation's feet are kept; eased (0.08 s) in and out. Nothing found
(a ledge): level.
- Hidden: the head and neck (shrunk onto the neck's root: no head or
collar seen from inside), the third-person arms (shrunk onto their
shoulders), the third-person weapon and all the biped carries.
- The first-person arms hang from the body's shoulders: beside its neck,
as wide as its shoulder joints within 14 to 24 cm (the aiming's shrug
left out). Left-handed, the shoulders go into the mirror with the arms
(the room's left one is the model's right): the body itself is the
room's, not mirrored.
- Seats: a passenger in an open vehicle (the Warthog's back) sits as the
seat's animation has it, the head and arms shrunk alike; at a wheel or a
gun the body is drawn whole as before (its hands on them, no
first-person weapon); in a closed seat (Ghost, Banshee, tank) none.
- Not drawn when dead, in cutscenes, in third person, in a mirror's view;
camouflage as the game has it.
- Only drawn, on this machine: the game's own pose, hit boxes, markers and
network messages are unchanged.
- Cost: `vr.log_performance` adds `body ... ms a frame in ... poses` to the
`first person:` line. In the emulator 0.006 to 0.025 ms a pose standing
(as busy as the emulator was), up to 0.1 ms averaged while turning with
the feet's rays (at most a few a second); the drawing is one more biped
model (as one more player close by).
| Setting | Values |
| --- | --- |
| `vr.body` | `"full"` (the whole body), `"arms"` (default), `"hands"`, `"off"` |
| `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
pelvis, spine, spine1, neck, head, clavicles, arms, thighs, calves, feet);
the body standing, crouched physically and by the button, leaning,
turning with the feet stepping; looking down, the shoulders below the eyes
and no head or collar; two-minute bot games on Blood Gulch with it on and
off, deaths and respawns, nothing in the log. Then the legs by the way of
moving (strafing, backwards, diagonally with the touch stick: the game's
sideways and backwards walk) and the feet on the ground: on the ramp of a
Blood Gulch base, the upper foot 15 cm up, the lower 10 cm down, both on
it; stairs not reached in the emulator (Prisoner's spawn was level). Three
rays when the feet are put down, one a step, none walking. Screenshots in
`build/body-shots/` (local).
Push to talk is built on the same branch: voice chat's talking on the free
hand's grip, while it holds neither the weapon's foregrip nor the Warthog's
wheel (`audio.voice_chat`, VR options PLAY page "Voice chat": push to talk
(grip), open mic, off).
To try in the headset (Quest 3 and Steam Frame), with `vr.body = "full"`
(VR options, Body: Full body) and `vr.log_performance = true`:
1. Standing, looking down: torso, hips, legs and feet, the shoulders below
the eyes, no head, collar or helmet in view, the chest not in the way;
the arms from the shoulders, lined up with the body, the weapon as
before.
2. Turning the head alone: the body stays within about 15 degrees, then
follows; turning further, the feet step after it, one at a time, and
settle. Snap and smooth turns: the body turns, the feet step.
3. Physical crouch: the knees bend, the feet stay; kneeling deep, nothing
of the body in the eyes. Leaning forward and aside: the torso tilts, the
feet stay. Crouch button: the game's crouch, shoulders below the eyes.
4. Walking, running, jumping by the stick: the legs walk with the game's
animation, no sliding when starting or stopping. Strafing left and
right, walking backwards and diagonally, also with the head turned away
from the way of walking: the legs step the way the body moves (sideways,
backwards), the torso facing as the head and hands have it; stopping,
the hips turn back under the torso and the feet step into place.
4a. Ground: standing still on a slope (Blood Gulch's hills and the bases'
ramps), across it and along it: both feet on the ground, tilted with
it, the lower knee straighter, the hips down with the lower foot; on
stairs (Prisoner, Chill Out, Damnation), one foot a step higher than
the other, turning in place there: each step lands on its stair. At a
ledge, one foot over it: level, nothing odd.
5. Left-handed (`vr.handedness = left`, mirrored or not): the arms from
the body's shoulders, the weapon in the left hand, the body as
right-handed.
6. Vehicles: Warthog driver and gunner as before (body with its hands on
the wheel or gun); Warthog passenger: seated legs and torso, the
first-person arms from its shoulders; Ghost, Banshee, Scorpion: no body.
7. Dying and respawning, a teleporter, a cutscene (campaign), the menus
and pause menu, the scope, camouflage, a game with 15 bots: nothing
broken, no body where it should not be.
8. Body "Arms and hands", "Hands only", "Weapon only": as before.
9. Performance: `first person: ... body ... ms` in `adb logcat -s halo`;
the Quest holds 90 Hz in a bot game; the Steam Frame's working time
against "arms" (about 12 ms of 13.9 before).
10. Push to talk (Voice chat: push to talk), in a network game with
another player: holding the free grip away from the weapon talks;
nothing while the free hand holds the foregrip or the Warthog's wheel;
"Open mic" talks without the grip; "Off" sends nothing (others still
heard).
## Frame hitches and GPU time (built: branch `fix/vr-frame-hitches`)
Measured on the Quest 3 (2026-10-12; 90 Hz, multiview, graphics "high",
full body, a 15-bot game on Blood Gulch): the game's CPU work 5-6 ms a
frame; the app's GPU time 5.5-6.4 ms (`xr: metrics`), the same with
foveation off, low, medium and high: **the GPU is not the bottleneck**.
The doubled pictures come from single long frames: shader programs compiled
in a frame (29 new ones in a 794 ms frame 22 s into a match, single ones
18-64 ms), draws of 254 ms (the first frame in a map) and 74 ms with
nothing compiled (most likely the driver making a program's variants at
its first draw), and the menus' PNG pictures (7 in 108 ms).
What the branch has:
| Part | What |
| --- | --- |
| Long frames | `VR: long frame N ms: input, ticks, camera, draw, present; shaders compiled or linked, programs from the cache; textures uploaded, menu pictures, render targets; draw calls N ms, the slowest N ms (vertex program, program key); N draws left out waiting for their programs`. The 5 s report counts the draws left out |
| GPU time | XR_META_performance_metrics on with `vr.log_performance`: `xr: metrics (mean/most) app/gpu_frametime=... compositor/dropped_frame_count=...` |
| Foveation | `vr.foveation` "off" (default) / "low" / "medium" on the MORE page (GL_QCOM_texture_foveated, the eyes only, lens centres); "high" taken out (it drew a grey rectangle below the player). Kept for later, no gain measured |
| Shader cache | `display.shader_cache`: programs kept in `shader_cache/` (binary, driver, sources); each map's list (`maps/<map>.txt`) and every map's (`all.txt`) |
| Worker | `display.shader_worker` (default true): programs made on a thread with a GL context sharing the game's (`gl worker: running`), from the cache's binary or compiled; a draw whose program is not made yet is left out for those frames, the frame never waits. As a map loads, its programs and then every map's are asked for |
| Warm-up | `debug.shader_warm_up`: "worker" (default: each program drawn with once on the worker into a small target of the eyes' kinds), "load" (the map's programs drawn with on the game's thread as it loads), "off" |
| Menus | The PC menus' pictures decoded on a thread of their own, drawn transparent for the frames that takes |
Second Quest test (latest build): the worker works, no long frame after the
map's first frames (245 ms and 72 ms there, each one draw of 67-75 ms with
vertex program 4). But doubled pictures in spells of seconds with the GPU
at 9-13 ms a frame (GPU% 0.91-0.94, 4-37 stale frames a second), 5-7 ms in
between: "high" is too much for 90 Hz in heavy scenes, "medium" holds.
Hence:
| Part | What |
| --- | --- |
| Graphics "auto" | `vr.graphics = "auto"`, the new default (Graphics row: Auto): "high" while the GPU keeps up. Past 4/5 of the frame (8.9 ms at 90 Hz) for a quarter second it leaves out the next steps (shield glow, shadows, reflections, specular light, lens flares, bump maps, the rest of "low"), as many as their learnt costs say; brings one back once GPU time + its cost stays under 7/10 of the frame for 3 s (longer if it had to go again within 10 s). Logs `VR graphics auto: shadows left out (step 2): the GPU 10.2 ms a frame of 11.1`, `... saved 1.9 ms`, `... back`. On the Steam Frame (no GPU time from SteamVR) "auto" is "high" |
| Measuring | `debug.vr_graphics_measure = true`: each effect left out in turn (1.5 s on, 1.5 s off), `VR graphics measure: shadows 2.10 ms (...)` and means each round |
What the effects cost, from the code (to be measured with the option above):
object shadows are drawn once a frame with multiview (the scene is drawn
once), twice one eye at a time; each shadow-casting object (every bot, its
weapon, vehicles) renders its map, blurs it and projects it onto the level
beneath: a pass of fill on both eyes per object. Reflections and specular
light are passes over the level's geometry. The shield's glow
(`display.shield_glow`, off by default) is a dynamic light per flaring unit
(at most 8), each lighting the level's surfaces in its reach once more, not
a pass per object; upstream's shield colour on the arms only changes a
colour. The whole body adds the player's own model and its shadow.
Not done, and why:
- Drawing the eyes straight into the swapchain: moot now (GPU not the
bottleneck); it needs GL_EXT_sRGB_write_control, the vignette as a pass
of its own and array swapchains acquired mid-frame.
- Decoding all menu pictures ahead: 165 pictures, about 350 MB decoded with
their mipmaps.
- Later: XR_KHR_visibility_mask.
### Headset test, graphics "auto" (Quest 3)
1. VR options, MORE: Graphics **Auto** (a `config.toml` written before
still says "high"), Foveation Off; `vr.log_performance = true`.
2. Log at start: `xr: performance metrics on (... the app's GPU time read)`,
`VR: graphics auto`.
3. 15-bot match on Blood Gulch, a few minutes, also into the busy middle:
- `VR graphics auto: ... left out (step N): the GPU X ms` within a
quarter to half a second of a heavy spell, `... saved Y ms`, and
`... back` a few seconds after it.
- VrApi: `Stale=` 0 nearly every second (at most a few in the second a
spell begins), no `ASW=1` spells; App GPU under about 9 ms.
- Look for effects that pop: shadows going and coming back. If they
change more than every ten seconds or so, note the log lines.
4. Costs: a second match with `debug.vr_graphics_measure = true` (Graphics
setting ignored meanwhile; stand somewhere busy, a few minutes):
`VR graphics measure: means (ms of GPU a frame): shadows ...;
reflections ...`. The steps' order should follow the largest costs.
5. The first frames of a map (245/72 ms, vertex program 4): try
`debug.shader_warm_up = "load"` once and compare those lines.
### Headset test, shader worker (Quest 3)
Before: `adb logcat -G 16M` (again after any reboot); in `config.toml`
`vr.log_performance = true`, 90 Hz, graphics "high", multiview, full body;
Foveation Off (VR options, MORE: the config may still say "medium"). After
each test: `adb logcat -d -s halo VrApi > test.txt`. The shader cache from
the earlier tests stays.
1. Start: `gl worker: running (ES 3, surfaceless, programs warmed up)` and
`shader cache: ... N programs kept (... N listed) ...; programs made on
the worker, warmed up there`. As the menus load: `shader cache:
levels_ui_ui: N programs listed; N asked of the worker ...; N of other
maps to ask for`.
2. Bot match on Blood Gulch: as it loads, `... bloodgulch: N listed; ...
N there already` (made while in the menus). In play no `VR: long frame`
with shaders compiled; at most a few `draws left out` (objects appearing
a frame or two late), and VrApi `Stale=0` most seconds, no `ASW=1`.
Any long frame left: its `draw calls ... the slowest N ms (vertex program
N, program K)` says whether a draw (a driver variant) is the cause.
3. A map not played yet (Damnation): as it loads, `N programs listed` is 0
but `N there already` should not matter: its programs mostly come from
every map's list (made in the menus). New ones are compiled on the worker
(`draws left out` for a moment, no long frame).
4. If draws of 70-250 ms remain with nothing compiled: `debug.shader_warm_up
= "load"` (the log: `shader cache: N programs drawn with once as the map
loaded, in N ms`), then compare; and `"off"` for the baseline.
5. Menus: open new screens; pictures may appear a frame late, no long
frames with `menu pictures`.
6. If anything breaks (missing objects for good, a crash):
`display.shader_worker = false` brings the game's-thread compiling back.
Steam Frame: the worker also runs there if its EGL shares contexts (the log
says `gl worker: running` or why not); no foveation. A short bot game to
see that nothing is missing or slower.
## Open points outside the order
- Multiview's doubled pictures (2026-10-16): on the developer's Quest 3,
doubled and shifted pictures from the start of big online games (26-35
players) as the head turned, nauseating; none in a local game with 48
bots; none online either with the eyes drawn one by one. Not the frame
rate: one by one, online at 72 Hz, the compositor repeated as many
frames (about 4 a second) and nothing was seen. Not the facing jumps
(respawns only) nor the camera (it moves every frame). Before: the
Steam Frame's (2026-10-05), each eye alone sharp. So the eyes are drawn
one by one by default now (`debug.multiview` for both at once). To do:
find what the shared pass draws wrongly online, e.g. by saving both
eyes' pictures in a big online game and comparing what is doubled
(sprites and effects projected by the game itself, `mv_depth`; programs
made on the shader worker; the sky rows), then turn it back on: it
halves the drawing's CPU cost.
- Big online games (2026-10-16): doubled pictures on a 32-player server,
none in a local game with 48 bots. Not the GPU (60–80 %): the ticks cost
3.3–4.6 ms as a client there (1.3–1.9 ms with bots or 16 players), so
47–70 of 150 frames with a tick missed 90 Hz's 11.1 ms (the doubling
itself was multiview's, above). In `main` 2026-10-16 (Quest 3 good):
`vr.refresh_rate_auto`
(VR options MORE, "Lower it when frames are late", on by default) steps
the rate down a step (90, 80, 72 Hz) while more than one frame in 32
comes late, and back up once they would keep up; "VR: refresh rate" in
the log. First try: 90 to 80 Hz a few seconds into a 30-player game, no
doubled picture seen; 2-5 stale frames a second left at 80, hence one
in 32 (was 16). Later:
why a client's ticks cost that much per remote player.
- Doubled pictures when turning on the developer's Quest 3 (2026-10-12):
with every build tried, back to 2026-10-06's, at 90 and at 72 Hz, even
in seconds the compositor reported no stale frame. That Quest runs
Horizon OS from the Public Test Channel (PTC; its build 2026-10-08),
the likeliest cause. Released with `vr.graphics` "medium" as the
default (smooth there). To do: try this and later builds with "high" on
a Quest 3 outside PTC (the developer's wife's), and set the default
back to "high" or "auto" if it holds.
Resolved 2026-10-16: multiview (above), not the Horizon OS PTC.
- Multiview on the Steam Frame: why the eyes do not fuse there (suspected
Mesa zink/Turnip); tools in branch `frame/doubled-images` (86335828),
not merged.
- Transparent geometry groups: the Xbox's 384 a window overflowed in
games of 30 and more players (the last submitted, glass, water,
particles, contrails, vanished). Raised to 2048 (320 KB) on
`feature/vr-comfort`, to check in a large game (step 2, second part,
test 6); upstream still has 384.
particles, contrails, vanished). Raised to 2048 (320 KB), in `main`;
still to check in a game of 30 and more players; upstream raised them
to 2048 too (2026-10-12).
## Headset tests (2026-10-06, all three in `main`)
+19 -5
View File
@@ -13,16 +13,30 @@ def buildNumberText = System.getenv("HALO_BUILD_NUMBER") ?: "0"
def buildNumber = buildNumberText.isInteger() ? buildNumberText.toInteger() : 0
// the workflow's signing key (.github/workflows/build.yml): every build of
// main signed with it can install over the one before; without it, the
// main signed with it can install over the one before; on a computer that
// builds for its own devices, the same key from port/android/signing.properties
// (not in the repository: storeFile, password, keyAlias), so that its
// builds and the releases install over each other; without either, the
// debug key of the computer that builds
def signingKey = System.getenv("HALO_ANDROID_KEYSTORE")
def signingPassword = System.getenv("HALO_ANDROID_KEYSTORE_PASSWORD")
def signingAlias = System.getenv("HALO_ANDROID_KEY_ALIAS") ?: "ringfall"
def signingProperties = new File(rootDir, "signing.properties")
if (!signingKey && signingProperties.isFile()) {
def properties = new Properties()
signingProperties.withInputStream { properties.load(it) }
signingKey = properties.getProperty("storeFile")
signingPassword = properties.getProperty("password")
signingAlias = properties.getProperty("keyAlias", "ringfall")
}
android {
namespace "com.halo.decomp"
compileSdk 35
defaultConfig {
applicationId "com.halo.decomp"
// (the app's own id; the Java classes keep the decompilation's package)
applicationId "io.github.cesarus85.ringfall"
minSdk 28
targetSdk 35
versionCode Math.max(1, buildNumber)
@@ -60,9 +74,9 @@ android {
if (signingKey) {
halo {
storeFile file(signingKey)
storePassword System.getenv("HALO_ANDROID_KEYSTORE_PASSWORD")
keyAlias System.getenv("HALO_ANDROID_KEY_ALIAS") ?: "halo"
keyPassword System.getenv("HALO_ANDROID_KEYSTORE_PASSWORD")
storePassword signingPassword
keyAlias signingAlias
keyPassword signingPassword
}
}
}
+14 -3
View File
@@ -19,17 +19,25 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- voice chat's microphone (asked for only when voice chat first talks) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- installing a new version of the game (Updater) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- enableOnBackInvokedCallback: the back button and the back gesture
stay KEYCODE_BACK, which SDL passes to the game as B; with
predictive back (the default once an app targets Android 16) the
system would take them and the game would never see them -->
<application
android:name=".HaloApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:isGame="true"
android:appCategory="game"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
android:hardwareAccelerated="true">
android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="false">
<!-- a game, for the system (isGame and appCategory above; the game
modes' configuration) and for launchers that look for this -->
@@ -68,7 +76,10 @@
(without them Horizon OS shows it as a flat panel, and the OpenXR
session never gets focus); phones ignore them. The launcher,
a flat panel on a headset, starts it once the game data is
there. -->
there. (port: upstream runs it in a process of its own,
":game", for handhelds whose Java heap covers the Xbox window;
not here: on the Steam Frame Lepton waits for the app's process
to end, and the headsets have no such overlap) -->
<activity
android:name=".HaloActivity"
android:exported="true"
@@ -93,7 +104,7 @@
<!-- the downloaded new version, for the package installer (Updater) -->
<provider
android:name=".UpdateProvider"
android:authorities="com.halo.decomp.update"
android:authorities="${applicationId}.update"
android:exported="false"
android:grantUriPermissions="true" />
@@ -0,0 +1,71 @@
package com.halo.decomp;
/**
* Gyroscope aiming (TouchControls): integrates the sensor's angular velocity,
* turned into the display's coordinates for its rotation, into radians of
* view turn between samples.
*/
final class GyroscopeAim {
/** below this (radians a second) a reading is the sensor's noise at rest, which must not drift the view */
private static final float NOISE = 0.01f;
/** a gap longer than this (seconds) starts over rather than turn at once */
private static final float LONGEST_GAP = 0.1f;
private long previousTime;
private int previousRotation = -1;
private float previousX;
private float previousY;
void reset() {
previousTime = 0;
previousRotation = -1;
}
/**
* takes one sample (its time in nanoseconds, the angular velocity about
* the device's x and y axes, the display's rotation 0..3) and puts the turn
* since the last into delta {yaw, pitch}; false while there is none
*/
boolean sample(long time, float x, float y, int rotation, float[] delta) {
delta[0] = 0;
delta[1] = 0;
if (!Float.isFinite(x) || !Float.isFinite(y) || rotation < 0 || rotation > 3) {
reset();
return false;
}
float screenX = x;
float screenY = y;
switch (rotation) {
case 1:
screenX = y;
screenY = -x;
break;
case 2:
screenX = -x;
screenY = -y;
break;
case 3:
screenX = -y;
screenY = x;
break;
default:
break;
}
if (Math.abs(screenX) < NOISE)
screenX = 0;
if (Math.abs(screenY) < NOISE)
screenY = 0;
float seconds = (time - previousTime) * 1e-9f;
boolean valid = previousTime != 0 && rotation == previousRotation && seconds > 0 && seconds <= LONGEST_GAP;
if (valid) {
// the trapezoid rule over the two samples
delta[0] = -(screenY + previousY) * 0.5f * seconds;
delta[1] = -(screenX + previousX) * 0.5f * seconds;
}
previousTime = time;
previousRotation = rotation;
previousX = screenX;
previousY = screenY;
return valid && (delta[0] != 0 || delta[1] != 0);
}
}
@@ -1,10 +1,25 @@
package com.halo.decomp;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.graphics.Insets;
import android.net.Uri;
import android.net.wifi.WifiManager;
import android.os.Build;
import android.os.Bundle;
import android.view.Display;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.widget.Toast;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import org.libsdl.app.SDLActivity;
@@ -15,6 +30,16 @@ import org.libsdl.app.SDLActivity;
public class HaloActivity extends SDLActivity {
/** lets system link's broadcasts in over Wi-Fi while the game runs */
private WifiManager.MulticastLock multicastLock;
/**
* the latest system gesture insets {left, top, right, bottom}, in pixels;
* written on the UI thread, read by the game's thread: replaced as a
* whole, never changed in place
*/
private volatile int[] gestureInsets = new int[] { 0, 0, 0, 0 };
/** the on-screen touch controls, over SDL's surface */
private TouchControls touchControls;
private static final int EXPORT_LAYOUT = 401, IMPORT_LAYOUT = 402;
private String pendingLayoutExport;
@Override
protected String[] getLibraries() {
@@ -24,21 +49,208 @@ public class HaloActivity extends SDLActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (savedInstanceState != null)
pendingLayoutExport = savedInstanceState.getString("pending-layout-export");
if (mLayout != null) {
touchControls = new TouchControls(this);
mLayout.addView(touchControls, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
}
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
preferHighestRefreshRate();
trackGestureInsets();
acquireMulticastLock();
// a new version looked for while the game starts
Updater.start(this);
}
/** the system's file picker, for the touch layout; no storage permission */
public void chooseLayoutFile(boolean export, String configuration) {
String message = export
? "Choose the folder and the name of the touch layout file."
: "Choose a touch layout file. It replaces your buttons, the look sensitivity and the General settings.";
new AlertDialog.Builder(this)
.setTitle(export ? "Export the layout" : "Import a layout")
.setMessage(message)
.setNegativeButton("Cancel", null)
.setPositiveButton(export ? "Choose the place" : "Choose the file", (dialog, which) -> {
Intent intent = new Intent(export ? Intent.ACTION_CREATE_DOCUMENT : Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(export ? "text/plain" : "*/*");
if (export) {
pendingLayoutExport = configuration;
intent.putExtra(Intent.EXTRA_TITLE, "halo-touch-layout.halolayout");
}
try {
startActivityForResult(intent, export ? EXPORT_LAYOUT : IMPORT_LAYOUT);
} catch (android.content.ActivityNotFoundException e) {
pendingLayoutExport = null;
Toast.makeText(this, "This device has no file picker.", Toast.LENGTH_LONG).show();
}
})
.show();
}
@Override
protected void onSaveInstanceState(Bundle state) {
super.onSaveInstanceState(state);
state.putString("pending-layout-export", pendingLayoutExport);
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
if (request != EXPORT_LAYOUT && request != IMPORT_LAYOUT) {
super.onActivityResult(request, result, data);
return;
}
String exported = pendingLayoutExport;
pendingLayoutExport = null;
if (result != RESULT_OK || data == null || data.getData() == null)
return;
Uri document = data.getData();
// (the file is read and written away from the UI thread)
new Thread(() -> {
try {
if (request == EXPORT_LAYOUT)
writeLayoutFile(document, exported);
else
readLayoutFile(document);
} catch (Exception e) {
runOnUiThread(() -> layoutFileError(e));
}
}, "halo-touch-layout-file").start();
}
private void writeLayoutFile(Uri document, String configuration) throws IOException {
if (configuration == null)
throw new IOException("the layout to export is gone");
try (OutputStream output = getContentResolver().openOutputStream(document, "wt")) {
if (output == null)
throw new IOException("the file cannot be opened");
output.write(configuration.getBytes(StandardCharsets.UTF_8));
}
runOnUiThread(() -> Toast.makeText(this, "The layout is exported.", Toast.LENGTH_SHORT).show());
}
private void readLayoutFile(Uri document) throws IOException {
ByteArrayOutputStream contents = new ByteArrayOutputStream();
try (InputStream input = getContentResolver().openInputStream(document)) {
if (input == null)
throw new IOException("the file cannot be opened");
byte[] buffer = new byte[4096];
int size;
while ((size = input.read(buffer)) != -1) {
if (contents.size() + size > 65536)
throw new IOException("the file is too large");
contents.write(buffer, 0, size);
}
}
String configuration = new String(contents.toByteArray(), StandardCharsets.UTF_8);
// checked here, then applied to the overlay at once
TouchLayout.importConfiguration(configuration);
runOnUiThread(() -> {
if (isFinishing() || isDestroyed() || touchControls == null)
return;
try {
touchControls.importLayout(configuration);
Toast.makeText(this, "The layout is imported and saved.", Toast.LENGTH_SHORT).show();
} catch (IllegalArgumentException e) {
layoutFileError(e);
}
});
}
private void layoutFileError(Exception error) {
if (isFinishing() || isDestroyed())
return;
new AlertDialog.Builder(this)
.setTitle("Touch layout file")
.setMessage("The operation did not complete: " + error.getMessage())
.setPositiveButton("OK", null)
.show();
}
@Override
protected void onResume() {
super.onResume();
if (touchControls != null && getWindow().getDecorView().hasWindowFocus())
touchControls.startDeviceInput();
}
@Override
protected void onPause() {
if (touchControls != null)
touchControls.stopDeviceInput();
super.onPause();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
if (touchControls != null) {
if (hasFocus)
touchControls.startDeviceInput();
else
touchControls.stopDeviceInput();
}
super.onWindowFocusChanged(hasFocus);
}
@Override
protected void onDestroy() {
if (touchControls != null)
touchControls.stopDeviceInput();
if (multicastLock != null && multicastLock.isHeld())
multicastLock.release();
multicastLock = null;
super.onDestroy();
}
/**
* keeps gestureInsets current; Android sends the insets again when the
* activity rotates (after the game's native code has started on a phone
* launched from portrait), so a single read at startup would keep the
* portrait values; the listener hands the insets on so SDL's own
* handling still sees them
*/
private void trackGestureInsets() {
getWindow().getDecorView().setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
@Override
public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) {
gestureInsets = readGestureInsets(insets);
return view.onApplyWindowInsets(insets);
}
});
}
@SuppressWarnings("deprecation") // getSystemGestureInsets is the only call on Android 10
private static int[] readGestureInsets(WindowInsets insets) {
Insets gesture;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
gesture = insets.getInsets(WindowInsets.Type.systemGestures());
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
gesture = insets.getSystemGestureInsets();
else
return new int[] { 0, 0, 0, 0 };
return new int[] { gesture.left, gesture.top, gesture.right, gesture.bottom };
}
/**
* the edges of the screen where Android keeps its gestures; in sticky
* full screen the first swipe from an edge only shows the system bars,
* and Android hands that swipe to the game as an ordinary finger, so
* the game (port/linux/src/touch_input.c) must ignore touches that
* begin there; returns a copy of {left, top, right, bottom} in pixels:
* all 0 before Android 10 (which has no insets) and until the first
* insets arrive. The game's native code calls this through JNI by name
* and signature (host_main.c GetMethodID(...,
* "getSystemGestureInsetsPixels", "()[I")), at every finger down, so
* it must not be renamed, retyped or removed as unused
*/
public int[] getSystemGestureInsetsPixels() {
return gestureInsets.clone();
}
/**
* Many phones drop the Wi-Fi's broadcast and multicast datagrams to
* save power unless an app holds this: without it they would not see
@@ -0,0 +1,33 @@
package com.halo.decomp;
import android.app.Application;
/**
* The game (HaloActivity) runs in a process of its own, ":game", so that
* its Java runtime starts fresh for it: nothing the launcher, the disc
* image import or an earlier game did is left in its heap.
*
* There the native library is loaded first thing, and reserves the fixed
* addresses the game needs at 0x80000000 (port/android/host/host_main.c,
* JNI_OnLoad) before the Java side has allocated anything large: on some
* devices, handhelds with a large Java heap especially, ART's large object
* space covers those addresses, and only an idle part of it can be taken
* back (port/android/host/host_memory.c).
*/
public class HaloApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
String process = Application.getProcessName();
if (process == null || !process.endsWith(":game"))
return;
try {
// in SDLActivity's order (HaloActivity.getLibraries); it
// finds them loaded
System.loadLibrary("SDL3");
System.loadLibrary("main");
} catch (UnsatisfiedLinkError e) {
// SDLActivity reports it
}
}
}
@@ -27,7 +27,7 @@ import java.nio.channels.FileChannel;
* Starts the game once its data is in place.
*
* The game reads the Xbox game data (the folder holding maps/) from the
* app's external files directory, /sdcard/Android/data/com.halo.decomp/files.
* app's external files directory, /sdcard/Android/data/<application id>/files.
* If it is missing, this screen lets the player pick an Xbox disc image of
* the game (.xiso or .iso, any version) with the system file picker, and
* copies its maps folder there (XisoExtractor), as the desktop games do; or
@@ -35,7 +35,7 @@ import java.nio.channels.FileChannel;
*/
public class LauncherActivity extends Activity {
private static final int PICK_IMAGE = 1;
private static final String SHARED_DATA_ROOT = "/home/steamos/Documents/HaloCE";
private static final String SHARED_DATA_ROOT = "/home/steamos/Documents/RingfallMjolnirVR";
private File dataRoot;
private TextView status;
@@ -49,10 +49,15 @@ public class LauncherActivity extends Activity {
dataRoot = getExternalFilesDir(null);
// the Steam Frame: Lepton empties the app's own folders whenever it
// starts again; the game's data lives in the SteamOS user's
// Documents instead, when it is there (port/android/STEAM_FRAME.md;
// the same choice as host_main.c's)
if (new File(SHARED_DATA_ROOT, "maps/ui.map").isFile() && new File(SHARED_DATA_ROOT).canWrite())
dataRoot = new File(SHARED_DATA_ROOT);
// Documents instead, whenever they can be written, so that a disc
// image imported here stays too (port/android/STEAM_FRAME.md; the
// same choice as host_main.c's)
File shared = new File(SHARED_DATA_ROOT);
if (shared.getParentFile().canWrite() || shared.canWrite()) {
shared.mkdirs();
if (shared.canWrite())
dataRoot = shared;
}
// created by the app, so that files pushed into it with adb stay
// readable (a directory adb creates there belongs to the shell user)
if (dataRoot != null)
@@ -142,15 +147,15 @@ public class LauncherActivity extends Activity {
layout.setBackgroundColor(Color.rgb(12, 16, 20));
TextView title = new TextView(this);
title.setText("Halo needs its game data");
title.setText("Ringfall: Mjolnir VR needs game data");
title.setTextColor(Color.WHITE);
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24);
title.setGravity(Gravity.CENTER);
layout.addView(title);
TextView message = new TextView(this);
message.setText("Choose an Xbox disc image of Halo: Combat Evolved (an .iso or .xiso file, any "
+ "version) on this device. Its maps folder is copied into the app's storage (about 1.8 GB), "
message.setText("Choose your own disc image of Halo: Combat Evolved for the original Xbox (an .iso or "
+ ".xiso file, any version) on this device. Its maps folder is copied into the app's storage (about 1.8 GB), "
+ "and you can delete the image afterwards.\n\n"
+ "You can also copy a maps folder from a computer:\n"
+ "adb push <folder with maps>/. " + (dataRoot != null ? dataRoot.getAbsolutePath() : "") + "/");
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,335 @@
package com.halo.decomp;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Properties;
/**
* The touch controls' layout (TouchControls): each control's kind, place,
* size and whether it shows, with the settings that go with them, in the
* overlay's logical units. Places are kept for the display the layout is
* on and saved on a 960 x 540 grid, so a layout moves between displays of
* other shapes. A layout file is Java properties text (version 3; 1 and 2
* are still read).
*/
final class TouchLayout {
/** the kinds of control: a button of TouchControls's list, by index */
static final int LEFT = 16;
static final int FIRE_LEFT = 17;
static final int BASE_COUNT = 18;
static final int MAX_CONTROLS = 64;
static final float MIN_SIZE = 0.5f;
static final float MAX_SIZE = 2f;
static final float MIN_OPACITY = 0.2f;
static final float MAX_OPACITY = 1f;
static final float MIN_SENSITIVITY = 0.25f;
static final float MAX_SENSITIVITY = 4f;
/**
* the largest minimum radius: a finger's width is capped here on a small,
* dense display, where the D-pad's buttons (69 apart) would overlap
*/
static final float MAX_MINIMUM_RADIUS = 34;
/** the saved grid */
private static final float GRID_WIDTH = 960;
private static final float GRID_HEIGHT = 540;
private static final int VERSION = 3;
private static final int MAX_FILE_LENGTH = 65536;
/** each kind's default place on the grid, and its radius */
private static final float[][] DEFAULTS = {
{856, 447}, {913, 377}, {794, 377}, {850, 312}, {915, 239}, {802, 239},
{236, 449}, {691, 449}, {360, 490}, {438, 490}, {570, 36}, {390, 36},
{100, 237}, {100, 335}, {51, 286}, {149, 286}, {115, 440}, {255, 239}
};
private static final float[] RADII = {
36, 32, 34, 32, 39, 35, 32, 32, 27, 29, 28, 28, 25, 25, 25, 25, 64, 39
};
private static final class Control {
final int type;
float x;
float y;
boolean shown = true;
float size = 1f;
Control(int type, float x, float y) {
this.type = type;
this.x = x;
this.y = y;
}
}
/** a layout and the sensitivity saved with it */
static final class Configuration {
final TouchLayout layout;
final float sensitivity;
Configuration(TouchLayout layout, float sensitivity) {
this.layout = layout;
this.sensitivity = sensitivity;
}
}
private final ArrayList<Control> controls = new ArrayList<>();
private float width = GRID_WIDTH;
private float height = GRID_HEIGHT;
/** the smallest radius a control is drawn and touched at, in logical units */
private float minimumRadius;
boolean rumbleEnabled = true;
boolean gyroscopeEnabled = false;
/** how solid the controls are drawn, MIN_OPACITY..MAX_OPACITY */
float opacity = 1f;
/** the move stick goes where the thumb lands in the lower left, not at its place */
boolean floatingStick = false;
TouchLayout() {
resetDefaults();
}
/** places the layout on a display of this size, in logical units */
void bounds(float width, float height) {
if (!Float.isFinite(width) || !Float.isFinite(height) || width <= 0 || height <= 0)
throw new IllegalArgumentException("Invalid display dimensions");
for (Control control : controls) {
control.x *= width / this.width;
control.y *= height / this.height;
}
this.width = width;
this.height = height;
keepAllInside();
}
/**
* the smallest radius, in logical units, at which a control is drawn and
* touched (a finger's width, whatever the display's density), at most
* MAX_MINIMUM_RADIUS
*/
void setMinimumRadius(float radius) {
minimumRadius = Float.isFinite(radius) && radius > 0 ? Math.min(radius, MAX_MINIMUM_RADIUS) : 0;
keepAllInside();
}
int size() {
return controls.size();
}
int type(int control) {
return controls.get(control).type;
}
float x(int control) {
return controls.get(control).x;
}
float y(int control) {
return controls.get(control).y;
}
float savedX(int control) {
return x(control) * GRID_WIDTH / width;
}
float savedY(int control) {
return y(control) * GRID_HEIGHT / height;
}
float radius(int control) {
return Math.max(RADII[type(control)] * sizeScale(control), minimumRadius);
}
float sizeScale(int control) {
return controls.get(control).size;
}
void setSize(int control, float size) {
if (!Float.isFinite(size) || size < MIN_SIZE || size > MAX_SIZE)
throw new IllegalArgumentException("Button size must be between 50% and 200%");
controls.get(control).size = size;
move(control, x(control), y(control));
}
boolean shown(int control) {
return controls.get(control).shown;
}
void setShown(int control, boolean shown) {
controls.get(control).shown = shown;
}
void setOpacity(float opacity) {
if (!validOpacity(opacity))
throw new IllegalArgumentException("Opacity must be between 20% and 100%");
this.opacity = opacity;
}
/** the default controls, at their default places, sizes and visibility */
void resetDefaults() {
controls.clear();
for (int i = 0; i < DEFAULTS.length; i++)
controls.add(new Control(i, DEFAULTS[i][0] * width / GRID_WIDTH, DEFAULTS[i][1] * height / GRID_HEIGHT));
keepAllInside();
}
/** adds a copy of a control beside it; -1 if there may be no more (or it is the stick) */
int duplicate(int source) {
if (size() >= MAX_CONTROLS || type(source) == LEFT)
return -1;
float offset = radius(source) * 2 + 12;
Control added = new Control(type(source), x(source) + offset, y(source));
added.size = sizeScale(source);
controls.add(added);
int index = size() - 1;
// where there is no room to the right, the copy goes to the left
if (added.x > width - radius(index))
added.x = x(source) - offset;
move(index, added.x, added.y);
return index;
}
/** shows a hidden control of this kind, or else adds a copy; -1 if neither can be */
int add(int type) {
if (type < 0 || type >= BASE_COUNT)
return -1;
for (int i = 0; i < size(); i++) {
if (type(i) == type && !shown(i)) {
setShown(i, true);
return i;
}
}
return duplicate(type);
}
/** moves a control, keeping all of it on the display */
void move(int control, float x, float y) {
if (!Float.isFinite(x) || !Float.isFinite(y))
return;
float radius = radius(control);
controls.get(control).x = Math.max(radius, Math.min(width - radius, x));
controls.get(control).y = Math.max(radius, Math.min(height - radius, y));
}
/** puts a control at a saved place on the grid, if it is a valid one */
void restore(int control, float x, float y) {
if (Float.isFinite(x) && Float.isFinite(y) && x >= 0 && x <= GRID_WIDTH && y >= 0 && y <= GRID_HEIGHT) {
controls.get(control).x = x;
controls.get(control).y = y;
}
}
private void keepAllInside() {
for (int i = 0; i < size(); i++)
move(i, x(i), y(i));
}
/** the layout as a layout file's text, with the look sensitivity */
String exportConfiguration(float sensitivity) {
if (!validSensitivity(sensitivity))
throw new IllegalArgumentException("Invalid sensitivity");
Properties values = new Properties();
values.setProperty("format", "halo-touch-layout");
values.setProperty("version", Integer.toString(VERSION));
values.setProperty("rumble", Boolean.toString(rumbleEnabled));
values.setProperty("gyroscope", Boolean.toString(gyroscopeEnabled));
values.setProperty("opacity", Float.toString(opacity));
values.setProperty("floating-stick", Boolean.toString(floatingStick));
values.setProperty("count", Integer.toString(size()));
values.setProperty("sensitivity", Float.toString(sensitivity));
for (int i = 0; i < size(); i++) {
String key = "control." + i + ".";
values.setProperty(key + "type", Integer.toString(type(i)));
values.setProperty(key + "x", Float.toString(savedX(i)));
values.setProperty(key + "y", Float.toString(savedY(i)));
values.setProperty(key + "visible", Boolean.toString(shown(i)));
values.setProperty(key + "size", Float.toString(sizeScale(i)));
}
StringWriter text = new StringWriter();
try {
values.store(text, "Halo Android touch layout");
} catch (IOException e) {
throw new IllegalStateException(e);
}
return text.toString();
}
/** reads a layout file's text; throws IllegalArgumentException for anything not valid */
static Configuration importConfiguration(String text) {
if (text == null || text.length() > MAX_FILE_LENGTH)
throw new IllegalArgumentException("Invalid layout file");
Properties values = new Properties();
try {
values.load(new StringReader(text));
int version = readVersion(values);
int count = Integer.parseInt(values.getProperty("count"));
float sensitivity = Float.parseFloat(values.getProperty("sensitivity"));
if (count < BASE_COUNT || count > MAX_CONTROLS || !validSensitivity(sensitivity))
throw new IllegalArgumentException("Invalid layout settings");
TouchLayout layout = new TouchLayout();
layout.controls.clear();
if (version >= 2) {
layout.rumbleEnabled = readBoolean(values, "rumble");
layout.gyroscopeEnabled = readBoolean(values, "gyroscope");
}
if (version >= 3) {
float opacity = Float.parseFloat(values.getProperty("opacity"));
if (!validOpacity(opacity))
throw new IllegalArgumentException("Invalid opacity");
layout.opacity = opacity;
layout.floatingStick = readBoolean(values, "floating-stick");
}
for (int i = 0; i < count; i++)
layout.controls.add(readControl(values, i, version));
return new Configuration(layout, sensitivity);
} catch (IOException | NullPointerException e) {
throw new IllegalArgumentException("Incomplete layout file", e);
}
}
private static int readVersion(Properties values) {
if (!"halo-touch-layout".equals(values.getProperty("format")))
throw new IllegalArgumentException("Unsupported layout file");
String version = values.getProperty("version");
for (int known = 1; known <= VERSION; known++) {
if (Integer.toString(known).equals(version))
return known;
}
throw new IllegalArgumentException("Unsupported layout file");
}
private static Control readControl(Properties values, int index, int version) {
String key = "control." + index + ".";
int type = Integer.parseInt(values.getProperty(key + "type"));
float x = Float.parseFloat(values.getProperty(key + "x"));
float y = Float.parseFloat(values.getProperty(key + "y"));
String shown = values.getProperty(key + "visible");
float size = version >= 2 ? Float.parseFloat(values.getProperty(key + "size")) : 1f;
boolean validType = type >= 0 && type < BASE_COUNT && (index >= BASE_COUNT ? type != LEFT : type == index);
boolean validPlace = Float.isFinite(x) && Float.isFinite(y) && x >= 0 && x <= GRID_WIDTH
&& y >= 0 && y <= GRID_HEIGHT;
boolean validSize = Float.isFinite(size) && size >= MIN_SIZE && size <= MAX_SIZE;
if (!validType || !validPlace || !validSize || !("true".equals(shown) || "false".equals(shown)))
throw new IllegalArgumentException("Invalid control in layout file");
Control control = new Control(type, x, y);
control.shown = Boolean.parseBoolean(shown);
control.size = size;
return control;
}
private static boolean readBoolean(Properties values, String key) {
String value = values.getProperty(key);
if (!("true".equals(value) || "false".equals(value)))
throw new IllegalArgumentException("Invalid " + key + " option");
return Boolean.parseBoolean(value);
}
static boolean validSensitivity(float value) {
return Float.isFinite(value) && value >= MIN_SENSITIVITY && value <= MAX_SENSITIVITY;
}
static boolean validOpacity(float value) {
return Float.isFinite(value) && value >= MIN_OPACITY && value <= MAX_OPACITY;
}
}
@@ -13,13 +13,13 @@ import java.io.FileNotFoundException;
/**
* Hands Android's package installer the downloaded new version of the app
* (Updater), read-only, the one file content://com.halo.decomp.update/halo.apk.
* (Updater), read-only, the one file content://<application id>.update/update.apk.
* (The app has no AndroidX, whose FileProvider does the same.)
*/
public class UpdateProvider extends ContentProvider {
static final String AUTHORITY = "com.halo.decomp.update";
static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".update";
static final String DIRECTORY = "update";
static final String APK = "halo.apk";
static final String APK = "update.apk";
private File apk() {
return new File(new File(getContext().getCacheDir(), DIRECTORY), APK);
@@ -49,9 +49,12 @@ import java.util.zip.ZipInputStream;
final class Updater {
/* the VR port's own releases: the original's, made without VR, must
never replace it (they share the package name) */
private static final String REPOSITORY = "Cesarus85/halo-ce-vr";
private static final String APP_NAME = "Ringfall: Mjolnir VR";
private static final String REPOSITORY = "Cesarus85/ringfall-mjolnir-vr";
private static final String USER_AGENT = "halo-ce-universal-updater";
private static final int TIMEOUT_MILLISECONDS = 20000;
/** the most a download (a release's zip, about 35 MB) or the app in it may be */
private static final long MAXIMUM_UPDATE_SIZE = 256L * 1024 * 1024;
private Updater() {
}
@@ -202,8 +205,8 @@ final class Updater {
if (activity.isFinishing())
return;
new AlertDialog.Builder(activity)
.setTitle("Halo: new version")
.setMessage("A new version of Halo was detected (build " + latest + "; this is build "
.setTitle(APP_NAME + ": new version")
.setMessage("A new version of " + APP_NAME + " was detected (build " + latest + "; this is build "
+ BuildConfig.HALO_BUILD_NUMBER + ").\n\nDo you want to update? The game will close and start "
+ "the new version.")
.setCancelable(false)
@@ -215,7 +218,7 @@ final class Updater {
private static void confirmNever(Activity activity) {
new AlertDialog.Builder(activity)
.setTitle("Halo: new version")
.setTitle(APP_NAME + ": new version")
.setMessage("Stop asking about new versions?\n\nTo ask again, set auto = true in the [update] section "
+ "of config.toml.")
.setCancelable(false)
@@ -232,7 +235,7 @@ final class Updater {
/* ---------- updating */
private static void update(Activity activity, int latest) {
String asset = "halo-android-" + (BuildConfig.DEBUG ? "debug" : "release") + ".zip";
String asset = "ringfall-mjolnir-vr-android-" + (BuildConfig.DEBUG ? "debug" : "release") + ".zip";
File directory = new File(activity.getCacheDir(), UpdateProvider.DIRECTORY);
LinearLayout layout = new LinearLayout(activity);
TextView status = new TextView(activity);
@@ -247,7 +250,7 @@ final class Updater {
layout.addView(status);
layout.addView(bar);
AlertDialog progress = new AlertDialog.Builder(activity)
.setTitle("Halo: new version")
.setTitle(APP_NAME + ": new version")
.setView(layout)
.setCancelable(false)
.show();
@@ -275,7 +278,7 @@ final class Updater {
activity.runOnUiThread(() -> {
progress.dismiss();
new AlertDialog.Builder(activity)
.setTitle("Halo: new version")
.setTitle(APP_NAME + ": new version")
.setMessage("The update failed:\n\n" + e.getMessage())
.setPositiveButton("OK", null)
.show();
@@ -304,8 +307,10 @@ final class Updater {
int count;
while ((count = in.read(buffer)) > 0) {
out.write(buffer, 0, count);
received += count;
if (received > MAXIMUM_UPDATE_SIZE)
throw new IOException("the download is larger than expected");
out.write(buffer, 0, count);
if (received - reported >= 256 * 1024 || received == total) {
progress.report(received, total);
reported = received;
@@ -329,10 +334,15 @@ final class Updater {
continue;
try (OutputStream out = new FileOutputStream(apk)) {
byte[] buffer = new byte[65536];
long written = 0;
int count;
while ((count = in.read(buffer)) > 0)
while ((count = in.read(buffer)) > 0) {
written += count;
if (written > MAXIMUM_UPDATE_SIZE)
throw new IOException("the app in the download is larger than expected");
out.write(buffer, 0, count);
}
}
return;
}
@@ -135,6 +135,20 @@ final class XisoExtractor {
return true;
}
/** whether a name is one to write in the maps folder: no path, no "." or
* "..", and only printable ASCII that every file system takes as a plain
* name (a disc's maps folder holds .map files and loading.tga) */
static boolean isPlainName(String name) {
if (name.isEmpty() || name.equals(".") || name.equals(".."))
return false;
for (int index = 0; index < name.length(); index++) {
char c = name.charAt(index);
if (c < 0x20 || c > 0x7E || "/\\:*?\"<>|".indexOf(c) >= 0)
return false;
}
return true;
}
/** the partition's volume descriptor: the root directory's sector and size */
private long[] findVolume() throws IOException {
for (long offset : PARTITION_OFFSETS) {
@@ -190,8 +204,9 @@ final class XisoExtractor {
for (int index = 0; index < nameLength; index++)
bytes[index] = table.get(at + ENTRY_HEADER_SIZE + index);
String name = new String(bytes, StandardCharsets.ISO_8859_1);
/* (as extract-xiso refuses them: no name may leave the folder) */
if (!name.equals(".") && !name.equals("..") && name.indexOf('/') < 0 && name.indexOf('\\') < 0)
/* (as extract-xiso refuses them: no name may leave the folder, and
none but a plain file name is written) */
if (isPlainName(name))
out.add(new Entry(name, table.getInt(at + 4) & 0xFFFFFFFFL, table.getInt(at + 8) & 0xFFFFFFFFL));
}
if (right != 0)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Halo</string>
<string name="app_name">Ringfall: Mjolnir VR</string>
</resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 21 KiB

+50 -2
View File
@@ -46,6 +46,8 @@ unsigned int host_memory_watch_generation(unsigned int address, unsigned int siz
unsigned int host_memory_watch_serial(void);
void host_memory_watch_prepare_write(unsigned int address, unsigned int size);
void host_memory_watch_forget(unsigned int address, unsigned int size);
/* memory_watch_begin_frame (port/linux/src/platform.h) */
void host_memory_watch_begin_frame(void);
/* ---------- SDL (guest/runtime/guest_sdl.c)
@@ -80,11 +82,16 @@ int host_sdl_gamepad_axis(unsigned int gamepad, int axis);
int host_sdl_gamepad_button(unsigned int gamepad, int button);
int host_sdl_gamepad_type(unsigned int gamepad);
int host_sdl_rumble_gamepad(unsigned int gamepad, unsigned int low, unsigned int high, unsigned int milliseconds);
void host_sdl_close_gamepad(unsigned int gamepad);
/* callback: void (*)(void *userdata, unsigned int stream, int additional, int total),
called on the audio thread */
unsigned int host_sdl_open_audio_stream(unsigned int device, const void *spec, unsigned int callback, unsigned int userdata);
int host_sdl_put_audio_stream_data(unsigned int stream, const void *data, int length);
int host_sdl_resume_audio_stream_device(unsigned int stream);
/* (voice chat's microphone, a stream without a callback) */
int host_sdl_get_audio_stream_data(unsigned int stream, void *data, int length);
int host_sdl_get_audio_stream_available(unsigned int stream);
void host_sdl_destroy_audio_stream(unsigned int stream);
/* ---------- OpenGL ES */
@@ -92,8 +99,9 @@ int host_sdl_resume_audio_stream_device(unsigned int stream);
void host_gl_get_string(unsigned int name, int index, char *buffer, unsigned int size);
/* nonzero if the context supports the named extension */
int host_gl_has_extension(const char *name);
/* a 32-bit word of a GL buffer object, waiting for the GPU */
unsigned int host_gl_read_buffer_word(unsigned int buffer, unsigned int offset);
/* copies size bytes at offset of a GL buffer object into data, waiting for
the GPU's writes to it */
void host_gl_read_buffer(unsigned int buffer, unsigned int offset, unsigned int size, void *data);
/* unsynchronized write into the buffer bound to target */
void host_gl_buffer_write(unsigned int target, unsigned int offset, unsigned int size, const void *data);
/* persistent, coherent storage for the buffer bound to target (0: the driver
@@ -108,6 +116,23 @@ void host_gl_wait_frame(unsigned int slot);
reads a snapshot once its frame is finished */
void host_gl_counters_snapshot(unsigned int buffer, unsigned int size, unsigned int slot);
int host_gl_counters_read(unsigned int slot, void *out, unsigned int size);
/* the shader programs' worker (port/android/host/host_gl_worker.c), for
port/linux/src/xgpu_program_cache.c: started on the game's thread with its
context current (1: running); a program asked for (its binary tried first,
else its sources compiled; urgent: a draw waits for it), a job's number or
0; hurried along; polled (1 done: *program, its kept binary's size, the
time it took; 0 not yet; -1 failed); its kept binary copied out; the job
forgotten; how many jobs are not done; and a warm-up draw with a program on
the calling thread's context */
int host_gl_worker_start(int warm_up);
unsigned int host_gl_program_request(int urgent, const char *vertex_source, const char *fragment_source,
unsigned int binary_format, const void *binary, unsigned int binary_size, int keep_binary);
void host_gl_program_hurry(unsigned int id);
int host_gl_program_poll(unsigned int id, unsigned int *program, unsigned int *binary_size, unsigned int *microseconds);
unsigned int host_gl_program_binary(unsigned int id, void *buffer, unsigned int size, unsigned int *format);
void host_gl_program_release(unsigned int id);
unsigned int host_gl_worker_waiting(void);
void host_gl_program_warm_up(unsigned int program, int multiview);
/* ---------- OpenXR (port/android/host/host_xr.c; the structures are in
halo_xr_abi.h) */
@@ -140,10 +165,33 @@ it lets none be chosen), and the one it runs at (0: unknown); and asking
for the highest offered at or below wanted (0: the highest) */
int host_xr_refresh_rates(float *rates, int maximum, float *current);
void host_xr_set_refresh_rate(float wanted);
/* the app's GPU time of a recent frame (ms; 0: the runtime does not tell
it), for vr.graphics "auto" */
float host_xr_gpu_time(void);
/* ---------- Android */
/* the storage directories the port uses, copied into buffer */
void host_android_path(int which, char *buffer, unsigned int size);
/* the edges where Android keeps its gestures, as left, top, right, bottom
in pixels of the current orientation, into insets[4]; all 0 when unknown */
void host_gesture_insets(int *insets);
/* ---------- the on-screen touch controls (host_touch.c) */
/* the overlay's controller: the SDL axes (left x, y, right x, y, left
trigger, right trigger) and the SDL button bits, into state[7] */
void host_touch_read(int *state);
/* the overlay's view swipe, then its gyroscope turn, since the last read,
into delta[4] */
void host_touch_look_read(float *delta);
/* port 0's motors, for the phone's vibration */
void host_touch_rumble(unsigned int low, unsigned int high);
/* tells the overlay when to show: touch_input.c's _touch_scene_* */
void host_touch_scene(int scene);
/* the game control on each of the 16 controller buttons (touch_game.c),
for the overlay's labels */
void host_touch_bindings(const int *controls);
#endif
@@ -39,3 +39,8 @@ void memory_watch_forget(void *address, unsigned long size)
{
host_memory_watch_forget((unsigned int)address, size);
}
void memory_watch_begin_frame(void)
{
host_memory_watch_begin_frame();
}
+20
View File
@@ -216,6 +216,11 @@ bool SDL_RumbleGamepad(SDL_Gamepad *gamepad, Uint16 low, Uint16 high, Uint32 mil
return host_sdl_rumble_gamepad((unsigned int)gamepad, low, high, milliseconds) != 0;
}
void SDL_CloseGamepad(SDL_Gamepad *gamepad)
{
host_sdl_close_gamepad((unsigned int)gamepad);
}
/* ---------- audio */
SDL_AudioStream *SDL_OpenAudioDeviceStream(SDL_AudioDeviceID device, const SDL_AudioSpec *spec,
@@ -234,3 +239,18 @@ bool SDL_ResumeAudioStreamDevice(SDL_AudioStream *stream)
{
return host_sdl_resume_audio_stream_device((unsigned int)stream) != 0;
}
int SDL_GetAudioStreamData(SDL_AudioStream *stream, void *data, int length)
{
return host_sdl_get_audio_stream_data((unsigned int)stream, data, length);
}
int SDL_GetAudioStreamAvailable(SDL_AudioStream *stream)
{
return host_sdl_get_audio_stream_available((unsigned int)stream);
}
void SDL_DestroyAudioStream(SDL_AudioStream *stream)
{
host_sdl_destroy_audio_stream((unsigned int)stream);
}
+2
View File
@@ -25,6 +25,7 @@ extern void (*__guest_init_end[])(void);
void __guest_start(const struct halo_guest_boot *boot);
void __guest_thread_start(unsigned int thread);
unsigned int __guest_thread_attach(void);
void __guest_thread_detach(void);
void __guest_thread_initialize_main(void);
extern char **__environ;
@@ -41,6 +42,7 @@ const struct halo_guest_header __guest_header =
(uint32_t)__guest_start,
(uint32_t)__guest_thread_start,
(uint32_t)__guest_thread_attach,
(uint32_t)__guest_thread_detach,
(uint32_t)__guest_init_start,
(uint32_t)__guest_init_end,
};
+37
View File
@@ -0,0 +1,37 @@
/*
GUEST_STRING.C
String functions faster than musl's generic C ones, which the guest takes
in their place (tools/android_build.py MUSL_EXCLUDE). The renderer's state
caches compare their keys with memcmp on every draw (the pixel shaders'
keys, the vertex constants, the uniforms), and musl's compares a byte at a
time.
*/
#include <stddef.h>
#include <stdint.h>
#include <string.h>
/* eight bytes at a time while they match (AArch64 loads them unaligned),
then the bytes that differ, or the tail. no_builtin: clang would turn the
loops back into a call to memcmp */
__attribute__((no_builtin)) int memcmp(const void *left, const void *right, size_t size)
{
const unsigned char *l = left, *r = right;
for (; size >= 8; size -= 8, l += 8, r += 8)
{
uint64_t a, b;
__builtin_memcpy(&a, l, 8);
__builtin_memcpy(&b, r, 8);
if (a != b)
break;
}
for (; size; size--, l++, r++)
{
if (*l != *r)
return *l - *r;
}
return 0;
}
+12
View File
@@ -125,6 +125,18 @@ unsigned int __guest_thread_attach(void)
return (unsigned int)thread;
}
/* a host thread that ran guest code (__guest_thread_attach) is ending:
its struct pthread goes */
void __guest_thread_detach(void)
{
struct guest_thread *thread = (struct guest_thread *)host_get_tp();
if (!thread || thread == &main_thread)
return;
host_set_tp(0);
thread_free(thread);
}
int pthread_create(pthread_t *restrict result, const pthread_attr_t *restrict attributes,
void *(*start)(void *), void *restrict argument)
{
+25 -1
View File
@@ -12,6 +12,7 @@ port/android/include/halo_android_abi.h for the guest contract.
#include <stdarg.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include "halo_android_abi.h"
@@ -35,8 +36,21 @@ Xbox window and the image's range at start-up, and hands out pages for
everything else (the guest's malloc arenas, thread stacks, anonymous
mappings) from pools of address space it reserves below 4 GB on demand. */
/* reserves the fixed ranges; returns 0 on success */
/* reserves the fixed ranges (the Xbox window and the image's) as early as
the process allows: from JNI_OnLoad, before the Java side of the game's
process allocates much (host_main.c); a failure is reported again by
host_memory_initialize */
void host_memory_reserve_early(void);
/* takes the fixed ranges for the image; returns 0 on success */
int host_memory_initialize(uint32_t image_base, uint32_t image_size);
/* 1 if the fixed ranges could not be reserved (something else holds them) */
int host_memory_fixed_unavailable(void);
/* lists the mappings below 4 GB in logcat and, if file is not NULL, in it */
void host_memory_report_low_mappings(FILE *file);
/* Makes the write tracking compare page contents instead of catching page
faults (host_watch_hash.h), for when the app cannot receive its own
SIGSEGV (ARM translation). Call before the guest starts. */
void host_memory_watch_use_hashes(void);
/* page-granular allocations below 4 GB; NULL on failure */
void *host_low_map(size_t size, int protection);
void host_low_unmap(void *address, size_t size);
@@ -93,6 +107,16 @@ void *host_resolve_import(const char *name);
void *host_gl_resolve(const char *name);
int host_gl_quiet_trace_markers(void);
/* the shader programs' worker (host_gl_worker.c; the guest's imports) */
int host_gl_worker_start(int warm_up);
uint32_t host_gl_program_request(int urgent, const char *vertex_source, const char *fragment_source,
uint32_t binary_format, const void *binary, uint32_t binary_size, int keep_binary);
void host_gl_program_hurry(uint32_t id);
int host_gl_program_poll(uint32_t id, uint32_t *program, uint32_t *binary_size, uint32_t *microseconds);
uint32_t host_gl_program_binary(uint32_t id, void *buffer, uint32_t size, uint32_t *format);
void host_gl_program_release(uint32_t id);
uint32_t host_gl_worker_waiting(void);
void host_gl_program_warm_up(uint32_t program, int multiview);
/* ---------- VR (host_xr.c) */
+14 -13
View File
@@ -101,25 +101,26 @@ int host_gl_has_extension(const char *name)
return 0;
}
/* one 32-bit word of a buffer object (the visibility test counters of
d3d8_gl.c); ES has no glGetBufferSubData, and the mapping it offers
instead is a host pointer */
uint32_t host_gl_read_buffer_word(uint32_t buffer, uint32_t offset)
/* copies size bytes of a buffer object (the snapshots of the visibility
tests' counters, d3d8_gl.c); ES has no glGetBufferSubData, and the mapping
it offers instead is a host pointer. Binds GL_COPY_READ_BUFFER, which the
renderer uses only for its copies, and leaves it unbound. */
void host_gl_read_buffer(uint32_t buffer, uint32_t offset, uint32_t size, void *data)
{
uint32_t value = 0;
GLint previous = 0;
const void *mapping;
glGetIntegerv(GL_ATOMIC_COUNTER_BUFFER_BINDING, &previous);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, buffer);
mapping = glMapBufferRange(GL_ATOMIC_COUNTER_BUFFER, offset, sizeof(value), GL_MAP_READ_BIT);
glBindBuffer(GL_COPY_READ_BUFFER, buffer);
mapping = glMapBufferRange(GL_COPY_READ_BUFFER, offset, size, GL_MAP_READ_BIT);
if (mapping)
{
memcpy(&value, mapping, sizeof(value));
glUnmapBuffer(GL_ATOMIC_COUNTER_BUFFER);
memcpy(data, mapping, size);
glUnmapBuffer(GL_COPY_READ_BUFFER);
}
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, (GLuint)previous);
return value;
else
{
memset(data, 0, size);
}
glBindBuffer(GL_COPY_READ_BUFFER, 0);
}
/* The visibility tests' counters (d3d8_gl.c), copied at the end of each
+671
View File
@@ -0,0 +1,671 @@
/*
HOST_GL_WORKER.C
The shader programs' worker (port/linux/src/xgpu_program_cache.c): a thread
with a GL context of its own, sharing the game's objects, that makes the
programs the game asks for while the game goes on drawing. The drivers
compile a program the first time it is drawn with, 10 to 70 ms each on the
Quest's, and the frame waited for it: in VR a frame shown late, the picture
doubled as the head turns.
- host_gl_worker_start, on the game's thread with its context current:
makes a context that shares the game's (surfaceless, or on a 1x1
pbuffer) and the thread that keeps it current.
- host_gl_program_request: a program of these sources, from their binary
where one is given and the driver takes it, else compiled and linked
(its binary kept for the cache's file). The sources and the binary are
copied: the caller's may go at once. Urgent requests (a draw waits for
them) go before the others (a map's programs made ready as it loads).
- On the worker each program is linked, its samplers set to their units
(as d3d8_gl.c sets them), drawn with once into a small target of its own
(a degenerate triangle: the driver makes the variants it makes at a
first draw now, not in the game's frame; "warm up"), and a fence put
after it all.
- host_gl_program_poll, on the game's thread: done once the fence has
passed (GL's rule for an object made in one context and used in another:
the work finished before the other binds it); then the program is the
game's (a program name is the same in both contexts).
host_gl_program_warm_up draws once with a program on the calling thread's
context instead (debug.shader_warm_up "load": on the game's, as a map
loads), for comparing the two.
*/
#include "host.h"
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <GLES3/gl32.h>
#include <GLES2/gl2ext.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define JOB_BUCKETS 1024
/* the warm-up's target, each way */
#define WARM_UP_SIZE 16
enum
{
JOB_QUEUED,
JOB_WORKING,
JOB_DONE,
JOB_FAILED,
};
struct job
{
struct job *next_in_bucket;
struct job *next_in_queue;
uint32_t id;
int urgent;
char *sources[2];
void *binary;
uint32_t binary_size;
uint32_t binary_format;
int keep_binary;
int state;
/* the results */
GLuint program;
GLsync sync;
int from_binary;
void *kept_binary;
uint32_t kept_size;
uint32_t kept_format;
uint32_t microseconds;
};
/* a context's warm-up target: the eyes' kinds, one layer and two
(multiview), RGBA8 with a depth and stencil buffer */
struct warm_up_targets
{
int made;
/* each kind's framebuffer complete */
int complete[2];
GLuint framebuffers[2];
GLuint textures[4];
GLuint vertex_array;
GLuint counters;
};
static struct
{
int started;
int running;
int warm_up;
EGLDisplay display;
EGLContext context;
EGLSurface surface;
pthread_t thread;
pthread_mutex_t lock;
pthread_cond_t wake;
pthread_cond_t ready;
struct job *buckets[JOB_BUCKETS];
struct job *urgent_first, *urgent_last;
struct job *later_first, *later_last;
uint32_t next_id;
uint32_t waiting;
int failures_logged;
PFNGLFRAMEBUFFERTEXTUREMULTIVIEWOVRPROC framebuffer_texture_multiview;
struct warm_up_targets targets[2];
} worker;
static uint64_t now_us(void)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (uint64_t)now.tv_sec * 1000000u + (uint64_t)now.tv_nsec / 1000u;
}
/* ---------- the warm-up draw */
static int targets_make(struct warm_up_targets *targets)
{
GLint major = 0, minor = 0;
int kind;
if (targets->made)
return targets->made > 0;
targets->made = -1;
glGenTextures(4, targets->textures);
glGenFramebuffers(2, targets->framebuffers);
for (kind = 0; kind < 2; kind++)
{
GLenum target = kind ? GL_TEXTURE_2D_ARRAY : GL_TEXTURE_2D;
glBindTexture(target, targets->textures[kind * 2]);
if (kind)
glTexStorage3D(target, 1, GL_RGBA8, WARM_UP_SIZE, WARM_UP_SIZE, 2);
else
glTexStorage2D(target, 1, GL_RGBA8, WARM_UP_SIZE, WARM_UP_SIZE);
glBindTexture(target, targets->textures[kind * 2 + 1]);
if (kind)
glTexStorage3D(target, 1, GL_DEPTH24_STENCIL8, WARM_UP_SIZE, WARM_UP_SIZE, 2);
else
glTexStorage2D(target, 1, GL_DEPTH24_STENCIL8, WARM_UP_SIZE, WARM_UP_SIZE);
glBindTexture(target, 0);
glBindFramebuffer(GL_FRAMEBUFFER, targets->framebuffers[kind]);
if (kind)
{
if (!worker.framebuffer_texture_multiview)
continue;
worker.framebuffer_texture_multiview(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, targets->textures[2], 0, 0, 2);
worker.framebuffer_texture_multiview(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, targets->textures[3], 0,
0, 2);
}
else
{
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, targets->textures[0], 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, targets->textures[1], 0);
}
targets->complete[kind] = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
if (!targets->complete[kind])
host_logf(HOST_LOG_WARN, "gl worker: the %s warm-up target is incomplete; no warm-up there",
kind ? "multiview" : "plain");
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glGenVertexArrays(1, &targets->vertex_array);
/* (the visibility tests' programs count samples into binding 0) */
glGetIntegerv(GL_MAJOR_VERSION, &major);
glGetIntegerv(GL_MINOR_VERSION, &minor);
if (major > 3 || (major == 3 && minor >= 1))
{
glGenBuffers(1, &targets->counters);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, targets->counters);
glBufferData(GL_ATOMIC_COUNTER_BUFFER, 64, NULL, GL_DYNAMIC_DRAW);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
}
targets->made = 1;
return 1;
}
/* one degenerate triangle with the program into the target of its kind,
depth tested and blended as most of the game's draws are; the GL state it
changes is left changed (the worker's context has no other use, and the
game's sets it again: d3d8_host_state_changed) */
static void warm_up_draw(struct warm_up_targets *targets, GLuint program, int multiview)
{
if (!targets_make(targets) || !targets->complete[multiview ? 1 : 0])
return;
glBindFramebuffer(GL_FRAMEBUFFER, targets->framebuffers[multiview ? 1 : 0]);
glViewport(0, 0, WARM_UP_SIZE, WARM_UP_SIZE);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_STENCIL_TEST);
glDisable(GL_CULL_FACE);
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LEQUAL);
glDepthMask(GL_TRUE);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glBindVertexArray(targets->vertex_array);
if (targets->counters)
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, targets->counters);
glUseProgram(program);
glDrawArrays(GL_TRIANGLES, 0, 3);
glUseProgram(0);
glBindVertexArray(0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
void host_gl_program_warm_up(uint32_t program, int multiview)
{
if (!worker.running || !program)
return;
warm_up_draw(&worker.targets[0], program, multiview);
}
/* ---------- the worker's jobs */
static struct job *job_find(uint32_t id)
{
struct job *job;
for (job = worker.buckets[id % JOB_BUCKETS]; job; job = job->next_in_bucket)
{
if (job->id == id)
return job;
}
return NULL;
}
static void queue_append(struct job *job)
{
struct job **first = job->urgent ? &worker.urgent_first : &worker.later_first;
struct job **last = job->urgent ? &worker.urgent_last : &worker.later_last;
job->next_in_queue = NULL;
if (*last)
(*last)->next_in_queue = job;
else
*first = job;
*last = job;
}
static struct job *queue_take(void)
{
struct job *job = worker.urgent_first;
if (job)
{
worker.urgent_first = job->next_in_queue;
if (!worker.urgent_first)
worker.urgent_last = NULL;
return job;
}
job = worker.later_first;
if (job)
{
worker.later_first = job->next_in_queue;
if (!worker.later_first)
worker.later_last = NULL;
}
return job;
}
static GLuint shader_compile(GLenum type, const char *source)
{
GLuint shader = glCreateShader(type);
GLint status = 0;
glShaderSource(shader, 1, &source, NULL);
glCompileShader(shader);
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (!status)
{
char log[1024];
log[0] = 0;
glGetShaderInfoLog(shader, sizeof(log), NULL, log);
if (worker.failures_logged++ < 4)
host_logf(HOST_LOG_WARN, "gl worker: a %s shader does not compile: %s",
type == GL_VERTEX_SHADER ? "vertex" : "pixel", log);
glDeleteShader(shader);
return 0;
}
return shader;
}
/* the job's program (outside the lock: only the worker touches a job
being worked on) */
static void job_work(struct job *job)
{
uint64_t start = now_us();
GLuint program = 0;
GLint status = 0;
if (job->binary && job->binary_size)
{
program = glCreateProgram();
glProgramBinary(program, (GLenum)job->binary_format, job->binary, (GLsizei)job->binary_size);
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status)
job->from_binary = 1;
else
{
glDeleteProgram(program);
program = 0;
while (glGetError() != GL_NO_ERROR)
{
}
}
}
if (!program && job->sources[0] && job->sources[1])
{
GLuint vertex = shader_compile(GL_VERTEX_SHADER, job->sources[0]);
GLuint fragment = vertex ? shader_compile(GL_FRAGMENT_SHADER, job->sources[1]) : 0;
if (vertex && fragment)
{
program = glCreateProgram();
glProgramParameteri(program, GL_PROGRAM_BINARY_RETRIEVABLE_HINT, GL_TRUE);
glAttachShader(program, vertex);
glAttachShader(program, fragment);
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (!status)
{
char log[1024];
log[0] = 0;
glGetProgramInfoLog(program, sizeof(log), NULL, log);
if (worker.failures_logged++ < 4)
host_logf(HOST_LOG_WARN, "gl worker: a program does not link: %s", log);
glDeleteProgram(program);
program = 0;
}
}
/* (attached, they go with the program) */
if (vertex)
glDeleteShader(vertex);
if (fragment)
glDeleteShader(fragment);
if (program && job->keep_binary)
{
GLint length = 0;
GLsizei written = 0;
GLenum format = 0;
glGetProgramiv(program, GL_PROGRAM_BINARY_LENGTH, &length);
if (length > 0 && (job->kept_binary = malloc((size_t)length)) != NULL)
{
glGetProgramBinary(program, length, &written, &format, job->kept_binary);
if (written > 0 && written <= length)
{
job->kept_size = (uint32_t)written;
job->kept_format = (uint32_t)format;
}
else
{
free(job->kept_binary);
job->kept_binary = NULL;
}
}
}
}
if (program)
{
int unit;
/* the samplers' units, as d3d8_gl.c's program_get sets them (a
first draw with two kinds of sampler on one unit draws nothing) */
glUseProgram(program);
for (unit = 0; unit < 4; unit++)
{
char name[8];
snprintf(name, sizeof(name), "tex%d", unit);
glUniform1i(glGetUniformLocation(program, name), unit);
}
glUseProgram(0);
if (worker.warm_up)
warm_up_draw(&worker.targets[1], program,
job->sources[0] && strstr(job->sources[0], "GL_OVR_multiview") != NULL);
job->sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
}
glFlush();
job->program = program;
job->microseconds = (uint32_t)(now_us() - start);
}
static void *worker_main(void *unused)
{
(void)unused;
if (!eglMakeCurrent(worker.display, worker.surface, worker.surface, worker.context))
{
host_logf(HOST_LOG_ERROR, "gl worker: its context cannot be made current (0x%x)", eglGetError());
pthread_mutex_lock(&worker.lock);
worker.started = -1;
pthread_cond_broadcast(&worker.ready);
pthread_mutex_unlock(&worker.lock);
return NULL;
}
pthread_mutex_lock(&worker.lock);
worker.started = 1;
pthread_cond_broadcast(&worker.ready);
for (;;)
{
struct job *job;
while ((job = queue_take()) == NULL)
pthread_cond_wait(&worker.wake, &worker.lock);
job->state = JOB_WORKING;
pthread_mutex_unlock(&worker.lock);
job_work(job);
pthread_mutex_lock(&worker.lock);
free(job->binary);
job->binary = NULL;
free(job->sources[0]);
free(job->sources[1]);
job->sources[0] = job->sources[1] = NULL;
job->state = job->program ? JOB_DONE : JOB_FAILED;
worker.waiting--;
}
return NULL;
}
/* ---------- the game's */
int host_gl_worker_start(int warm_up)
{
EGLDisplay display = eglGetCurrentDisplay();
EGLContext shared = eglGetCurrentContext();
EGLint config_id = 0, count = 0, version = 3;
EGLint config_attributes[] = { EGL_CONFIG_ID, 0, EGL_NONE };
EGLint context_attributes[] = { EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE };
EGLConfig config;
const char *extensions;
int started;
if (worker.running)
return 1;
if (worker.started)
return 0;
worker.warm_up = warm_up;
if (display == EGL_NO_DISPLAY || shared == EGL_NO_CONTEXT)
{
host_logf(HOST_LOG_WARN, "gl worker: no current context to share");
worker.started = -1;
return 0;
}
eglQueryContext(display, shared, EGL_CONFIG_ID, &config_id);
eglQueryContext(display, shared, EGL_CONTEXT_CLIENT_VERSION, &version);
config_attributes[1] = config_id;
if (!eglChooseConfig(display, config_attributes, &config, 1, &count) || count != 1)
{
host_logf(HOST_LOG_WARN, "gl worker: the game's context's configuration is not found");
worker.started = -1;
return 0;
}
context_attributes[1] = version;
extensions = eglQueryString(display, EGL_EXTENSIONS);
worker.surface = EGL_NO_SURFACE;
if (!extensions || !strstr(extensions, "EGL_KHR_surfaceless_context"))
{
/* (a 1x1 pbuffer to be current on, of a configuration that has one) */
EGLint pbuffer_config[] = { EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
EGL_NONE };
EGLint pbuffer_size[] = { EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE };
if (!eglChooseConfig(display, pbuffer_config, &config, 1, &count) || count != 1 ||
(worker.surface = eglCreatePbufferSurface(display, config, pbuffer_size)) == EGL_NO_SURFACE)
{
host_logf(HOST_LOG_WARN, "gl worker: neither surfaceless contexts nor a pbuffer");
worker.started = -1;
return 0;
}
}
worker.context = eglCreateContext(display, config, shared, context_attributes);
if (worker.context == EGL_NO_CONTEXT)
{
host_logf(HOST_LOG_WARN, "gl worker: no context sharing the game's (0x%x)", eglGetError());
worker.started = -1;
return 0;
}
worker.display = display;
worker.next_id = 1;
worker.framebuffer_texture_multiview = (PFNGLFRAMEBUFFERTEXTUREMULTIVIEWOVRPROC)eglGetProcAddress(
"glFramebufferTextureMultiviewOVR");
pthread_mutex_init(&worker.lock, NULL);
pthread_cond_init(&worker.wake, NULL);
pthread_cond_init(&worker.ready, NULL);
if (pthread_create(&worker.thread, NULL, worker_main, NULL) != 0)
{
eglDestroyContext(display, worker.context);
worker.started = -1;
return 0;
}
pthread_mutex_lock(&worker.lock);
while (!worker.started)
pthread_cond_wait(&worker.ready, &worker.lock);
started = worker.started > 0;
pthread_mutex_unlock(&worker.lock);
worker.running = started;
/* (Mesa's trace markers, for the new context's too: host_gl.c) */
if (started)
host_gl_quiet_trace_markers();
host_logf(HOST_LOG_INFO, "gl worker: %s (ES %d, %s%s)", started ? "running" : "failed", (int)version,
worker.surface == EGL_NO_SURFACE ? "surfaceless" : "on a pbuffer", warm_up ? ", programs warmed up" : "");
return started;
}
uint32_t host_gl_program_request(int urgent, const char *vertex_source, const char *fragment_source,
uint32_t binary_format, const void *binary, uint32_t binary_size, int keep_binary)
{
struct job *job;
uint32_t id;
if (!worker.running)
return 0;
job = calloc(1, sizeof(*job));
if (!job)
return 0;
job->urgent = urgent != 0;
job->keep_binary = keep_binary != 0;
job->sources[0] = vertex_source ? strdup(vertex_source) : NULL;
job->sources[1] = fragment_source ? strdup(fragment_source) : NULL;
if (binary && binary_size && (job->binary = malloc(binary_size)) != NULL)
{
memcpy(job->binary, binary, binary_size);
job->binary_size = binary_size;
job->binary_format = binary_format;
}
pthread_mutex_lock(&worker.lock);
id = job->id = worker.next_id++;
if (!worker.next_id)
worker.next_id = 1;
job->state = JOB_QUEUED;
job->next_in_bucket = worker.buckets[id % JOB_BUCKETS];
worker.buckets[id % JOB_BUCKETS] = job;
queue_append(job);
worker.waiting++;
pthread_cond_signal(&worker.wake);
pthread_mutex_unlock(&worker.lock);
return id;
}
/* a queued job a draw now waits for: before the others */
void host_gl_program_hurry(uint32_t id)
{
struct job *job, **link;
if (!worker.running)
return;
pthread_mutex_lock(&worker.lock);
job = job_find(id);
if (job && job->state == JOB_QUEUED && !job->urgent)
{
for (link = &worker.later_first; *link; link = &(*link)->next_in_queue)
{
if (*link == job)
{
struct job *previous = NULL, *walk;
*link = job->next_in_queue;
/* (the last of the later queue found again) */
for (walk = worker.later_first; walk; walk = walk->next_in_queue)
previous = walk;
worker.later_last = previous;
job->urgent = 1;
queue_append(job);
break;
}
}
}
pthread_mutex_unlock(&worker.lock);
}
int host_gl_program_poll(uint32_t id, uint32_t *program, uint32_t *binary_size, uint32_t *microseconds)
{
struct job *job;
int result = 0;
if (!worker.running)
return -1;
pthread_mutex_lock(&worker.lock);
job = job_find(id);
if (!job || job->state == JOB_FAILED)
result = -1;
else if (job->state == JOB_DONE)
{
/* (the fence passed: everything the worker did with the program is
done, and this context may use it) */
if (job->sync)
{
GLenum waited = glClientWaitSync(job->sync, 0, 0);
if (waited == GL_ALREADY_SIGNALED || waited == GL_CONDITION_SATISFIED || waited == GL_WAIT_FAILED)
{
glDeleteSync(job->sync);
job->sync = NULL;
}
}
if (!job->sync)
{
*program = job->program;
*binary_size = job->kept_size;
*microseconds = job->microseconds;
result = 1;
}
}
pthread_mutex_unlock(&worker.lock);
return result;
}
uint32_t host_gl_program_binary(uint32_t id, void *buffer, uint32_t size, uint32_t *format)
{
struct job *job;
uint32_t copied = 0;
if (!worker.running)
return 0;
pthread_mutex_lock(&worker.lock);
job = job_find(id);
if (job && job->state == JOB_DONE && job->kept_binary && size >= job->kept_size)
{
memcpy(buffer, job->kept_binary, job->kept_size);
*format = job->kept_format;
copied = job->kept_size;
}
pthread_mutex_unlock(&worker.lock);
return copied;
}
/* forgets a job (its program stays: the game's); a job still queued or
being worked on is let go when it is done */
void host_gl_program_release(uint32_t id)
{
struct job *job, **link;
if (!worker.running)
return;
pthread_mutex_lock(&worker.lock);
for (link = &worker.buckets[id % JOB_BUCKETS]; (job = *link) != NULL; link = &job->next_in_bucket)
{
if (job->id != id)
continue;
if (job->state == JOB_QUEUED || job->state == JOB_WORKING)
break;
*link = job->next_in_bucket;
if (job->sync)
glDeleteSync(job->sync);
free(job->kept_binary);
free(job);
break;
}
pthread_mutex_unlock(&worker.lock);
}
uint32_t host_gl_worker_waiting(void)
{
uint32_t waiting;
if (!worker.running)
return 0;
pthread_mutex_lock(&worker.lock);
waiting = worker.waiting;
pthread_mutex_unlock(&worker.lock);
return waiting;
}
+148 -8
View File
@@ -25,12 +25,14 @@ that runs here.
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <android/log.h>
#include <jni.h>
#include <errno.h>
#include <ftw.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/system_properties.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <time.h>
@@ -63,7 +65,7 @@ void host_fatal(const char *format, ...)
vsnprintf(message, sizeof(message), format, arguments);
va_end(arguments);
__android_log_write(ANDROID_LOG_FATAL, "halo", message);
SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Halo", message, NULL);
SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Ringfall: Mjolnir VR", message, NULL);
_exit(1);
}
@@ -91,7 +93,7 @@ int host_errno(void)
static char data_root[512];
static char save_root[512];
/* where a device that empties the app's folders keeps the game's data */
#define SHARED_DATA_ROOT "/home/steamos/Documents/HaloCE"
#define SHARED_DATA_ROOT "/home/steamos/Documents/RingfallMjolnirVR"
void host_android_path(int which, char *buffer, uint32_t size)
{
@@ -137,6 +139,52 @@ struct environment
int count;
};
/* asks the activity for Android's system gesture insets via JNI
(HaloActivity.getSystemGestureInsetsPixels); the guest calls it at every
finger down because the insets change when the phone rotates; the method is
found on the activity's own class because the game's native thread has the
system's class loader, which does not know the app's classes; a change is
logged so the log shows the value the game works with; all 0 on any failure
(the touch controls then use the whole screen) */
void host_gesture_insets(int *insets)
{
static int logged[4] = { -1, -1, -1, -1 };
JNIEnv *env = (JNIEnv *)SDL_GetAndroidJNIEnv();
jobject activity = (jobject)SDL_GetAndroidActivity();
jint values[4] = { 0, 0, 0, 0 };
int index;
if (env && activity)
{
jclass activity_class = (*env)->GetObjectClass(env, activity);
jmethodID method = activity_class ? (*env)->GetMethodID(env, activity_class, "getSystemGestureInsetsPixels", "()[I") : NULL;
jintArray array = method ? (jintArray)(*env)->CallObjectMethod(env, activity, method) : NULL;
if (array && !(*env)->ExceptionCheck(env) && (*env)->GetArrayLength(env, array) == 4)
(*env)->GetIntArrayRegion(env, array, 0, 4, values);
/* a Java exception left pending would break the next JNI call of the
thread, and a failed read must leave zeros */
if ((*env)->ExceptionCheck(env))
{
(*env)->ExceptionClear(env);
for (index = 0; index < 4; index++)
values[index] = 0;
}
if (array)
(*env)->DeleteLocalRef(env, array);
if (activity_class)
(*env)->DeleteLocalRef(env, activity_class);
(*env)->DeleteLocalRef(env, activity);
}
for (index = 0; index < 4; index++)
insets[index] = (int)values[index];
if (memcmp(logged, insets, sizeof(logged)))
{
memcpy(logged, insets, sizeof(logged));
host_logf(HOST_LOG_INFO, "system gesture insets %d,%d,%d,%d", insets[0], insets[1], insets[2], insets[3]);
}
}
static void environment_set(struct environment *environment, const char *name, const char *value)
{
size_t length = strlen(name);
@@ -205,6 +253,37 @@ static double config_number(const char *path, const char *key, double fallback)
return value;
}
/* a boolean setting of config.toml (a dotted name), or otherwise when the
file, the setting or a boolean is missing */
static int config_boolean_or(const char *path, const char *name, int otherwise)
{
toml_result_t result = toml_parse_file_ex(path);
int value = otherwise;
if (!result.ok)
return otherwise;
{
toml_datum_t datum = toml_seek(result.toptab, name);
if (datum.type == TOML_BOOLEAN)
value = datum.u.boolean ? 1 : 0;
}
toml_free(result);
return value;
}
/* Whether the app runs through an ARM translator. The x86 emulator runs the
app's ARM code through one, which ro.dalvik.vm.native.bridge names; it
cannot deliver the page faults of the write tracking to the app
(host_memory.c). */
static int native_bridge_active(void)
{
char value[PROP_VALUE_MAX] = "";
__system_property_get("ro.dalvik.vm.native.bridge", value);
return value[0] && strcmp(value, "0") != 0;
}
/* POSIX TZ for the current local offset (the guest's musl has no zone
database) */
static void time_zone(char *buffer, size_t size)
@@ -257,6 +336,11 @@ static uint32_t make_boot(const struct environment *environment)
#define MAIN_STACK_SIZE (16 * 1024 * 1024)
/* the thread that runs the game: passes the display and its density, the
time zone and the data and save folders to the guest through its
environment, chooses the write tracking (page protection, or page hashes when
translated or when debug.memory_watch is false), and runs the guest's main;
never returns normally */
static void *game_main(void *unused)
{
struct environment environment = { { 0 }, 0 };
@@ -273,9 +357,9 @@ static void *game_main(void *unused)
host_fatal("Android storage is unavailable: %s", SDL_GetError());
/* the Steam Frame: Lepton empties the app's own folders whenever it
starts again; the game's data lives in the SteamOS user's Documents
instead, when it is there (port/android/STEAM_FRAME.md; the same choice
as LauncherActivity's) */
if (directory_has_maps(SHARED_DATA_ROOT) && access(SHARED_DATA_ROOT, W_OK) == 0)
instead, whenever they can be written (port/android/STEAM_FRAME.md; the
same choice as LauncherActivity's, which makes the folder) */
if (access(SHARED_DATA_ROOT, W_OK) == 0)
external = SHARED_DATA_ROOT;
snprintf(data_root, sizeof(data_root), "%s", external);
snprintf(save_root, sizeof(save_root), "%s/save", external);
@@ -292,7 +376,7 @@ static void *game_main(void *unused)
}
if (!directory_has_maps(data_root))
{
host_fatal("The Halo game data was not found.\n\nCopy the PAL game data (build 01.01.14.2342), "
host_fatal("The game data was not found.\n\nCopy the PAL game data (build 01.01.14.2342), "
"the folder that contains maps, into\n%s\nor import it from the launcher screen.", data_root);
}
@@ -319,7 +403,21 @@ static void *game_main(void *unused)
if (!image)
host_fatal("cannot read the game image from the APK: %s", SDL_GetError());
if (host_load_image(image, image_size) != 0)
host_fatal("cannot load the game image; see logcat (tag \"halo\") for details");
{
FILE *report;
if (!host_memory_fixed_unavailable())
host_fatal("cannot load the game image; see logcat (tag \"halo\") for details");
snprintf(path, sizeof(path), "%s/memory_map.txt", data_root);
report = fopen(path, "w");
host_memory_report_low_mappings(report);
if (report)
fclose(report);
host_fatal("The game cannot start: the memory it needs at 0x80000000 is taken by Android's "
"Java runtime on this device.\n\nRestarting the device may help. Please report it with "
"memory_map.txt from\n%s\n(or adb logcat -s halo).", data_root);
}
SDL_free(image);
/* after the image, whose loading reserves the guest's fixed address
@@ -346,6 +444,9 @@ static void *game_main(void *unused)
if (deferred >= 0.0)
setenv("HALO_XR_DEFERRED", deferred != 0.0 ? "1" : "0", 1);
}
/* (vr.log_performance: the compositor's counters too, host_xr.c) */
if (config_boolean_or(path, "vr.log_performance", 0))
setenv("HALO_XR_LOG_PERFORMANCE", "1", 1);
{
int eye_width, eye_height;
@@ -370,6 +471,7 @@ static void *game_main(void *unused)
(landscape) unless display.screen_width says otherwise (d3d8_gl.c) */
const SDL_DisplayMode *mode;
char width[16];
char text_density[16];
SDL_InitSubSystem(SDL_INIT_VIDEO);
mode = SDL_GetDesktopDisplayMode(SDL_GetPrimaryDisplay());
@@ -382,6 +484,18 @@ static void *game_main(void *unused)
environment_set(&environment, "HALO_DISPLAY_WIDTH", width);
host_logf(HOST_LOG_INFO, "display %dx%d: rendering %sx480", mode->w, mode->h, width);
}
{
/* dp for the touch controls (port/linux/src/touch_input.c): SDL
gives Android's densityDpi / 160 */
float density = SDL_GetDisplayContentScale(SDL_GetPrimaryDisplay());
if (density > 0.0f)
{
snprintf(text_density, sizeof(text_density), "%g", density);
environment_set(&environment, "HALO_DISPLAY_DENSITY", text_density);
host_logf(HOST_LOG_INFO, "display density %s", text_density);
}
}
}
}
@@ -391,16 +505,42 @@ static void *game_main(void *unused)
if (config_sample_seconds(path, seconds, sizeof(seconds)))
host_debug_start_sampler(seconds);
}
if (native_bridge_active())
{
host_memory_watch_use_hashes();
host_logf(HOST_LOG_INFO, "write tracking: page hashes (ARM translation)");
}
else if (!config_boolean_or(path, "debug.memory_watch", 1))
{
host_memory_watch_use_hashes();
host_logf(HOST_LOG_INFO, "write tracking: page hashes (debug.memory_watch = false)");
}
else
{
host_logf(HOST_LOG_INFO, "write tracking: page protection");
}
boot = make_boot(&environment);
host_logf(HOST_LOG_INFO, "data %s, saves %s", data_root, save_root);
host_run_guest_main(boot);
}
/* Called when HaloApplication loads this library at the start of the
game's own process (":game"), before its Java side has allocated anything
large: ART's large object space, which on some devices covers the Xbox
window, is then idle there and can be reclaimed (host_memory.c). */
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{
(void)vm;
(void)reserved;
host_memory_reserve_early();
return JNI_VERSION_1_6;
}
int main(int argc, char *argv[])
{
(void)argc;
(void)argv;
host_logf(HOST_LOG_INFO, "Halo for Android starting");
host_logf(HOST_LOG_INFO, "Ringfall: Mjolnir VR starting");
host_install_signal_handlers();
if (host_native_thread_create(game_main, NULL, MAIN_STACK_SIZE) != 0)
host_fatal("cannot start the game thread");
+464 -54
View File
@@ -17,18 +17,25 @@ This file also implements guest memory write tracking (the interface of
port/linux/src/memory_watch.c): the renderer write-protects the pages behind
the textures it caches, and the SIGSEGV handler here records the first
write to each. Other faults are reported (with guest-relative addresses) and
passed on to the previous handler.
passed on to the previous handler. Under ARM translation (the x86 emulator)
page faults cannot be caught, and the tracking compares page contents
instead (host_watch_hash.c).
*/
#include "host.h"
#include "host_watch_hash.h"
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <sys/system_properties.h>
#include <ucontext.h>
#include <unistd.h>
@@ -101,58 +108,264 @@ static uint64_t find_gap(uint64_t size, uint64_t minimum)
return result;
}
/* ART reserves its spaces (notably the free-list large object space) at
addresses chosen at zygote start; on some devices (for example the Retroid
Pocket Flip2, kernel 4.19) that reservation covers the fixed Xbox memory
window. That space commits pages lazily from its bottom, and the Java side
of this app hardly allocates large objects, so the slice over the window is
normally idle address space: unmap exactly the intersection (never more)
and let the caller retry. Only that space, and only a slice with no page in
use (present or swapped out, from /proc/self/pagemap): ART's other spaces
(its heap, bitmaps and card tables) are live, and unmapping them would
corrupt it where failing to start is at least clear. */
/* ART reserves its spaces at addresses chosen at zygote start, and on some
devices one of them covers the fixed Xbox memory window. ART's heap
(dalvik.vm.heapsize) decides how much of the low 4 GB its spaces take: on
handhelds with a large heap (the Retroid Pocket Flip2, the AYN Thor) the
free-list large object space usually lands right above the boot image, over
0x80000000.
That space commits pages lazily from its bottom, and the game's process
(HaloActivity runs in a process of its own, ":game", and reserves the
window from JNI_OnLoad before its Java side has done anything:
host_memory_reserve_early) hardly allocates large objects, so the slice
over the window is normally idle address space: unmap exactly the
intersection (never more) and let the caller retry. Only that space, and
only a slice with no page in use (present or swapped out): ART's other
spaces (its heap, bitmaps and card tables) are live, and unmapping them
would corrupt it where failing to start is at least clear. */
#define ART_LARGE_OBJECT_SPACE "[anon:dalvik-free list large object space]"
/* whether no page from..to (page aligned) is present or swapped out; 0 if
it cannot tell */
static int range_unused(uint64_t from, uint64_t to)
/* what range_usage found */
enum
{
FILE *pagemap = fopen("/proc/self/pagemap", "rb");
RANGE_UNUSED,
RANGE_IN_USE,
RANGE_UNKNOWN
};
/* why the fixed ranges could not be had: each reason also goes to the
log, and the report (host_memory_report_low_mappings) starts with them, so
memory_map.txt alone tells which case it was */
static char findings[2048];
static void finding(int priority, const char *format, ...)
{
char line[512];
size_t used = strlen(findings);
va_list arguments;
va_start(arguments, format);
vsnprintf(line, sizeof(line), format, arguments);
va_end(arguments);
host_logf(priority, "%s", line);
if (used + strlen(line) + 2 < sizeof(findings))
snprintf(findings + used, sizeof(findings) - used, "%s\n", line);
}
struct range_usage
{
uint64_t pages_in_use;
uint64_t lowest_in_use, highest_in_use;
const char *method;
};
static void note_in_use(struct range_usage *usage, uint64_t address)
{
if (!usage->pages_in_use++)
usage->lowest_in_use = address;
usage->highest_in_use = address;
}
/* debug.halo.art_overlap's -nopagemap (simulate_art_overlap) */
static int simulated_no_pagemap;
/* from /proc/self/pagemap: bit 63 present, bit 62 swapped out */
static int range_usage_pagemap(uint64_t from, uint64_t to, struct range_usage *usage)
{
int descriptor = simulated_no_pagemap ? -1 : open("/proc/self/pagemap", O_RDONLY | O_CLOEXEC);
uint64_t entries[512];
uint64_t page = from / PAGE;
int unused = 1;
if (!pagemap)
return 0;
if (fseeko(pagemap, (off_t)(page * sizeof(uint64_t)), SEEK_SET) != 0)
unused = 0;
while (unused && page < to / PAGE)
if (simulated_no_pagemap)
errno = EACCES;
if (descriptor < 0)
{
finding(HOST_LOG_WARN, "cannot read /proc/self/pagemap (%s)", strerror(errno));
return RANGE_UNKNOWN;
}
usage->method = "pagemap";
while (page < to / PAGE)
{
size_t wanted = (size_t)(to / PAGE - page);
size_t count;
size_t index;
ssize_t bytes;
size_t count, index;
if (wanted > sizeof(entries) / sizeof(entries[0]))
wanted = sizeof(entries) / sizeof(entries[0]);
count = fread(entries, sizeof(entries[0]), wanted, pagemap);
if (count != wanted)
bytes = pread(descriptor, entries, wanted * sizeof(entries[0]), (off_t)(page * sizeof(entries[0])));
if (bytes <= 0 || bytes % sizeof(entries[0]))
{
unused = 0;
break;
finding(HOST_LOG_WARN, "cannot read /proc/self/pagemap at %08llx (%s)",
(unsigned long long)(page * PAGE), bytes < 0 ? strerror(errno) : "short read");
close(descriptor);
return RANGE_UNKNOWN;
}
/* (bit 63 present, bit 62 swapped) */
count = (size_t)bytes / sizeof(entries[0]);
for (index = 0; index < count; index++)
{
if (entries[index] & (3ULL << 62))
{
unused = 0;
break;
}
note_in_use(usage, (page + index) * PAGE);
}
page += count;
}
fclose(pagemap);
return unused;
close(descriptor);
return usage->pages_in_use ? RANGE_IN_USE : RANGE_UNUSED;
}
/* the Swap: line of the /proc/self/smaps entry starting at start, in kB; -1
if it cannot be read (or no entry starts there); with end, the entry must
also end there. dont_dump, if not NULL, tells whether its VmFlags have dd
(MADV_DONTDUMP). */
static long mapping_swap_kb_ending(uint64_t start, uint64_t end, int *dont_dump)
{
FILE *smaps = fopen("/proc/self/smaps", "r");
char line[512];
int inside = 0;
long result = -1;
if (dont_dump)
*dont_dump = 0;
if (!smaps)
return -1;
while (fgets(line, sizeof(line), smaps))
{
unsigned long long lo, hi;
long kb;
if (sscanf(line, "%llx-%llx ", &lo, &hi) == 2 && strchr(line, '-') < strchr(line, ' '))
{
if (inside)
break;
inside = lo == start && (!end || hi == end);
continue;
}
if (!inside)
continue;
if (sscanf(line, "Swap: %ld kB", &kb) == 1)
result = kb;
else if (dont_dump && !strncmp(line, "VmFlags:", 8))
*dont_dump = strstr(line, " dd") != NULL;
}
fclose(smaps);
return result;
}
static long mapping_swap_kb(uint64_t start)
{
return mapping_swap_kb_ending(start, 0, NULL);
}
/* whether any of from..to (in the mapping starting at mapping_start) is
swapped out, when that mapping has swap somewhere: smaps counts swap per
mapping, so the range is made a mapping of its own for a moment
(MADV_DONTDUMP splits it off and changes nothing about its pages: neither
ART nor the game ever dumps core), read, and merged back (MADV_DODUMP). Not
for a mapping already marked so (the restore would then change it). -1 if
that cannot be done */
static long range_swap_kb(uint64_t mapping_start, uint64_t from, uint64_t to)
{
int dont_dump;
long kb;
if (mapping_swap_kb_ending(mapping_start, 0, &dont_dump) < 0)
{
finding(HOST_LOG_WARN, "cannot read the smaps entry of ART's large object space at %08llx",
(unsigned long long)mapping_start);
return -1;
}
if (dont_dump)
{
finding(HOST_LOG_WARN, "ART's large object space at %08llx is marked not to be dumped; "
"the swap of %08llx-%08llx cannot be read on its own", (unsigned long long)mapping_start,
(unsigned long long)from, (unsigned long long)to);
return -1;
}
if (madvise((void *)from, (size_t)(to - from), MADV_DONTDUMP) != 0)
{
finding(HOST_LOG_WARN, "cannot split %08llx-%08llx off ART's large object space (%s)",
(unsigned long long)from, (unsigned long long)to, strerror(errno));
return -1;
}
kb = mapping_swap_kb_ending(from, to, NULL);
madvise((void *)from, (size_t)(to - from), MADV_DODUMP);
if (kb < 0)
finding(HOST_LOG_WARN, "no smaps entry for %08llx-%08llx once split off", (unsigned long long)from,
(unsigned long long)to);
return kb;
}
/* without pagemap (some kernels or policies refuse it): mincore() tells
which pages are resident, and smaps whether any of the range is swapped
out, which mincore cannot see */
static int range_usage_mincore(uint64_t mapping_start, uint64_t from, uint64_t to, struct range_usage *usage)
{
unsigned char residency[4096];
uint64_t address = from;
long swapped = mapping_swap_kb(mapping_start);
usage->method = "mincore";
if (swapped < 0)
{
finding(HOST_LOG_WARN, "cannot read the smaps entry of ART's large object space at %08llx",
(unsigned long long)mapping_start);
return RANGE_UNKNOWN;
}
if (swapped > 0)
{
/* (swap somewhere in the space: only the range's own matters) */
long range_swapped = range_swap_kb(mapping_start, from, to);
if (range_swapped < 0)
return RANGE_UNKNOWN;
if (range_swapped > 0)
{
/* (objects there, swapped out: which pages smaps does not say) */
usage->method = "the range's swap in smaps";
usage->pages_in_use = (uint64_t)range_swapped * 1024 / PAGE;
usage->lowest_in_use = from;
usage->highest_in_use = to - PAGE;
return RANGE_IN_USE;
}
finding(HOST_LOG_INFO, "ART's large object space at %08llx: %ld kB of it swapped out, none of %08llx-%08llx",
(unsigned long long)mapping_start, swapped, (unsigned long long)from, (unsigned long long)to);
}
while (address < to)
{
uint64_t length = to - address;
size_t index;
if (length > sizeof(residency) * PAGE)
length = sizeof(residency) * PAGE;
if (mincore((void *)address, length, residency) != 0)
{
finding(HOST_LOG_WARN, "mincore at %08llx failed (%s)", (unsigned long long)address, strerror(errno));
return RANGE_UNKNOWN;
}
for (index = 0; index < length / PAGE; index++)
{
if (residency[index] & 1)
note_in_use(usage, address + index * PAGE);
}
address += length;
}
return usage->pages_in_use ? RANGE_IN_USE : RANGE_UNUSED;
}
/* whether any page from..to (page aligned) of the mapping starting at
mapping_start holds data */
static int range_usage(uint64_t mapping_start, uint64_t from, uint64_t to, struct range_usage *usage)
{
int result;
memset(usage, 0, sizeof(*usage));
result = range_usage_pagemap(from, to, usage);
if (result == RANGE_UNKNOWN)
{
memset(usage, 0, sizeof(*usage));
result = range_usage_mincore(mapping_start, from, to, usage);
}
return result;
}
static int reclaim_art_overlap(uint64_t address, uint64_t size)
@@ -167,6 +380,7 @@ static int reclaim_art_overlap(uint64_t address, uint64_t size)
{
unsigned long long lo, hi;
uint64_t from, to;
struct range_usage usage;
char *name;
int name_offset = 0;
@@ -180,21 +394,31 @@ static int reclaim_art_overlap(uint64_t address, uint64_t size)
to = hi < address + size ? hi : address + size;
if (strcmp(name, ART_LARGE_OBJECT_SPACE))
{
host_logf(HOST_LOG_ERROR, "a fixed guest range is overlapped by %08llx-%08llx (%s), which is not ART's large object space; left alone",
finding(HOST_LOG_ERROR, "a fixed guest range is overlapped by %08llx-%08llx (%s), which is not ART's large object space; left alone",
lo, hi, name[0] ? name : "unnamed");
continue;
}
if (!range_unused(from, to))
switch (range_usage(lo, from, to, &usage))
{
host_logf(HOST_LOG_ERROR, "ART's large object space over %08llx-%08llx is in use (or its pages cannot be read); left alone",
(unsigned long long)from, (unsigned long long)to);
case RANGE_IN_USE:
finding(HOST_LOG_ERROR,
"ART's large object space %08llx-%08llx holds objects over %08llx-%08llx "
"(%llu pages in use, %08llx-%08llx, by %s); left alone",
lo, hi, (unsigned long long)from, (unsigned long long)to,
(unsigned long long)usage.pages_in_use, (unsigned long long)usage.lowest_in_use,
(unsigned long long)usage.highest_in_use + PAGE, usage.method);
continue;
case RANGE_UNKNOWN:
finding(HOST_LOG_ERROR,
"ART's large object space %08llx-%08llx covers %08llx-%08llx, and whether that part is in use "
"cannot be told; left alone", lo, hi, (unsigned long long)from, (unsigned long long)to);
continue;
}
if (munmap((void *)from, to - from) == 0)
{
host_logf(HOST_LOG_INFO,
"reclaimed idle ART range %08llx-%08llx (%s)",
(unsigned long long)from, (unsigned long long)to, name);
"reclaimed idle ART range %08llx-%08llx of %08llx-%08llx (%s, checked by %s)",
(unsigned long long)from, (unsigned long long)to, lo, hi, name, usage.method);
reclaimed = 1;
}
}
@@ -214,8 +438,10 @@ static int reserve(uint64_t address, uint64_t size, int reclaim_art)
return 0;
if (result != MAP_FAILED)
{
/* (a kernel older than 4.17 takes MAP_FIXED_NOREPLACE as a hint,
and maps elsewhere when something is in the way) */
munmap(result, size);
return -1;
errno = EEXIST;
}
if (reclaim_art && errno == EEXIST)
{
@@ -233,11 +459,47 @@ static int reserve(uint64_t address, uint64_t size, int reclaim_art)
if (result == (void *)address)
return 0;
if (result != MAP_FAILED)
{
munmap(result, size);
errno = EEXIST;
}
}
return -1;
}
/* the mappings below 4 GB, for a report of why the fixed ranges could not
be had (logcat, and memory_map.txt in the data folder: host_main.c) */
void host_memory_report_low_mappings(FILE *file)
{
FILE *maps = fopen("/proc/self/maps", "r");
char line[512];
char model[PROP_VALUE_MAX] = "", heap[PROP_VALUE_MAX] = "", release[PROP_VALUE_MAX] = "";
__system_property_get("ro.product.model", model);
__system_property_get("dalvik.vm.heapsize", heap);
__system_property_get("ro.build.version.release", release);
if (file)
{
fprintf(file, "device: %s, Android %s, Java heap %s\n", model[0] ? model : "unknown",
release[0] ? release : "unknown", heap[0] ? heap : "unknown");
fprintf(file, "why:\n%s\nmappings below 4 GB:\n", findings[0] ? findings : "(no reason recorded)\n");
}
if (!maps)
return;
while (fgets(line, sizeof(line), maps))
{
unsigned long long lo, hi;
if (sscanf(line, "%llx-%llx", &lo, &hi) != 2 || lo >= LOW_LIMIT)
continue;
line[strcspn(line, "\n")] = '\0';
host_logf(HOST_LOG_INFO, "low mapping: %s", line);
if (file)
fprintf(file, "%s\n", line);
}
fclose(maps);
}
static struct pool *pool_new(void)
{
/* above the image first: ART allocates its own low-4 GB memory from
@@ -274,24 +536,122 @@ static struct pool *pool_new(void)
return NULL;
}
/* The fixed ranges: the Xbox window and, right above it, room for the guest
image (HALO_GUEST_IMAGE_RESERVE), reserved as early as the process allows
(host_memory_reserve_early, from JNI_OnLoad) or else when the image loads. */
static int fixed_reserved;
static int fixed_error;
/* when: the attempt, as the report names it (the reasons below it are
those of that attempt) */
static int reserve_fixed(const char *when)
{
size_t used = strlen(findings);
if (fixed_reserved)
return 0;
snprintf(findings + used, sizeof(findings) - used, "%s:\n", when);
if (reserve(HALO_GUEST_WINDOW_BASE, HALO_GUEST_WINDOW_SIZE, 1) != 0)
{
fixed_error = errno;
finding(HOST_LOG_ERROR, "cannot reserve the Xbox memory window at %08llx (%s)",
(unsigned long long)HALO_GUEST_WINDOW_BASE, strerror(errno));
return -1;
}
if (reserve(HALO_GUEST_IMAGE_BASE, HALO_GUEST_IMAGE_RESERVE, 1) != 0)
{
fixed_error = errno;
finding(HOST_LOG_ERROR, "cannot reserve the guest image range at %08llx (%s)",
(unsigned long long)HALO_GUEST_IMAGE_BASE, strerror(errno));
munmap((void *)(uintptr_t)HALO_GUEST_WINDOW_BASE, HALO_GUEST_WINDOW_SIZE);
return -1;
}
fixed_reserved = 1;
return 0;
}
int host_memory_fixed_unavailable(void)
{
return !fixed_reserved && fixed_error != 0;
}
/* For testing the reclaim on any device: with the system property
debug.halo.art_overlap set (adb shell setprop debug.halo.art_overlap idle,
or busy), a mapping named as ART's large object space is put over the fixed
ranges first, idle, or with a page in use inside the window, the way ART's
is on the devices that need the reclaim. Players never set it.
Either may be followed by -nopagemap, for devices that refuse
/proc/self/pagemap (the check then falls back to mincore and smaps), and
by -swapped, which pushes the stand-in's objects (its bottom pages, and the
window's page if busy) out to swap, as they can be on those devices. */
#ifndef MADV_PAGEOUT
#define MADV_PAGEOUT 21
#endif
#define PR_SET_VMA_ 0x53564d41
#define PR_SET_VMA_ANON_NAME_ 0
#define SIMULATED_SPACE_BASE 0x7c000000ULL
#define SIMULATED_SPACE_SIZE 0x14000000ULL
static void simulate_art_overlap(void)
{
char value[PROP_VALUE_MAX] = "";
char *window_page = (char *)(uintptr_t)HALO_GUEST_WINDOW_BASE + 0x100000;
int busy;
void *space;
if (__system_property_get("debug.halo.art_overlap", value) <= 0 || (strncmp(value, "idle", 4) && strncmp(value, "busy", 4)))
return;
busy = !strncmp(value, "busy", 4);
space = mmap((void *)SIMULATED_SPACE_BASE, SIMULATED_SPACE_SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED_NOREPLACE, -1, 0);
if (space != (void *)SIMULATED_SPACE_BASE)
{
host_logf(HOST_LOG_WARN, "debug.halo.art_overlap: %08llx-%08llx is not free",
SIMULATED_SPACE_BASE, SIMULATED_SPACE_BASE + SIMULATED_SPACE_SIZE);
if (space != MAP_FAILED)
munmap(space, SIMULATED_SPACE_SIZE);
return;
}
prctl(PR_SET_VMA_, PR_SET_VMA_ANON_NAME_, space, SIMULATED_SPACE_SIZE, "dalvik-free list large object space");
/* objects at its bottom, as ART allocates them */
memset(space, 0x5a, 4 * PAGE);
if (busy)
memset(window_page, 0x5a, PAGE);
if (strstr(value, "-swapped"))
{
if (madvise(space, 4 * PAGE, MADV_PAGEOUT) != 0 || (busy && madvise(window_page, PAGE, MADV_PAGEOUT) != 0))
host_logf(HOST_LOG_WARN, "debug.halo.art_overlap: cannot page out the stand-in (%s)", strerror(errno));
}
simulated_no_pagemap = strstr(value, "-nopagemap") != NULL;
host_logf(HOST_LOG_WARN, "debug.halo.art_overlap=%s: a stand-in for ART's large object space at %08llx-%08llx "
"(%ld kB of it swapped out)", value, SIMULATED_SPACE_BASE, SIMULATED_SPACE_BASE + SIMULATED_SPACE_SIZE,
mapping_swap_kb(SIMULATED_SPACE_BASE));
}
void host_memory_reserve_early(void)
{
simulate_art_overlap();
if (reserve_fixed("at start-up (JNI_OnLoad)") == 0)
host_logf(HOST_LOG_INFO, "reserved the Xbox memory window and the image range at start-up");
}
int host_memory_initialize(uint32_t base, uint32_t size)
{
if (base != HALO_GUEST_IMAGE_BASE || size > HALO_GUEST_IMAGE_RESERVE)
{
host_logf(HOST_LOG_ERROR, "the guest image (%08x, %u bytes) does not fit its range", base, size);
return -1;
}
if (reserve_fixed("when the image loaded") != 0)
{
errno = fixed_error;
return -1;
}
window_base = HALO_GUEST_WINDOW_BASE;
window_end = window_base + HALO_GUEST_WINDOW_SIZE;
if (reserve(window_base, HALO_GUEST_WINDOW_SIZE, 1) != 0)
{
host_logf(HOST_LOG_ERROR, "cannot reserve the Xbox memory window at %08llx (%s)",
(unsigned long long)window_base, strerror(errno));
return -1;
}
image_base = base;
image_end = base + round_up(size);
if (reserve(image_base, image_end - image_base, 1) != 0)
{
host_logf(HOST_LOG_ERROR, "cannot reserve the guest image range at %08llx (%s)",
(unsigned long long)image_base, strerror(errno));
return -1;
}
return 0;
}
@@ -478,6 +838,21 @@ static uint8_t page_protected[WATCH_PAGE_COUNT];
static uint32_t page_generation[WATCH_PAGE_COUNT];
static volatile uint32_t current_generation = 1;
static int watch_active;
static int watch_hashing;
static uint8_t page_hash_watched[WATCH_PAGE_COUNT];
static uint64_t page_hash[WATCH_PAGE_COUNT];
static uint32_t page_hashed_frame[WATCH_PAGE_COUNT];
static struct watch_hash page_hashes =
{
.base = (const uint8_t *)(uintptr_t)HALO_GUEST_WINDOW_BASE,
.page_count = WATCH_PAGE_COUNT,
.watched = page_hash_watched,
.hash = page_hash,
.hashed_frame = page_hashed_frame,
.generation = page_generation,
.current_generation = &current_generation,
.frame = 1,
};
static int in_window(uint64_t address)
{
@@ -601,6 +976,14 @@ void host_memory_watch_initialize(void)
watch_active = 1;
}
void host_memory_watch_use_hashes(void)
{
watch_hashing = 1;
}
/* memory_watch_protect: write-protects the pages of a range, or keeps their
hash under host_memory_watch_use_hashes; a size outside the guest window
is ignored */
void host_memory_watch_protect(uint32_t address, uint32_t size)
{
uint64_t first, last, page;
@@ -611,6 +994,11 @@ void host_memory_watch_protect(uint32_t address, uint32_t size)
last = watch_page((uint64_t)address + size - 1);
if (last >= WATCH_PAGE_COUNT)
last = WATCH_PAGE_COUNT - 1;
if (watch_hashing)
{
watch_hash_protect(&page_hashes, (uint32_t)first, (uint32_t)last);
return;
}
for (page = first; page <= last; page++)
{
if (!page_protected[page])
@@ -626,6 +1014,8 @@ uint32_t host_memory_watch_serial(void)
return current_generation;
}
/* memory_watch_generation: the newest generation of the pages of a range.
Under hashing this hashes the pages again, once a frame. */
uint32_t host_memory_watch_generation(uint32_t address, uint32_t size)
{
uint64_t first, last, page;
@@ -637,6 +1027,8 @@ uint32_t host_memory_watch_generation(uint32_t address, uint32_t size)
last = watch_page((uint64_t)address + size - 1);
if (last >= WATCH_PAGE_COUNT)
last = WATCH_PAGE_COUNT - 1;
if (watch_hashing)
return watch_hash_generation(&page_hashes, (uint32_t)first, (uint32_t)last);
for (page = first; page <= last; page++)
{
if (page_generation[page] > newest)
@@ -645,11 +1037,14 @@ uint32_t host_memory_watch_generation(uint32_t address, uint32_t size)
return newest;
}
/* memory_watch_prepare_write: unprotects the pages the host itself is about
to write into. Nothing under hashing: the write shows in the next hash and
there is no protection to fault on. */
void host_memory_watch_prepare_write(uint32_t address, uint32_t size)
{
uint64_t start = address, first, last, page;
if (!watch_active || !size)
if (!watch_active || watch_hashing || !size)
return;
if (start + size <= HALO_GUEST_WINDOW_BASE || start >= (uint64_t)HALO_GUEST_WINDOW_BASE + HALO_GUEST_WINDOW_SIZE)
return;
@@ -666,6 +1061,8 @@ void host_memory_watch_prepare_write(uint32_t address, uint32_t size)
}
}
/* memory_watch_forget: the pages of a range were remapped or reprotected, so
treat them as written and unwatched */
void host_memory_watch_forget(uint32_t address, uint32_t size)
{
uint64_t first, last, page;
@@ -676,9 +1073,22 @@ void host_memory_watch_forget(uint32_t address, uint32_t size)
last = watch_page((uint64_t)address + size - 1);
if (last >= WATCH_PAGE_COUNT)
last = WATCH_PAGE_COUNT - 1;
if (watch_hashing)
{
watch_hash_forget(&page_hashes, (uint32_t)first, (uint32_t)last);
return;
}
for (page = first; page <= last; page++)
{
page_protected[page] = 0;
page_generation[page] = __sync_add_and_fetch(&current_generation, 1);
}
}
/* memory_watch_begin_frame: pages may be hashed again; nothing under page
protection */
void host_memory_watch_begin_frame(void)
{
if (watch_hashing)
watch_hash_begin_frame(&page_hashes);
}
+108 -3
View File
@@ -32,6 +32,8 @@ struct handle
{
int type;
void *object;
/* an audio stream with a callback: its binding (below) */
void *binding;
};
static struct handle handles[HANDLE_COUNT];
@@ -59,6 +61,7 @@ static uint32_t handle_new(int type, void *object)
{
handles[index].type = type;
handles[index].object = object;
handles[index].binding = NULL;
pthread_mutex_unlock(&handle_lock);
return index;
}
@@ -81,6 +84,30 @@ static void *handle_get(uint32_t handle, int type)
return object;
}
/* the object of a handle, which the handle no longer refers to; NULL when
there is none. *binding gets what handle_new's caller stored (audio) */
static void *handle_release(uint32_t handle, int type, void **binding)
{
void *object = NULL;
if (binding)
*binding = NULL;
if (handle == 0 || handle >= HANDLE_COUNT)
return NULL;
pthread_mutex_lock(&handle_lock);
if (handles[handle].type == type)
{
object = handles[handle].object;
if (binding)
*binding = handles[handle].binding;
handles[handle].type = _handle_free;
handles[handle].object = NULL;
handles[handle].binding = NULL;
}
pthread_mutex_unlock(&handle_lock);
return object;
}
/* ---------- general */
int host_sdl_init(uint32_t flags)
@@ -221,6 +248,15 @@ uint32_t host_sdl_gamepad_from_id(uint32_t id)
return handle_new(_handle_gamepad, SDL_GetGamepadFromID((SDL_JoystickID)id));
}
/* (the event thread, when the controller goes: sdl_platform.c) */
void host_sdl_close_gamepad(uint32_t gamepad)
{
SDL_Gamepad *object = handle_release(gamepad, _handle_gamepad, NULL);
if (object)
SDL_CloseGamepad(object);
}
int host_sdl_gamepad_axis(uint32_t gamepad, int axis)
{
SDL_Gamepad *object = handle_get(gamepad, _handle_gamepad);
@@ -267,6 +303,8 @@ struct audio_binding
pthread_cond_t requested;
pthread_cond_t done;
int pending;
/* the stream is destroyed: the thread frees the binding and ends */
int quit;
int additional;
int total;
unsigned char *buffer;
@@ -286,8 +324,10 @@ static void *audio_thread(void *context)
{
int additional, total;
while (!binding->pending)
while (!binding->pending && !binding->quit)
pthread_cond_wait(&binding->requested, &binding->lock);
if (binding->quit)
break;
additional = binding->additional;
total = binding->total;
pthread_mutex_unlock(&binding->lock);
@@ -298,6 +338,12 @@ static void *audio_thread(void *context)
binding->pending = 0;
pthread_cond_signal(&binding->done);
}
pthread_mutex_unlock(&binding->lock);
pthread_cond_destroy(&binding->done);
pthread_cond_destroy(&binding->requested);
pthread_mutex_destroy(&binding->lock);
SDL_free(binding->buffer);
SDL_free(binding);
return NULL;
}
@@ -343,9 +389,19 @@ static int audio_keep(struct audio_binding *binding, const void *data, int lengt
uint32_t host_sdl_open_audio_stream(uint32_t device, const void *spec, uint32_t callback, uint32_t userdata)
{
struct audio_binding *binding = SDL_calloc(1, sizeof(*binding));
struct audio_binding *binding;
SDL_AudioStream *stream;
/* (one without a callback, the microphone's, which the guest reads:
nothing to bind) */
if (!callback)
{
stream = SDL_OpenAudioDeviceStream((SDL_AudioDeviceID)device, spec, NULL, NULL);
return stream ? handle_new(_handle_audio, stream) : 0;
}
binding = SDL_calloc(1, sizeof(*binding));
if (!binding)
return 0;
binding->callback = callback;
binding->userdata = userdata;
pthread_mutex_init(&binding->lock, NULL);
@@ -360,7 +416,19 @@ uint32_t host_sdl_open_audio_stream(uint32_t device, const void *spec, uint32_t
}
/* the device starts paused, so no callback can run before this */
binding->handle = handle_new(_handle_audio, stream);
if (callback && host_native_thread_create(audio_thread, binding, 256 * 1024) != 0)
if (!binding->handle)
{
SDL_DestroyAudioStream(stream);
pthread_cond_destroy(&binding->done);
pthread_cond_destroy(&binding->requested);
pthread_mutex_destroy(&binding->lock);
SDL_free(binding);
return 0;
}
pthread_mutex_lock(&handle_lock);
handles[binding->handle].binding = binding;
pthread_mutex_unlock(&handle_lock);
if (host_native_thread_create(audio_thread, binding, 256 * 1024) != 0)
host_fatal("cannot start the audio thread");
return binding->handle;
}
@@ -383,6 +451,43 @@ int host_sdl_resume_audio_stream_device(uint32_t stream)
return object ? SDL_ResumeAudioStreamDevice(object) : 0;
}
/* (voice chat's microphone: port/linux/src/voice_audio.c) */
int host_sdl_get_audio_stream_data(uint32_t stream, void *data, int length)
{
SDL_AudioStream *object = handle_get(stream, _handle_audio);
return object ? SDL_GetAudioStreamData(object, data, length) : -1;
}
int host_sdl_get_audio_stream_available(uint32_t stream)
{
SDL_AudioStream *object = handle_get(stream, _handle_audio);
return object ? SDL_GetAudioStreamAvailable(object) : -1;
}
/* (a stream with a callback has a thread the guest's callbacks run on:
once no callback can run any more, it is told to end, and frees the
binding and its stack as it goes) */
void host_sdl_destroy_audio_stream(uint32_t stream)
{
void *context;
SDL_AudioStream *object = handle_release(stream, _handle_audio, &context);
struct audio_binding *binding = context;
if (!object)
return;
/* (returns once a callback that is running has finished) */
SDL_DestroyAudioStream(object);
if (binding)
{
pthread_mutex_lock(&binding->lock);
binding->quit = 1;
pthread_cond_signal(&binding->requested);
pthread_mutex_unlock(&binding->lock);
}
}
/* ---------- the clipboard (internet play's invite links) */
int host_sdl_set_clipboard_text(const char *text)
+4
View File
@@ -131,6 +131,10 @@ static void *thread_main(void *context)
free(context);
host_debug_thread_started();
start.function(start.argument);
/* (a thread the guest made has let its struct pthread go by now; one
of the host's that ran guest code, the audio thread, has it still) */
if (guest_tp)
((guest_function)(uintptr_t)host_image.header->thread_detach)(0, 0, 0, 0);
host_debug_thread_exited();
guest_tp = 0;
+229
View File
@@ -0,0 +1,229 @@
/*
HOST_TOUCH.C
The on-screen touch controls between the overlay and the game. The overlay
(app/.../TouchControls.java) is an Android view over SDL's surface: it
hands its stick, buttons, view swipes and gyroscope turns over JNI on the UI
thread, and the game reads them through host imports when it reads port 0's
controller (port/linux/src/xinput_sdl.c). The game hands back what the
overlay needs to know: whether it is in a game with no menu or cinematic up
and the input.touch_controls setting (host_touch_scene), the profile's
button mapping, which names the buttons (host_touch_bindings), and how hard
port 0 rumbles.
*/
#include <jni.h>
#include <pthread.h>
#include <stdint.h>
#include <string.h>
#include <time.h>
/* a press shorter than this still reaches the game: it reads the
controller once a frame and acts at 30 ticks a second, and a quick tap can
go down and up between two reads */
#define TOUCH_TAP_NS 60000000LL
/* view motion and rumble older than this are dropped: left over from the
menus, a cinematic or the app in the background */
#define TOUCH_STALE_NS 150000000LL
/* SDL's 15 gamepad buttons, then the left and right triggers */
#define TOUCH_INPUTS 17
static pthread_mutex_t touch_lock = PTHREAD_MUTEX_INITIALIZER;
/* the SDL axes (left x, y, right x, y, left trigger, right trigger), then
the SDL button bits */
static int32_t touch_state[7];
static int64_t touch_pressed_ns[TOUCH_INPUTS];
static float look_delta[2];
static int64_t look_ns;
/* the gyroscope's turn, apart: the profile's invert is the swipe's only */
static float gyro_delta[2];
static int64_t gyro_ns;
static int rumble_amplitude;
static int64_t rumble_ns;
static volatile int touch_scene;
/* the game control on each controller button (-1: none), and a count of
its changes, which the overlay polls */
#define TOUCH_BUTTONS 16
static int32_t touch_bindings[TOUCH_BUTTONS];
static int touch_bindings_serial;
static int64_t now_ns(void)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
}
/* the inputs of a state as bits: buttons, then the triggers */
static uint32_t touch_inputs(const int32_t *state)
{
return ((uint32_t)state[6] & 0x7fff) | (state[4] ? 1u << 15 : 0) | (state[5] ? 1u << 16 : 0);
}
JNIEXPORT void JNICALL Java_com_halo_decomp_TouchControls_nativeState(
JNIEnv *env, jclass cls, jint lx, jint ly, jint rx, jint ry,
jint lt, jint rt, jint buttons)
{
int32_t next[] = { lx, ly, rx, ry, lt, rt, buttons };
uint32_t pressed;
int64_t now = now_ns();
int input;
(void)env;
(void)cls;
pthread_mutex_lock(&touch_lock);
pressed = touch_inputs(next) & ~touch_inputs(touch_state);
for (input = 0; input < TOUCH_INPUTS; input++)
{
if (pressed & (1u << input))
touch_pressed_ns[input] = now;
}
memcpy(touch_state, next, sizeof(next));
pthread_mutex_unlock(&touch_lock);
}
/* the guest's: the overlay's controller state, with each press held for
at least TOUCH_TAP_NS */
void host_touch_read(int32_t *state)
{
int64_t now = now_ns();
int input;
pthread_mutex_lock(&touch_lock);
memcpy(state, touch_state, sizeof(touch_state));
for (input = 0; input < TOUCH_INPUTS; input++)
{
if (touch_pressed_ns[input] && now - touch_pressed_ns[input] < TOUCH_TAP_NS)
{
if (input < 15)
state[6] |= 1 << input;
else if (!state[4 + input - 15])
state[4 + input - 15] = 32767;
}
}
pthread_mutex_unlock(&touch_lock);
}
JNIEXPORT void JNICALL Java_com_halo_decomp_TouchControls_nativeLook(
JNIEnv *env, jclass cls, jfloat dx, jfloat dy)
{
(void)env;
(void)cls;
pthread_mutex_lock(&touch_lock);
look_delta[0] += dx;
look_delta[1] += dy;
look_ns = now_ns();
pthread_mutex_unlock(&touch_lock);
}
JNIEXPORT void JNICALL Java_com_halo_decomp_TouchControls_nativeGyro(
JNIEnv *env, jclass cls, jfloat dx, jfloat dy)
{
(void)env;
(void)cls;
pthread_mutex_lock(&touch_lock);
gyro_delta[0] += dx;
gyro_delta[1] += dy;
gyro_ns = now_ns();
pthread_mutex_unlock(&touch_lock);
}
JNIEXPORT void JNICALL Java_com_halo_decomp_TouchControls_nativeLookReset(JNIEnv *env, jclass cls)
{
(void)env;
(void)cls;
pthread_mutex_lock(&touch_lock);
look_delta[0] = look_delta[1] = 0.0f;
gyro_delta[0] = gyro_delta[1] = 0.0f;
pthread_mutex_unlock(&touch_lock);
}
/* the guest's: the view swipe, then the gyroscope's turn, since the last
read, into delta[4], in the overlay's logical pixels with its sensitivity
applied */
void host_touch_look_read(float *delta)
{
int64_t now = now_ns();
pthread_mutex_lock(&touch_lock);
memcpy(delta, look_delta, sizeof(look_delta));
memcpy(delta + 2, gyro_delta, sizeof(gyro_delta));
if (now - look_ns > TOUCH_STALE_NS)
delta[0] = delta[1] = 0.0f;
if (now - gyro_ns > TOUCH_STALE_NS)
delta[2] = delta[3] = 0.0f;
look_delta[0] = look_delta[1] = 0.0f;
gyro_delta[0] = gyro_delta[1] = 0.0f;
pthread_mutex_unlock(&touch_lock);
}
/* the guest's: port 0's motors (XInputSetState), each 0..65535; the game
sends them every frame, and zero while paused or with the profile's
vibration off */
void host_touch_rumble(unsigned int low, unsigned int high)
{
unsigned int strength = low > high ? low : high;
pthread_mutex_lock(&touch_lock);
rumble_amplitude = strength ? 64 + (int)(strength * 191u / 65535u) : 0;
rumble_ns = now_ns();
pthread_mutex_unlock(&touch_lock);
}
/* the phone's vibration strength, 0 or 64..255 */
JNIEXPORT jint JNICALL Java_com_halo_decomp_TouchControls_nativeRumble(JNIEnv *env, jclass cls)
{
int amplitude;
(void)env;
(void)cls;
pthread_mutex_lock(&touch_lock);
amplitude = now_ns() - rumble_ns > TOUCH_STALE_NS ? 0 : rumble_amplitude;
pthread_mutex_unlock(&touch_lock);
return amplitude;
}
/* the guest's, at every read of port 0: port/linux/src/touch_input.c's
_touch_scene_* bits */
void host_touch_scene(int scene)
{
touch_scene = scene;
}
/* the guest's, when the profile's mapping changes: the game control on
each of the 16 controller buttons (port/linux/game/touch_game.c) */
void host_touch_bindings(const int32_t *controls)
{
pthread_mutex_lock(&touch_lock);
memcpy(touch_bindings, controls, sizeof(touch_bindings));
touch_bindings_serial++;
pthread_mutex_unlock(&touch_lock);
}
/* the mapping into controls[16]; returns its count of changes (0 before the
game has sent it) */
JNIEXPORT jint JNICALL Java_com_halo_decomp_TouchControls_nativeBindings(JNIEnv *env, jclass cls,
jintArray controls)
{
int32_t copy[TOUCH_BUTTONS];
int serial;
(void)cls;
pthread_mutex_lock(&touch_lock);
memcpy(copy, touch_bindings, sizeof(copy));
serial = touch_bindings_serial;
pthread_mutex_unlock(&touch_lock);
if (controls && (*env)->GetArrayLength(env, controls) >= TOUCH_BUTTONS)
(*env)->SetIntArrayRegion(env, controls, 0, TOUCH_BUTTONS, (const jint *)copy);
return serial;
}
/* 0 until the game has read its controller once */
JNIEXPORT jint JNICALL Java_com_halo_decomp_TouchControls_nativeScene(JNIEnv *env, jclass cls)
{
(void)env;
(void)cls;
return touch_scene;
}
+180
View File
@@ -0,0 +1,180 @@
/*
HOST_WATCH_HASH.C
Write tracking by page contents (host_watch_hash.h).
*/
#include "host_watch_hash.h"
#ifdef __wasm_simd128__
/*
FNV-style mixing in sixteen independent 32-bit lanes, four vectors of four
(WebAssembly's SIMD: the web build, web_memory_watch.c), so that no lane
waits for another and the multiplies are the vector unit's own; its 64-bit
multiplies are scalar, which made the hashing of every watched page each
frame a quarter of the web build's frame, twice what this takes. A change of
one word changes its lane's state for good (xor and an odd multiply are both
invertible), and the fold keeps every lane's bits, so a single-word write
always shows. Changes of several words could cancel each other, which is
unlikely for game data. (host_watch_hash.h)
*/
typedef uint32_t watch_hash_lanes __attribute__((vector_size(16), aligned(4), may_alias));
uint64_t watch_hash_page(const uint8_t *page)
{
const watch_hash_lanes *words = (const watch_hash_lanes *)page;
const watch_hash_lanes prime = { 0x01000193u, 0x01000193u, 0x01000193u, 0x01000193u };
watch_hash_lanes lane0 = { 0x811c9dc5u, 0x9e3779b9u, 0x85ebca6bu, 0xc2b2ae35u };
watch_hash_lanes lane1 = { 0x27d4eb2fu, 0x165667b1u, 0xd3a2646cu, 0xfd7046c5u };
watch_hash_lanes lane2 = { 0xb55a4f09u, 0x7feb352du, 0x846ca68bu, 0x2c1b3c6du };
watch_hash_lanes lane3 = { 0x297a2d39u, 0xe6546b64u, 0xcc9e2d51u, 0x1b873593u };
watch_hash_lanes folded;
uint32_t index;
for (index = 0; index < WATCH_HASH_PAGE_SIZE / sizeof(watch_hash_lanes); index += 4)
{
lane0 = (lane0 ^ words[index + 0]) * prime;
lane1 = (lane1 ^ words[index + 1]) * prime;
lane2 = (lane2 ^ words[index + 2]) * prime;
lane3 = (lane3 ^ words[index + 3]) * prime;
}
/* (each vector's lanes turned before they are combined, so that the same
change in two of them does not cancel) */
folded = lane0 ^ ((lane1 << 8) | (lane1 >> 24)) ^ ((lane2 << 16) | (lane2 >> 16)) ^ ((lane3 << 24) | (lane3 >> 8));
return ((uint64_t)(folded[0] ^ ((folded[2] << 13) | (folded[2] >> 19))) << 32) |
(folded[1] ^ ((folded[3] << 13) | (folded[3] >> 19)));
}
#else
/*
FNV-style mixing in four independent lanes, so that the multiplies of
one lane do not wait for another's. A change of one word changes its
lane's state for good (xor and an odd multiply are both invertible), so
a single-word write always shows. Changes of several words could cancel
each other, which is unlikely for game data. (host_watch_hash.h)
*/
uint64_t watch_hash_page(const uint8_t *page)
{
const uint64_t *words = (const uint64_t *)page;
uint64_t lane0 = 0xcbf29ce484222325ULL, lane1 = 0x9e3779b97f4a7c15ULL;
uint64_t lane2 = 0xc2b2ae3d27d4eb4fULL, lane3 = 0x165667b19e3779f9ULL;
uint32_t index;
for (index = 0; index < WATCH_HASH_PAGE_SIZE / 8; index += 4)
{
lane0 = (lane0 ^ words[index + 0]) * 0x100000001b3ULL;
lane1 = (lane1 ^ words[index + 1]) * 0x100000001b3ULL;
lane2 = (lane2 ^ words[index + 2]) * 0x100000001b3ULL;
lane3 = (lane3 ^ words[index + 3]) * 0x100000001b3ULL;
}
return lane0 ^ ((lane1 << 16) | (lane1 >> 48)) ^ ((lane2 << 32) | (lane2 >> 32)) ^
((lane3 << 48) | (lane3 >> 16));
}
#endif
/* clamps last to the watched memory; 0 when nothing is left of the range */
static int clamp(const struct watch_hash *watch, uint32_t first, uint32_t *last)
{
if (first >= watch->page_count || first > *last)
return 0;
if (*last >= watch->page_count)
*last = watch->page_count - 1;
return 1;
}
static const uint8_t *page_memory(const struct watch_hash *watch, uint32_t page)
{
return watch->base + (uint64_t)page * WATCH_HASH_PAGE_SIZE;
}
void watch_hash_protect(struct watch_hash *watch, uint32_t first, uint32_t last)
{
uint32_t page;
if (!clamp(watch, first, &last))
return;
for (page = first; page <= last; page++)
{
if (watch->watched[page])
continue;
watch->watched[page] = 1;
watch->hash[page] = watch_hash_page(page_memory(watch, page));
watch->hashed_frame[page] = watch->frame;
if (watch->unchanged)
watch->unchanged[page] = 0;
}
}
/* a stable page hashed recently enough: not hashed again yet
(host_watch_hash.h) */
static int stable_for_now(const struct watch_hash *watch, uint32_t page)
{
return watch->unchanged && watch->unchanged[page] >= WATCH_HASH_STABLE_CHECKS &&
watch->frame - watch->hashed_frame[page] < WATCH_HASH_STABLE_FRAMES;
}
uint32_t watch_hash_generation(struct watch_hash *watch, uint32_t first, uint32_t last)
{
uint32_t page, newest = 0;
if (!clamp(watch, first, &last))
return 0;
for (page = first; page <= last; page++)
{
if (watch->watched[page] && watch->hashed_frame[page] != watch->frame && !stable_for_now(watch, page))
{
uint64_t hash = watch_hash_page(page_memory(watch, page));
watch->hashed_frame[page] = watch->frame;
if (hash != watch->hash[page])
{
watch->hash[page] = hash;
watch->generation[page] = __sync_add_and_fetch(watch->current_generation, 1);
if (watch->unchanged)
watch->unchanged[page] = 0;
}
else if (watch->unchanged && watch->unchanged[page] < WATCH_HASH_STABLE_CHECKS)
{
watch->unchanged[page]++;
}
}
if (watch->generation[page] > newest)
newest = watch->generation[page];
}
return newest;
}
void watch_hash_forget(struct watch_hash *watch, uint32_t first, uint32_t last)
{
uint32_t page;
if (!clamp(watch, first, &last))
return;
for (page = first; page <= last; page++)
{
watch->watched[page] = 0;
watch->generation[page] = __sync_add_and_fetch(watch->current_generation, 1);
}
}
void watch_hash_prepare_write(struct watch_hash *watch, uint32_t first, uint32_t last)
{
uint32_t page;
if (!watch->unchanged || !clamp(watch, first, &last))
return;
for (page = first; page <= last; page++)
watch->unchanged[page] = 0;
}
/*
The serial changes each frame, so a cache that skips generation queries
while the serial stays the same (xbox_textures.c's recent_textures) asks
again once a frame. (host_watch_hash.h)
*/
void watch_hash_begin_frame(struct watch_hash *watch)
{
watch->frame++;
if (!watch->frame)
watch->frame = 1;
__sync_add_and_fetch(watch->current_generation, 1);
}
+74
View File
@@ -0,0 +1,74 @@
/*
HOST_WATCH_HASH.H
Write tracking by page contents, for processes that cannot catch their own
page protection faults: the ARM translation of the x86 Android emulator
cannot deliver SIGSEGV to the app, so the protection-based tracking of
host_memory.c would stop the game at its first write to a cached page.
A watched page's hash is kept. Asking for its generation hashes it again,
at most once a frame (watch_hash_begin_frame), and a different hash counts
as a write. A write is therefore seen in the next frame, not at once.
Hashing every page drawn from each frame is a large part of the frame on
the web (port/web/src/web_memory_watch.c), where most of them never change
once loaded. With the optional unchanged counts, a page whose hash stayed the
same WATCH_HASH_STABLE_CHECKS times in a row is hashed only every
WATCH_HASH_STABLE_FRAMES frames, until a write into it is announced
(watch_hash_prepare_write: the file layer's reads, the Direct3D locks) or
its hash changes. A write that is not announced into such a page is seen up
to that many frames late.
*/
#ifndef HOST_WATCH_HASH_H
#define HOST_WATCH_HASH_H
#include <stdint.h>
#define WATCH_HASH_PAGE_SIZE 0x1000u
#define WATCH_HASH_STABLE_CHECKS 30u
#define WATCH_HASH_STABLE_FRAMES 60u
struct watch_hash
{
/* the memory of page 0 */
const uint8_t *base;
uint32_t page_count;
/* page_count entries each */
uint8_t *watched;
uint64_t *hash;
uint32_t *hashed_frame;
/* shared with the protection-based tracking (host_memory.c) */
uint32_t *generation;
volatile uint32_t *current_generation;
uint32_t frame;
/* NULL, or page_count entries: the checks in a row that found the page
unchanged (up to WATCH_HASH_STABLE_CHECKS) */
uint8_t *unchanged;
};
/* a hash of one page (WATCH_HASH_PAGE_SIZE bytes, 8-byte aligned) that
changes whenever a word of it changes */
uint64_t watch_hash_page(const uint8_t *page);
/* Starts watching pages first to last (inclusive), remembering their hashes.
Pages already watched keep their hash; pages beyond page_count are
ignored. */
void watch_hash_protect(struct watch_hash *watch, uint32_t first, uint32_t last);
/* The newest generation among pages first to last (inclusive), after hashing
again the watched ones not yet hashed this frame (a stable one may wait,
above); a page whose hash changed gets a new generation. 0 if the range is
outside the watched memory. */
uint32_t watch_hash_generation(struct watch_hash *watch, uint32_t first, uint32_t last);
/* The pages were remapped: treat them as written and unwatched. */
void watch_hash_forget(struct watch_hash *watch, uint32_t first, uint32_t last);
/* The pages are about to be written: hash them every frame again. */
void watch_hash_prepare_write(struct watch_hash *watch, uint32_t first, uint32_t last);
/* A new frame: pages may be hashed again, and the serial changes. */
void watch_hash_begin_frame(struct watch_hash *watch);
#endif
+138 -30
View File
@@ -47,6 +47,11 @@ picture; the copy flips them to GL's bottom-up images.
#include <openxr/openxr_platform.h>
#define MAXIMUM_SWAPCHAIN_IMAGES 8
/* XR_META_performance_metrics' counters kept (the Quest 3 has some twenty:
the app's, the compositor's, the device's and each core's) */
#define MAXIMUM_METRICS 48
/* sampled every this many frames */
#define METRICS_EVERY 3
struct xr_swapchain
{
@@ -129,10 +134,24 @@ static struct
/* (XR_META_performance_metrics: the compositor's counts, in the log,
for telling frames shown late from the game's own pace) */
int has_metrics;
/* the counters and the copies' times logged (vr.log_performance) */
int log_metrics;
/* the app's GPU time of its last frame, ms (app/gpu_frametime: its
counter's index, -1 none), read each frame for vr.graphics "auto" */
int gpu_metric;
float gpu_milliseconds;
PFN_xrQueryPerformanceMetricsCounterMETA query_metric;
XrPath metric_paths[16];
XrPath metric_paths[MAXIMUM_METRICS];
uint32_t metric_count;
struct timespec metrics_logged;
/* each counter sampled every few frames since the last line: sum, most
and samples (the app's GPU time and the compositor's dropped frames
vary from frame to frame) */
double metric_sums[MAXIMUM_METRICS];
double metric_most[MAXIMUM_METRICS];
int metric_samples[MAXIMUM_METRICS];
int metric_units[MAXIMUM_METRICS];
int metric_frame;
/* (SteamVR: the frames' thread kept on the fast cores, xr_fast_cores) */
int steamvr;
int cores_pinned;
@@ -1225,14 +1244,19 @@ int host_xr_probe(float resolution_scale, int *eye_width, int *eye_height)
xr.has_refresh_rate = has_extension(extensions, extension_count, XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME);
if (xr.has_refresh_rate)
enabled[enabled_count++] = XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME;
/* (with vr.log_performance: host_main.c) */
/* (the app's GPU time for vr.graphics "auto"; all the counters logged
with vr.log_performance: host_main.c) */
xr.has_metrics = has_extension(extensions, extension_count, XR_META_PERFORMANCE_METRICS_EXTENSION_NAME);
xr.log_metrics = getenv("HALO_XR_LOG_PERFORMANCE") != NULL;
xr.gpu_metric = -1;
if (xr.has_metrics)
enabled[enabled_count++] = XR_META_PERFORMANCE_METRICS_EXTENSION_NAME;
android.applicationVM = machine;
android.applicationActivity = activity;
instance.next = &android;
strcpy(instance.applicationInfo.applicationName, "Halo VR");
strcpy(instance.applicationInfo.applicationName, "Ringfall Mjolnir VR");
instance.applicationInfo.applicationVersion = 1;
strcpy(instance.applicationInfo.engineName, "halo-ce-universal");
instance.applicationInfo.engineVersion = 1;
@@ -1390,12 +1414,39 @@ int host_xr_initialize(struct halo_xr_info *info, float wanted_refresh_rate)
xrGetInstanceProcAddr(xr.instance, "xrSetPerformanceMetricsStateMETA", (PFN_xrVoidFunction *)&set_state);
xrGetInstanceProcAddr(xr.instance, "xrQueryPerformanceMetricsCounterMETA",
(PFN_xrVoidFunction *)&xr.query_metric);
uint32_t available = 0;
XrPath *paths = NULL;
state.enabled = XR_TRUE;
/* (asked how many first: the Quest 3 has more than 16, and a smaller
array failed the call, so the metrics were off) */
xr.has_metrics = enumerate && set_state && xr.query_metric &&
XR_SUCCEEDED(enumerate(xr.instance, 16, &xr.metric_count, xr.metric_paths)) &&
XR_SUCCEEDED(enumerate(xr.instance, 0, &available, NULL)) && available > 0 &&
(paths = calloc(available, sizeof(*paths))) != NULL &&
XR_SUCCEEDED(enumerate(xr.instance, available, &available, paths)) &&
XR_SUCCEEDED(set_state(xr.session, &state));
host_logf(HOST_LOG_INFO, "xr: performance metrics %s (%u counters)", xr.has_metrics ? "on" : "off",
xr.has_metrics ? xr.metric_count : 0);
xr.metric_count = 0;
if (xr.has_metrics)
{
uint32_t counter;
xr.metric_count = available < MAXIMUM_METRICS ? available : MAXIMUM_METRICS;
memcpy(xr.metric_paths, paths, xr.metric_count * sizeof(*paths));
for (counter = 0; counter < xr.metric_count; counter++)
{
char name[XR_MAX_PATH_LENGTH];
uint32_t length = 0;
if (XR_SUCCEEDED(xrPathToString(xr.instance, xr.metric_paths[counter], sizeof(name), &length, name)) &&
!strcmp(name, "/perfmetrics_meta/app/gpu_frametime"))
{
xr.gpu_metric = (int)counter;
}
}
}
free(paths);
host_logf(HOST_LOG_INFO, "xr: performance metrics %s (%u counters of %u; the app's GPU time %s)",
xr.has_metrics ? "on" : "off", xr.metric_count, available, xr.gpu_metric >= 0 ? "read" : "not offered");
}
if (xr.has_performance_settings)
@@ -1601,10 +1652,10 @@ static int copy_layer_region(int layer, uint32_t texture, int clear_alpha, float
int host_xr_copy_layer_region(int layer, uint32_t texture, int clear_alpha, float left, float top, float right,
float bottom)
{
double start = xr.has_metrics ? seconds_now() : 0.0;
double start = xr.log_metrics ? seconds_now() : 0.0;
int copied = copy_layer_region(layer, texture, clear_alpha, left, top, right, bottom);
if (xr.has_metrics && layer >= 0 && layer < HALO_XR_LAYER_COUNT)
if (xr.log_metrics && layer >= 0 && layer < HALO_XR_LAYER_COUNT)
xr.copy_seconds[layer] += seconds_now() - start;
return copied;
}
@@ -1625,7 +1676,7 @@ static int copy_layer_region(int layer, uint32_t texture, int clear_alpha, float
return xr.deferred ? 2 : 1;
wait.timeout = XR_INFINITE_DURATION;
{
double start = xr.has_metrics ? seconds_now() : 0.0;
double start = xr.log_metrics ? seconds_now() : 0.0;
if (!xr_ok(xrAcquireSwapchainImage(swapchain->handle, &acquire, &swapchain->acquired_index),
"xrAcquireSwapchainImage"))
@@ -1634,7 +1685,7 @@ static int copy_layer_region(int layer, uint32_t texture, int clear_alpha, float
}
if (!xr_ok(xrWaitSwapchainImage(swapchain->handle, &wait), "xrWaitSwapchainImage"))
return 0;
if (xr.has_metrics)
if (xr.log_metrics)
xr.acquire_seconds += seconds_now() - start;
}
@@ -1755,7 +1806,7 @@ static int copy_layer_region(int layer, uint32_t texture, int clear_alpha, float
/* deferred: the images the frame's copies kept, released before it ends */
static void release_held(void)
{
double start = xr.has_metrics ? seconds_now() : 0.0;
double start = xr.log_metrics ? seconds_now() : 0.0;
int layer;
for (layer = 0; layer < HALO_XR_LAYER_COUNT; layer++)
@@ -1768,7 +1819,7 @@ static void release_held(void)
swapchain->held = 0;
xr_ok(xrReleaseSwapchainImage(swapchain->handle, &release), "xrReleaseSwapchainImage");
}
if (xr.has_metrics)
if (xr.log_metrics)
xr.release_seconds += seconds_now() - start;
}
@@ -1974,38 +2025,88 @@ int host_xr_end_frame(const struct halo_xr_submit *submit)
if (!getenv("HALO_XR_ANY_CORE"))
xr_fast_cores();
}
/* (every five seconds, the compositor's counters, and the core the
frames' thread is on) */
if (xr.has_metrics)
/* (the app's GPU time, each frame) */
if (xr.has_metrics && xr.gpu_metric >= 0)
{
XrPerformanceMetricsCounterMETA value = { XR_TYPE_PERFORMANCE_METRICS_COUNTER_META };
if (XR_SUCCEEDED(xr.query_metric(xr.session, xr.metric_paths[xr.gpu_metric], &value)) &&
(value.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_ANY_VALUE_VALID_BIT_META))
{
xr.gpu_milliseconds = (value.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META) ?
value.floatValue : (float)value.uintValue;
}
}
/* (the compositor's counters sampled every few frames; every five
seconds their means and most, and the core the frames' thread is on) */
if (xr.has_metrics && xr.log_metrics)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
if (now.tv_sec - xr.metrics_logged.tv_sec >= 5)
if (++xr.metric_frame >= METRICS_EVERY)
{
char line[768] = "";
uint32_t counter;
xr.metrics_logged = now;
for (counter = 0; counter < xr.metric_count && counter < 16; counter++)
xr.metric_frame = 0;
for (counter = 0; counter < xr.metric_count; counter++)
{
XrPerformanceMetricsCounterMETA value = { XR_TYPE_PERFORMANCE_METRICS_COUNTER_META };
char name[XR_MAX_PATH_LENGTH];
uint32_t length = 0;
char item[96];
double number;
if (!XR_SUCCEEDED(xr.query_metric(xr.session, xr.metric_paths[counter], &value)) ||
!XR_SUCCEEDED(xrPathToString(xr.instance, xr.metric_paths[counter], sizeof(name), &length, name)))
!(value.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_ANY_VALUE_VALID_BIT_META))
{
continue;
}
snprintf(item, sizeof(item), " %s=%g", strrchr(name, '/') ? strrchr(name, '/') + 1 : name,
(value.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META) ?
(double)value.floatValue : (double)value.uintValue);
number = (value.counterFlags & XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META) ?
(double)value.floatValue : (double)value.uintValue;
xr.metric_sums[counter] += number;
if (!xr.metric_samples[counter] || number > xr.metric_most[counter])
xr.metric_most[counter] = number;
xr.metric_samples[counter]++;
xr.metric_units[counter] = (int)value.counterUnit;
}
}
clock_gettime(CLOCK_MONOTONIC, &now);
if (now.tv_sec - xr.metrics_logged.tv_sec >= 5)
{
char line[1024] = "";
uint32_t counter;
xr.metrics_logged = now;
for (counter = 0; counter < xr.metric_count; counter++)
{
char name[XR_MAX_PATH_LENGTH];
const char *shown = name;
uint32_t length = 0;
char item[112];
if (!xr.metric_samples[counter] ||
!XR_SUCCEEDED(xrPathToString(xr.instance, xr.metric_paths[counter], sizeof(name), &length, name)))
{
xr.metric_samples[counter] = 0;
continue;
}
/* ("/perfmetrics_meta/app/gpu_frametime": "app/gpu_frametime") */
if (strrchr(name, '/'))
{
const char *last = strrchr(name, '/');
shown = last;
while (shown > name && shown[-1] != '/')
shown--;
if (shown == name)
shown = last + 1;
}
snprintf(item, sizeof(item), " %s=%.3g/%.3g%s", shown, xr.metric_sums[counter] / xr.metric_samples[counter],
xr.metric_most[counter], xr.metric_units[counter] == XR_PERFORMANCE_METRICS_COUNTER_UNIT_MILLISECONDS_META ?
"ms" : xr.metric_units[counter] == XR_PERFORMANCE_METRICS_COUNTER_UNIT_PERCENTAGE_META ? "%" : "");
if (strlen(line) + strlen(item) < sizeof(line))
strcat(line, item);
xr.metric_sums[counter] = 0.0;
xr.metric_samples[counter] = 0;
}
host_logf(HOST_LOG_INFO, "xr: metrics%s, on core %d", line, sched_getcpu());
host_logf(HOST_LOG_INFO, "xr: metrics (mean/most)%s, on core %d", line, sched_getcpu());
if (xr.timed_frames)
{
double frames = (double)xr.timed_frames;
@@ -2035,10 +2136,10 @@ int host_xr_end_frame(const struct halo_xr_submit *submit)
end.layerCount = layer_count;
end.layers = layers;
{
double start = xr.has_metrics ? seconds_now() : 0.0;
double start = xr.log_metrics ? seconds_now() : 0.0;
int ended = xr_ok(xrEndFrame(xr.session, &end), "xrEndFrame");
if (xr.has_metrics)
if (xr.log_metrics)
{
xr.end_seconds += seconds_now() - start;
xr.timed_frames++;
@@ -2047,6 +2148,13 @@ int host_xr_end_frame(const struct halo_xr_submit *submit)
}
}
/* the app's GPU time of a recent frame (ms), 0 where the runtime does not
tell it (XR_META_performance_metrics: the Quest's) */
float host_xr_gpu_time(void)
{
return xr.gpu_metric >= 0 ? xr.gpu_milliseconds : 0.0f;
}
void host_xr_set_vignette(float strength)
{
xr.vignette = strength < 0.0f ? 0.0f : strength > 1.0f ? 1.0f : strength;
@@ -2167,7 +2275,7 @@ void host_xr_test(void)
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
window = SDL_CreateWindow("Halo", 640, 480, SDL_WINDOW_OPENGL | SDL_WINDOW_FULLSCREEN);
window = SDL_CreateWindow("Ringfall: Mjolnir VR", 640, 480, SDL_WINDOW_OPENGL | SDL_WINDOW_FULLSCREEN);
context = window ? SDL_GL_CreateContext(window) : NULL;
if (!context || !SDL_GL_MakeCurrent(window, context))
host_fatal("VR test: no OpenGL ES context: %s", SDL_GetError());
+21 -1
View File
@@ -13,6 +13,7 @@ host_memory_watch_generation
host_memory_watch_serial
host_memory_watch_prepare_write
host_memory_watch_forget
host_memory_watch_begin_frame
host_sdl_init
host_sdl_set_hint
host_sdl_get_error
@@ -38,14 +39,18 @@ host_sdl_gamepad_axis
host_sdl_gamepad_button
host_sdl_gamepad_type
host_sdl_rumble_gamepad
host_sdl_close_gamepad
host_sdl_scancode_name
host_sdl_scancode_from_name
host_sdl_open_audio_stream
host_sdl_put_audio_stream_data
host_sdl_resume_audio_stream_device
host_sdl_get_audio_stream_data
host_sdl_get_audio_stream_available
host_sdl_destroy_audio_stream
host_gl_get_string
host_gl_has_extension
host_gl_read_buffer_word
host_gl_read_buffer
host_gl_buffer_write
host_gl_buffer_storage
host_gl_persistent_write
@@ -53,6 +58,14 @@ host_gl_fence_frame
host_gl_wait_frame
host_gl_counters_snapshot
host_gl_counters_read
host_gl_worker_start
host_gl_program_request
host_gl_program_hurry
host_gl_program_poll
host_gl_program_binary
host_gl_program_release
host_gl_worker_waiting
host_gl_program_warm_up
host_xr_initialize
host_xr_begin_frame
host_xr_copy_layer
@@ -63,6 +76,13 @@ host_xr_set_vignette
host_xr_copy_layer_array
host_xr_refresh_rates
host_xr_set_refresh_rate
host_xr_gpu_time
host_android_path
host_gesture_insets
host_touch_read
host_touch_look_read
host_touch_rumble
host_touch_scene
host_touch_bindings
host_errno
+4 -1
View File
@@ -29,13 +29,15 @@ This header is included by both halves.
/* the guest image is linked to run here, just above the Xbox window: ART
keeps its heaps low in the address space and fills it upwards */
#define HALO_GUEST_IMAGE_BASE 0x88000000u
/* the room the host reserves for the image there (port/android/host/host_memory.c) */
#define HALO_GUEST_IMAGE_RESERVE 0x04000000u
/* the Xbox contiguous memory window (port/linux/src/platform.h) */
#define HALO_GUEST_WINDOW_BASE 0x80000000u
#define HALO_GUEST_WINDOW_SIZE 0x08000000u
#define HALO_GUEST_MAGIC 0x4f4c4148u /* 'HALO' */
#define HALO_GUEST_ABI_VERSION 1
#define HALO_GUEST_ABI_VERSION 2
/* at HALO_GUEST_IMAGE_BASE */
struct halo_guest_header
@@ -49,6 +51,7 @@ struct halo_guest_header
uint32_t start; /* void __guest_start(struct halo_guest_boot *) */
uint32_t thread_start; /* void __guest_thread_start(uint32_t thread) */
uint32_t thread_attach; /* uint32_t __guest_thread_attach(void) */
uint32_t thread_detach; /* void __guest_thread_detach(void) */
uint32_t init_array_start; /* void (*)(void) entries, 4 bytes each */
uint32_t init_array_end;
};
@@ -0,0 +1,39 @@
diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java b/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java
index 8681d05..1beeac4 100644
--- a/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java
+++ b/android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java
@@ -787,6 +787,34 @@ class SDLGenericMotionListener_API14 implements View.OnGenericMotionListener {
consumed = true;
break;
+ case MotionEvent.ACTION_BUTTON_PRESS:
+ if (checkRelativeEvent(event)) {
+ x = getEventX(event, i);
+ y = getEventY(event, i);
+ SDLActivity.onNativeMouse(event.getButtonState(), MotionEvent.ACTION_DOWN, x, y, true);
+ consumed = true;
+ }
+ break;
+
+ case MotionEvent.ACTION_BUTTON_RELEASE:
+ if (checkRelativeEvent(event)) {
+ x = getEventX(event, i);
+ y = getEventY(event, i);
+ SDLActivity.onNativeMouse(event.getButtonState(), MotionEvent.ACTION_UP, x, y, true);
+ consumed = true;
+ }
+ break;
+
+ case MotionEvent.ACTION_MOVE:
+ // Pointer capture delivers relative mouse motion as ACTION_MOVE.
+ if (checkRelativeEvent(event)) {
+ x = getEventX(event, i);
+ y = getEventY(event, i);
+ SDLActivity.onNativeMouse(0, action, x, y, true);
+ consumed = true;
+ }
+ break;
+
case MotionEvent.ACTION_HOVER_MOVE:
x = getEventX(event, i);
y = getEventY(event, i);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+288
View File
@@ -0,0 +1,288 @@
#!/bin/bash
# Ringfall: Mjolnir VR's installer for the Steam Frame, run in its Desktop
# Mode from the bundle the release's "Install Ringfall: Mjolnir VR" desktop
# file downloads (steam_frame_installer.desktop; the bundle: this script,
# RingfallMjolnirVR.apk, ringfall-mjolnir-vr.sh = steam_frame_launch.sh,
# steam_frame_xiso.py and the library pictures in grid/). Everything is
# answered with dialogs, no keyboard:
# - the game in ~/Documents/RingfallMjolnirVR (port/android/STEAM_FRAME.md),
# its APK replaced (Lepton installs a newer one at the next start), so
# running the installer again updates the game; the folder of the builds
# before the name, ~/Documents/HaloCE, becomes it;
# - the game data: the maps folder copied out of a disc image of the
# original Xbox game (.iso or .xiso) found in Downloads, Documents, the
# Desktop or on a memory card, or chosen, unless it is there already;
# - the game in the Steam library, with its pictures, unless it is there
# already: added by Steam itself (steam://addnonsteamgame, as SteamOS's
# "Add to Steam" does), from a desktop file beside the game. Steam is not
# closed: it keeps its shortcuts in memory and writes them back, and on the
# Steam Frame Desktop Mode runs inside Gaming Mode's session, which ends
# with Steam. The shortcut of the builds before the name ("Halo VR") is
# left for the player to remove (Steam would write it back).
# RINGFALL_UNATTENDED=1 answers every question with its default and prints
# instead of showing dialogs (RINGFALL_IMAGE: the disc image to use), for
# testing over adb; it never closes Steam.
BUNDLE="$(cd "$(dirname "$0")" && pwd)"
GAME_DIR="$HOME/Documents/RingfallMjolnirVR"
STEAM_DIR="$HOME/.local/share/Steam"
LEPTON="$STEAM_DIR/steamapps/common/Lepton/lepton"
TITLE="Ringfall: Mjolnir VR"
NAME="Ringfall: Mjolnir VR"
APK_NAME="RingfallMjolnirVR.apk"
LAUNCHER="ringfall-mjolnir-vr.sh"
EXE="\"$GAME_DIR/$LAUNCHER\""
# (the desktop file Steam adds the game from)
NAME_FILE="Ringfall Mjolnir VR"
# the builds before the name: their folder and Steam shortcut
OLD_DIR="$HOME/Documents/HaloCE"
OLD_NAME="Halo VR"
# what the installer did, for looking into problems: written to /tmp while
# it runs, then kept beside the game as install.log
LOG="/tmp/ringfall-mjolnir-vr-install.log"
: > "$LOG"
log() { echo "$(date '+%F %T') $*" >> "$LOG"; }
keep_log() { [ -d "$GAME_DIR" ] && cp -f "$LOG" "$GAME_DIR/install.log" 2>/dev/null; }
trap keep_log EXIT
log "installer from $BUNDLE"
# whether Steam runs: its pid file (~/.steam/steam.pid), or its client's
# process (ubuntu12_32/steam: the process is not always named "steam")
steam_running()
{
local pid
pid="$(cat "$HOME/.steam/steam.pid" 2>/dev/null)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
return 0
fi
pgrep -f 'ubuntu12_32/steam( |$)' >/dev/null || pgrep -x steam >/dev/null
}
unattended() { [ "${RINGFALL_UNATTENDED:-0}" = 1 ] || ! command -v zenity >/dev/null; }
info()
{
if unattended; then printf "info: %b\n" "$1"; else zenity --info --title="$TITLE" --width=460 --text="$1"; fi
}
fail()
{
log "error: $1"
if unattended; then printf "error: %b\n" "$1" >&2; else zenity --error --title="$TITLE" --width=460 --text="$1"; fi
exit 1
}
# yes/no; unattended: the default given ($2, "yes" or "no")
ask()
{
if unattended; then [ "$2" = yes ]; return; fi
zenity --question --title="$TITLE" --width=460 --text="$1"
}
# the disc image the player chooses
choose_image()
{
if unattended; then return 1; fi
zenity --file-selection --title="Choose a disc image of Halo: Combat Evolved for the Xbox (.iso or .xiso)" \
--filename="$HOME/Downloads/" --file-filter="Disc images | *.iso *.ISO *.xiso *.XISO" \
--file-filter="All files | *"
}
# the disc images near at hand, largest first (the game's image is a few GB;
# anything under 500 MB is not one)
find_images()
{
find "$HOME/Downloads" "$HOME/Documents" "$HOME/Desktop" /run/media -maxdepth 3 -type f \
\( -iname '*.iso' -o -iname '*.xiso' \) -size +500M -printf '%s\t%p\n' 2>/dev/null |
sort -rn | cut -f2-
}
extract_maps()
{
local image="$1" log
log="$(mktemp)"
if unattended; then
python3 "$BUNDLE/steam_frame_xiso.py" "$image" "$GAME_DIR" 2>"$log" | grep '^#' | tail -n 1
status=${PIPESTATUS[0]}
else
python3 "$BUNDLE/steam_frame_xiso.py" "$image" "$GAME_DIR" 2>"$log" |
zenity --progress --title="$TITLE" --width=460 --auto-close --no-cancel \
--text="Copying the game data out of the disc image..."
status=${PIPESTATUS[0]}
fi
if [ "$status" != 0 ]; then
reason="$(sed -n 's/^error: //p' "$log" | head -n 1)"
rm -f "$log"
fail "The game data could not be copied out of\n$image\n\n${reason:-Unknown error.}"
fi
rm -f "$log"
}
# the Steam users' shortcut files (userdata/0 is no user)
shortcut_files()
{
local config
for config in "$STEAM_DIR"/userdata/*/config; do
case "$config" in */userdata/0/*) continue ;; esac
[ -f "$config/shortcuts.vdf" ] && echo "$config/shortcuts.vdf"
done
}
# whether a Steam user's library holds the launcher given
have_shortcut_to()
{
local file
while read -r file; do
grep -qaF "$1" "$file" && return 0
done < <(shortcut_files)
return 1
}
have_shortcut() { have_shortcut_to "$GAME_DIR/$LAUNCHER"; }
# the id Steam gave the shortcut whose Exe is $2, in shortcuts.vdf $1 (Steam
# picks it; its library pictures in config/grid are named after it)
shortcut_id()
{
python3 - "$1" "$2" <<'PYTHON'
import struct, sys
path, exe = sys.argv[1], sys.argv[2].encode()
# Steam's binary VDF: 0x00 a map, 0x01 a string, 0x02 an int32, 0x08 a map's end
def read_map(data, at):
items = []
while True:
kind = data[at]; at += 1
if kind == 0x08:
return items, at
end = data.index(b"\0", at); key = data[at:end].lower(); at = end + 1
if kind == 0x00:
value, at = read_map(data, at)
elif kind == 0x01:
end = data.index(b"\0", at); value = data[at:end]; at = end + 1
elif kind == 0x02:
value = struct.unpack_from("<I", data, at)[0]; at += 4
else:
raise ValueError("unknown VDF type %d" % kind)
items.append((key, value))
with open(path, "rb") as file:
top, _ = read_map(file.read(), 0)
for key, shortcuts in top:
if key == b"shortcuts":
for _, entry in shortcuts:
fields = dict(entry)
if fields.get(b"exe") == exe and b"appid" in fields:
print(fields[b"appid"])
sys.exit(0)
sys.exit(1)
PYTHON
}
# the game added to the library of the Steam user logged in, by Steam (it
# must run), from a desktop file beside the game; then its pictures
add_shortcut()
{
local desktop="$GAME_DIR/$NAME_FILE.desktop" url file id
printf '[Desktop Entry]\nType=Application\nName=%s\nExec=%s\nPath=%s\nIcon=steam\nTerminal=false\n' \
"$NAME" "$GAME_DIR/$LAUNCHER" "$GAME_DIR" > "$desktop" && chmod 755 "$desktop" || return 1
url="steam://addnonsteamgame/$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$desktop")"
log "asking Steam: $url"
# (Steam's own "Add to Steam" touches this first: steamos-add-to-steam)
touch /tmp/addnonsteamgamefile
steam "$url" >/dev/null 2>&1 &
for _ in $(seq 30); do have_shortcut && break; sleep 1; done
have_shortcut || return 1
while read -r file; do
id="$(shortcut_id "$file" "$EXE")" || continue
log "shortcut $id in $file"
mkdir -p "$(dirname "$file")/grid"
cp "$BUNDLE/grid/library.png" "$(dirname "$file")/grid/${id}.png"
cp "$BUNDLE/grid/libraryp.png" "$(dirname "$file")/grid/${id}p.png"
cp "$BUNDLE/grid/library_hero.png" "$(dirname "$file")/grid/${id}_hero.png"
cp "$BUNDLE/grid/library_logo.png" "$(dirname "$file")/grid/${id}_logo.png"
done < <(shortcut_files)
return 0
}
# --- the game
[ -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."
rm -f "$GAME_DIR/HaloVR.apk" "$GAME_DIR/halo-vr.sh"
fi
if [ -f "$GAME_DIR/$APK_NAME" ] || [ -f "$GAME_DIR/maps/ui.map" ]; then
ask "$NAME is installed already. Update it to this version?\n\n(Your settings, saves and game data stay.)" yes || exit 0
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 || exit 0
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)
chmod 777 "$GAME_DIR" "$GAME_DIR/maps"
cp "$BUNDLE/$LAUNCHER" "$GAME_DIR/$LAUNCHER.tmp" && chmod 755 "$GAME_DIR/$LAUNCHER.tmp" &&
mv -f "$GAME_DIR/$LAUNCHER.tmp" "$GAME_DIR/$LAUNCHER" || fail "Could not write $GAME_DIR/$LAUNCHER."
# (newer than the installed one: Lepton installs it at the next start)
cp "$BUNDLE/$APK_NAME" "$GAME_DIR/$APK_NAME.tmp" && chmod 666 "$GAME_DIR/$APK_NAME.tmp" &&
mv -f "$GAME_DIR/$APK_NAME.tmp" "$GAME_DIR/$APK_NAME" && touch "$GAME_DIR/$APK_NAME" ||
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"
exit 0
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
steamos-session-select gamescope >/dev/null 2>&1 ||
info "Switch to Gaming Mode yourself (the \"Return to Gaming Mode\" icon or the power menu)."
fi
@@ -0,0 +1,14 @@
[Desktop Entry]
# Ringfall: Mjolnir VR's installer for the Steam Frame
# (port/android/STEAM_FRAME.md): a release asset,
# "Install-Ringfall-Mjolnir-VR.desktop", opened in Desktop Mode. It downloads
# the latest release's bundle (tools/steam_frame_bundle.py, which fills in
# the repository) and runs its installer, steam_frame_install.sh.
# (no "$", "\" or "%" in Exec: the desktop entry format would need them
# escaped)
Type=Application
Name=Install Ringfall: Mjolnir VR
Comment=Installs Ringfall: Mjolnir VR for the Steam Frame, or updates it
Icon=steam
Terminal=false
Exec=bash -c "if curl -fL --retry 3 -o /tmp/ringfall-mjolnir-vr-steam-frame.zip https://github.com/@REPOSITORY@/releases/latest/download/ringfall-mjolnir-vr-steam-frame.zip && rm -rf /tmp/ringfall-mjolnir-vr-install && python3 -m zipfile -e /tmp/ringfall-mjolnir-vr-steam-frame.zip /tmp/ringfall-mjolnir-vr-install; then exec bash /tmp/ringfall-mjolnir-vr-install/install.sh; else zenity --error --width=460 --title='Ringfall: Mjolnir VR' --text='Ringfall: Mjolnir VR could not be downloaded. Is the Steam Frame online?'; fi"
+7 -6
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Halo VR on the Steam Frame: Lepton runs the APK in a container of its own,
# Ringfall: Mjolnir VR on the Steam Frame: Lepton runs the APK in a container of its own,
# and installs it anew whenever the APK is newer than the installed one
# (port/android/STEAM_FRAME.md). The game's data: ~/Documents/HaloCE.
# (port/android/STEAM_FRAME.md). The game's data: ~/Documents/RingfallMjolnirVR.
# (a container of its own, not the shortcut's id: with that, SteamVR's
# client in the container waited on xrCreateInstance)
export SteamAppId="${HALO_LEPTON_APP_ID:-4290000001}"
@@ -14,10 +14,11 @@ export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/
mkdir -p "$STEAM_COMPAT_DATA_PATH/internal" "$STEAM_COMPAT_SHADER_PATH"
unset STEAM_FOSSILIZE_DUMP_PATH LD_PRELOAD
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
APK="$HOME/Documents/HaloCE/HaloVR.apk"
LOG="$HOME/Documents/HaloCE/launcher.log"
DATA="$HOME/Documents/RingfallMjolnirVR"
APK="$DATA/RingfallMjolnirVR.apk"
LOG="$DATA/launcher.log"
# (written by the game when the player quits it: no start again)
QUIT="$HOME/Documents/HaloCE/quit_requested.txt"
QUIT="$DATA/quit_requested.txt"
rm -f "$QUIT"
CONTEXT="steamlaunch-$SteamAppId"
@@ -49,7 +50,7 @@ setsid bash -c '
# the game's log while it runs (Lepton's logcat of its container), for
# looking into problems: logcat.txt, anew each start
LOGCAT="$HOME/Documents/HaloCE/logcat.txt"
LOGCAT="$DATA/logcat.txt"
: > "$LOGCAT"
setsid bash -c '
while kill -0 '"$script"' 2>/dev/null; do
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""
Copies the maps folder out of an Xbox disc image (an "xiso", or a whole
disc's ".iso") on the Steam Frame's SteamOS side, for its installer
(steam_frame_install.sh). It reads the image as the app's own import does
(app/src/main/java/com/halo/decomp/XisoExtractor.java, which this follows;
the desktop games' is port/linux/src/xiso.c).
steam_frame_xiso.py <image> <destination>
writes <destination>/maps (through maps.partial, so an interrupted copy
never leaves maps/ui.map behind) and prints its progress for zenity
--progress: a percentage on a line of its own, and "# <text>" lines. On
failure it prints "error: <reason the player can act on>" to stderr and
exits 1.
The image's file system is XDVDFS, read as extract-xiso does
(https://github.com/XboxDev/extract-xiso, extract-xiso.c, whose format
handling this follows; its license is below and in
port/third_party/extract-xiso/LICENSE.TXT): 2048-byte sectors; a volume
descriptor at 0x10000 that starts and ends with "MICROSOFT*XBOX*MEDIA" and
gives the root directory's sector and size; and directories whose entries
form a binary tree (each entry: left and right subtree offsets in 4-byte
units, the start sector, the size, attributes, the name's length and the
name). Images made from a whole disc put the game partition further in, at
one of the offsets below.
Some parts of this code are copyright in@fishtank.com. This product
includes software developed by in <in@fishtank.com>.
Copyright (c) 2003 in <in@fishtank.com>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. All advertising materials mentioning features or use of this software
must display the following acknowledgement:
This product includes software developed by in <in@fishtank.com>.
4. Neither the name of "in" nor the email address "in@fishtank.com"
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED `AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR OR ANY CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
import os
import struct
import sys
SECTOR_SIZE = 2048
VOLUME_DESCRIPTOR_OFFSET = 0x10000
ENTRY_HEADER_SIZE = 14
ATTRIBUTE_DIRECTORY = 0x10
# directory tables are a few sectors; anything much larger is not one
MAXIMUM_DIRECTORY_SIZE = 4 << 20
MAXIMUM_FILES = 256
COPY_BUFFER_SIZE = 1 << 20
VOLUME_MAGIC = b"MICROSOFT*XBOX*MEDIA"
# where the game partition starts: a plain image, and whole-disc images
# (extract-xiso's GLOBAL, XGD3 and XGD1 offsets)
PARTITION_OFFSETS = (0, 0x0FD90000, 0x02080000, 0x18300000)
class ExtractError(Exception):
"""a reason the player can act on"""
def read_at(image, offset, size):
image.seek(offset)
data = image.read(size)
if len(data) != size:
raise ExtractError("Could not read the disc image (is it complete?).")
return data
def find_volume(image):
"""the game partition's offset, and the root directory's sector and size"""
for offset in PARTITION_OFFSETS:
try:
descriptor = read_at(image, offset + VOLUME_DESCRIPTOR_OFFSET, SECTOR_SIZE)
except ExtractError:
continue
if descriptor[:20] != VOLUME_MAGIC or descriptor[0x7EC:0x7EC + 20] != VOLUME_MAGIC:
continue
sector, size = struct.unpack_from("<II", descriptor, 20)
return offset, sector, size
raise ExtractError("This is not an Xbox disc image.")
def read_directory(image, partition, sector, size, what):
if size <= 0 or size > MAXIMUM_DIRECTORY_SIZE:
raise ExtractError(what)
return read_at(image, partition + sector * SECTOR_SIZE, size)
def walk(table, offset, depth, directories, out, visited):
"""collects the entries (name, sector, size) of the subtree at offset
(4-byte units), files or directories as asked"""
at = offset * 4
# a malformed table must not loop or run off its end
visited[0] += 1
if depth > 64 or visited[0] > 4096 or at + ENTRY_HEADER_SIZE > len(table):
return
left, right, sector, size, attributes, name_length = struct.unpack_from("<HHIIBB", table, at)
# 0xFFFF: padding, an empty directory
if left == 0xFFFF:
return
if left:
walk(table, left, depth + 1, directories, out, visited)
is_directory = bool(attributes & ATTRIBUTE_DIRECTORY)
if (at + ENTRY_HEADER_SIZE + name_length <= len(table) and name_length > 0
and is_directory == directories and len(out) < MAXIMUM_FILES):
name = table[at + ENTRY_HEADER_SIZE:at + ENTRY_HEADER_SIZE + name_length].decode("latin-1")
# (as extract-xiso refuses them: no name may leave the folder)
if name not in (".", "..") and "/" not in name and "\\" not in name:
out.append((name, sector, size))
if right:
walk(table, right, depth + 1, directories, out, visited)
def extract(image_path, destination):
with open(image_path, "rb") as image:
partition, root_sector, root_size = find_volume(image)
# the root's maps folder
table = read_directory(image, partition, root_sector, root_size,
"The disc image's file system is damaged.")
directories = []
walk(table, 0, 0, True, directories, [0])
maps = [entry for entry in directories if entry[0].lower() == "maps"]
if not maps:
raise ExtractError("The disc image has no maps folder: it is not a Halo disc.")
# its files
table = read_directory(image, partition, maps[-1][1], maps[-1][2],
"The disc image's maps folder is damaged.")
files = []
walk(table, 0, 0, False, files, [0])
if not any(name.lower() == "ui.map" for name, _, _ in files):
raise ExtractError("The disc image's maps folder has no ui.map: it is not a Halo disc.")
total = sum(size for _, _, size in files) or 1
partial = os.path.join(destination, "maps.partial")
finished = os.path.join(destination, "maps")
try:
os.makedirs(partial, exist_ok=True)
except OSError:
raise ExtractError("Could not create %s." % partial)
done = 0
shown = -1
for name, sector, size in files:
path = os.path.join(partial, name)
offset = partition + sector * SECTOR_SIZE
remaining = size
print("# Copying maps/%s" % name, flush=True)
try:
with open(path, "wb") as out:
while remaining > 0:
count = min(remaining, COPY_BUFFER_SIZE)
out.write(read_at(image, offset, count))
offset += count
remaining -= count
done += count
# (zenity closes at 100: that is left to the caller)
percent = min(99, done * 100 // total)
if percent != shown:
print(percent, flush=True)
shown = percent
except ExtractError:
raise ExtractError("Could not read %s from the disc image (is it complete?)." % name)
except OSError:
raise ExtractError("Could not write %s (is the storage full?)." % path)
# (the maps folder may be there, empty)
try:
os.makedirs(finished, exist_ok=True)
for name, _, _ in files:
os.replace(os.path.join(partial, name), os.path.join(finished, name))
os.rmdir(partial)
except OSError as error:
raise ExtractError("Could not move the maps into %s (%s)." % (finished, error.strerror))
def main():
if len(sys.argv) != 3:
print("usage: steam_frame_xiso.py <image> <destination>", file=sys.stderr)
return 2
try:
extract(sys.argv[1], sys.argv[2])
except ExtractError as error:
print("error: %s" % error, file=sys.stderr)
return 1
except OSError as error:
print("error: Could not open the disc image (%s)." % error.strerror, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+272
View File
@@ -0,0 +1,272 @@
/*
HOST_WATCH_HASH_TEST.C
Tests of the page-hash write tracking (port/android/host/host_watch_hash.c).
Built and run by tools/test_host_watch_hash.py; exits nonzero on a failure.
*/
#include "../host/host_watch_hash.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define PAGES 4
static int failures;
#define CHECK(condition) \
do \
{ \
if (!(condition)) \
{ \
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \
failures++; \
} \
} while (0)
struct fixture
{
uint8_t *memory;
uint8_t watched[PAGES];
uint64_t hash[PAGES];
uint32_t hashed_frame[PAGES];
uint32_t generation[PAGES];
uint8_t unchanged[PAGES];
volatile uint32_t current;
struct watch_hash watch;
};
static void setup(struct fixture *f)
{
memset(f, 0, sizeof(*f));
f->memory = aligned_alloc(WATCH_HASH_PAGE_SIZE, PAGES * WATCH_HASH_PAGE_SIZE);
memset(f->memory, 0x5a, PAGES * WATCH_HASH_PAGE_SIZE);
f->current = 1;
f->watch.base = f->memory;
f->watch.page_count = PAGES;
f->watch.watched = f->watched;
f->watch.hash = f->hash;
f->watch.hashed_frame = f->hashed_frame;
f->watch.generation = f->generation;
f->watch.current_generation = &f->current;
f->watch.frame = 1;
}
static void teardown(struct fixture *f)
{
free(f->memory);
}
static uint8_t *byte_of(struct fixture *f, uint32_t page, uint32_t offset)
{
return f->memory + page * WATCH_HASH_PAGE_SIZE + offset;
}
static void test_unchanged_page_keeps_generation(void)
{
struct fixture f;
uint32_t before;
setup(&f);
watch_hash_protect(&f.watch, 0, 0);
before = watch_hash_generation(&f.watch, 0, 0);
watch_hash_begin_frame(&f.watch);
CHECK(watch_hash_generation(&f.watch, 0, 0) == before);
teardown(&f);
}
static void test_write_is_seen_next_frame(void)
{
struct fixture f;
uint32_t before, after;
setup(&f);
watch_hash_protect(&f.watch, 1, 1);
before = watch_hash_generation(&f.watch, 1, 1);
*byte_of(&f, 1, 100) ^= 1;
/* the page was hashed in this frame already */
CHECK(watch_hash_generation(&f.watch, 1, 1) == before);
watch_hash_begin_frame(&f.watch);
after = watch_hash_generation(&f.watch, 1, 1);
CHECK(after > before);
CHECK(f.current >= after);
teardown(&f);
}
static void test_any_byte_is_seen(void)
{
static const uint32_t offsets[] = { 0, 2047, WATCH_HASH_PAGE_SIZE - 1 };
unsigned index;
for (index = 0; index < sizeof(offsets) / sizeof(offsets[0]); index++)
{
struct fixture f;
uint32_t before;
setup(&f);
watch_hash_protect(&f.watch, 2, 2);
watch_hash_begin_frame(&f.watch);
before = watch_hash_generation(&f.watch, 2, 2);
*byte_of(&f, 2, offsets[index]) += 1;
watch_hash_begin_frame(&f.watch);
CHECK(watch_hash_generation(&f.watch, 2, 2) > before);
teardown(&f);
}
}
static void test_hash_at_most_once_per_frame(void)
{
struct fixture f;
uint32_t before;
setup(&f);
watch_hash_protect(&f.watch, 0, 0);
watch_hash_begin_frame(&f.watch);
before = watch_hash_generation(&f.watch, 0, 0);
CHECK(f.hashed_frame[0] == f.watch.frame);
*byte_of(&f, 0, 8) ^= 0xff;
CHECK(watch_hash_generation(&f.watch, 0, 0) == before);
watch_hash_begin_frame(&f.watch);
CHECK(watch_hash_generation(&f.watch, 0, 0) > before);
teardown(&f);
}
static void test_unwatched_page_is_not_hashed(void)
{
struct fixture f;
setup(&f);
*byte_of(&f, 3, 0) ^= 1;
watch_hash_begin_frame(&f.watch);
CHECK(watch_hash_generation(&f.watch, 3, 3) == 0);
CHECK(f.hashed_frame[3] == 0);
teardown(&f);
}
static void test_forget_unwatches_and_bumps(void)
{
struct fixture f;
uint32_t before;
setup(&f);
watch_hash_protect(&f.watch, 2, 2);
before = watch_hash_generation(&f.watch, 2, 2);
watch_hash_forget(&f.watch, 2, 2);
CHECK(f.watched[2] == 0);
CHECK(watch_hash_generation(&f.watch, 2, 2) > before);
teardown(&f);
}
static void test_range_returns_newest(void)
{
struct fixture f;
uint32_t newest;
setup(&f);
watch_hash_protect(&f.watch, 0, 3);
watch_hash_begin_frame(&f.watch);
*byte_of(&f, 3, 5) ^= 1;
watch_hash_begin_frame(&f.watch);
newest = watch_hash_generation(&f.watch, 0, 3);
CHECK(newest == f.generation[3]);
CHECK(watch_hash_generation(&f.watch, 0, 2) < newest);
teardown(&f);
}
static void test_begin_frame_changes_serial(void)
{
struct fixture f;
uint32_t serial;
setup(&f);
serial = f.current;
watch_hash_begin_frame(&f.watch);
CHECK(f.current != serial);
teardown(&f);
}
static void test_ranges_are_clamped(void)
{
struct fixture f;
setup(&f);
watch_hash_protect(&f.watch, 2, 99);
CHECK(f.watched[2] == 1 && f.watched[3] == 1);
CHECK(watch_hash_generation(&f.watch, 5, 9) == 0);
watch_hash_forget(&f.watch, 7, 8);
teardown(&f);
}
/* a page unchanged for long: hashed every few frames, or at once again
after a write is announced */
static void test_stable_page_is_hashed_less_often(void)
{
struct fixture f;
uint32_t before, check, frame;
setup(&f);
f.watch.unchanged = f.unchanged;
watch_hash_protect(&f.watch, 1, 1);
for (check = 0; check < WATCH_HASH_STABLE_CHECKS; check++)
{
watch_hash_begin_frame(&f.watch);
watch_hash_generation(&f.watch, 1, 1);
}
CHECK(f.unchanged[1] == WATCH_HASH_STABLE_CHECKS);
before = watch_hash_generation(&f.watch, 1, 1);
*byte_of(&f, 1, 40) ^= 1;
watch_hash_begin_frame(&f.watch);
CHECK(watch_hash_generation(&f.watch, 1, 1) == before);
/* seen within WATCH_HASH_STABLE_FRAMES frames all the same */
for (frame = 2; frame < WATCH_HASH_STABLE_FRAMES; frame++)
{
watch_hash_begin_frame(&f.watch);
watch_hash_generation(&f.watch, 1, 1);
}
watch_hash_begin_frame(&f.watch);
CHECK(watch_hash_generation(&f.watch, 1, 1) > before);
CHECK(f.unchanged[1] == 0);
teardown(&f);
}
static void test_announced_write_is_seen_next_frame(void)
{
struct fixture f;
uint32_t before, check;
setup(&f);
f.watch.unchanged = f.unchanged;
watch_hash_protect(&f.watch, 2, 2);
for (check = 0; check < WATCH_HASH_STABLE_CHECKS; check++)
{
watch_hash_begin_frame(&f.watch);
watch_hash_generation(&f.watch, 2, 2);
}
before = watch_hash_generation(&f.watch, 2, 2);
watch_hash_prepare_write(&f.watch, 2, 2);
*byte_of(&f, 2, 4000) ^= 1;
watch_hash_begin_frame(&f.watch);
CHECK(watch_hash_generation(&f.watch, 2, 2) > before);
teardown(&f);
}
int main(void)
{
test_unchanged_page_keeps_generation();
test_write_is_seen_next_frame();
test_any_byte_is_seen();
test_hash_at_most_once_per_frame();
test_unwatched_page_is_not_hashed();
test_forget_unwatches_and_bumps();
test_range_returns_newest();
test_begin_frame_changes_serial();
test_ranges_are_clamped();
test_stable_page_is_hashed_less_often();
test_announced_write_is_seen_next_frame();
if (failures)
printf("%d failures\n", failures);
else
printf("all tests passed\n");
return failures != 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+212
View File
@@ -0,0 +1,212 @@
{
"assets": [
{
"name": "a_butn__0",
"tag": "ui\\shell\\bitmaps\\a_butn",
"bitmap": 0,
"width": 32,
"height": 32,
"format": "dxt3",
"scale": 8,
"crc": 1140253829,
"svg": "a_butn.svg",
"place": [
1,
0,
0
],
"letter": [
-0.006,
0.0
]
},
{
"name": "b_butn__0",
"tag": "ui\\shell\\bitmaps\\b_butn",
"bitmap": 0,
"width": 32,
"height": 32,
"format": "dxt3",
"scale": 8,
"crc": 2900806204,
"svg": "b_butn.svg",
"place": [
1,
0,
0
],
"letter": [
0.001,
0.002
]
},
{
"name": "x_butn__0",
"tag": "ui\\shell\\bitmaps\\x_butn",
"bitmap": 0,
"width": 32,
"height": 32,
"format": "dxt3",
"scale": 8,
"crc": 2247463469,
"svg": "x_butn.svg",
"place": [
1,
0,
0
],
"letter": [
-0.002,
-0.008
]
},
{
"name": "y_butn__0",
"tag": "ui\\shell\\bitmaps\\y_butn",
"bitmap": 0,
"width": 32,
"height": 32,
"format": "dxt3",
"scale": 8,
"crc": 783963872,
"svg": "y_butn.svg",
"place": [
1,
0,
0
],
"letter": [
-0.009,
0.028
]
},
{
"name": "a_butn_sm__0",
"tag": "ui\\shell\\bitmaps\\a_butn_sm",
"bitmap": 0,
"width": 32,
"height": 32,
"format": "a8r8g8b8",
"scale": 8,
"crc": 2356213272,
"svg": "a_butn.svg",
"place": [
0.7702,
-0.222,
-0.272
],
"letter": [
0.014,
0.071
]
},
{
"name": "b_butn_sm__0",
"tag": "ui\\shell\\bitmaps\\b_butn_sm",
"bitmap": 0,
"width": 32,
"height": 32,
"format": "a8r8g8b8",
"scale": 8,
"crc": 3999851658,
"svg": "b_butn.svg",
"place": [
0.7677,
-0.14,
-0.225
],
"letter": [
-0.054,
0.062
]
},
{
"name": "hud_msg_icons_sm__0",
"tag": "ui\\hud\\bitmaps\\hud_msg_icons_sm",
"bitmap": 0,
"width": 128,
"height": 128,
"format": "a8y8",
"scale": 8,
"crc": 2908635490,
"sprites": [
{
"sequence": 0,
"cell": [
68,
21,
84,
41
],
"svg": "a_butn.svg",
"place": [
0.7063,
1.04,
1.026
],
"letter": [
0.119,
0.079
]
},
{
"sequence": 1,
"cell": [
103,
0,
119,
20
],
"svg": "b_butn.svg",
"place": [
0.681,
1.667,
1.601
],
"letter": [
0.474,
0.082
]
},
{
"sequence": 2,
"cell": [
71,
0,
87,
20
],
"svg": "x_butn.svg",
"place": [
0.6987,
1.023,
1.294
],
"letter": [
0.235,
1.718
]
},
{
"sequence": 3,
"cell": [
87,
0,
103,
20
],
"svg": "y_butn.svg",
"place": [
0.6992,
1.071,
1.29
],
"letter": [
-0.306,
1.601
]
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<!-- The A button of the menus' button keys (ui\shell\bitmaps\a_butn), redrawn: drawn
in the 32x32 bitmap's texels, where its art is (tools/button_assets.py). A tilted
green oval, dark at the top and bright at the bottom, with a pale rim, and its
letter in white, upright from its baseline and slanted. -->
<defs>
<!-- (along the oval's long axis, top to bottom) -->
<linearGradient id="body" gradientUnits="userSpaceOnUse" x1="0" y1="-12.932" x2="0" y2="12.932">
<stop offset="0" stop-color="#08370f"/>
<stop offset="0.5" stop-color="#056a1c"/>
<stop offset="1" stop-color="#0a9924"/>
</linearGradient>
<linearGradient id="rim" gradientUnits="userSpaceOnUse" x1="0" y1="-12.932" x2="0" y2="12.932">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.74"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.73"/>
</linearGradient>
</defs>
<g transform="translate(10.341 12.879) rotate(-26.459)">
<ellipse id="oval" rx="8.659" ry="12.932" fill="url(#body)"/>
<ellipse rx="8.082" ry="12.355" fill="none" stroke="url(#rim)" stroke-width="1.154"/>
</g>
<path id="letter" transform="matrix(1 0 -0.185 1 9.644 16.929)" fill="#ffffff"
d="M-1.508,-11.229 L0.746,-11.229 L-3.038,0.000 L-5.292,0.000 Z M5.292,0.000 L3.038,0.000 L-0.746,-11.229 L1.508,-11.229 Z M-2.739,-4.232 L2.739,-4.232 L3.285,-2.613 L-3.285,-2.613 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<!-- The B button of the menus' button keys (ui\shell\bitmaps\b_butn), redrawn: drawn
in the 32x32 bitmap's texels, where its art is (tools/button_assets.py). A tilted
red oval, dark at the top and bright at the bottom, with a pale rim, and its
letter in white, upright from its baseline and slanted. -->
<defs>
<!-- (along the oval's long axis, top to bottom) -->
<linearGradient id="body" gradientUnits="userSpaceOnUse" x1="0" y1="-13.053" x2="0" y2="13.053">
<stop offset="0" stop-color="#660f13"/>
<stop offset="0.5" stop-color="#b50000"/>
<stop offset="1" stop-color="#ff0200"/>
</linearGradient>
<linearGradient id="rim" gradientUnits="userSpaceOnUse" x1="0" y1="-13.053" x2="0" y2="13.053">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.72"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.79"/>
</linearGradient>
</defs>
<g transform="translate(10.193 12.921) rotate(-27.868)">
<ellipse id="oval" rx="9.041" ry="13.053" fill="url(#body)"/>
<ellipse rx="8.452" ry="12.464" fill="none" stroke="url(#rim)" stroke-width="1.178"/>
</g>
<path id="letter" transform="matrix(1 0 -0.185 1 3.807 16.894)" fill="#ffffff"
d="M0.000,-11.283 L5.649,-11.283 A3.265,3.265 0 0 1 8.914,-8.018 A3.265,3.265 0 0 1 5.649,-4.753 L0.000,-4.753 Z M0.000,-6.538 L6.095,-6.538 A3.238,3.238 0 0 1 9.333,-3.300 L9.333,-3.238 A3.238,3.238 0 0 1 6.095,0.000 L0.000,0.000 Z M2.188,-9.498 L2.188,-6.538 L5.246,-6.538 A1.480,1.480 0 0 0 6.726,-8.018 A1.480,1.480 0 0 0 5.246,-9.498 Z M2.188,-4.753 L2.188,-1.785 L6.095,-1.785 A1.050,1.050 0 0 0 7.145,-2.835 L7.145,-3.704 A1.050,1.050 0 0 0 6.095,-4.753 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<!-- The X button of the menus' button keys (ui\shell\bitmaps\x_butn), redrawn: drawn
in the 32x32 bitmap's texels, where its art is (tools/button_assets.py). A tilted
blue oval, dark at the top and bright at the bottom, with a pale rim, and its
letter in white, upright from its baseline and slanted. -->
<defs>
<!-- (along the oval's long axis, top to bottom) -->
<linearGradient id="body" gradientUnits="userSpaceOnUse" x1="0" y1="-12.932" x2="0" y2="12.932">
<stop offset="0" stop-color="#0d1c3d"/>
<stop offset="0.5" stop-color="#08266c"/>
<stop offset="1" stop-color="#05349d"/>
</linearGradient>
<linearGradient id="rim" gradientUnits="userSpaceOnUse" x1="0" y1="-12.932" x2="0" y2="12.932">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.75"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.74"/>
</linearGradient>
</defs>
<g transform="translate(10.341 12.879) rotate(-26.459)">
<ellipse id="oval" rx="8.659" ry="12.932" fill="url(#body)"/>
<ellipse rx="8.082" ry="12.355" fill="none" stroke="url(#rim)" stroke-width="1.154"/>
</g>
<path id="letter" transform="matrix(1 0 -0.185 1 8.621 16.043)" fill="#ffffff"
d="M-4.981,-10.970 L-2.302,-10.970 L5.237,0.000 L2.558,0.000 Z M2.302,-10.970 L4.981,-10.970 L-2.558,0.000 L-5.237,0.000 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<!-- The Y button of the menus' button keys (ui\shell\bitmaps\y_butn), redrawn: drawn
in the 32x32 bitmap's texels, where its art is (tools/button_assets.py). A tilted
yellow oval, dark at the top and bright at the bottom, with a pale rim, and its
letter in white, upright from its baseline and slanted,
casting a dark shadow down and to the right. -->
<defs>
<!-- (along the oval's long axis, top to bottom) -->
<linearGradient id="body" gradientUnits="userSpaceOnUse" x1="0" y1="-12.932" x2="0" y2="12.932">
<stop offset="0" stop-color="#504505"/>
<stop offset="0.5" stop-color="#bb7c03"/>
<stop offset="1" stop-color="#ffbc00"/>
</linearGradient>
<linearGradient id="rim" gradientUnits="userSpaceOnUse" x1="0" y1="-12.932" x2="0" y2="12.932">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.71"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.63"/>
</linearGradient>
</defs>
<g transform="translate(10.341 12.879) rotate(-26.459)">
<ellipse id="oval" rx="8.659" ry="12.932" fill="url(#body)"/>
<ellipse rx="8.082" ry="12.355" fill="none" stroke="url(#rim)" stroke-width="1.154"/>
</g>
<path id="shadow" transform="matrix(1 0 -0.185 1 10.387 16.978)" fill="#3c1e00" fill-opacity="0.60"
d="M-5.184,-11.167 L-2.927,-11.167 L1.047,-4.308 L-1.047,-4.308 Z M2.927,-11.167 L5.184,-11.167 L1.047,-4.308 L-1.047,-4.308 Z M-1.047,-4.908 L1.047,-4.908 L1.047,0.000 L-1.047,0.000 Z"/>
<path id="letter" transform="matrix(1 0 -0.185 1 8.887 15.778)" fill="#ffffff"
d="M-5.184,-11.167 L-2.927,-11.167 L1.047,-4.308 L-1.047,-4.308 Z M2.927,-11.167 L5.184,-11.167 L1.047,-4.308 L-1.047,-4.308 Z M-1.047,-4.908 L1.047,-4.908 L1.047,0.000 L-1.047,0.000 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 183 KiB

+243 -91
View File
@@ -1203,20 +1203,20 @@
66,
66
],
"svg": "hud__bitmaps__combined__hud_reticles__0.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_20.svg",
"source_scale": 1.32252,
"source": [
0,
0
-4.1329,
-4.1329
],
"clip": [
0,
0,
264,
264
78,
78
],
"kind": "alpha",
"score": 0.627
"score": 0.995
},
{
"xbox": [
@@ -1225,20 +1225,20 @@
18,
76
],
"svg": "hud__bitmaps__combined__hud_reticles__0.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_30.svg",
"source_scale": 1.2325,
"source": [
0,
264
1.6947,
1.3866
],
"clip": [
0,
264,
72,
304
0,
26,
10
],
"kind": "alpha",
"score": 0.486
"score": 0.991
},
{
"xbox": [
@@ -1247,20 +1247,20 @@
94,
28
],
"svg": "hud__bitmaps__combined__hud_reticles__0.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_19.svg",
"source_scale": 1.35,
"source": [
264,
0
0.675,
0.675
],
"clip": [
264,
0,
376,
112
0,
38,
38
],
"kind": "alpha",
"score": 0.547
"score": 0.988
},
{
"xbox": [
@@ -1269,20 +1269,20 @@
122,
28
],
"svg": "hud__bitmaps__combined__hud_reticles__0.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_28.svg",
"source_scale": 1.32812,
"source": [
376,
0
-11.2891,
-11.2891
],
"clip": [
376,
0,
488,
112
0,
14,
14
],
"kind": "alpha",
"score": 0.548
"score": 1.0
}
]
},
@@ -1303,20 +1303,20 @@
64,
64
],
"svg": "hud__bitmaps__combined__hud_reticles__1.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_17.svg",
"source_scale": 1.35947,
"source": [
0,
0
-10.7058,
-12.915
],
"clip": [
0,
0,
256,
256
67,
51
],
"kind": "alpha",
"score": 0.741
"score": 0.998
}
]
},
@@ -1371,20 +1371,20 @@
64,
64
],
"svg": "hud__bitmaps__combined__hud_reticles__3.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_06.svg",
"source_scale": 1.33099,
"source": [
2,
1
-5.324,
-2.9947
],
"clip": [
0,
0,
256,
256
74,
65
],
"kind": "alpha",
"score": 0.752
"score": 0.987
}
]
},
@@ -1405,20 +1405,20 @@
64,
64
],
"svg": "hud__bitmaps__combined__hud_reticles__4.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_07.svg",
"source_scale": 1.33636,
"source": [
-2,
-2
-11.3591,
-11.5261
],
"clip": [
0,
0,
256,
256
64,
64
],
"kind": "alpha",
"score": 0.753
"score": 0.996
}
]
},
@@ -1439,20 +1439,20 @@
64,
64
],
"svg": "hud__bitmaps__combined__hud_reticles__5.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_14.svg",
"source_scale": 1.3687,
"source": [
0,
0
-4.7905,
-6.1592
],
"clip": [
0,
0,
256,
256
77,
77
],
"kind": "alpha",
"score": 0.446
"score": 0.998
}
]
},
@@ -1473,20 +1473,20 @@
64,
64
],
"svg": "hud__bitmaps__combined__hud_reticles__6.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_08.svg",
"source_scale": 1.35078,
"source": [
0,
0
-3.7146,
-30.0549
],
"clip": [
0,
0,
256,
256
80,
28
],
"kind": "alpha",
"score": 0.68
"score": 0.985
}
]
},
@@ -1507,20 +1507,20 @@
64,
64
],
"svg": "hud__bitmaps__combined__hud_reticles__8.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_23.svg",
"source_scale": 1.33389,
"source": [
0,
0
-3.3347,
-3.3347
],
"clip": [
0,
0,
256,
256
78,
78
],
"kind": "alpha",
"score": 0.46
"score": 0.992
}
]
},
@@ -1575,20 +1575,20 @@
64,
64
],
"svg": "hud__bitmaps__combined__hud_reticles__10.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_25.svg",
"source_scale": 1.37061,
"source": [
1,
0
-7.881,
-3.4265
],
"clip": [
0,
0,
256,
256
71,
79
],
"kind": "alpha",
"score": 0.763
"score": 0.975
}
]
},
@@ -1609,20 +1609,54 @@
64,
64
],
"svg": "hud__bitmaps__combined__hud_reticles__11.svg",
"source_scale": 4,
"svg": "hud2_left_reticles__reticle_16.svg",
"source_scale": 1.36027,
"source": [
1,
-1
-3.4007,
-11.5623
],
"clip": [
0,
0,
256,
256
80,
49
],
"kind": "alpha",
"score": 0.69
"score": 0.996
}
]
},
{
"name": "hud_reticles__7",
"tag": "ui\\hud\\bitmaps\\combined\\hud_reticles",
"bitmap": 7,
"width": 64,
"height": 64,
"format": "ay8",
"scale": 8,
"crc": 1857742423,
"cells": [
{
"xbox": [
0,
0,
64,
64
],
"svg": "hud2_left_reticles__reticle_27.svg",
"source_scale": 1.34157,
"source": [
1.8447,
1.0062
],
"clip": [
0,
0,
89,
89
],
"kind": "alpha",
"score": 0.97
}
]
},
@@ -1928,7 +1962,17 @@
136
],
"kind": "meter",
"score": 0.737
"score": 0.737,
"thresholds": [
30,
60,
90,
120,
150,
180,
210,
240
]
},
{
"xbox": [
@@ -1972,7 +2016,17 @@
192
],
"kind": "meter",
"score": 0.728
"score": 0.728,
"thresholds": [
30,
60,
90,
120,
150,
180,
210,
240
]
},
{
"xbox": [
@@ -2016,7 +2070,17 @@
316
],
"kind": "meter",
"score": 0.738
"score": 0.738,
"thresholds": [
30,
60,
90,
120,
150,
180,
210,
240
]
},
{
"xbox": [
@@ -2038,7 +2102,17 @@
316
],
"kind": "meter",
"score": 0.738
"score": 0.738,
"thresholds": [
30,
60,
90,
120,
150,
180,
210,
240
]
},
{
"xbox": [
@@ -3102,6 +3176,84 @@
}
]
},
{
"name": "sniper__hud_reticles_scope__0",
"tag": "ui\\hud\\bitmaps\\sniper\\hud_reticles_scope",
"bitmap": 0,
"width": 64,
"height": 64,
"format": "a8y8",
"scale": 8,
"crc": 2373183232,
"cells": [
{
"xbox": [
0,
0,
31,
24
],
"svg": "label__Overpass-900__2x.svg",
"source_scale": 5.20673,
"source": [
-22.7794,
-9.7626
],
"clip": [
0,
0,
117,
127
],
"kind": "alpha",
"score": 0.972
},
{
"xbox": [
0,
24,
43,
48
],
"svg": "label__Overpass-900__10x.svg",
"source_scale": 5.28894,
"source": [
-36.3615,
-11.239
],
"clip": [
0,
0,
171,
127
],
"kind": "alpha",
"score": 0.937
},
{
"xbox": [
32,
0,
50,
18
],
"svg": "hud2_left_reticles__reticle_18.svg",
"source_scale": 1.365,
"source": [
-3.5831,
-3.5831
],
"clip": [
0,
0,
16,
16
],
"kind": "alpha",
"score": 0.991
}
]
},
{
"name": "sniper_rifle__angle_ticks__0",
"tag": "weapons\\sniper rifle\\bitmaps\\angle_ticks",
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,12 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="73.1723" height="64.4224" viewBox="166.3910 62.5345 73.1723 64.4224">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path335" d="m 149.25049,-1106.897 c 0,0 3.00195,15.8833 3.00195,15.8833 0,0 2.96289,-15.8833 2.96289,-15.8833 0,0 -5.96484,0 -5.96484,0 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path336" d="m 129.52393,-1061.9141 c 0,0 10.79296,-9.9389 10.79296,-9.9389 0,0 -14.02343,4.9262 -14.02343,4.9262 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path337" d="m 174.94189,-1061.5806 c 0,0 -10.95898,-10.1054 -10.95898,-10.1054 0,0 14.18945,5.0927 14.18945,5.0927 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,13 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="63.9728" height="63.7551" viewBox="172.5590 176.9850 63.9728 63.7551">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path331" d="m 135.70264,-976.24268 c 0,0 8.04687,-13.08007 8.04687,-13.08007 0,0 -12.83008,8.42334 -12.83008,8.42334 0,0 4.78321,4.65673 4.78321,4.65673 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path332" d="m 135.70264,-1021.0591 c 0,0 8.04687,13.0801 8.04687,13.0801 0,0 -12.83008,-8.4233 -12.83008,-8.4233 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path333" d="m 171.11572,-976.24268 c 0,0 -8.04687,-13.08007 -8.04687,-13.08007 0,0 12.83008,8.42334 12.83008,8.42334 0,0 -4.78321,4.65673 -4.78321,4.65673 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path334" d="m 171.11572,-1021.0591 c 0,0 -8.04687,13.0801 -8.04687,13.0801 0,0 12.83008,-8.4233 12.83008,-8.4233 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,13 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="79.0222" height="27.8132" viewBox="175.7510 449.5130 79.0222 27.8132">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path317" d="m 143.15186,-807.7334 c 0,0 -2.82813,2.55274 -2.82813,2.55274 0,0 0,-5.10498 0,-5.10498 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path318" d="m 143.15186,-798.80322 c 0,0 -7.19922,-3.13379 -7.19922,-8.93018 0,-5.79687 7.19922,-8.92969 7.19922,-8.92969 0,0 -9.83887,0.93897 -9.83887,8.45948 0,7.52002 9.83887,9.40039 9.83887,9.40039 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path319" d="m 179.7417,-807.7334 c 0,0 2.82812,2.55274 2.82812,2.55274 0,0 0,-5.10498 0,-5.10498 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path320" d="m 179.7417,-798.80322 c 0,0 7.19824,-3.13379 7.19824,-8.93018 0,-5.79687 -7.19824,-8.92969 -7.19824,-8.92969 0,0 9.83789,0.93897 9.83789,8.45948 0,7.52002 -9.83789,9.40039 -9.83789,9.40039 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,33 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="76.5678" height="76.5678" viewBox="171.6560 1155.4900 76.5678 76.5678">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path338" d="m 157.54199,-287.18066 c 0,0 0,5.92968 0,5.92968" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path339" d="m 138.27441,-279.27246 c 0,0 4.19434,4.19336 4.19434,4.19336" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path340" d="m 130.24219,-260.05566 c 0,0 5.93066,-9.8e-4 5.93066,-9.8e-4" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path341" d="m 138.15234,-240.78809 c 0,0 4.19336,-4.19531 4.19336,-4.19531" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path342" d="m 157.36914,-232.75684 c 0,0 -9.8e-4,-5.93066 -9.8e-4,-5.93066" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path343" d="m 176.63672,-240.66602 c 0,0 -4.19434,-4.19238 -4.19434,-4.19238" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path344" d="m 184.66797,-259.88184 c 0,0 -5.93164,0 -5.93164,0" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path345" d="m 176.75879,-279.14941 c 0,0 -4.19336,4.19336 -4.19336,4.19336" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path346" d="m 164.34863,-286.29395 c 0,0 -1.48437,5.74219 -1.48437,5.74219" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path347" d="m 143.71484,-283.45801 c 0,0 3.00977,5.10938 3.00977,5.10938" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path348" d="m 131.12988,-266.8623 c 0,0 5.74219,1.48437 5.74219,1.48437" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path349" d="m 133.96777,-246.22754 c 0,0 5.10938,-3.01172 5.10938,-3.01172" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path350" d="m 150.56445,-233.64355 c 0,0 1.48243,-5.74317 1.48243,-5.74317" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path351" d="m 171.19629,-236.48145 c 0,0 -3.00977,-5.10839 -3.00977,-5.10839" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path352" d="m 183.78125,-253.07813 c 0,0 -5.74219,-1.48242 -5.74219,-1.48242" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path353" d="m 180.94336,-273.71191 c 0,0 -5.10742,3.01269 -5.10742,3.01269" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path354" d="m 170.71387,-283.7334 c 0,0 -2.87305,5.18848 -2.87305,5.18848" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path355" d="m 150.02734,-286.14746 c 0,0 1.63868,5.69922 1.63868,5.69922" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path356" d="m 133.69043,-273.22754 c 0,0 5.19043,2.87305 5.19043,2.87305" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path357" d="m 131.27734,-252.54004 c 0,0 5.7002,-1.63769 5.7002,-1.63769" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path358" d="m 144.19824,-236.2041 c 0,0 2.87207,-5.18945 2.87207,-5.18945" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path359" d="m 164.88574,-233.79004 c 0,0 -1.63769,-5.69922 -1.63769,-5.69922" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path360" d="m 181.21973,-246.71191 c 0,0 -5.18946,-2.8711 -5.18946,-2.8711" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path361" d="m 183.63477,-267.39941 c 0,0 -5.69922,1.63867 -5.69922,1.63867" style="fill:none;stroke:#2896ff;stroke-width:1.0211;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="79.7540" height="48.8438" viewBox="177.7120 1414.4300 79.7540 48.8438">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path321" d="m 180.94434,-59.33984 c 0,0 7.79687,-3.13379 7.79687,-8.93067 0,-5.79687 -7.79687,-8.92969 -7.79687,-8.92969 0,0 10.65527,0.93946 10.65527,8.45899 0,7.52148 -10.65527,9.40137 -10.65527,9.40137 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path322" d="m 145.44141,-59.33984 c 0,0 -7.79786,-3.13379 -7.79786,-8.93067 0,-5.79687 7.79786,-8.92969 7.79786,-8.92969 0,0 -10.65723,0.93946 -10.65723,8.45899 0,7.52148 10.65723,9.40137 10.65723,9.40137 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path323" d="m 158.28809,-68.30371 c 0,2.89941 -2.34961,5.25 -5.24903,5.25 -2.90039,0 -5.25,-2.35059 -5.25,-5.25 0,-2.89844 2.34961,-5.24902 5.25,-5.24902 2.89942,0 5.24903,2.35058 5.24903,5.24902 z" style="fill:none;stroke:#2896ff;stroke-width:2.0421;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path324" d="m 178.45605,-68.30371 c 0,2.89941 -2.35156,5.25 -5.25097,5.25 -2.89844,0 -5.24903,-2.35059 -5.24903,-5.25 0,-2.89844 2.35059,-5.24902 5.24903,-5.24902 2.89941,0 5.25097,2.35058 5.25097,5.24902 z" style="fill:none;stroke:#2896ff;stroke-width:2.0421;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path325" d="m 160.23242,-92.97168 c 0,0 3,13.38281 3,13.38281 0,0 2.96387,-13.38281 2.96387,-13.38281 0,0 -5.96387,0 -5.96387,0 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="66.0606" height="50.1986" viewBox="184.7380 1492.2000 66.0606 50.1986">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path326" d="m 158.44873,-9.97949 c 0,2.89941 -2.34961,5.25 -5.24902,5.25 -2.89942,0 -5.24903,-2.35059 -5.24903,-5.25 0,-2.89942 2.34961,-5.25 5.24903,-5.25 2.89941,0 5.24902,2.35058 5.24902,5.25 z" style="fill:none;stroke:#2896ff;stroke-width:2.0421;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path327" d="m 178.6167,-9.97949 c 0,2.89941 -2.35059,5.25 -5.25,5.25 -2.89844,0 -5.24902,-2.35059 -5.24902,-5.25 0,-2.89942 2.35058,-5.25 5.24902,-5.25 2.89941,0 5.25,2.35058 5.25,5.25 z" style="fill:none;stroke:#2896ff;stroke-width:2.0421;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path328" d="m 160.39307,-34.64844 c 0,0 3.00097,13.38281 3.00097,13.38281 0,0 2.96387,-13.38281 2.96387,-13.38281 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path329" d="m 143.28369,0 c 0,0 9.625,-9.77148 9.625,-9.77148 0,0 -12.85547,4.75976 -12.85547,4.75976 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path330" d="m 183.36865,0 c 0,0 -9.625,-9.77148 -9.625,-9.77148 0,0 12.85547,4.75976 12.85547,4.75976 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,11 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="16.0000" height="16.0000" viewBox="360.3170 1.4408 16.0000 16.0000">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path1013" d="m 276.70654,-1148.2173 c 0,0.2588 -0.20996,0.4688 -0.46875,0.4688 -0.25879,0 -0.46875,-0.21 -0.46875,-0.4688 0,-0.2588 0.20996,-0.4687 0.46875,-0.4687 0.25879,0 0.46875,0.2099 0.46875,0.4687 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path1014" d="m 280.23779,-1148.2173 c 0,2.209 -1.79101,4 -4,4 -2.20898,0 -4,-1.791 -4,-4 0,-2.209 1.79102,-4 4,-4 2.20899,0 4,1.791 4,4 z" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,14 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="37.7787" height="37.7787" viewBox="346.7610 78.3236 37.7787 37.7787">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path372" d="m 286.40479,-1082.3882 c 0,6.7197 -5.44727,12.167 -12.167,12.167 -6.71972,0 -12.16699,-5.4473 -12.16699,-12.167 0,-6.7197 5.44727,-12.167 12.16699,-12.167 6.71973,0 12.167,5.4473 12.167,12.167 z" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path373" d="m 274.23779,-1094.5552 c 0,0 0,3.166 0,3.166" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path374" d="m 262.0708,-1082.3882 c 0,0 3.16602,0 3.16602,0" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path375" d="m 274.23779,-1070.2212 c 0,0 0,-5.5 0,-5.5" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path376" d="m 286.40479,-1082.3882 c 0,0 -3.16602,0 -3.16602,0" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,17 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="77.6326" height="77.6316" viewBox="328.8340 163.9380 77.6326 77.6316">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path383" d="m 275.73779,-1025.4902 c 0,0 0,-5.3545 0,-5.3545" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path384" d="m 253.48096,-1003.2329 c 0,0 -5.35547,0 -5.35547,0" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path385" d="m 275.73779,-980.97607 c 0,0 0,5.35546 0,5.35546" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path386" d="m 297.99463,-1003.2329 c 0,0 5.35547,0 5.35547,0" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path387" d="m 294.21533,-1016.9233 c 0,0 0,2.7402 0,2.7402 1.91113,3.2124 3.0293,6.9487 3.0293,10.9502 0,4.00145 -1.11817,7.73778 -3.0293,10.95019 0,0 0,2.74023 0,2.74023 2.84375,-3.82813 4.5293,-8.56592 4.5293,-13.69042 0,-5.1245 -1.68555,-9.8623 -4.5293,-13.6904 z" style="fill:#2896ff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path388" d="m 265.1333,-1021.9209 c 3.13281,-1.7851 6.74707,-2.8193 10.60449,-2.8193 3.85742,0 7.47168,1.0342 10.6045,2.8193 0,0 2.79003,0 2.79003,0 -3.77539,-2.7144 -8.39941,-4.3193 -13.39453,-4.3193 -4.99511,0 -9.61914,1.6049 -13.39453,4.3193 z" style="fill:#2896ff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path389" d="m 286.85986,-984.85986 c -3.25,1.97509 -7.04882,3.13427 -11.12207,3.13427 -4.07324,0 -7.87207,-1.15918 -11.12207,-3.13427 0,0 -2.70801,0 -2.70801,0 3.85254,2.90722 8.64356,4.63427 13.83008,4.63427 5.18653,0 9.97754,-1.72705 13.83008,-4.63427 z" style="fill:#2896ff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path390" d="m 257.25928,-992.28467 c -1.91016,-3.21191 -3.02832,-6.94726 -3.02832,-10.94823 0,-4.001 1.11816,-7.7363 3.02832,-10.9482 0,0 0,-2.7408 0,-2.7408 -2.84278,3.8277 -4.52832,8.565 -4.52832,13.689 0,5.12401 1.68554,9.86132 4.52832,13.68895 0,0 0,-2.74072 0,-2.74072 z" style="fill:#2896ff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,10 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="77.4870" height="77.4870" viewBox="334.4060 420.4670 77.4870 77.4870">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path363" d="m 253.50391,-810.89209 c 0,-14.5332 11.82422,-26.35645 26.35839,-26.35645 14.53418,0 26.3584,11.82325 26.3584,26.35645 0,14.53516 -11.82422,26.36035 -26.3584,26.36035 -14.53417,0 -26.35839,-11.82519 -26.35839,-26.36035 z m -1.19922,0 c 0,15.19629 12.3623,27.55957 27.55761,27.55957 15.19532,0 27.55762,-12.36328 27.55762,-27.55957 0,-15.19434 -12.3623,-27.55566 -27.55762,-27.55566 -15.19531,0 -27.55761,12.36132 -27.55761,27.55566 z" style="fill:#2896ff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="70.6050" height="78.0603" viewBox="329.0980 681.8930 70.6050 78.0603">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path301" d="m 248.32373,-635.7334 c 0,0 17.83301,11.30078 17.83301,11.30078 0,0 -11.34082,-17.79101 -11.34082,-17.79101 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path302" d="m 291.7876,-642.37793 c 0,0 -11.30371,17.83496 -11.30371,17.83496 0,0 17.79297,-11.34277 17.79297,-11.34277 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path303" d="m 278.30615,-586.83301 c 0,0 -4.61914,-20.60351 -4.61914,-20.60351 0,0 -4.56152,20.60351 -4.56152,20.60351 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path304" d="m 286.5708,-606.49707 c 0,0 -5.89062,-3.7334 -5.89062,-3.7334 0,0 3.74707,5.87793 3.74707,5.87793 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path305" d="m 262.83643,-604.30176 c 0,0 3.73437,-5.8916 3.73437,-5.8916 0,0 -5.87793,3.74707 -5.87793,3.74707 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path306" d="m 271.896,-634.00098 c 0,0 1.52636,6.80664 1.52636,6.80664 0,0 1.50684,-6.80664 1.50684,-6.80664 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path307" d="m 290.44873,-618.89941 c 0,0 -6.80273,1.52539 -6.80273,1.52539 0,0 6.80468,1.50781 6.80468,1.50781 0,0 -0.002,-3.0332 -0.002,-3.0332 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path308" d="m 256.92139,-615.73633 c 0,0 6.80468,-1.52539 6.80468,-1.52539 0,0 -6.80468,-1.50879 -6.80468,-1.50879 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path309" d="m 281.32568,-602.29395 c 0,0 -3.97949,-5.72558 -3.97949,-5.72558 0,0 1.17188,6.87012 1.17188,6.87012 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path310" d="m 278.47314,-633.33691 c 0,0 -1.23437,6.86523 -1.23437,6.86523 0,0 4.03027,-5.68848 4.03027,-5.68848 0,0 -2.7959,-1.17675 -2.7959,-1.17675 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path311" d="m 257.58447,-622.31348 c 0,0 6.86524,1.23438 6.86524,1.23438 0,0 -5.68848,-4.0293 -5.68848,-4.0293 0,0 -1.17676,2.79492 -1.17676,2.79492 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path312" d="m 289.74463,-612.31543 c 0,0 -6.87012,-1.19336 -6.87012,-1.19336 0,0 5.71192,3.99609 5.71192,3.99609 0,0 1.1582,-2.80273 1.1582,-2.80273 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path313" d="m 268.66064,-601.20215 c 0,0 1.19239,-6.87012 1.19239,-6.87012 0,0 -3.99512,5.71192 -3.99512,5.71192 0,0 2.80273,1.1582 2.80273,1.1582 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path314" d="m 265.65771,-632.10547 c 0,0 4.01661,5.70313 4.01661,5.70313 0,0 -1.21387,-6.86329 -1.21387,-6.86329 0,0 -2.80274,1.16016 -2.80274,1.16016 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path315" d="m 288.58057,-625.25684 c 0,0 -5.70118,4.01465 -5.70118,4.01465 0,0 6.86231,-1.21386 6.86231,-1.21386 0,0 -1.16113,-2.80079 -1.16113,-2.80079 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path316" d="m 258.81592,-609.49805 c 0,0 5.70312,-4.01562 5.70312,-4.01562 0,0 -6.8623,1.21387 -6.8623,1.21387 z" style="fill:#2896ff;fill-opacity:1;fill-rule:evenodd;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,29 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="88.2461" height="88.8105" viewBox="324.0030 932.7860 88.2461 88.8105">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path391" d="m 245.17725,-422.25293 c 0,0 5.54394,0 5.54394,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path392" d="m 301.76807,-422.25293 c 0,0 5.54394,0 5.54394,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path393" d="m 255.69287,-422.25293 c 0,0 7.07324,0 7.07324,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path394" d="m 289.6626,-422.25293 c 0,0 7.07324,0 7.07324,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path395" d="m 269.76514,-422.25293 c 0,0 13.0498,0 13.0498,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path396" d="m 269.76514,-411.20703 c 0,0 13.0498,0 13.0498,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path397" d="m 269.76514,-432.23926 c 0,0 13.0498,0 13.0498,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path398" d="m 269.76514,-400.2373 c 0,0 13.0498,0 13.0498,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path399" d="m 269.76514,-443.28613 c 0,0 13.0498,0 13.0498,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path400" d="m 282.46436,-452.32617 c 0,0 0,-1.23145 0,-1.23145 -2.05762,-0.42578 -4.18848,-0.65039 -6.36915,-0.65039 -2.18066,0 -4.31152,0.22461 -6.36914,0.65039 0,0 0,1.23145 0,1.23145 2.05469,-0.44238 4.18457,-0.68067 6.36914,-0.68067 2.18458,0 4.31446,0.23829 6.36915,0.68067 z" style="fill:#2896ff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path401" d="m 244.50244,-422.40527 c 0,17.53711 14.17188,31.80468 31.59277,31.80468 17.41993,0 31.5918,-14.26757 31.5918,-31.80468 0,-11.54493 -6.14355,-21.66993 -15.31152,-27.24317 0,0 0,1.42188 0,1.42188 8.47461,5.43652 14.1123,14.97558 14.1123,25.82129 0,16.87597 -13.63379,30.60547 -30.39258,30.60547 -16.75878,0 -30.39355,-13.7295 -30.39355,-30.60547" style="fill:#2896ff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path402" d="m 245.70166,-422.40527 c 0,-10.84571 5.6377,-20.38477 14.11328,-25.82227 0,0 0,-1.42187 0,-1.42187 -9.16894,5.57324 -15.3125,15.69921 -15.3125,27.24414" style="fill:#2896ff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path403" d="m 261.229,-453.73926 c 0,0 0,2.5 0,2.5 0,0 2,0 2,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path404" d="m 263.229,-453.73926 c 0,0 0,3.9375 0,3.9375" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path405" d="m 267.2915,-450.23926 c 0,0 -2,0 -2,0 0,0 0,-3 0,-3 0,0 2,0 2,0 z" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path406" d="m 289.2915,-453.70801 c 0,0 0,3.5 0,3.5 0,0 2.5,0 2.5,0" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path407" d="m 285.2915,-453.73926 c 0,0 0,4.04688 0,4.04688" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path408" d="m 287.2915,-453.72363 c 0,0 0,2.01562 0,2.01562" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path409" d="m 287.2915,-450.72363 c 0,0 0,1.01562 0,1.01562" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path410" d="m 286.27588,-451.70801 c 0,0 0,1.01563 0,1.01563" style="fill:none;stroke:#2896ff;stroke-width:1.0144;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -0,0 +1,15 @@
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="13.2811" height="13.3171" viewBox="364.3760 1250.6000 13.2811 13.3171">
<defs id="defs1">
<clipPath id="clipLeftOfCut" clipPathUnits="userSpaceOnUse"><rect id="rectLeftOfCut" x="0" y="0" width="525.1332" height="1540.3972"/></clipPath></defs>
<g id="leftOfCut" clip-path="url(#clipLeftOfCut)"><g id="g1" transform="translate(866.3776,223.15626)">
<g id="group-MC1">
<path id="path377" d="m 274.78223,-214.33887 c 0,0 1.50879,0 1.50879,0 0,0 0,-1.50781 0,-1.50781" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path378" d="m 280.23535,-215.84668 c 0,0 0,1.50879 0,1.50879 0,0 1.50781,0 1.50781,0" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path379" d="m 281.74316,-210.36523 c 0,0 -1.50781,0 -1.50781,0 0,0 0,1.50781 0,1.50781" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path380" d="m 276.29004,-208.85742 c 0,0 0,-1.50781 0,-1.50781 0,0 -1.50781,0 -1.50781,0" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path381" d="m 278.2334,-213.8457 c 0,0 0,2.9873 0,2.9873" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
<path id="path382" d="m 276.7832,-212.36719 c 0,0 2.95899,0 2.95899,0" style="fill:none;stroke:#2896ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" transform="matrix(1.3333333,0,0,1.3333333,-866.3776,1317.2409)"/>
</g>
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Loaded 100 of 874 files, more files were not shown because too many files have changed in this diff. Show more