Skip to content

Custom Edition maps a host does not send downloaded from the community's archives - #335

Open
FractumSeraph wants to merge 4 commits into
OpenCommunityEdition:mainfrom
FractumSeraph:map-downloads
Open

FractumSeraph wants to merge 4 commits into
OpenCommunityEdition:mainfrom
FractumSeraph:map-downloads

Conversation

@FractumSeraph

@FractumSeraph FractumSeraph commented Oct 13, 2026 •

Copy link
Copy Markdown

Custom Edition maps that a host does not send are now downloaded from the community's archives (HaloNet, then HaloMaps, then a web seed). Each file is checked against an index of the maps before the game uses it. Map sharing's downloads from the game's players also take pieces from the web seed, so a host with a slow upload doesn't hold its players up.

This builds on #333 and #334, and its branch contains their commits until they are merged; after that I'll rebase it so only its own two commits remain: "Custom Edition maps a host does not send downloaded from the community's archives" and "Map sharing takes pieces from the web seeds too". #333 gives a downloaded map its name (<name>_<hash>.map, which the game finds without renaming), and #334 gives the download screen its drawing.

This replaces #315, which added a BitTorrent client and reverted map sharing, and no longer merged with main (the web build among it). This one is built on main as it is, network version 28, and keeps map sharing as it is. It has no BitTorrent client and changes nothing on the wire.

When it downloads

Map sharing downloads a missing map from the game's players. When it cannot, the game downloads the map instead:

  • the host declines (SHARE MAPS off);
  • the level is named without its hash (a host of another build);
  • a game in the games list is joined without its map.

A map comes from the zip on HaloNet's repository or on HaloMaps (maps.archives), taken out of the zip, and otherwise from a web seed (maps.web_seeds).

Every file is checked against the index (port/assets/network/map_index.txt: 5,829 maps plus the three resource maps) by the SHA-1 info hash of its single-file torrent. A file that fails is thrown away and the next source is tried.

Halo's loading screen covers the menus while a map downloads, and B stops the download. From the games list, the join goes ahead by itself once the map is there. When the host declined, the player is told to join again.

Details are in docs/map_downloads.md.

Web seeds for map sharing

When map sharing downloads a map from the game's players, the web seed sends pieces of it too:

  • It is used when the index has the file at the size the host offered: the map by its name and the host's header checksum, a resource map by its name.
  • It is asked for 16 pieces (4 MiB) at a time, pieces no player has been asked for, with an HTTP range request on a thread of its own (update_request_range on Linux and Windows).
  • Each piece is checked against its hash, as a player's piece is. A piece that isn't the file's ends the web seed for that file; three failed requests end it for the download, with a pause after each.
  • A range reply must be 206 for exactly that range (Content-Range) and its full length. A server that sends the whole file instead is refused unread.
  • No setup: by default it's the seed box's folder of every map in the index. It is off with maps.downloads = false or an empty maps.web_seeds.

Nothing of this goes over the game's connections, and players of other builds are unaffected.

No map file is ever replaced or renamed

  • A downloaded map goes into custom_maps as <name>_<digits>.map, the same name map sharing gives that version, beside any other version.
  • A resource map is written only when none is there.
  • On POSIX, the name is taken with O_EXCL before the rename.
  • When a host names its level without the hash and the player's <name>.map is another version, the client plays the version beside it whose header checksum is the host's.

Platforms

  • Linux and Windows download, using the self-updater's HTTPS. update_request adds a POST, a user agent and a stop flag to posix_update.c and win32_update.c, plus update_request_range; its download cap is 1 GiB (the largest map in the index is about 760 MB).
  • Android has no HTTPS in its platform layer, so it doesn't download from the archives or the web seed. Map sharing works as before.
  • The web build leaves the downloads out (port/web/src/web_map_downloads.c).

Network version

Unchanged (28). Nothing new goes over the game's connections.

The web seed and torrents

The default web seed, https://halomaps.fractumseraph.net/maps/, is my seed box's folder of every map in the index. The index's info hashes are those of the torrents it seeds, so anyone can seed the same files with any BitTorrent client. A BitTorrent source could be added later as another entry in maps.archives, without changing the index.

Checked

  • The whole branch (all three commits) was tested together; each commit also builds on its own.
  • Linux CI steps in Docker (tools/ci_build.py linux release): the test step's pytest list with tools/test_map_index.py and the new tools/test_map_download_check.py (335 passed; each commit also builds on its own), and test_light_storage.py.
  • Web CI steps in Docker (emsdk 6.0.10): ci_build.py web release, tools/test_web_build.py, node --test port/web/tests (16 passed).
  • Windows: ci_build.py windows release built with clang-cl and MSVC 2019's libraries. Then run on Windows:
    • A host with map sharing off and a client without the map. The map came from the web seed over WinHTTP, and in a second run from HaloMaps alone. It went in as ChaosGulch_7cac43f9.map and was played on the second join; the host's chaosgulch.map was unchanged.
    • A host sharing at 16 KB/s: 13.4 of the 13.9 MB came from the web seed in WinHTTP ranges, and the client played the map.
  • End to end on Linux in Docker, on the loopback, with OpenCE build-201 and a ChupathingyCE-based dedicated server (version 28, with plain level names when set to):
Case Result
This build hosts; build-201's client has no map Map sharing sends it under the hash name; the host's chaosgulch.map keeps its name and bytes
Build-201 hosts; this build's client has no map Map sharing sends it; the second join plays the downloaded copy
Host with SHARE MAPS off The client is told to join again; the map comes from the web seed (chaosgulch), and from HaloNet's zip (battlecity_lvl1) beside the client's own other version; each is the host's byte for byte, and plays on the second join
Server sends custom_maps\chaosgulch (no hash); the client has another version The host's version comes from HaloMaps's zip as ChaosGulch_7cac43f9.map; the second join plays it, chosen by header checksum; the client's own copy is unchanged
An index whose info hash is not the file's The download is refused and nothing is written
Server sends custom_maps\chaosgulch; the client has two other versions named for their hashes and no chaosgulch.map The host's version comes from the web seed as ChaosGulch_7cac43f9.map; the second join plays it, chosen by header checksum; the client's two files are unchanged
Map sharing with the host's upload limited to 16 KB/s 13.4 of the 13.9 MB came from the web seed; the map is the host's byte for byte, and the client played
Map sharing with a web seed that answers 404 Three failed requests, then the web seed is left alone; the host sent the map
Map sharing with maps.downloads off No web seed asked; the host sent the map
A second run of a host map_hashes.txt is used and nothing is hashed again
Loading screen, recorded on a virtual display The light sweeps over the Xbox picture as paradis_perdu (126 MB) arrives from the web seed, with "from the web seed", the amount counting up, and B=STOP

No map file was renamed or changed by this build in any run.

Found along the way and left alone (they are in build-201 too): with debug.network_test, a client that joins a lobby game whose host starts it mid-download stops responding (so the two map sharing rows above use debug.network_test_start to give the download time); and the Windows build does not exit at debug.exit_after.

🤖 Generated with Claude Code

FractumSeraph and others added 4 commits October 13, 2026 08:33
A Custom Edition map's level is named for its hash
(custom_maps\<name>_<8 digits>), so that two versions of a map have two
names and a game always loads the host's. Until now the map file in
custom_maps was renamed to that name. A player's map files are theirs, and
other programs use them by name (Halo Custom Edition, map managers, a
server's playlists), so the file now keeps its name, and the game
remembers its hash instead:

- map_share_files.c remembers the digits of each map it hashed by its
  folder, name, size and time, and keeps them in the save root
  (z:\map_hashes.txt), so that a map is hashed once until its file
  changes, as when it was renamed. map_share_name_digits gives a file's
  digits, hashing it then if it was not hashed yet.
- A host's level is named custom_maps\<name>_<digits> as before
  (map_share_level_name_hash): nothing on the wire changes, and network
  version 28's builds (build-201) play with it either way.
- custom_edition_map_path finds <name>.map for <name>_<digits> when that
  file's digits are those. Maps renamed by earlier builds keep working. A
  name too long to keep whole with the digits is cut short to fit, as the
  renaming did, and finds the file whose name starts so and whose digits
  they are.
- The maps of a Custom Edition install's folder are hashed in the
  background too (they are still not shared), so that a game on one does
  not hash it while joining. The digits are kept only if the file did not
  change while it was hashed, map_hashes.txt is written once the hashing is
  done, and it holds as many maps as the map lists.
- The menus sort the maps by their names without the hash, so that two
  versions of a map are always told apart.

Checked: the Linux CI steps in Docker; the web build and its tests; the
Windows release build. End to end on Linux in Docker, on the loopback:
this build hosts chaosgulch (its file not renamed) and build-201's client
downloads it by map sharing and plays; build-201 hosts and this build's
client does the same, and plays the downloaded copy on a second join; a
ChupathingyCE-based dedicated server (version 28, hash names) sends it to
this build's client; a second run of a host hashes nothing again
(map_hashes.txt). No map file of this build's changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loading screen (interface/progress_bar.c: a light sweeping from left to
right over a picture, with a glowing trail) drew nothing: it uses the
fixed-function pipeline (SetVertexShader with 0), which d3d8_gl.c did not
have, and its picture, loading.tga, is on no retail disc.

- d3d8_gl.c draws fixed-function immediate-mode draws with a vertex shader
  of its own (nv2a_fixed_function_glsl), positions transformed by the
  world, view and projection matrices; and keeps a front buffer (a 640x480
  render target taking the middle of the frame at BlockUntilVerticalBlank)
  for GetBackBuffer with -1, the frame before, whose blur makes the light's
  trail. The screen is drawn in the middle 640 columns, as the menus are on
  a wide screen.
- After it, the rasterizer's caches of the device's states and of its
  vertex shader are made the device's again (rasterizer_forget_hardware_state,
  rasterizer_vertex_shader_runtime_forget): it sets them itself; and
  SelectVertexShader (a packed program) always leaves the fixed-function
  pipeline.
- On OpenGL ES (Android, the web) the fixed-function shader turns
  Direct3D's clip space as the others do (HALO_GLES); without OpenGL
  (debug.null_renderer) it is not compiled.
- The picture is the Xbox's own: the retail default.xbe holds it (320 by
  240, runs of 4-bit greys after its width and height), and importing a disc
  image now writes it into the maps folder as loading.tga: port/linux/src/
  xiso.c, the Android app's XisoExtractor.java and the web page's
  xiso-worker.js. Without one (a maps folder copied by hand) the light
  sweeps over clouds the game makes. No game data is in the repository.
- progress_bar.c also has a mode a download screen over the menus can use
  (a share given, from 0, drawn over the menus, and let go of).
- A picture that cannot be written whole is not left cut short (the
  loading screen would read past it).

Checked: port/web/tests/xiso.test.js takes the picture out of made-up
executables (and an image's default.xbe); the Java decoder gives the same
bytes as the JavaScript one; xiso.c's picture from a Rev 2 disc image
matched a separate decoder's byte for byte; the Linux CI steps and the
web build and its tests in Docker; the Windows release build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y's archives

Map sharing downloads a missing map from the game's players. When the host
does not send it (SHARE MAPS off, a build that names its maps without
their hash, or a game seen in the games list before joining), the game now
downloads it from the zips its authors published, on HaloNet's map
repository and HaloMaps, else from a web seed, and checks every byte
against an index of the maps before it uses it. Design and details in
docs/map_downloads.md.

- The index (port/assets/network/map_index.txt, tools/map_index.py):
  5,829 maps and the three resource maps, by file name and header checksum
  (the map version the host already sends), with each one's size and the
  info hash of its single-file torrent, and for 5,826 of them the
  archives' zips that hold it. The builds put it next to the game.
- The archives (port/linux/src/map_archives.c, maps.archives): HaloNet's
  zip by its URL, HaloMaps's as its Download button fetches it, then the
  web seeds (maps.web_seeds), over HTTPS with the self-updater's download
  (update_request in update.h: a POST, a user agent and a stop flag added
  to posix_update.c and win32_update.c). The map is taken out of its zip
  (map_archives_zip.c: every offset checked, and its CRC), and a file is
  used only once its info hash is the index's (map_archives_verify, with
  map_sha1.c); one that is not is thrown away and the next source tried.
- The downloads (port/linux/src/map_downloads.c, maps.downloads): one file
  at a time, polled each frame from main.c's loop. A map goes into
  custom_maps as <name>_<digits>.map, the name map sharing gives that
  version, beside any other version; a resource map keeps its name and is
  written only when none is there. No file is ever replaced or renamed
  (the name is taken with O_EXCL before the rename on POSIX).
- The hooks: a host declining map sharing's request (map_share.c,
  download_offered) and a level map sharing cannot download
  (cache_files_map_present) start a download, and the player joins again
  once it is there; joining a game from the games list whose map is
  missing waits for its download and joins by itself (menu_functions.c).
  custom_edition_cache_files_missing says which files are missing.
- A host's level named custom_maps\<name> alone, whose version is not the
  player's <name>.map, plays the version beside it whose header checksum is
  the host's (custom_edition_cache.c; custom_edition_map_version_forget
  once the player leaves).
- The download screen (port/linux/game/download_screen.c,
  maps.download_screen): Halo's loading screen over the menus at the
  download's share, the map's name over it, B to stop it; the menus go on
  behind it. With it off, the games list's status line shows the download.
- tools/map_download_check.c runs the unzip and the check alone (ninja
  linux builds it): tools/test_map_download_check.py.
- The web build leaves the downloads out (port/web/src/web_map_downloads.c),
  and Android has no HTTPS of its platform layer: neither downloads.

Nothing goes over the game's connections: no network version of its own.

Checked:
- The Linux CI steps in Docker (tools/ci_build.py linux release): the
  test step's pytest list, with tools/test_map_index.py and the new
  tools/test_map_download_check.py (335 passed), and test_light_storage.
- The web CI steps in Docker (emsdk 6.0.10): ci_build.py web release and
  tools/test_web_build.py; node --test port/web/tests.
- The Windows release built with clang-cl and MSVC 2019's libraries
  (ci_build.py windows release), and run on Windows: a host with map
  sharing off and a client without the map on the loopback; the map came
  from the web seed (WinHTTP), and again from HaloMaps alone, was the
  index's, went in as ChaosGulch_7cac43f9.map and was played on the second
  join; the host's chaosgulch.map unchanged.
- End to end on Linux in Docker, two or three machines on the loopback,
  with OpenCE build-201 and a ChupathingyCE-based dedicated server
  (version 28, plain level names when set to):
  - this build hosts (hash name, its file not renamed) and build-201's
    client downloads the map by map sharing and plays; build-201 hosts
    and this build's client does the same, and plays it again on a second
    join;
  - a host with map sharing off: the client's map came from the web seed
    (chaosgulch), and from HaloNet's zip beside the client's own other
    version of battlecity_lvl1; each the host's byte for byte, played on
    the second join, the client's own files unchanged;
  - a server naming its level custom_maps\chaosgulch, the client having
    another version: the host's came from HaloMaps's zip as
    ChaosGulch_7cac43f9.map, and the second join played it (chosen by its
    header checksum);
  - an index whose info hash is not the file's: the web seed's download
    refused, nothing written;
  - map_hashes.txt: a second run of a host hashes nothing again;
  - the download screen recorded on a virtual display: the light sweeping
    over the Xbox's picture as paradis_perdu (126 MB) came from the web
    seed, "from the web seed", the share counting up, B=STOP.
- No map file was renamed or changed by this build in any run (build-201's
  host renames its own for its hash, as it does).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When map sharing downloads a map from the game's players, a web seed
(maps.web_seeds, by default the seed box's folder of every map of the
index) now sends pieces of it too, so that a host whose upload is slow does
not hold its players' downloads up. Nothing goes over the game's
connections: no network version.

- A file of the download that the index has (the map by its name and the
  host's header checksum, a resource map by its name), at the size the
  host offered, is asked of the web seed 16 pieces (4 MiB) at a time, pieces
  no player has been asked for, with an HTTP range request on a thread of
  its own (map_archives_range_*, map_downloads_range_*). Each piece is
  checked against its hash as a player's piece is; one that is not the
  file's ends the web seed for that file, and three failed asks end it for
  the download, with a pause after each.
- update_request_range (update.h) on Linux (Mbed TLS) and Windows
  (WinHTTP): a GET with a Range header whose answer must be 206, for that
  range (Content-Range) and of its length; a server that sends the whole
  file instead is refused unread. No range is asked while a map comes from
  the archives (one HTTPS request at a time on Linux).
- The download cap of update_request is 1 GiB (the largest map of the
  index is about 760 MB).
- Android and the web have no HTTPS of their platform layer: their builds
  ask nothing (the stubs in map_archives.c and web_map_downloads.c).
- docs/map_downloads.md, port/linux/README.md and NETCODE.md say so; the
  setting's comment too.

Checked: the Linux CI steps in Docker (335 passed), the web build and its
tests, and the Windows release build. End to end on Linux in Docker, a
host sharing chaosgulch at 16 KB/s: 13.4 of the 13.9 MB came from the web
seed, the map the host's byte for byte, and the client played; a web seed
answering 404: three failed asks, then the host sent the map; maps.downloads
off: no web seed asked. On Windows, the same host at 16 KB/s: 13.4 MB came
from the web seed in WinHTTP ranges, and the client played the map.

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant