diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 457a7e4464..ee97c1e592 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -91,7 +91,7 @@ jobs:
- name: Test
run: |
- python -m pytest -q tools/test_linux_port.py tools/test_host_watch_hash.py tools/test_touch_menu.py tools/harness tools/test_profile.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
@@ -260,90 +260,13 @@ jobs:
path: dist/halo-windows-release-symbols
retention-days: 3
- web:
- # 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 eight builds (and the Windows builds' PDBs) as a release, tagged
# build- 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, web]
+ needs: [linux, android, windows]
if: ${{ github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
@@ -421,143 +344,3 @@ jobs:
while read -r tag; do
gh release delete "$tag" --cleanup-tag --yes
done
-
- pages:
- # 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]
- if: ${{ github.ref == 'refs/heads/main' }}
- 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 and the site.
- needs: [linux, android, windows, web, release, pages]
- if: ${{ !cancelled() }}
- runs-on: ubuntu-24.04
- timeout-minutes: 5
- steps:
- - name: Post to Discord
- env:
- WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
- 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 }}
- BRANCH: ${{ github.ref_name }}
- SHA: ${{ github.sha }}
- ACTOR: ${{ github.actor }}
- MESSAGE: ${{ github.event.head_commit.message }}
- RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
- run: |
- if [ -z "$WEBHOOK" ]; then
- echo "no DISCORD_WEBHOOK secret: nothing to post"
- exit 0
- fi
- status() {
- case "$1" in
- success) echo "✅ Built" ;;
- failure) echo "❌ Failed" ;;
- cancelled) echo "⏹️ Cancelled" ;;
- *) echo "➖ Skipped" ;;
- esac
- }
- if [ "$LINUX" = success ] && [ "$ANDROID" = success ] && [ "$WINDOWS" = success ] && [ "$WEB" = 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) · [Web]($SITE_URL) ([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
- subject="$(printf '%s' "${MESSAGE:-manual run}" | head -n 1 | cut -c1-200)"
- jq -n \
- --arg title "$title" --argjson color "$color" --arg url "$RUN_URL" \
- --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,
- fields: [
- {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}
- ]
- }]}' > payload.json
- # Discord rate limits webhooks (HTTP 429, with how many seconds to
- # wait in retry_after): wait and try again, a few times. A post
- # still limited, or a network or Discord error, is a warning rather
- # than a failed run; any other answer (a deleted webhook) fails. (At
- # most 5 tries of 15 seconds and 4 waits of 30: inside the job's 5
- # minutes.)
- for attempt in 1 2 3 4 5; do
- code=$(curl -sS --max-time 15 -o response.json -w '%{http_code}' \
- -H "Content-Type: application/json" -d @payload.json "$WEBHOOK") || code=000
- case "$code" in
- 2??)
- exit 0
- ;;
- 429)
- wait=$(jq -r '.retry_after // 5' response.json 2>/dev/null || echo 5)
- wait=$(awk -v w="$wait" 'BEGIN { w += 0; if (w < 1) w = 1; if (w > 30) w = 30; printf "%d", w + 0.999 }')
- echo "Discord is rate limiting the webhook; trying again in ${wait}s (attempt $attempt of 5)"
- ;;
- 000|5??)
- wait=5
- echo "Discord answered HTTP $code; trying again in ${wait}s (attempt $attempt of 5)"
- ;;
- *)
- echo "::error::Discord answered HTTP $code: $(cat response.json 2>/dev/null)"
- exit 1
- ;;
- esac
- [ "$attempt" -lt 5 ] && sleep "$wait"
- done
- echo "::warning::The Discord post did not go through after 5 attempts (HTTP $code)"
diff --git a/.gitignore b/.gitignore
index e04521b7d5..d77ce9b787 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,6 @@ original/
# CI build outputs (tools/ci_build.py)
dist/
+
+./cmake-build-debug
+CMakeLists.txt
diff --git a/AGENTS.md b/AGENTS.md
index abdbea8854..75a9cdb3cb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,8 +15,8 @@ end.
| 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/src/` | The platform layer the three ports share: Direct3D 8 on OpenGL (`d3d8_gl.c`), DirectSound and XInput on SDL3, files, threads, memory, sockets, settings (`port_config.c`), the mods folder (`port_mods.c`), internet play (`p2p*.c`) |
+| `port/linux/game/` | The port's game-side code, compiled as the game's sources are: the distributed netcode (`network_*.c`), co-op, map sharing (`map_share*.c`), the menus (`menu_tags.c`, `menu_functions.c`), Custom Edition maps (`custom_edition_*.c`, `cache_file_formats.c`), the tag validator (`tag_validate.c`, `tag_schema_*.c`), mods (`mods.c`, `mod_*.c`), 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) |
@@ -26,7 +26,7 @@ end.
| `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`) |
+| `docs/` | Longer design notes (`custom_edition_caches.md`, `mods.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` |
@@ -84,6 +84,7 @@ setting (`game.custom_edition`), checked at run time with
```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_mods.py # mods played over a map (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
@@ -216,6 +217,7 @@ as documentation of file formats, never copied.
| [port/web/README.md](port/web/README.md) | The web build, the page, the game data in the browser, limits, how it operates, hosting on GitHub Pages |
| [port/include/xdk/README.md](port/include/xdk/README.md) | How the SDK declarations were written and are checked |
| [docs/custom_edition_caches.md](docs/custom_edition_caches.md) | Loading and converting Custom Edition maps, and the evidence for each layout |
+| [docs/mods.md](docs/mods.md) | Mods: folders of Halo PC tag files whose sounds, bitmaps, text and models play over a map's on one machine |
| [port/assets/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 |
diff --git a/AUTHORS.md b/AUTHORS.md
index 59e4f483e4..52d271ed08 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -18,3 +18,6 @@
[Lucide](https://lucide.dev) 1.53.0, copyright (c) 2026 Lucide Icons and
Contributors, under the ISC licence
(`port/assets/icons/lucide/LICENSE`).
+- **The Mods menu's warning icons** (a mod out of its requested order, and
+ a mod that cannot be played): `triangle-alert` and `circle-x` from
+ [Lucide](https://lucide.dev) 1.53.0, under the same licence.
diff --git a/README.md b/README.md
index 7f6bec4609..9af23c5554 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,10 @@ release, and the web site then plays it. The [Releases](https://github.com/OpenC
page keeps the last five releases. If the latest build has a problem, get
an older build from that page.
+### Arch-based Linux Distributions
+
+The above Linux binary release is available on the [AUR](https://aur.archlinux.org/packages/opence-bin).
+
## Game data
The port does not include the game data. Download an Xbox disc image
@@ -57,6 +61,15 @@ the web, the page copies `maps/` out of the disc image into the browser's
storage for the site; nothing is uploaded. Refer to
[port/web/README.md](port/web/README.md).
+## Mods
+
+Mods are folders of Halo PC tag files, as the Halo Editing Kit and Invader
+write them, in `mods/` next to `maps/`: texture, sound, text and model packs
+that play over the maps' on your machine only, in any game. Each has a
+`mod.toml`. Every mod in the folder is on; the main menu's Mods turns them
+off and on and sets the order they load in.
+Refer to [docs/mods.md](docs/mods.md).
+
## Platforms
Each platform has its own instructions:
diff --git a/docs/mods.md b/docs/mods.md
new file mode 100644
index 0000000000..a1fb47d5b3
--- /dev/null
+++ b/docs/mods.md
@@ -0,0 +1,329 @@
+# Mods
+
+A mod is a folder of Halo PC tag files that the game plays over a map's own
+tags on your machine, as a resource pack does: sounds (a high-res audio
+pack), bitmaps (a texture pack, a HUD redraw), text (a translation) and
+models (higher-detail weapons, vehicles and scenery). Only the machine that
+has a mod plays it. Nothing of a mod is sent to another machine, and a mod
+changes nothing that the machines of a network game must agree on, so you
+can play with mods in any game, with players who have other mods or none. (A host's mod sounds of other lengths change how long
+the host's scripts and AI wait for dialogue to end; the host decides those
+for every machine, so they still agree.)
+
+The game's code is `port/linux/game/mods.c` (with `mod_sounds.c`,
+`mod_bitmaps.c`, `mod_strings.c` and `mod_models.c`, one for each kind of
+tag) and `port/linux/src/port_mods.c` (the mods folder and its files).
+
+## A mod's folder
+
+The mods are in the `mods` folder of the data root (the folder with
+`maps/`; on Android the app's data folder, on the web the browser's storage
+for the site). Each mod is a folder in it, with a `mod.toml` and a `tags`
+folder:
+
+```
+mods/
+ hd_audio/
+ mod.toml
+ tags/
+ sound/sfx/weapons/assault rifle/fire.sound
+ sound/dialog/...
+ german/
+ mod.toml
+ tags/
+ ui/hud/hud_item_messages.unicode_string_list
+```
+
+The files in `tags` have the paths of the tags they replace: a tag's name,
+then its group's extension, as the Halo Editing Kit and Invader keep them.
+`tags/sound/sfx/weapons/assault rifle/fire.sound` replaces the sound tag
+`sound\sfx\weapons\assault rifle\fire` of every map that has one. Names are
+not case-sensitive. The game only looks up the names of the map's tags among
+a mod's files: a mod cannot add tags, and no tag name of a map makes a path
+outside the mod.
+
+`mod.toml` says what the mod is. Every key is optional:
+
+```toml
+name = "High-res audio" # what the Mods menu shows (else the folder's name)
+id = "hd_audio" # what other mods name it by (else the folder's name)
+version = "1.0"
+authors = ["Someone", "Someone else"] # or one string
+description = "The game's sounds at 44 kHz"
+maps = ["a10", "bloodgulch"] # the maps it is for, by their files' names (else every map)
+load_after = ["hd_textures"] # mods it plays over: they load before it
+load_before = ["fixes"] # mods that play over it: they load after it
+incompatible = ["other_audio"] # mods it cannot be played with
+format = 1 # the mods' format it is made for
+```
+
+`load_after`, `load_before` and `incompatible` name other mods by their `id`
+or their folder's name, not case-sensitive. A mod they name that is not in
+the mods folder is passed over. `maps = []` is every map.
+
+A folder without `mod.toml` is not a mod. A mod made for a later `format`
+than the build's, or whose `maps` names no map, is not played. The game
+logs anything else wrong in `mod.toml` (an unknown key, a value of the
+wrong type) and uses the rest; a `mod.toml` that is not TOML at all is
+logged, and the mod played without what it says.
+
+The game's log is its output: the terminal on Linux, `halo.log` beside
+`halo.exe` on Windows, logcat on Android, and the page's log on the web.
+
+Limits: 256 mods, folder names of 63 bytes at most, 262144 files in all, 24
+folders deep under `tags`, and 64 maps in a mod's `maps`.
+
+## The load order
+
+Every mod in the mods folder is on until you turn it off. The mods load in
+an order, and where two mods have a file for the same tag, the one loaded
+later plays.
+
+- The default order is the folders' names in alphabetical order (not
+ case-sensitive), except that the mods a mod must load after (those it
+ asks to load after, and those that ask to load before it) are taken to
+ just before it. Mods that ask in a ring (a after b, b after a) load
+ alphabetically, and the game's log says so.
+- The main menu's **Mods** (in the PC version's menus, `display.menus =
+ "pc"`, the default) is laid out as the game lobby is: the mods on the
+ left as a resource pack list has them, the one at the top playing over
+ all those below it (the load order upside down: the mod loaded last is at
+ the top), and the chosen mod's details in the frame on the right (its
+ name, what is wrong with it, whether it is on, its priority, 1 the top,
+ and its version, authors, folder, maps and description). Choosing a mod
+ (A, or a click on the chosen one: the first click chooses it) turns it on
+ or off (ON, OFF); left and right, or **Move up** and **Move down**, move
+ the chosen mod (up: over one more mod). **OK** keeps the order and plays
+ it at once (on a map that is loaded, too); **Cancel** leaves it as it was.
+- A warning sign beside a mod means it is on the wrong side of a mod it
+ asks to load before or after (both on): a mod that loads after another is
+ above it in the menu. A red cross means a mod that is on says it cannot be
+ played with this one, or that it cannot be played at all (made for a later
+ build, or its `maps` names none): it stays off. The details say which.
+
+The menu keeps the order, and the mods turned off, in `mods/load_order.toml`,
+by the mods' folders' names:
+
+```toml
+order = ["german", "hd_audio", "hd_textures"]
+disabled = ["hd_textures"]
+```
+
+A mod added to the folder later is put where the default order puts it,
+after the mod before it there (but on the side it asks for of the mods it
+names), and is on. A mod gone from the folder is left out. When the file
+cannot be written, the game says so in its log and keeps the order until it
+quits.
+
+### Mods that cannot be played together
+
+When a mod that is on names another that is on as `incompatible`, the game
+asks as it starts, before the window opens:
+
+- **Play anyway** plays them all (closing the box does too);
+- **Turn off ...** turns off the mods named as incompatible (if mod A says
+ it cannot be played with mod B, B is turned off), and keeps that in
+ `load_order.toml`;
+- **Quit** closes the game.
+
+The Mods menu turns them on again. On the web, **Quit** starts the page
+again. A run nobody watches (`debug.hidden_window`, `debug.null_renderer`,
+`debug.exit_after` or `debug.network_test`) is not asked: it plays them all,
+and the game's log says which conflict.
+
+At the console:
+
+| Command | What it does |
+| --- | --- |
+| `mods_list` | Lists the mods in the mods folder in their load order, and which are on, then how many of the map's string lists, bitmaps, sounds and models the mods play. |
+| `mods_reload` | Finds the mods again and reads their files again: a changed file is read again (one that now fails a check leaves what was played before), a new one is played, and the map's tag comes back for a file that is gone. If a sound changed, all sounds stop. It does nothing while `mods_enable false`. |
+| `mods_enable false` | Plays the map's own tags until `mods_enable true` or the next map. |
+
+These commands work in any game, a client's too: they change nothing of the
+game.
+
+### Getting a mod onto each platform
+
+- **Linux, Windows**: put the mod's folder in `mods/` next to `maps/`.
+- **Android**: put it in `mods/` in the app's data folder,
+ `/sdcard/Android/data/com.halo.decomp/files/mods`, for example with
+ `adb push hd_audio /sdcard/Android/data/com.halo.decomp/files/mods/`.
+- **Web**: on the page, **Mods**, **Add a mod folder…** copies a mod's
+ `mod.toml` and `tags` folder into the browser's storage for the site.
+ **Remove** deletes it again.
+
+## What a mod can replace
+
+The game plays these groups from a mod. It does not read the files of any
+other group (a `.weapon`, `.vehicle`, `.model_collision_geometry` or
+`.scenario`, for example): they would change what the machines of a network
+game must agree on.
+
+| Group | Extension | What it is |
+| --- | --- | --- |
+| Sound | `.sound` | Sounds, music and dialogue |
+| Bitmap | `.bitmap` | Textures, sprites, the HUD's pictures |
+| Unicode string list | `.unicode_string_list` | The text the game shows |
+| String list | `.string_list` | Other text |
+| Model | `.gbxmodel` (Halo PC's), `.model` (the Xbox's) | What objects look like: weapons, first-person weapons and arms, vehicles, scenery, characters |
+
+A file must be a Halo PC tag file of its group, as the Halo Editing Kit's
+tools and Invader write them (a model also as `invader-extract` writes an
+Xbox map's: `.model`). The game checks every count, size and offset
+in it against the file, and every value against what the game reads, before
+it uses the file. A file that fails a check is left out (the map's tag
+plays), and `debug.txt` and the console say why.
+
+### Sounds
+
+- The samples can be 16-bit PCM, Xbox ADPCM or Ogg Vorbis, mono or stereo,
+ at 22 or 44 kHz. The game converts them to the Xbox ADPCM that it plays,
+ as it does for Custom Edition maps: mono at 22 kHz, stereo at its own rate.
+- Empty values are filled in as a map's build fills them in: the defaults
+ of the sound's class, the playback rates and the bend.
+- A sound must have permutations: a file with an empty pitch range (some
+ Halo PC translations have them) is left out.
+- A permutation must fit in a block of the sound cache (one sound's samples:
+ `MAXIMUM_SOUND_DATA_SIZE`).
+
+### Bitmaps
+
+- A bitmap's types and formats are those the game draws: 2D, 3D and cube
+ maps; A8, Y8, AY8, A8Y8, R5G6B5, A1R5G5B5, A4R4G4B4, X8R8G8B8, A8R8G8B8,
+ DXT1, DXT3, DXT5 and P8 bump. Sides are 4096 at most (512 for a 3D
+ bitmap). A bitmap that is not a power of two on each side is drawn
+ linear, as a Custom Edition map's is: it must be an uncompressed 2D
+ bitmap.
+- One bitmap's pixels may take a quarter of the texture cache: 64 MB on
+ Linux and Windows, 5.5 MB on Android and the web (a 2048 x 2048 DXT5 with
+ its mipmaps fits on all of them).
+- A bitmap replaces the map's bitmap of its name, and the map's other tags
+ use it as the map's: it must have at least as many bitmaps, of the same
+ types, and as many sequences, each with at least as many sprites. It can
+ be larger: a redraw at twice the size is drawn at the map's size, sharper.
+- The HUD draws a bitmap at its own size. So for each HUD element that draws
+ a mod's bitmap, the game scales the element by the map's size of what it
+ draws (its sprite, or with no sprites its bitmap) to the mod's, when that
+ is the same ratio for all of them. A bitmap the HUD draws that is not the
+ map's redrawn so (Halo PC's HUD bitmaps lay their sprites out to other
+ sizes) is left out. The HUD's digits can be the map's size or twice it.
+- The menus draw a bitmap in their widgets' bounds, whatever its size.
+- Halo PC keeps the channels of a model shader's multipurpose maps and of
+ the HUD's meters in another order than the Xbox. The game finds those
+ bitmaps among the mod's and draws them in the Xbox's order, as it does for
+ Custom Edition maps (`custom_edition_bitmaps.c`).
+- The pixels are read from the file when the texture cache loads them, not
+ kept in memory.
+
+### Text
+
+- A list keeps the map's number of strings: a mod's strings past it are
+ left out, and the map's own strings stay where the mod's list is shorter.
+- The game uses many strings as the format of what it prints
+ (`%s was killed by %s`). A mod's string replaces the map's only if it has
+ the same conversions in the same order (`%s`, `%d`, ...); otherwise the
+ map's string stays. A translation keeps the conversions of the original.
+- Unicode strings are UTF-16, as the tool writes them.
+- The main menu is the port's own (the PC version's menus, `port/assets/menus`),
+ whose text is in tags named `pc\...`. A mod's `ui\shell` lists change the
+ Xbox's menus (`display.menus = "xbox"`) and the text shown while playing
+ (HUD messages, the multiplayer messages, the pause menu).
+
+### Models
+
+A model is more than what is drawn: its nodes (its skeleton) are what its
+animations move, its markers are where projectiles leave a weapon, where a
+unit sits and where things attach, and its regions and permutations are what
+damage and an object's variant choose among. The machines of a network game
+agree on those, so a mod's model must have the map's:
+
+- the same nodes: their names, hierarchy and default positions;
+- the same markers: their names, regions, permutations, nodes, positions
+ and rotations (on the model's permutations, as the Halo Editing Kit keeps
+ them, or on the model, as a map does);
+- the same regions and permutations: their names and flags.
+
+Positions and rotations may differ from the map's by 0.001 at most (a model
+exported again is not always bit for bit the map's). Only what is drawn is
+the mod's: the geometries, their parts and vertices, which geometry each
+permutation draws at each detail level, the detail levels' cutoffs, the
+base map's scale, and which shaders each part is drawn with. A detail level
+the map draws must draw one of the mod's geometries, and one the map draws
+nothing at stays so. Every shader must be one the map has (a mod cannot add
+tags); a mod's bitmaps can redraw them.
+
+The model is checked against each map's own model of its name as that map
+loads. On a map whose model of that name has other nodes, markers or
+regions (a custom map's own assault rifle, say), or lacks a shader the mod
+names, the mod's model is left out and the map's drawn; the mod's other
+files still play there. `maps` in `mod.toml` keeps a mod to the maps it is
+made for.
+
+- Where a mod has both, its `.gbxmodel` plays; otherwise the file of the mod
+ loaded later, whichever its format.
+- What the tool works out as it builds a map is worked out as it does: a
+ part's strip is its triangles block, its centroid's nodes are its most
+ weighted, its links of 0 to the parts drawn before and after it are none,
+ a base map scale of 0 is 1, and a model whose tag asks for it has the
+ normals of its vertices that share a position blended. The vertices are
+ compressed as the game draws them.
+- A part's links to the parts drawn before and after it (for transparency)
+ must not go round.
+- A model of 44 nodes or more (the renderer skins 43 at once; only Custom
+ Edition maps' have so many) is drawn a part's own nodes at a time, as a
+ Custom Edition map's is: a part's vertices may name 22 nodes at most.
+- The collision geometry, the animations and the object's tag are the map's:
+ a larger weapon still hits and is hit where the map's does, and an object
+ is still left undrawn by the map's bounds of it (a much larger model may
+ vanish at the screen's edges).
+- Every model made from a file is kept until the map changes, a file edited
+ and read again by `mods_reload` too, and counts against the mods' memory.
+
+### Shaders
+
+A model's parts can be drawn with a shader of every group Halo PC has but
+one: `shader_transparent_chicago_extended`, which Gearbox added for Halo PC.
+The Xbox game does not have it, so do not use it: use a
+`shader_transparent_chicago` shader instead. (A Custom Edition map's
+chicago extended shaders become chicago shaders as the map loads, keeping
+only their four-stage maps: see
+[custom_edition_caches.md](custom_edition_caches.md).) As for every
+shader, it must be one the map has.
+
+## Making a mod
+
+1. Get the tags you want to change as tag files. `invader-extract` extracts
+ them from a map, and the Halo Editing Kit's tags are Halo PC's own.
+2. Change them with the Halo Editing Kit or Invader (`invader-bitmap`,
+ `invader-sound` and `invader-model` build bitmaps, sounds and models from
+ images, audio files and JMS files). Keep each tag's name.
+3. Put them in `tags/` of a folder in `mods/`, with a `mod.toml`.
+4. Load a map (a new mod is on). `mods_reload` at the console reads the
+ files again as you change them, without loading the map again.
+
+`debug.txt` has a line for every file the game leaves out, and why, and a
+summary of what each kind of tag plays from the mods:
+
+```
+mods: 9 string lists from their files (9 new), 0 given back, 0 left out
+mods: 66 HUD placements scaled to draw larger bitmaps at the map's size
+mods: 354 bitmaps from their files (354 new), 0 given back, 6 left out
+mods: 313 sounds from their files (313 new), 0 given back, 2 left out
+mods: 4 models from their files (4 new), 0 given back, 1 left out
+```
+
+## How it operates
+
+- When a map's tags load, at `mods_reload` and at the Mods menu's **OK**,
+ the game lists the files of the mods that are on, once, in a table of
+ their tag paths. Each of the map's sound, bitmap, string list and model
+ tags is looked up in it.
+- A tag replaced by a mod takes the file's values in place, so everything
+ that holds the tag keeps it, and the map's own values come back when the
+ file goes. A sound playing or a texture in the cache is never changed
+ under the game: before a reload changes anything, the sounds stop and the
+ caches let go of what they hold.
+- The mods' tags keep at most 512 MB in memory (192 MB on Android and the
+ web): their files, their sounds' samples, their bitmaps' tags and their
+ models' geometry.
+- A Custom Edition map takes mods as an Xbox map does.
diff --git a/port/android/README.md b/port/android/README.md
index e82bc9c25b..9cb3625c8f 100644
--- a/port/android/README.md
+++ b/port/android/README.md
@@ -96,6 +96,7 @@ To install the data from a computer:
| Item | Location in `/sdcard/Android/data/com.halo.decomp/files` |
| --- | --- |
| Saved games (`z:\` and `u:\`) | `save` |
+| Mods (`docs/mods.md`) | `mods`: `adb push /sdcard/Android/data/com.halo.decomp/files/mods/`; the main menu's Mods orders them and turns them off |
| Log | `debug.txt` |
| Settings | `config.toml` |
@@ -307,7 +308,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
diff --git a/port/android/guest/runtime/guest_host.h b/port/android/guest/runtime/guest_host.h
index 60c5d2599a..e6d2b0813a 100644
--- a/port/android/guest/runtime/guest_host.h
+++ b/port/android/guest/runtime/guest_host.h
@@ -75,6 +75,10 @@ void host_sdl_scancode_name(int scancode, char *buffer, unsigned int size);
int host_sdl_scancode_from_name(const char *name);
int host_sdl_show_toast(const char *message, int duration, int gravity, int x, int y);
int host_sdl_show_simple_message_box(unsigned int flags, const char *title, const char *message);
+/* SDL_ShowMessageBox of up to three buttons (SDL_MessageBoxData has not the
+guest's layout): the index of the one chosen, else -1 */
+int host_sdl_show_message_box(unsigned int flags, const char *title, const char *message, int default_index,
+ const char *button0, const char *button1, const char *button2);
int host_sdl_get_gamepads(unsigned int *ids, int capacity);
unsigned int host_sdl_open_gamepad(unsigned int id);
unsigned int host_sdl_gamepad_from_id(unsigned int id);
diff --git a/port/android/guest/runtime/guest_sdl.c b/port/android/guest/runtime/guest_sdl.c
index bd7b309bca..4e5a5cd389 100644
--- a/port/android/guest/runtime/guest_sdl.c
+++ b/port/android/guest/runtime/guest_sdl.c
@@ -95,6 +95,31 @@ bool SDL_ShowSimpleMessageBox(SDL_MessageBoxFlags flags, const char *title, cons
return host_sdl_show_simple_message_box((unsigned int)flags, title, message) != 0;
}
+/* (up to three buttons, their ids those of the guest's) */
+bool SDL_ShowMessageBox(const SDL_MessageBoxData *data, int *buttonid)
+{
+ const char *labels[3] = { NULL, NULL, NULL };
+ int default_index = -1;
+ int index;
+ int chosen;
+
+ if (!data || data->numbuttons < 1 || data->numbuttons > 3)
+ return false;
+ for (index = 0; index < data->numbuttons; index++)
+ {
+ labels[index] = data->buttons[index].text;
+ if (data->buttons[index].flags & SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT)
+ default_index = index;
+ }
+ chosen = host_sdl_show_message_box((unsigned int)data->flags, data->title, data->message, default_index,
+ labels[0], labels[1], labels[2]);
+ if (chosen < 0 || chosen >= data->numbuttons)
+ return false;
+ if (buttonid)
+ *buttonid = data->buttons[chosen].buttonID;
+ return true;
+}
+
void SDL_Delay(Uint32 milliseconds)
{
struct timespec duration;
diff --git a/port/android/host/host_sdl.c b/port/android/host/host_sdl.c
index ff85894511..085dd7b9d9 100644
--- a/port/android/host/host_sdl.c
+++ b/port/android/host/host_sdl.c
@@ -510,3 +510,35 @@ int host_sdl_show_simple_message_box(uint32_t flags, const char *title, const ch
{
return SDL_ShowSimpleMessageBox((SDL_MessageBoxFlags)flags, title, message, NULL) ? 1 : 0;
}
+
+/* ---------- a question for the player (the mods at startup: port_mods.c) */
+
+int host_sdl_show_message_box(uint32_t flags, const char *title, const char *message, int default_index,
+ const char *button0, const char *button1, const char *button2)
+{
+ const char *labels[3] = { button0, button1, button2 };
+ SDL_MessageBoxButtonData buttons[3];
+ SDL_MessageBoxData question;
+ int count = 0;
+ int answer = -1;
+ int index;
+
+ for (index = 0; index < 3; index++)
+ {
+ if (!labels[index])
+ continue;
+ buttons[count].flags = index == default_index ?
+ SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT | SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT : 0;
+ buttons[count].buttonID = index;
+ buttons[count].text = labels[index];
+ count++;
+ }
+ question.flags = (SDL_MessageBoxFlags)flags;
+ question.window = NULL;
+ question.title = title;
+ question.message = message;
+ question.numbuttons = count;
+ question.buttons = buttons;
+ question.colorScheme = NULL;
+ return SDL_ShowMessageBox(&question, &answer) ? answer : -1;
+}
diff --git a/port/android/host_imports.list b/port/android/host_imports.list
index 41d52cfcfc..a12b4819c7 100644
--- a/port/android/host_imports.list
+++ b/port/android/host_imports.list
@@ -32,6 +32,7 @@ host_sdl_set_clipboard_text
host_sdl_get_clipboard_text
host_sdl_show_toast
host_sdl_show_simple_message_box
+host_sdl_show_message_box
host_sdl_get_gamepads
host_sdl_open_gamepad
host_sdl_gamepad_from_id
diff --git a/port/assets/buttons/black_butn__0.png b/port/assets/buttons/black_butn__0.png
new file mode 100644
index 0000000000..9baffc88d7
Binary files /dev/null and b/port/assets/buttons/black_butn__0.png differ
diff --git a/port/assets/buttons/buttons.json b/port/assets/buttons/buttons.json
index 2d07f8e1e4..93e21faa80 100644
--- a/port/assets/buttons/buttons.json
+++ b/port/assets/buttons/buttons.json
@@ -207,6 +207,320 @@
]
}
]
+ },
+ {
+ "name": "black_butn__0",
+ "tag": "ui\\shell\\bitmaps\\black_butn",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "svg": "black_butn.svg",
+ "place": [
+ 1,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "white_butn__0",
+ "tag": "ui\\shell\\bitmaps\\white_butn",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "svg": "white_butn.svg",
+ "place": [
+ 1,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "classic_a__0",
+ "tag": "ui\\hud\\buttons\\classic_a",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "svg": "a_butn.svg",
+ "place": [
+ 1,
+ 0,
+ 0
+ ],
+ "classic": "#40eb40",
+ "letter": [
+ 0.1685,
+ 0.1119
+ ]
+ },
+ {
+ "name": "classic_b__0",
+ "tag": "ui\\hud\\buttons\\classic_b",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "svg": "b_butn.svg",
+ "place": [
+ 1,
+ 0,
+ 0
+ ],
+ "classic": "#fa5946",
+ "letter": [
+ 0.696,
+ 0.1204
+ ]
+ },
+ {
+ "name": "classic_x__0",
+ "tag": "ui\\hud\\buttons\\classic_x",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "svg": "x_butn.svg",
+ "place": [
+ 1,
+ 0,
+ 0
+ ],
+ "classic": "#3998fa",
+ "letter": [
+ 0.3363,
+ 2.4589
+ ]
+ },
+ {
+ "name": "classic_y__0",
+ "tag": "ui\\hud\\buttons\\classic_y",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "svg": "y_butn.svg",
+ "place": [
+ 1,
+ 0,
+ 0
+ ],
+ "classic": "#f9e928",
+ "letter": [
+ -0.4376,
+ 2.2898
+ ]
+ },
+ {
+ "name": "classic_black__0",
+ "tag": "ui\\hud\\buttons\\classic_black",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "svg": "black_butn.svg",
+ "place": [
+ 1,
+ 0,
+ 0
+ ],
+ "classic": "#000000"
+ },
+ {
+ "name": "classic_white__0",
+ "tag": "ui\\hud\\buttons\\classic_white",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "svg": "white_butn.svg",
+ "place": [
+ 1,
+ 0,
+ 0
+ ],
+ "classic": "#ffffff"
+ },
+ {
+ "name": "gem_a__0",
+ "tag": "ui\\hud\\buttons\\gem_a",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/a_gem.png"
+ },
+ {
+ "name": "gem_b__0",
+ "tag": "ui\\hud\\buttons\\gem_b",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/b_gem.png"
+ },
+ {
+ "name": "gem_x__0",
+ "tag": "ui\\hud\\buttons\\gem_x",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/x_gem.png"
+ },
+ {
+ "name": "gem_y__0",
+ "tag": "ui\\hud\\buttons\\gem_y",
+ "bitmap": 0,
+ "width": 32,
+ "height": 32,
+ "format": "dxt3",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/y_gem.png"
+ },
+ {
+ "name": "trigger_left__0",
+ "tag": "ui\\hud\\buttons\\trigger_left",
+ "bitmap": 0,
+ "width": 64,
+ "height": 64,
+ "format": "a8r8g8b8",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/trigger_left.png",
+ "control_cell": [
+ 50,
+ 35
+ ]
+ },
+ {
+ "name": "trigger_right__0",
+ "tag": "ui\\hud\\buttons\\trigger_right",
+ "bitmap": 0,
+ "width": 64,
+ "height": 64,
+ "format": "a8r8g8b8",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/trigger_right.png",
+ "control_cell": [
+ 50,
+ 35
+ ]
+ },
+ {
+ "name": "thumbstick_left__0",
+ "tag": "ui\\hud\\buttons\\thumbstick_left",
+ "bitmap": 0,
+ "width": 64,
+ "height": 64,
+ "format": "a8r8g8b8",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/thumbstick_left.png",
+ "control_cell": [
+ 36,
+ 40
+ ]
+ },
+ {
+ "name": "thumbstick_right__0",
+ "tag": "ui\\hud\\buttons\\thumbstick_right",
+ "bitmap": 0,
+ "width": 64,
+ "height": 64,
+ "format": "a8r8g8b8",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/thumbstick_right.png",
+ "control_cell": [
+ 36,
+ 40
+ ]
+ },
+ {
+ "name": "thumbstick-halfway_left__0",
+ "tag": "ui\\hud\\buttons\\thumbstick-halfway_left",
+ "bitmap": 0,
+ "width": 64,
+ "height": 64,
+ "format": "a8r8g8b8",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/thumbstick-halfway_left.png",
+ "control_cell": [
+ 36,
+ 40
+ ]
+ },
+ {
+ "name": "thumbstick-halfway_right__0",
+ "tag": "ui\\hud\\buttons\\thumbstick-halfway_right",
+ "bitmap": 0,
+ "width": 64,
+ "height": 64,
+ "format": "a8r8g8b8",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/thumbstick-halfway_right.png",
+ "control_cell": [
+ 36,
+ 40
+ ]
+ },
+ {
+ "name": "thumbstick-full_left__0",
+ "tag": "ui\\hud\\buttons\\thumbstick-full_left",
+ "bitmap": 0,
+ "width": 64,
+ "height": 64,
+ "format": "a8r8g8b8",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/thumbstick-full_left.png",
+ "control_cell": [
+ 36,
+ 40
+ ]
+ },
+ {
+ "name": "thumbstick-full_right__0",
+ "tag": "ui\\hud\\buttons\\thumbstick-full_right",
+ "bitmap": 0,
+ "width": 64,
+ "height": 64,
+ "format": "a8r8g8b8",
+ "scale": 8,
+ "crc": 0,
+ "source_png": "source/thumbstick-full_right.png",
+ "control_cell": [
+ 36,
+ 40
+ ]
}
]
}
diff --git a/port/assets/buttons/classic_a__0.png b/port/assets/buttons/classic_a__0.png
new file mode 100644
index 0000000000..f5aa7f4172
Binary files /dev/null and b/port/assets/buttons/classic_a__0.png differ
diff --git a/port/assets/buttons/classic_b__0.png b/port/assets/buttons/classic_b__0.png
new file mode 100644
index 0000000000..c02c0845ea
Binary files /dev/null and b/port/assets/buttons/classic_b__0.png differ
diff --git a/port/assets/buttons/classic_black__0.png b/port/assets/buttons/classic_black__0.png
new file mode 100644
index 0000000000..2a1474ff06
Binary files /dev/null and b/port/assets/buttons/classic_black__0.png differ
diff --git a/port/assets/buttons/classic_white__0.png b/port/assets/buttons/classic_white__0.png
new file mode 100644
index 0000000000..9110e724b8
Binary files /dev/null and b/port/assets/buttons/classic_white__0.png differ
diff --git a/port/assets/buttons/classic_x__0.png b/port/assets/buttons/classic_x__0.png
new file mode 100644
index 0000000000..981f39f03b
Binary files /dev/null and b/port/assets/buttons/classic_x__0.png differ
diff --git a/port/assets/buttons/classic_y__0.png b/port/assets/buttons/classic_y__0.png
new file mode 100644
index 0000000000..9977992640
Binary files /dev/null and b/port/assets/buttons/classic_y__0.png differ
diff --git a/port/assets/buttons/gem_a__0.png b/port/assets/buttons/gem_a__0.png
new file mode 100644
index 0000000000..eb4c7e22a6
Binary files /dev/null and b/port/assets/buttons/gem_a__0.png differ
diff --git a/port/assets/buttons/gem_b__0.png b/port/assets/buttons/gem_b__0.png
new file mode 100644
index 0000000000..89d8b840c0
Binary files /dev/null and b/port/assets/buttons/gem_b__0.png differ
diff --git a/port/assets/buttons/gem_x__0.png b/port/assets/buttons/gem_x__0.png
new file mode 100644
index 0000000000..e93fca25bf
Binary files /dev/null and b/port/assets/buttons/gem_x__0.png differ
diff --git a/port/assets/buttons/gem_y__0.png b/port/assets/buttons/gem_y__0.png
new file mode 100644
index 0000000000..236457c914
Binary files /dev/null and b/port/assets/buttons/gem_y__0.png differ
diff --git a/port/assets/buttons/source/a_gem.png b/port/assets/buttons/source/a_gem.png
new file mode 100644
index 0000000000..6d3c506aa3
Binary files /dev/null and b/port/assets/buttons/source/a_gem.png differ
diff --git a/port/assets/buttons/source/b_gem.png b/port/assets/buttons/source/b_gem.png
new file mode 100644
index 0000000000..48c3f0dc3c
Binary files /dev/null and b/port/assets/buttons/source/b_gem.png differ
diff --git a/port/assets/buttons/source/thumbstick-full_left.png b/port/assets/buttons/source/thumbstick-full_left.png
new file mode 100644
index 0000000000..8b32328f96
Binary files /dev/null and b/port/assets/buttons/source/thumbstick-full_left.png differ
diff --git a/port/assets/buttons/source/thumbstick-full_right.png b/port/assets/buttons/source/thumbstick-full_right.png
new file mode 100644
index 0000000000..3c50c8f64e
Binary files /dev/null and b/port/assets/buttons/source/thumbstick-full_right.png differ
diff --git a/port/assets/buttons/source/thumbstick-halfway_left.png b/port/assets/buttons/source/thumbstick-halfway_left.png
new file mode 100644
index 0000000000..49e2ed6944
Binary files /dev/null and b/port/assets/buttons/source/thumbstick-halfway_left.png differ
diff --git a/port/assets/buttons/source/thumbstick-halfway_right.png b/port/assets/buttons/source/thumbstick-halfway_right.png
new file mode 100644
index 0000000000..fa5bec1241
Binary files /dev/null and b/port/assets/buttons/source/thumbstick-halfway_right.png differ
diff --git a/port/assets/buttons/source/thumbstick_left.png b/port/assets/buttons/source/thumbstick_left.png
new file mode 100644
index 0000000000..4fc313794e
Binary files /dev/null and b/port/assets/buttons/source/thumbstick_left.png differ
diff --git a/port/assets/buttons/source/thumbstick_right.png b/port/assets/buttons/source/thumbstick_right.png
new file mode 100644
index 0000000000..912de069fc
Binary files /dev/null and b/port/assets/buttons/source/thumbstick_right.png differ
diff --git a/port/assets/buttons/source/trigger_left.png b/port/assets/buttons/source/trigger_left.png
new file mode 100644
index 0000000000..9ae92c7165
Binary files /dev/null and b/port/assets/buttons/source/trigger_left.png differ
diff --git a/port/assets/buttons/source/trigger_right.png b/port/assets/buttons/source/trigger_right.png
new file mode 100644
index 0000000000..b8be877618
Binary files /dev/null and b/port/assets/buttons/source/trigger_right.png differ
diff --git a/port/assets/buttons/source/x_gem.png b/port/assets/buttons/source/x_gem.png
new file mode 100644
index 0000000000..b2778c3679
Binary files /dev/null and b/port/assets/buttons/source/x_gem.png differ
diff --git a/port/assets/buttons/source/y_gem.png b/port/assets/buttons/source/y_gem.png
new file mode 100644
index 0000000000..f61f6874f9
Binary files /dev/null and b/port/assets/buttons/source/y_gem.png differ
diff --git a/port/assets/buttons/svg/black_butn.svg b/port/assets/buttons/svg/black_butn.svg
new file mode 100644
index 0000000000..2d9b1be661
--- /dev/null
+++ b/port/assets/buttons/svg/black_butn.svg
@@ -0,0 +1,21 @@
+
diff --git a/port/assets/buttons/svg/white_butn.svg b/port/assets/buttons/svg/white_butn.svg
new file mode 100644
index 0000000000..63e41b5fbf
--- /dev/null
+++ b/port/assets/buttons/svg/white_butn.svg
@@ -0,0 +1,21 @@
+
diff --git a/port/assets/buttons/thumbstick-full_left__0.png b/port/assets/buttons/thumbstick-full_left__0.png
new file mode 100644
index 0000000000..9d4588f08d
Binary files /dev/null and b/port/assets/buttons/thumbstick-full_left__0.png differ
diff --git a/port/assets/buttons/thumbstick-full_right__0.png b/port/assets/buttons/thumbstick-full_right__0.png
new file mode 100644
index 0000000000..33c44fe6bc
Binary files /dev/null and b/port/assets/buttons/thumbstick-full_right__0.png differ
diff --git a/port/assets/buttons/thumbstick-halfway_left__0.png b/port/assets/buttons/thumbstick-halfway_left__0.png
new file mode 100644
index 0000000000..77db56482c
Binary files /dev/null and b/port/assets/buttons/thumbstick-halfway_left__0.png differ
diff --git a/port/assets/buttons/thumbstick-halfway_right__0.png b/port/assets/buttons/thumbstick-halfway_right__0.png
new file mode 100644
index 0000000000..80c3ee1948
Binary files /dev/null and b/port/assets/buttons/thumbstick-halfway_right__0.png differ
diff --git a/port/assets/buttons/thumbstick_left__0.png b/port/assets/buttons/thumbstick_left__0.png
new file mode 100644
index 0000000000..4a66f579ed
Binary files /dev/null and b/port/assets/buttons/thumbstick_left__0.png differ
diff --git a/port/assets/buttons/thumbstick_right__0.png b/port/assets/buttons/thumbstick_right__0.png
new file mode 100644
index 0000000000..000f6b07a9
Binary files /dev/null and b/port/assets/buttons/thumbstick_right__0.png differ
diff --git a/port/assets/buttons/trigger_left__0.png b/port/assets/buttons/trigger_left__0.png
new file mode 100644
index 0000000000..f50f159a66
Binary files /dev/null and b/port/assets/buttons/trigger_left__0.png differ
diff --git a/port/assets/buttons/trigger_right__0.png b/port/assets/buttons/trigger_right__0.png
new file mode 100644
index 0000000000..a9ed3fe2c2
Binary files /dev/null and b/port/assets/buttons/trigger_right__0.png differ
diff --git a/port/assets/buttons/white_butn__0.png b/port/assets/buttons/white_butn__0.png
new file mode 100644
index 0000000000..051d63b4d8
Binary files /dev/null and b/port/assets/buttons/white_butn__0.png differ
diff --git a/port/assets/hud/hud_ammo_meters__0.png b/port/assets/hud/hud_ammo_meters__0.png
index b5f394ac81..c04fb7a508 100644
Binary files a/port/assets/hud/hud_ammo_meters__0.png and b/port/assets/hud/hud_ammo_meters__0.png differ
diff --git a/port/assets/hud/hud_unit_meters__0.png b/port/assets/hud/hud_unit_meters__0.png
index 46036f9c3c..ff93e3eb36 100644
Binary files a/port/assets/hud/hud_unit_meters__0.png and b/port/assets/hud/hud_unit_meters__0.png differ
diff --git a/port/assets/hud/layout.json b/port/assets/hud/layout.json
index 073a5f7487..066163edf6 100644
--- a/port/assets/hud/layout.json
+++ b/port/assets/hud/layout.json
@@ -302,7 +302,70 @@
108
],
"kind": "meter",
- "score": 0.683
+ "score": 0.683,
+ "thresholds": [
+ 4,
+ 8,
+ 12,
+ 16,
+ 20,
+ 24,
+ 28,
+ 32,
+ 36,
+ 40,
+ 44,
+ 48,
+ 52,
+ 56,
+ 60,
+ 64,
+ 68,
+ 72,
+ 76,
+ 80,
+ 84,
+ 88,
+ 92,
+ 96,
+ 100,
+ 104,
+ 108,
+ 112,
+ 116,
+ 120,
+ 124,
+ 128,
+ 132,
+ 136,
+ 140,
+ 144,
+ 148,
+ 152,
+ 156,
+ 160,
+ 164,
+ 168,
+ 172,
+ 176,
+ 180,
+ 184,
+ 188,
+ 192,
+ 196,
+ 200,
+ 204,
+ 208,
+ 212,
+ 216,
+ 220,
+ 224,
+ 228,
+ 232,
+ 236,
+ 240
+ ],
+ "quantize_thresholds": true
},
{
"xbox": [
@@ -324,7 +387,22 @@
180
],
"kind": "meter",
- "score": 0.676
+ "score": 0.676,
+ "thresholds": [
+ 10,
+ 30,
+ 50,
+ 70,
+ 90,
+ 110,
+ 130,
+ 150,
+ 170,
+ 190,
+ 210,
+ 230
+ ],
+ "quantize_thresholds": true
},
{
"xbox": [
@@ -346,7 +424,22 @@
248
],
"kind": "meter",
- "score": 0.747
+ "score": 0.747,
+ "thresholds": [
+ 10,
+ 30,
+ 50,
+ 70,
+ 90,
+ 110,
+ 130,
+ 150,
+ 170,
+ 190,
+ 210,
+ 230
+ ],
+ "quantize_thresholds": true
},
{
"xbox": [
@@ -434,7 +527,30 @@
248
],
"kind": "meter",
- "score": 0.721
+ "score": 0.721,
+ "thresholds": [
+ 12,
+ 24,
+ 36,
+ 48,
+ 60,
+ 72,
+ 84,
+ 96,
+ 108,
+ 120,
+ 132,
+ 144,
+ 156,
+ 168,
+ 180,
+ 192,
+ 204,
+ 216,
+ 228,
+ 240
+ ],
+ "quantize_thresholds": true
},
{
"xbox": [
@@ -456,7 +572,14 @@
88
],
"kind": "meter",
- "score": 0.852
+ "score": 0.852,
+ "thresholds": [
+ 60,
+ 120,
+ 180,
+ 240
+ ],
+ "quantize_thresholds": true
},
{
"xbox": [
@@ -478,7 +601,12 @@
180
],
"kind": "meter",
- "score": 0.82
+ "score": 0.82,
+ "thresholds": [
+ 120,
+ 240
+ ],
+ "quantize_thresholds": true
}
]
},
@@ -3237,7 +3365,7 @@
50,
18
],
- "svg": "hud2_left_reticles__reticle_18.svg",
+ "svg": "sniper_diamond.svg",
"source_scale": 1.365,
"source": [
-3.5831,
@@ -3355,6 +3483,72 @@
"score": 0.996
}
]
+ },
+ {
+ "name": "vehicle_wrench_outline__0",
+ "tag": "ui\\hud\\redraws\\vehicle_wrench_outline",
+ "bitmap": 0,
+ "width": 128,
+ "height": 32,
+ "format": "ay8",
+ "scale": 4,
+ "crc": 0,
+ "cells": [
+ {
+ "xbox": [
+ 0,
+ 0,
+ 128,
+ 32
+ ],
+ "svg": "vehicle_wrench_outline.svg",
+ "source_scale": 1,
+ "source": [
+ 0,
+ 0
+ ],
+ "clip": [
+ 0,
+ 0,
+ 128,
+ 32
+ ],
+ "kind": "alpha"
+ }
+ ]
+ },
+ {
+ "name": "passenger_health_outline__0",
+ "tag": "ui\\hud\\redraws\\passenger_health_outline",
+ "bitmap": 0,
+ "width": 128,
+ "height": 32,
+ "format": "ay8",
+ "scale": 4,
+ "crc": 0,
+ "cells": [
+ {
+ "xbox": [
+ 0,
+ 0,
+ 128,
+ 32
+ ],
+ "svg": "passenger_health_outline.svg",
+ "source_scale": 1,
+ "source": [
+ 0,
+ 0
+ ],
+ "clip": [
+ 0,
+ 0,
+ 128,
+ 32
+ ],
+ "kind": "alpha"
+ }
+ ]
}
]
}
diff --git a/port/assets/hud/passenger_health_outline__0.png b/port/assets/hud/passenger_health_outline__0.png
new file mode 100644
index 0000000000..800c69e216
Binary files /dev/null and b/port/assets/hud/passenger_health_outline__0.png differ
diff --git a/port/assets/hud/sniper__hud_reticles_scope__0.png b/port/assets/hud/sniper__hud_reticles_scope__0.png
index 754a882106..f21e85fe9a 100644
Binary files a/port/assets/hud/sniper__hud_reticles_scope__0.png and b/port/assets/hud/sniper__hud_reticles_scope__0.png differ
diff --git a/port/assets/hud/svg/hud_xbox_order__bitmaps__combined__hud_unit_meters.svg b/port/assets/hud/svg/hud_xbox_order__bitmaps__combined__hud_unit_meters.svg
index 4dc6425a60..36bf47f476 100644
--- a/port/assets/hud/svg/hud_xbox_order__bitmaps__combined__hud_unit_meters.svg
+++ b/port/assets/hud/svg/hud_xbox_order__bitmaps__combined__hud_unit_meters.svg
@@ -28,12 +28,12 @@
-
+
-
+
diff --git a/port/assets/hud/svg/passenger_health_outline.svg b/port/assets/hud/svg/passenger_health_outline.svg
new file mode 100644
index 0000000000..30e76a22fa
--- /dev/null
+++ b/port/assets/hud/svg/passenger_health_outline.svg
@@ -0,0 +1 @@
+
diff --git a/port/assets/hud/svg/sniper_diamond.svg b/port/assets/hud/svg/sniper_diamond.svg
new file mode 100644
index 0000000000..3de389b364
--- /dev/null
+++ b/port/assets/hud/svg/sniper_diamond.svg
@@ -0,0 +1,3 @@
+
diff --git a/port/assets/hud/svg/vehicle_wrench_outline.svg b/port/assets/hud/svg/vehicle_wrench_outline.svg
new file mode 100644
index 0000000000..acfb4fa51e
--- /dev/null
+++ b/port/assets/hud/svg/vehicle_wrench_outline.svg
@@ -0,0 +1 @@
+
diff --git a/port/assets/hud/vehicle_wrench_outline__0.png b/port/assets/hud/vehicle_wrench_outline__0.png
new file mode 100644
index 0000000000..b81b7d799e
Binary files /dev/null and b/port/assets/hud/vehicle_wrench_outline__0.png differ
diff --git a/port/assets/icons/lucide/README.md b/port/assets/icons/lucide/README.md
index 534da09065..6ba817ab5c 100644
--- a/port/assets/icons/lucide/README.md
+++ b/port/assets/icons/lucide/README.md
@@ -1,11 +1,17 @@
# Lucide icons
-Two icons of [Lucide](https://lucide.dev) 1.53.0 (ISC licence: `LICENSE`),
+Four icons of [Lucide](https://lucide.dev) 1.53.0 (ISC licence: `LICENSE`),
as `icons/` has them at the `1.53.0` tag of
https://github.com/lucide-icons/lucide:
- `volume-2.svg`: voice chat's speaker, of a player who talks;
-- `volume-x.svg`: ... of a player muted.
+- `volume-x.svg`: ... of a player muted;
+- `triangle-alert.svg`: the Mods menu's sign of a mod that loads on the
+ wrong side of a mod it asks to load before or after;
+- `circle-x.svg`: ... of a mod that cannot be played, or that another mod
+ says it cannot be played with.
-`tools/ce_menus.py` draws them, white, as the menus' bitmap `voice/speaker`
-(port/linux/game/network_voice.c tints it). Credited in `AUTHORS.md`.
+`tools/ce_menus.py` draws the first two, white, as the menus' bitmap
+`voice/speaker` (port/linux/game/network_voice.c tints it), and the others,
+amber and red, as `mods/status` and the smaller `mods/status_small`
+(`tools/port_settings.py`). Credited in `AUTHORS.md`.
diff --git a/port/assets/icons/lucide/circle-x.svg b/port/assets/icons/lucide/circle-x.svg
new file mode 100644
index 0000000000..e210b2d101
--- /dev/null
+++ b/port/assets/icons/lucide/circle-x.svg
@@ -0,0 +1,15 @@
+
diff --git a/port/assets/icons/lucide/triangle-alert.svg b/port/assets/icons/lucide/triangle-alert.svg
new file mode 100644
index 0000000000..3232fb062d
--- /dev/null
+++ b/port/assets/icons/lucide/triangle-alert.svg
@@ -0,0 +1,15 @@
+
diff --git a/port/assets/menus/NON_HANDDRAWN.md b/port/assets/menus/NON_HANDDRAWN.md
index d894a55ecf..c2336bc296 100644
--- a/port/assets/menus/NON_HANDDRAWN.md
+++ b/port/assets/menus/NON_HANDDRAWN.md
@@ -62,7 +62,7 @@ until it is redrawn.
| `ce/shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__6.png` | 512x256 | `shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__6.png` |
| `ce/shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__8.png` | 512x256 | `shell/main_menu/settings_select/player_setup/player_profile_edit/profile_options__8.png` |
-13 of 164 frames are placeholders.
+13 of 177 frames are placeholders.
The PC version's `ui\gamespy` and `ui\ticker` fonts are drawn with `ui\small_ui`, which
the Xbox's map has.
diff --git a/port/assets/menus/UNWIRED.md b/port/assets/menus/UNWIRED.md
index 27d205371a..1a54f8b53c 100644
--- a/port/assets/menus/UNWIRED.md
+++ b/port/assets/menus/UNWIRED.md
@@ -10,7 +10,7 @@ do (save a setting, join a game) does not happen; the screens open and close as
| Function | Uses | |
| --- | --- | --- |
-| `common button bar update` | 27 | PC function |
+| `common button bar update` | 28 | PC function |
| `direct ip connect init` | 1 | PC function |
| `direct ip connect update` | 1 | PC function |
| `direct ip edit field` | 4 | PC function |
@@ -21,7 +21,7 @@ do (save a setting, join a game) does not happen; the screens open and close as
| `gamespy update filter settings` | 2 | PC function |
| `gt edit list update` | 1 | PC function |
| `gt select list update` | 1 | PC function |
-| `mouse spinner 1wide click` | 82 | PC function |
+| `mouse spinner 1wide click` | 84 | PC function |
| `mp map list update` | 1 | PC function |
| `mp prof init teamplay options` | 1 | PC function |
| `mp prof init vehicle options` | 1 | PC function |
diff --git a/port/assets/menus/ce/bitmaps.xml b/port/assets/menus/ce/bitmaps.xml
index 1b1038e5b9..0b98afb0ee 100644
--- a/port/assets/menus/ce/bitmaps.xml
+++ b/port/assets/menus/ce/bitmaps.xml
@@ -314,6 +314,9 @@
+
+
+
@@ -356,10 +359,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/port/assets/menus/ce/main_menu.mods.xml b/port/assets/menus/ce/main_menu.mods.xml
new file mode 100644
index 0000000000..689fd23f76
--- /dev/null
+++ b/port/assets/menus/ce/main_menu.mods.xml
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.connected.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.connected.xml
index 786f8ca170..4ae27ed86b 100644
--- a/port/assets/menus/ce/main_menu.multiplayer_type_select.connected.xml
+++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.connected.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/port/assets/menus/ce/main_menu.multiplayer_type_select.xml b/port/assets/menus/ce/main_menu.multiplayer_type_select.xml
index 496ac69e7f..9d8d8a83c2 100644
--- a/port/assets/menus/ce/main_menu.multiplayer_type_select.xml
+++ b/port/assets/menus/ce/main_menu.multiplayer_type_select.xml
@@ -60,13 +60,13 @@
-
+
-
+
diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.sound_processing.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.sound_processing.xml
new file mode 100644
index 0000000000..df7e8f3880
--- /dev/null
+++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.sound_processing.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.xml
index 2d164a11ee..fb64b13a0c 100644
--- a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.xml
+++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.xml
@@ -21,14 +21,14 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -81,20 +81,11 @@
-
-
-
-
-
-
-
-
-
-
+
@@ -103,7 +94,7 @@
-
+
@@ -112,11 +103,19 @@
-
+
+
+
+
+
+
+
+
+
@@ -133,10 +132,10 @@
-
+
@@ -145,9 +144,9 @@
-
+
diff --git a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.graphics.xml b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.graphics.xml
index 5e76b82fb4..1dcf49034a 100644
--- a/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.graphics.xml
+++ b/port/assets/menus/ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.graphics.xml
@@ -14,13 +14,14 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -37,11 +38,20 @@
+
+
+
+
+
+
+
+
+
-
+
@@ -50,7 +60,7 @@
-
+
@@ -59,7 +69,7 @@
-
+
@@ -68,7 +78,7 @@
-
+
@@ -77,7 +87,7 @@
-
+
@@ -86,7 +96,7 @@
-
+
@@ -95,7 +105,7 @@
-
+
@@ -112,6 +122,7 @@
+
@@ -123,6 +134,7 @@
+
diff --git a/port/assets/menus/ce/main_menu.xml b/port/assets/menus/ce/main_menu.xml
index 6395308455..650ec304cb 100644
--- a/port/assets/menus/ce/main_menu.xml
+++ b/port/assets/menus/ce/main_menu.xml
@@ -56,7 +56,8 @@
-
+
+
diff --git a/port/assets/menus/ce/port/main_menu/menu_mods__0.png b/port/assets/menus/ce/port/main_menu/menu_mods__0.png
new file mode 100644
index 0000000000..a3cd2fb5a0
Binary files /dev/null and b/port/assets/menus/ce/port/main_menu/menu_mods__0.png differ
diff --git a/port/assets/menus/ce/port/main_menu/menu_mods__1.png b/port/assets/menus/ce/port/main_menu/menu_mods__1.png
new file mode 100644
index 0000000000..f967ea05a1
Binary files /dev/null and b/port/assets/menus/ce/port/main_menu/menu_mods__1.png differ
diff --git a/port/assets/menus/ce/port/main_menu/mods/header_mods.png b/port/assets/menus/ce/port/main_menu/mods/header_mods.png
new file mode 100644
index 0000000000..da475344a5
Binary files /dev/null and b/port/assets/menus/ce/port/main_menu/mods/header_mods.png differ
diff --git a/port/assets/menus/ce/port/mods/status__0.png b/port/assets/menus/ce/port/mods/status__0.png
new file mode 100644
index 0000000000..ba218dc4c7
Binary files /dev/null and b/port/assets/menus/ce/port/mods/status__0.png differ
diff --git a/port/assets/menus/ce/port/mods/status__1.png b/port/assets/menus/ce/port/mods/status__1.png
new file mode 100644
index 0000000000..6963b12486
Binary files /dev/null and b/port/assets/menus/ce/port/mods/status__1.png differ
diff --git a/port/assets/menus/ce/port/mods/status__2.png b/port/assets/menus/ce/port/mods/status__2.png
new file mode 100644
index 0000000000..debf280ead
Binary files /dev/null and b/port/assets/menus/ce/port/mods/status__2.png differ
diff --git a/port/assets/menus/ce/port/mods/status_small__0.png b/port/assets/menus/ce/port/mods/status_small__0.png
new file mode 100644
index 0000000000..da04edbdd5
Binary files /dev/null and b/port/assets/menus/ce/port/mods/status_small__0.png differ
diff --git a/port/assets/menus/ce/port/mods/status_small__1.png b/port/assets/menus/ce/port/mods/status_small__1.png
new file mode 100644
index 0000000000..2f0a8da924
Binary files /dev/null and b/port/assets/menus/ce/port/mods/status_small__1.png differ
diff --git a/port/assets/menus/ce/port/mods/status_small__2.png b/port/assets/menus/ce/port/mods/status_small__2.png
new file mode 100644
index 0000000000..7d3c5b0eb5
Binary files /dev/null and b/port/assets/menus/ce/port/mods/status_small__2.png differ
diff --git a/port/assets/menus/ce/port/pause/objectives_center__0.png b/port/assets/menus/ce/port/pause/objectives_center__0.png
new file mode 100644
index 0000000000..5fb90e5b02
Binary files /dev/null and b/port/assets/menus/ce/port/pause/objectives_center__0.png differ
diff --git a/port/assets/menus/ce/port/pause/objectives_left__0.png b/port/assets/menus/ce/port/pause/objectives_left__0.png
new file mode 100644
index 0000000000..3b6a46315e
Binary files /dev/null and b/port/assets/menus/ce/port/pause/objectives_left__0.png differ
diff --git a/port/assets/menus/ce/port/pause/objectives_right__0.png b/port/assets/menus/ce/port/pause/objectives_right__0.png
new file mode 100644
index 0000000000..197d12be4f
Binary files /dev/null and b/port/assets/menus/ce/port/pause/objectives_right__0.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_center__0.png b/port/assets/menus/ce/port/pause/pausebox_center__0.png
index abcb1e692b..bf08e36d56 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_center__0.png and b/port/assets/menus/ce/port/pause/pausebox_center__0.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_center__1.png b/port/assets/menus/ce/port/pause/pausebox_center__1.png
index 30ed8a290f..b91c793739 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_center__1.png and b/port/assets/menus/ce/port/pause/pausebox_center__1.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_center__2.png b/port/assets/menus/ce/port/pause/pausebox_center__2.png
index 5044549206..56fd5eb305 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_center__2.png and b/port/assets/menus/ce/port/pause/pausebox_center__2.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_left__0.png b/port/assets/menus/ce/port/pause/pausebox_left__0.png
index 038a8f7c91..e367b6f0ab 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_left__0.png and b/port/assets/menus/ce/port/pause/pausebox_left__0.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_left__1.png b/port/assets/menus/ce/port/pause/pausebox_left__1.png
index 4965a8ea3b..1eaef9fda5 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_left__1.png and b/port/assets/menus/ce/port/pause/pausebox_left__1.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_left__2.png b/port/assets/menus/ce/port/pause/pausebox_left__2.png
index 9790d15120..b61be5b7ab 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_left__2.png and b/port/assets/menus/ce/port/pause/pausebox_left__2.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_right__0.png b/port/assets/menus/ce/port/pause/pausebox_right__0.png
index 238fdca6e4..dba0b4fb4b 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_right__0.png and b/port/assets/menus/ce/port/pause/pausebox_right__0.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_right__1.png b/port/assets/menus/ce/port/pause/pausebox_right__1.png
index 336669b5fb..d0930c8fbf 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_right__1.png and b/port/assets/menus/ce/port/pause/pausebox_right__1.png differ
diff --git a/port/assets/menus/ce/port/pause/pausebox_right__2.png b/port/assets/menus/ce/port/pause/pausebox_right__2.png
index 62901c565c..e41dc19125 100644
Binary files a/port/assets/menus/ce/port/pause/pausebox_right__2.png and b/port/assets/menus/ce/port/pause/pausebox_right__2.png differ
diff --git a/port/assets/menus/menus.json b/port/assets/menus/menus.json
index ee64e0adc7..8ab3b470ba 100644
--- a/port/assets/menus/menus.json
+++ b/port/assets/menus/menus.json
@@ -6,6 +6,7 @@
"ce/main_menu.campaign_select.xml",
"ce/main_menu.difficulty_select.xml",
"ce/main_menu.gametype_select.xml",
+ "ce/main_menu.mods.xml",
"ce/main_menu.multiplayer_type_select.connected.4way_profile_select.xml",
"ce/main_menu.multiplayer_type_select.connected.xml",
"ce/main_menu.multiplayer_type_select.coop.xml",
@@ -39,6 +40,7 @@
"ce/main_menu.settings_select.multiplayer_setup.teamplay_options_edit.xml",
"ce/main_menu.settings_select.multiplayer_setup.vehicle_options_edit.xml",
"ce/main_menu.settings_select.multiplayer_setup.xml",
+ "ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.sound_processing.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.audio_settings.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.color_edit.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.controls_setup.xml",
@@ -56,6 +58,9 @@
"ce/shell.xml",
"ce/strings.xml",
"ce/port/blank__0.png",
+ "ce/port/main_menu/menu_mods__0.png",
+ "ce/port/main_menu/menu_mods__1.png",
+ "ce/port/main_menu/mods/header_mods.png",
"ce/port/main_menu/multiplayer_type_select/coop/header_player_2.png",
"ce/port/main_menu/multiplayer_type_select/join_game/header_direct_link.png",
"ce/port/main_menu/multiplayer_type_select/join_game/header_password.png",
@@ -65,6 +70,15 @@
"ce/port/main_menu/multiplayer_type_select/server_settings/coop_options/header_coop_options.png",
"ce/port/main_menu/multiplayer_type_select/server_settings/map_share_options/header_map_share_options.png",
"ce/port/main_menu/multiplayer_type_select/server_settings/voice_options/header_voice_options.png",
+ "ce/port/mods/status__0.png",
+ "ce/port/mods/status__1.png",
+ "ce/port/mods/status__2.png",
+ "ce/port/mods/status_small__0.png",
+ "ce/port/mods/status_small__1.png",
+ "ce/port/mods/status_small__2.png",
+ "ce/port/pause/objectives_center__0.png",
+ "ce/port/pause/objectives_left__0.png",
+ "ce/port/pause/objectives_right__0.png",
"ce/port/pause/pausebox_center__0.png",
"ce/port/pause/pausebox_center__1.png",
"ce/port/pause/pausebox_center__2.png",
diff --git a/port/assets/menus/port_svg/main_menu/menu_mods__0.svg b/port/assets/menus/port_svg/main_menu/menu_mods__0.svg
new file mode 100644
index 0000000000..9e76b79b46
--- /dev/null
+++ b/port/assets/menus/port_svg/main_menu/menu_mods__0.svg
@@ -0,0 +1,15 @@
+
diff --git a/port/assets/menus/port_svg/main_menu/menu_mods__1.svg b/port/assets/menus/port_svg/main_menu/menu_mods__1.svg
new file mode 100644
index 0000000000..7426b3eaa2
--- /dev/null
+++ b/port/assets/menus/port_svg/main_menu/menu_mods__1.svg
@@ -0,0 +1,24 @@
+
diff --git a/port/assets/menus/port_svg/main_menu/mods/header_mods.svg b/port/assets/menus/port_svg/main_menu/mods/header_mods.svg
new file mode 100644
index 0000000000..f1f92a1426
--- /dev/null
+++ b/port/assets/menus/port_svg/main_menu/mods/header_mods.svg
@@ -0,0 +1,13 @@
+
diff --git a/port/assets/menus/port_svg/mods/status__0.svg b/port/assets/menus/port_svg/mods/status__0.svg
new file mode 100644
index 0000000000..efe8edd070
--- /dev/null
+++ b/port/assets/menus/port_svg/mods/status__0.svg
@@ -0,0 +1 @@
+
diff --git a/port/assets/menus/port_svg/mods/status__1.svg b/port/assets/menus/port_svg/mods/status__1.svg
new file mode 100644
index 0000000000..4413d2ed52
--- /dev/null
+++ b/port/assets/menus/port_svg/mods/status__1.svg
@@ -0,0 +1,15 @@
+
diff --git a/port/assets/menus/port_svg/mods/status__2.svg b/port/assets/menus/port_svg/mods/status__2.svg
new file mode 100644
index 0000000000..36bfc19468
--- /dev/null
+++ b/port/assets/menus/port_svg/mods/status__2.svg
@@ -0,0 +1,15 @@
+
diff --git a/port/assets/menus/port_svg/mods/status_small__0.svg b/port/assets/menus/port_svg/mods/status_small__0.svg
new file mode 100644
index 0000000000..6128a89931
--- /dev/null
+++ b/port/assets/menus/port_svg/mods/status_small__0.svg
@@ -0,0 +1 @@
+
diff --git a/port/assets/menus/port_svg/mods/status_small__1.svg b/port/assets/menus/port_svg/mods/status_small__1.svg
new file mode 100644
index 0000000000..4c7bce1d8a
--- /dev/null
+++ b/port/assets/menus/port_svg/mods/status_small__1.svg
@@ -0,0 +1,15 @@
+
diff --git a/port/assets/menus/port_svg/mods/status_small__2.svg b/port/assets/menus/port_svg/mods/status_small__2.svg
new file mode 100644
index 0000000000..ed74cdc907
--- /dev/null
+++ b/port/assets/menus/port_svg/mods/status_small__2.svg
@@ -0,0 +1,15 @@
+
diff --git a/port/assets/menus/port_svg/pause/objectives_center__0.svg b/port/assets/menus/port_svg/pause/objectives_center__0.svg
new file mode 100644
index 0000000000..2d899b0cdc
--- /dev/null
+++ b/port/assets/menus/port_svg/pause/objectives_center__0.svg
@@ -0,0 +1,13 @@
+
diff --git a/port/assets/menus/port_svg/pause/objectives_left__0.svg b/port/assets/menus/port_svg/pause/objectives_left__0.svg
new file mode 100644
index 0000000000..835d4620f4
--- /dev/null
+++ b/port/assets/menus/port_svg/pause/objectives_left__0.svg
@@ -0,0 +1,13 @@
+
diff --git a/port/assets/menus/port_svg/pause/objectives_right__0.svg b/port/assets/menus/port_svg/pause/objectives_right__0.svg
new file mode 100644
index 0000000000..6c458c26cc
--- /dev/null
+++ b/port/assets/menus/port_svg/pause/objectives_right__0.svg
@@ -0,0 +1,15 @@
+
diff --git a/port/assets/menus/port_svg/pause/pausebox_center__0.svg b/port/assets/menus/port_svg/pause/pausebox_center__0.svg
index e779828768..900065539b 100644
--- a/port/assets/menus/port_svg/pause/pausebox_center__0.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_center__0.svg
@@ -1,5 +1,14 @@
diff --git a/port/assets/menus/port_svg/pause/pausebox_center__1.svg b/port/assets/menus/port_svg/pause/pausebox_center__1.svg
index 7afd402ebd..3d5aff4883 100644
--- a/port/assets/menus/port_svg/pause/pausebox_center__1.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_center__1.svg
@@ -1,5 +1,14 @@
diff --git a/port/assets/menus/port_svg/pause/pausebox_center__2.svg b/port/assets/menus/port_svg/pause/pausebox_center__2.svg
index e3302c2066..3767ab27b8 100644
--- a/port/assets/menus/port_svg/pause/pausebox_center__2.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_center__2.svg
@@ -1,5 +1,14 @@
diff --git a/port/assets/menus/port_svg/pause/pausebox_left__0.svg b/port/assets/menus/port_svg/pause/pausebox_left__0.svg
index 84b787e00e..d29584a468 100644
--- a/port/assets/menus/port_svg/pause/pausebox_left__0.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_left__0.svg
@@ -1,4 +1,14 @@
diff --git a/port/assets/menus/port_svg/pause/pausebox_left__1.svg b/port/assets/menus/port_svg/pause/pausebox_left__1.svg
index a4e1005686..54fe0be99a 100644
--- a/port/assets/menus/port_svg/pause/pausebox_left__1.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_left__1.svg
@@ -1,4 +1,14 @@
diff --git a/port/assets/menus/port_svg/pause/pausebox_left__2.svg b/port/assets/menus/port_svg/pause/pausebox_left__2.svg
index 205b6ef57e..d622ec31ca 100644
--- a/port/assets/menus/port_svg/pause/pausebox_left__2.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_left__2.svg
@@ -1,4 +1,14 @@
diff --git a/port/assets/menus/port_svg/pause/pausebox_right__0.svg b/port/assets/menus/port_svg/pause/pausebox_right__0.svg
index dc58cd8dd0..fc29084791 100644
--- a/port/assets/menus/port_svg/pause/pausebox_right__0.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_right__0.svg
@@ -1,6 +1,16 @@
diff --git a/port/assets/menus/port_svg/pause/pausebox_right__1.svg b/port/assets/menus/port_svg/pause/pausebox_right__1.svg
index 6b9edf4ab9..d1b24609fc 100644
--- a/port/assets/menus/port_svg/pause/pausebox_right__1.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_right__1.svg
@@ -1,6 +1,16 @@
diff --git a/port/assets/menus/port_svg/pause/pausebox_right__2.svg b/port/assets/menus/port_svg/pause/pausebox_right__2.svg
index 9574e89010..7988c5c0f6 100644
--- a/port/assets/menus/port_svg/pause/pausebox_right__2.svg
+++ b/port/assets/menus/port_svg/pause/pausebox_right__2.svg
@@ -1,6 +1,16 @@
diff --git a/port/linux/NETCODE.md b/port/linux/NETCODE.md
index 5106c542bc..3b79853c65 100644
--- a/port/linux/NETCODE.md
+++ b/port/linux/NETCODE.md
@@ -517,7 +517,13 @@ its name alone beside it, has its name given back as the map lists find it.
rest is on the ground, so it lies limp again where it stops instead of
keeping the pose of its fall in the air. A client puts its copies there, and the difference is
drawn fading over a few ticks (`render_interpolation.c`) instead of a
- jump. A client drives its own player's vehicle and sends where it is,
+ jump. A vehicle no player drives is carried between those updates along
+ the positions the host sent, a recording's lifepod as well as a Pelican.
+ Its physics velocity swings about that path, so the carry steps from
+ one of those positions to the next, and a jump further than a step of
+ the path is left where the host put it. The host's word that it is at
+ rest stops that step, and the step is kept only for the last gap
+ between positions, so a late word does not drive it on. A client drives its own player's vehicle and sends where it is,
which the host takes within a tolerance, as it does its own player's
unit, and no further from an anchor (one it took, a newer once a
second) than the vehicle moves in the client's ticks since, those no
diff --git a/port/linux/README.md b/port/linux/README.md
index def58c7fbe..4dbf26af6d 100644
--- a/port/linux/README.md
+++ b/port/linux/README.md
@@ -170,7 +170,7 @@ These files are in the data root:
| --- | --- |
| `debug.txt` | The log of the game. At start-up, the game shows the data root in the terminal. A crash writes its report (the faulting address and the calls that led to it) here as well; the `reference address` line at the top of each session places those addresses in the build. |
| `init.txt` | Console commands that the game does at start-up. For example, `map_name levels\a10\a10` starts the first campaign level. |
-| `tags/` | Sound tag files that replace the sounds of the maps (`audio.loose_sounds`). |
+| `mods/` | Mods: folders of Halo PC tag files whose sounds, bitmaps, text and models play over the maps' on this machine, in the order of the main menu's Mods (`load_order.toml`). Refer to [docs/mods.md](../../docs/mods.md). |
The settings are in `config.toml` next to the executable. Refer to
"Settings". Internet play's MQTT brokers are in `brokers.txt` next to it
@@ -359,10 +359,10 @@ the setting for one start of the game. It has priority over the file.
| `audio.music_volume` | `1.0` | `HALO_MUSIC_VOLUME` | The music's volume, of the master volume. |
| `audio.effects_volume` | `1.0` | `HALO_EFFECTS_VOLUME` | The volume of the other sounds (effects and speech), of the master volume. |
| `audio.reverb` | `true` | `HALO_REVERB` | `true`: the sounds of the world reverberate as the place the player is in does: the sound environments of the maps (a corridor, a cave, a large hall, outdoors) set the reverberation, as the I3DL2 reverb of the Xbox did. A sound behind a wall or a door is muffled in it too. `false`: no reverberation (sounds behind a wall are still muffled). |
+| `audio.resampler` | `"sinc"` | `HALO_AUDIO_RESAMPLER` | How the sounds are converted to the output's rate (48 kHz): `"sinc"` with a band-limited low pass, which keeps out the images above the sound's own rate; `"linear"` between neighbouring samples, which lets some of them through and sounds brighter for it. Audio Setup's SOUND PROCESSING screen sets it, with `audio.reverb`. |
| `audio.voice_chat` | `"push_to_talk"` | `HALO_VOICE_CHAT` | How you talk in voice chat: `"push_to_talk"` (while `controls.push_to_talk` is held; the microphone opens when you first press it), `"open_mic"` (when the microphone hears speech), or `"off"`. You hear the other players in every case. Refer to "Voice chat". |
| `audio.voice_volume` | `1.0` | `HALO_VOICE_VOLUME` | The volume of the voices of the other players, `0` to `2`. |
| `audio.output_device`, `audio.input_device` | `"default"` | `HALO_AUDIO_OUTPUT_DEVICE`, `HALO_AUDIO_INPUT_DEVICE` | The speakers and the microphone, by the name that Settings > Audio shows, or `"default"` for the device of the system. If the device is not found, the game uses the device of the system. Not on Android. |
-| `audio.loose_sounds` | `false` | `HALO_LOOSE_SOUNDS` | For those who make sounds. `true`: each sound of a map that has a sound tag file of its name in `tags/` in the data root (for example `tags/sound/sfx/weapons/assault rifle/fire.sound`) plays from that file. The files are Halo PC tag files, as the Halo Editing Kit and Invader write them. At the console, `loose_sounds_reload` reads the files again, and `loose_sounds false` plays the sounds of the map again. When a file changes, all sounds stop. |
| `input.mouse_sensitivity` | `1.0` | `HALO_MOUSE_SENSITIVITY` | The multiplier for the mouse aim. |
| `input.mouse_vertical_sensitivity` | `0.0` | `HALO_MOUSE_VERTICAL_SENSITIVITY` | The multiplier for the vertical mouse aim. `0`: the same as `input.mouse_sensitivity`. |
| `input.invert_mouse` | `false` | `HALO_MOUSE_INVERT=1` sets `true` | `true`: the vertical mouse aim is inverted. |
@@ -972,6 +972,25 @@ the invite with the invite button of Discord. When a person accepts it, that
person joins the game. If the game does not operate, Discord starts it.
The game sends the activity only to a Discord client of the same user.
+## Mods
+
+A mod is a folder in `mods/` in the data root with a `mod.toml` and a
+`tags` folder of Halo PC tag files, as the Halo Editing Kit and Invader write
+them: sounds, bitmaps, string lists and models that play over the maps' tags
+of the same names, on this machine only (a high-res audio or texture pack, a
+translation, higher-detail models that keep the map's nodes, markers and
+regions). Every mod in the folder is on, in alphabetical order moved as the
+mods ask (`load_after`, `load_before`); the main menu's Mods lists them as a
+resource pack list does, the mod at the top playing over those below it,
+turns them off and moves them, and keeps that in `mods/load_order.toml`.
+Mods that say they cannot be played together (`incompatible`) are asked
+about at startup. The console's `mods_list` lists them, `mods_reload` reads
+their files again and `mods_enable false` plays the maps' own tags. The game
+checks every file before it plays it, and leaves out (with a line in
+`debug.txt`) one it cannot trust or that does not fit the map's tag it
+replaces. A mod changes nothing that the machines of a
+network game agree on. Refer to [docs/mods.md](../../docs/mods.md).
+
## Map checks
The game reads a map's tags straight into memory and uses them as its own
@@ -1138,7 +1157,9 @@ Other changes:
| `cseries/errors.c` | `debug.txt` stays open between lines. |
| `networking/`, `game/`, `interface/`, `bungie_net/network/` and the pools of objects, effects and sounds | The system link limits and the memory for them. |
| `game/`, `objects/`, `units/`, `networking/` | The distributed netcode. Refer to `NETCODE.md`. |
-| `cache/cache_files.c` | When a map's tags load and unload, the port finds the bitmaps that the high-res HUD replaces (`game/hud_hires_tags.c`), and adds the tags of the menus to the menus' map (`game/menu_tags.c`). |
+| `cache/cache_files.c` | When a map's tags load and unload, the port finds the bitmaps that the high-res HUD replaces (`game/hud_hires_tags.c`), adds the tags of the menus to the menus' map (`game/menu_tags.c`), and plays the mods' tags (`game/mods.c`). |
+| `cache/xbox_sound_cache.c`, `cache/xbox_texture_cache.c` | The samples of a mod's sound are copied from memory, and the pixels of a mod's bitmap read from its file and laid out for the hardware, at once (`game/mod_sounds.c`, `game/mod_bitmaps.c`); a mod's linear bitmap need not have rows of whole steps of the pitch. |
+| `hs/hs.c` | The console's `mods_reload`, `mods_enable` and `mods_list`, which a client may run too. |
| `interface/ui_widget.c`, `interface/ui_widget_event_handler_functions.c`, `interface/ui_widget_game_data_input_functions.c` | The main menu is the PC version's from `port/assets/menus` (`display.menus`); the menus' widgets can call the port's functions (`game/menu_functions.c`) and send the PC version's custom activation event; the widgets' memory is 256 KB, not 16 KB; the main menu and Multiplayer clear co-op's controllers, so that a gamepad going back to controller 1 is not a controller unplugged; the lobby's split screen players leave alone, and one who quits in game is not joined to the next game (`interface/player_ui.c`). |
| `interface/player_ui.c` | Fast setup opens the pregame screen through the port's menus (the lobby), as `interface/ui_widget.c` does. |
| `input/input_abstraction.c`, `game/player_control.c`, `game/players.c`, `game/player_queues_new.c`, `units/units.h` | The keyboard and mouse's actions (`src/xinput_sdl.c`, `include/halo_keyboard.h`) join controller 1's game controls; their reload key reloads on its own, and their action key only acts (a control flag of the port's, sent with the player's action, stops the reload the controller's X falls back to). |
diff --git a/port/linux/game/cache_file_formats.c b/port/linux/game/cache_file_formats.c
index 0106db747d..9f856c6e37 100644
--- a/port/linux/game/cache_file_formats.c
+++ b/port/linux/game/cache_file_formats.c
@@ -3183,93 +3183,73 @@ static int hud_bitmap_halves_scale(
return flag_is_set(flags, BITMAP_HALF_HUD_SCALE_BIT) || flag_is_set(flags, BITMAP_FORCE_HUD_HIGH_RESOLUTION_SCALE_BIT);
}
-/* the element at `element`, whose bitmap is `bitmap_offset` bytes on
-(NO_HUD_BITMAP: none) */
-static void hud_element_convert(
- struct load_state const *state,
- uint8_t *element,
- int32_t bitmap_offset,
- struct custom_edition_conversion_report *report)
-{
- hud_placement_convert(
- element,
- bitmap_offset != NO_HUD_BITMAP && hud_bitmap_halves_scale(state, element + bitmap_offset),
- report);
-
- return;
-}
-
/* the placement at `placement_offset` in each element of the block at
-`block`, when the block lies within the tag cache: each drawing the bitmap
-`bitmap_offset` bytes after its placement (NO_HUD_BITMAP: none of its own),
-or the items of a bitmap that halves their scale */
-static void hud_placement_block_convert(
- struct load_state const *state,
+`block`: each drawing the bitmap `bitmap_offset` bytes after its placement
+(NO_HUD_BITMAP: none of its own) */
+static void hud_walk_block(
+ struct custom_edition_hud_walk const *walk,
uint8_t const *block,
uint32_t element_bytes,
uint32_t placement_offset,
- int32_t bitmap_offset,
- int items_of_halving_bitmap,
- struct custom_edition_conversion_report *report)
+ int32_t bitmap_offset)
{
int32_t element_count;
- uint32_t elements_offset;
+ uint8_t *elements = walk->block_elements(walk->context, block, element_bytes, &element_count);
int32_t element_index;
- if (!loaded_block_get(block, CUSTOM_EDITION_TAG_CACHE_ADDRESS, state->used_bytes, element_bytes, &element_count, &elements_offset))
+ for (element_index = 0; elements && element_index < element_count; element_index++)
{
- return;
- }
- for (element_index = 0; element_index < element_count; element_index++)
- {
- uint8_t *placement = state->tag_cache + elements_offset + (uint32_t)element_index * element_bytes + placement_offset;
+ uint8_t *placement = elements + (uint32_t)element_index * element_bytes + placement_offset;
- if (items_of_halving_bitmap)
- hud_placement_convert(placement, 1, report);
- else
- hud_element_convert(state, placement, bitmap_offset, report);
+ walk->placement(walk->context, placement, bitmap_offset != NO_HUD_BITMAP ? placement + bitmap_offset : NULL, 0);
}
return;
}
-/* the placements of the items of each weapon HUD crosshair or overlay in
-the block at `block` whose bitmap halves their scale */
-static void weapon_hud_items_convert(
- struct load_state const *state,
+/* the items of the block at `block`, of `item_bytes` each starting with its
+placement, all drawing the bitmap `bitmap` names */
+static void hud_walk_items(
+ struct custom_edition_hud_walk const *walk,
uint8_t const *block,
uint32_t item_bytes,
- struct custom_edition_conversion_report *report)
+ uint8_t const *bitmap)
{
- int32_t element_count;
- uint32_t elements_offset;
- int32_t element_index;
+ int32_t item_count;
+ uint8_t *items = walk->block_elements(walk->context, block, item_bytes, &item_count);
+ int32_t item_index;
- if (!loaded_block_get(
- block,
- CUSTOM_EDITION_TAG_CACHE_ADDRESS,
- state->used_bytes,
- WEAPON_HUD_CROSSHAIRS_OR_OVERLAYS_BYTES,
- &element_count,
- &elements_offset))
+ for (item_index = 0; items && item_index < item_count; item_index++)
{
- return;
+ walk->placement(walk->context, items + (uint32_t)item_index * item_bytes, bitmap, 1);
}
- for (element_index = 0; element_index < element_count; element_index++)
+
+ return;
+}
+
+/* the items of each weapon HUD crosshair or overlay in the block at `block` */
+static void hud_walk_weapon_items(
+ struct custom_edition_hud_walk const *walk,
+ uint8_t const *block,
+ uint32_t item_bytes)
+{
+ int32_t element_count;
+ uint8_t *elements = walk->block_elements(walk->context, block, WEAPON_HUD_CROSSHAIRS_OR_OVERLAYS_BYTES,
+ &element_count);
+ int32_t element_index;
+
+ for (element_index = 0; elements && element_index < element_count; element_index++)
{
- uint8_t const *element = state->tag_cache + elements_offset +
- (uint32_t)element_index * WEAPON_HUD_CROSSHAIRS_OR_OVERLAYS_BYTES;
+ uint8_t const *element = elements + (uint32_t)element_index * WEAPON_HUD_CROSSHAIRS_OR_OVERLAYS_BYTES;
- if (hud_bitmap_halves_scale(state, element + WEAPON_HUD_CROSSHAIRS_OR_OVERLAYS_BITMAP_OFFSET))
- hud_placement_block_convert(state, element + WEAPON_HUD_ITEMS_OFFSET, item_bytes, 0, NO_HUD_BITMAP, 1, report);
+ hud_walk_items(walk, element + WEAPON_HUD_ITEMS_OFFSET, item_bytes,
+ element + WEAPON_HUD_CROSSHAIRS_OR_OVERLAYS_BITMAP_OFFSET);
}
return;
}
-/* the size of a HUD definition whose placements hud_placements_convert
-converts, or 0 for any other group */
-static uint32_t hud_definition_bytes(
+uint32_t custom_edition_hud_definition_bytes(
uint32_t group_tag)
{
switch (group_tag)
@@ -3287,48 +3267,52 @@ static uint32_t hud_definition_bytes(
}
}
-/* every placement of the HUD definition of group `group_tag` at `offset` */
-static void hud_placements_convert(
- struct load_state const *state,
+void custom_edition_hud_placements_walk(
+ uint8_t *definition,
uint32_t group_tag,
- uint32_t offset,
- struct custom_edition_conversion_report *report)
+ struct custom_edition_hud_walk const *walk)
{
- uint8_t *definition = state->tag_cache + offset;
size_t index;
switch (group_tag)
{
case UNIT_HUD_INTERFACE_GROUP_TAG:
for (index = 0; index < sizeof(unit_hud_interface_elements) / sizeof(unit_hud_interface_elements[0]); index++)
- hud_element_convert(state, definition + unit_hud_interface_elements[index], HUD_ELEMENT_BITMAP_OFFSET, report);
- hud_element_convert(state, definition + UNIT_HUD_INTERFACE_BLIPS_OFFSET, NO_HUD_BITMAP, report);
- hud_placement_block_convert(state, definition + UNIT_HUD_INTERFACE_AUXILIARY_OVERLAYS_OFFSET,
- UNIT_HUD_INTERFACE_AUXILIARY_OVERLAY_BYTES, 0, HUD_ELEMENT_BITMAP_OFFSET, 0, report);
- hud_placement_block_convert(state, definition + UNIT_HUD_INTERFACE_AUXILIARY_METERS_OFFSET,
+ {
+ uint8_t *placement = definition + unit_hud_interface_elements[index];
+
+ walk->placement(walk->context, placement, placement + HUD_ELEMENT_BITMAP_OFFSET, 0);
+ }
+ walk->placement(walk->context, definition + UNIT_HUD_INTERFACE_BLIPS_OFFSET, NULL, 0);
+ hud_walk_block(walk, definition + UNIT_HUD_INTERFACE_AUXILIARY_OVERLAYS_OFFSET,
+ UNIT_HUD_INTERFACE_AUXILIARY_OVERLAY_BYTES, 0, HUD_ELEMENT_BITMAP_OFFSET);
+ hud_walk_block(walk, definition + UNIT_HUD_INTERFACE_AUXILIARY_METERS_OFFSET,
UNIT_HUD_INTERFACE_AUXILIARY_METER_BYTES, UNIT_HUD_INTERFACE_AUXILIARY_METER_BACKGROUND_OFFSET,
- HUD_ELEMENT_BITMAP_OFFSET, 0, report);
- hud_placement_block_convert(state, definition + UNIT_HUD_INTERFACE_AUXILIARY_METERS_OFFSET,
+ HUD_ELEMENT_BITMAP_OFFSET);
+ hud_walk_block(walk, definition + UNIT_HUD_INTERFACE_AUXILIARY_METERS_OFFSET,
UNIT_HUD_INTERFACE_AUXILIARY_METER_BYTES, UNIT_HUD_INTERFACE_AUXILIARY_METER_METER_OFFSET,
- HUD_ELEMENT_BITMAP_OFFSET, 0, report);
+ HUD_ELEMENT_BITMAP_OFFSET);
break;
case WEAPON_HUD_INTERFACE_GROUP_TAG:
- hud_placement_block_convert(state, definition + WEAPON_HUD_INTERFACE_STATICS_OFFSET,
- WEAPON_HUD_STATIC_OR_METER_BYTES, WEAPON_HUD_ELEMENT_PLACEMENT_OFFSET, HUD_ELEMENT_BITMAP_OFFSET, 0, report);
- hud_placement_block_convert(state, definition + WEAPON_HUD_INTERFACE_METERS_OFFSET,
- WEAPON_HUD_STATIC_OR_METER_BYTES, WEAPON_HUD_ELEMENT_PLACEMENT_OFFSET, HUD_ELEMENT_BITMAP_OFFSET, 0, report);
- weapon_hud_items_convert(state, definition + WEAPON_HUD_INTERFACE_CROSSHAIRS_OFFSET, WEAPON_HUD_CROSSHAIR_ITEM_BYTES, report);
- weapon_hud_items_convert(state, definition + WEAPON_HUD_INTERFACE_OVERLAYS_OFFSET, WEAPON_HUD_OVERLAY_ITEM_BYTES, report);
+ hud_walk_block(walk, definition + WEAPON_HUD_INTERFACE_STATICS_OFFSET,
+ WEAPON_HUD_STATIC_OR_METER_BYTES, WEAPON_HUD_ELEMENT_PLACEMENT_OFFSET, HUD_ELEMENT_BITMAP_OFFSET);
+ hud_walk_block(walk, definition + WEAPON_HUD_INTERFACE_METERS_OFFSET,
+ WEAPON_HUD_STATIC_OR_METER_BYTES, WEAPON_HUD_ELEMENT_PLACEMENT_OFFSET, HUD_ELEMENT_BITMAP_OFFSET);
+ hud_walk_weapon_items(walk, definition + WEAPON_HUD_INTERFACE_CROSSHAIRS_OFFSET, WEAPON_HUD_CROSSHAIR_ITEM_BYTES);
+ hud_walk_weapon_items(walk, definition + WEAPON_HUD_INTERFACE_OVERLAYS_OFFSET, WEAPON_HUD_OVERLAY_ITEM_BYTES);
break;
case GRENADE_HUD_INTERFACE_GROUP_TAG:
for (index = 0; index < sizeof(grenade_hud_interface_elements) / sizeof(grenade_hud_interface_elements[0]); index++)
- hud_element_convert(state, definition + grenade_hud_interface_elements[index], HUD_ELEMENT_BITMAP_OFFSET, report);
- if (hud_bitmap_halves_scale(state, definition + GRENADE_HUD_INTERFACE_OVERLAY_BITMAP_OFFSET))
- hud_placement_block_convert(state, definition + GRENADE_HUD_INTERFACE_OVERLAY_ITEMS_OFFSET,
- WEAPON_HUD_OVERLAY_ITEM_BYTES, 0, NO_HUD_BITMAP, 1, report);
+ {
+ uint8_t *placement = definition + grenade_hud_interface_elements[index];
+
+ walk->placement(walk->context, placement, placement + HUD_ELEMENT_BITMAP_OFFSET, 0);
+ }
+ hud_walk_items(walk, definition + GRENADE_HUD_INTERFACE_OVERLAY_ITEMS_OFFSET, WEAPON_HUD_OVERLAY_ITEM_BYTES,
+ definition + GRENADE_HUD_INTERFACE_OVERLAY_BITMAP_OFFSET);
break;
case HUD_GLOBALS_GROUP_TAG:
- hud_element_convert(state, definition + HUD_GLOBALS_MESSAGING_PLACEMENT_OFFSET, NO_HUD_BITMAP, report);
+ walk->placement(walk->context, definition + HUD_GLOBALS_MESSAGING_PLACEMENT_OFFSET, NULL, 0);
break;
default:
break;
@@ -3337,6 +3321,75 @@ static void hud_placements_convert(
return;
}
+/* what hud_placements_convert walks with */
+struct hud_conversion
+{
+ struct load_state const *state;
+ struct custom_edition_conversion_report *report;
+};
+
+static uint8_t *hud_conversion_block_elements(
+ void *context,
+ uint8_t const *block,
+ uint32_t element_bytes,
+ int32_t *count)
+{
+ struct hud_conversion const *conversion = context;
+ uint32_t elements_offset;
+
+ if (!loaded_block_get(block, CUSTOM_EDITION_TAG_CACHE_ADDRESS, conversion->state->used_bytes, element_bytes, count,
+ &elements_offset) || !*count)
+ {
+ return NULL;
+ }
+
+ return conversion->state->tag_cache + elements_offset;
+}
+
+/* an element's placement halved where Halo PC halves it; a crosshair's or
+overlay's item's by its bitmap's flags alone (hud_placement_convert) */
+static void hud_conversion_placement(
+ void *context,
+ uint8_t *placement,
+ uint8_t const *bitmap,
+ int items)
+{
+ struct hud_conversion const *conversion = context;
+ int bitmap_halves_scale = bitmap && hud_bitmap_halves_scale(conversion->state, bitmap);
+
+ if (items)
+ {
+ if (bitmap_halves_scale)
+ hud_placement_convert(placement, 1, conversion->report);
+ }
+ else
+ {
+ hud_placement_convert(placement, bitmap_halves_scale, conversion->report);
+ }
+
+ return;
+}
+
+/* every placement of the HUD definition of group `group_tag` at `offset` */
+static void hud_placements_convert(
+ struct load_state const *state,
+ uint32_t group_tag,
+ uint32_t offset,
+ struct custom_edition_conversion_report *report)
+{
+ struct hud_conversion conversion;
+ struct custom_edition_hud_walk walk;
+
+ conversion.state = state;
+ conversion.report = report;
+ walk.block_elements = hud_conversion_block_elements;
+ walk.placement = hud_conversion_placement;
+ walk.context = &conversion;
+ custom_edition_hud_placements_walk(state->tag_cache + offset, group_tag, &walk);
+
+ return;
+}
+
/* Halo PC's hint that a key shows the score names the key where it reads
"%s" in quotes. This build copies the hint as it is, so the Xbox button it
reads for the score takes the place of the placeholder and its quotes: BACK,
@@ -3705,8 +3758,9 @@ enum cache_file_status custom_edition_cache_convert(
{
sound_prepare(&state, offset, read_u32(instance + TAG_INSTANCE_HANDLE_OFFSET), report);
}
- if (hud_definition_bytes(group_tag) &&
- tag_cache_offset(&state, read_u32(instance + TAG_INSTANCE_ADDRESS_OFFSET), hud_definition_bytes(group_tag), &offset))
+ if (custom_edition_hud_definition_bytes(group_tag) &&
+ tag_cache_offset(&state, read_u32(instance + TAG_INSTANCE_ADDRESS_OFFSET),
+ custom_edition_hud_definition_bytes(group_tag), &offset))
{
hud_placements_convert(&state, group_tag, offset, report);
hud_meters_convert(&state, group_tag, offset, report);
diff --git a/port/linux/game/cache_file_formats.h b/port/linux/game/cache_file_formats.h
index 616cc919b6..775e7d0c9b 100644
--- a/port/linux/game/cache_file_formats.h
+++ b/port/linux/game/cache_file_formats.h
@@ -401,6 +401,33 @@ enum cache_file_status custom_edition_cache_convert(
char const *map_name,
struct custom_edition_conversion_report *report);
+/* A walk of the placements of a HUD definition (custom_edition_hud_placements_walk):
+`block_elements` gives the first of the `element_bytes`-byte elements of the
+tag block at `block`, their count in *count (NULL: none to walk), and
+`placement` is called for each placement: of an element drawing the bitmap
+the tag reference at `bitmap` names (NULL: none of its own), or (`items`)
+of an item of a crosshair or overlay drawing it. */
+struct custom_edition_hud_walk
+{
+ uint8_t *(*block_elements)(void *context, uint8_t const *block, uint32_t element_bytes, int32_t *count);
+ void (*placement)(void *context, uint8_t *placement, uint8_t const *bitmap, int items);
+ void *context;
+};
+
+/* the size of a HUD definition of group `group_tag` (unit, weapon and
+grenade HUD interfaces, the HUD globals), whose placements
+custom_edition_hud_placements_walk walks, or 0 for any other group */
+uint32_t custom_edition_hud_definition_bytes(
+ uint32_t group_tag);
+/* Walks every placement of the HUD definition at `definition` (of group
+`group_tag`, a Custom Edition map's or this build's: they lie alike). The
+Custom Edition maps' conversion halves those Halo PC halves; mods scale those
+of their larger bitmaps (port/linux/game/mod_bitmaps.c). */
+void custom_edition_hud_placements_walk(
+ uint8_t *definition,
+ uint32_t group_tag,
+ struct custom_edition_hud_walk const *walk);
+
/* the name of a Halo PC behaviour, as Chimera's map list names it */
char const *custom_edition_behaviour_name(
short behaviour);
diff --git a/port/linux/game/custom_edition_bitmaps.c b/port/linux/game/custom_edition_bitmaps.c
index 29124462f6..92bf09eb09 100644
--- a/port/linux/game/custom_edition_bitmaps.c
+++ b/port/linux/game/custom_edition_bitmaps.c
@@ -23,6 +23,10 @@ would mean decompressing them, which the texture cache has no room for, so
the renderer is told where Halo PC keeps the channels of each bitmap's
pixels as they arrive, and samples them in this build's order
(port/linux/src/xbox_textures.c).
+
+The bitmaps of mods (port/linux/game/mod_bitmaps.c) are Halo PC's too,
+whatever map they are played on: their channels are found in the same way
+in the loaded map's tags, once the mods' bitmaps are in them.
*/
/* ---------- headers */
@@ -35,6 +39,7 @@ pixels as they arrive, and samples them in this build's order
#include "rasterizer/rasterizer_swizzle.h"
#include "shaders/shader_definitions.h"
#include "interface/unit_hud_interface_definition.h"
+#include "cache/cache_files.h"
#include "cache_file_formats.h"
#include "custom_edition_cache.h"
@@ -89,6 +94,29 @@ enum
/* ---------- structures */
+/* a tag in the loaded map's table (cache_files.c's) */
+struct cache_file_tag_instance
+{
+ long group_tag;
+ long parent_group_tags[2];
+ long tag_index;
+ char *name;
+ void *base_address;
+ unsigned long unused[2];
+};
+
+typedef char verify_cache_file_tag_instance_size[
+ sizeof(struct cache_file_tag_instance) == 0x20 ? 1 : -1];
+
+/* the tags the bitmaps' users are found in: a tag cache
+custom_edition_cache_load filled (`loaded_bytes` of it in use), or with no
+tag cache the loaded map's, which the validator has checked */
+struct tag_view
+{
+ byte *tag_cache;
+ unsigned long loaded_bytes;
+};
+
/* A model shader, to its reflection cube map: the bitmaps it draws with.
rasterizer_xbox_models.c defines this build's shader_model for itself (no
header declares it) and asserts where the indices of its base,
@@ -177,8 +205,102 @@ struct custom_edition_bitmaps_globals
static struct custom_edition_bitmaps_globals custom_edition_bitmaps_globals;
+/* ---------- prototypes */
+
+/* the game's (port: cache_files.c) */
+void *cache_files_tag_instances(long *count);
+boolean tag_index_is_group(long tag_index, long group_tag);
+
/* ---------- private code */
+/* the definition of the next tag of group `group_tag` after the absolute
+index `*tag_index` (NONE: from the first), which becomes its index; NULL
+when there is none */
+static void *view_tag_next(
+ struct tag_view const *view,
+ unsigned long group_tag,
+ unsigned long definition_bytes,
+ int32_t *tag_index)
+{
+ struct cache_file_tag_instance *instances;
+ long count;
+ long index;
+
+ if (view->tag_cache)
+ {
+ return custom_edition_cache_tag_next(view->tag_cache, view->loaded_bytes, group_tag, definition_bytes, tag_index);
+ }
+ instances = cache_files_tag_instances(&count);
+ for (index = *tag_index < 0 ? 0 : *tag_index + 1; instances && index < count; index++)
+ {
+ struct cache_file_tag_instance const *instance = &instances[index];
+
+ if (instance->base_address &&
+ ((unsigned long)instance->group_tag == group_tag ||
+ (unsigned long)instance->parent_group_tags[0] == group_tag ||
+ (unsigned long)instance->parent_group_tags[1] == group_tag))
+ {
+ *tag_index = (int32_t)index;
+ return instance->base_address;
+ }
+ }
+ *tag_index = (int32_t)count;
+
+ return NULL;
+}
+
+/* the definition of the tag `handle` names, or NULL unless it is one of
+group `group_tag` */
+static void *view_tag_get(
+ struct tag_view const *view,
+ long handle,
+ unsigned long group_tag,
+ unsigned long definition_bytes)
+{
+ if (view->tag_cache)
+ {
+ return custom_edition_cache_tag_get(view->tag_cache, view->loaded_bytes, (unsigned long)handle, group_tag,
+ definition_bytes);
+ }
+
+ return tag_index_is_group(handle, (long)group_tag) ? tag_get(group_tag, handle) : NULL;
+}
+
+/* element `element_index` of a block, or NULL */
+static void *view_block_element(
+ struct tag_view const *view,
+ struct tag_block const *block,
+ long element_index,
+ unsigned long element_bytes)
+{
+ if (view->tag_cache)
+ {
+ return custom_edition_cache_block_element(view->tag_cache, view->loaded_bytes, block, element_index,
+ element_bytes);
+ }
+
+ return element_index >= 0 && element_index < block->count && block->address ?
+ (byte *)block->address + element_index * element_bytes : NULL;
+}
+
+/* the name of the tag of absolute index `tag_index`, for messages */
+static char const *view_tag_name(
+ struct tag_view const *view,
+ long tag_index)
+{
+ struct cache_file_tag_instance *instances;
+ long count;
+
+ if (view->tag_cache)
+ {
+ return custom_edition_cache_tag_name(view->tag_cache, view->loaded_bytes, tag_index);
+ }
+ instances = cache_files_tag_instances(&count);
+
+ return instances && tag_index >= 0 && tag_index < count && instances[tag_index].name ?
+ instances[tag_index].name : "";
+}
+
static boolean power_of_two(
long value)
{
@@ -220,7 +342,7 @@ the texture cache assume without checking: the game's own bitmap_verify, a
format with a hardware texture, a compressed flag that says whether the
format is compressed, power-of-two dimensions for the swizzle (or else a
linear 2D bitmap), square cube maps, and every pixel its levels need. */
-static boolean custom_edition_bitmap_drawable(
+boolean custom_edition_bitmap_drawable(
struct bitmap_data *bitmap)
{
boolean linear = TEST_FLAG(bitmap->flags, _bitmap_linear_bit);
@@ -309,16 +431,14 @@ names draws (its bitmaps, or its sprites'; all the tag's for NONE or a
sequence it has not got) as drawn in `channel_order`, or as they are
(NONE). FALSE when there is no memory for them. */
static boolean reordered_bitmaps_mark(
- byte *tag_cache,
- unsigned long loaded_bytes,
+ struct tag_view const *view,
long handle,
short sequence_index,
short channel_order)
{
- struct bitmap_group *group = custom_edition_cache_tag_get(
- tag_cache, loaded_bytes, (unsigned long)handle, BITMAP_GROUP_TAG, sizeof(struct bitmap_group));
+ struct bitmap_group *group = view_tag_get(view, handle, BITMAP_GROUP_TAG, sizeof(struct bitmap_group));
struct bitmap_group_sequence *sequence = group && sequence_index != NONE ?
- custom_edition_cache_block_element(tag_cache, loaded_bytes, &group->sequences, sequence_index, sizeof(*sequence)) :
+ view_block_element(view, &group->sequences, sequence_index, sizeof(*sequence)) :
NULL;
boolean marked = TRUE;
long index;
@@ -336,8 +456,7 @@ static boolean reordered_bitmaps_mark(
{
for (index = 0; marked && index < sequence->sprites.count; index++)
{
- struct bitmap_group_sprite *sprite = custom_edition_cache_block_element(
- tag_cache, loaded_bytes, &sequence->sprites, index, sizeof(*sprite));
+ struct bitmap_group_sprite *sprite = view_block_element(view, &sequence->sprites, index, sizeof(*sprite));
if (!sprite)
break;
@@ -355,41 +474,37 @@ static boolean reordered_bitmaps_mark(
/* the bitmaps a HUD meter draws, in Halo PC's meter order */
static boolean meter_bitmaps_mark(
- byte *tag_cache,
- unsigned long loaded_bytes,
+ struct tag_view const *view,
struct meter_hud_element_definition const *meter)
{
- return reordered_bitmaps_mark(tag_cache, loaded_bytes, meter->meter_bitmap.index, meter->sequence_index,
+ return reordered_bitmaps_mark(view, meter->meter_bitmap.index, meter->sequence_index,
_custom_edition_channels_hud_meter);
}
/* the bitmaps a HUD static element draws as they are */
static void static_bitmaps_mark(
- byte *tag_cache,
- unsigned long loaded_bytes,
+ struct tag_view const *view,
struct static_hud_element_definition const *element)
{
- reordered_bitmaps_mark(tag_cache, loaded_bytes, element->interface_bitmap.index, element->sequence_index, NONE);
+ reordered_bitmaps_mark(view, element->interface_bitmap.index, element->sequence_index, NONE);
return;
}
/* the bitmaps of the HUD meters of a unit HUD interface */
static boolean unit_hud_meters_add(
- byte *tag_cache,
- unsigned long loaded_bytes,
+ struct tag_view const *view,
struct unit_hud_interface_definition *hud)
{
boolean added =
- meter_bitmaps_mark(tag_cache, loaded_bytes, &hud->shield_meter.meter) &&
- meter_bitmaps_mark(tag_cache, loaded_bytes, &hud->health_meter.meter);
+ meter_bitmaps_mark(view, &hud->shield_meter.meter) &&
+ meter_bitmaps_mark(view, &hud->health_meter.meter);
long meter_index;
for (meter_index = 0; added && meter_index < hud->auxilary_meters.count; meter_index++)
{
- struct auxilary_meter_definition *meter = custom_edition_cache_block_element(
- tag_cache,
- loaded_bytes,
+ struct auxilary_meter_definition *meter = view_block_element(
+ view,
&hud->auxilary_meters,
meter_index,
sizeof(*meter));
@@ -399,7 +514,7 @@ static boolean unit_hud_meters_add(
{
break;
}
- added = meter_bitmaps_mark(tag_cache, loaded_bytes, &meter->panel.meter);
+ added = meter_bitmaps_mark(view, &meter->panel.meter);
}
return added;
@@ -407,22 +522,20 @@ static boolean unit_hud_meters_add(
/* the bitmaps a unit HUD interface draws as they are */
static void unit_hud_statics_note(
- byte *tag_cache,
- unsigned long loaded_bytes,
+ struct tag_view const *view,
struct unit_hud_interface_definition *hud)
{
long element_index;
- static_bitmaps_mark(tag_cache, loaded_bytes, &hud->background);
- static_bitmaps_mark(tag_cache, loaded_bytes, &hud->shield_meter.background);
- static_bitmaps_mark(tag_cache, loaded_bytes, &hud->health_meter.background);
- static_bitmaps_mark(tag_cache, loaded_bytes, &hud->motion_sensor.background);
- static_bitmaps_mark(tag_cache, loaded_bytes, &hud->motion_sensor.foreground);
+ static_bitmaps_mark(view, &hud->background);
+ static_bitmaps_mark(view, &hud->shield_meter.background);
+ static_bitmaps_mark(view, &hud->health_meter.background);
+ static_bitmaps_mark(view, &hud->motion_sensor.background);
+ static_bitmaps_mark(view, &hud->motion_sensor.foreground);
for (element_index = 0; element_index < hud->auxilary_panel.auxilary_overlays.count; element_index++)
{
- struct auxilary_overlay_definition *overlay = custom_edition_cache_block_element(
- tag_cache,
- loaded_bytes,
+ struct auxilary_overlay_definition *overlay = view_block_element(
+ view,
&hud->auxilary_panel.auxilary_overlays,
element_index,
sizeof(*overlay));
@@ -431,13 +544,12 @@ static void unit_hud_statics_note(
{
break;
}
- static_bitmaps_mark(tag_cache, loaded_bytes, &overlay->static_element);
+ static_bitmaps_mark(view, &overlay->static_element);
}
for (element_index = 0; element_index < hud->auxilary_meters.count; element_index++)
{
- struct auxilary_meter_definition *meter = custom_edition_cache_block_element(
- tag_cache,
- loaded_bytes,
+ struct auxilary_meter_definition *meter = view_block_element(
+ view,
&hud->auxilary_meters,
element_index,
sizeof(*meter));
@@ -446,7 +558,7 @@ static void unit_hud_statics_note(
{
break;
}
- static_bitmaps_mark(tag_cache, loaded_bytes, &meter->panel.background);
+ static_bitmaps_mark(view, &meter->panel.background);
}
return;
@@ -454,8 +566,7 @@ static void unit_hud_statics_note(
/* the bitmaps of the HUD meters of a weapon HUD interface */
static boolean weapon_hud_meters_add(
- byte *tag_cache,
- unsigned long loaded_bytes,
+ struct tag_view const *view,
struct weapon_hud_interface_elements *hud)
{
boolean added = TRUE;
@@ -463,9 +574,8 @@ static boolean weapon_hud_meters_add(
for (meter_index = 0; added && meter_index < hud->meters.count; meter_index++)
{
- struct weapon_hud_meter_element *meter = custom_edition_cache_block_element(
- tag_cache,
- loaded_bytes,
+ struct weapon_hud_meter_element *meter = view_block_element(
+ view,
&hud->meters,
meter_index,
sizeof(*meter));
@@ -474,7 +584,7 @@ static boolean weapon_hud_meters_add(
{
break;
}
- added = meter_bitmaps_mark(tag_cache, loaded_bytes, &meter->meter_element);
+ added = meter_bitmaps_mark(view, &meter->meter_element);
}
return added;
@@ -482,17 +592,15 @@ static boolean weapon_hud_meters_add(
/* the bitmaps a weapon HUD interface draws as they are */
static void weapon_hud_statics_note(
- byte *tag_cache,
- unsigned long loaded_bytes,
+ struct tag_view const *view,
struct weapon_hud_interface_elements *hud)
{
long static_index;
for (static_index = 0; static_index < hud->statics.count; static_index++)
{
- struct weapon_hud_static_element *element = custom_edition_cache_block_element(
- tag_cache,
- loaded_bytes,
+ struct weapon_hud_static_element *element = view_block_element(
+ view,
&hud->statics,
static_index,
sizeof(*element));
@@ -501,59 +609,16 @@ static void weapon_hud_statics_note(
{
break;
}
- static_bitmaps_mark(tag_cache, loaded_bytes, &element->static_element);
+ static_bitmaps_mark(view, &element->static_element);
}
return;
}
-/* ---------- public code */
-
-boolean custom_edition_bitmaps_verify(
- byte *tag_cache,
- unsigned long loaded_bytes)
-{
- struct bitmap_group *group;
- int32_t tag_index = NONE;
- long bitmap_count = 0;
-
- while ((group = custom_edition_cache_tag_next(tag_cache, loaded_bytes, BITMAP_GROUP_TAG, sizeof(*group), &tag_index)) != NULL)
- {
- long bitmap_index;
-
- for (bitmap_index = 0; bitmap_index < group->bitmaps.count; bitmap_index++)
- {
- struct bitmap_data *bitmap = TAG_BLOCK_GET_ELEMENT(&group->bitmaps, bitmap_index, struct bitmap_data);
-
- if (!custom_edition_bitmap_drawable(bitmap))
- {
- error(
- _error_silent,
- "custom edition: bitmap %ld of '%s' (type %d, format %d, %dx%dx%d, flags 0x%X) cannot be drawn by this build",
- bitmap_index,
- custom_edition_cache_tag_name(tag_cache, loaded_bytes, tag_index),
- bitmap->type,
- bitmap->format,
- bitmap->width,
- bitmap->height,
- bitmap->depth,
- bitmap->flags);
- return FALSE;
- }
- bitmap_count++;
- }
- }
- error(
- _error_silent,
- "custom edition: %ld bitmaps can be drawn",
- bitmap_count);
-
- return TRUE;
-}
-
-boolean custom_edition_reordered_bitmaps_find(
- byte *tag_cache,
- unsigned long loaded_bytes)
+/* the bitmaps of the tags `view` reads whose channels Halo PC keeps
+elsewhere (custom_edition_reordered_bitmaps_find) */
+static boolean reordered_bitmaps_find(
+ struct tag_view const *view)
{
struct custom_edition_bitmaps_globals *globals = &custom_edition_bitmaps_globals;
struct shader_model_maps *shader;
@@ -568,25 +633,25 @@ boolean custom_edition_reordered_bitmaps_find(
/* the bitmaps drawn in Halo PC's orders: model shaders' multipurpose
maps and HUD meters */
tag_index = NONE;
- while ((shader = custom_edition_cache_tag_next(tag_cache, loaded_bytes, SHADER_MODEL_GROUP_TAG, sizeof(*shader), &tag_index)) != NULL)
+ while ((shader = view_tag_next(view, SHADER_MODEL_GROUP_TAG, sizeof(*shader), &tag_index)) != NULL)
{
- if (!reordered_bitmaps_mark(tag_cache, loaded_bytes, shader->multipurpose_map.index, NONE, _custom_edition_channels_multipurpose))
+ if (!reordered_bitmaps_mark(view, shader->multipurpose_map.index, NONE, _custom_edition_channels_multipurpose))
{
return FALSE;
}
}
tag_index = NONE;
- while ((unit_hud = custom_edition_cache_tag_next(tag_cache, loaded_bytes, UNIT_HUD_INTERFACE_DEFINITION_TAG, sizeof(*unit_hud), &tag_index)) != NULL)
+ while ((unit_hud = view_tag_next(view, UNIT_HUD_INTERFACE_DEFINITION_TAG, sizeof(*unit_hud), &tag_index)) != NULL)
{
- if (!unit_hud_meters_add(tag_cache, loaded_bytes, unit_hud))
+ if (!unit_hud_meters_add(view, unit_hud))
{
return FALSE;
}
}
tag_index = NONE;
- while ((weapon_hud = custom_edition_cache_tag_next(tag_cache, loaded_bytes, WEAPON_HUD_INTERFACE_GROUP_TAG, sizeof(*weapon_hud), &tag_index)) != NULL)
+ while ((weapon_hud = view_tag_next(view, WEAPON_HUD_INTERFACE_GROUP_TAG, sizeof(*weapon_hud), &tag_index)) != NULL)
{
- if (!weapon_hud_meters_add(tag_cache, loaded_bytes, weapon_hud))
+ if (!weapon_hud_meters_add(view, weapon_hud))
{
return FALSE;
}
@@ -595,21 +660,21 @@ boolean custom_edition_reordered_bitmaps_find(
/* and those drawn as they are too: a model shader's other maps and a
HUD's static elements */
tag_index = NONE;
- while ((shader = custom_edition_cache_tag_next(tag_cache, loaded_bytes, SHADER_MODEL_GROUP_TAG, sizeof(*shader), &tag_index)) != NULL)
+ while ((shader = view_tag_next(view, SHADER_MODEL_GROUP_TAG, sizeof(*shader), &tag_index)) != NULL)
{
- reordered_bitmaps_mark(tag_cache, loaded_bytes, shader->base_map.index, NONE, NONE);
- reordered_bitmaps_mark(tag_cache, loaded_bytes, shader->detail_map.index, NONE, NONE);
- reordered_bitmaps_mark(tag_cache, loaded_bytes, shader->reflection_cube_map.index, NONE, NONE);
+ reordered_bitmaps_mark(view, shader->base_map.index, NONE, NONE);
+ reordered_bitmaps_mark(view, shader->detail_map.index, NONE, NONE);
+ reordered_bitmaps_mark(view, shader->reflection_cube_map.index, NONE, NONE);
}
tag_index = NONE;
- while ((unit_hud = custom_edition_cache_tag_next(tag_cache, loaded_bytes, UNIT_HUD_INTERFACE_DEFINITION_TAG, sizeof(*unit_hud), &tag_index)) != NULL)
+ while ((unit_hud = view_tag_next(view, UNIT_HUD_INTERFACE_DEFINITION_TAG, sizeof(*unit_hud), &tag_index)) != NULL)
{
- unit_hud_statics_note(tag_cache, loaded_bytes, unit_hud);
+ unit_hud_statics_note(view, unit_hud);
}
tag_index = NONE;
- while ((weapon_hud = custom_edition_cache_tag_next(tag_cache, loaded_bytes, WEAPON_HUD_INTERFACE_GROUP_TAG, sizeof(*weapon_hud), &tag_index)) != NULL)
+ while ((weapon_hud = view_tag_next(view, WEAPON_HUD_INTERFACE_GROUP_TAG, sizeof(*weapon_hud), &tag_index)) != NULL)
{
- weapon_hud_statics_note(tag_cache, loaded_bytes, weapon_hud);
+ weapon_hud_statics_note(view, weapon_hud);
}
/* which keep their channels as they are */
@@ -622,9 +687,10 @@ boolean custom_edition_reordered_bitmaps_find(
{
error(
_error_silent,
- "custom edition: bitmap %d of '%s' is drawn with Halo PC's %s channels and otherwise too, and keeps its channels as they are",
+ "%s: bitmap %d of '%s' is drawn with Halo PC's %s channels and otherwise too, and keeps its channels as they are",
+ view->tag_cache ? "custom edition" : "mods",
bitmap->bitmap_index,
- custom_edition_cache_tag_name(tag_cache, loaded_bytes, DATUM_INDEX_TO_ABSOLUTE_INDEX(bitmap->handle)),
+ view_tag_name(view, DATUM_INDEX_TO_ABSOLUTE_INDEX(bitmap->handle)),
bitmap->channel_order == _custom_edition_channels_multipurpose ? "multipurpose map" : "HUD meter");
*bitmap = globals->reordered_bitmaps[--globals->reordered_bitmap_count];
kept_count++;
@@ -642,16 +708,108 @@ boolean custom_edition_reordered_bitmaps_find(
bitmap_index++;
}
}
+ if (view->tag_cache)
+ {
+ error(
+ _error_silent,
+ "custom edition: %ld multipurpose maps and %ld HUD meters are drawn with their channels in this build's order (%ld drawn otherwise too are not)",
+ multipurpose_count,
+ meter_count,
+ kept_count);
+ }
+ else
+ {
+ error(
+ _error_silent,
+ "mods: of the map's bitmaps, %ld are multipurpose maps and %ld HUD meters, a mod's drawn with its channels in this build's order (%ld drawn otherwise too are not)",
+ multipurpose_count,
+ meter_count,
+ kept_count);
+ }
+
+ return TRUE;
+}
+
+/* ---------- public code */
+
+boolean custom_edition_bitmaps_verify(
+ byte *tag_cache,
+ unsigned long loaded_bytes)
+{
+ struct bitmap_group *group;
+ int32_t tag_index = NONE;
+ long bitmap_count = 0;
+
+ while ((group = custom_edition_cache_tag_next(tag_cache, loaded_bytes, BITMAP_GROUP_TAG, sizeof(*group), &tag_index)) != NULL)
+ {
+ long bitmap_index;
+
+ for (bitmap_index = 0; bitmap_index < group->bitmaps.count; bitmap_index++)
+ {
+ struct bitmap_data *bitmap = TAG_BLOCK_GET_ELEMENT(&group->bitmaps, bitmap_index, struct bitmap_data);
+
+ if (!custom_edition_bitmap_drawable(bitmap))
+ {
+ error(
+ _error_silent,
+ "custom edition: bitmap %ld of '%s' (type %d, format %d, %dx%dx%d, flags 0x%X) cannot be drawn by this build",
+ bitmap_index,
+ custom_edition_cache_tag_name(tag_cache, loaded_bytes, tag_index),
+ bitmap->type,
+ bitmap->format,
+ bitmap->width,
+ bitmap->height,
+ bitmap->depth,
+ bitmap->flags);
+ return FALSE;
+ }
+ bitmap_count++;
+ }
+ }
error(
_error_silent,
- "custom edition: %ld multipurpose maps and %ld HUD meters are drawn with their channels in this build's order (%ld drawn otherwise too are not)",
- multipurpose_count,
- meter_count,
- kept_count);
+ "custom edition: %ld bitmaps can be drawn",
+ bitmap_count);
return TRUE;
}
+boolean custom_edition_reordered_bitmaps_find(
+ byte *tag_cache,
+ unsigned long loaded_bytes)
+{
+ struct tag_view view;
+
+ view.tag_cache = tag_cache;
+ view.loaded_bytes = loaded_bytes;
+
+ return reordered_bitmaps_find(&view);
+}
+
+boolean custom_edition_loaded_reordered_bitmaps_find(
+ void)
+{
+ struct custom_edition_bitmaps_globals *globals = &custom_edition_bitmaps_globals;
+ struct tag_view view;
+
+ /* (found again: the tags may have changed) */
+ globals->reordered_bitmap_count = 0;
+ view.tag_cache = NULL;
+ view.loaded_bytes = 0;
+
+ return reordered_bitmaps_find(&view);
+}
+
+short custom_edition_bitmap_channel_order(
+ long handle,
+ long bitmap_index)
+{
+ long index = reordered_bitmap_index(handle, bitmap_index);
+
+ return index != NONE ? custom_edition_bitmaps_globals.reordered_bitmaps[index].channel_order :
+ _custom_edition_channels_xbox;
+}
+
void custom_edition_bitmaps_dispose(
void)
{
diff --git a/port/linux/game/custom_edition_cache.h b/port/linux/game/custom_edition_cache.h
index bf317c4807..cea5f00a2f 100644
--- a/port/linux/game/custom_edition_cache.h
+++ b/port/linux/game/custom_edition_cache.h
@@ -43,6 +43,7 @@ characters of a level name (game_engine.c, struct game_engine_stage). */
/* ---------- structures */
+struct bitmap_data;
struct cache_file_tag_header;
struct custom_edition_load_report;
struct scenario_object_datum;
@@ -200,6 +201,22 @@ them either way. */
boolean custom_edition_reordered_bitmaps_find(
byte *tag_cache,
unsigned long loaded_bytes);
+/* As custom_edition_reordered_bitmaps_find, in the loaded map's tags, which
+the validator has checked: for the bitmaps of mods (mod_bitmaps.c), Halo
+PC's on any map. FALSE after logging why it cannot. */
+boolean custom_edition_loaded_reordered_bitmaps_find(
+ void);
+/* The order (enum custom_edition_channel_order) Halo PC keeps the channels
+of bitmap `bitmap_index` of the tag `handle` names in, of those found. */
+short custom_edition_bitmap_channel_order(
+ long handle,
+ long bitmap_index);
+/* Whether the texture cache can draw `bitmap` from pixels laid out as a
+Halo PC tag lays them out (rasterizer_xbox_bitmap_rebuild_hardware_format
+makes them the hardware's): the game's bitmap_verify, a format with a
+hardware texture, sizes it can swizzle, and every pixel its levels need. */
+boolean custom_edition_bitmap_drawable(
+ struct bitmap_data *bitmap);
/* Decodes every sound permutation of a tag cache custom_edition_cache_load
filled (its resource offsets combined) that this build would not play: Ogg
Vorbis, uncompressed, or mono at 44 kHz. Each is encoded again in memory as
@@ -217,7 +234,7 @@ boolean custom_edition_sounds_decode(
`compression` (16-bit PCM, little-endian or `big_endian`; Xbox ADPCM; Ogg
Vorbis), `channels` channels at `rate`, encoded as Xbox ADPCM at
`encoded_rate`: a buffer of the game's allocator of *encoded_bytes; NULL
-when they cannot be (loose_sounds.c) */
+when they cannot be (mod_sounds.c) */
byte *custom_edition_sounds_encode(
byte const *data,
long data_bytes,
diff --git a/port/linux/game/custom_edition_geometry.c b/port/linux/game/custom_edition_geometry.c
index 67016a1719..0c90a0caea 100644
--- a/port/linux/game/custom_edition_geometry.c
+++ b/port/linux/game/custom_edition_geometry.c
@@ -33,6 +33,7 @@ Edition vertices (docs/custom_edition_caches.md).
#include "structures/structure_bsp_definitions.h"
#include "cache_file_formats.h"
#include "custom_edition_cache.h"
+#include "mods.h"
#include
#include
@@ -914,7 +915,9 @@ short custom_edition_part_palette(
return globals->palettes[index].node_count;
}
}
- return 0;
+ /* (a mod's model of many nodes, drawn its parts' nodes at a time too:
+ mod_models.c) */
+ return mod_models_part_palette(vertex_buffer, nodes);
}
void custom_edition_models_dispose(
diff --git a/port/linux/game/custom_edition_sounds.c b/port/linux/game/custom_edition_sounds.c
index fba612e4c0..e4ed8065bd 100644
--- a/port/linux/game/custom_edition_sounds.c
+++ b/port/linux/game/custom_edition_sounds.c
@@ -304,7 +304,7 @@ static boolean vorbis_decode(
}
/* uncompressed samples, 16-bit little-endian as Halo PC's maps keep them
-(big-endian as its tag files do: loose_sounds.c) */
+(big-endian as its tag files do: mod_sounds.c) */
static boolean pcm_decode(
byte const *data,
long data_bytes,
diff --git a/port/linux/game/hud_button_layout.h b/port/linux/game/hud_button_layout.h
new file mode 100644
index 0000000000..0f15db3da7
--- /dev/null
+++ b/port/linux/game/hud_button_layout.h
@@ -0,0 +1,64 @@
+#ifndef HUD_BUTTON_LAYOUT_H
+#define HUD_BUTTON_LAYOUT_H
+
+/* Half-alpha bounds in the embedded 8x artwork. Match the visible Xbox
+ * A/B/X/Y face (21x26), rather than its transparent texture padding.
+ * Black/White retain their existing layout. */
+static void hud_button_face_bounds(short style, short index,
+ real *x, real *y, real *width, real *height)
+{
+ static short const bounds[3][4][4] = {
+ {{5,5,160,201}, {1,5,162,202}, {5,5,160,201}, {5,5,160,201}},
+ {{5,8,161,197}, {5,8,161,197}, {5,8,161,197}, {5,8,161,197}},
+ {{5,5,160,201}, {1,5,162,202}, {5,5,160,201}, {5,5,160,201}}
+ };
+ short const *face;
+ real sx, sy;
+
+ if (index < 0 || index >= 4 || style < 0 || style >= 3) return;
+ face = bounds[style][index];
+ sx = *width / (face[2] - face[0]);
+ sy = *height / (face[3] - face[1]);
+ *x -= face[0] * sx;
+ *y -= face[1] * sy;
+ *width = 168.0f * sx;
+ *height = 208.0f * sy;
+}
+
+/* HUD controller indices 14/15 animate a stick press; 16/17 show a stick.
+ * Follow the original three-frame sequence and its authored tick divisor. */
+static short hud_button_control_slot(short index, long frame)
+{
+ if (index == 6 || index == 7) return index;
+ if (index == 16 || index == 17) return index - 8;
+ if (index == 14 || index == 15)
+ return (short)(index - 6 + 2 * (frame < 0 ? 0 : frame % 3));
+ return -1;
+}
+
+static char const *hud_button_control_name(short slot)
+{
+ static char const *names[] = {
+ "ui\\hud\\buttons\\trigger_left", "ui\\hud\\buttons\\trigger_right",
+ "ui\\hud\\buttons\\thumbstick_left", "ui\\hud\\buttons\\thumbstick_right",
+ "ui\\hud\\buttons\\thumbstick-halfway_left", "ui\\hud\\buttons\\thumbstick-halfway_right",
+ "ui\\hud\\buttons\\thumbstick-full_left", "ui\\hud\\buttons\\thumbstick-full_right"
+ };
+ return slot >= 6 && slot < 14 ? names[slot - 6] : 0;
+}
+
+/* Original combined HUD sheet: triggers have a 44x33 visible face and
+ * both sticks have 32x34 in all three frames. Exclude texture padding. */
+static void hud_button_control_bounds(short slot, real *x, real *y, real *width, real *height)
+{
+ real sx, sy;
+ if (slot < 6 || slot >= 14) return;
+ sx = *width / (slot < 8 ? 344.0f : 272.0f);
+ sy = *height / (slot < 8 ? 264.0f : (slot & 1) ? 294.0f : 293.0f);
+ *x -= (slot < 8 ? 28.0f : 8.0f) * sx;
+ *y -= (slot < 8 ? 8.0f : 13.0f) * sy;
+ *width = (slot < 8 ? 400.0f : 288.0f) * sx;
+ *height = (slot < 8 ? 280.0f : 320.0f) * sy;
+}
+
+#endif
diff --git a/port/linux/game/hud_hires_tags.c b/port/linux/game/hud_hires_tags.c
index 38b39ded8d..2286674ac4 100644
--- a/port/linux/game/hud_hires_tags.c
+++ b/port/linux/game/hud_hires_tags.c
@@ -26,10 +26,19 @@ When the texture cannot be drawn, the sprites are drawn from their bitmap.
#include "bitmaps/bitmaps.h"
#include "rasterizer/xbox/rasterizer_xbox_hardware_bitmaps.h"
#include "tag_files/tag_groups.h"
+#include "game/players.h"
+#include "game/game.h"
+#include "interface/hud.h"
+#include "interface/hud_definitions.h"
+#include "rasterizer/rasterizer.h"
+#include "text/draw_string.h"
+#include "hud_button_layout.h"
/* the platform layer's (port/linux/src) */
void platform_log(char const *format, ...);
unsigned long config_changes(void);
+int config_boolean(char const *name);
+char const *config_string(char const *name);
long hud_hires_asset_count(void);
char const *hud_hires_asset_tag(long asset);
long hud_hires_asset_bitmap(long asset);
@@ -60,6 +69,10 @@ static struct
} hires_bitmaps[MAXIMUM_HIRES_BITMAPS];
static long hires_bitmap_count = 0;
+/* Sheets without redraws: retain their original lettering and sprite layout. */
+static struct { struct bitmap_data *bitmap; long kind; } upscale_bitmaps[16];
+static long upscale_bitmap_count;
+
/* the bitmaps some of whose sprites have a texture, with their placeholder,
and whether the texture can be drawn for the pixels at checked_address
(checked again when the texture cache loads them elsewhere, or the settings
@@ -76,6 +89,29 @@ static struct
} hires_sprite_bitmaps[MAXIMUM_HIRES_SPRITE_BITMAPS];
static long hires_sprite_bitmap_count = 0;
+/* Original Xbox controller faces, shared with the menu redraws. */
+static struct
+{
+ struct bitmap_data *bitmap;
+ long asset;
+ boolean attempted;
+} hud_buttons[14];
+static short hud_buttons_style;
+
+static void hud_buttons_forget(void)
+{
+ long index;
+ for (index = 0; index < 14; index++)
+ {
+ if (hud_buttons[index].bitmap)
+ {
+ hud_hires_register_placeholder(hud_buttons[index].asset, NULL);
+ bitmap_delete(hud_buttons[index].bitmap);
+ }
+ }
+ csmemset(hud_buttons, 0, sizeof(hud_buttons));
+}
+
/* ---------- private code */
static void hud_hires_sprite_bitmaps_forget(
@@ -92,6 +128,7 @@ static void hud_hires_sprite_bitmaps_forget(
}
}
hires_sprite_bitmap_count = 0;
+ hud_buttons_forget();
return;
}
@@ -107,6 +144,36 @@ void hud_hires_tags_loaded(
hires_bitmap_count = 0;
hud_hires_sprite_bitmaps_forget();
+ upscale_bitmap_count = 0;
+ {
+ static struct { char const *tag; short first, last; } const sheets[] = {
+ { "ui\\hud\\bitmaps\\combined\\hud_unit_backgrounds", 8, 12 },
+ { "ui\\hud\\bitmaps\\combined\\hud_reticle_warnings", 0, 0 },
+ { "ui\\hud\\bitmaps\\combined\\hud_msg_icons", 0, 3 },
+ { "ui\\hud\\bitmaps\\hud_msg_icons_sm", 0, 1 }
+ };
+ long sheet;
+ short index;
+
+ for (sheet = 0; sheet < NUMBEROF(sheets); sheet++)
+ {
+ long group = tag_loaded(BITMAP_GROUP_TAG, sheets[sheet].tag);
+
+ if (group == NONE)
+ continue;
+ for (index = sheets[sheet].first; index <= sheets[sheet].last; index++)
+ {
+ struct bitmap_data *bitmap = bitmap_group_try_and_get_bitmap(group, index);
+
+ if (bitmap && upscale_bitmap_count < NUMBEROF(upscale_bitmaps))
+ {
+ upscale_bitmaps[upscale_bitmap_count].bitmap = bitmap;
+ upscale_bitmaps[upscale_bitmap_count++].kind = sheet == 0 ?
+ (index == 9 || index == 10 ? 3 : 2) : 1;
+ }
+ }
+ }
+ }
for (asset = 0; asset < asset_count && hires_bitmap_count < MAXIMUM_HIRES_BITMAPS; asset++)
{
long group_index = tag_loaded(BITMAP_GROUP_TAG, hud_hires_asset_tag(asset));
@@ -150,6 +217,7 @@ void hud_hires_tags_unloaded(
void)
{
hires_bitmap_count = 0;
+ upscale_bitmap_count = 0;
hud_hires_sprite_bitmaps_forget();
return;
@@ -244,3 +312,132 @@ long hud_hires_asset_at(
return NONE;
}
+
+long hud_hires_upscale_at(unsigned long address, long width, long height)
+{
+ long index;
+
+ for (index = 0; index < upscale_bitmap_count; index++)
+ {
+ struct bitmap_data const *bitmap = upscale_bitmaps[index].bitmap;
+
+ if (bitmap->cache_block_index != NONE && (unsigned long)bitmap->base_address == address &&
+ bitmap->width == width && bitmap->height == height)
+ return upscale_bitmaps[index].kind;
+ }
+ return FALSE;
+}
+
+/* Match the controller entry itself: weapon icons may reuse its sequence.
+ * Draw the existing Xbox menu faces with straight alpha, independently of
+ * the messaging sheet and its tint. Disabling the HUD keeps the map's art. */
+boolean hud_hires_button_draw(struct icon_hud_element_definition const *icon,
+ rectangle2d *cursor, pixel32 color)
+{
+ static char const *names[] = {
+ "ui\\shell\\bitmaps\\a_butn", "ui\\shell\\bitmaps\\b_butn",
+ "ui\\shell\\bitmaps\\x_butn", "ui\\shell\\bitmaps\\y_butn",
+ "ui\\shell\\bitmaps\\black_butn", "ui\\shell\\bitmaps\\white_butn"};
+ static char const *classic_names[] = {
+ "ui\\hud\\buttons\\classic_a", "ui\\hud\\buttons\\classic_b",
+ "ui\\hud\\buttons\\classic_x", "ui\\hud\\buttons\\classic_y",
+ "ui\\hud\\buttons\\classic_black", "ui\\hud\\buttons\\classic_white"};
+ static char const *gem_names[] = {
+ "ui\\hud\\buttons\\gem_a", "ui\\hud\\buttons\\gem_b",
+ "ui\\hud\\buttons\\gem_x", "ui\\hud\\buttons\\gem_y",
+ "ui\\shell\\bitmaps\\black_butn", "ui\\shell\\bitmaps\\white_butn"};
+ struct icon_hud_element_definition const *buttons;
+ struct rasterizer_dynamic_screen_geometry_parameters parameters;
+ struct dynamic_screen_vertex vertices[4];
+ struct bitmap_data *bitmap;
+ real viewport, scale, x, y, width, height, u, v;
+ short advance;
+ long index, asset;
+ short i;
+ short style, slot;
+
+ if (!config_boolean("display.high_res_hud") || !hud_globals)
+ return FALSE;
+ buttons = hud_globals->messaging.button_icons.address;
+ if (!buttons) return FALSE;
+ for (index = 0; index < 18 && index < hud_globals->messaging.button_icons.count; index++)
+ if (icon == buttons + index) break;
+ if (index >= 18 || index >= hud_globals->messaging.button_icons.count)
+ return FALSE;
+ slot = index < 6 ? (short)index : hud_button_control_slot((short)index,
+ icon->frame_rate > 0 ? game_time_get() / icon->frame_rate : 0);
+ if (slot < 0) return FALSE;
+ style = (!strcmp(config_string("display.hud_button_style"), "dark_gems") || !strcmp(config_string("display.hud_button_style"), "menu_gems")) ? 2 :
+ !strcmp(config_string("display.hud_button_style"), "gems");
+ if (style != hud_buttons_style)
+ {
+ hud_buttons_forget();
+ hud_buttons_style = style;
+ }
+ if (!hud_buttons[slot].attempted)
+ {
+ hud_buttons[slot].attempted = TRUE;
+ for (asset = 0; asset < hud_hires_asset_count(); asset++)
+ {
+ if (strcmp(hud_hires_asset_tag(asset), slot >= 6 ? hud_button_control_name(slot) : style == 0 ? classic_names[index] :
+ style == 1 ? gem_names[index] : names[index])) continue;
+ bitmap = bitmap_2d_new(slot >= 6 ? 64 : 32, slot >= 6 ? 64 : 32, 0, 11);
+ if (!bitmap) break;
+ if (!rasterizer_bitmap_new(bitmap))
+ {
+ bitmap_delete(bitmap);
+ break;
+ }
+ hud_hires_register_placeholder(asset, bitmap->hardware_format);
+ hud_buttons[slot].asset = asset;
+ hud_buttons[slot].bitmap = bitmap;
+ break;
+ }
+ }
+ bitmap = hud_buttons[slot].bitmap;
+ if (!bitmap) return FALSE;
+ viewport = local_player_count() > 1 ? 0.75f : 1.0f;
+ scale = viewport;
+ /* Account for the text renderer's leading inset on the following run. */
+ x = cursor->x0 + 4.0f * viewport;
+ y = draw_unicode_string_capital_middle(cursor, L"") + 13.0f * viewport;
+ width = 21.0f * scale;
+ height = 26.0f * scale;
+ advance = (short)(cursor->x0 + (index < 4 ? 22.0f : 20.0f) * viewport);
+ y -= height;
+ hud_button_face_bounds(style, (short)index, &x, &y, &width, &height);
+ u = 21.0f / 32.0f;
+ v = 26.0f / 32.0f;
+ if (slot >= 6)
+ {
+ width = (slot < 8 ? 44.0f : 32.0f) * scale;
+ height = (slot < 8 ? 33.0f : 34.0f) * scale;
+ x = cursor->x0 + 4.0f * viewport;
+ y = draw_unicode_string_capital_middle(cursor, L"") - height * 0.5f;
+ u = (slot < 8 ? 50.0f : 36.0f) / 64.0f;
+ v = (slot < 8 ? 35.0f : 40.0f) / 64.0f;
+ advance = (short)(cursor->x0 + width + viewport);
+ hud_button_control_bounds(slot, &x, &y, &width, &height);
+ }
+ csmemset(¶meters, 0, sizeof(parameters));
+ parameters.map[0] = bitmap;
+ parameters.map_texture_scale[0].i = parameters.map_texture_scale[0].j = 1.0f;
+ parameters.map_scale[0].i = parameters.map_scale[0].j = 1.0f;
+ /* Stick/trigger art uses the original HUD blend, with premultiplied edges. */
+ parameters.framebuffer_blend_function = slot >= 6 ? 7 : 0;
+ /* Keep the icon's authored opacity, as the low-resolution path does.
+ * The embedded artwork supplies RGB; prompt text may be translucent. */
+ if (icon->flags & 2) color = icon->color;
+ for (i = 0; i < 4; i++)
+ {
+ boolean right = ((i + 1) & 2) != 0, bottom = i > 1;
+ vertices[i].position.x = x + (right ? width : 0.0f);
+ vertices[i].position.y = y + (bottom ? height : 0.0f);
+ vertices[i].texture_coordinates.x = right ? u : 0.0f;
+ vertices[i].texture_coordinates.y = bottom ? v : 0.0f;
+ vertices[i].color = slot >= 6 ? color : (color & 0xFF000000UL) | 0xFFFFFFUL;
+ }
+ rasterizer_psuedo_dynamic_screen_quad_draw(¶meters, vertices);
+ cursor->x0 = advance;
+ return TRUE;
+}
diff --git a/port/linux/game/menu_functions.c b/port/linux/game/menu_functions.c
index 8bf724b8d4..42b5dfb4b5 100644
--- a/port/linux/game/menu_functions.c
+++ b/port/linux/game/menu_functions.c
@@ -1524,6 +1524,36 @@ static void video_rows_show(struct widget_instance *list)
}
}
+/* Graphics' button style follows the pending HUD choice, including Defaults.
+The remaining options close the gap; the help and bottom buttons stay put. */
+static void hud_button_style_row_show(struct widget_instance *list)
+{
+ struct widget_instance *hud = named(list, "high_res_hud_spinner", 0);
+ struct widget_instance *row = named(list, "op_hud_button_style", 0);
+ struct pc_menu_setting *setting = hud ? pc_menu_setting_get(hud->definition_tag_index) : NULL;
+ struct widget_instance *child;
+ short index;
+ boolean visible;
+
+ if (!setting || !row) return;
+ index = hud->parameters.list.selected_index;
+ visible = index >= 0 && index < setting->value_count && !strcmp(setting->values[index], "true");
+ if (row->visible != visible)
+ {
+ for (child = row->next; child; child = child->next)
+ if (!strncmp(child->name, "op_", 3))
+ child->vertical_offset += visible ? 30 : -30;
+ row->visible = visible;
+ }
+ if (!visible && list->focused_child == row)
+ {
+ for (index = 0, child = list->child; child && child != hud->parent; child = child->next)
+ index++;
+ list->focused_child = hud->parent;
+ list->parameters.list.selected_index = index;
+ }
+}
+
/* ---------- Change Color: the profile's colour, from a list of the
game's colours (more than its rows: it scrolls), by the Xbox's names for
its spinner's functions */
@@ -2161,6 +2191,462 @@ static void controls_update(struct widget_instance *list)
}
}
+/* ---------- Mods (the main menu's: tools/port_settings.py's _mods), laid
+out as the lobby is: the mods folder's mods, a row each (op_mod_), as a
+resource pack list is, the one that plays over the others at the top (the
+load order upside down: the mod loaded last first), turned on or off and
+moved up or down there, and in the frame beside them the mod chosen's
+details; OK keeps it (port/linux/src/port_mods.c's load_order.toml) and
+plays them on the map now (mods.c), Cancel forgets it */
+
+#define MOD_ROWS 11
+#define MOD_DETAILS_LENGTH 1024
+/* the details' lines: as many as fit down the frame */
+#define MOD_DETAILS_LINES 13
+/* (from the shadow of each box's last character) */
+#define MOD_TEXT_MARGIN 8
+
+/* the text boxes whose lines are measured: the details' mod name and the
+rest, and the rows' names */
+enum
+{
+ _mod_details_name,
+ _mod_details_text,
+ _mod_row_name,
+ NUMBER_OF_MOD_TEXT_BOXES
+};
+
+/* the text boxes' names, and as many characters as fit across each until
+it has been drawn, and its font is known */
+static char const *const mod_text_box_names[NUMBER_OF_MOD_TEXT_BOXES] = { "mod_details_name", "mod_details_text",
+ "mod_name" };
+static short const mod_text_box_characters[NUMBER_OF_MOD_TEXT_BOXES] = { 17, 30, 24 };
+
+/* (port/linux/src/port_mods.c, port_mods.h's) */
+enum
+{
+ _port_mod_fine,
+ _port_mod_out_of_order,
+ _port_mod_incompatible,
+ _port_mod_unplayable,
+};
+
+long port_mods_count(void);
+long port_mods_at(long position);
+char const *port_mods_name(long index);
+int port_mods_on(long index);
+int port_mods_status(long index, char *text, unsigned long size);
+int port_mods_details(long index, char *text, unsigned long size);
+long port_mods_move(long position, long places);
+void port_mods_turn(long index, int on);
+int port_mods_save(void);
+void port_mods_revert(void);
+/* (mods.c) */
+void mods_apply(void);
+
+static struct
+{
+ /* the place on the list (0 the top: the mod loaded last) of the first
+ row, and of the mod chosen last (MOVE UP and MOVE DOWN's, which take the
+ focus from the rows) */
+ short first;
+ short chosen;
+ /* the text boxes' fonts and widths as they were last drawn
+ (menu_functions_text_box_drawn), which their lines are measured in;
+ NONE before then */
+ long fonts[NUMBER_OF_MOD_TEXT_BOXES];
+ short widths[NUMBER_OF_MOD_TEXT_BOXES];
+ /* what the details show (they are wrapped again when it changes), and
+ the mod each row's name is of, in which font (NONE: none yet) */
+ char shown[128];
+ long row_mods[MOD_ROWS];
+ long row_fonts[MOD_ROWS];
+} mods_screen;
+
+/* the row (op_mod_) a widget is or is in, else NULL */
+static struct widget_instance *mod_row(struct widget_instance *widget)
+{
+ for (; widget; widget = widget->parent)
+ {
+ if (!strncmp(widget->name, "op_mod_", 7))
+ return widget;
+ }
+ return NULL;
+}
+
+/* the place on the list of the mod on a row, else NONE */
+static short mod_row_place(struct widget_instance *row)
+{
+ short place = row ? (short)(mods_screen.first + atoi(row->name + 7) - 1) : NONE;
+
+ return place >= 0 && place < port_mods_count() ? place : NONE;
+}
+
+/* the mod at a place on the list (its place in the load order counted from
+the end), else NONE */
+static long mod_at_place(short place)
+{
+ long count = port_mods_count();
+
+ return place >= 0 && place < count ? port_mods_at(count - 1 - place) : NONE;
+}
+
+/* UTF-8 text (mod.toml's) as the menus' (a line break the game's \r\n; a
+character the menus' UTF-16 cannot hold, or a broken one, a ?) */
+static void mods_wide_text(char const *text, wchar_t *wide, short length)
+{
+ short index = 0;
+
+ while (*text && index < length - 2)
+ {
+ unsigned char first = (unsigned char)*text++;
+ unsigned long character = first;
+ short following = first >= 0xF0 ? 3 : first >= 0xE0 ? 2 : first >= 0xC0 ? 1 : 0;
+
+ if (first >= 0x80)
+ {
+ character = first & (0x3F >> following);
+ for (; following > 0 && ((unsigned char)*text & 0xC0) == 0x80; following--)
+ character = (character << 6) | ((unsigned char)*text++ & 0x3F);
+ if (following || first < 0xC0 || character > 0xFFFF)
+ character = '?';
+ }
+ if (character == '\n')
+ wide[index++] = '\r';
+ wide[index++] = (wchar_t)character;
+ }
+ wide[index] = 0;
+}
+
+/* the start of the UTF-8 character before `at` in text */
+static size_t mods_character_before(char const *text, size_t at)
+{
+ if (at > 0)
+ at--;
+ while (at > 0 && ((unsigned char)text[at] & 0xC0) == 0x80)
+ at--;
+ return at;
+}
+
+/* whether `count` bytes of text fit across a text box (box): measured in
+its font, from its left as it is drawn (leading spaces and all), else
+counted */
+static boolean mods_text_fits(short box, char const *text, size_t count)
+{
+ static real_argb_color const white = { 1.0f, 1.0f, 1.0f, 1.0f };
+ char part[MOD_DETAILS_LENGTH];
+ wchar_t wide[MOD_DETAILS_LENGTH];
+ rectangle2d bounds;
+ rectangle2d text_bounds;
+ rectangle2d cursor;
+
+ if (!count)
+ return TRUE;
+ if (mods_screen.fonts[box] == NONE)
+ return count <= (size_t)mod_text_box_characters[box];
+ if (count >= sizeof(part))
+ return FALSE;
+ memcpy(part, text, count);
+ part[count] = 0;
+ mods_wide_text(part, wide, NUMBEROF(wide));
+ bounds.x0 = 0;
+ bounds.y0 = 0;
+ bounds.x1 = 0x3FFF;
+ bounds.y1 = 480;
+ draw_string_set_draw_mode(mods_screen.fonts[box], NONE, 0, 0, &white);
+ draw_unicode_string_compute_bounds(&bounds, wide, &text_bounds, &cursor);
+ return MAX(text_bounds.x1, cursor.x0) - bounds.x0 <= mods_screen.widths[box] - MOD_TEXT_MARGIN;
+}
+
+/* `count` bytes of text, as much of them as fits across a text box (box)
+with "..." after it, into `shortened` */
+static void mods_text_shorten(short box, char const *text, size_t count, char *shortened, size_t size)
+{
+ size_t kept = count;
+
+ for (;;)
+ {
+ snprintf(shortened, size, "%.*s...", (int)kept, text);
+ if (!kept || mods_text_fits(box, shortened, strlen(shortened)))
+ return;
+ kept = mods_character_before(text, kept);
+ }
+}
+
+/* text, after what `wrapped` holds, in lines that fit across a text box
+(box), broken at spaces and at its own line breaks (word by word: a word
+too long for a line broken where it fits); `lines` lines in all at most,
+the last cut short with "..." when there is more: how many it holds now */
+static short mods_wrap(char const *text, char *wrapped, size_t size, short box, short lines, short line)
+{
+ size_t length = strlen(wrapped);
+
+ while (line < lines && *text)
+ {
+ char const *end = strchr(text, '\n');
+ size_t count = end ? (size_t)(end - text) : strlen(text);
+ size_t cut = 0;
+ boolean more;
+
+ for (;;)
+ {
+ size_t next = cut;
+
+ while (next < count && text[next] == ' ')
+ next++;
+ while (next < count && text[next] != ' ')
+ next++;
+ if (next == cut || !mods_text_fits(box, text, next))
+ break;
+ cut = next;
+ }
+ if (!cut && count)
+ {
+ for (cut = 0; cut < count && text[cut] == ' '; cut++)
+ {
+ }
+ while (cut < count && text[cut] != ' ')
+ cut++;
+ while (cut > 1 && !mods_text_fits(box, text, cut))
+ cut = mods_character_before(text, cut);
+ if (!cut)
+ cut = 1;
+ }
+ more = cut < count || (text[count] == '\n' && text[count + 1]);
+ if (line == lines - 1 && more)
+ {
+ char shortened[MOD_DETAILS_LENGTH];
+
+ mods_text_shorten(box, text, cut, shortened, sizeof(shortened));
+ snprintf(wrapped + length, size - length, "%s%s", length ? "\n" : "", shortened);
+ return lines;
+ }
+ snprintf(wrapped + length, size - length, "%s%.*s", length ? "\n" : "", (int)cut, text);
+ length = strlen(wrapped);
+ line++;
+ text += cut;
+ while (*text == ' ')
+ text++;
+ if (*text == '\n')
+ text++;
+ }
+ return line;
+}
+
+/* "port mods init" (the list's created), and "port mods close" (its
+deleted: what OK has not kept is forgotten) */
+static boolean mods_screen_reset(void)
+{
+ short index;
+
+ port_mods_revert();
+ mods_screen.first = 0;
+ mods_screen.chosen = 0;
+ mods_screen.shown[0] = 0;
+ for (index = 0; index < NUMBER_OF_MOD_TEXT_BOXES; index++)
+ {
+ mods_screen.fonts[index] = NONE;
+ mods_screen.widths[index] = 0;
+ }
+ for (index = 0; index < MOD_ROWS; index++)
+ mods_screen.row_mods[index] = NONE;
+ return TRUE;
+}
+
+/* "port mods toggle" (A on a row; a mod that cannot be played stays as it
+is, so that it is still off when it can be) */
+static boolean mods_toggle(struct widget_instance *widget)
+{
+ short place = mod_row_place(mod_row(widget));
+ long mod = mod_at_place(place);
+
+ if (place == NONE)
+ return FALSE;
+ if (port_mods_status(mod, NULL, 0) != _port_mod_unplayable)
+ port_mods_turn(mod, !port_mods_on(mod));
+ return TRUE;
+}
+
+/* "port mods move up" and "port mods move down" (left and right on a row,
+and the buttons): the mod of the row, else the one chosen last, moved one
+place on the list (up: later in the load order, over one more mod); the
+focus follows it on its row */
+static boolean mods_move(struct widget_instance *widget, short places)
+{
+ struct widget_instance *row = mod_row(widget);
+ short count = (short)port_mods_count();
+ short place = row ? mod_row_place(row) : mods_screen.chosen;
+ short moved;
+
+ /* (no mods: nothing to move) */
+ if (!count)
+ return TRUE;
+ if (place == NONE || place >= count)
+ return FALSE;
+ moved = (short)(count - 1 - port_mods_move(count - 1 - place, -places));
+ mods_screen.chosen = moved;
+ if (moved < mods_screen.first)
+ mods_screen.first = moved;
+ else if (moved >= mods_screen.first + MOD_ROWS)
+ mods_screen.first = (short)(moved - MOD_ROWS + 1);
+ if (row && row->parent)
+ focus_row(row->parent, (short)(moved - mods_screen.first));
+ return TRUE;
+}
+
+/* the details of the mod at `place` on the list, in the frame (the list's
+description): its name; what is wrong with it, beside its icon; whether it
+is on and its priority (1 the top, the mod that wins); and what its
+mod.toml says */
+static void mods_details_show(struct widget_instance *details, short place, short count)
+{
+ long mod = mod_at_place(place);
+ struct widget_instance *icon = named(details, "mod_details_status", 0);
+ char problem[MOD_DETAILS_LENGTH];
+ char about[MOD_DETAILS_LENGTH];
+ char text[MOD_DETAILS_LENGTH];
+ wchar_t wide[MOD_DETAILS_LENGTH];
+ int status;
+ short line;
+ char shown[sizeof(mods_screen.shown)];
+
+ /* (wrapped again only when what they show, or its fonts, change) */
+ snprintf(shown, sizeof(shown), "%ld %d %d %d %d %ld %d %ld %d", mod, port_mods_status(mod, NULL, 0),
+ port_mods_on(mod), place, count, mods_screen.fonts[_mod_details_name],
+ mods_screen.widths[_mod_details_name], mods_screen.fonts[_mod_details_text],
+ mods_screen.widths[_mod_details_text]);
+ if (!strcmp(shown, mods_screen.shown))
+ return;
+ strcpy(mods_screen.shown, shown);
+ if (mod < 0)
+ {
+ /* (no mods: where they go) */
+ mods_wide_text(count ? "" : "No mods", wide, NUMBEROF(wide));
+ text_set_length(named(details, "mod_details_name", 0), wide, NUMBEROF(wide));
+ text[0] = 0;
+ if (!count)
+ {
+ mods_wrap("Each mod is a folder of its own in the mods folder, beside the maps folder.", text,
+ sizeof(text), _mod_details_text, MOD_DETAILS_LINES, 0);
+ }
+ mods_wide_text(text, wide, NUMBEROF(wide));
+ text_set_length(named(details, "mod_details_text", 0), wide, NUMBEROF(wide));
+ visible_set(icon, FALSE);
+ return;
+ }
+ status = port_mods_status(mod, problem, sizeof(problem));
+ text[0] = 0;
+ mods_wrap(port_mods_name(mod), text, sizeof(text), _mod_details_name, 2, 0);
+ mods_wide_text(text, wide, NUMBEROF(wide));
+ text_set_length(named(details, "mod_details_name", 0), wide, NUMBEROF(wide));
+ visible_set(icon, status != _port_mod_fine);
+ if (icon)
+ icon->animation.current_frame_index = status == _port_mod_out_of_order ? 1 : 2;
+ text[0] = 0;
+ line = 0;
+ if (status != _port_mod_fine)
+ {
+ /* (its first line indented, for the icon) */
+ snprintf(text, sizeof(text), " %s", problem);
+ strcpy(problem, text);
+ text[0] = 0;
+ line = mods_wrap(problem, text, sizeof(text), _mod_details_text, MOD_DETAILS_LINES, line);
+ line = mods_wrap(" ", text, sizeof(text), _mod_details_text, MOD_DETAILS_LINES, line);
+ }
+ snprintf(about, sizeof(about), "%s\nPriority: %d of %d", status == _port_mod_unplayable ? "Cannot be played" :
+ port_mods_on(mod) ? "On" : "Off", place + 1, count);
+ line = mods_wrap(about, text, sizeof(text), _mod_details_text, MOD_DETAILS_LINES, line);
+ line = mods_wrap(" ", text, sizeof(text), _mod_details_text, MOD_DETAILS_LINES, line);
+ port_mods_details(mod, about, sizeof(about));
+ mods_wrap(about, text, sizeof(text), _mod_details_text, MOD_DETAILS_LINES, line);
+ mods_wide_text(text, wide, NUMBEROF(wide));
+ text_set_length(named(details, "mod_details_text", 0), wide, NUMBEROF(wide));
+}
+
+/* "port mods update": the rows (the list moving on at its ends while there
+are more mods past them), each mod's name (cut short where it is too long),
+ON or OFF and its icon (out of the order it asks for; another says it cannot
+be played with it, or it cannot be played), and the details of the mod
+chosen */
+static void mods_update(struct widget_instance *list)
+{
+ struct widget_instance *row;
+ struct widget_instance *focused = mod_row(list->focused_child);
+ short count = (short)port_mods_count();
+ short focused_index = focused ? (short)(atoi(focused->name + 7) - 1) : NONE;
+
+ if (focused_index == MOD_ROWS - 1 && mods_screen.first + MOD_ROWS < count && list_scrolls_at_end())
+ {
+ mods_screen.first++;
+ focus_row(list, --focused_index);
+ }
+ else if (focused_index == 0 && mods_screen.first > 0 && list_scrolls_at_end())
+ {
+ mods_screen.first--;
+ focus_row(list, ++focused_index);
+ }
+ if (mods_screen.first > 0 && mods_screen.first + MOD_ROWS > count)
+ mods_screen.first = (short)MAX(0, count - MOD_ROWS);
+ if (focused_index != NONE && mods_screen.first + focused_index < count)
+ mods_screen.chosen = (short)(mods_screen.first + focused_index);
+ for (row = list->child; row; row = row->next)
+ {
+ short place;
+ short row_index;
+ long mod;
+ struct widget_instance *icon;
+ int status;
+
+ if (strncmp(row->name, "op_mod_", 7))
+ continue;
+ /* (its background: the focused one's outlined frame, which the
+ engine shows only on a bitmap of two frames, as the browser's) */
+ row->animation.current_frame_index = row == focused ? 1 : 0;
+ place = mod_row_place(row);
+ row->visible = place != NONE;
+ if (place == NONE)
+ {
+ if (row == focused)
+ focus_row(list, count ? (short)(count - 1 - mods_screen.first) : NONE);
+ continue;
+ }
+ mod = mod_at_place(place);
+ status = port_mods_status(mod, NULL, 0);
+ row_index = (short)PIN(atoi(row->name + 7) - 1, 0, MOD_ROWS - 1);
+ if (mods_screen.row_mods[row_index] != mod ||
+ mods_screen.row_fonts[row_index] != mods_screen.fonts[_mod_row_name])
+ {
+ char const *name = port_mods_name(mod);
+ char shortened[MOD_DETAILS_LENGTH];
+ wchar_t wide[ROW_TEXT_LENGTH];
+
+ if (mods_text_fits(_mod_row_name, name, strlen(name)))
+ snprintf(shortened, sizeof(shortened), "%s", name);
+ else
+ mods_text_shorten(_mod_row_name, name, strlen(name), shortened, sizeof(shortened));
+ mods_wide_text(shortened, wide, ROW_TEXT_LENGTH);
+ text_set(named(row, "mod_name", 0), wide);
+ mods_screen.row_mods[row_index] = mod;
+ mods_screen.row_fonts[row_index] = mods_screen.fonts[_mod_row_name];
+ }
+ text_set(named(row, "mod_state", 0), port_mods_on(mod) ? L"ON" : L"OFF");
+ icon = named(row, "mod_status", 0);
+ visible_set(icon, status != _port_mod_fine);
+ if (icon)
+ icon->animation.current_frame_index = status == _port_mod_out_of_order ? 1 : 2;
+ }
+ mods_details_show(list->parameters.list.extended_description, count ? mods_screen.chosen : NONE, count);
+}
+
+/* "port mods save" (OK): kept, and played at once (port_mods_save keeps
+what it cannot write until the game quits) */
+static boolean mods_save(void)
+{
+ port_mods_save();
+ mods_apply();
+ return TRUE;
+}
+
/* ---------- Multiplayer (port/assets/menus' PLAN: Create Game > Internet
or LAN; Join Game > Server Browser (blank, for now), LAN, Direct Link): the
Xbox's networking, run by the engine's port entry points
@@ -4319,6 +4805,18 @@ void menu_functions_text_box_drawn(struct widget_instance *widget, rectangle2d c
{
short row;
+ /* (the Mods screen's texts: the fonts and widths their lines are
+ measured in, mods_text_fits) */
+ for (row = 0; widget->name && row < NUMBER_OF_MOD_TEXT_BOXES; row++)
+ {
+ if (!strcmp(widget->name, mod_text_box_names[row]))
+ {
+ mods_screen.fonts[row] = draw_string_get_font();
+ mods_screen.widths[row] = (short)(bounds->x1 - bounds->x0);
+ return;
+ }
+ }
+
/* (as the lobby last updated them: the rows' widgets go with it) */
if (!lobby_icons.count || system_milliseconds() - lobby_icons.updated_at > 250)
return;
@@ -5605,6 +6103,22 @@ boolean pc_menu_event_function_invoke(
{
settings_each(screen_of(widget), setting_default_show);
}
+ else if (!strcmp(name, "port mods init") || !strcmp(name, "port mods close"))
+ {
+ return mods_screen_reset();
+ }
+ else if (!strcmp(name, "port mods toggle"))
+ {
+ return mods_toggle(widget);
+ }
+ else if (!strcmp(name, "port mods move up") || !strcmp(name, "port mods move down"))
+ {
+ return mods_move(widget, (short)(!strcmp(name, "port mods move up") ? -1 : 1));
+ }
+ else if (!strcmp(name, "port mods save"))
+ {
+ return mods_save();
+ }
else if (!strcmp(name, "controls screen init") || !strcmp(name, "controls screen defaults"))
{
return controls_load(!strcmp(name, "controls screen defaults"));
@@ -5779,10 +6293,13 @@ void pc_menu_game_data_function_invoke(
else if (!strcmp(name, "port settings help"))
{
video_rows_show(widget);
+ hud_button_style_row_show(widget);
settings_help(widget);
}
else if (!strcmp(name, "controls update menu"))
controls_update(widget);
+ else if (!strcmp(name, "port mods update"))
+ mods_update(widget);
else if (!strcmp(name, "color picker update"))
color_list_update(widget);
else if (!strcmp(name, "3wide player profile list update"))
diff --git a/port/linux/game/menu_tags.c b/port/linux/game/menu_tags.c
index 9414ea11a3..a2d7696549 100644
--- a/port/linux/game/menu_tags.c
+++ b/port/linux/game/menu_tags.c
@@ -387,6 +387,9 @@ static char const *const port_function_names[] =
/* (a new profile's screens: Settings' is then being edited, which the
Xbox's leaves to its own screens) */
"new campaign decision",
+ /* (the main menu's Mods: menu_functions.c) */
+ "port mods init", "port mods close", "port mods toggle", "port mods move up", "port mods move down",
+ "port mods save",
};
/* the PC version's game data functions that the Xbox's have not, from
@@ -408,6 +411,8 @@ static char const *const port_game_data_input_names[] =
/* (the profile settings' picture: the Xbox's of the button settings,
on Gamepad Setup's row) */
"port gamepad layout preview",
+ /* (the main menu's Mods) */
+ "port mods update",
};
static struct
@@ -1782,15 +1787,19 @@ frame is the stock 159 grown by the 36 that five rows at this pitch add
/* the port box piece for one of the map's pause box pieces, else NULL */
static char const *pause_campaign_box_bitmap(long tag_index)
{
- if (tag_name_ends(tag_index, "\\pausebox2_left_center") || tag_name_ends(tag_index, "\\pausebox_right_center") ||
- tag_name_ends(tag_index, "\\pausebox_center"))
- {
+ if (tag_name_ends(tag_index, "\\pausebox2_left_center"))
return "pause/pausebox_center";
- }
- if (tag_name_ends(tag_index, "\\pausebox2_left") || tag_name_ends(tag_index, "\\pausebox_left"))
+ if (tag_name_ends(tag_index, "\\pausebox2_left"))
return "pause/pausebox_left";
- if (tag_name_ends(tag_index, "\\pausebox2_right") || tag_name_ends(tag_index, "\\pausebox_right"))
+ if (tag_name_ends(tag_index, "\\pausebox2_right"))
return "pause/pausebox_right";
+ /* The objectives' pieces carry a header, not the button-key footer. */
+ if (tag_name_ends(tag_index, "\\pausebox_right_center") || tag_name_ends(tag_index, "\\pausebox_center"))
+ return "pause/objectives_center";
+ if (tag_name_ends(tag_index, "\\pausebox_left"))
+ return "pause/objectives_left";
+ if (tag_name_ends(tag_index, "\\pausebox_right"))
+ return "pause/objectives_right";
return NULL;
}
@@ -1863,15 +1872,16 @@ static void pause_campaign_grow_box(struct ui_widget_definition *widget, short s
/* (bitmap_name is the lookup, not piece_bitmap: find calls it) */
long bitmap = find(piece_bitmap, build.menus->bitmap_count, bitmap_name);
short height = (short)(child_widget->bounds.y1 - child_widget->bounds.y0);
+ short frame = strstr(piece_bitmap, "objectives_") ? 0 : PAUSE_CAMPAIGN_BOX_FRAME;
if (bitmap != NONE && (short)(height + shift) == PAUSE_CAMPAIGN_BOX_HEIGHT)
{
struct bitmap_group *group = bitmap_group_get(build.bitmap_tags[bitmap]);
struct bitmap_group_sequence *sequence = group ? group->sequences.address : NULL;
- if (sequence && PAUSE_CAMPAIGN_BOX_FRAME < group->bitmaps.count)
+ if (sequence && frame < group->bitmaps.count)
{
- sequence->first_bitmap_index = PAUSE_CAMPAIGN_BOX_FRAME;
+ sequence->first_bitmap_index = frame;
sequence->bitmap_count = 1;
reference_set(&child_widget->background_bitmap, BITMAP_GROUP_TAG, build.bitmap_tags[bitmap]);
child_widget->bounds.y1 = (short)(child_widget->bounds.y0 + PAUSE_CAMPAIGN_BOX_HEIGHT);
@@ -2031,13 +2041,13 @@ static void pause_campaign_patch(struct cache_file_tag_instance *instances)
}
}
-/* the campaign played here by one player: not network co-op, nor split
-screen */
-static boolean single_player_campaign_map(void)
+/* The full-screen campaign pause menu also serves network co-op. Local
+split-screen keeps its separate layout. */
+static boolean full_screen_campaign_map(void)
{
return tag_loaded('Soul', MULTIPLAYER_COLLECTION) == NONE &&
tag_loaded(UI_WIDGET_DEFINITION_TAG, CAMPAIGN_PAUSE_SCREEN) != NONE &&
- game_connection() == _game_connection_local && !network_game_is_active() && !game_is_cooperative();
+ (game_connection() != _game_connection_local || network_game_is_active() || !game_is_cooperative());
}
/* whether display.menus asks for the PC version's menus: "pc" in any case
@@ -2065,7 +2075,7 @@ void menu_tags_loaded(
long widget_count, own_lists = 0, total, index;
boolean game_map = strcmp(map_name, "ui") != 0;
- boolean campaign = game_map && single_player_campaign_map();
+ boolean campaign = game_map && full_screen_campaign_map();
/* (ui.map, and a multiplayer map: its pause menu's SETTINGS; a
single-player campaign's too) */
@@ -2080,7 +2090,9 @@ void menu_tags_loaded(
memset(&build, 0, sizeof(build));
build.menus = menus;
build.game_map = game_map;
- build.campaign = campaign;
+ /* Settings must not pause the simulation in a network campaign. */
+ build.campaign = campaign && game_connection() == _game_connection_local &&
+ !network_game_is_active() && !game_is_cooperative();
widget_count = menus->widget_count;
build.widget_tags = malloc((widget_count + 1) * sizeof(long));
build.text_tags = malloc((widget_count + 1) * sizeof(long));
diff --git a/port/linux/game/mod_bitmaps.c b/port/linux/game/mod_bitmaps.c
new file mode 100644
index 0000000000..feeebd57db
--- /dev/null
+++ b/port/linux/game/mod_bitmaps.c
@@ -0,0 +1,1265 @@
+/*
+MOD_BITMAPS.C
+
+The bitmaps of mods (mods.c): each of a map's bitmap tags that a mod has a
+tag file of (tags\...\name.bitmap) is drawn from that file instead of the
+map, its textures, sprites and HUD pictures, and mods_reload reads them
+again.
+
+A tag file is Halo PC's (big-endian, version 7): the bitmap, then its
+compressed color plate, its processed pixel data, its sequences, each
+sequence's sprites, and its bitmaps, as the Halo Editing Kit's tool and
+Invader write them (bitmap_group.c's fields). All but the pixels is read
+when the map loads, and checked: every count and offset against the file,
+which must end where its bitmaps do, every bitmap as the texture cache can
+draw it from pixels laid out as Halo PC's are (the Custom Edition maps'
+check, custom_edition_bitmap_drawable), and every sequence's and sprite's
+bitmap among its bitmaps. A non-power-of-two 2D bitmap is drawn linear, as
+a Custom Edition map's is. A file is also left out if what the map's other
+tags ask of the bitmap it replaces is not there: as many bitmaps of the same
+types, as many sequences, and each with as many sprites (a mod's bitmap is
+the map's redrawn, larger or not).
+
+The HUD draws a bitmap at its own size (hud_draw.c), so a bitmap a redraw
+made larger, every one of its sprites (or, with none, its bitmaps) by the
+same ratio, has each HUD placement that draws it
+(custom_edition_hud_placements_walk) scaled by the inverse: the HUD keeps
+its size, sharper. A bitmap the HUD draws that is not the map's redrawn so
+(its sprites made to other sizes: Halo PC's HUD is not the Xbox's) is left
+out, and the map's drawn. The HUD's digits are drawn as the digits tag
+says, whose bitmap's half HUD scale flag halves their size
+(hud_draw_numbers): a mod's digits twice the map's size get it, of the
+map's size lose it, and of any other are left out. Menus draw their bitmaps
+in their widgets' bounds, whatever their size.
+
+A bitmap's pixels are read from the file as the texture cache loads them
+(mod_bitmaps_read, xbox_texture_cache.c), into its cache block, and laid out
+as the hardware wants them as a Custom Edition map's are
+(rasterizer_xbox_bitmap_rebuild_hardware_format); the renderer is told
+which keep their channels where Halo PC does (multipurpose maps and HUD
+meters: custom_edition_bitmaps.c). A bitmap whose pixels are more than a
+quarter of the texture cache is left out: it could not be drawn with what
+the map draws around it. A bitmap is drawn, not simulated: nothing a network
+game's machines agree on reads one.
+
+The map's bitmap tag takes the file's values in place, so everything that
+holds the tag keeps holding it, and its own come back when the file goes.
+When a reload changes any, the texture cache lets go of every texture first;
+a bitmap made is kept until the map goes, as anything may still hold it.
+*/
+
+/* ---------- headers */
+
+#include "cseries.h"
+#include "errors.h"
+#include "bitmaps/bitmaps.h"
+#include "bitmaps/bitmap_group.h"
+#include "cache/cache_files.h"
+#include "cache/texture_cache.h"
+#include "interface/hud_definitions.h"
+#include "interface/unit_hud_interface_definition.h"
+#include "main/console.h"
+#include "rasterizer/rasterizer_swizzle.h"
+#include "tag_files/tag_groups.h"
+#include "cache_file_formats.h"
+#include "custom_edition_cache.h"
+#include "mods.h"
+
+#include
+#include
+#include
+
+/* the platform layer's (port/linux/src) */
+void halo_custom_edition_texels_channels(void const *texels, unsigned char channel_order);
+
+/* the game's (port) */
+char *tag_get_name(long tag_index);
+
+/* ---------- constants */
+
+/* a bitmap tag's file: tags\.bitmap */
+#define TAG_FILE_EXTENSION ".bitmap"
+/* (bitmap_group.c's bitmap_group) */
+#define BITMAP_TAG_VERSION 7
+
+/* the sizes of a tag file's bitmap, sequence, sprite and bitmap data
+(bitmap_group.h's structures, as the file has them) */
+#define BITMAP_GROUP_SIZE 0x6C
+#define SEQUENCE_SIZE 0x40
+#define SPRITE_SIZE 0x20
+#define BITMAP_DATA_SIZE 0x30
+
+/* the tool's limits (bitmap_group.c's blocks) */
+#define MAXIMUM_SEQUENCES 256
+#define MAXIMUM_SPRITES_PER_SEQUENCE 64
+#define MAXIMUM_BITMAPS 2048
+/* the device's (d3d8_gl.c, D3DDevice_GetDeviceCaps) */
+#define MAXIMUM_BITMAP_SIZE 4096
+#define MAXIMUM_VOLUME_BITMAP_SIZE 512
+/* the most of the texture cache one bitmap's pixels may take */
+#define MAXIMUM_BITMAP_PIXELS_SIZE (HALO_PORT_TEXTURE_CACHE_SIZE / 4)
+
+/* bitmaps.c's bitmap types and flags, and bitmap_group.c's group types */
+enum
+{
+ _bitmap_type_2d,
+ _bitmap_type_3d,
+ _bitmap_type_cube_map,
+ NUMBER_OF_BITMAP_TYPES,
+};
+
+enum
+{
+ _bitmap_format_dxt1 = 14,
+ _bitmap_format_dxt5 = 16,
+ NUMBER_OF_BITMAP_FORMATS = 18,
+};
+
+enum
+{
+ _bitmap_has_power_of_two_dimensions_bit,
+ _bitmap_compressed_bit,
+ _bitmap_palettized_bit,
+ _bitmap_swizzled_bit,
+ _bitmap_linear_bit,
+ _bitmap_v16u16_bit,
+ _bitmap_allocated_bit,
+ _bitmap_cached_bit,
+};
+
+enum
+{
+ _bitmap_group_type_sprites = 3,
+ NUMBER_OF_BITMAP_GROUP_TYPES = 5,
+
+ /* (hud_draw.c's) */
+ _bitmap_group_half_hud_scale_bit = 4,
+};
+
+/* the HUD's digits' tag (hud_draw.c): their bitmap first */
+#define HUD_NUMBER_GROUP_TAG 'hud#'
+
+typedef char verify_bitmap_group_size[sizeof(struct bitmap_group) == BITMAP_GROUP_SIZE ? 1 : -1];
+typedef char verify_bitmap_group_sequence_size[sizeof(struct bitmap_group_sequence) == SEQUENCE_SIZE ? 1 : -1];
+typedef char verify_bitmap_group_sprite_size[sizeof(struct bitmap_group_sprite) == SPRITE_SIZE ? 1 : -1];
+typedef char verify_bitmap_data_size[sizeof(struct bitmap_data) == BITMAP_DATA_SIZE ? 1 : -1];
+
+/* ---------- structures */
+
+/* a bitmap tag file read, and what it makes */
+struct mod_bitmap
+{
+ struct mod_bitmap *next;
+ long tag_index;
+ /* the file (its index in the mods' files of the last time they were
+ found), and its path, size and modification time, to tell whether it
+ changed */
+ long file;
+ char *path;
+ unsigned long file_size;
+ unsigned long stamp[2];
+ /* where its processed pixel data is in the file, and how much there is */
+ unsigned long pixel_data_offset;
+ unsigned long pixel_data_size;
+ /* the definition it makes, and its sequences, sprites and bitmaps */
+ struct bitmap_group group;
+ struct bitmap_group_sequence *sequences;
+ struct bitmap_group_sprite *sprites;
+ struct bitmap_data *bitmaps;
+ unsigned long kept_bytes;
+ /* the map's size of what it draws to its own, the same for every
+ sprite or bitmap, by which the HUD's placements that draw it are scaled
+ (1: as they are; 0: not the same, which the HUD cannot draw) */
+ real hud_scale;
+ /* the HUD's digits, drawn as the digits tag says (not scaled) */
+ boolean digits;
+};
+
+/* a HUD placement's scale before it was scaled for a mod's bitmap */
+struct hud_placement_scale
+{
+ real *scale;
+ real original[2];
+};
+
+/* a bitmap of the map drawn from a file: the map's own definition, given
+back when it goes */
+struct mod_bitmap_override
+{
+ struct mod_bitmap *bitmap;
+ struct bitmap_group map_group;
+};
+
+static struct
+{
+ /* by the map's tag, its override (none: the map's own) */
+ struct mod_bitmap_override *overrides;
+ long override_count;
+ /* every bitmap made while the map is loaded */
+ struct mod_bitmap *bitmaps;
+ /* the HUD placements scaled for them, with their scales before */
+ struct hud_placement_scale *scales;
+ long scale_count;
+ long scale_capacity;
+ /* whether the channel orders of the mods' bitmaps were found
+ (custom_edition_loaded_reordered_bitmaps_find), and a pixel read that
+ failed was said */
+ boolean orders_found;
+ boolean read_failure_reported;
+} mod_bitmaps_globals;
+
+/* ---------- private code */
+
+static void mod_bitmap_free(
+ struct mod_bitmap *bitmap)
+{
+ /* (the game's free, debug_free, does not take NULL) */
+ if (bitmap->path)
+ free(bitmap->path);
+ if (bitmap->sequences)
+ free(bitmap->sequences);
+ if (bitmap->sprites)
+ free(bitmap->sprites);
+ if (bitmap->bitmaps)
+ free(bitmap->bitmaps);
+ free(bitmap);
+
+ return;
+}
+
+static boolean power_of_two(
+ long value)
+{
+ return value > 0 && !(value & (value - 1));
+}
+
+static short floor_log2_of(
+ long value)
+{
+ short log = 0;
+
+ while (value > 1)
+ {
+ value >>= 1;
+ log++;
+ }
+
+ return log;
+}
+
+/* whether a format is one bitmaps.c has (not one of the unused) */
+static boolean format_known(
+ short format)
+{
+ switch (format)
+ {
+ case 4: case 5: case 7: case 12: case 13:
+ return FALSE;
+ default:
+ return format >= 0 && format < NUMBER_OF_BITMAP_FORMATS;
+ }
+}
+
+/* Makes the bitmap read from `bytes` (a file's bitmap data, its pixels at
+`pixels_offset` in a processed pixel data of `pixel_data_size` bytes) one
+the texture cache can draw, as the tag `tag_index`'s; FALSE with *reason
+saying why it cannot be. */
+static boolean bitmap_data_read(
+ struct bitmap_data *bitmap,
+ byte const *bytes,
+ long tag_index,
+ unsigned long pixel_data_size,
+ char const **reason)
+{
+ boolean compressed;
+ short maximum_size;
+ unsigned long end;
+
+ memset(bitmap, 0, sizeof(*bitmap));
+ bitmap->signature = mods_read_be32(bytes + offsetof(struct bitmap_data, signature));
+ bitmap->width = mods_read_short(bytes + offsetof(struct bitmap_data, width));
+ bitmap->height = mods_read_short(bytes + offsetof(struct bitmap_data, height));
+ bitmap->depth = mods_read_short(bytes + offsetof(struct bitmap_data, depth));
+ bitmap->type = mods_read_short(bytes + offsetof(struct bitmap_data, type));
+ bitmap->format = mods_read_short(bytes + offsetof(struct bitmap_data, format));
+ bitmap->flags = (unsigned short)mods_read_be16(bytes + offsetof(struct bitmap_data, flags));
+ bitmap->registration_point.x = mods_read_short(bytes + offsetof(struct bitmap_data, registration_point));
+ bitmap->registration_point.y = mods_read_short(bytes + offsetof(struct bitmap_data, registration_point) + 2);
+ bitmap->mipmap_count = mods_read_short(bytes + offsetof(struct bitmap_data, mipmap_count));
+ bitmap->pixels_offset = (long)mods_read_be32(bytes + offsetof(struct bitmap_data, pixels_offset));
+
+ *reason = "a bitmap is of a type, format or size this build does not draw";
+ maximum_size = bitmap->type == _bitmap_type_3d ? MAXIMUM_VOLUME_BITMAP_SIZE : MAXIMUM_BITMAP_SIZE;
+ if (bitmap->signature != BITMAP_GROUP_TAG ||
+ bitmap->type < 0 || bitmap->type >= NUMBER_OF_BITMAP_TYPES || !format_known(bitmap->format) ||
+ bitmap->width < 1 || bitmap->width > maximum_size ||
+ bitmap->height < 1 || bitmap->height > maximum_size ||
+ bitmap->depth < 1 || bitmap->depth > (bitmap->type == _bitmap_type_3d ? maximum_size : 1) ||
+ bitmap->mipmap_count < 0 ||
+ bitmap->mipmap_count > floor_log2_of(MAX(bitmap->width, MAX(bitmap->height, bitmap->depth))))
+ {
+ return FALSE;
+ }
+ /* (what the tool says of it, the game's flags; of the rest, a map's
+ build marks it cached) */
+ compressed = bitmap->format >= _bitmap_format_dxt1 && bitmap->format <= _bitmap_format_dxt5;
+ bitmap->flags &= FLAG(_bitmap_has_power_of_two_dimensions_bit) | FLAG(_bitmap_palettized_bit) |
+ FLAG(_bitmap_linear_bit) | FLAG(_bitmap_v16u16_bit);
+ SET_FLAG(bitmap->flags, _bitmap_compressed_bit, compressed);
+ SET_FLAG(bitmap->flags, _bitmap_has_power_of_two_dimensions_bit,
+ power_of_two(bitmap->width) && power_of_two(bitmap->height) && power_of_two(bitmap->depth));
+ /* Halo PC draws a 2D bitmap of any size, this build swizzles all but
+ linear ones: an uncompressed one that has not got power-of-two sides
+ is drawn linear, as a Custom Edition map's is (cache_file_formats.c,
+ bitmap_npot_make_linear) */
+ if (bitmap->type == _bitmap_type_2d && !compressed &&
+ !TEST_FLAG(bitmap->flags, _bitmap_has_power_of_two_dimensions_bit))
+ {
+ SET_FLAG(bitmap->flags, _bitmap_linear_bit, TRUE);
+ }
+ if (!bitmap_verify(bitmap, FALSE))
+ {
+ return FALSE;
+ }
+ bitmap->pixels_size = bitmap_get_pixel_data_size(bitmap);
+ if (!custom_edition_bitmap_drawable(bitmap))
+ {
+ return FALSE;
+ }
+ *reason = "a bitmap's pixels are not in the file";
+ if (bitmap->pixels_offset < 0 ||
+ !mods_in_file((unsigned long)bitmap->pixels_offset, (unsigned long)bitmap->pixels_size, pixel_data_size))
+ {
+ return FALSE;
+ }
+ *reason = "a bitmap is larger than the texture cache can draw among others";
+ /* (what the texture cache takes for it: the larger of its sizes) */
+ end = (unsigned long)rasterizer_xbox_bitmap_get_pixel_data_size(bitmap);
+ if (MAX(end, (unsigned long)bitmap->pixels_size) > MAXIMUM_BITMAP_PIXELS_SIZE)
+ {
+ return FALSE;
+ }
+ SET_FLAG(bitmap->flags, _bitmap_cached_bit, TRUE);
+ bitmap->tag_index = tag_index;
+ bitmap->cache_block_index = NONE;
+ bitmap->hardware_format = NULL;
+ bitmap->base_address = NULL;
+
+ return TRUE;
+}
+
+static real difference(
+ real first,
+ real second)
+{
+ return first > second ? first - second : second - first;
+}
+
+/* the ratio of the size the map's bitmap `map_group` draws a sprite at to
+the size the bitmap read, `bitmap`, draws it (both of the same sequences and
+sprites: a sheet the tool made again lays them out anew), or of their
+bitmaps, the same for all of them; 0 if it is not the same */
+static real hud_scale_find(
+ struct bitmap_group const *map_group,
+ struct mod_bitmap const *bitmap)
+{
+ real scale = 0.f;
+ boolean sprites = FALSE;
+ long sequence_index;
+ long index;
+
+ for (sequence_index = 0; sequence_index < map_group->sequences.count; sequence_index++)
+ {
+ struct bitmap_group_sequence const *map_sequence =
+ TAG_BLOCK_GET_ELEMENT(&map_group->sequences, sequence_index, struct bitmap_group_sequence);
+ struct bitmap_group_sequence const *sequence = &bitmap->sequences[sequence_index];
+
+ for (index = 0; index < map_sequence->sprites.count; index++)
+ {
+ struct bitmap_group_sprite const *map_sprite =
+ TAG_BLOCK_GET_ELEMENT(&map_sequence->sprites, index, struct bitmap_group_sprite);
+ struct bitmap_group_sprite const *sprite = (struct bitmap_group_sprite const *)sequence->sprites.address + index;
+ struct bitmap_data const *sprite_bitmap = &bitmap->bitmaps[sprite->bitmap_index];
+ struct bitmap_data const *map_bitmap =
+ TAG_BLOCK_GET_ELEMENT(&map_group->bitmaps, map_sprite->bitmap_index, struct bitmap_data);
+ real map_width = (map_sprite->bounds.x1 - map_sprite->bounds.x0) * map_bitmap->width;
+ real map_height = (map_sprite->bounds.y1 - map_sprite->bounds.y0) * map_bitmap->height;
+ real width = (sprite->bounds.x1 - sprite->bounds.x0) * sprite_bitmap->width;
+ real height = (sprite->bounds.y1 - sprite->bounds.y0) * sprite_bitmap->height;
+
+ /* (a sprite of no size draws nothing) */
+ if (map_width < 1.f || map_height < 1.f || width < 1.f || height < 1.f)
+ {
+ continue;
+ }
+ sprites = TRUE;
+ if (!scale)
+ {
+ scale = map_width / width;
+ }
+ /* (to the pixel the tool rounds a sprite's bounds to) */
+ if (difference(map_width, width * scale) > 1.5f * MAX(scale, 1.f) ||
+ difference(map_height, height * scale) > 1.5f * MAX(scale, 1.f))
+ {
+ return 0.f;
+ }
+ }
+ }
+ if (sprites)
+ {
+ return scale;
+ }
+ for (index = 0; index < map_group->bitmaps.count; index++)
+ {
+ struct bitmap_data const *map_bitmap = TAG_BLOCK_GET_ELEMENT(&map_group->bitmaps, index, struct bitmap_data);
+ struct bitmap_data const *mod_bitmap = &bitmap->bitmaps[index];
+
+ if (!scale)
+ {
+ scale = (real)map_bitmap->width / (real)mod_bitmap->width;
+ }
+ if ((real)map_bitmap->width != (real)mod_bitmap->width * scale ||
+ (real)map_bitmap->height != (real)mod_bitmap->height * scale)
+ {
+ return 0.f;
+ }
+ }
+
+ return scale ? scale : 1.f;
+}
+
+/* The bitmap the tag file `file` makes for the map's bitmap tag
+`tag_index`, whose definition is `map_group`, or NULL with *reason saying
+why not. */
+static struct mod_bitmap *mod_bitmap_new(
+ long tag_index,
+ long file,
+ struct bitmap_group const *map_group,
+ char const **reason)
+{
+ byte head[MOD_TAG_FILE_HEADER_SIZE + BITMAP_GROUP_SIZE];
+ byte const *root = head + MOD_TAG_FILE_HEADER_SIZE;
+ struct mod_bitmap *bitmap;
+ struct bitmap_group *group;
+ byte *tail = NULL;
+ unsigned long tail_size;
+ unsigned long cursor = sizeof(head);
+ unsigned long color_plate_size;
+ unsigned long sequence_count;
+ unsigned long bitmap_count;
+ unsigned long sprite_count = 0;
+ unsigned long tail_cursor;
+ unsigned long index;
+
+ *reason = "it cannot be read";
+ bitmap = malloc(sizeof(*bitmap));
+ if (!bitmap)
+ {
+ *reason = "out of memory";
+ return NULL;
+ }
+ memset(bitmap, 0, sizeof(*bitmap));
+ bitmap->tag_index = tag_index;
+ bitmap->file = file;
+ group = &bitmap->group;
+ bitmap->path = malloc(strlen(port_mods_file_path(file)) + 1);
+ if (!bitmap->path)
+ {
+ *reason = "out of memory";
+ goto refused;
+ }
+ strcpy(bitmap->path, port_mods_file_path(file));
+ if (!port_mods_file_stamp(file, &bitmap->file_size, bitmap->stamp))
+ {
+ goto refused;
+ }
+ *reason = "not a bitmap tag file of this version";
+ if (bitmap->file_size < sizeof(head) || !port_mods_file_read(file, 0, head, sizeof(head)) ||
+ !mods_tag_file_header_valid(head, BITMAP_GROUP_TAG, BITMAP_TAG_VERSION))
+ {
+ goto refused;
+ }
+
+ /* the bitmap's values */
+ group->type = mods_read_short(root + offsetof(struct bitmap_group, type));
+ group->format = mods_read_short(root + offsetof(struct bitmap_group, format));
+ group->usage = mods_read_short(root + offsetof(struct bitmap_group, usage));
+ group->flags = (unsigned short)mods_read_be16(root + offsetof(struct bitmap_group, flags));
+ group->detail_fade = mods_read_real(root + offsetof(struct bitmap_group, detail_fade));
+ group->sharpen_amount = mods_read_real(root + offsetof(struct bitmap_group, sharpen_amount));
+ group->bump_height = mods_read_real(root + offsetof(struct bitmap_group, bump_height));
+ group->sprite_budget_size = mods_read_short(root + offsetof(struct bitmap_group, sprite_budget_size));
+ group->sprite_budget_count = (unsigned short)mods_read_be16(root + offsetof(struct bitmap_group, sprite_budget_count));
+ group->import_width = mods_read_short(root + offsetof(struct bitmap_group, import_width));
+ group->import_height = mods_read_short(root + offsetof(struct bitmap_group, import_height));
+ group->smoothing_filter_size = mods_read_real(root + offsetof(struct bitmap_group, smoothing_filter_size));
+ group->alpha_bias = mods_read_real(root + offsetof(struct bitmap_group, alpha_bias));
+ group->mipmap_count = mods_read_short(root + offsetof(struct bitmap_group, mipmap_count));
+ group->sprite_usage = mods_read_short(root + offsetof(struct bitmap_group, sprite_usage));
+ group->sprite_spacing = mods_read_short(root + offsetof(struct bitmap_group, sprite_spacing));
+ color_plate_size = mods_read_be32(root + offsetof(struct bitmap_group, import_bitmap.size));
+ bitmap->pixel_data_size = mods_read_be32(root + offsetof(struct bitmap_group, pixel_data.size));
+ sequence_count = mods_read_be32(root + offsetof(struct bitmap_group, sequences.count));
+ bitmap_count = mods_read_be32(root + offsetof(struct bitmap_group, bitmaps.count));
+ *reason = "a value of the bitmap is out of range";
+ if (group->type < 0 || group->type >= NUMBER_OF_BITMAP_GROUP_TYPES ||
+ sequence_count > MAXIMUM_SEQUENCES || bitmap_count < 1 || bitmap_count > MAXIMUM_BITMAPS)
+ {
+ goto refused;
+ }
+
+ /* the color plate (not read), the pixels (read as they are drawn), then
+ the sequences, their sprites and the bitmaps, read whole */
+ *reason = "its pixels are not in the file";
+ if (!mods_in_file(cursor, color_plate_size, bitmap->file_size))
+ {
+ goto refused;
+ }
+ cursor += color_plate_size;
+ if (!mods_in_file(cursor, bitmap->pixel_data_size, bitmap->file_size))
+ {
+ goto refused;
+ }
+ bitmap->pixel_data_offset = cursor;
+ cursor += bitmap->pixel_data_size;
+ tail_size = bitmap->file_size - cursor;
+ *reason = "its sequences and bitmaps are not in the file";
+ if (tail_size < sequence_count * SEQUENCE_SIZE + bitmap_count * BITMAP_DATA_SIZE ||
+ tail_size > MAXIMUM_SEQUENCES * (SEQUENCE_SIZE + MAXIMUM_SPRITES_PER_SEQUENCE * SPRITE_SIZE) +
+ MAXIMUM_BITMAPS * BITMAP_DATA_SIZE)
+ {
+ goto refused;
+ }
+ tail = malloc(tail_size);
+ if (!tail)
+ {
+ *reason = "out of memory";
+ goto refused;
+ }
+ *reason = "it cannot be read";
+ if (!port_mods_file_read(file, cursor, tail, tail_size))
+ {
+ goto refused;
+ }
+
+ /* the sequences, then each one's sprites */
+ *reason = "out of memory";
+ bitmap->sequences = malloc(MAX(sequence_count, 1) * sizeof(*bitmap->sequences));
+ bitmap->bitmaps = malloc(bitmap_count * sizeof(*bitmap->bitmaps));
+ if (!bitmap->sequences || !bitmap->bitmaps)
+ {
+ goto refused;
+ }
+ memset(bitmap->sequences, 0, MAX(sequence_count, 1) * sizeof(*bitmap->sequences));
+ *reason = "its sprites are not in the file";
+ for (index = 0; index < sequence_count; index++)
+ {
+ byte const *bytes = tail + index * SEQUENCE_SIZE;
+ unsigned long count = mods_read_be32(bytes + offsetof(struct bitmap_group_sequence, sprites.count));
+
+ if (count > MAXIMUM_SPRITES_PER_SEQUENCE)
+ {
+ goto refused;
+ }
+ sprite_count += count;
+ }
+ tail_cursor = sequence_count * SEQUENCE_SIZE;
+ if (!mods_in_file(tail_cursor, sprite_count * SPRITE_SIZE + bitmap_count * BITMAP_DATA_SIZE, tail_size))
+ {
+ goto refused;
+ }
+ *reason = "out of memory";
+ bitmap->sprites = malloc(MAX(sprite_count, 1) * sizeof(*bitmap->sprites));
+ if (!bitmap->sprites)
+ {
+ goto refused;
+ }
+ sprite_count = 0;
+ for (index = 0; index < sequence_count; index++)
+ {
+ byte const *bytes = tail + index * SEQUENCE_SIZE;
+ struct bitmap_group_sequence *sequence = &bitmap->sequences[index];
+ unsigned long count = mods_read_be32(bytes + offsetof(struct bitmap_group_sequence, sprites.count));
+ unsigned long sprite_index;
+
+ memcpy(sequence->name, bytes, sizeof(sequence->name));
+ sequence->name[sizeof(sequence->name) - 1] = 0;
+ sequence->first_bitmap_index = mods_read_short(bytes + offsetof(struct bitmap_group_sequence, first_bitmap_index));
+ sequence->bitmap_count = mods_read_short(bytes + offsetof(struct bitmap_group_sequence, bitmap_count));
+ /* (a sprites group's sequences are their sprites, whatever the tool
+ left in these: bitmap_group.c, postprocess_bitmap_group) */
+ if (group->type == _bitmap_group_type_sprites)
+ {
+ sequence->first_bitmap_index = 0;
+ sequence->bitmap_count = 0;
+ }
+ sequence->sprites.count = (long)count;
+ sequence->sprites.address = count ? bitmap->sprites + sprite_count : NULL;
+ sequence->sprites.definition = NULL;
+ *reason = "a sequence names bitmaps it has not got";
+ if (sequence->first_bitmap_index < 0 || sequence->bitmap_count < 0 ||
+ (unsigned long)sequence->first_bitmap_index + (unsigned long)sequence->bitmap_count > bitmap_count)
+ {
+ goto refused;
+ }
+ for (sprite_index = 0; sprite_index < count; sprite_index++)
+ {
+ byte const *sprite_bytes = tail + tail_cursor;
+ struct bitmap_group_sprite *sprite = &bitmap->sprites[sprite_count++];
+
+ memset(sprite, 0, sizeof(*sprite));
+ sprite->bitmap_index = mods_read_short(sprite_bytes + offsetof(struct bitmap_group_sprite, bitmap_index));
+ sprite->bounds.x0 = mods_read_real(sprite_bytes + offsetof(struct bitmap_group_sprite, bounds.x0));
+ sprite->bounds.x1 = mods_read_real(sprite_bytes + offsetof(struct bitmap_group_sprite, bounds.x1));
+ sprite->bounds.y0 = mods_read_real(sprite_bytes + offsetof(struct bitmap_group_sprite, bounds.y0));
+ sprite->bounds.y1 = mods_read_real(sprite_bytes + offsetof(struct bitmap_group_sprite, bounds.y1));
+ sprite->registration_point.x = mods_read_real(sprite_bytes + offsetof(struct bitmap_group_sprite, registration_point.x));
+ sprite->registration_point.y = mods_read_real(sprite_bytes + offsetof(struct bitmap_group_sprite, registration_point.y));
+ tail_cursor += SPRITE_SIZE;
+ *reason = "a sprite is of a bitmap it has not got, or out of range";
+ if (sprite->bitmap_index < 0 || (unsigned long)sprite->bitmap_index >= bitmap_count ||
+ !mods_finite_real(sprite->bounds.x0) || !mods_finite_real(sprite->bounds.x1) ||
+ !mods_finite_real(sprite->bounds.y0) || !mods_finite_real(sprite->bounds.y1) ||
+ !mods_finite_real(sprite->registration_point.x) || !mods_finite_real(sprite->registration_point.y))
+ {
+ goto refused;
+ }
+ }
+ }
+
+ /* the bitmaps, which must end the file */
+ for (index = 0; index < bitmap_count; index++)
+ {
+ if (!bitmap_data_read(&bitmap->bitmaps[index], tail + tail_cursor, tag_index, bitmap->pixel_data_size, reason))
+ {
+ goto refused;
+ }
+ tail_cursor += BITMAP_DATA_SIZE;
+ }
+ *reason = "the file goes on past its last bitmap";
+ if (tail_cursor != tail_size)
+ {
+ goto refused;
+ }
+ free(tail);
+ tail = NULL;
+
+ /* what the map's other tags ask of the bitmap (as many sequences: what
+ draws it keeps a sequence's index, which must still be one when the map's
+ bitmap comes back, weather_particle_systems.c) */
+ *reason = "it has fewer bitmaps or sprites than the map's, other sequences, or bitmaps of other types";
+ if ((long)bitmap_count < map_group->bitmaps.count || (long)sequence_count != map_group->sequences.count)
+ {
+ goto refused;
+ }
+ for (index = 0; index < (unsigned long)map_group->bitmaps.count; index++)
+ {
+ struct bitmap_data const *map_bitmap = TAG_BLOCK_GET_ELEMENT(&map_group->bitmaps, index, struct bitmap_data);
+
+ if (map_bitmap->type != bitmap->bitmaps[index].type)
+ {
+ goto refused;
+ }
+ }
+ for (index = 0; index < (unsigned long)map_group->sequences.count; index++)
+ {
+ struct bitmap_group_sequence const *map_sequence =
+ TAG_BLOCK_GET_ELEMENT(&map_group->sequences, index, struct bitmap_group_sequence);
+ struct bitmap_group_sequence const *sequence = &bitmap->sequences[index];
+
+ if (sequence->sprites.count < map_sequence->sprites.count ||
+ (map_sequence->bitmap_count > 0 && sequence->bitmap_count <= 0))
+ {
+ goto refused;
+ }
+ }
+
+ /* (the HUD draws it at the map's size if what it draws, its sprites or
+ its bitmaps, is all larger by one ratio) */
+ bitmap->hud_scale = hud_scale_find(map_group, bitmap);
+
+ group->import_bitmap.size = 0;
+ group->import_bitmap.address = NULL;
+ group->pixel_data.size = (long)bitmap->pixel_data_size;
+ group->pixel_data.file_offset = 0;
+ group->pixel_data.address = NULL;
+ group->sequences.count = (long)sequence_count;
+ group->sequences.address = sequence_count ? bitmap->sequences : NULL;
+ group->bitmaps.count = (long)bitmap_count;
+ group->bitmaps.address = bitmap->bitmaps;
+ bitmap->kept_bytes = sizeof(*bitmap) + MAX(sequence_count, 1) * sizeof(*bitmap->sequences) +
+ MAX(sprite_count, 1) * sizeof(*bitmap->sprites) + bitmap_count * sizeof(*bitmap->bitmaps);
+
+ return bitmap;
+
+refused:
+ if (tail)
+ free(tail);
+ mod_bitmap_free(bitmap);
+
+ return NULL;
+}
+
+/* the override of the bitmap tag `tag_index` */
+static struct mod_bitmap_override *override_get(
+ long tag_index)
+{
+ long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index);
+
+ return absolute_index >= 0 && absolute_index < mod_bitmaps_globals.override_count ?
+ &mod_bitmaps_globals.overrides[absolute_index] : NULL;
+}
+
+/* Makes the table of overrides, one for each of the map's tags up to its
+last bitmap tag; FALSE when it cannot be. */
+static boolean overrides_new(
+ void)
+{
+ struct tag_iterator iterator;
+ long tag_index;
+ long count = 0;
+
+ if (mod_bitmaps_globals.overrides)
+ {
+ return TRUE;
+ }
+ tag_iterator_new(&iterator, BITMAP_GROUP_TAG);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ count = MAX(count, DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index) + 1);
+ }
+ if (count <= 0 || !(mod_bitmaps_globals.overrides = malloc((size_t)count * sizeof(struct mod_bitmap_override))))
+ {
+ return FALSE;
+ }
+ memset(mod_bitmaps_globals.overrides, 0, (size_t)count * sizeof(struct mod_bitmap_override));
+ mod_bitmaps_globals.override_count = count;
+
+ return TRUE;
+}
+
+/* the bitmap made that `bitmap` is one of the bitmaps of, played over its
+tag's, or NULL (a bitmap names its tag: bitmap_data_read) */
+static struct mod_bitmap *bitmap_of(
+ struct bitmap_data const *bitmap)
+{
+ struct mod_bitmap_override const *override = override_get(bitmap->tag_index);
+ struct mod_bitmap *made = override ? override->bitmap : NULL;
+
+ return made && bitmap >= made->bitmaps && bitmap < made->bitmaps + made->group.bitmaps.count ? made : NULL;
+}
+
+/* Gives the map's bitmap tag `tag_index` its own definition back, and has
+the bitmap made that was played over it not drawn (nothing should name it
+now; a stale pointer to one is a bitmap with no texture). */
+static void bitmap_unplug(
+ long tag_index,
+ struct mod_bitmap_override *override)
+{
+ struct mod_bitmap *made = override->bitmap;
+ long index;
+
+ memcpy(bitmap_group_get(tag_index), &override->map_group, sizeof(override->map_group));
+ override->bitmap = NULL;
+ for (index = 0; made && index < made->group.bitmaps.count; index++)
+ {
+ SET_FLAG(made->bitmaps[index].flags, _bitmap_cached_bit, FALSE);
+ made->bitmaps[index].hardware_format = NULL;
+ }
+
+ return;
+}
+
+/* Plays the bitmap made `made` over the map's bitmap tag `tag_index`. */
+static void bitmap_plug(
+ long tag_index,
+ struct mod_bitmap_override *override,
+ struct mod_bitmap *made)
+{
+ struct bitmap_group *definition = bitmap_group_get(tag_index);
+ long index;
+
+ for (index = 0; index < made->group.bitmaps.count; index++)
+ {
+ SET_FLAG(made->bitmaps[index].flags, _bitmap_cached_bit, TRUE);
+ made->bitmaps[index].cache_block_index = NONE;
+ made->bitmaps[index].hardware_format = NULL;
+ made->bitmaps[index].base_address = NULL;
+ }
+ memcpy(&override->map_group, definition, sizeof(*definition));
+ memcpy(definition, &made->group, sizeof(*definition));
+ override->bitmap = made;
+
+ return;
+}
+
+/* Lets go of a bitmap made that was never drawn (the HUD checks', as it is
+made), and of the memory it was counted. */
+static void bitmap_forget(
+ struct mod_bitmap *made)
+{
+ struct mod_bitmap **reference = &mod_bitmaps_globals.bitmaps;
+
+ while (*reference && *reference != made)
+ reference = &(*reference)->next;
+ if (*reference)
+ *reference = made->next;
+ mods_memory_give(made->kept_bytes);
+ mod_bitmap_free(made);
+
+ return;
+}
+
+/* a bitmap made before for the tag `tag_index` from the file at `path` as
+it is now (`size` bytes, modified at `stamp`), played or not, or NULL */
+static struct mod_bitmap *bitmap_kept(
+ long tag_index,
+ char const *path,
+ unsigned long size,
+ unsigned long const stamp[2])
+{
+ struct mod_bitmap *made;
+
+ for (made = mod_bitmaps_globals.bitmaps; made; made = made->next)
+ {
+ if (made->tag_index == tag_index && made->file_size == size && made->stamp[0] == stamp[0] &&
+ made->stamp[1] == stamp[1] && !strcmp(made->path, path))
+ {
+ return made;
+ }
+ }
+
+ return NULL;
+}
+
+/* the scale of the HUD placements that draw the bitmap tag `handle` names */
+static real hud_scale_of(
+ long handle)
+{
+ struct mod_bitmap_override const *override = handle != NONE ? override_get(handle) : NULL;
+
+ return override && override->bitmap && override->bitmap->tag_index == handle && override->bitmap->hud_scale > 0.f &&
+ !override->bitmap->digits ? override->bitmap->hud_scale : 1.f;
+}
+
+static uint8_t *hud_block_elements(
+ void *context,
+ uint8_t const *block,
+ uint32_t element_bytes,
+ int32_t *count)
+{
+ struct tag_block const *elements = (struct tag_block const *)block;
+
+ (void)context;
+ (void)element_bytes;
+ *count = elements->count;
+
+ return elements->count > 0 && elements->address ? elements->address : NULL;
+}
+
+/* a placement drawing a mod's larger bitmap, scaled to draw it at the
+map's size */
+static void hud_placement_scale(
+ void *context,
+ uint8_t *placement,
+ uint8_t const *bitmap,
+ int items)
+{
+ struct hud_placement_definition *definition = (struct hud_placement_definition *)placement;
+ real scale = bitmap ? hud_scale_of(((struct tag_reference const *)bitmap)->index) : 1.f;
+ struct hud_placement_scale *scaled;
+
+ (void)context;
+ (void)items;
+ if (scale == 1.f)
+ {
+ return;
+ }
+ if (mod_bitmaps_globals.scale_count == mod_bitmaps_globals.scale_capacity)
+ {
+ long capacity = mod_bitmaps_globals.scale_capacity ? 2 * mod_bitmaps_globals.scale_capacity : 64;
+ struct hud_placement_scale *grown = malloc(capacity * sizeof(*grown));
+
+ if (!grown)
+ {
+ return;
+ }
+ if (mod_bitmaps_globals.scales)
+ {
+ memcpy(grown, mod_bitmaps_globals.scales, mod_bitmaps_globals.scale_count * sizeof(*grown));
+ free(mod_bitmaps_globals.scales);
+ }
+ mod_bitmaps_globals.scales = grown;
+ mod_bitmaps_globals.scale_capacity = capacity;
+ }
+ scaled = &mod_bitmaps_globals.scales[mod_bitmaps_globals.scale_count++];
+ scaled->scale = &definition->scale.i;
+ scaled->original[0] = definition->scale.i;
+ scaled->original[1] = definition->scale.j;
+ definition->scale.i *= scale;
+ definition->scale.j *= scale;
+
+ return;
+}
+
+/* a placement drawing a mod's bitmap that is not the map's redrawn to one
+ratio: the map's bitmap comes back (the texture cache holds no bitmap) */
+static void hud_placement_bitmap_check(
+ void *context,
+ uint8_t *placement,
+ uint8_t const *bitmap,
+ int items)
+{
+ long handle = bitmap ? ((struct tag_reference const *)bitmap)->index : NONE;
+ struct mod_bitmap_override *override = handle != NONE ? override_get(handle) : NULL;
+ long *given_back = context;
+
+ (void)placement;
+ (void)items;
+ if (override && override->bitmap && override->bitmap->tag_index == handle && override->bitmap->hud_scale <= 0.f &&
+ !override->bitmap->digits)
+ {
+ struct mod_bitmap *made = override->bitmap;
+
+ error(_error_silent, "mods: %s left out: the HUD draws it, and it is not the map's redrawn larger by one ratio",
+ made->path);
+ console_warning("mods: %s.bitmap left out: the HUD draws it, and it is not the map's redrawn larger by one ratio",
+ tag_get_name(handle));
+ bitmap_unplug(handle, override);
+ bitmap_forget(made);
+ (*given_back)++;
+ }
+
+ return;
+}
+
+/* every placement of the HUD's definitions, walked */
+static void hud_placements_walk(
+ struct custom_edition_hud_walk const *walk)
+{
+ static unsigned long const groups[] =
+ {
+ UNIT_HUD_INTERFACE_DEFINITION_TAG, 'wphi', 'grhi', 'hudg',
+ };
+ long index;
+
+ for (index = 0; index < (long)NUMBEROF(groups); index++)
+ {
+ struct tag_iterator iterator;
+ long tag_index;
+
+ tag_iterator_new(&iterator, groups[index]);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ if (tag_get_group_tag(tag_index) == groups[index])
+ custom_edition_hud_placements_walk(tag_get(groups[index], tag_index), groups[index], walk);
+ }
+ }
+
+ return;
+}
+
+/* Gives the map's bitmaps back where the HUD cannot draw a mod's at the
+map's size: how many. */
+static long hud_bitmaps_check(
+ void)
+{
+ struct custom_edition_hud_walk walk;
+ long given_back = 0;
+
+ walk.block_elements = hud_block_elements;
+ walk.placement = hud_placement_bitmap_check;
+ walk.context = &given_back;
+ hud_placements_walk(&walk);
+
+ return given_back;
+}
+
+/* Gives the map's digits back where the HUD cannot draw a mod's at the
+map's size, and has it draw the others at it, once each: how many were
+given back. The map draws its digits at their size, or half of it with
+the flag (a Custom Edition map's halved digits); a mod's twice the size get
+the flag where the map's have not, a mod's of the same size the map's. */
+static long hud_digits_check(
+ void)
+{
+ struct tag_iterator iterator;
+ long tag_index;
+ long given_back = 0;
+
+ tag_iterator_new(&iterator, HUD_NUMBER_GROUP_TAG);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ struct tag_reference const *digits = tag_get(HUD_NUMBER_GROUP_TAG, tag_index);
+ long handle = digits ? digits->index : NONE;
+ struct mod_bitmap_override *override = handle != NONE ? override_get(handle) : NULL;
+ struct mod_bitmap *made = override ? override->bitmap : NULL;
+
+ boolean map_half;
+
+ if (!made || made->tag_index != handle || made->digits)
+ {
+ continue;
+ }
+ map_half = TEST_FLAG(override->map_group.flags, _bitmap_group_half_hud_scale_bit);
+ if (difference(made->hud_scale, 1.f) < 0.01f || (difference(made->hud_scale, 0.5f) < 0.01f && !map_half))
+ {
+ boolean half = map_half || difference(made->hud_scale, 0.5f) < 0.01f;
+
+ SET_FLAG(made->group.flags, _bitmap_group_half_hud_scale_bit, half);
+ SET_FLAG(bitmap_group_get(handle)->flags, _bitmap_group_half_hud_scale_bit, half);
+ /* (its placements are not scaled: the digits' are their own) */
+ made->digits = TRUE;
+ }
+ else
+ {
+ error(_error_silent, "mods: %s left out: the HUD draws digits at the map's size or twice it, not this one's",
+ made->path);
+ console_warning("mods: %s.bitmap left out: the HUD draws digits at the map's size or twice it, not this one's",
+ tag_get_name(handle));
+ bitmap_unplug(handle, override);
+ bitmap_forget(made);
+ given_back++;
+ }
+ }
+
+ return given_back;
+}
+
+/* Gives the HUD's placements their scales back, then scales those that
+draw the mods' bitmaps now played. */
+static long hud_placements_rescale(
+ void)
+{
+ struct custom_edition_hud_walk walk;
+ long index;
+
+ for (index = mod_bitmaps_globals.scale_count - 1; index >= 0; index--)
+ {
+ mod_bitmaps_globals.scales[index].scale[0] = mod_bitmaps_globals.scales[index].original[0];
+ mod_bitmaps_globals.scales[index].scale[1] = mod_bitmaps_globals.scales[index].original[1];
+ }
+ mod_bitmaps_globals.scale_count = 0;
+ walk.block_elements = hud_block_elements;
+ walk.placement = hud_placement_scale;
+ walk.context = NULL;
+ hud_placements_walk(&walk);
+
+ return mod_bitmaps_globals.scale_count;
+}
+
+/* ---------- public code */
+
+long mod_bitmaps_update(
+ boolean remove_all,
+ boolean at_load)
+{
+ struct tag_iterator iterator;
+ long tag_index;
+ long loaded_count = 0;
+ long removed_count = 0;
+ long refused_count = 0;
+ long played_count = 0;
+ boolean flushed = FALSE;
+ /* (whether a bitmap played changed: the HUD's placements and the
+ channels' orders are found again) */
+ boolean changed = FALSE;
+
+ if (!mods_map_loaded() || !overrides_new())
+ {
+ return 0;
+ }
+
+ tag_iterator_new(&iterator, BITMAP_GROUP_TAG);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ struct mod_bitmap_override *override = override_get(tag_index);
+ long file = remove_all ? NONE : mods_tag_file_find(tag_index, TAG_FILE_EXTENSION);
+ struct bitmap_group *definition = bitmap_group_get(tag_index);
+ struct mod_bitmap *made = NULL;
+ boolean made_now = FALSE;
+ char const *reason = NULL;
+
+ if (!override || !definition)
+ {
+ continue;
+ }
+ if (file != NONE)
+ {
+ unsigned long size;
+ unsigned long stamp[2];
+
+ /* (one made before from the file as it is: only where it is in the
+ mods' files changes) */
+ if (port_mods_file_stamp(file, &size, stamp))
+ made = bitmap_kept(tag_index, port_mods_file_path(file), size, stamp);
+ if (made)
+ made->file = file;
+ if (made && made == override->bitmap)
+ continue;
+ }
+ if (file != NONE && !made)
+ {
+ made = mod_bitmap_new(tag_index, file, override->bitmap ? &override->map_group : definition, &reason);
+ if (made && !mods_memory_take(made->kept_bytes))
+ {
+ reason = "the mods' tags already hold too much memory";
+ mod_bitmap_free(made);
+ made = NULL;
+ }
+ if (!made)
+ {
+ error(_error_silent, "mods: %s left out: %s", port_mods_file_path(file), reason);
+ console_warning("mods: %s.bitmap of %s left out: %s", tag_get_name(tag_index), port_mods_file_mod(file),
+ reason);
+ refused_count++;
+ }
+ made_now = made != NULL;
+ }
+ /* (a bitmap whose file is gone or changed, read again or not, is the
+ map's until another is made: its pixels are read from its file) */
+ if (!made && !override->bitmap)
+ {
+ continue;
+ }
+
+ /* a change: no texture holds a bitmap while it is made */
+ if (!flushed && !at_load)
+ {
+ texture_cache_flush();
+ flushed = TRUE;
+ }
+ if (override->bitmap)
+ {
+ bitmap_unplug(tag_index, override);
+ changed = TRUE;
+ if (!made)
+ removed_count++;
+ }
+ if (made)
+ {
+ bitmap_plug(tag_index, override, made);
+ changed = TRUE;
+ if (made_now)
+ {
+ made->next = mod_bitmaps_globals.bitmaps;
+ mod_bitmaps_globals.bitmaps = made;
+ }
+ loaded_count++;
+ }
+ }
+
+ /* (those the HUD cannot draw at the map's size go: no texture holds
+ them yet) */
+ if (loaded_count)
+ {
+ long given_back = hud_digits_check() + hud_bitmaps_check();
+
+ loaded_count -= given_back;
+ refused_count += given_back;
+ }
+ for (tag_index = 0; tag_index < mod_bitmaps_globals.override_count; tag_index++)
+ {
+ if (mod_bitmaps_globals.overrides[tag_index].bitmap)
+ played_count++;
+ }
+ /* which of them keep their channels where Halo PC does, found again
+ with them in the map's tags (a Custom Edition map's own bitmaps, all Halo
+ PC's, are found again too: they are drawn by the same table) */
+ if (changed && (played_count || custom_edition_cache_tags_loaded()))
+ {
+ mod_bitmaps_globals.orders_found = custom_edition_loaded_reordered_bitmaps_find();
+ }
+ if (changed)
+ {
+ long scaled_count = hud_placements_rescale();
+
+ if (scaled_count)
+ error(_error_silent, "mods: %ld HUD placements scaled to draw larger bitmaps at the map's size", scaled_count);
+ }
+ if (!at_load || loaded_count || refused_count)
+ {
+ error(_error_silent, "mods: %ld bitmaps from their files (%ld new), %ld given back, %ld left out",
+ played_count, loaded_count, removed_count, refused_count);
+ if (!at_load && mods_say())
+ {
+ console_printf(FALSE, "mods: %ld bitmaps from their files (%ld new), %ld given back, %ld left out",
+ played_count, loaded_count, removed_count, refused_count);
+ }
+ }
+
+ return played_count;
+}
+
+/* (once the texture cache is closed: no block holds a bitmap) */
+void mod_bitmaps_tags_unloaded(
+ void)
+{
+ struct mod_bitmap *made = mod_bitmaps_globals.bitmaps;
+
+ while (made)
+ {
+ struct mod_bitmap *next = made->next;
+
+ mod_bitmap_free(made);
+ made = next;
+ }
+ if (mod_bitmaps_globals.overrides)
+ free(mod_bitmaps_globals.overrides);
+ if (mod_bitmaps_globals.scales)
+ free(mod_bitmaps_globals.scales);
+ /* (a Custom Edition map's are its own, which it forgets as it goes) */
+ if (mod_bitmaps_globals.orders_found && !custom_edition_cache_tags_loaded())
+ custom_edition_bitmaps_dispose();
+ memset(&mod_bitmaps_globals, 0, sizeof(mod_bitmaps_globals));
+
+ return;
+}
+
+boolean mod_bitmaps_contain(
+ struct bitmap_data const *bitmap)
+{
+ return bitmap_of(bitmap) != NULL;
+}
+
+boolean mod_bitmaps_read(
+ struct bitmap_data *bitmap,
+ void *buffer)
+{
+ struct mod_bitmap *made = bitmap_of(bitmap);
+ unsigned long size;
+ unsigned long stamp[2];
+
+ if (!made)
+ {
+ /* (the renderer keeps each address's channel order until it is told
+ another: an Xbox map's bitmap where a mod's was has the Xbox's) */
+ if (mod_bitmaps_globals.orders_found && !custom_edition_cache_tags_loaded())
+ halo_custom_edition_texels_channels(buffer, _custom_edition_channels_xbox);
+ return FALSE;
+ }
+ /* (a file changed since it was read is read again by mods_reload: until
+ then its bitmaps are black) */
+ if (!port_mods_file_stamp(made->file, &size, stamp) || size != made->file_size ||
+ stamp[0] != made->stamp[0] || stamp[1] != made->stamp[1] ||
+ !port_mods_file_read(made->file, made->pixel_data_offset + (unsigned long)bitmap->pixels_offset, buffer,
+ (unsigned long)bitmap->pixels_size))
+ {
+ if (!mod_bitmaps_globals.read_failure_reported)
+ {
+ error(_error_silent, "mods: the pixels of %s cannot be read from %s (mods_reload reads it again)",
+ tag_get_name(made->tag_index), made->path);
+ mod_bitmaps_globals.read_failure_reported = TRUE;
+ }
+ memset(buffer, 0, (size_t)bitmap->pixels_size);
+ }
+ halo_custom_edition_texels_channels(
+ buffer,
+ mod_bitmaps_globals.orders_found || custom_edition_cache_tags_loaded() ?
+ (unsigned char)custom_edition_bitmap_channel_order(made->tag_index, (long)(bitmap - made->bitmaps)) :
+ _custom_edition_channels_xbox);
+ /* (on failure it logs, and the texture shows the pixels as read) */
+ rasterizer_xbox_bitmap_rebuild_hardware_format(bitmap);
+
+ return TRUE;
+}
diff --git a/port/linux/game/mod_models.c b/port/linux/game/mod_models.c
new file mode 100644
index 0000000000..ff531d78f5
--- /dev/null
+++ b/port/linux/game/mod_models.c
@@ -0,0 +1,1960 @@
+/*
+MOD_MODELS.C
+
+The models of mods (mods.c): each of a map's model tags that a mod has a
+tag file of (tags\...\name.gbxmodel, Halo PC's, or tags\...\name.model, the
+Xbox's as invader-extract writes it) is drawn from that file instead of the
+map, and mods_reload reads them again.
+
+A model is more than what is drawn: its nodes are what its animations move,
+its markers are where projectiles leave a weapon, where a unit sits and
+where things attach, and its regions and permutations are what damage and
+an object's variant choose among. Those are what the machines of a network
+game agree on, so a mod's model must have the map's: the same nodes (names,
+hierarchy, default positions), the same markers (names, regions,
+permutations, nodes, positions and rotations, whether the file keeps them
+on its permutations, as the Halo Editing Kit does, or on the model, as a
+map does) and the same regions and permutations (names and flags). Only what
+is drawn is the file's: its geometries and their parts, which geometry each
+permutation draws at each detail level, the detail levels' cutoffs, the base
+map's scale, and which of the map's shaders each part is drawn with (a mod
+cannot add tags: every shader the file names must be one the map has). The
+model played keeps the map's nodes, markers and regions; nothing a machine
+simulates reads anything else of a model.
+
+A tag file is big-endian, as the Halo Editing Kit's tool and Invader write
+it: the model, then each block's elements followed by each element's own
+blocks, in order (its markers and their instances, its nodes, its regions,
+their permutations and their markers, its geometries, their parts and each
+part's vertices, compressed vertices and strip, and its shaders and their
+names). Everything is checked against the file before it is used, which must
+end where its last shader's name does. A part's strip is the triangles
+block's indices, three to an element, less the empty ones that end it, as
+the tool builds a map's strips. What a map keeps that a tag file has not got
+is made as the tool makes it: each part's centroid nodes and weights (its
+two most weighted nodes). A part's vertices are compressed as a Custom
+Edition map's are, by the game's own rasterizer_geometry_compress_vertices,
+into the Xbox's contiguous memory, and given buffers by the game's own
+rasterizer_vertex_buffer_new and rasterizer_triangle_buffer_new; a model of
+more nodes than the renderer skins at once has each part drawn its own nodes
+at a time (custom_edition_part_palette asks mod_models_part_palette).
+
+The map's model tag takes the made model's definition in place, so every
+object that holds the tag keeps holding it, and its own comes back when the
+file goes. A model made is kept until the map goes, its buffers with it, as
+a frame may still draw from it.
+*/
+
+/* ---------- headers */
+
+#include "cseries.h"
+#include "cseries/cseries_windows.h"
+#include "errors.h"
+#include "cache/cache_files.h"
+#include "main/console.h"
+#include "math/real_math.h"
+#include "models/model_definitions.h"
+#include "models/models.h"
+#include "rasterizer/rasterizer.h"
+#include "rasterizer/rasterizer_geometry.h"
+#include "rasterizer/rasterizer_model_types.h"
+#include "tag_files/tag_groups.h"
+#include "mods.h"
+
+#include
+#include
+#include
+#include
+#include
+
+/* the game's (port) */
+char *tag_get_name(long tag_index);
+unsigned long tag_get_group_tag(long tag_index);
+
+/* ---------- constants */
+
+/* a model tag's file: tags\.gbxmodel (Halo PC's), else
+tags\.model (the Xbox's) */
+#define GBXMODEL_FILE_EXTENSION ".gbxmodel"
+#define MODEL_FILE_EXTENSION ".model"
+#define GBXMODEL_GROUP_TAG 'mod2'
+#define GBXMODEL_TAG_VERSION 5
+
+/* the sizes of a tag file's model, marker, marker instance, node, region,
+permutation, permutation marker, geometry, part (the Xbox's and Halo PC's),
+vertex, compressed vertex, strip element and shader reference */
+#define MODEL_SIZE 0xE8
+#define MARKER_SIZE 0x40
+#define MARKER_INSTANCE_SIZE 0x20
+#define NODE_SIZE 0x9C
+#define REGION_SIZE 0x4C
+#define PERMUTATION_SIZE 0x58
+#define PERMUTATION_MARKER_SIZE 0x50
+#define GEOMETRY_SIZE 0x30
+#define PART_SIZE 0x68
+#define GBXMODEL_PART_SIZE 0x84
+#define VERTEX_SIZE 0x44
+#define COMPRESSED_VERTEX_SIZE 0x20
+#define STRIP_ELEMENT_SIZE 6
+#define SHADER_REFERENCE_SIZE 0x20
+
+/* where a tag file's model, marker, node, region, permutation, permutation
+marker, geometry, part and shader reference keep what is read of them */
+#define MODEL_FLAGS_OFFSET 0x00
+#define MODEL_CUTOFFS_OFFSET 0x08
+#define MODEL_BASE_MAP_SCALE_OFFSET 0x30
+#define MODEL_MARKERS_OFFSET 0xAC
+#define MODEL_NODES_OFFSET 0xB8
+#define MODEL_REGIONS_OFFSET 0xC4
+#define MODEL_GEOMETRIES_OFFSET 0xD0
+#define MODEL_SHADERS_OFFSET 0xDC
+#define MARKER_INSTANCES_OFFSET 0x34
+#define NODE_HIERARCHY_OFFSET 0x20
+#define NODE_TRANSLATION_OFFSET 0x28
+#define NODE_ROTATION_OFFSET 0x34
+#define NODE_DISTANCE_OFFSET 0x44
+#define REGION_PERMUTATIONS_OFFSET 0x40
+#define PERMUTATION_FLAGS_OFFSET 0x20
+#define PERMUTATION_GEOMETRIES_OFFSET 0x40
+#define PERMUTATION_MARKERS_OFFSET 0x4C
+#define PERMUTATION_MARKER_NODE_OFFSET 0x20
+#define PERMUTATION_MARKER_ROTATION_OFFSET 0x24
+#define PERMUTATION_MARKER_TRANSLATION_OFFSET 0x34
+#define GEOMETRY_PARTS_OFFSET 0x24
+#define PART_SHADER_OFFSET 0x04
+#define PART_PREVIOUS_OFFSET 0x06
+#define PART_NEXT_OFFSET 0x07
+#define PART_CENTROID_OFFSET 0x14
+#define PART_VERTICES_OFFSET 0x20
+#define PART_COMPRESSED_VERTICES_OFFSET 0x2C
+#define PART_TRIANGLES_OFFSET 0x38
+#define GBXMODEL_PART_LOCAL_NODE_COUNT_OFFSET 0x6B
+#define GBXMODEL_PART_LOCAL_NODES_OFFSET 0x6C
+#define SHADER_REFERENCE_NAME_LENGTH_OFFSET 0x08
+#define SHADER_REFERENCE_PERMUTATION_OFFSET 0x10
+
+/* a tag file's model's flags: its vertices' normals blended where they
+share a position (the tool does it as it builds a map), and its parts'
+vertices name nodes through their parts' tables (Halo PC's: as
+custom_edition_geometry.c's _gbxmodel_parts_have_local_nodes_bit) */
+#define MODEL_BLEND_SHARED_NORMALS_BIT 0
+#define MODEL_PARTS_HAVE_LOCAL_NODES_BIT 1
+/* a part's flags: its vertices name local nodes (custom_edition_geometry.c) */
+#define PART_LOCAL_NODES_BIT 1
+/* a permutation's flags the machines agree on */
+#define PERMUTATION_FLAGS_MASK FLAG(_model_region_permutation_cannot_be_chosen_randomly_bit)
+
+/* an empty strip index (the tool's padding at a strip's end), and an empty
+part link */
+#define NULL_INDEX 0xFFFF
+#define NULL_LINK 0xFF
+
+/* how far a node's or marker's position or rotation may be from the map's
+(a model exported again is not always bit for bit the map's) */
+#define MAXIMUM_DIFFERENCE 0.001f
+
+/* rasterizer_geometry_compress_vertices clamps normals and the like to
+[-1, 1] and asserts the packed vector is within 0.01 of the original
+(custom_edition_geometry.c's limit) */
+#define MAXIMUM_COMPRESSIBLE_COMPONENT 1.005f
+
+/* the most marker instances compared: a model's markers' (the most
+markers of the most instances), and as many of its permutations' */
+#define MAXIMUM_MARKER_INSTANCES (2 * MAXIMUM_MARKERS_PER_MODEL * MAXIMUM_INSTANCES_PER_MODEL_MARKER)
+
+/* the most of the Xbox's contiguous memory the mods' models take (what is
+left is the game's: its own maps' geometry, and on Android and the web a
+window of it in all); past it, their geometry is drawn from the heap */
+#if defined(HALO_ANDROID) || defined(HALO_WEB)
+#define MAXIMUM_CONTIGUOUS_GEOMETRY_BYTES (24UL * 1024 * 1024)
+#else
+#define MAXIMUM_CONTIGUOUS_GEOMETRY_BYTES (128UL * 1024 * 1024)
+#endif
+
+/* ---------- structures */
+
+/* this build's model geometry, part and shader reference, as models.c and
+rasterizer.c each define them for themselves */
+struct model_geometry
+{
+ byte reserved[0x24];
+ struct tag_block parts;
+};
+
+struct model_geometry_part
+{
+ unsigned long flags;
+ short shader_index;
+ char previous_part_index;
+ char next_part_index;
+ short centroid_primary_node_index;
+ short centroid_secondary_node_index;
+ real centroid_primary_node_weight;
+ real centroid_secondary_node_weight;
+ real_point3d centroid;
+ struct tag_block uncompressed_vertices;
+ struct tag_block compressed_vertices;
+ struct tag_block triangles;
+ struct triangle_buffer triangle_buffer;
+ struct vertex_buffer vertex_buffer;
+};
+
+struct model_shader_reference
+{
+ struct tag_reference shader;
+ short permutation_index;
+ word pad;
+ long unused[3];
+};
+
+typedef char verify_model_size[sizeof(struct model) == MODEL_SIZE ? 1 : -1];
+typedef char verify_model_node_size[sizeof(struct model_node) == NODE_SIZE ? 1 : -1];
+typedef char verify_model_region_size[sizeof(struct model_region) == REGION_SIZE ? 1 : -1];
+typedef char verify_model_region_permutation_size[
+ sizeof(struct model_region_permutation) == PERMUTATION_SIZE ? 1 : -1];
+typedef char verify_model_marker_size[sizeof(struct model_marker) == MARKER_SIZE ? 1 : -1];
+typedef char verify_model_marker_instance_size[sizeof(struct model_marker_instance) == MARKER_INSTANCE_SIZE ? 1 : -1];
+typedef char verify_model_geometry_size[sizeof(struct model_geometry) == GEOMETRY_SIZE ? 1 : -1];
+typedef char verify_model_geometry_part_size[sizeof(struct model_geometry_part) == PART_SIZE ? 1 : -1];
+typedef char verify_model_shader_reference_size[
+ sizeof(struct model_shader_reference) == SHADER_REFERENCE_SIZE ? 1 : -1];
+typedef char verify_model_vertex_size[sizeof(struct model_vertex_uncompressed) == VERTEX_SIZE ? 1 : -1];
+
+/* a marker's instance, of a file or of the map: what is compared */
+struct marker_instance
+{
+ char name[TAG_STRING_LENGTH + 1];
+ short region_index;
+ short permutation_index;
+ short node_index;
+ real_point3d translation;
+ real_quaternion rotation;
+ boolean matched;
+};
+
+/* where a file's part is, and its vertices and strip (offsets in the file) */
+struct file_part
+{
+ unsigned long offset;
+ unsigned long vertices;
+ long vertex_count;
+ unsigned long strip;
+ long strip_element_count;
+ /* the indices its strip has, less the empty ones that end it */
+ long strip_index_count;
+};
+
+/* what a file has, and where (offsets in the file), as it is walked */
+struct model_file
+{
+ byte const *bytes;
+ unsigned long size;
+ boolean gbxmodel;
+ unsigned long part_size;
+ unsigned long model_flags;
+ unsigned long nodes;
+ long node_count;
+ unsigned long regions;
+ long region_count;
+ /* each region's permutations, and each permutation's markers */
+ unsigned long permutations[MAXIMUM_REGIONS_PER_MODEL];
+ long permutation_counts[MAXIMUM_REGIONS_PER_MODEL];
+ unsigned long permutation_markers[MAXIMUM_REGIONS_PER_MODEL][MAXIMUM_PERMUTATIONS_PER_MODEL_REGION];
+ long permutation_marker_counts[MAXIMUM_REGIONS_PER_MODEL][MAXIMUM_PERMUTATIONS_PER_MODEL_REGION];
+ /* the model's markers (a map's way: the tool's tag files have none),
+ and each one's instances */
+ unsigned long markers;
+ long marker_count;
+ unsigned long marker_instances[MAXIMUM_MARKERS_PER_MODEL];
+ long marker_instance_counts[MAXIMUM_MARKERS_PER_MODEL];
+ long geometry_count;
+ long geometry_part_counts[MAXIMUM_GEOMETRIES_PER_MODEL];
+ struct file_part *parts;
+ long part_count;
+ unsigned long shaders;
+ long shader_count;
+ unsigned long shader_names[MAXIMUM_SHADERS_PER_MODEL];
+};
+
+/* the nodes a part of a model of many nodes is drawn with, by its vertex
+buffer (custom_edition_geometry.c's part_palette) */
+struct mod_model_palette
+{
+ struct vertex_buffer const *vertex_buffer;
+ byte node_count;
+ byte nodes[MAXIMUM_NODES_PER_MODEL_GEOMETRY_PART];
+};
+
+/* a model tag file read, and what it makes */
+struct mod_model
+{
+ struct mod_model *next;
+ long tag_index;
+ /* the file (its index in the mods' files of the last time they were
+ found), and its path, size and modification time, to tell whether it
+ changed */
+ long file;
+ char *path;
+ unsigned long file_size;
+ unsigned long stamp[2];
+ /* the definition it makes, and the blocks it has of its own */
+ struct model model;
+ struct model_region *regions;
+ struct model_region_permutation *permutations;
+ struct model_geometry *geometries;
+ struct model_geometry_part *parts;
+ long part_count;
+ struct model_shader_reference *shaders;
+ struct mod_model_palette *palettes;
+ long palette_count;
+ /* the compressed vertices and strips the parts' buffers are made from,
+ their size, and whether they are in contiguous memory */
+ byte *geometry;
+ unsigned long geometry_size;
+ boolean geometry_contiguous;
+ unsigned long kept_bytes;
+};
+
+/* a model of the map drawn from a file: the map's own definition, given
+back when it goes */
+struct mod_model_override
+{
+ struct mod_model *model;
+ struct model map_model;
+};
+
+static struct
+{
+ /* by the map's tag, its override (none: the map's own) */
+ struct mod_model_override *overrides;
+ long override_count;
+ /* every model made while the map is loaded, and the contiguous memory
+ their geometry takes */
+ struct mod_model *models;
+ unsigned long contiguous_bytes;
+} mod_models_globals;
+
+/* ---------- private code */
+
+static boolean close_enough(
+ real first,
+ real second)
+{
+ return fabs(first - second) <= MAXIMUM_DIFFERENCE;
+}
+
+static boolean points_close(
+ real_point3d const *first,
+ real_point3d const *second)
+{
+ return close_enough(first->x, second->x) && close_enough(first->y, second->y) &&
+ close_enough(first->z, second->z);
+}
+
+/* (a rotation and its negation are the same) */
+static boolean rotations_close(
+ real_quaternion const *first,
+ real_quaternion const *second)
+{
+ return (close_enough(first->v.i, second->v.i) && close_enough(first->v.j, second->v.j) &&
+ close_enough(first->v.k, second->v.k) && close_enough(first->w, second->w)) ||
+ (close_enough(first->v.i, -second->v.i) && close_enough(first->v.j, -second->v.j) &&
+ close_enough(first->v.k, -second->v.k) && close_enough(first->w, -second->w));
+}
+
+static boolean vector_compressible(
+ real_vector3d const *vector)
+{
+ /* a NaN fails every comparison */
+ return fabs(vector->i) <= MAXIMUM_COMPRESSIBLE_COMPONENT &&
+ fabs(vector->j) <= MAXIMUM_COMPRESSIBLE_COMPONENT &&
+ fabs(vector->k) <= MAXIMUM_COMPRESSIBLE_COMPONENT;
+}
+
+static void read_point(
+ byte const *bytes,
+ real_point3d *point)
+{
+ point->x = mods_read_real(bytes);
+ point->y = mods_read_real(bytes + 4);
+ point->z = mods_read_real(bytes + 8);
+}
+
+static void read_vector(
+ byte const *bytes,
+ real_vector3d *vector)
+{
+ vector->i = mods_read_real(bytes);
+ vector->j = mods_read_real(bytes + 4);
+ vector->k = mods_read_real(bytes + 8);
+}
+
+static void read_quaternion(
+ byte const *bytes,
+ real_quaternion *quaternion)
+{
+ read_vector(bytes, &quaternion->v);
+ quaternion->w = mods_read_real(bytes + 12);
+}
+
+static boolean point_finite(
+ real_point3d const *point)
+{
+ return mods_finite_real(point->x) && mods_finite_real(point->y) && mods_finite_real(point->z);
+}
+
+static boolean quaternion_finite(
+ real_quaternion const *quaternion)
+{
+ return mods_finite_real(quaternion->v.i) && mods_finite_real(quaternion->v.j) &&
+ mods_finite_real(quaternion->v.k) && mods_finite_real(quaternion->w);
+}
+
+/* a tag file's name (TAG_STRING_LENGTH characters at most), terminated */
+static void read_name(
+ byte const *bytes,
+ char name[TAG_STRING_LENGTH + 1])
+{
+ memcpy(name, bytes, TAG_STRING_LENGTH);
+ name[TAG_STRING_LENGTH] = 0;
+}
+
+/* `count` elements of `size` bytes at *cursor, which passes them: FALSE
+when they are more than `maximum` or not all in the file */
+static boolean block_take(
+ struct model_file const *file,
+ unsigned long *cursor,
+ long count,
+ long maximum,
+ unsigned long size,
+ unsigned long *offset)
+{
+ if (count < 0 || count > maximum || !mods_in_file(*cursor, (unsigned long)count * size, file->size))
+ {
+ return FALSE;
+ }
+ *offset = *cursor;
+ *cursor += (unsigned long)count * size;
+
+ return TRUE;
+}
+
+/* a block's count, in a tag file's element */
+static long block_count(
+ byte const *element,
+ unsigned long offset)
+{
+ return (long)mods_read_be32(element + offset);
+}
+
+/* Walks the file, finding where everything is: FALSE (with *reason) when a
+count is past what the tool allows, something is not in the file, or the
+file goes on past its last shader's name. */
+static boolean model_file_walk(
+ struct model_file *file,
+ char const **reason)
+{
+ byte const *root = file->bytes + MOD_TAG_FILE_HEADER_SIZE;
+ unsigned long cursor = MOD_TAG_FILE_HEADER_SIZE + MODEL_SIZE;
+ long index;
+ long part_index = 0;
+
+ *reason = "a count of its blocks is past what the tool allows, or the blocks are not all in the file";
+ file->model_flags = mods_read_be32(root + MODEL_FLAGS_OFFSET);
+ file->marker_count = block_count(root, MODEL_MARKERS_OFFSET);
+ file->node_count = block_count(root, MODEL_NODES_OFFSET);
+ file->region_count = block_count(root, MODEL_REGIONS_OFFSET);
+ file->geometry_count = block_count(root, MODEL_GEOMETRIES_OFFSET);
+ file->shader_count = block_count(root, MODEL_SHADERS_OFFSET);
+
+ if (!block_take(file, &cursor, file->marker_count, MAXIMUM_MARKERS_PER_MODEL, MARKER_SIZE, &file->markers))
+ {
+ return FALSE;
+ }
+ for (index = 0; index < file->marker_count; index++)
+ {
+ byte const *marker = file->bytes + file->markers + (unsigned long)index * MARKER_SIZE;
+
+ file->marker_instance_counts[index] = block_count(marker, MARKER_INSTANCES_OFFSET);
+ if (!block_take(file, &cursor, file->marker_instance_counts[index], MAXIMUM_INSTANCES_PER_MODEL_MARKER,
+ MARKER_INSTANCE_SIZE, &file->marker_instances[index]))
+ {
+ return FALSE;
+ }
+ }
+ if (!block_take(file, &cursor, file->node_count, MAXIMUM_NODES_PER_MODEL, NODE_SIZE, &file->nodes) ||
+ !block_take(file, &cursor, file->region_count, MAXIMUM_REGIONS_PER_MODEL, REGION_SIZE, &file->regions))
+ {
+ return FALSE;
+ }
+ for (index = 0; index < file->region_count; index++)
+ {
+ byte const *region = file->bytes + file->regions + (unsigned long)index * REGION_SIZE;
+ long permutation;
+
+ file->permutation_counts[index] = block_count(region, REGION_PERMUTATIONS_OFFSET);
+ if (!block_take(file, &cursor, file->permutation_counts[index], MAXIMUM_PERMUTATIONS_PER_MODEL_REGION,
+ PERMUTATION_SIZE, &file->permutations[index]))
+ {
+ return FALSE;
+ }
+ for (permutation = 0; permutation < file->permutation_counts[index]; permutation++)
+ {
+ byte const *element = file->bytes + file->permutations[index] + (unsigned long)permutation * PERMUTATION_SIZE;
+
+ file->permutation_marker_counts[index][permutation] = block_count(element, PERMUTATION_MARKERS_OFFSET);
+ if (!block_take(file, &cursor, file->permutation_marker_counts[index][permutation],
+ MAXIMUM_MARKERS_PER_MODEL_REGION_PERMUTATION, PERMUTATION_MARKER_SIZE,
+ &file->permutation_markers[index][permutation]))
+ {
+ return FALSE;
+ }
+ }
+ }
+ {
+ unsigned long geometries;
+
+ if (!block_take(file, &cursor, file->geometry_count, MAXIMUM_GEOMETRIES_PER_MODEL, GEOMETRY_SIZE, &geometries))
+ {
+ return FALSE;
+ }
+ for (index = 0; index < file->geometry_count; index++)
+ {
+ byte const *geometry = file->bytes + geometries + (unsigned long)index * GEOMETRY_SIZE;
+ unsigned long parts;
+ long part;
+
+ file->geometry_part_counts[index] = block_count(geometry, GEOMETRY_PARTS_OFFSET);
+ if (!block_take(file, &cursor, file->geometry_part_counts[index], MAXIMUM_PARTS_PER_MODEL_GEOMETRY,
+ file->part_size, &parts))
+ {
+ return FALSE;
+ }
+ for (part = 0; part < file->geometry_part_counts[index]; part++)
+ {
+ struct file_part *found = &file->parts[part_index++];
+ byte const *element = file->bytes + parts + (unsigned long)part * file->part_size;
+ unsigned long compressed;
+
+ found->offset = parts + (unsigned long)part * file->part_size;
+ found->vertex_count = block_count(element, PART_VERTICES_OFFSET);
+ found->strip_element_count = block_count(element, PART_TRIANGLES_OFFSET);
+ if (!block_take(file, &cursor, found->vertex_count, MAXIMUM_VERTICES_PER_MODEL_GEOMETRY_PART, VERTEX_SIZE,
+ &found->vertices) ||
+ !block_take(file, &cursor, block_count(element, PART_COMPRESSED_VERTICES_OFFSET),
+ MAXIMUM_VERTICES_PER_MODEL_GEOMETRY_PART, COMPRESSED_VERTEX_SIZE, &compressed) ||
+ !block_take(file, &cursor, found->strip_element_count, MAXIMUM_TRIANGLES_PER_MODEL_GEOMETRY_PART,
+ STRIP_ELEMENT_SIZE, &found->strip))
+ {
+ return FALSE;
+ }
+ }
+ }
+ }
+ file->part_count = part_index;
+ if (!block_take(file, &cursor, file->shader_count, MAXIMUM_SHADERS_PER_MODEL, SHADER_REFERENCE_SIZE, &file->shaders))
+ {
+ return FALSE;
+ }
+ *reason = "a shader's name is not in the file";
+ for (index = 0; index < file->shader_count; index++)
+ {
+ byte const *reference = file->bytes + file->shaders + (unsigned long)index * SHADER_REFERENCE_SIZE;
+ unsigned long length = mods_read_be32(reference + SHADER_REFERENCE_NAME_LENGTH_OFFSET);
+
+ file->shader_names[index] = cursor;
+ if (!length || length > 255 || !mods_in_file(cursor, length + 1, file->size) ||
+ file->bytes[cursor + length] != 0 || memchr(file->bytes + cursor, 0, length))
+ {
+ return FALSE;
+ }
+ cursor += length + 1;
+ }
+ *reason = "the file goes on past its last shader's name";
+
+ return cursor == file->size;
+}
+
+/* the most parts a file walked can find (MAXIMUM_PARTS_PER_MODEL_GEOMETRY
+for each of its geometries), for the room to note them in; NONE when its
+geometries are more than the tool allows */
+static long model_file_part_count(
+ struct model_file const *file)
+{
+ byte const *root = file->bytes + MOD_TAG_FILE_HEADER_SIZE;
+ long geometry_count = block_count(root, MODEL_GEOMETRIES_OFFSET);
+
+ /* (model_file_walk finds the parts; this is only their most) */
+ if (geometry_count < 0 || geometry_count > MAXIMUM_GEOMETRIES_PER_MODEL)
+ return NONE;
+
+ return geometry_count * MAXIMUM_PARTS_PER_MODEL_GEOMETRY;
+}
+
+/* Whether the file's nodes are the map's: their names, hierarchy and
+default positions. */
+static boolean nodes_match(
+ struct model_file const *file,
+ struct model const *map_model)
+{
+ long index;
+
+ if (file->node_count != map_model->nodes.count)
+ {
+ return FALSE;
+ }
+ for (index = 0; index < file->node_count; index++)
+ {
+ byte const *element = file->bytes + file->nodes + (unsigned long)index * NODE_SIZE;
+ struct model_node const *map_node = TAG_BLOCK_GET_ELEMENT(&map_model->nodes, index, struct model_node);
+ char name[TAG_STRING_LENGTH + 1];
+ real_point3d translation;
+ real_quaternion rotation;
+
+ if (!map_node)
+ return FALSE;
+ read_name(element, name);
+ read_point(element + NODE_TRANSLATION_OFFSET, &translation);
+ read_quaternion(element + NODE_ROTATION_OFFSET, &rotation);
+ if (strncmp(name, map_node->name, TAG_STRING_LENGTH) ||
+ mods_read_short(element + NODE_HIERARCHY_OFFSET) != map_node->next_sibling_node_index ||
+ mods_read_short(element + NODE_HIERARCHY_OFFSET + 2) != map_node->first_child_node_index ||
+ mods_read_short(element + NODE_HIERARCHY_OFFSET + 4) != map_node->parent_node_index ||
+ !point_finite(&translation) || !quaternion_finite(&rotation) ||
+ !points_close(&translation, &map_node->default_translation) ||
+ !rotations_close(&rotation, &map_node->default_rotation) ||
+ !close_enough(mods_read_real(element + NODE_DISTANCE_OFFSET), map_node->node_distance_from_parent))
+ {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+/* Whether the file's regions and permutations are the map's (names and the
+flags the machines agree on), each permutation's geometries ones the file
+has. */
+static boolean regions_match(
+ struct model_file const *file,
+ struct model const *map_model)
+{
+ long region_index;
+
+ if (file->region_count != map_model->regions.count)
+ {
+ return FALSE;
+ }
+ for (region_index = 0; region_index < file->region_count; region_index++)
+ {
+ byte const *region = file->bytes + file->regions + (unsigned long)region_index * REGION_SIZE;
+ struct model_region const *map_region = TAG_BLOCK_GET_ELEMENT(&map_model->regions, region_index,
+ struct model_region);
+ char name[TAG_STRING_LENGTH + 1];
+ long permutation_index;
+
+ read_name(region, name);
+ if (!map_region || strncmp(name, map_region->name, TAG_STRING_LENGTH) ||
+ file->permutation_counts[region_index] != map_region->permutations.count)
+ {
+ return FALSE;
+ }
+ for (permutation_index = 0; permutation_index < file->permutation_counts[region_index]; permutation_index++)
+ {
+ byte const *permutation = file->bytes + file->permutations[region_index] +
+ (unsigned long)permutation_index * PERMUTATION_SIZE;
+ struct model_region_permutation const *map_permutation = TAG_BLOCK_GET_ELEMENT(&map_region->permutations,
+ permutation_index, struct model_region_permutation);
+ long detail;
+
+ read_name(permutation, name);
+ if (!map_permutation || strncmp(name, map_permutation->name, TAG_STRING_LENGTH) ||
+ (mods_read_be32(permutation + PERMUTATION_FLAGS_OFFSET) & PERMUTATION_FLAGS_MASK) !=
+ ((unsigned long)map_permutation->flags & PERMUTATION_FLAGS_MASK))
+ {
+ return FALSE;
+ }
+ for (detail = 0; detail < 5; detail++)
+ {
+ short geometry = mods_read_short(permutation + PERMUTATION_GEOMETRIES_OFFSET + 2 * detail);
+
+ /* (a detail level the map draws nothing at stays so) */
+ if (map_permutation->geometry_indices[detail] == NONE ? geometry != NONE :
+ geometry < 0 || geometry >= file->geometry_count)
+ {
+ return FALSE;
+ }
+ }
+ }
+ }
+
+ return TRUE;
+}
+
+/* the marker instances of the map's model (its markers', and its
+permutations' should it have any), into `instances`: their count, or NONE
+when they are more than it holds */
+static long map_marker_instances(
+ struct model const *map_model,
+ struct marker_instance *instances)
+{
+ long count = 0;
+ long marker_index;
+ long region_index;
+
+ for (marker_index = 0; marker_index < map_model->markers.count; marker_index++)
+ {
+ struct model_marker const *marker = TAG_BLOCK_GET_ELEMENT(&map_model->markers, marker_index, struct model_marker);
+ long instance_index;
+
+ for (instance_index = 0; marker && instance_index < marker->instances.count; instance_index++)
+ {
+ struct model_marker_instance const *instance = TAG_BLOCK_GET_ELEMENT(&marker->instances, instance_index,
+ struct model_marker_instance);
+ struct marker_instance *found = &instances[count];
+
+ if (!instance)
+ continue;
+ if (count == MAXIMUM_MARKER_INSTANCES)
+ return NONE;
+ memcpy(found->name, marker->name, TAG_STRING_LENGTH);
+ found->name[TAG_STRING_LENGTH] = 0;
+ found->region_index = instance->region_index;
+ found->permutation_index = instance->permutation_index;
+ found->node_index = instance->node_index;
+ found->translation = instance->translation;
+ found->rotation = instance->rotation;
+ found->matched = FALSE;
+ count++;
+ }
+ }
+ for (region_index = 0; region_index < map_model->regions.count; region_index++)
+ {
+ struct model_region const *region = TAG_BLOCK_GET_ELEMENT(&map_model->regions, region_index, struct model_region);
+ long permutation_index;
+
+ for (permutation_index = 0; region && permutation_index < region->permutations.count; permutation_index++)
+ {
+ struct model_region_permutation const *permutation = TAG_BLOCK_GET_ELEMENT(®ion->permutations,
+ permutation_index, struct model_region_permutation);
+ long marker_index_in_permutation;
+
+ /* (object_marker: a name, a node, a rotation and a translation,
+ as a tag file's permutation markers are, PERMUTATION_MARKER_SIZE) */
+ for (marker_index_in_permutation = 0; permutation &&
+ marker_index_in_permutation < permutation->markers.count; marker_index_in_permutation++)
+ {
+ byte const *marker = (byte const *)permutation->markers.address +
+ (unsigned long)marker_index_in_permutation * PERMUTATION_MARKER_SIZE;
+ struct marker_instance *found = &instances[count];
+
+ if (!permutation->markers.address)
+ break;
+ if (count == MAXIMUM_MARKER_INSTANCES)
+ return NONE;
+ memcpy(found->name, marker, TAG_STRING_LENGTH);
+ found->name[TAG_STRING_LENGTH] = 0;
+ found->region_index = (short)region_index;
+ found->permutation_index = (short)permutation_index;
+ csmemcpy(&found->node_index, marker + PERMUTATION_MARKER_NODE_OFFSET, sizeof(short));
+ csmemcpy(&found->rotation, marker + PERMUTATION_MARKER_ROTATION_OFFSET, sizeof(found->rotation));
+ csmemcpy(&found->translation, marker + PERMUTATION_MARKER_TRANSLATION_OFFSET, sizeof(found->translation));
+ found->matched = FALSE;
+ count++;
+ }
+ }
+ }
+
+ return count;
+}
+
+/* Whether a marker instance of the file is one of the map's not yet
+matched, and matches it. */
+static boolean marker_instance_match(
+ struct marker_instance *instances,
+ long count,
+ char const *name,
+ short region_index,
+ short permutation_index,
+ short node_index,
+ real_point3d const *translation,
+ real_quaternion const *rotation)
+{
+ long index;
+
+ if (!point_finite(translation) || !quaternion_finite(rotation))
+ {
+ return FALSE;
+ }
+ for (index = 0; index < count; index++)
+ {
+ struct marker_instance *instance = &instances[index];
+
+ if (!instance->matched && !strncmp(instance->name, name, TAG_STRING_LENGTH) &&
+ instance->region_index == region_index && instance->permutation_index == permutation_index &&
+ instance->node_index == node_index && points_close(&instance->translation, translation) &&
+ rotations_close(&instance->rotation, rotation))
+ {
+ instance->matched = TRUE;
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+}
+
+/* Whether the file's markers, on its model or on its permutations, are the
+map's, every one of them (*reason says why not). */
+static boolean markers_match(
+ struct model_file const *file,
+ struct model const *map_model,
+ char const **reason)
+{
+ struct marker_instance *instances = malloc(MAXIMUM_MARKER_INSTANCES * sizeof(*instances));
+ long count;
+ long matched = 0;
+ long index;
+ boolean match = TRUE;
+
+ *reason = "out of memory";
+ if (!instances)
+ {
+ return FALSE;
+ }
+ *reason = "the map's model has more marker instances than are compared";
+ count = map_marker_instances(map_model, instances);
+ if (count == NONE)
+ {
+ free(instances);
+ return FALSE;
+ }
+ *reason = "its markers are not the map's (their names, nodes, positions and rotations must be)";
+ /* (as many as the map's first: each is then looked for among them) */
+ for (index = 0; index < file->marker_count; index++)
+ matched += file->marker_instance_counts[index];
+ for (index = 0; index < file->region_count; index++)
+ {
+ long permutation_index;
+
+ for (permutation_index = 0; permutation_index < file->permutation_counts[index]; permutation_index++)
+ matched += file->permutation_marker_counts[index][permutation_index];
+ }
+ if (matched != count)
+ {
+ free(instances);
+ return FALSE;
+ }
+ for (index = 0; match && index < file->marker_count; index++)
+ {
+ byte const *marker = file->bytes + file->markers + (unsigned long)index * MARKER_SIZE;
+ char name[TAG_STRING_LENGTH + 1];
+ long instance_index;
+
+ read_name(marker, name);
+ for (instance_index = 0; match && instance_index < file->marker_instance_counts[index]; instance_index++)
+ {
+ byte const *instance = file->bytes + file->marker_instances[index] +
+ (unsigned long)instance_index * MARKER_INSTANCE_SIZE;
+ real_point3d translation;
+ real_quaternion rotation;
+
+ read_point(instance + 4, &translation);
+ read_quaternion(instance + 16, &rotation);
+ match = marker_instance_match(instances, count, name, instance[0], instance[1], instance[2], &translation,
+ &rotation);
+ }
+ }
+ for (index = 0; match && index < file->region_count; index++)
+ {
+ long permutation_index;
+
+ for (permutation_index = 0; match && permutation_index < file->permutation_counts[index]; permutation_index++)
+ {
+ long marker_index;
+
+ for (marker_index = 0; match && marker_index < file->permutation_marker_counts[index][permutation_index];
+ marker_index++)
+ {
+ byte const *marker = file->bytes + file->permutation_markers[index][permutation_index] +
+ (unsigned long)marker_index * PERMUTATION_MARKER_SIZE;
+ char name[TAG_STRING_LENGTH + 1];
+ real_point3d translation;
+ real_quaternion rotation;
+
+ read_name(marker, name);
+ read_quaternion(marker + PERMUTATION_MARKER_ROTATION_OFFSET, &rotation);
+ read_point(marker + PERMUTATION_MARKER_TRANSLATION_OFFSET, &translation);
+ match = marker_instance_match(instances, count, name, (short)index, (short)permutation_index,
+ mods_read_short(marker + PERMUTATION_MARKER_NODE_OFFSET), &translation, &rotation);
+ }
+ }
+ }
+ free(instances);
+
+ /* (each matched one of the map's, as many as it has: none is missing) */
+ return match;
+}
+
+/* the map's shader the file's shader reference `index` names (a tag of one
+of the shader groups of that name, its own group's first), or NONE */
+static long shader_find(
+ struct model_file const *file,
+ long index)
+{
+ static unsigned long const shader_groups[] =
+ {
+ 'soso', 'senv', 'schi', 'scex', 'sotr', 'sgla', 'smet', 'spla', 'swat',
+ };
+ byte const *reference = file->bytes + file->shaders + (unsigned long)index * SHADER_REFERENCE_SIZE;
+ char const *name = (char const *)file->bytes + file->shader_names[index];
+ unsigned long file_group = mods_read_be32(reference);
+ long tag_index = NONE;
+ long group;
+
+ for (group = 0; tag_index == NONE && group < (long)NUMBEROF(shader_groups); group++)
+ {
+ if (shader_groups[group] == file_group)
+ tag_index = tag_loaded((long)file_group, name);
+ }
+ for (group = 0; tag_index == NONE && group < (long)NUMBEROF(shader_groups); group++)
+ {
+ tag_index = tag_loaded((long)shader_groups[group], name);
+ }
+
+ return tag_index;
+}
+
+/* the model node a vertex's node index names, through its part's table
+when the file's parts have local nodes; NONE for none, and for one past what
+is named */
+static short vertex_model_node(
+ struct model_file const *file,
+ byte const *part,
+ short node_index)
+{
+ boolean local = file->gbxmodel && TEST_FLAG(file->model_flags, MODEL_PARTS_HAVE_LOCAL_NODES_BIT);
+
+ if (node_index < 0)
+ {
+ return NONE;
+ }
+ if (local)
+ {
+ byte local_count = part[GBXMODEL_PART_LOCAL_NODE_COUNT_OFFSET];
+
+ if (node_index >= local_count || local_count > MAXIMUM_NODES_PER_MODEL_GEOMETRY_PART)
+ return NONE;
+ node_index = part[GBXMODEL_PART_LOCAL_NODES_OFFSET + node_index];
+ }
+
+ return node_index < file->node_count ? node_index : NONE;
+}
+
+/* A part's vertices, as this build keeps them uncompressed, into
+`vertices`, their nodes the model's: FALSE when a vertex names a node the
+model has not got, or has a value this build cannot draw. */
+static boolean part_vertices_read(
+ struct model_file const *file,
+ struct file_part const *part,
+ struct model_vertex_uncompressed *vertices)
+{
+ byte const *element = file->bytes + part->offset;
+ long index;
+
+ for (index = 0; index < part->vertex_count; index++)
+ {
+ byte const *bytes = file->bytes + part->vertices + (unsigned long)index * VERTEX_SIZE;
+ struct model_vertex_uncompressed *vertex = &vertices[index];
+ short second;
+
+ read_point(bytes, &vertex->position);
+ read_vector(bytes + 0x0C, &vertex->normal);
+ read_vector(bytes + 0x18, &vertex->binormal);
+ read_vector(bytes + 0x24, &vertex->tangent);
+ vertex->texcoord.x = mods_read_real(bytes + 0x30);
+ vertex->texcoord.y = mods_read_real(bytes + 0x34);
+ vertex->nodes[0] = vertex_model_node(file, element, mods_read_short(bytes + 0x38));
+ second = mods_read_short(bytes + 0x3A);
+ vertex->node_weights[0] = mods_read_real(bytes + 0x3C);
+ vertex->node_weights[1] = mods_read_real(bytes + 0x40);
+ /* (none: the first node's alone; it is the first node again, of no
+ weight, so that it names no node more) */
+ if (second == NONE)
+ {
+ vertex->nodes[1] = vertex->nodes[0];
+ vertex->node_weights[1] = 0.f;
+ }
+ else
+ {
+ vertex->nodes[1] = vertex_model_node(file, element, second);
+ }
+ if (vertex->nodes[0] == NONE || vertex->nodes[1] == NONE ||
+ !point_finite(&vertex->position) || !mods_finite_real(vertex->texcoord.x) ||
+ !mods_finite_real(vertex->texcoord.y) || !mods_finite_real(vertex->node_weights[0]) ||
+ !mods_finite_real(vertex->node_weights[1]) || !vector_compressible(&vertex->normal) ||
+ !vector_compressible(&vertex->binormal) || !vector_compressible(&vertex->tangent))
+ {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+/* The part's strip's indices (the triangles block's, less the empty ones
+that end it) counted into part->strip_index_count: FALSE when it has not
+got a triangle's, or names a vertex the part has not got. */
+static boolean part_strip_check(
+ struct model_file const *file,
+ struct file_part *part)
+{
+ byte const *strip = file->bytes + part->strip;
+ long count = part->strip_element_count * 3;
+ long index;
+
+ while (count > 0 && mods_read_be16(strip + 2 * (count - 1)) == NULL_INDEX)
+ {
+ count--;
+ }
+ if (count < 3)
+ {
+ return FALSE;
+ }
+ for (index = 0; index < count; index++)
+ {
+ if (mods_read_be16(strip + 2 * index) >= (unsigned long)part->vertex_count)
+ return FALSE;
+ }
+ part->strip_index_count = count;
+
+ return TRUE;
+}
+
+/* a part's link to the part drawn before or after it (prev_filthy_part_index,
+next_filthy_part_index), NULL_LINK for none: a tag's two links of 0 are
+none, as the tool takes them */
+static byte part_link(
+ byte const *element,
+ unsigned long offset)
+{
+ return element[PART_PREVIOUS_OFFSET] == 0 && element[PART_NEXT_OFFSET] == 0 ? NULL_LINK : element[offset];
+}
+
+/* Whether the parts of each geometry are what this build can draw: their
+shaders the file's, their links within the geometry and with no ring, their
+strips and vertices sound. */
+static boolean parts_check(
+ struct model_file *file,
+ struct model_vertex_uncompressed *scratch,
+ char const **reason)
+{
+ long geometry_index;
+ long first = 0;
+
+ for (geometry_index = 0; geometry_index < file->geometry_count; geometry_index++)
+ {
+ long count = file->geometry_part_counts[geometry_index];
+ long part_index;
+
+ for (part_index = 0; part_index < count; part_index++)
+ {
+ struct file_part *part = &file->parts[first + part_index];
+ byte const *element = file->bytes + part->offset;
+ short shader = mods_read_short(element + PART_SHADER_OFFSET);
+ byte previous = part_link(element, PART_PREVIOUS_OFFSET);
+ byte next = part_link(element, PART_NEXT_OFFSET);
+ long steps;
+ long link;
+ real_point3d centroid;
+
+ *reason = "a part names a shader or part the model has not got, or its parts' links go round";
+ if (shader < 0 || shader >= file->shader_count ||
+ (previous != NULL_LINK && previous >= count) || (next != NULL_LINK && next >= count))
+ {
+ return FALSE;
+ }
+ /* (the transparent sort draws a part after those it links
+ before it: the links must end) */
+ for (link = part_index, steps = 0; link != NULL_LINK && steps <= count; steps++)
+ {
+ link = part_link(file->bytes + file->parts[first + link].offset, PART_NEXT_OFFSET);
+ if (link != NULL_LINK && link >= count)
+ return FALSE;
+ }
+ if (steps > count)
+ {
+ return FALSE;
+ }
+ *reason = "a part has no uncompressed vertices (the tool's tag files have them), or its strip has no triangle or names a vertex it has not got";
+ if (part->vertex_count < 1 || !part_strip_check(file, part))
+ {
+ return FALSE;
+ }
+ *reason = "a part's vertex names a node the model has not got, or has a value this build cannot draw";
+ read_point(element + PART_CENTROID_OFFSET, ¢roid);
+ if (!point_finite(¢roid) || !part_vertices_read(file, part, scratch))
+ {
+ return FALSE;
+ }
+ }
+ first += count;
+ }
+
+ return TRUE;
+}
+
+/* the part's centroid's nodes and weights, as the tool makes them: its two
+nodes most weighted by its vertices, their weights shared between them */
+static void part_centroid_nodes(
+ struct model_geometry_part *part,
+ struct model_vertex_uncompressed const *vertices,
+ long vertex_count,
+ long node_count)
+{
+ real weights[MAXIMUM_NODES_PER_MODEL];
+ short first = NONE;
+ short second = NONE;
+ long index;
+
+ csmemset(weights, 0, sizeof(weights));
+ for (index = 0; index < vertex_count; index++)
+ {
+ weights[vertices[index].nodes[0]] += vertices[index].node_weights[0];
+ weights[vertices[index].nodes[1]] += vertices[index].node_weights[1];
+ }
+ for (index = 0; index < node_count; index++)
+ {
+ if (first == NONE || weights[index] > weights[first])
+ {
+ second = first;
+ first = (short)index;
+ }
+ else if (second == NONE || weights[index] > weights[second])
+ {
+ second = (short)index;
+ }
+ }
+ part->centroid_primary_node_index = 0;
+ part->centroid_secondary_node_index = 0;
+ part->centroid_primary_node_weight = 1.f;
+ part->centroid_secondary_node_weight = 0.f;
+ if (node_count > 1 && first != NONE && weights[first] > 0.f)
+ {
+ part->centroid_primary_node_index = first;
+ if (second != NONE && weights[second] > 0.f)
+ {
+ real total = weights[first] + weights[second];
+
+ part->centroid_secondary_node_index = second;
+ part->centroid_primary_node_weight = weights[first] / total;
+ part->centroid_secondary_node_weight = weights[second] / total;
+ }
+ }
+}
+
+/* The nodes a part's vertices name, at most
+MAXIMUM_NODES_PER_MODEL_GEOMETRY_PART of them, into the palette, and the
+vertices made to name them by their place there: FALSE when there are more. */
+static boolean part_palette_make(
+ struct mod_model_palette *palette,
+ struct model_vertex_uncompressed *vertices,
+ long vertex_count)
+{
+ long index;
+
+ palette->node_count = 0;
+ for (index = 0; index < vertex_count; index++)
+ {
+ long slot;
+
+ for (slot = 0; slot < 2; slot++)
+ {
+ short node = vertices[index].nodes[slot];
+ short local;
+
+ for (local = 0; local < palette->node_count && palette->nodes[local] != node; local++)
+ {
+ }
+ if (local == palette->node_count)
+ {
+ if (palette->node_count == MAXIMUM_NODES_PER_MODEL_GEOMETRY_PART)
+ return FALSE;
+ palette->nodes[palette->node_count++] = (byte)node;
+ }
+ vertices[index].nodes[slot] = local;
+ }
+ }
+
+ return TRUE;
+}
+
+/* geometry the renderer draws from: in the Xbox's contiguous memory, as a
+map's is (custom_edition_geometry.c's geometry_allocate), while the mods'
+models take no more of it than MAXIMUM_CONTIGUOUS_GEOMETRY_BYTES; else the
+heap */
+static void *geometry_allocate(
+ unsigned long size,
+ boolean *contiguous)
+{
+ void *geometry = NULL;
+
+ if (size <= MAXIMUM_CONTIGUOUS_GEOMETRY_BYTES - mod_models_globals.contiguous_bytes)
+ geometry = XPhysicalAlloc(size, (unsigned long)-1, 0, PAGE_READWRITE);
+ *contiguous = geometry != NULL;
+ if (geometry)
+ mod_models_globals.contiguous_bytes += size;
+ else
+ geometry = system_malloc(size);
+
+ return geometry;
+}
+
+static void geometry_free(
+ void *geometry,
+ unsigned long size,
+ boolean contiguous)
+{
+ if (contiguous)
+ {
+ XPhysicalFree(geometry);
+ mod_models_globals.contiguous_bytes -= size;
+ }
+ else
+ {
+ system_free(geometry);
+ }
+}
+
+static void mod_model_free(
+ struct mod_model *made)
+{
+ long index;
+
+ for (index = 0; made->parts && index < made->part_count; index++)
+ {
+ rasterizer_triangle_buffer_delete(&made->parts[index].triangle_buffer);
+ rasterizer_vertex_buffer_delete(&made->parts[index].vertex_buffer);
+ }
+ /* (the game's free, debug_free, does not take NULL) */
+ if (made->geometry)
+ geometry_free(made->geometry, made->geometry_size, made->geometry_contiguous);
+ if (made->path)
+ free(made->path);
+ if (made->regions)
+ free(made->regions);
+ if (made->permutations)
+ free(made->permutations);
+ if (made->geometries)
+ free(made->geometries);
+ if (made->parts)
+ free(made->parts);
+ if (made->shaders)
+ free(made->shaders);
+ if (made->palettes)
+ free(made->palettes);
+ free(made);
+
+ return;
+}
+
+/* The map's regions and permutations, as the map has them (their markers
+the map's), each permutation drawing the file's geometries. */
+static boolean regions_make(
+ struct mod_model *made,
+ struct model_file const *file,
+ struct model const *map_model)
+{
+ long permutation_count = 0;
+ long region_index;
+ long next = 0;
+
+ for (region_index = 0; region_index < map_model->regions.count; region_index++)
+ {
+ permutation_count += file->permutation_counts[region_index];
+ }
+ made->regions = malloc((size_t)map_model->regions.count * sizeof(*made->regions) + 1);
+ made->permutations = malloc((size_t)permutation_count * sizeof(*made->permutations) + 1);
+ if (!made->regions || !made->permutations)
+ {
+ return FALSE;
+ }
+ made->kept_bytes += (unsigned long)map_model->regions.count * sizeof(*made->regions) +
+ (unsigned long)permutation_count * sizeof(*made->permutations);
+ for (region_index = 0; region_index < map_model->regions.count; region_index++)
+ {
+ struct model_region const *map_region = TAG_BLOCK_GET_ELEMENT(&map_model->regions, region_index,
+ struct model_region);
+ long permutation_index;
+
+ made->regions[region_index] = *map_region;
+ made->regions[region_index].permutations.address = made->permutations + next;
+ made->regions[region_index].permutations.definition = NULL;
+ for (permutation_index = 0; permutation_index < map_region->permutations.count; permutation_index++)
+ {
+ struct model_region_permutation *permutation = &made->permutations[next++];
+ byte const *element = file->bytes + file->permutations[region_index] +
+ (unsigned long)permutation_index * PERMUTATION_SIZE;
+ long detail;
+
+ *permutation = *TAG_BLOCK_GET_ELEMENT(&map_region->permutations, permutation_index,
+ struct model_region_permutation);
+ for (detail = 0; detail < 5; detail++)
+ {
+ permutation->geometry_indices[detail] = mods_read_short(element + PERMUTATION_GEOMETRIES_OFFSET + 2 * detail);
+ }
+ }
+ }
+ made->model.regions.address = made->regions;
+ made->model.regions.definition = NULL;
+
+ return TRUE;
+}
+
+/* The file's shaders, each the map's of its name. */
+static boolean shaders_make(
+ struct mod_model *made,
+ struct model_file const *file,
+ char const **reason)
+{
+ long index;
+
+ made->shaders = malloc((size_t)file->shader_count * sizeof(*made->shaders) + 1);
+ if (!made->shaders)
+ {
+ *reason = "out of memory";
+ return FALSE;
+ }
+ made->kept_bytes += (unsigned long)file->shader_count * sizeof(*made->shaders);
+ for (index = 0; index < file->shader_count; index++)
+ {
+ struct model_shader_reference *shader = &made->shaders[index];
+ long tag_index = shader_find(file, index);
+
+ if (tag_index == NONE)
+ {
+ *reason = "it names a shader the map has not got (a mod cannot add tags)";
+ return FALSE;
+ }
+ csmemset(shader, 0, sizeof(*shader));
+ shader->shader.group_tag = tag_get_group_tag(tag_index);
+ shader->shader.index = tag_index;
+ shader->shader.name = tag_get_name(tag_index);
+ shader->shader.name_length = (long)strlen(shader->shader.name);
+ /* (a negative one, the tool's none, is the shader's first: the
+ renderer takes the permutation modulo the bitmap's count) */
+ shader->permutation_index = MAX(0, mods_read_short(file->bytes + file->shaders +
+ (unsigned long)index * SHADER_REFERENCE_SIZE + SHADER_REFERENCE_PERMUTATION_OFFSET));
+ }
+ made->model.shaders.count = file->shader_count;
+ made->model.shaders.address = made->shaders;
+ made->model.shaders.definition = NULL;
+
+ return TRUE;
+}
+
+/* the position's hash, by its bits */
+static unsigned long position_hash(
+ real_point3d const *position)
+{
+ unsigned long words[3];
+ unsigned long hash = 2166136261UL;
+ long index;
+
+ memcpy(words, position, sizeof(words));
+ for (index = 0; index < 3; index++)
+ hash = (hash ^ words[index]) * 16777619UL;
+
+ return hash;
+}
+
+/* Blends the normals of the model's vertices that have the same position,
+every part's, as the tool does for a model whose tag asks for it: each
+their sum made a unit vector (one that sums to none, the x axis). FALSE
+when there is no memory for it. */
+static boolean shared_normals_blend(
+ struct model_vertex_uncompressed *vertices,
+ long count)
+{
+ unsigned long size = 1;
+ long *table;
+ long *firsts;
+ real_vector3d *sums;
+ long index;
+
+ while (size < (unsigned long)count * 2)
+ size <<= 1;
+ table = malloc(size * sizeof(*table));
+ firsts = malloc((size_t)count * sizeof(*firsts) + 1);
+ sums = malloc((size_t)count * sizeof(*sums) + 1);
+ if (!table || !firsts || !sums)
+ {
+ if (table)
+ free(table);
+ if (firsts)
+ free(firsts);
+ if (sums)
+ free(sums);
+ return FALSE;
+ }
+ for (index = 0; index < (long)size; index++)
+ table[index] = NONE;
+ /* (each vertex's first of its position, and the sum of their normals) */
+ for (index = 0; index < count; index++)
+ {
+ unsigned long slot = position_hash(&vertices[index].position) & (size - 1);
+
+ while (table[slot] != NONE &&
+ memcmp(&vertices[table[slot]].position, &vertices[index].position, sizeof(real_point3d)))
+ {
+ slot = (slot + 1) & (size - 1);
+ }
+ if (table[slot] == NONE)
+ {
+ table[slot] = index;
+ sums[index].i = sums[index].j = sums[index].k = 0.f;
+ }
+ firsts[index] = table[slot];
+ add_vectors3d(&sums[firsts[index]], &vertices[index].normal, &sums[firsts[index]]);
+ }
+ for (index = 0; index < count; index++)
+ {
+ if (firsts[index] == index && normalize3d(&sums[index]) == 0.f)
+ {
+ sums[index].i = 1.f;
+ sums[index].j = sums[index].k = 0.f;
+ }
+ }
+ for (index = 0; index < count; index++)
+ vertices[index].normal = sums[firsts[index]];
+ free(table);
+ free(firsts);
+ free(sums);
+
+ return TRUE;
+}
+
+/* The file's geometries and parts, their vertices compressed and given
+buffers, as a Custom Edition map's are. */
+static boolean geometries_make(
+ struct mod_model *made,
+ struct model_file const *file,
+ char const **reason)
+{
+ long vertex_size = rasterizer_geometry_get_vertex_size(_rasterizer_vertex_type_model_compressed);
+ long uncompressed_vertex_size = rasterizer_geometry_get_vertex_size(_rasterizer_vertex_type_model_uncompressed);
+ boolean palettes = file->node_count >= RASTERIZER_MAXIMUM_NODES_PER_MODEL;
+ unsigned long geometry_bytes = 0;
+ struct model_vertex_uncompressed *uncompressed;
+ struct model_vertex_uncompressed *scratch;
+ struct model_vertex_compressed *vertices;
+ word *strips;
+ long vertex_total = 0;
+ long geometry_index;
+ long part_index;
+ long first = 0;
+
+ *reason = "out of memory";
+ for (part_index = 0; part_index < file->part_count; part_index++)
+ {
+ vertex_total += file->parts[part_index].vertex_count;
+ geometry_bytes += (unsigned long)file->parts[part_index].vertex_count * (unsigned long)vertex_size +
+ (unsigned long)file->parts[part_index].strip_index_count * sizeof(word);
+ }
+ /* (every part's vertices, as this build keeps them uncompressed, at once:
+ their normals may be blended across parts. parts_check read them) */
+ uncompressed = malloc((size_t)vertex_total * sizeof(*uncompressed) + 1);
+ if (!uncompressed)
+ {
+ return FALSE;
+ }
+ for (part_index = 0, scratch = uncompressed; part_index < file->part_count; part_index++)
+ {
+ part_vertices_read(file, &file->parts[part_index], scratch);
+ scratch += file->parts[part_index].vertex_count;
+ }
+ if (TEST_FLAG(file->model_flags, MODEL_BLEND_SHARED_NORMALS_BIT) && !shared_normals_blend(uncompressed, vertex_total))
+ {
+ free(uncompressed);
+ return FALSE;
+ }
+ made->geometries = malloc((size_t)file->geometry_count * sizeof(*made->geometries) + 1);
+ made->parts = malloc((size_t)file->part_count * sizeof(*made->parts) + 1);
+ made->palettes = palettes ? malloc((size_t)file->part_count * sizeof(*made->palettes) + 1) : NULL;
+ made->geometry_size = geometry_bytes + 1;
+ made->geometry = geometry_allocate(made->geometry_size, &made->geometry_contiguous);
+ if (!made->geometries || !made->parts || (palettes && !made->palettes) || !made->geometry)
+ {
+ free(uncompressed);
+ return FALSE;
+ }
+ csmemset(made->parts, 0, (size_t)file->part_count * sizeof(*made->parts));
+ made->kept_bytes += geometry_bytes + (unsigned long)file->geometry_count * sizeof(*made->geometries) +
+ (unsigned long)file->part_count * (sizeof(*made->parts) + (palettes ? sizeof(*made->palettes) : 0));
+ vertices = (struct model_vertex_compressed *)made->geometry;
+ strips = (word *)(vertices + vertex_total);
+ scratch = uncompressed;
+
+ for (geometry_index = 0; geometry_index < file->geometry_count; geometry_index++)
+ {
+ struct model_geometry *geometry = &made->geometries[geometry_index];
+ long count = file->geometry_part_counts[geometry_index];
+
+ csmemset(geometry, 0, sizeof(*geometry));
+ geometry->parts.count = count;
+ geometry->parts.address = made->parts + first;
+ for (part_index = 0; part_index < count; part_index++)
+ {
+ struct file_part const *source = &file->parts[first + part_index];
+ struct model_geometry_part *part = &made->parts[first + part_index];
+ byte const *element = file->bytes + source->offset;
+ long strip_index;
+
+ part->flags = mods_read_be32(element) & ~FLAG(PART_LOCAL_NODES_BIT);
+ part->shader_index = mods_read_short(element + PART_SHADER_OFFSET);
+ part->previous_part_index = part_link(element, PART_PREVIOUS_OFFSET) == NULL_LINK ? NONE :
+ (char)part_link(element, PART_PREVIOUS_OFFSET);
+ part->next_part_index = part_link(element, PART_NEXT_OFFSET) == NULL_LINK ? NONE :
+ (char)part_link(element, PART_NEXT_OFFSET);
+ read_point(element + PART_CENTROID_OFFSET, &part->centroid);
+ part_centroid_nodes(part, scratch, source->vertex_count, file->node_count);
+ if (palettes)
+ {
+ struct mod_model_palette *palette = &made->palettes[made->palette_count];
+
+ if (!part_palette_make(palette, scratch, source->vertex_count))
+ {
+ *reason = "a part of this model of many nodes has vertices that name more nodes than a part is drawn with";
+ free(uncompressed);
+ return FALSE;
+ }
+ palette->vertex_buffer = &part->vertex_buffer;
+ made->palette_count++;
+ }
+ rasterizer_geometry_compress_vertices(
+ _rasterizer_vertex_type_model_uncompressed,
+ source->vertex_count,
+ vertices,
+ source->vertex_count * vertex_size,
+ scratch,
+ source->vertex_count * uncompressed_vertex_size);
+ for (strip_index = 0; strip_index < source->strip_index_count; strip_index++)
+ {
+ strips[strip_index] = (word)mods_read_be16(file->bytes + source->strip + 2 * strip_index);
+ }
+ made->part_count = first + part_index + 1;
+ *reason = "the renderer cannot make its buffers";
+ if (!rasterizer_vertex_buffer_new(&part->vertex_buffer, _rasterizer_vertex_type_model_compressed,
+ source->vertex_count, vertices, source->vertex_count * vertex_size) ||
+ !rasterizer_triangle_buffer_new(&part->triangle_buffer, _triangle_buffer_type_precompiled_strip,
+ source->strip_index_count - 2, strips))
+ {
+ free(uncompressed);
+ return FALSE;
+ }
+ vertices += source->vertex_count;
+ strips += source->strip_index_count;
+ scratch += source->vertex_count;
+ }
+ first += count;
+ }
+ made->model.geometries.count = file->geometry_count;
+ made->model.geometries.address = made->geometries;
+ made->model.geometries.definition = NULL;
+ free(uncompressed);
+
+ return TRUE;
+}
+
+static struct mod_model *mod_model_new(
+ long tag_index,
+ long file_index,
+ struct model const *map_model,
+ char const **reason)
+{
+ struct mod_model *made;
+ struct model_file file;
+ struct model_vertex_uncompressed *scratch = NULL;
+ byte *bytes;
+ unsigned long size = 0;
+ long part_room;
+ long index;
+
+ *reason = "out of memory";
+ bytes = mods_tag_file_read(file_index, &size, reason);
+ if (!bytes)
+ {
+ return NULL;
+ }
+ csmemset(&file, 0, sizeof(file));
+ file.bytes = bytes;
+ file.size = size;
+ file.gbxmodel = mods_read_be32(bytes + MOD_TAG_FILE_GROUP_OFFSET) == GBXMODEL_GROUP_TAG;
+ file.part_size = file.gbxmodel ? GBXMODEL_PART_SIZE : PART_SIZE;
+ made = malloc(sizeof(*made));
+ if (made)
+ {
+ csmemset(made, 0, sizeof(*made));
+ made->tag_index = tag_index;
+ made->file = file_index;
+ made->path = malloc(strlen(port_mods_file_path(file_index)) + 1);
+ }
+ *reason = "out of memory";
+ if (!made || !made->path || !port_mods_file_stamp(file_index, &made->file_size, made->stamp))
+ {
+ goto refused;
+ }
+ strcpy(made->path, port_mods_file_path(file_index));
+ made->kept_bytes = sizeof(*made) + strlen(made->path) + 1;
+
+ *reason = "it is not a model tag file of the Halo Editing Kit's version";
+ if (size < MOD_TAG_FILE_HEADER_SIZE + MODEL_SIZE ||
+ !(file.gbxmodel ? mods_tag_file_header_valid(bytes, GBXMODEL_GROUP_TAG, GBXMODEL_TAG_VERSION) :
+ mods_tag_file_header_valid(bytes, MODELS_GROUP_TAG, MODELS_GROUP_VERSION)))
+ {
+ goto refused;
+ }
+ part_room = model_file_part_count(&file);
+ *reason = "a count of its blocks is past what the tool allows";
+ if (part_room == NONE)
+ {
+ goto refused;
+ }
+ *reason = "out of memory";
+ file.parts = malloc((size_t)part_room * sizeof(*file.parts) + 1);
+ scratch = malloc((size_t)MAXIMUM_VERTICES_PER_MODEL_GEOMETRY_PART * sizeof(*scratch));
+ if (!file.parts || !scratch || !model_file_walk(&file, reason))
+ {
+ goto refused;
+ }
+
+ /* what the machines agree on: the map's */
+ *reason = "its nodes are not the map's (their names, hierarchy and default positions must be)";
+ if (!nodes_match(&file, map_model))
+ {
+ goto refused;
+ }
+ *reason = "its regions and permutations are not the map's, or a permutation draws a geometry it has not got";
+ if (!regions_match(&file, map_model))
+ {
+ goto refused;
+ }
+ if (!markers_match(&file, map_model, reason))
+ {
+ goto refused;
+ }
+ if (!parts_check(&file, scratch, reason))
+ {
+ goto refused;
+ }
+
+ /* what is drawn: the file's */
+ made->model = *map_model;
+ made->model.flags &= ~FLAG(MODEL_PARTS_HAVE_LOCAL_NODES_BIT);
+ /* (a tag file has its detail levels' cutoffs from super high to super
+ low, a map from super low to super high: the tool turns them round) */
+ for (index = 0; index < 5; index++)
+ {
+ made->model.detail_cutoff_pixels[index] = mods_read_real(bytes + MOD_TAG_FILE_HEADER_SIZE +
+ MODEL_CUTOFFS_OFFSET + 4 * (4 - index));
+ }
+ /* (a scale of 0 is the tool's default, 1) */
+ made->model.base_map_scale.i = mods_read_real(bytes + MOD_TAG_FILE_HEADER_SIZE + MODEL_BASE_MAP_SCALE_OFFSET);
+ made->model.base_map_scale.j = mods_read_real(bytes + MOD_TAG_FILE_HEADER_SIZE + MODEL_BASE_MAP_SCALE_OFFSET + 4);
+ if (made->model.base_map_scale.i == 0.f)
+ made->model.base_map_scale.i = 1.f;
+ if (made->model.base_map_scale.j == 0.f)
+ made->model.base_map_scale.j = 1.f;
+ *reason = "a detail level's cutoff or the base map's scale is not a number";
+ if (!mods_finite_real(made->model.detail_cutoff_pixels[0]) || !mods_finite_real(made->model.detail_cutoff_pixels[1]) ||
+ !mods_finite_real(made->model.detail_cutoff_pixels[2]) || !mods_finite_real(made->model.detail_cutoff_pixels[3]) ||
+ !mods_finite_real(made->model.detail_cutoff_pixels[4]) || !mods_finite_real(made->model.base_map_scale.i) ||
+ !mods_finite_real(made->model.base_map_scale.j))
+ {
+ goto refused;
+ }
+ *reason = "out of memory";
+ if (!regions_make(made, &file, map_model) || !shaders_make(made, &file, reason) ||
+ !geometries_make(made, &file, reason))
+ {
+ goto refused;
+ }
+ free(scratch);
+ free(file.parts);
+ free(bytes);
+
+ return made;
+
+refused:
+ if (scratch)
+ free(scratch);
+ if (file.parts)
+ free(file.parts);
+ free(bytes);
+ if (made)
+ mod_model_free(made);
+
+ return NULL;
+}
+
+/* the override of the model tag `tag_index` */
+static struct mod_model_override *override_get(
+ long tag_index)
+{
+ long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index);
+
+ return absolute_index >= 0 && absolute_index < mod_models_globals.override_count ?
+ &mod_models_globals.overrides[absolute_index] : NULL;
+}
+
+/* Makes the table of overrides, one for each of the map's tags up to its
+last model tag; FALSE when it cannot be. */
+static boolean overrides_new(
+ void)
+{
+ struct tag_iterator iterator;
+ long tag_index;
+ long count = 0;
+
+ if (mod_models_globals.overrides)
+ {
+ return TRUE;
+ }
+ tag_iterator_new(&iterator, MODELS_GROUP_TAG);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ count = MAX(count, DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index) + 1);
+ }
+ if (count <= 0 || !(mod_models_globals.overrides = malloc((size_t)count * sizeof(struct mod_model_override))))
+ {
+ return FALSE;
+ }
+ memset(mod_models_globals.overrides, 0, (size_t)count * sizeof(struct mod_model_override));
+ mod_models_globals.override_count = count;
+
+ return TRUE;
+}
+
+/* Gives the map's model tag `tag_index` its own definition back. */
+static void model_unplug(
+ long tag_index,
+ struct mod_model_override *override)
+{
+ memcpy(model_definition_get(tag_index), &override->map_model, sizeof(override->map_model));
+ override->model = NULL;
+
+ return;
+}
+
+/* Draws the model made `made` for the map's model tag `tag_index`. */
+static void model_plug(
+ long tag_index,
+ struct mod_model_override *override,
+ struct mod_model *made)
+{
+ struct model *definition = model_definition_get(tag_index);
+
+ memcpy(&override->map_model, definition, sizeof(*definition));
+ memcpy(definition, &made->model, sizeof(*definition));
+ override->model = made;
+
+ return;
+}
+
+/* a model made before for the tag `tag_index` from the file at `path` as
+it is now (`size` bytes, modified at `stamp`), played or not, or NULL */
+static struct mod_model *model_kept(
+ long tag_index,
+ char const *path,
+ unsigned long size,
+ unsigned long const stamp[2])
+{
+ struct mod_model *made;
+
+ for (made = mod_models_globals.models; made; made = made->next)
+ {
+ if (made->tag_index == tag_index && made->file_size == size && made->stamp[0] == stamp[0] &&
+ made->stamp[1] == stamp[1] && !strcmp(made->path, path))
+ {
+ return made;
+ }
+ }
+
+ return NULL;
+}
+
+/* ---------- public code */
+
+long mod_models_update(
+ boolean remove_all,
+ boolean at_load)
+{
+ struct tag_iterator iterator;
+ long tag_index;
+ long loaded_count = 0;
+ long removed_count = 0;
+ long refused_count = 0;
+ long played_count = 0;
+ boolean changed = FALSE;
+ long index;
+
+ if (!mods_map_loaded() || !overrides_new())
+ {
+ return 0;
+ }
+
+ tag_iterator_new(&iterator, MODELS_GROUP_TAG);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ struct mod_model_override *override = override_get(tag_index);
+ struct model *definition = model_definition_get(tag_index);
+ struct mod_model *made = NULL;
+ long file = NONE;
+ char const *reason = NULL;
+
+ if (!override || !definition)
+ {
+ continue;
+ }
+ /* (of a .gbxmodel and a .model, the file of the mod loaded later;
+ of one mod's two, the .gbxmodel) */
+ if (!remove_all)
+ {
+ long xbox_file = mods_tag_file_find(tag_index, MODEL_FILE_EXTENSION);
+
+ file = mods_tag_file_find(tag_index, GBXMODEL_FILE_EXTENSION);
+ if (file == NONE || (xbox_file != NONE && port_mods_file_order(xbox_file) > port_mods_file_order(file)))
+ file = xbox_file;
+ }
+ if (file != NONE)
+ {
+ unsigned long size;
+ unsigned long stamp[2];
+
+ /* (one made before from the file as it is: only where it is in the
+ mods' files changes) */
+ if (port_mods_file_stamp(file, &size, stamp))
+ made = model_kept(tag_index, port_mods_file_path(file), size, stamp);
+ if (made)
+ made->file = file;
+ if (made && made == override->model)
+ continue;
+ }
+ if (file != NONE && !made)
+ {
+ made = mod_model_new(tag_index, file, override->model ? &override->map_model : definition, &reason);
+ if (made && !mods_memory_take(made->kept_bytes))
+ {
+ reason = "the mods' tags already hold too much memory";
+ mod_model_free(made);
+ made = NULL;
+ }
+ /* (one that changed and now fails a check leaves what was played
+ before, as the other groups' files do) */
+ if (!made)
+ {
+ error(_error_silent, "mods: %s left out: %s", port_mods_file_path(file), reason);
+ console_warning("mods: the model %s of %s left out: %s", tag_get_name(tag_index), port_mods_file_mod(file),
+ reason);
+ refused_count++;
+ continue;
+ }
+ else
+ {
+ made->next = mod_models_globals.models;
+ mod_models_globals.models = made;
+ loaded_count++;
+ }
+ }
+ if (!made && !override->model)
+ {
+ continue;
+ }
+ if (override->model)
+ {
+ model_unplug(tag_index, override);
+ if (!made)
+ removed_count++;
+ }
+ if (made)
+ {
+ model_plug(tag_index, override, made);
+ }
+ changed = TRUE;
+ }
+ for (index = 0; index < mod_models_globals.override_count; index++)
+ {
+ if (mod_models_globals.overrides[index].model)
+ played_count++;
+ }
+ /* (a map's are linked as its render starts: render_initialize_for_new_map;
+ a model whose parts are already linked is left as it is) */
+ if (changed && !at_load)
+ {
+ models_fix_transparent_part_links();
+ }
+ if (!at_load || loaded_count || refused_count)
+ {
+ error(_error_silent, "mods: %ld models from their files (%ld new), %ld given back, %ld left out",
+ played_count, loaded_count, removed_count, refused_count);
+ if (!at_load && mods_say())
+ {
+ console_printf(FALSE, "mods: %ld models from their files (%ld new), %ld given back, %ld left out",
+ played_count, loaded_count, removed_count, refused_count);
+ }
+ }
+
+ return played_count;
+}
+
+/* (before the tags go: the map's models get their own definitions back,
+unless they are gone already, and every model made lets go of its
+buffers) */
+void mod_models_tags_unloaded(
+ boolean give_back)
+{
+ struct mod_model *made = mod_models_globals.models;
+ long index;
+
+ for (index = 0; give_back && index < mod_models_globals.override_count; index++)
+ {
+ struct mod_model_override *override = &mod_models_globals.overrides[index];
+
+ if (override->model)
+ model_unplug(override->model->tag_index, override);
+ }
+ while (made)
+ {
+ struct mod_model *next = made->next;
+
+ mod_model_free(made);
+ made = next;
+ }
+ if (mod_models_globals.overrides)
+ free(mod_models_globals.overrides);
+ memset(&mod_models_globals, 0, sizeof(mod_models_globals));
+
+ return;
+}
+
+short mod_models_part_palette(
+ struct vertex_buffer const *vertex_buffer,
+ byte const **nodes)
+{
+ struct mod_model *made;
+
+ for (made = mod_models_globals.models; made; made = made->next)
+ {
+ long index;
+
+ for (index = 0; index < made->palette_count; index++)
+ {
+ if (made->palettes[index].vertex_buffer == vertex_buffer)
+ {
+ *nodes = made->palettes[index].nodes;
+ return made->palettes[index].node_count;
+ }
+ }
+ }
+
+ return 0;
+}
diff --git a/port/linux/game/loose_sounds.c b/port/linux/game/mod_sounds.c
similarity index 66%
rename from port/linux/game/loose_sounds.c
rename to port/linux/game/mod_sounds.c
index 0ecd1fcfd8..7a91b2963e 100644
--- a/port/linux/game/loose_sounds.c
+++ b/port/linux/game/mod_sounds.c
@@ -1,14 +1,10 @@
/*
-LOOSE_SOUNDS.C
+MOD_SOUNDS.C
-Sound tags played over a map's, for those making them (audio.loose_sounds,
-off unless set). When a map's tags load (scenario_tags_load), each of its
-sound tags that has a tag file of its name in the data root's tags folder
-(tags/sound/.../name.sound, as the Halo Editing Kit and Invader write them)
-is played from that file instead of the map, and the script function
-loose_sounds_reload, typed at the console, reads them again: a changed
-file is taken, a removed one gives the map's sound back. loose_sounds false
-gives every sound back until loose_sounds true.
+The sounds of mods (mods.c): each of a map's sound tags that a mod has a
+tag file of (tags\sound\...\name.sound) is played from that file instead of
+the map, and mods_reload, typed at the console, reads them again: a changed
+file is taken, a removed one gives the map's sound back.
A tag file is Halo PC's (big-endian, version 4): the sound, the path of its
promotion sound, its pitch ranges, then each range's permutations, each
@@ -24,14 +20,15 @@ build (tool.exe, Invader) fills in are filled in as it does: the defaults
of the sound's class, the pitch ranges' playback rates, the per-tick bend.
The longest permutation length is Tool's measure of a permutation, a split
(linked) sound's chain of them summed: the whole chain plays, and the
-scripts and AI speech that wait for a sound wait for it.
+scripts and AI speech that wait for a sound wait for it (on the host, the
+only machine of a network game that runs them).
The map's definition takes the file's values in place, so everything that
holds the sound keeps holding it; only what the game counts as it plays
(promotion counter and time, scripting time and sound) stays the map's
definition's own, kept across a reload or switch. The map's own values come
back when the file goes. The samples are read into the sound cache from
-memory (loose_sounds_read, xbox_sound_cache.c).
+memory (mod_sounds_read, xbox_sound_cache.c).
A sound playing holds its permutation by index and the sound cache by
address, so nothing is changed under one: when a reload changes anything,
@@ -51,41 +48,27 @@ no cache block holds them (at the latest when the map goes).
#include "sound/sound_manager.h"
#include "tag_files/tag_groups.h"
#include "custom_edition_cache.h"
+#include "mods.h"
#include
#include
-#include
#include
#include
-/* the platform layer's (port/linux/src) */
-int config_boolean(char const *name);
-
/* the game's (port) */
char *tag_get_name(long tag_index);
/* ---------- constants */
-/* where a sound tag's file is: tags\\.sound in the data root */
-#define TAG_FILE_FOLDER "d:\\tags\\"
+/* a sound tag's file: tags\.sound */
#define TAG_FILE_EXTENSION ".sound"
-/* a tag file's header, and the sizes of its sound, pitch range and
-permutation (sound_definitions.h's structures, as the file has them) */
-#define TAG_FILE_HEADER_SIZE 0x40
-#define TAG_FILE_GROUP_OFFSET 0x24
-#define TAG_FILE_HEADER_SIZE_OFFSET 0x2C
-#define TAG_FILE_VERSION_OFFSET 0x38
-#define TAG_FILE_SIGNATURE_OFFSET 0x3C
+/* the sizes of a tag file's sound, pitch range and permutation
+(sound_definitions.h's structures, as the file has them) */
#define SOUND_SIZE 0xA4
#define PITCH_RANGE_SIZE 0x48
#define PERMUTATION_SIZE 0x7C
-/* the largest file read (the largest of Halo PC's sounds is a few MB), and
-the most the files and their samples together keep */
-#define MAXIMUM_FILE_SIZE (64UL * 1024 * 1024)
-#define MAXIMUM_KEPT_BYTES (512UL * 1024 * 1024)
-
#define SOUND_COMPRESSION_NONE 0
#define SOUND_COMPRESSION_XBOX_ADPCM 1
#define SOUND_COMPRESSION_OGG_VORBIS 3
@@ -132,11 +115,12 @@ enum
/* ---------- structures */
/* a sound tag file read, and what it makes */
-struct loose_sound
+struct mod_sound
{
- struct loose_sound *next;
+ struct mod_sound *next;
long tag_index;
- /* the file, kept to tell whether it changed */
+ /* the file, kept to tell whether it changed (its mouth and subtitle
+ data are read from it) */
byte *file;
unsigned long file_size;
/* the definition it makes (its counters unused), its pitch ranges and
@@ -150,144 +134,26 @@ struct loose_sound
/* a sound of the map played from a file: the map's own values, given back
when it goes */
-struct loose_override
+struct mod_sound_override
{
- struct loose_sound *sound;
+ struct mod_sound *sound;
struct sound_definition map_definition;
};
static struct
{
- boolean map_loaded;
- boolean enabled;
/* by the map's sound tag, its override (none: the map's own) */
- struct loose_override *overrides;
+ struct mod_sound_override *overrides;
long override_count;
/* the sounds made and not yet let go: those of the overrides, and those
gone but still in the sound cache */
- struct loose_sound *sounds;
- unsigned long kept_bytes;
-} loose_sounds_globals;
+ struct mod_sound *sounds;
+} mod_sounds_globals;
/* ---------- private code */
-static unsigned long read_be16(
- byte const *bytes)
-{
- return ((unsigned long)bytes[0] << 8) | bytes[1];
-}
-
-static unsigned long read_be32(
- byte const *bytes)
-{
- return ((unsigned long)bytes[0] << 24) | ((unsigned long)bytes[1] << 16) |
- ((unsigned long)bytes[2] << 8) | bytes[3];
-}
-
-static short read_short(
- byte const *bytes)
-{
- return (short)read_be16(bytes);
-}
-
-static real read_real(
- byte const *bytes)
-{
- unsigned long bits = read_be32(bytes);
- real value;
-
- memcpy(&value, &bits, sizeof(value));
-
- return value;
-}
-
-/* whether `size` bytes at `offset` lie within `file_size` */
-static boolean in_file(
- unsigned long offset,
- unsigned long size,
- unsigned long file_size)
-{
- return offset <= file_size && size <= file_size - offset;
-}
-
-static boolean finite_real(
- real value)
-{
- return value == value && value - value == 0.f;
-}
-
-/* the tag file of the sound tag `name`, in the data root's tags folder (an
-Xbox path, which the game's fopen finds there: port/linux/src/xbox_files.c);
-FALSE when the name could reach outside it */
-static boolean tag_file_path(
- char const *name,
- char *path,
- size_t path_size)
-{
- char const *character;
-
- if (!name || !*name || strlen(name) + sizeof(TAG_FILE_FOLDER TAG_FILE_EXTENSION) > path_size)
- {
- return FALSE;
- }
- /* (a name is relative and of the folder: no drive, no parent, no
- absolute or empty part) */
- for (character = name; *character; character++)
- {
- boolean separator = *character == '\\' || *character == '/';
-
- if (*character == ':' ||
- (separator && (character == name || !character[1] || character[1] == '\\' || character[1] == '/')) ||
- (*character == '.' && character[1] == '.' &&
- (character == name || character[-1] == '\\' || character[-1] == '/')))
- {
- return FALSE;
- }
- }
- sprintf(path, "%s%s%s", TAG_FILE_FOLDER, name, TAG_FILE_EXTENSION);
-
- return TRUE;
-}
-
-/* the file at `path` in a buffer of the game's allocator, or NULL (no file:
-*missing) */
-static byte *file_read(
- char const *path,
- unsigned long *size,
- boolean *missing)
-{
- FILE *file = fopen(path, "rb");
- byte *data = NULL;
- long length;
-
- *missing = file == NULL;
- if (!file)
- {
- return NULL;
- }
- if (fseek(file, 0, SEEK_END) == 0 &&
- (length = ftell(file)) >= TAG_FILE_HEADER_SIZE + SOUND_SIZE &&
- (unsigned long)length <= MAXIMUM_FILE_SIZE &&
- fseek(file, 0, SEEK_SET) == 0 &&
- (data = malloc((size_t)length)) != NULL)
- {
- if (fread(data, 1, (size_t)length, file) == (size_t)length)
- {
- *size = (unsigned long)length;
- }
- else
- {
- free(data);
- data = NULL;
- }
- }
- fclose(file);
-
- return data;
-}
-
-static void loose_sound_free(
- struct loose_sound *sound)
+static void mod_sound_free(
+ struct mod_sound *sound)
{
long permutation_index;
@@ -443,16 +309,16 @@ static real permutation_length(
/* The sound the tag file `file` makes for the map's sound tag
`tag_index`, or NULL with *reason saying why not. */
-static struct loose_sound *loose_sound_new(
+static struct mod_sound *mod_sound_new(
long tag_index,
byte *file,
unsigned long file_size,
char const **reason)
{
- struct loose_sound *sound;
+ struct mod_sound *sound;
struct sound_definition *definition;
- byte const *root = file + TAG_FILE_HEADER_SIZE;
- unsigned long cursor = TAG_FILE_HEADER_SIZE + SOUND_SIZE;
+ byte const *root = file + MOD_TAG_FILE_HEADER_SIZE;
+ unsigned long cursor = MOD_TAG_FILE_HEADER_SIZE + SOUND_SIZE;
unsigned long range_count;
unsigned long promotion_length;
unsigned long range_index;
@@ -464,10 +330,8 @@ static struct loose_sound *loose_sound_new(
real longest = 0.f;
*reason = "not a sound tag file of this version";
- if (memcmp(file + TAG_FILE_GROUP_OFFSET, "snd!", 4) ||
- memcmp(file + TAG_FILE_SIGNATURE_OFFSET, "blam", 4) ||
- read_be32(file + TAG_FILE_HEADER_SIZE_OFFSET) != TAG_FILE_HEADER_SIZE ||
- read_be16(file + TAG_FILE_VERSION_OFFSET) != SOUND_DEFINITION_VERSION)
+ if (file_size < MOD_TAG_FILE_HEADER_SIZE + SOUND_SIZE ||
+ !mods_tag_file_header_valid(file, SOUND_DEFINITION_TAG, SOUND_DEFINITION_VERSION))
{
return NULL;
}
@@ -482,9 +346,9 @@ static struct loose_sound *loose_sound_new(
definition = &sound->definition;
/* the sound's values */
-#define SOUND_SHORT(field) definition->field = read_short(root + offsetof(struct sound_definition, field))
-#define SOUND_REAL(field) definition->field = read_real(root + offsetof(struct sound_definition, field))
- definition->flags = read_be32(root + offsetof(struct sound_definition, flags));
+#define SOUND_SHORT(field) definition->field = mods_read_short(root + offsetof(struct sound_definition, field))
+#define SOUND_REAL(field) definition->field = mods_read_real(root + offsetof(struct sound_definition, field))
+ definition->flags = mods_read_be32(root + offsetof(struct sound_definition, flags));
SOUND_SHORT(sound_class);
SOUND_SHORT(sample_rate);
SOUND_SHORT(encoding);
@@ -513,17 +377,17 @@ static struct loose_sound *loose_sound_new(
(definition->sample_rate != 0 && definition->sample_rate != 1) ||
(definition->encoding != 0 && definition->encoding != 1) ||
definition->promotion_count < 0 ||
- !finite_real(definition->minimum_distance) || !finite_real(definition->maximum_distance) ||
+ !mods_finite_real(definition->minimum_distance) || !mods_finite_real(definition->maximum_distance) ||
definition->minimum_distance < 0.f || definition->maximum_distance < 0.f ||
- !finite_real(definition->skip_fraction) ||
- !finite_real(definition->random_pitch_bounds.lower) || !finite_real(definition->random_pitch_bounds.upper) ||
+ !mods_finite_real(definition->skip_fraction) ||
+ !mods_finite_real(definition->random_pitch_bounds.lower) || !mods_finite_real(definition->random_pitch_bounds.upper) ||
definition->random_pitch_bounds.lower < 0.f || definition->random_pitch_bounds.upper < 0.f ||
- !finite_real(definition->inner_cone_angle) || !finite_real(definition->outer_cone_angle) ||
- !finite_real(definition->outer_cone_gain) || !finite_real(definition->gain_modifier) ||
- !finite_real(definition->maximum_bend_per_second) || definition->maximum_bend_per_second < 0.f ||
- !finite_real(definition->zero_skip_fraction_modifier) || !finite_real(definition->one_skip_fraction_modifier) ||
- !finite_real(definition->zero_gain_modifier) || !finite_real(definition->one_gain_modifier) ||
- !finite_real(definition->zero_pitch_modifier) || !finite_real(definition->one_pitch_modifier) ||
+ !mods_finite_real(definition->inner_cone_angle) || !mods_finite_real(definition->outer_cone_angle) ||
+ !mods_finite_real(definition->outer_cone_gain) || !mods_finite_real(definition->gain_modifier) ||
+ !mods_finite_real(definition->maximum_bend_per_second) || definition->maximum_bend_per_second < 0.f ||
+ !mods_finite_real(definition->zero_skip_fraction_modifier) || !mods_finite_real(definition->one_skip_fraction_modifier) ||
+ !mods_finite_real(definition->zero_gain_modifier) || !mods_finite_real(definition->one_gain_modifier) ||
+ !mods_finite_real(definition->zero_pitch_modifier) || !mods_finite_real(definition->one_pitch_modifier) ||
definition->zero_pitch_modifier < 0.f || definition->one_pitch_modifier < 0.f)
{
goto refused;
@@ -537,7 +401,7 @@ static struct loose_sound *loose_sound_new(
}
definition_defaults(definition);
*reason = "a value of the sound is out of range";
- if (!finite_real(definition->maximum_bend_per_second) ||
+ if (!mods_finite_real(definition->maximum_bend_per_second) ||
MIN(definition->random_pitch_bounds.lower, definition->random_pitch_bounds.upper) *
MIN(definition->zero_pitch_modifier, definition->one_pitch_modifier) <= 0.f)
{
@@ -550,7 +414,7 @@ static struct loose_sound *loose_sound_new(
encoded_rate = channels == 1 ? 22050 : rate;
/* the promotion sound: one of the map's, or none */
- promotion_length = read_be32(root + offsetof(struct sound_definition, promotion_sound.name_length));
+ promotion_length = mods_read_be32(root + offsetof(struct sound_definition, promotion_sound.name_length));
definition->promotion_sound.group_tag = SOUND_DEFINITION_TAG;
definition->promotion_sound.index = NONE;
definition->promotion_sound.name = NULL;
@@ -561,7 +425,7 @@ static struct loose_sound *loose_sound_new(
long promotion_index;
*reason = "the promotion sound's name is not in the file";
- if (promotion_length >= sizeof(promotion) || !in_file(cursor, promotion_length + 1, file_size) ||
+ if (promotion_length >= sizeof(promotion) || !mods_in_file(cursor, promotion_length + 1, file_size) ||
file[cursor + promotion_length] != 0 || memchr(file + cursor, 0, promotion_length))
{
goto refused;
@@ -578,17 +442,17 @@ static struct loose_sound *loose_sound_new(
}
/* the pitch ranges, then each one's permutations and their data */
- range_count = read_be32(root + offsetof(struct sound_definition, pitch_ranges.count));
+ range_count = mods_read_be32(root + offsetof(struct sound_definition, pitch_ranges.count));
*reason = "its pitch ranges are not in the file";
if (range_count < 1 || range_count > MAXIMUM_PITCH_RANGES_PER_SOUND ||
- !in_file(cursor, range_count * PITCH_RANGE_SIZE, file_size))
+ !mods_in_file(cursor, range_count * PITCH_RANGE_SIZE, file_size))
{
goto refused;
}
for (range_index = 0; range_index < range_count; range_index++)
{
/* (no more than a range takes: a larger count is refused below) */
- permutation_total += (long)MIN(read_be32(file + cursor + range_index * PITCH_RANGE_SIZE +
+ permutation_total += (long)MIN(mods_read_be32(file + cursor + range_index * PITCH_RANGE_SIZE +
offsetof(struct sound_pitch_range, permutations.count)), MAXIMUM_PERMUTATIONS_PER_PITCH_RANGE);
}
sound->ranges = malloc(range_count * sizeof(*sound->ranges));
@@ -608,29 +472,30 @@ static struct loose_sound *loose_sound_new(
{
byte const *range_bytes = ranges + range_index * PITCH_RANGE_SIZE;
struct sound_pitch_range *range = &sound->ranges[range_index];
- unsigned long count = read_be32(range_bytes + offsetof(struct sound_pitch_range, permutations.count));
+ unsigned long count = mods_read_be32(range_bytes + offsetof(struct sound_pitch_range, permutations.count));
struct sound_permutation *permutations = sound->permutations + sound->permutation_count;
byte const *permutation_bytes = file + cursor;
unsigned long permutation_index;
real *lengths;
- *reason = "a pitch range's permutations are not in the file";
+ *reason = count < 1 ? "a pitch range has no permutations (an empty sound)" :
+ "a pitch range's permutations are not in the file";
if (count < 1 || count > MAXIMUM_PERMUTATIONS_PER_PITCH_RANGE ||
- !in_file(cursor, count * PERMUTATION_SIZE, file_size))
+ !mods_in_file(cursor, count * PERMUTATION_SIZE, file_size))
{
goto refused;
}
cursor += count * PERMUTATION_SIZE;
memcpy(range->name, range_bytes, sizeof(range->name));
range->name[sizeof(range->name) - 1] = 0;
- range->natural_pitch = read_real(range_bytes + offsetof(struct sound_pitch_range, natural_pitch));
- range->bend_bounds.lower = read_real(range_bytes + offsetof(struct sound_pitch_range, bend_bounds.lower));
- range->bend_bounds.upper = read_real(range_bytes + offsetof(struct sound_pitch_range, bend_bounds.upper));
+ range->natural_pitch = mods_read_real(range_bytes + offsetof(struct sound_pitch_range, natural_pitch));
+ range->bend_bounds.lower = mods_read_real(range_bytes + offsetof(struct sound_pitch_range, bend_bounds.lower));
+ range->bend_bounds.upper = mods_read_real(range_bytes + offsetof(struct sound_pitch_range, bend_bounds.upper));
range->actual_permutation_count = linked ?
- read_short(range_bytes + offsetof(struct sound_pitch_range, actual_permutation_count)) : (short)count;
+ mods_read_short(range_bytes + offsetof(struct sound_pitch_range, actual_permutation_count)) : (short)count;
*reason = "a value of a pitch range is out of range";
- if (!finite_real(range->natural_pitch) || !finite_real(range->bend_bounds.lower) ||
- !finite_real(range->bend_bounds.upper) ||
+ if (!mods_finite_real(range->natural_pitch) || !mods_finite_real(range->bend_bounds.lower) ||
+ !mods_finite_real(range->bend_bounds.upper) ||
range->actual_permutation_count < 1 ||
range->actual_permutation_count > MAXIMUM_PERMUTATIONS_PER_RANDOM_PITCH_RANGE ||
(unsigned long)range->actual_permutation_count > count)
@@ -663,22 +528,22 @@ static struct loose_sound *loose_sound_new(
{
byte const *bytes = permutation_bytes + permutation_index * PERMUTATION_SIZE;
struct sound_permutation *permutation = &permutations[permutation_index];
- unsigned long samples_size = read_be32(bytes + offsetof(struct sound_permutation, samples.size));
- unsigned long mouth_size = read_be32(bytes + offsetof(struct sound_permutation, mouth_data.size));
- unsigned long subtitle_size = read_be32(bytes + offsetof(struct sound_permutation, subtitle_data.size));
- unsigned long buffer_size = read_be32(bytes + offsetof(struct sound_permutation, sample_buffer_size));
- short compression = read_short(bytes + offsetof(struct sound_permutation, compression));
- short next = read_short(bytes + offsetof(struct sound_permutation, next_permutation_index));
+ unsigned long samples_size = mods_read_be32(bytes + offsetof(struct sound_permutation, samples.size));
+ unsigned long mouth_size = mods_read_be32(bytes + offsetof(struct sound_permutation, mouth_data.size));
+ unsigned long subtitle_size = mods_read_be32(bytes + offsetof(struct sound_permutation, subtitle_data.size));
+ unsigned long buffer_size = mods_read_be32(bytes + offsetof(struct sound_permutation, sample_buffer_size));
+ short compression = mods_read_short(bytes + offsetof(struct sound_permutation, compression));
+ short next = mods_read_short(bytes + offsetof(struct sound_permutation, next_permutation_index));
byte const *samples = file + cursor;
unsigned long encoded_bytes = 0;
sound->permutation_count++;
*reason = "a permutation's data is not in the file";
- if (samples_size == 0 || samples_size > MAXIMUM_FILE_SIZE ||
+ if (samples_size == 0 || samples_size > MAXIMUM_MOD_TAG_FILE_SIZE ||
mouth_size > MAXIMUM_SOUND_MOUTH_DATA_SIZE || subtitle_size > MAXIMUM_SOUND_SUBTITLE_DATA_SIZE ||
- !in_file(cursor, samples_size, file_size) ||
- !in_file(cursor + samples_size, mouth_size, file_size) ||
- !in_file(cursor + samples_size + mouth_size, subtitle_size, file_size))
+ !mods_in_file(cursor, samples_size, file_size) ||
+ !mods_in_file(cursor + samples_size, mouth_size, file_size) ||
+ !mods_in_file(cursor + samples_size + mouth_size, subtitle_size, file_size))
{
free(lengths);
goto refused;
@@ -686,10 +551,10 @@ static struct loose_sound *loose_sound_new(
*reason = "a permutation's values are out of range";
memcpy(permutation->name, bytes, sizeof(permutation->name));
permutation->name[sizeof(permutation->name) - 1] = 0;
- permutation->skip_fraction = read_real(bytes + offsetof(struct sound_permutation, skip_fraction));
- permutation->gain = read_real(bytes + offsetof(struct sound_permutation, gain));
+ permutation->skip_fraction = mods_read_real(bytes + offsetof(struct sound_permutation, skip_fraction));
+ permutation->gain = mods_read_real(bytes + offsetof(struct sound_permutation, gain));
if (compression != definition->compression ||
- !finite_real(permutation->skip_fraction) || !finite_real(permutation->gain) ||
+ !mods_finite_real(permutation->skip_fraction) || !mods_finite_real(permutation->gain) ||
(linked && next != NONE && (next < 0 || (unsigned long)next >= count)) ||
(compression == SOUND_COMPRESSION_XBOX_ADPCM && samples_size % (ADPCM_BLOCK_BYTES * channels)) ||
(compression == SOUND_COMPRESSION_NONE && samples_size % (2 * channels)))
@@ -798,7 +663,7 @@ static struct loose_sound *loose_sound_new(
return sound;
refused:
- loose_sound_free(sound);
+ mod_sound_free(sound);
return NULL;
}
@@ -820,18 +685,18 @@ holds a sound cache block (the cache's blocks point at their permutations). */
static void sounds_let_go(
boolean all)
{
- struct loose_sound **reference = &loose_sounds_globals.sounds;
+ struct mod_sound **reference = &mod_sounds_globals.sounds;
while (*reference)
{
- struct loose_sound *sound = *reference;
+ struct mod_sound *sound = *reference;
boolean in_use = FALSE;
long index;
if (!all)
{
- struct loose_override const *override =
- &loose_sounds_globals.overrides[DATUM_INDEX_TO_ABSOLUTE_INDEX(sound->tag_index)];
+ struct mod_sound_override const *override =
+ &mod_sounds_globals.overrides[DATUM_INDEX_TO_ABSOLUTE_INDEX(sound->tag_index)];
in_use = override->sound == sound;
for (index = 0; !in_use && index < sound->permutation_count; index++)
@@ -846,8 +711,8 @@ static void sounds_let_go(
else
{
*reference = sound->next;
- loose_sounds_globals.kept_bytes -= sound->kept_bytes;
- loose_sound_free(sound);
+ mods_memory_give(sound->kept_bytes);
+ mod_sound_free(sound);
}
}
@@ -855,55 +720,85 @@ static void sounds_let_go(
}
/* the override of the sound tag `tag_index` */
-static struct loose_override *override_get(
+static struct mod_sound_override *override_get(
long tag_index)
{
long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index);
- return absolute_index >= 0 && absolute_index < loose_sounds_globals.override_count ?
- &loose_sounds_globals.overrides[absolute_index] : NULL;
+ return absolute_index >= 0 && absolute_index < mod_sounds_globals.override_count ?
+ &mod_sounds_globals.overrides[absolute_index] : NULL;
+}
+
+/* Makes the table of overrides, one for each of the map's tags up to its
+last sound tag; FALSE when it cannot be. */
+static boolean overrides_new(
+ void)
+{
+ struct tag_iterator iterator;
+ long tag_index;
+ long count = 0;
+
+ if (mod_sounds_globals.overrides)
+ {
+ return TRUE;
+ }
+ tag_iterator_new(&iterator, SOUND_DEFINITION_TAG);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ count = MAX(count, DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index) + 1);
+ }
+ if (count <= 0 || !(mod_sounds_globals.overrides = malloc((size_t)count * sizeof(struct mod_sound_override))))
+ {
+ return FALSE;
+ }
+ memset(mod_sounds_globals.overrides, 0, (size_t)count * sizeof(struct mod_sound_override));
+ mod_sounds_globals.override_count = count;
+
+ return TRUE;
}
-/* Reads every sound tag's file again (or, `remove_all`, none), and plays
-the map's sounds from what changed. As the map loads (`at_load`) nothing
-plays yet and nothing is said at the console. */
-static void loose_sounds_update(
+/* ---------- public code */
+
+long mod_sounds_update(
boolean remove_all,
boolean at_load)
{
struct tag_iterator iterator;
long tag_index;
- struct loose_sound *made = NULL;
+ struct mod_sound *made = NULL;
long loaded_count = 0;
long removed_count = 0;
long refused_count = 0;
+ long played_count = 0;
boolean stopped = FALSE;
- if (!loose_sounds_globals.map_loaded || !loose_sounds_globals.overrides)
+ if (!mods_map_loaded() || !overrides_new())
{
- return;
+ return 0;
}
/* what each file makes, before anything is changed */
tag_iterator_new(&iterator, SOUND_DEFINITION_TAG);
while ((tag_index = tag_iterator_next(&iterator)) != NONE)
{
- struct loose_override *override = override_get(tag_index);
- char path[1024];
+ struct mod_sound_override *override = override_get(tag_index);
+ long file_index = remove_all ? NONE : mods_tag_file_find(tag_index, TAG_FILE_EXTENSION);
byte *file = NULL;
unsigned long file_size = 0;
- boolean missing = TRUE;
+ char const *reason = NULL;
if (!override)
{
continue;
}
- if (!remove_all && tag_file_path(tag_get_name(tag_index), path, sizeof(path)))
+ if (file_index != NONE)
{
- file = file_read(path, &file_size, &missing);
- if (!file && !missing)
+ file = mods_tag_file_read(file_index, &file_size, &reason);
+ if (!file)
{
- error(_error_silent, "loose sounds: cannot read %s", path);
+ error(_error_silent, "mods: %s left out: %s", port_mods_file_path(file_index), reason);
+ console_warning("mods: %s.sound of %s left out: %s", tag_get_name(tag_index),
+ port_mods_file_mod(file_index), reason);
refused_count++;
continue;
}
@@ -917,29 +812,34 @@ static void loose_sounds_update(
}
if (file)
{
- char const *reason = NULL;
- struct loose_sound *sound = NULL;
+ /* (refused before its samples are decoded when the file alone
+ is more than the mods may keep) */
+ struct mod_sound *sound = mods_memory_left() < file_size ? NULL :
+ mod_sound_new(tag_index, file, file_size, &reason);
- if (loose_sounds_globals.kept_bytes + file_size > MAXIMUM_KEPT_BYTES)
+ if (!sound && mods_memory_left() < file_size)
{
- reason = "the sounds read already hold too much memory";
+ reason = "the mods' tags already hold too much memory";
}
- else
+ if (sound && !mods_memory_take(sound->kept_bytes))
{
- sound = loose_sound_new(tag_index, file, file_size, &reason);
+ reason = "the mods' tags already hold too much memory";
+ sound->file = NULL;
+ mod_sound_free(sound);
+ sound = NULL;
}
if (!sound)
{
/* (one refused leaves what was played before) */
- error(_error_silent, "loose sounds: %s left out: %s", path, reason);
- console_warning("loose sounds: %s left out: %s", tag_get_name(tag_index), reason);
+ error(_error_silent, "mods: %s left out: %s", port_mods_file_path(file_index), reason);
+ console_warning("mods: %s.sound of %s left out: %s", tag_get_name(tag_index),
+ port_mods_file_mod(file_index), reason);
free(file);
refused_count++;
continue;
}
sound->next = made;
made = sound;
- loose_sounds_globals.kept_bytes += sound->kept_bytes;
}
else if (!override->sound)
{
@@ -974,94 +874,52 @@ static void loose_sounds_update(
/* the sounds made join those kept, and those no longer played go */
while (made)
{
- struct loose_sound *next = made->next;
+ struct mod_sound *next = made->next;
- made->next = loose_sounds_globals.sounds;
- loose_sounds_globals.sounds = made;
+ made->next = mod_sounds_globals.sounds;
+ mod_sounds_globals.sounds = made;
made = next;
}
sounds_let_go(FALSE);
+ for (tag_index = 0; tag_index < mod_sounds_globals.override_count; tag_index++)
+ {
+ if (mod_sounds_globals.overrides[tag_index].sound)
+ played_count++;
+ }
if (!at_load || loaded_count || refused_count)
{
- error(_error_silent, "loose sounds: %ld from tags, %ld given back, %ld left out (%lu KB kept)",
- loaded_count, removed_count, refused_count, loose_sounds_globals.kept_bytes / 1024);
- if (!at_load)
+ error(_error_silent, "mods: %ld sounds from their files (%ld new), %ld given back, %ld left out",
+ played_count, loaded_count, removed_count, refused_count);
+ if (!at_load && mods_say())
{
- console_printf(FALSE, "loose sounds: %ld from tags, %ld given back, %ld left out",
- loaded_count, removed_count, refused_count);
+ console_printf(FALSE, "mods: %ld sounds from their files (%ld new), %ld given back, %ld left out",
+ played_count, loaded_count, removed_count, refused_count);
}
}
- return;
-}
-
-/* Makes the table of overrides, one for each of the map's tags up to its
-last sound tag; FALSE when it cannot be. */
-static boolean overrides_new(
- void)
-{
- struct tag_iterator iterator;
- long tag_index;
- long count = 0;
-
- if (loose_sounds_globals.overrides)
- {
- return TRUE;
- }
- tag_iterator_new(&iterator, SOUND_DEFINITION_TAG);
- while ((tag_index = tag_iterator_next(&iterator)) != NONE)
- {
- count = MAX(count, DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index) + 1);
- }
- if (count <= 0 || !(loose_sounds_globals.overrides = malloc((size_t)count * sizeof(struct loose_override))))
- {
- return FALSE;
- }
- memset(loose_sounds_globals.overrides, 0, (size_t)count * sizeof(struct loose_override));
- loose_sounds_globals.override_count = count;
-
- return TRUE;
+ return played_count;
}
-/* ---------- public code */
-
-/* (scenario_tags_load, once a map's tags are loaded) */
-void loose_sounds_tags_loaded(
- void)
-{
- loose_sounds_globals.map_loaded = TRUE;
- loose_sounds_globals.enabled = config_boolean("audio.loose_sounds") != 0;
- if (loose_sounds_globals.enabled && overrides_new())
- {
- loose_sounds_update(FALSE, TRUE);
- }
-
- return;
-}
-
-/* (scenario_tags_unload, once the sound cache is closed: no block holds a
-permutation) */
-void loose_sounds_tags_unloaded(
+/* (once the sound cache is closed: no block holds a permutation) */
+void mod_sounds_tags_unloaded(
void)
{
sounds_let_go(TRUE);
- if (loose_sounds_globals.overrides)
- free(loose_sounds_globals.overrides);
- memset(&loose_sounds_globals, 0, sizeof(loose_sounds_globals));
+ if (mod_sounds_globals.overrides)
+ free(mod_sounds_globals.overrides);
+ memset(&mod_sounds_globals, 0, sizeof(mod_sounds_globals));
return;
}
-/* (sound_cache_start_loading_sound) Whether `permutation` is one of a tag
-file's, read at once into `buffer`. */
-boolean loose_sounds_read(
+boolean mod_sounds_read(
struct sound_permutation const *permutation,
void *buffer)
{
- struct loose_sound const *sound;
+ struct mod_sound const *sound;
- for (sound = loose_sounds_globals.sounds; sound; sound = sound->next)
+ for (sound = mod_sounds_globals.sounds; sound; sound = sound->next)
{
if (permutation >= sound->permutations && permutation < sound->permutations + sound->permutation_count)
{
@@ -1072,40 +930,3 @@ boolean loose_sounds_read(
return FALSE;
}
-
-/* (the script function loose_sounds_reload) */
-void loose_sounds_reload(
- void)
-{
- if (!loose_sounds_globals.map_loaded)
- {
- return;
- }
- if (!loose_sounds_globals.enabled)
- {
- console_warning("loose sounds are off (loose_sounds true, or audio.loose_sounds in config.toml)");
- return;
- }
- loose_sounds_update(FALSE, FALSE);
-
- return;
-}
-
-/* (the script function loose_sounds) Plays the sounds of tag files, or
-gives the map's back, until the map goes. */
-void loose_sounds_enable(
- boolean enabled)
-{
- if (!loose_sounds_globals.map_loaded || enabled == loose_sounds_globals.enabled)
- {
- return;
- }
- if (enabled && !overrides_new())
- {
- return;
- }
- loose_sounds_globals.enabled = enabled;
- loose_sounds_update(!enabled, FALSE);
-
- return;
-}
diff --git a/port/linux/game/mod_strings.c b/port/linux/game/mod_strings.c
new file mode 100644
index 0000000000..3c3f356733
--- /dev/null
+++ b/port/linux/game/mod_strings.c
@@ -0,0 +1,586 @@
+/*
+MOD_STRINGS.C
+
+The text of mods (mods.c): each of a map's string lists (unicode string
+lists, the text the game shows, and string lists) that a mod has a tag file
+of (tags\...\name.unicode_string_list, name.string_list) shows that file's
+strings instead of the map's, a translation or other words, and
+mods_reload reads them again.
+
+A tag file is Halo PC's (big-endian, version 1): the list, its strings, then
+each string's characters (UTF-16 for a unicode string list, as the tool
+writes them, else bytes). Every count and size is checked against the file,
+which must end where its last string does. The game finds a string by its
+index and reads many as the format of what it prints ("%s was killed by
+%s"), so a list keeps the map's number of strings, and a string replaces
+the map's only where it has the same conversions in the same order (any
+other would read what printf was not given): the map's own string stays
+where the file's has not got them, or has none at that index. Each is
+terminated, and a unicode one ended where the game's text drawing would read
+past it (tag_schema_unicode_string_end), as the validator does a map's.
+
+The map's list takes the file's strings in place, so everything that holds
+the tag keeps holding it, and its own come back when the file goes. A list
+made is kept until the map goes, as what is shown may hold its strings.
+*/
+
+/* ---------- headers */
+
+#include "cseries.h"
+#include "errors.h"
+#include "cache/cache_files.h"
+#include "main/console.h"
+#include "tag_files/tag_groups.h"
+#include "text/text_group.h"
+#include "tag_schema.h"
+#include "mods.h"
+
+#include
+#include
+#include
+
+/* the game's (port) */
+char *tag_get_name(long tag_index);
+
+/* ---------- constants */
+
+/* (text_group.c's string_list and unicode_string_list groups) */
+#define STRING_LIST_TAG_VERSION 1
+#define UNICODE_STRING_LIST_TAG_VERSION 1
+
+/* the sizes of a tag file's list and string (text_group.h's structures, as
+the file has them) */
+#define STRING_LIST_SIZE 0x0C
+#define STRING_LIST_ENTRY_SIZE 0x14
+
+/* the validator's limits (tag_schema_effects.c) */
+#define MAXIMUM_STRINGS SHORT_MAX
+#define MAXIMUM_STRING_BYTES (2 * SHORT_MAX)
+
+/* the most conversions a string's format is compared by */
+#define MAXIMUM_CONVERSIONS 32
+
+typedef char verify_string_list_size[sizeof(struct string_list) == STRING_LIST_SIZE ? 1 : -1];
+typedef char verify_string_list_entry_size[sizeof(struct string_list_entry) == STRING_LIST_ENTRY_SIZE ? 1 : -1];
+
+/* ---------- structures */
+
+/* a group of lists: its tag, the extension of its files, and whether its
+characters are UTF-16 */
+struct string_group
+{
+ unsigned long group_tag;
+ char const *extension;
+ short version;
+ boolean unicode;
+};
+
+/* a list's tag file read, and what it makes */
+struct mod_string_list
+{
+ struct mod_string_list *next;
+ long tag_index;
+ /* the file, kept to tell whether it changed */
+ byte *file;
+ unsigned long file_size;
+ /* the list it makes: the map's number of strings, the file's where they
+ may be, and the characters of the file's */
+ struct string_list list;
+ struct string_list_entry *entries;
+ byte *characters;
+ long replaced_count;
+ unsigned long kept_bytes;
+};
+
+/* a list of the map's shown from a file: the map's own, given back when it
+goes */
+struct mod_string_override
+{
+ struct mod_string_list *list;
+ struct string_list map_list;
+};
+
+/* a conversion of a format, as it reads its arguments */
+struct conversion
+{
+ /* the conversion, its length modifier, and how many '*' (each an
+ argument) it has */
+ char conversion;
+ char modifier[3];
+ short stars;
+};
+
+/* ---------- globals */
+
+static struct string_group const string_groups[] =
+{
+ { UNICODE_STRING_LIST_TAG, ".unicode_string_list", UNICODE_STRING_LIST_TAG_VERSION, TRUE },
+ { STRING_LIST_TAG, ".string_list", STRING_LIST_TAG_VERSION, FALSE },
+};
+
+static struct
+{
+ /* by the map's tag, its override (none: the map's own) */
+ struct mod_string_override *overrides;
+ long override_count;
+ /* every list made while the map is loaded */
+ struct mod_string_list *lists;
+} mod_strings_globals;
+
+/* ---------- private code */
+
+static void mod_string_list_free(
+ struct mod_string_list *list)
+{
+ /* (the game's free, debug_free, does not take NULL) */
+ if (list->file)
+ free(list->file);
+ if (list->entries)
+ free(list->entries);
+ if (list->characters)
+ free(list->characters);
+ free(list);
+
+ return;
+}
+
+/* the character at `index` of a string of `size` bytes */
+static unsigned short character_get(
+ byte const *string,
+ unsigned long size,
+ unsigned long index,
+ boolean unicode)
+{
+ if (unicode)
+ {
+ return 2 * index + 1 < size ? (unsigned short)(string[2 * index] | string[2 * index + 1] << 8) : 0;
+ }
+
+ return index < size ? string[index] : 0;
+}
+
+/* whether a character is one of the ASCII characters of `set` */
+static boolean character_in(
+ unsigned short character,
+ char const *set)
+{
+ return character && character < 0x80 && strchr(set, (char)character) != NULL;
+}
+
+/* the conversions of a printf format (a string's of `size` bytes) up to its
+terminator: how many, or NONE for more than *conversions holds */
+static long format_conversions(
+ byte const *string,
+ unsigned long size,
+ boolean unicode,
+ struct conversion *conversions)
+{
+ unsigned long length = unicode ? size / 2 : size;
+ unsigned long index = 0;
+ long count = 0;
+
+ while (index < length && character_get(string, size, index, unicode))
+ {
+ struct conversion *conversion;
+ unsigned short character;
+ short modifier_length = 0;
+
+ if (character_get(string, size, index, unicode) != '%')
+ {
+ index++;
+ continue;
+ }
+ index++;
+ if (character_get(string, size, index, unicode) == '%')
+ {
+ index++;
+ continue;
+ }
+ if (count == MAXIMUM_CONVERSIONS)
+ {
+ return NONE;
+ }
+ conversion = &conversions[count++];
+ memset(conversion, 0, sizeof(*conversion));
+ /* (flags, width and precision, then the length modifier) */
+ while (character_in(character = character_get(string, size, index, unicode), "-+ #0123456789.*hlLqjzt"))
+ {
+ if (character == '*')
+ {
+ conversion->stars++;
+ }
+ else if (character_in(character, "hlLqjzt") && modifier_length < (short)sizeof(conversion->modifier) - 1)
+ {
+ conversion->modifier[modifier_length++] = (char)character;
+ }
+ index++;
+ }
+ conversion->conversion = (char)MIN(character, 0x7F);
+ if (character)
+ {
+ index++;
+ }
+ }
+
+ return count;
+}
+
+/* whether two strings read the same arguments as formats */
+static boolean formats_match(
+ byte const *string,
+ unsigned long size,
+ byte const *map_string,
+ unsigned long map_size,
+ boolean unicode)
+{
+ struct conversion conversions[MAXIMUM_CONVERSIONS];
+ struct conversion map_conversions[MAXIMUM_CONVERSIONS];
+ long count = format_conversions(string, size, unicode, conversions);
+ long map_count = format_conversions(map_string, map_size, unicode, map_conversions);
+ long index;
+
+ if (count == NONE || count != map_count)
+ {
+ return FALSE;
+ }
+ for (index = 0; index < count; index++)
+ {
+ if (conversions[index].conversion != map_conversions[index].conversion ||
+ conversions[index].stars != map_conversions[index].stars ||
+ strcmp(conversions[index].modifier, map_conversions[index].modifier))
+ {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+}
+
+/* The list the tag file `file` (`file_size` bytes) of group `group` makes
+for the map's list `tag_index`, whose strings are `map_list`'s, or NULL with
+*reason saying why not. */
+static struct mod_string_list *mod_string_list_new(
+ long tag_index,
+ struct string_group const *group,
+ byte *file,
+ unsigned long file_size,
+ struct string_list const *map_list,
+ char const **reason)
+{
+ struct mod_string_list *list;
+ unsigned long cursor = MOD_TAG_FILE_HEADER_SIZE + STRING_LIST_SIZE;
+ unsigned long string_count;
+ unsigned long data_cursor;
+ unsigned long characters_size = 0;
+ unsigned long character_cursor = 0;
+ unsigned long terminator = group->unicode ? 2 : 1;
+ unsigned long index;
+
+ *reason = "not a string list tag file of this version";
+ if (file_size < cursor || !mods_tag_file_header_valid(file, group->group_tag, group->version))
+ {
+ return NULL;
+ }
+ list = malloc(sizeof(*list));
+ if (!list)
+ {
+ *reason = "out of memory";
+ return NULL;
+ }
+ memset(list, 0, sizeof(*list));
+ list->tag_index = tag_index;
+ string_count = mods_read_be32(file + MOD_TAG_FILE_HEADER_SIZE + offsetof(struct string_list, strings.count));
+ *reason = "its strings are not in the file";
+ if (string_count > MAXIMUM_STRINGS || !mods_in_file(cursor, string_count * STRING_LIST_ENTRY_SIZE, file_size))
+ {
+ goto refused;
+ }
+
+ /* each string's characters, which must end the file */
+ data_cursor = cursor + string_count * STRING_LIST_ENTRY_SIZE;
+ for (index = 0; index < string_count; index++)
+ {
+ unsigned long size = mods_read_be32(file + cursor + index * STRING_LIST_ENTRY_SIZE +
+ offsetof(struct string_list_entry, string.size));
+
+ if (size > MAXIMUM_STRING_BYTES || (group->unicode && size % 2) || !mods_in_file(data_cursor, size, file_size))
+ {
+ goto refused;
+ }
+ data_cursor += size;
+ characters_size += size + terminator;
+ }
+ *reason = "the file goes on past its last string";
+ if (data_cursor != file_size)
+ {
+ goto refused;
+ }
+
+ /* the map's number of strings: the file's where they read what the
+ map's do */
+ *reason = "out of memory";
+ list->entries = malloc(MAX(map_list->strings.count, 1) * sizeof(*list->entries));
+ list->characters = malloc(MAX(characters_size, 1));
+ if (!list->entries || !list->characters)
+ {
+ goto refused;
+ }
+ data_cursor = cursor + string_count * STRING_LIST_ENTRY_SIZE;
+ for (index = 0; index < (unsigned long)map_list->strings.count; index++)
+ {
+ struct string_list_entry const *map_entry =
+ TAG_BLOCK_GET_ELEMENT(&map_list->strings, index, struct string_list_entry);
+ struct string_list_entry *entry = &list->entries[index];
+
+ *entry = *map_entry;
+ if (index < string_count)
+ {
+ unsigned long size = mods_read_be32(file + cursor + index * STRING_LIST_ENTRY_SIZE +
+ offsetof(struct string_list_entry, string.size));
+ byte const *string = file + data_cursor;
+
+ data_cursor += size;
+ if (map_entry->string.size > 0 && map_entry->string.address &&
+ formats_match(string, size, map_entry->string.address, (unsigned long)map_entry->string.size,
+ group->unicode))
+ {
+ byte *characters = list->characters + character_cursor;
+
+ memcpy(characters, string, size);
+ memset(characters + size, 0, terminator);
+ character_cursor += size + terminator;
+ if (group->unicode)
+ {
+ tag_schema_unicode_string_end((wchar_t *)characters, (long)((size + terminator) / 2));
+ }
+ entry->string.size = (long)(size + terminator);
+ entry->string.address = characters;
+ entry->string.file_offset = 0;
+ entry->string.definition = NULL;
+ list->replaced_count++;
+ }
+ }
+ }
+ list->list.strings.count = map_list->strings.count;
+ list->list.strings.address = map_list->strings.count ? list->entries : NULL;
+ list->list.strings.definition = NULL;
+ list->file = file;
+ list->file_size = file_size;
+ list->kept_bytes = sizeof(*list) + file_size + MAX(map_list->strings.count, 1) * sizeof(*list->entries) +
+ MAX(characters_size, 1);
+ if ((long)string_count > map_list->strings.count || list->replaced_count < MIN((long)string_count, map_list->strings.count))
+ {
+ error(_error_silent, "mods: %ld of the %ld strings of %s replace the map's %ld; the rest are past them, or read other arguments",
+ list->replaced_count, (long)string_count, tag_get_name(tag_index), map_list->strings.count);
+ }
+
+ return list;
+
+refused:
+ list->file = NULL;
+ mod_string_list_free(list);
+
+ return NULL;
+}
+
+/* a list made before for the tag `tag_index` from a file of these bytes,
+shown or not, or NULL */
+static struct mod_string_list *list_kept(
+ long tag_index,
+ byte const *file,
+ unsigned long file_size)
+{
+ struct mod_string_list *list;
+
+ for (list = mod_strings_globals.lists; list; list = list->next)
+ {
+ if (list->tag_index == tag_index && list->file_size == file_size && !memcmp(list->file, file, file_size))
+ return list;
+ }
+
+ return NULL;
+}
+
+/* the override of the tag `tag_index` */
+static struct mod_string_override *override_get(
+ long tag_index)
+{
+ long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index);
+
+ return absolute_index >= 0 && absolute_index < mod_strings_globals.override_count ?
+ &mod_strings_globals.overrides[absolute_index] : NULL;
+}
+
+/* Makes the table of overrides, one for each of the map's tags up to its
+last string list; FALSE when it cannot be. */
+static boolean overrides_new(
+ void)
+{
+ long count = 0;
+ short group_index;
+
+ if (mod_strings_globals.overrides)
+ {
+ return TRUE;
+ }
+ for (group_index = 0; group_index < (short)NUMBEROF(string_groups); group_index++)
+ {
+ struct tag_iterator iterator;
+ long tag_index;
+
+ tag_iterator_new(&iterator, string_groups[group_index].group_tag);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ count = MAX(count, DATUM_INDEX_TO_ABSOLUTE_INDEX(tag_index) + 1);
+ }
+ }
+ if (count <= 0 || !(mod_strings_globals.overrides = malloc((size_t)count * sizeof(struct mod_string_override))))
+ {
+ return FALSE;
+ }
+ memset(mod_strings_globals.overrides, 0, (size_t)count * sizeof(struct mod_string_override));
+ mod_strings_globals.override_count = count;
+
+ return TRUE;
+}
+
+/* ---------- public code */
+
+long mod_strings_update(
+ boolean remove_all,
+ boolean at_load)
+{
+ long loaded_count = 0;
+ long removed_count = 0;
+ long refused_count = 0;
+ long played_count = 0;
+ short group_index;
+ long tag_index;
+
+ if (!mods_map_loaded() || !overrides_new())
+ {
+ return 0;
+ }
+
+ for (group_index = 0; group_index < (short)NUMBEROF(string_groups); group_index++)
+ {
+ struct string_group const *group = &string_groups[group_index];
+ struct tag_iterator iterator;
+
+ tag_iterator_new(&iterator, group->group_tag);
+ while ((tag_index = tag_iterator_next(&iterator)) != NONE)
+ {
+ struct mod_string_override *override = override_get(tag_index);
+ struct string_list *definition = tag_get(group->group_tag, tag_index);
+ long file_index = remove_all ? NONE : mods_tag_file_find(tag_index, group->extension);
+ struct mod_string_list *made = NULL;
+ byte *file = NULL;
+ unsigned long file_size = 0;
+ char const *reason = NULL;
+
+ /* (a tag of the other group, as tag_get gives it) */
+ if (!override || !definition || tag_get_group_tag(tag_index) != group->group_tag)
+ {
+ continue;
+ }
+ if (file_index != NONE)
+ {
+ file = mods_tag_file_read(file_index, &file_size, &reason);
+ /* (one made before from the same file, shown or not) */
+ made = file ? list_kept(tag_index, file, file_size) : NULL;
+ if (made)
+ {
+ free(file);
+ if (made == override->list)
+ continue;
+ }
+ else if (file)
+ {
+ made = mod_string_list_new(tag_index, group, file, file_size,
+ override->list ? &override->map_list : definition, &reason);
+ if (made && !mods_memory_take(made->kept_bytes))
+ {
+ reason = "the mods' tags already hold too much memory";
+ made->file = NULL;
+ mod_string_list_free(made);
+ made = NULL;
+ }
+ if (!made)
+ {
+ free(file);
+ }
+ }
+ if (!made)
+ {
+ error(_error_silent, "mods: %s left out: %s", port_mods_file_path(file_index), reason);
+ console_warning("mods: %s%s of %s left out: %s", tag_get_name(tag_index), group->extension,
+ port_mods_file_mod(file_index), reason);
+ refused_count++;
+ /* (one refused leaves what was shown before) */
+ continue;
+ }
+ }
+ else if (!override->list)
+ {
+ continue;
+ }
+
+ if (override->list)
+ {
+ memcpy(definition, &override->map_list, sizeof(*definition));
+ override->list = NULL;
+ if (!made)
+ removed_count++;
+ }
+ if (made)
+ {
+ memcpy(&override->map_list, definition, sizeof(*definition));
+ memcpy(definition, &made->list, sizeof(*definition));
+ override->list = made;
+ if (!list_kept(tag_index, made->file, made->file_size))
+ {
+ made->next = mod_strings_globals.lists;
+ mod_strings_globals.lists = made;
+ }
+ loaded_count++;
+ }
+ }
+ }
+
+ for (tag_index = 0; tag_index < mod_strings_globals.override_count; tag_index++)
+ {
+ if (mod_strings_globals.overrides[tag_index].list)
+ played_count++;
+ }
+ if (!at_load || loaded_count || refused_count)
+ {
+ error(_error_silent, "mods: %ld string lists from their files (%ld new), %ld given back, %ld left out",
+ played_count, loaded_count, removed_count, refused_count);
+ if (!at_load && mods_say())
+ {
+ console_printf(FALSE, "mods: %ld string lists from their files (%ld new), %ld given back, %ld left out",
+ played_count, loaded_count, removed_count, refused_count);
+ }
+ }
+
+ return played_count;
+}
+
+void mod_strings_tags_unloaded(
+ void)
+{
+ struct mod_string_list *list = mod_strings_globals.lists;
+
+ while (list)
+ {
+ struct mod_string_list *next = list->next;
+
+ mod_string_list_free(list);
+ list = next;
+ }
+ if (mod_strings_globals.overrides)
+ free(mod_strings_globals.overrides);
+ memset(&mod_strings_globals, 0, sizeof(mod_strings_globals));
+
+ return;
+}
diff --git a/port/linux/game/mods.c b/port/linux/game/mods.c
new file mode 100644
index 0000000000..64a3557a6b
--- /dev/null
+++ b/port/linux/game/mods.c
@@ -0,0 +1,400 @@
+/*
+MODS.C
+
+Mods: tag files played over a map's tags on this machine, as a resource
+pack is: a map's sounds (mod_sounds.c), bitmaps (mod_bitmaps.c), text
+(mod_strings.c) and models (mod_models.c) from Halo PC tag files, as the
+Halo Editing Kit and Invader write them, in the mods of the mods folder that
+are on, in their load order (port/linux/src/port_mods.c; the main menu's
+Mods chooses: menu_functions.c; the mod.toml files: docs/mods.md). Nothing a
+mod plays is anything the machines of a network game must agree on: no tag
+that the game simulates is played from a file (a model's nodes, markers and
+regions must be the map's: only what is drawn of it is the file's), and no
+mod is sent to another machine, so a mod is this machine's alone, in any
+game.
+
+When a map's tags load (scenario_tags_load), when the console asks
+(mods_reload) and when the Mods menu's OK keeps a new order (mods_apply),
+the mods are found again, and each of the map's tags of those groups that a
+mod has a file of (tags\.)
+is played from the file, made what this build plays: the tag's definition
+takes the file's values in place, so everything that holds the tag keeps
+holding it, and the map's own values come back when the file goes. A file
+that is not what its group's reader can trust (each checks every count,
+offset and value against the file and against what the game reads), or
+that does not fit what the map's tags ask of the tag it replaces, is said
+and left out, and the map's tag played. mods_enable false gives every tag
+back until mods_enable true, or until the map changes.
+*/
+
+/* ---------- headers */
+
+#include "cseries.h"
+#include "main/console.h"
+#include "mods.h"
+
+#include
+#include
+
+/* the game's (port) */
+char *tag_get_name(long tag_index);
+
+/* ---------- constants */
+
+/* the most the mods' tags keep in memory (their files, their sounds'
+samples, their bitmaps' tags and their models' geometry; bitmaps' pixels
+are read as they are drawn) */
+#if defined(HALO_ANDROID) || defined(HALO_WEB)
+#define MAXIMUM_KEPT_BYTES (192UL * 1024 * 1024)
+#else
+#define MAXIMUM_KEPT_BYTES (512UL * 1024 * 1024)
+#endif
+
+/* ---------- globals */
+
+static struct
+{
+ boolean map_loaded;
+ boolean enabled;
+ char map_name[64];
+ unsigned long kept_bytes;
+ /* how many times the mods had been found when they last were here
+ (port_mods_scans) */
+ unsigned long scans;
+ /* the groups' updates say what they did at the console (not as the
+ Mods menu applies them) */
+ boolean say;
+ /* how many of the map's strings, bitmaps, sounds and models the mods play */
+ long string_count;
+ long bitmap_count;
+ long sound_count;
+ long model_count;
+} mods_globals;
+
+/* ---------- private code */
+
+static void mods_let_go(boolean give_back);
+
+/* every group's tags played from the mods' files, or (remove_all) none */
+static void mods_update(
+ boolean remove_all,
+ boolean at_load)
+{
+ mods_globals.string_count = mod_strings_update(remove_all, at_load);
+ mods_globals.bitmap_count = mod_bitmaps_update(remove_all, at_load);
+ mods_globals.sound_count = mod_sounds_update(remove_all, at_load);
+ mods_globals.model_count = mod_models_update(remove_all, at_load);
+
+ return;
+}
+
+/* ---------- public code */
+
+unsigned long mods_read_be16(
+ byte const *bytes)
+{
+ return ((unsigned long)bytes[0] << 8) | bytes[1];
+}
+
+unsigned long mods_read_be32(
+ byte const *bytes)
+{
+ return ((unsigned long)bytes[0] << 24) | ((unsigned long)bytes[1] << 16) |
+ ((unsigned long)bytes[2] << 8) | bytes[3];
+}
+
+short mods_read_short(
+ byte const *bytes)
+{
+ return (short)mods_read_be16(bytes);
+}
+
+real mods_read_real(
+ byte const *bytes)
+{
+ unsigned long bits = mods_read_be32(bytes);
+ real value;
+
+ memcpy(&value, &bits, sizeof(value));
+
+ return value;
+}
+
+/* whether `size` bytes at `offset` lie within `file_size` */
+boolean mods_in_file(
+ unsigned long offset,
+ unsigned long size,
+ unsigned long file_size)
+{
+ return offset <= file_size && size <= file_size - offset;
+}
+
+boolean mods_finite_real(
+ real value)
+{
+ return value == value && value - value == 0.f;
+}
+
+boolean mods_tag_file_header_valid(
+ byte const *file,
+ unsigned long group_tag,
+ short version)
+{
+ return mods_read_be32(file + MOD_TAG_FILE_GROUP_OFFSET) == group_tag &&
+ !memcmp(file + MOD_TAG_FILE_SIGNATURE_OFFSET, "blam", 4) &&
+ mods_read_be32(file + MOD_TAG_FILE_HEADER_SIZE_OFFSET) == MOD_TAG_FILE_HEADER_SIZE &&
+ mods_read_short(file + MOD_TAG_FILE_VERSION_OFFSET) == version;
+}
+
+long mods_tag_file_find(
+ long tag_index,
+ char const *extension)
+{
+ char const *name = tag_get_name(tag_index);
+ char tag_file[512];
+
+ if (!mods_globals.map_loaded || !name || !*name || strlen(name) + strlen(extension) >= sizeof(tag_file))
+ {
+ return NONE;
+ }
+ strcpy(tag_file, name);
+ strcat(tag_file, extension);
+
+ return port_mods_file_find(tag_file, mods_globals.map_name);
+}
+
+byte *mods_tag_file_read(
+ long file,
+ unsigned long *size,
+ char const **reason)
+{
+ unsigned long stamp[2];
+ byte *data;
+
+ *reason = "it cannot be read";
+ if (!port_mods_file_stamp(file, size, stamp))
+ {
+ return NULL;
+ }
+ if (*size < MOD_TAG_FILE_HEADER_SIZE || *size > MAXIMUM_MOD_TAG_FILE_SIZE)
+ {
+ *reason = "it is not the size of a tag file";
+ return NULL;
+ }
+ data = malloc(*size);
+ if (!data)
+ {
+ *reason = "out of memory";
+ return NULL;
+ }
+ if (!port_mods_file_read(file, 0, data, *size))
+ {
+ free(data);
+ return NULL;
+ }
+
+ return data;
+}
+
+boolean mods_memory_take(
+ unsigned long size)
+{
+ if (size > MAXIMUM_KEPT_BYTES - mods_globals.kept_bytes)
+ {
+ return FALSE;
+ }
+ mods_globals.kept_bytes += size;
+
+ return TRUE;
+}
+
+unsigned long mods_memory_left(
+ void)
+{
+ return MAXIMUM_KEPT_BYTES - mods_globals.kept_bytes;
+}
+
+void mods_memory_give(
+ unsigned long size)
+{
+ mods_globals.kept_bytes -= MIN(size, mods_globals.kept_bytes);
+
+ return;
+}
+
+char const *mods_map_name(
+ void)
+{
+ return mods_globals.map_name;
+}
+
+boolean mods_map_loaded(
+ void)
+{
+ return mods_globals.map_loaded;
+}
+
+/* (scenario_tags_load, once a map's tags are loaded) */
+void mods_tags_loaded(
+ char const *map_name)
+{
+ /* (a map whose tags loaded, but not the rest of it, is not unloaded
+ before the next map's tags load over its own: what the mods made for it
+ goes, and nothing of it is written over the new map's tags) */
+ if (mods_globals.map_loaded)
+ mods_let_go(FALSE);
+ mods_globals.map_loaded = TRUE;
+ mods_globals.enabled = TRUE;
+ csstrncpy(mods_globals.map_name, map_name ? map_name : "", sizeof(mods_globals.map_name) - 1);
+ mods_globals.map_name[sizeof(mods_globals.map_name) - 1] = 0;
+ /* (found again, unless the menus of this map just found them: the
+ first map's, menu_tags.c) */
+ if (port_mods_scans() == mods_globals.scans)
+ port_mods_scan();
+ mods_globals.scans = port_mods_scans();
+ if (port_mods_played())
+ {
+ mods_update(FALSE, TRUE);
+ }
+
+ return;
+}
+
+/* Every group lets go of what it made for the map; the map's models get
+their own definitions back if `give_back` (their tags are still there). */
+static void mods_let_go(
+ boolean give_back)
+{
+ mod_sounds_tags_unloaded();
+ mod_bitmaps_tags_unloaded();
+ mod_models_tags_unloaded(give_back);
+ mod_strings_tags_unloaded();
+ mods_globals.map_loaded = FALSE;
+ mods_globals.enabled = FALSE;
+ mods_globals.map_name[0] = 0;
+ mods_globals.kept_bytes = 0;
+ mods_globals.string_count = 0;
+ mods_globals.bitmap_count = 0;
+ mods_globals.sound_count = 0;
+ mods_globals.model_count = 0;
+
+ return;
+}
+
+/* (scenario_tags_unload, once the sound and texture caches are closed: no
+cache block holds a mod's permutation or bitmap; nothing draws a model) */
+void mods_tags_unloaded(
+ void)
+{
+ mods_let_go(TRUE);
+
+ return;
+}
+
+boolean mods_say(
+ void)
+{
+ return mods_globals.say;
+}
+
+/* (the script function mods_reload) */
+void mods_reload(
+ void)
+{
+ if (!mods_globals.map_loaded)
+ {
+ return;
+ }
+ if (!mods_globals.enabled)
+ {
+ console_warning("the mods are off until mods_enable true, or the map changes");
+ return;
+ }
+ console_printf(FALSE, "mods: %ld played", port_mods_scan());
+ mods_globals.scans = port_mods_scans();
+ mods_globals.say = TRUE;
+ mods_update(FALSE, FALSE);
+ mods_globals.say = FALSE;
+
+ return;
+}
+
+/* (the Mods menu's OK, menu_functions.c) The mods found again and played
+on the map in their new order, quietly. */
+void mods_apply(
+ void)
+{
+ if (!mods_globals.map_loaded)
+ {
+ port_mods_scan();
+ return;
+ }
+ port_mods_scan();
+ mods_globals.scans = port_mods_scans();
+ if (mods_globals.enabled)
+ {
+ mods_update(FALSE, FALSE);
+ }
+
+ return;
+}
+
+/* (the script function mods_enable) Plays the mods' tags, or gives the
+map's back, until the map changes. */
+void mods_enable(
+ boolean enabled)
+{
+ if (!mods_globals.map_loaded || enabled == mods_globals.enabled)
+ {
+ return;
+ }
+ mods_globals.enabled = enabled;
+ if (enabled)
+ {
+ port_mods_scan();
+ mods_globals.scans = port_mods_scans();
+ }
+ mods_globals.say = TRUE;
+ mods_update(!enabled, FALSE);
+ mods_globals.say = FALSE;
+
+ return;
+}
+
+/* (the script function mods_list) */
+void mods_list(
+ void)
+{
+ long count = port_mods_count();
+ long index;
+
+ if (!count)
+ {
+ console_printf(FALSE, "mods: none in the mods folder");
+ }
+ /* (in their load order) */
+ for (index = 0; index < count; index++)
+ {
+ char text[512];
+
+ if (port_mods_describe(port_mods_at(index), text, sizeof(text), mods_globals.map_name))
+ {
+ console_printf(FALSE, "%s", text);
+ }
+ }
+ if (mods_globals.map_loaded)
+ {
+ if (mods_globals.enabled)
+ {
+ console_printf(FALSE, "mods: on %s, %ld string lists, %ld bitmaps, %ld sounds and %ld models are theirs (%lu KB kept)",
+ mods_globals.map_name, mods_globals.string_count, mods_globals.bitmap_count, mods_globals.sound_count,
+ mods_globals.model_count, mods_globals.kept_bytes / 1024);
+ }
+ else
+ {
+ console_printf(FALSE, "mods: off on %s until mods_enable true", mods_globals.map_name);
+ }
+ }
+
+ return;
+}
diff --git a/port/linux/game/mods.h b/port/linux/game/mods.h
new file mode 100644
index 0000000000..99e5fbbdfd
--- /dev/null
+++ b/port/linux/game/mods.h
@@ -0,0 +1,129 @@
+/*
+MODS.H
+
+The mods' tags played over a map's (mods.c), and the files they come from
+(port/linux/src/port_mods.c).
+*/
+
+#ifndef __MODS_H
+#define __MODS_H
+#pragma once
+
+/* ---------- headers */
+
+#include "cseries.h"
+
+/* ---------- constants */
+
+/* a tag file's header (Halo PC's, big-endian, as the Halo Editing Kit and
+Invader write it): its group, its header's size, its group's version and
+its signature, then its tag's root */
+#define MOD_TAG_FILE_HEADER_SIZE 0x40
+#define MOD_TAG_FILE_GROUP_OFFSET 0x24
+#define MOD_TAG_FILE_HEADER_SIZE_OFFSET 0x2C
+#define MOD_TAG_FILE_VERSION_OFFSET 0x38
+#define MOD_TAG_FILE_SIGNATURE_OFFSET 0x3C
+
+/* the largest tag file read whole (the largest of Halo PC's sounds is a few
+MB; a bitmap's pixels are read as they are drawn) */
+#define MAXIMUM_MOD_TAG_FILE_SIZE (64UL * 1024 * 1024)
+
+/* ---------- structures */
+
+struct sound_permutation;
+struct bitmap_data;
+struct vertex_buffer;
+
+/* ---------- prototypes/PORT_MODS.C (the platform layer's: port_mods.h) */
+
+long port_mods_scan(void);
+unsigned long port_mods_scans(void);
+long port_mods_played(void);
+long port_mods_file_find(char const *tag_file, char const *map_name);
+int port_mods_file_stamp(long file, unsigned long *size, unsigned long stamp[2]);
+int port_mods_file_read(long file, unsigned long offset, void *buffer, unsigned long size);
+long port_mods_file_order(long file);
+char const *port_mods_file_mod(long file);
+char const *port_mods_file_path(long file);
+long port_mods_count(void);
+long port_mods_at(long position);
+char const *port_mods_folder(long index);
+int port_mods_describe(long index, char *text, unsigned long size, char const *map_name);
+
+/* ---------- prototypes/MODS.C */
+
+/* the game's (cache_files.c, scenario_tags_load and scenario_tags_unload) */
+void mods_tags_loaded(char const *map_name);
+void mods_tags_unloaded(void);
+/* the console's (hs.c) */
+void mods_reload(void);
+void mods_enable(boolean enabled);
+void mods_list(void);
+/* the Mods menu's (menu_functions.c): the mods played again as it left them */
+void mods_apply(void);
+/* for each group's: whether its update is the console's, to say what it did
+there */
+boolean mods_say(void);
+
+/* for each group's: a tag file's bytes */
+unsigned long mods_read_be16(byte const *bytes);
+unsigned long mods_read_be32(byte const *bytes);
+short mods_read_short(byte const *bytes);
+real mods_read_real(byte const *bytes);
+boolean mods_in_file(unsigned long offset, unsigned long size, unsigned long file_size);
+boolean mods_finite_real(real value);
+/* whether the first bytes of a tag file are a header of the group's, of
+its version */
+boolean mods_tag_file_header_valid(byte const *file, unsigned long group_tag, short version);
+/* the file of the mods played that is the tag `tag_index`'s with its
+group's `extension` (".sound"), or NONE */
+long mods_tag_file_find(long tag_index, char const *extension);
+/* the whole file, in a buffer of the game's allocator (free() it); NULL
+with *reason saying why not */
+byte *mods_tag_file_read(long file, unsigned long *size, char const **reason);
+/* what the mods' tags keep in memory, of all their groups: FALSE when
+`size` more would be too much */
+boolean mods_memory_take(unsigned long size);
+void mods_memory_give(unsigned long size);
+unsigned long mods_memory_left(void);
+/* the map loaded's name ("a10"), and whether its tags are loaded */
+char const *mods_map_name(void);
+boolean mods_map_loaded(void);
+
+/* ---------- prototypes/MOD_SOUNDS.C */
+
+/* every one of a group's: the map's tags played from the mods' files (or,
+remove_all, none); as the map loads (at_load) nothing plays yet, and
+nothing is said at the console. How many of its tags the mods play */
+long mod_sounds_update(boolean remove_all, boolean at_load);
+void mod_sounds_tags_unloaded(void);
+/* (sound_cache_start_loading_sound) whether a permutation is a mod's,
+read at once into buffer */
+boolean mod_sounds_read(struct sound_permutation const *permutation, void *buffer);
+
+/* ---------- prototypes/MOD_BITMAPS.C */
+
+long mod_bitmaps_update(boolean remove_all, boolean at_load);
+void mod_bitmaps_tags_unloaded(void);
+/* (texture_cache_start_loading_bitmap) whether a bitmap is a mod's, its
+pixels read at once into buffer and made the hardware's */
+boolean mod_bitmaps_read(struct bitmap_data *bitmap, void *buffer);
+/* whether a bitmap is a mod's (whose linear rows are padded as they load) */
+boolean mod_bitmaps_contain(struct bitmap_data const *bitmap);
+
+/* ---------- prototypes/MOD_STRINGS.C */
+
+long mod_strings_update(boolean remove_all, boolean at_load);
+void mod_strings_tags_unloaded(void);
+
+/* ---------- prototypes/MOD_MODELS.C */
+
+long mod_models_update(boolean remove_all, boolean at_load);
+/* (before the tags go: the map's models are given back, unless give_back
+is FALSE (the tags are gone already), and the mods' buffers let go) */
+void mod_models_tags_unloaded(boolean give_back);
+/* (custom_edition_part_palette) the nodes a mod's part of a model of many
+nodes is drawn with, by its vertex buffer: their count, 0 for any other */
+short mod_models_part_palette(struct vertex_buffer const *vertex_buffer, byte const **nodes);
+
+#endif
diff --git a/port/linux/game/network_objects.c b/port/linux/game/network_objects.c
index 74081e532d..4b872e91b9 100644
--- a/port/linux/game/network_objects.c
+++ b/port/linux/game/network_objects.c
@@ -201,6 +201,10 @@ fourth (as it sends players, network_distributed.c) */
#define MIDDLE_OBJECT_DISTANCE 60.0f
#define FAR_OBJECT_DISTANCE 120.0f
+/* a step of an unsteered vehicle's path (world units a tick). Further than
+this in one tick, the host moved it some other way (a teleport), which is
+not carried on as the path */
+#define UNSTEERED_PATH_STEP 4.0f
/* world units */
#define REMOTE_OBJECT_TOLERANCE 0.05f
/* ... and the cosine of the angle (an object at rest turned) */
@@ -418,6 +422,15 @@ static struct
long object_index;
long time;
} objects_client_dead[MAXIMUM_TRACKED_OBJECTS];
+/* ... where the host last had each unsteered vehicle, and when, and for
+how many ticks that step may be carried (distributed_client_note_unsteered_path) */
+static struct
+{
+ long object_index;
+ long time;
+ long coast_ticks;
+ real_point3d position;
+} objects_client_unsteered_path[MAXIMUM_TRACKED_OBJECTS];
/* ... its own objects made since the last tick (of any kind: those of the
kinds the host has go), and whether to look through all the objects
instead (the host's all told, too many made, or made where it cannot say) */
@@ -2549,6 +2562,59 @@ static void distributed_client_dead_vehicle(
object_damage_update(object_index);
}
+/* Client: the velocity that stays on the path of the host's positions for
+this unsteered vehicle. The velocity in the host's state is its physics
+velocity. A recording (a lifepod, which has no pilot) swings that about
+the path, and the next position pulls the vehicle back. The step from the
+host's previous position does not. Kept only for that gap: a late next
+position must not drive the vehicle on, and the host's word that it is at
+rest stops it. */
+static void distributed_client_note_unsteered_path(
+ long object_index,
+ real_point3d const *host_position,
+ boolean host_at_rest)
+{
+ long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(object_index);
+ struct object_datum *object;
+ long now;
+ long since;
+ long coast_ticks;
+ real_vector3d step;
+
+ if (absolute_index < 0 || absolute_index >= MAXIMUM_TRACKED_OBJECTS || !host_position)
+ return;
+ object = object_get(object_index);
+ now = game_time_get();
+ since = objects_client_unsteered_path[absolute_index].object_index == object_index ?
+ now - objects_client_unsteered_path[absolute_index].time : 0;
+ coast_ticks = 0;
+ /* (stopped as the host has it: the last step would sink a warthog, or
+ carry a Pelican past the pad, until the next word) */
+ if (host_at_rest)
+ {
+ object->object.translational_velocity = *global_zero_vector3d;
+ object->object.angular_velocity = *global_zero_vector3d;
+ }
+ else if (since > 0)
+ {
+ step.i = (host_position->x - objects_client_unsteered_path[absolute_index].position.x) / (real)since;
+ step.j = (host_position->y - objects_client_unsteered_path[absolute_index].position.y) / (real)since;
+ step.k = (host_position->z - objects_client_unsteered_path[absolute_index].position.z) / (real)since;
+ /* (a teleport is not a step of the path: leave it where the host put it) */
+ if (step.i * step.i + step.j * step.j + step.k * step.k <= UNSTEERED_PATH_STEP * UNSTEERED_PATH_STEP)
+ {
+ object->object.translational_velocity = step;
+ coast_ticks = since;
+ }
+ else
+ object->object.translational_velocity = *global_zero_vector3d;
+ }
+ objects_client_unsteered_path[absolute_index].object_index = object_index;
+ objects_client_unsteered_path[absolute_index].time = now;
+ objects_client_unsteered_path[absolute_index].coast_ticks = coast_ticks;
+ objects_client_unsteered_path[absolute_index].position = *host_position;
+}
+
void network_objects_handle_states(
void const *entries,
short count)
@@ -2655,6 +2721,9 @@ void network_objects_handle_states(
forward.k * object->object.forward.k >= angle_tolerance &&
up.i * object->object.up.i + up.j * object->object.up.j + up.k * object->object.up.k >= angle_tolerance)
{
+ if (distributed_vehicle_unsteered(state->object_index))
+ distributed_client_note_unsteered_path(state->object_index, &state->position,
+ TEST_FLAG(state->flags, _distributed_object_at_rest_bit));
continue;
}
if (network_objects_reconcile(state->object_index, &state->position, &forward, &up, &velocity,
@@ -2662,6 +2731,9 @@ void network_objects_handle_states(
{
distributed_count_correction();
}
+ if (distributed_vehicle_unsteered(state->object_index))
+ distributed_client_note_unsteered_path(state->object_index, &state->position,
+ TEST_FLAG(state->flags, _distributed_object_at_rest_bit));
/* (one it drives, put where the host has it, is as the host has it) */
if (own_vehicle)
SET_FLAG(object->object.flags, _object_at_rest_bit, at_rest);
@@ -3121,10 +3193,11 @@ static void distributed_client_remove_own_objects(
objects_client_new_object_count = 0;
}
-/* Client: keeps each AI-driven vehicle (such as a Pelican on its flight
-path) moving at the velocity the host last sent. These vehicles are held at
-rest here, and a distant one is only updated every few ticks, so otherwise
-it would stop between updates and jump on each one. */
+/* Client: keeps each unsteered vehicle moving along the path of the host's
+positions (distributed_client_note_unsteered_path): a Pelican its pilot
+flies, and a recording's lifepod, which has no pilot. These vehicles are
+held at rest here, and a distant one is only updated every few ticks, so
+otherwise it would stop between updates and jump on each one. */
static void distributed_client_carry_unsteered_vehicles(
void)
{
@@ -3141,11 +3214,23 @@ static void distributed_client_carry_unsteered_vehicles(
real_point3d position;
real angle;
- if (object->object.parent_object_index != NONE || unit_get(iterator.index)->unit.driver_object_index == NONE ||
- !distributed_vehicle_unsteered(iterator.index))
+ if (object->object.parent_object_index != NONE || !distributed_vehicle_unsteered(iterator.index))
{
continue;
}
+ /* (the step of the last gap only: past that, hold for the host) */
+ {
+ long absolute_index = DATUM_INDEX_TO_ABSOLUTE_INDEX(iterator.index);
+
+ if (absolute_index < 0 || absolute_index >= MAXIMUM_TRACKED_OBJECTS ||
+ objects_client_unsteered_path[absolute_index].object_index != iterator.index ||
+ objects_client_unsteered_path[absolute_index].coast_ticks <= 0 ||
+ game_time_get() - objects_client_unsteered_path[absolute_index].time >
+ objects_client_unsteered_path[absolute_index].coast_ticks)
+ {
+ continue;
+ }
+ }
angle = normalize3d(&axis);
if (velocity->i == 0.0f && velocity->j == 0.0f && velocity->k == 0.0f && angle == 0.0f)
continue;
@@ -3222,6 +3307,9 @@ void network_objects_new_game(
objects_host_inventories[absolute_index].ammunition_time = NONE;
objects_client_has[absolute_index] = NONE;
objects_client_dead[absolute_index].object_index = NONE;
+ objects_client_unsteered_path[absolute_index].object_index = NONE;
+ objects_client_unsteered_path[absolute_index].time = NONE;
+ objects_client_unsteered_path[absolute_index].coast_ticks = 0;
}
objects_host_told_count = 0;
objects_host_resting_cursor = 0;
diff --git a/port/linux/game/tag_schema.h b/port/linux/game/tag_schema.h
index 2a781a0cff..32b33cf490 100644
--- a/port/linux/game/tag_schema.h
+++ b/port/linux/game/tag_schema.h
@@ -390,6 +390,16 @@ void *tag_validate_index_buffer_data(
struct tag_validation *validation,
void const *buffer);
+/* ---------- prototypes/TAG_SCHEMA_EFFECTS.C */
+
+/* Ends a unicode string of `length` characters (the last of them a
+terminator) where draw_string.c's parse_unicode_string, which takes the
+character after a '|' as part of it, reads it to an end: whether it had to
+be ended sooner. */
+boolean tag_schema_unicode_string_end(
+ wchar_t *string,
+ long length);
+
/* ---------- prototypes/where the validator's messages go (tag_validate_report.c) */
void tag_validate_report(
diff --git a/port/linux/game/tag_schema_effects.c b/port/linux/game/tag_schema_effects.c
index 19854ffa8d..f8239ae928 100644
--- a/port/linux/game/tag_schema_effects.c
+++ b/port/linux/game/tag_schema_effects.c
@@ -2685,8 +2685,9 @@ static boolean unicode_string_ends(
}
/* ends a string of length characters (it ends at the latest with its last)
-so that it is read to an end in it: whether it had to */
-static boolean unicode_string_end(
+so that it is read to an end in it: whether it had to (the mods' strings
+too: mod_strings.c) */
+boolean tag_schema_unicode_string_end(
wchar_t *string,
long length)
{
@@ -2703,7 +2704,7 @@ static boolean unicode_string_end(
}
/* (a string is terminated, at its last whole character, as it is got:
-text_group.c; it is then read as its characters say: unicode_string_end) */
+text_group.c; it is then read as its characters say: tag_schema_unicode_string_end) */
static boolean unicode_string_list_entry_check(
struct tag_validation *validation,
void *base)
@@ -2725,7 +2726,7 @@ static boolean unicode_string_list_entry_check(
tag_validate_correct(validation, "is not terminated: terminated");
string[length - 1] = 0;
}
- if (unicode_string_end(string, length))
+ if (tag_schema_unicode_string_end(string, length))
tag_validate_correct(validation, "ends in a '|': ended before it");
return TRUE;
diff --git a/port/linux/include/halo_port_capacity.h b/port/linux/include/halo_port_capacity.h
index 93f480a08f..da345ef30c 100644
--- a/port/linux/include/halo_port_capacity.h
+++ b/port/linux/include/halo_port_capacity.h
@@ -58,7 +58,7 @@ Android's window is 128 MB, as the web build's, and its cache the Xbox's. */
The sound cache holds the sounds being played in 4 KB pages, 4 MB of them on
the Xbox. Halo Custom Edition maps' sounds, converted when they load
(port/linux/game/custom_edition_sounds.c), and those of sound tags loaded
-over a map's (loose_sounds.c), are longer than the Xbox maps' and fill it (a
+over a map's by mods (mod_sounds.c), are longer than the Xbox maps' and fill it (a
campaign map's dialogue and music did, "SOUND CACHE BLOWN" in debug.txt).
The desktop builds' cache is 16 MB; Android's and the web build's windows keep
the Xbox's. */
diff --git a/port/linux/src/dsound_sdl.c b/port/linux/src/dsound_sdl.c
index b553e60721..abf6ba659f 100644
--- a/port/linux/src/dsound_sdl.c
+++ b/port/linux/src/dsound_sdl.c
@@ -30,8 +30,9 @@ Without an audio device, a clock thread runs the same mixer into a scratch
buffer, so streams still drain at their real rate.
audio.volume sets the master volume (default 1.0); audio.reverb = false
-turns the reverb off; audio.enabled = false skips opening a device
-(port_config.c).
+turns the reverb off; audio.resampler = "linear" resamples between
+neighbouring frames instead of with the sinc low pass; audio.enabled = false
+skips opening a device (port_config.c).
*/
#include "platform.h"
@@ -483,6 +484,25 @@ next, so the blend is a multiply and an add a tap */
static float resampler_phases[RESAMPLER_TABLE_STEPS + 1][RESAMPLER_TAPS] RESAMPLER_ALIGNED;
static float resampler_deltas[RESAMPLER_TABLE_STEPS][RESAMPLER_TAPS] RESAMPLER_ALIGNED;
+/* how a voice's frames are converted to the output's rate: the windowed sinc
+low pass, or between the two frames either side (linear: it lets more of the
+images above the source's rate through, which some hear as brighter) */
+enum resampler_mode
+{
+ RESAMPLER_SINC,
+ RESAMPLER_LINEAR
+};
+/* audio.resampler */
+static enum resampler_mode resampler_mode = RESAMPLER_SINC;
+
+/* audio.resampler's mode: sinc for any value but "linear" */
+static enum resampler_mode resampler_mode_read(void)
+{
+ const char *setting = config_string("audio.resampler");
+
+ return setting && !strcmp(setting, "linear") ? RESAMPLER_LINEAR : RESAMPLER_SINC;
+}
+
static double bessel_i0(double x)
{
double sum = 1.0, term = 1.0;
@@ -777,6 +797,17 @@ static void mix_voice(struct sdl_stream *stream, float *output, float *send, uns
/* a voice turned all the way down (out of earshot), and not
sending to the reverb, only moves on */
}
+ else if (resampler_mode == RESAMPLER_LINEAR)
+ {
+ /* (the frame the output is at and the next, side by side
+ however the ring wraps) */
+ const float *left_frames = stream->history[0] + stream->center % RESAMPLER_HISTORY;
+ const float *right_frames = stream->history[1] + stream->center % RESAMPLER_HISTORY;
+ float fraction = (float)stream->phase;
+
+ sample_left = left_frames[0] + (left_frames[1] - left_frames[0]) * fraction;
+ sample_right = right_frames[0] + (right_frames[1] - right_frames[0]) * fraction;
+ }
else if (scale == 1.0f)
{
double position = stream->phase * RESAMPLER_TABLE_STEPS;
@@ -1357,6 +1388,7 @@ static void audio_start(void)
audio_started = TRUE;
master_volume = (float)config_real("audio.volume");
reverb_enabled = config_boolean("audio.reverb");
+ resampler_mode = resampler_mode_read();
resampler_initialize();
resampler_phases_initialize();
reverb_initialize();
@@ -1688,14 +1720,15 @@ VOID WINAPI DirectSoundDoWork(void)
{
static unsigned long volume_read_at = (unsigned long)-1;
- /* (audio.volume and audio.reverb read again when the settings change: on
- the game's thread, not the mixer's, whose lock the config's file I/O
- would hold) */
+ /* (audio.volume, audio.reverb and audio.resampler read again when the
+ settings change: on the game's thread, not the mixer's, whose lock the
+ config's file I/O would hold) */
if (volume_read_at != config_changes())
{
volume_read_at = config_changes();
master_volume = (float)config_real("audio.volume");
reverb_enabled = config_boolean("audio.reverb");
+ resampler_mode = resampler_mode_read();
}
streams_complete_finished();
}
diff --git a/port/linux/src/hud_hires.c b/port/linux/src/hud_hires.c
index f2e420edd4..2d56852da5 100644
--- a/port/linux/src/hud_hires.c
+++ b/port/linux/src/hud_hires.c
@@ -33,7 +33,8 @@ zlib (port/third_party/zlib).
/* the game's (port/linux/game/hud_hires_tags.c) */
long hud_hires_asset_at(unsigned long address, long width, long height);
-#define MAXIMUM_TEXTURES 128
+/* Include every embedded HUD and button style, including stick frames. */
+#define MAXIMUM_TEXTURES 256
/* a PNG's inflated rows and its texels, which are held at once: 128 MB for
a 4096 by 4096 sheet (the largest shipped, 2048 by 2048, takes 32 MB), and
no more for a small file that names a large size (a menus folder's) */
@@ -72,6 +73,31 @@ long hud_hires_asset_fits(long asset, long width, long height)
embedded->width / width == embedded->height / height && embedded->width / width > 1;
}
+/* Changed settings since the previous rendered frame: HUD bit 0, titles bit 1.
+ * Pending menu edits do not change config_changes(). */
+unsigned long hud_hires_settings_changed(void)
+{
+ static unsigned long read_at = (unsigned long)-1;
+ static unsigned long previous;
+ unsigned long current, changed;
+
+ if (read_at == config_changes()) return 0;
+ current = (config_boolean("display.high_res_hud") ? 1UL : 0UL) |
+ (config_boolean("display.high_res_text") ? 2UL : 0UL);
+ changed = read_at == (unsigned long)-1 ? 0 : previous ^ current;
+ read_at = config_changes();
+ previous = current;
+ return changed;
+}
+
+int hud_hires_bitmap_affected(unsigned long address, long width, long height, unsigned long changed)
+{
+ long asset = hud_hires_asset_at(address, width, height);
+
+ return asset >= 0 && asset < hud_hires_asset_count() &&
+ (changed & (hud_hires_embedded[asset].title ? 2UL : 1UL)) != 0;
+}
+
long hud_hires_override_find(unsigned long address, unsigned long width, unsigned long height,
unsigned long level0_size)
{
@@ -115,7 +141,8 @@ int hud_hires_override_coverage(long asset)
/* the placeholders of the textures drawn for some of a bitmap's sprites
(port/linux/game/hud_hires_tags.c): their D3D textures' Data */
-#define MAXIMUM_PLACEHOLDERS 8
+/* Room for the gameplay button styles and map-owned sprite placeholders. */
+#define MAXIMUM_PLACEHOLDERS 32
static struct
{
@@ -201,6 +228,146 @@ int hud_hires_override_point_threshold(long asset)
/* ---------- decoding */
+/* Catmull-Rom interpolation of the map's original texels. No font, sprite
+ * rectangle, registration point or animation frame is substituted. Keeping
+ * the input centres at (x + .5) also keeps the original HUD alignment. */
+static void upscale_weights(float t, float *weights)
+{
+ float square = t * t, cube = square * t;
+
+ weights[0] = -0.5f * t + square - 0.5f * cube;
+ weights[1] = 1.0f - 2.5f * square + 1.5f * cube;
+ weights[2] = 0.5f * t + 2.0f * square - 1.5f * cube;
+ weights[3] = -0.5f * square + 0.5f * cube;
+}
+
+unsigned char *hud_hires_upscale_pixels(const unsigned char *bgra, unsigned long width, unsigned long height)
+{
+ unsigned char *out;
+ unsigned long x, y, channel;
+
+ if (!bgra || !width || !height || width > 256 || height > 256)
+ return NULL;
+ out = malloc(width * height * 16 * 4);
+ if (!out)
+ return NULL;
+ for (y = 0; y < height * 4; y++)
+ {
+ float sy = (y + 0.5f) * 0.25f - 0.5f, wy[4];
+ long iy = (long)sy;
+
+ if (sy < iy) iy--;
+ upscale_weights(sy - iy, wy);
+ for (x = 0; x < width * 4; x++)
+ {
+ float sx = (x + 0.5f) * 0.25f - 0.5f, wx[4];
+ long ix = (long)sx;
+
+ if (sx < ix) ix--;
+ upscale_weights(sx - ix, wx);
+ for (channel = 0; channel < 4; channel++)
+ {
+ float value = 0.0f;
+ long row, column;
+ unsigned long source_channel = channel == 0 ? 2 : channel == 2 ? 0 : channel;
+
+ for (row = 0; row < 4; row++)
+ {
+ long py = iy + row - 1;
+
+ if (py < 0) py = 0;
+ if (py >= (long)height) py = height - 1;
+ for (column = 0; column < 4; column++)
+ {
+ long px = ix + column - 1;
+
+ if (px < 0) px = 0;
+ if (px >= (long)width) px = width - 1;
+ value += bgra[(py * width + px) * 4 + source_channel] * wy[row] * wx[column];
+ }
+ }
+ out[(y * width * 4 + x) * 4 + channel] =
+ (unsigned char)(value < 0 ? 0 : value > 255 ? 255 : value + 0.5f);
+ }
+ }
+ }
+ return out;
+}
+
+static unsigned char *png_decode(const unsigned char *data, unsigned long size,
+ unsigned long *png_width, unsigned long *png_height);
+
+/* The background's old icon otherwise draws over the existing high-res meter
+ * contour. Only the five known Xbox sheets get this matching contour; their
+ * text, and customized sheets, retain their original pixels and layout. */
+static void upscale_outline(unsigned char *pixels, const unsigned char *bgra,
+ unsigned long width, unsigned long height, long kind)
+{
+ unsigned long crc, png_width, png_height, row;
+ long asset;
+ unsigned char *outline;
+ char const *name;
+ unsigned long left, top, right, bottom;
+
+ if (width != 128 || height != 32 || (kind != 2 && kind != 3))
+ return;
+ crc = crc32(0L, bgra, (uInt)(width * height * 4));
+ if (kind == 2)
+ {
+ if (crc != 0x23894da5UL && crc != 0x38fb467aUL && crc != 0xcd279468UL)
+ return;
+ name = "ui\\hud\\redraws\\vehicle_wrench_outline";
+ left = 73; top = 11; right = 101; bottom = 25;
+ }
+ else
+ {
+ if (crc != 0x17446e88UL && crc != 0xb14f00a1UL)
+ return;
+ name = "ui\\hud\\redraws\\passenger_health_outline";
+ left = 74; top = 10; right = 89; bottom = 24;
+ }
+ for (asset = 0; asset < hud_hires_asset_count(); asset++)
+ {
+ const struct hud_hires_embedded *embedded = &hud_hires_embedded[asset];
+
+ if (strcmp(embedded->tag, name))
+ continue;
+ outline = png_decode((const unsigned char *)embedded->png, embedded->png_size, &png_width, &png_height);
+ if (outline && png_width == width * 4 && png_height == height * 4)
+ for (row = top * 4; row < bottom * 4; row++)
+ memcpy(pixels + (row * width * 4 + left * 4) * 4,
+ outline + (row * width * 4 + left * 4) * 4, (right - left) * 16);
+ free(outline);
+ break;
+ }
+}
+
+unsigned int hud_hires_upscale_texture(const unsigned char *bgra, unsigned long width,
+ unsigned long height, long kind, unsigned long *levels)
+{
+ unsigned char *pixels = hud_hires_upscale_pixels(bgra, width, height);
+ unsigned long largest;
+ GLuint texture;
+
+ if (!pixels)
+ return 0;
+ upscale_outline(pixels, bgra, width, height, kind);
+ *levels = 1;
+ for (largest = (width > height ? width : height) * 4; largest > 1; largest >>= 1)
+ (*levels)++;
+ glGenTextures(1, &texture);
+ glBindTexture(GL_TEXTURE_2D, texture);
+ glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, (GLint)*levels - 1);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, (GLsizei)width * 4, (GLsizei)height * 4,
+ 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
+ glGenerateMipmap(GL_TEXTURE_2D);
+ xgpu_gl_state_invalidate();
+ free(pixels);
+ return texture;
+}
+
static unsigned long big_endian_long(const unsigned char *bytes)
{
return ((unsigned long)bytes[0] << 24) | ((unsigned long)bytes[1] << 16) |
diff --git a/port/linux/src/hud_hires.h b/port/linux/src/hud_hires.h
index c53cbff940..af04f48493 100644
--- a/port/linux/src/hud_hires.h
+++ b/port/linux/src/hud_hires.h
@@ -51,6 +51,11 @@ struct hud_hires_embedded
extern const struct hud_hires_embedded hud_hires_embedded[];
extern const unsigned int hud_hires_embedded_count;
+/* At the frame boundary, identify cached bitmaps affected by committed HUD
+ * or title setting changes. Pixel/CRC validation still occurs on upload. */
+unsigned long hud_hires_settings_changed(void);
+int hud_hires_bitmap_affected(unsigned long address, long width, long height, unsigned long changed);
+
/* the texture standing for the bitmap whose pixels are uploaded from address
(guest virtual) with this size, its first mip level being level0_size bytes,
or -1: none, its setting (display.high_res_hud, or display.high_res_text for
@@ -75,4 +80,10 @@ unsigned int hud_hires_placeholder_texture(unsigned long data, unsigned long *le
nv2a_psh.c: point_threshold) */
int hud_hires_override_point_threshold(long asset);
+/* Original HUD sheets lacking redraws, with their tag coordinates unchanged.
+ * The cache owns the upscaled texture and invalidates it with the source. */
+long hud_hires_upscale_at(unsigned long address, long width, long height);
+unsigned int hud_hires_upscale_texture(const unsigned char *bgra, unsigned long width,
+ unsigned long height, long kind, unsigned long *levels);
+
#endif
diff --git a/port/linux/src/platform.h b/port/linux/src/platform.h
index a62eaf67f2..707dbd724b 100644
--- a/port/linux/src/platform.h
+++ b/port/linux/src/platform.h
@@ -105,6 +105,11 @@ const char *platform_custom_edition_root(void);
out of an Xbox disc image into destination (sdl_platform.c), and quits if
the player declines; nonzero once destination has one */
BOOL platform_offer_game_data(const char *destination);
+/* asks the player, in a box of its own that waits for the answer (before
+the window opens, or between frames), with `count` buttons (3 at most: Android's),
+`default_index` Enter's and Escape's: the index of the one chosen, or -1
+when it cannot ask */
+int platform_ask(const char *title, const char *text, const char *const *labels, int count, int default_index);
const char *platform_save_root(void);
/* ---------- contiguous ("physical") memory
diff --git a/port/linux/src/port_config.c b/port/linux/src/port_config.c
index 94e390a5df..004626fe4a 100644
--- a/port/linux/src/port_config.c
+++ b/port/linux/src/port_config.c
@@ -137,6 +137,10 @@ static const struct config_setting config_settings[] =
"Draw the HUD (meters, counters, panels, motion sensor, reticles,\n"
"waypoints, scopes) from the high-res assets (8x the maps' bitmaps);\n"
"false draws the maps' own bitmaps." },
+ { "display.hud_button_style", _config_string, "\"classic\"", "HALO_HUD_BUTTON_STYLE", _environment_value, _platform_all,
+ "High-res gameplay controller buttons: classic uses plain Xbox faces;\n"
+ "gems uses shaded Xbox faces; menu_gems uses the menu's button art.\n"
+ "Requires display.high_res_hud." },
{ "display.high_res_text", _config_boolean, "true", "HALO_HIGH_RES_TEXT", _environment_value, _platform_all,
"Draw the menus' and HUD's text with the fonts in port/assets/fonts\n"
"(Overpass) at the resolution the game draws at, and the menus' titles\n"
@@ -189,6 +193,10 @@ static const struct config_setting config_settings[] =
"Reverberate the world's sounds as the place the player is in does (the\n"
"maps' sound environments, as the Xbox's I3DL2 reverb did); false keeps\n"
"them dry." },
+ { "audio.resampler", _config_string, "\"sinc\"", "HALO_AUDIO_RESAMPLER", _environment_value, _platform_all,
+ "How sounds are converted to the output's rate: \"sinc\" with a\n"
+ "band-limited low pass; \"linear\" between neighbouring samples,\n"
+ "brighter for the aliasing it lets through." },
{ "audio.voice_chat", _config_string, "\"push_to_talk\"", "HALO_VOICE_CHAT", _environment_value, _platform_all,
"Talking in network games' voice chat: \"push_to_talk\" (while\n"
"controls.push_to_talk is held; the microphone opens the first time),\n"
@@ -204,12 +212,6 @@ static const struct config_setting config_settings[] =
_platform_desktop,
"The microphone voice chat listens to: a device's name as Settings >\n"
"Audio lists it, or \"default\" for the system's (also if it is not found)." },
- { "audio.loose_sounds", _config_boolean, "false", "HALO_LOOSE_SOUNDS", _environment_value, _platform_all,
- "For those making sounds: play each of a map's sounds that has a Halo PC\n"
- "sound tag file of its name under the data root's tags folder\n"
- "(tags/sound/.../name.sound) from that file. At the console,\n"
- "loose_sounds_reload reads the files again and loose_sounds false gives\n"
- "the map's sounds back." },
{ "input.touch_controls", _config_string, "\"auto\"", "HALO_TOUCH_CONTROLS", _environment_value, _platform_android | _platform_web,
"The on-screen touch controls in a game: \"auto\" shows them on a\n"
diff --git a/port/linux/src/port_mods.c b/port/linux/src/port_mods.c
new file mode 100644
index 0000000000..64ca369f6e
--- /dev/null
+++ b/port/linux/src/port_mods.c
@@ -0,0 +1,1435 @@
+/*
+PORT_MODS.C
+
+The mods (port_mods.h): folders in the data root's mods folder, each with a
+mod.toml saying what it is and a tags folder of Halo PC tag files, as the
+Halo Editing Kit and Invader write them (tags/sound/.../name.sound). The
+game plays a map's tags from the files of the mods that are on
+(port/linux/game/mods.c): only those of this machine's sounds, bitmaps,
+text and models (what is drawn of them), so that a mod changes nothing
+another machine of a network game sees.
+
+Every mod in the folder is on, in a load order: the folders' names'
+alphabetical order, except that the mods a mod must load after (those it
+asks to load after, and those that ask to load before it) move up to just
+before it. The Mods menu (menu_functions.c) turns mods off and moves them,
+and keeps that in the mods folder's load_order.toml (the folders' names in
+order, then those turned off); a mod not in it yet takes its place in the
+default order, on the side of the others it asks for. A file of several
+mods is the one of the mod loaded last, of those that are on and for the
+map.
+
+The mods played are scanned when a map loads and when the console asks
+(mods_reload): their mod.toml read, and each one's tags folder listed once
+into a table of its files by their tag path, lower case with backslashes
+("sound\sfx\weapons\assault rifle\fire.sound"). A map's tag is looked up in
+that table by its name, so no name a map holds ever makes a path: a mod's
+files are only the ones its folder has.
+
+mod.toml (every key may be left out):
+
+ name = "High-res audio" # what the menu shows (else its folder's name)
+ id = "hd_audio" # what other mods name it by (else its folder's name)
+ version = "1.0"
+ authors = ["...", "..."] # or one string
+ description = "..."
+ maps = ["a10", "bloodgulch"] # the maps it is for (else every map)
+ load_after = ["hd_textures"] # mods it plays over, which load before it
+ load_before = ["fixes"] # mods that play over it
+ incompatible = ["other_hud"] # mods it cannot be played with
+ format = 1 # the mods' format it is made for
+
+A folder without mod.toml is not a mod, and one made for a later format than
+this build's is not played. Anything else wrong is logged, and the rest used.
+When mods that are on name others that are on as incompatible, the game asks
+at startup whether to play them anyway, turn those named off, or quit
+(port_mods_conflicts).
+*/
+
+#include "platform.h"
+#include "port_config.h"
+#include "port_mods.h"
+#include "posix.h"
+#include "tomlc17.h"
+
+#include
+#include
+#include
+#include
+
+#ifdef HALO_WEB
+/* (port/web/src/web_library.js) */
+void web_js_post(int kind, const char *text);
+#endif
+
+/* ---------- constants */
+
+/* the mods' format this build plays */
+#define MODS_FORMAT 1
+
+#define MAXIMUM_MODS 256
+#define MAXIMUM_MOD_FOLDER_LENGTH 64
+/* the most files the mods played may have, and the deepest their folders go
+(a folder linking to one above it stops there) */
+#define MAXIMUM_MOD_FILES 262144
+#define MAXIMUM_FOLDER_DEPTH 24
+/* and the most folders they may have (a folder linking to one above it
+twice would otherwise be walked 2 to the depth times) */
+#define MAXIMUM_MOD_FOLDERS 65536
+#define MAXIMUM_PATH_LENGTH 1024
+#define MAXIMUM_MAPS_PER_MOD 64
+#define FILE_BUCKET_COUNT 16384
+/* what the file reads go through on their way into the Xbox's memory, which
+the renderer watches (memory_watch.c) */
+#define READ_BOUNCE_SIZE 0x10000
+/* the most a folder name's character takes in load_order.toml (\\u00XX) */
+#define TOML_CHARACTER_LENGTH 6
+
+/* ---------- structures */
+
+/* the mods a mod names (by their ids or folders) */
+struct mod_names
+{
+ char **names;
+ long count;
+};
+
+struct port_mod
+{
+ char folder[MAXIMUM_MOD_FOLDER_LENGTH];
+ char id[MAXIMUM_MOD_FOLDER_LENGTH];
+ char name[128];
+ char version[32];
+ char authors[128];
+ char description[256];
+ /* NULL: every map */
+ char **maps;
+ long map_count;
+ struct mod_names load_after;
+ struct mod_names load_before;
+ struct mod_names incompatible;
+ /* turned off in the Mods menu */
+ int disabled;
+ /* its place in the load order */
+ long position;
+ /* its place in the order its files were listed in (later wins), or -1:
+ not played */
+ long order;
+ /* why it is not played whatever the menu says, or NULL */
+ const char *refusal;
+ long file_count;
+};
+
+struct port_mod_file
+{
+ long next;
+ unsigned long hash;
+ long mod;
+ /* lower case, backslashes */
+ char *key;
+ char *path;
+};
+
+/* ---------- globals */
+
+static struct
+{
+ struct port_mod mods[MAXIMUM_MODS];
+ long mod_count;
+ /* the mods by their places in the load order */
+ long ordered[MAXIMUM_MODS];
+ long played_count;
+ struct port_mod_file *files;
+ long file_count;
+ long file_capacity;
+ long buckets[FILE_BUCKET_COUNT];
+ long folder_count;
+ unsigned long scans;
+ int full_reported;
+ /* load_order.toml as it was last kept, when it could not be written:
+ what the mods are ordered by until it can (NULL: the file) */
+ char *unsaved_order;
+} port_mods;
+
+/* ---------- private code */
+
+static char lower(char character)
+{
+ return character >= 'A' && character <= 'Z' ? (char)(character - 'A' + 'a') : character;
+}
+
+static unsigned long key_hash(const char *key)
+{
+ unsigned long hash = 2166136261UL;
+
+ for (; *key; key++)
+ hash = (hash ^ (unsigned char)*key) * 16777619UL;
+ return hash;
+}
+
+/* `text` as a key: lower case, backslashes; 0 when it does not fit */
+static int key_make(const char *text, char *key, size_t size)
+{
+ size_t length = 0;
+
+ for (; *text; text++)
+ {
+ if (length + 1 >= size)
+ return 0;
+ key[length++] = *text == '/' ? '\\' : lower(*text);
+ }
+ key[length] = 0;
+ return 1;
+}
+
+/* the alphabetical order of two names, whatever their case */
+static int names_compare(const char *first, const char *second)
+{
+ for (; *first && lower(*first) == lower(*second); first++, second++)
+ {
+ }
+ return (unsigned char)lower(*first) - (unsigned char)lower(*second);
+}
+
+static int names_equal(const char *first, const char *second)
+{
+ for (; *first && *second; first++, second++)
+ {
+ if (lower(*first) != lower(*second))
+ return 0;
+ }
+ return *first == *second;
+}
+
+static void names_dispose(struct mod_names *names)
+{
+ long index;
+
+ for (index = 0; index < names->count; index++)
+ free(names->names[index]);
+ free(names->names);
+ names->names = NULL;
+ names->count = 0;
+}
+
+static void mod_dispose(struct port_mod *mod)
+{
+ long index;
+
+ for (index = 0; index < mod->map_count; index++)
+ free(mod->maps[index]);
+ free(mod->maps);
+ mod->maps = NULL;
+ mod->map_count = 0;
+ names_dispose(&mod->load_after);
+ names_dispose(&mod->load_before);
+ names_dispose(&mod->incompatible);
+}
+
+/* whether the mod `mod` is the one `name` names: its id, or its folder */
+static int mod_named(const struct port_mod *mod, const char *name)
+{
+ return names_equal(mod->id, name) || names_equal(mod->folder, name);
+}
+
+/* whether a mod names `other` among `names` */
+static int names_name(const struct mod_names *names, const struct port_mod *other)
+{
+ long index;
+
+ for (index = 0; index < names->count; index++)
+ {
+ if (mod_named(other, names->names[index]))
+ return 1;
+ }
+ return 0;
+}
+
+/* a list of strings of mod.toml (or of load_order.toml) */
+static void toml_names(toml_datum_t table, const char *key, const char *path, struct mod_names *names)
+{
+ toml_datum_t value = toml_get(table, key);
+ long index;
+
+ if (value.type == TOML_UNKNOWN)
+ return;
+ if (value.type != TOML_ARRAY)
+ {
+ platform_log("mods: %s line %d: %s is not a list of mods", path, value.lineno, key);
+ return;
+ }
+ names->names = value.u.arr.size ? calloc((size_t)value.u.arr.size, sizeof(*names->names)) : NULL;
+ for (index = 0; names->names && index < value.u.arr.size && index < MAXIMUM_MODS; index++)
+ {
+ if (value.u.arr.elem[index].type != TOML_STRING)
+ {
+ platform_log("mods: %s line %d: an entry of %s is not a string", path, value.u.arr.elem[index].lineno, key);
+ continue;
+ }
+ names->names[names->count] = strdup(value.u.arr.elem[index].u.s);
+ if (names->names[names->count])
+ names->count++;
+ }
+}
+
+static void files_dispose(void)
+{
+ long index;
+
+ for (index = 0; index < port_mods.file_count; index++)
+ {
+ free(port_mods.files[index].key);
+ free(port_mods.files[index].path);
+ }
+ free(port_mods.files);
+ port_mods.files = NULL;
+ port_mods.file_count = 0;
+ port_mods.file_capacity = 0;
+ for (index = 0; index < FILE_BUCKET_COUNT; index++)
+ port_mods.buckets[index] = -1;
+}
+
+/* text cut short where it ends in part of a UTF-8 character */
+static void utf8_trim(char *text)
+{
+ size_t length = strlen(text);
+ size_t lead = length;
+ unsigned char first;
+ size_t need;
+
+ while (lead > 0 && length - lead < 3 && ((unsigned char)text[lead - 1] & 0xC0) == 0x80)
+ lead--;
+ if (!lead)
+ return;
+ first = (unsigned char)text[--lead];
+ need = first >= 0xF0 ? 4 : first >= 0xE0 ? 3 : first >= 0xC0 ? 2 : 1;
+ if (length - lead < need)
+ text[lead] = 0;
+}
+
+/* text as mod.toml has it, whole UTF-8 characters, and its control
+characters (but its line breaks, if `lines`) spaces: what the menus, the
+message box and the web page's dialog show of it */
+static void text_clean(char *text, int lines)
+{
+ utf8_trim(text);
+ for (; *text; text++)
+ {
+ if (((unsigned char)*text < 0x20 && !(lines && *text == '\n')) || *text == 0x7F)
+ *text = ' ';
+ }
+}
+
+static void text_copy(char *destination, size_t size, const char *source)
+{
+ snprintf(destination, size, "%s", source ? source : "");
+ text_clean(destination, 0);
+}
+
+/* a string key of mod.toml, or NULL (and logged when it is not a string) */
+static const char *toml_text(toml_datum_t table, const char *key, const char *path)
+{
+ toml_datum_t value = toml_get(table, key);
+
+ if (value.type == TOML_UNKNOWN)
+ return NULL;
+ if (value.type != TOML_STRING)
+ {
+ platform_log("mods: %s line %d: %s is not a string", path, value.lineno, key);
+ return NULL;
+ }
+ return value.u.s;
+}
+
+/* reads the mod.toml of the mod in `folder`; 0 when there is none */
+static int mod_read(struct port_mod *mod, const char *root, const char *folder)
+{
+ static const char *const keys[] =
+ {
+ "name", "id", "version", "authors", "description", "maps", "load_after", "load_before", "incompatible", "format",
+ };
+ char path[MAXIMUM_PATH_LENGTH];
+ size_t size = 0;
+ char *text;
+ toml_result_t result;
+ toml_datum_t value;
+ int index;
+
+ memset(mod, 0, sizeof(*mod));
+ mod->order = -1;
+ text_copy(mod->folder, sizeof(mod->folder), folder);
+ text_copy(mod->id, sizeof(mod->id), folder);
+ text_copy(mod->name, sizeof(mod->name), folder);
+ snprintf(path, sizeof(path), "%s/%s/mod.toml", root, folder);
+ text = config_file_read(path, &size);
+ if (!text)
+ {
+ platform_log("mods: %s/%s has no mod.toml: it is not a mod", root, folder);
+ return 0;
+ }
+ result = toml_parse(text, (int)size);
+ free(text);
+ if (!result.ok)
+ {
+ platform_log("mods: %s: %s; it is played without what it says", path, result.errmsg);
+ toml_free(result);
+ return 1;
+ }
+
+ text_copy(mod->name, sizeof(mod->name), toml_text(result.toptab, "name", path));
+ if (!mod->name[0])
+ text_copy(mod->name, sizeof(mod->name), folder);
+ text_copy(mod->id, sizeof(mod->id), toml_text(result.toptab, "id", path));
+ if (!mod->id[0])
+ text_copy(mod->id, sizeof(mod->id), folder);
+ toml_names(result.toptab, "load_after", path, &mod->load_after);
+ toml_names(result.toptab, "load_before", path, &mod->load_before);
+ toml_names(result.toptab, "incompatible", path, &mod->incompatible);
+ text_copy(mod->version, sizeof(mod->version), toml_text(result.toptab, "version", path));
+ snprintf(mod->description, sizeof(mod->description), "%s",
+ toml_text(result.toptab, "description", path) ? toml_text(result.toptab, "description", path) : "");
+ text_clean(mod->description, 1);
+ value = toml_get(result.toptab, "authors");
+ if (value.type == TOML_STRING)
+ {
+ text_copy(mod->authors, sizeof(mod->authors), value.u.s);
+ }
+ else if (value.type == TOML_ARRAY)
+ {
+ for (index = 0; index < value.u.arr.size; index++)
+ {
+ size_t length = strlen(mod->authors);
+
+ if (value.u.arr.elem[index].type == TOML_STRING)
+ snprintf(mod->authors + length, sizeof(mod->authors) - length, "%s%s", length ? ", " : "",
+ value.u.arr.elem[index].u.s);
+ }
+ }
+ else if (value.type != TOML_UNKNOWN)
+ {
+ platform_log("mods: %s line %d: authors is not a string or a list of them", path, value.lineno);
+ }
+ text_clean(mod->authors, 0);
+ value = toml_get(result.toptab, "maps");
+ if (value.type == TOML_ARRAY && value.u.arr.size > 0)
+ {
+ long count = value.u.arr.size < MAXIMUM_MAPS_PER_MOD ? value.u.arr.size : MAXIMUM_MAPS_PER_MOD;
+
+ if (value.u.arr.size > MAXIMUM_MAPS_PER_MOD)
+ platform_log("mods: %s line %d: more than %d maps: those after them are left out", path, value.lineno,
+ MAXIMUM_MAPS_PER_MOD);
+ mod->maps = calloc((size_t)count, sizeof(*mod->maps));
+ for (index = 0; mod->maps && index < count; index++)
+ {
+ if (value.u.arr.elem[index].type != TOML_STRING)
+ {
+ platform_log("mods: %s line %d: a map is not a string", path, value.u.arr.elem[index].lineno);
+ continue;
+ }
+ mod->maps[mod->map_count] = strdup(value.u.arr.elem[index].u.s);
+ if (mod->maps[mod->map_count])
+ mod->map_count++;
+ }
+ /* (a list of nothing but mistakes is for no map) */
+ if (!mod->map_count)
+ mod->refusal = "its list of maps names none";
+ }
+ else if (value.type != TOML_UNKNOWN && value.type != TOML_ARRAY)
+ {
+ platform_log("mods: %s line %d: maps is not a list of maps", path, value.lineno);
+ }
+ value = toml_get(result.toptab, "format");
+ if (value.type == TOML_INT64 && value.u.int64 > MODS_FORMAT)
+ {
+ platform_log("mods: %s is made for a later build (format %lld): it is not played", path,
+ (long long)value.u.int64);
+ mod->refusal = "made for a later build";
+ }
+ else if (value.type != TOML_UNKNOWN && value.type != TOML_INT64)
+ {
+ platform_log("mods: %s line %d: format is not a number", path, value.lineno);
+ }
+ for (index = 0; index < result.toptab.u.tab.size; index++)
+ {
+ size_t key;
+
+ for (key = 0; key < sizeof(keys) / sizeof(keys[0]); key++)
+ {
+ if (!strcmp(result.toptab.u.tab.key[index], keys[key]))
+ break;
+ }
+ if (key == sizeof(keys) / sizeof(keys[0]))
+ platform_log("mods: %s line %d: unknown key %s", path, result.toptab.u.tab.value[index].lineno,
+ result.toptab.u.tab.key[index]);
+ }
+ toml_free(result);
+ return 1;
+}
+
+/* the mods in the data root's mods folder, in their folders' order */
+static void mods_find(const char *root)
+{
+ void *directory = posix_directory_open(root);
+ char name[256];
+
+ port_mods.mod_count = 0;
+ if (!directory)
+ return;
+ while (posix_directory_next(directory, name, sizeof(name)))
+ {
+ char path[MAXIMUM_PATH_LENGTH];
+ struct posix_file_information information;
+ struct port_mod mod;
+ long index;
+
+ snprintf(path, sizeof(path), "%s/%s", root, name);
+ if (name[0] == '.' || posix_stat(path, &information) != 0 || !(information.flags & _posix_file_is_directory))
+ continue;
+ if (strlen(name) >= MAXIMUM_MOD_FOLDER_LENGTH)
+ {
+ platform_log("mods: the folder name %s is longer than %d characters: it is not a mod", name,
+ MAXIMUM_MOD_FOLDER_LENGTH - 1);
+ continue;
+ }
+ if (port_mods.mod_count == MAXIMUM_MODS)
+ {
+ platform_log("mods: more than %d folders in %s: %s and those after it are not mods", MAXIMUM_MODS, root,
+ name);
+ break;
+ }
+ if (!mod_read(&mod, root, name))
+ continue;
+ /* (sorted by folder, alphabetically: the default load order, the
+ same on every system) */
+ for (index = port_mods.mod_count; index > 0 && names_compare(port_mods.mods[index - 1].folder, mod.folder) > 0;
+ index--)
+ port_mods.mods[index] = port_mods.mods[index - 1];
+ port_mods.mods[index] = mod;
+ port_mods.mod_count++;
+ }
+ posix_directory_close(directory);
+}
+
+/* the mod `name` names (its id or folder), or -1 */
+static long mod_find(const char *name)
+{
+ long index;
+
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ if (mod_named(&port_mods.mods[index], name))
+ return index;
+ }
+ return -1;
+}
+
+/* whether the mod `first` asks to load before the mod `second`: `first`
+names it in load_before, or `second` names `first` in load_after */
+static int mod_wants_before(long first, long second)
+{
+ const struct port_mod *one = &port_mods.mods[first];
+ const struct port_mod *other = &port_mods.mods[second];
+
+ return first != second && (names_name(&one->load_before, other) || names_name(&other->load_after, one));
+}
+
+/* places `mod` in the default order (order_default), after the mods it
+must load after, placed first, alphabetically */
+static void order_place(long mod, char *state, long *order, long *count, int *ring_reported)
+{
+ long other;
+
+ /* (1 being placed, 2 placed: a mod met again while it is being placed
+ asks in a ring, which is left where it closes) */
+ if (state[mod] == 2)
+ return;
+ if (state[mod] == 1)
+ {
+ if (!*ring_reported)
+ platform_log("mods: %s and others ask to load before and after each other: they load alphabetically",
+ port_mods.mods[mod].folder);
+ *ring_reported = 1;
+ return;
+ }
+ state[mod] = 1;
+ for (other = 0; other < port_mods.mod_count; other++)
+ {
+ if (mod_wants_before(other, mod))
+ order_place(other, state, order, count, ring_reported);
+ }
+ state[mod] = 2;
+ order[(*count)++] = mod;
+}
+
+/* The default load order, into `order`: the mods alphabetically (as they
+are found, mods_find), but with the mods each must load after moved up to
+just before it. Mods that ask in a ring load alphabetically where it
+closes. */
+static void order_default(long *order)
+{
+ char state[MAXIMUM_MODS];
+ long count = 0;
+ long mod;
+ int ring_reported = 0;
+
+ memset(state, 0, sizeof(state));
+ for (mod = 0; mod < port_mods.mod_count; mod++)
+ order_place(mod, state, order, &count, &ring_reported);
+}
+
+static const char *order_file(const char *root, char *path, size_t size)
+{
+ snprintf(path, size, "%s/load_order.toml", root);
+ return path;
+}
+
+/* Gives every mod its place in the load order and whether it is turned off:
+the order load_order.toml keeps, each mod it does not name yet after the
+mod before it in the default order (or first), and the default order with
+no file. */
+static void mods_order(const char *root)
+{
+ char path[MAXIMUM_PATH_LENGTH];
+ long defaults[MAXIMUM_MODS];
+ char placed[MAXIMUM_MODS];
+ struct mod_names kept = { NULL, 0 };
+ struct mod_names disabled = { NULL, 0 };
+ size_t size = 0;
+ char *text;
+ long count = 0;
+ long index;
+
+ order_default(defaults);
+ order_file(root, path, sizeof(path));
+ text = port_mods.unsaved_order ? strdup(port_mods.unsaved_order) : config_file_read(path, &size);
+ if (text && port_mods.unsaved_order)
+ size = strlen(text);
+ if (text)
+ {
+ toml_result_t result = toml_parse(text, (int)size);
+
+ free(text);
+ if (result.ok)
+ {
+ toml_names(result.toptab, "order", path, &kept);
+ toml_names(result.toptab, "disabled", path, &disabled);
+ }
+ else
+ {
+ platform_log("mods: %s: %s; the mods load in their default order", path, result.errmsg);
+ }
+ toml_free(result);
+ }
+ memset(placed, 0, sizeof(placed));
+ for (index = 0; index < kept.count; index++)
+ {
+ long mod = -1;
+ long other;
+
+ for (other = 0; other < port_mods.mod_count && mod < 0; other++)
+ {
+ if (names_equal(port_mods.mods[other].folder, kept.names[index]))
+ mod = other;
+ }
+ if (mod >= 0 && !placed[mod])
+ {
+ placed[mod] = 1;
+ port_mods.ordered[count++] = mod;
+ }
+ }
+ /* (the mods it does not name, where the default order puts them: after
+ the one before them there that is placed, but not after a placed mod
+ they must load before, nor before one they must load after) */
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ long mod = defaults[index];
+ long at = 0;
+ long before;
+ long other;
+
+ if (placed[mod])
+ continue;
+ for (before = index - 1; before >= 0; before--)
+ {
+ if (!placed[defaults[before]])
+ continue;
+ for (other = 0; other < count; other++)
+ {
+ if (port_mods.ordered[other] == defaults[before])
+ at = other + 1;
+ }
+ break;
+ }
+ for (other = 0; other < count; other++)
+ {
+ if (mod_wants_before(mod, port_mods.ordered[other]) && at > other)
+ at = other;
+ }
+ for (other = count - 1; other >= 0; other--)
+ {
+ if (mod_wants_before(port_mods.ordered[other], mod) && at <= other)
+ {
+ at = other + 1;
+ break;
+ }
+ }
+ for (other = count; other > at; other--)
+ port_mods.ordered[other] = port_mods.ordered[other - 1];
+ port_mods.ordered[at] = mod;
+ placed[mod] = 1;
+ count++;
+ }
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ struct port_mod *mod = &port_mods.mods[port_mods.ordered[index]];
+ long other;
+
+ mod->position = index;
+ mod->disabled = 0;
+ for (other = 0; other < disabled.count; other++)
+ {
+ if (names_equal(mod->folder, disabled.names[other]))
+ mod->disabled = 1;
+ }
+ }
+ names_dispose(&kept);
+ names_dispose(&disabled);
+}
+
+/* whether a mod is on (whatever the map) */
+static int mod_on(const struct port_mod *mod)
+{
+ return !mod->disabled && !mod->refusal;
+}
+
+/* the first mod that is on and names `mod` as incompatible, or -1 */
+static long mod_incompatible_with(long mod)
+{
+ long index;
+
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ if (index != mod && mod_on(&port_mods.mods[index]) &&
+ names_name(&port_mods.mods[index].incompatible, &port_mods.mods[mod]))
+ {
+ return index;
+ }
+ }
+ return -1;
+}
+
+/* the first mod that is on that `mod` (on) loads on the wrong side of, as
+it asks itself (load_before, load_after), or -1; *before says which side it
+asks for */
+static long mod_out_of_order_with(long mod, int *before)
+{
+ const struct port_mod *one = &port_mods.mods[mod];
+ long index;
+
+ if (!mod_on(one))
+ return -1;
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ const struct port_mod *other = &port_mods.mods[index];
+
+ if (index == mod || !mod_on(other))
+ continue;
+ if (names_name(&one->load_before, other) && one->position > other->position)
+ {
+ *before = 1;
+ return index;
+ }
+ if (names_name(&one->load_after, other) && one->position < other->position)
+ {
+ *before = 0;
+ return index;
+ }
+ }
+ return -1;
+}
+
+/* a mod's folder name as a TOML basic string's characters, into `text`
+(its control characters escaped, which TOML reads back as they were) */
+static void toml_string_append(char *text, size_t size, const char *name)
+{
+ size_t length = strlen(text);
+
+ for (; *name && length + TOML_CHARACTER_LENGTH < size; name++)
+ {
+ unsigned char character = (unsigned char)*name;
+
+ if (character < 0x20 || character == 0x7F)
+ {
+ snprintf(text + length, size - length, "\\u%04X", character);
+ length += 6;
+ continue;
+ }
+ if (character == '"' || character == '\\')
+ text[length++] = '\\';
+ text[length++] = (char)character;
+ }
+ text[length] = 0;
+}
+
+/* writes `text` to the file `path`, whole: to a file beside it first, which
+then takes its place (so that a write cut short leaves the file as it was) */
+static int text_file_write(const char *path, const char *text)
+{
+ char temporary[MAXIMUM_PATH_LENGTH];
+ int written;
+#ifdef HALO_ARM64_GUEST
+ FILE *file;
+#endif
+
+ if (snprintf(temporary, sizeof(temporary), "%s.new", path) >= (int)sizeof(temporary))
+ return 0;
+#ifdef HALO_ARM64_GUEST
+ /* (the guest's own C library: SDL's are the host's) */
+ file = fopen(temporary, "wb");
+ if (!file)
+ return 0;
+ written = fwrite(text, 1, strlen(text), file) == strlen(text);
+ written = fclose(file) == 0 && written;
+ if (written)
+ written = rename(temporary, path) == 0;
+ if (!written)
+ remove(temporary);
+#else
+ written = SDL_SaveFile(temporary, text, strlen(text));
+ if (written)
+ written = SDL_RenamePath(temporary, path);
+ if (!written)
+ SDL_RemovePath(temporary);
+#endif
+ return written;
+}
+
+static long file_add(long mod, const char *key, const char *path)
+{
+ struct port_mod_file *file;
+
+ if (port_mods.file_count == MAXIMUM_MOD_FILES)
+ {
+ if (!port_mods.full_reported)
+ platform_log("mods: more than %d files: %s and those after it are left out", MAXIMUM_MOD_FILES, path);
+ port_mods.full_reported = 1;
+ return -1;
+ }
+ if (port_mods.file_count == port_mods.file_capacity)
+ {
+ long capacity = port_mods.file_capacity ? port_mods.file_capacity * 2 : 1024;
+ struct port_mod_file *grown = realloc(port_mods.files, (size_t)capacity * sizeof(*grown));
+
+ if (!grown)
+ {
+ platform_log("mods: out of memory for the list of their files");
+ return -1;
+ }
+ port_mods.files = grown;
+ port_mods.file_capacity = capacity;
+ }
+ file = &port_mods.files[port_mods.file_count];
+ file->key = strdup(key);
+ file->path = strdup(path);
+ if (!file->key || !file->path)
+ {
+ free(file->key);
+ free(file->path);
+ platform_log("mods: out of memory for the list of their files");
+ return -1;
+ }
+ file->mod = mod;
+ file->hash = key_hash(key);
+ file->next = port_mods.buckets[file->hash % FILE_BUCKET_COUNT];
+ port_mods.buckets[file->hash % FILE_BUCKET_COUNT] = port_mods.file_count;
+ port_mods.mods[mod].file_count++;
+ return port_mods.file_count++;
+}
+
+/* lists the files of the folder `path`, `key` their tag path's start */
+static void files_list(long mod, const char *path, const char *key, int depth)
+{
+ void *directory;
+ char name[256];
+
+ if (depth > MAXIMUM_FOLDER_DEPTH)
+ {
+ platform_log("mods: %s is more than %d folders deep: its files are left out", path, MAXIMUM_FOLDER_DEPTH);
+ return;
+ }
+ /* (no more once the files or folders are as many as are listed) */
+ if (port_mods.full_reported)
+ return;
+ if (++port_mods.folder_count > MAXIMUM_MOD_FOLDERS)
+ {
+ platform_log("mods: more than %d folders: %s and those after it are left out", MAXIMUM_MOD_FOLDERS, path);
+ port_mods.full_reported = 1;
+ return;
+ }
+ directory = posix_directory_open(path);
+ if (!directory)
+ return;
+ while (posix_directory_next(directory, name, sizeof(name)))
+ {
+ char child_path[MAXIMUM_PATH_LENGTH];
+ char child_key[MAXIMUM_PATH_LENGTH];
+ struct posix_file_information information;
+ int fits;
+
+ if (name[0] == '.')
+ continue;
+ fits = snprintf(child_path, sizeof(child_path), "%s/%s", path, name) < (int)sizeof(child_path) &&
+ snprintf(child_key, sizeof(child_key), "%s%s%s", key, *key ? "\\" : "", name) < (int)sizeof(child_key) &&
+ key_make(child_key, child_key, sizeof(child_key));
+ if (!fits)
+ {
+ platform_log("mods: the path of %s/%s is too long: it is left out", path, name);
+ continue;
+ }
+ if (posix_stat(child_path, &information) != 0)
+ continue;
+ if (information.flags & _posix_file_is_directory)
+ files_list(mod, child_path, child_key, depth + 1);
+ else
+ file_add(mod, child_key, child_path);
+ }
+ posix_directory_close(directory);
+}
+
+static int mod_for_map(const struct port_mod *mod, const char *map_name)
+{
+ long index;
+
+ if (!mod->maps)
+ return 1;
+ for (index = 0; index < mod->map_count; index++)
+ {
+ if (map_name && names_equal(mod->maps[index], map_name))
+ return 1;
+ }
+ return 0;
+}
+
+static const char *mods_root(char *path, size_t size)
+{
+ snprintf(path, size, "%s/mods", platform_data_root());
+ return path;
+}
+
+/* ---------- public code */
+
+long port_mods_scan(void)
+{
+ char root[MAXIMUM_PATH_LENGTH];
+ long mod_index;
+
+ for (mod_index = 0; mod_index < port_mods.mod_count; mod_index++)
+ mod_dispose(&port_mods.mods[mod_index]);
+ files_dispose();
+ port_mods.full_reported = 0;
+ port_mods.folder_count = 0;
+ port_mods.played_count = 0;
+ port_mods.scans++;
+ mods_root(root, sizeof(root));
+ mods_find(root);
+ mods_order(root);
+ for (mod_index = 0; mod_index < port_mods.mod_count; mod_index++)
+ {
+ struct port_mod *mod = &port_mods.mods[mod_index];
+ char path[MAXIMUM_PATH_LENGTH];
+
+ /* (those on play in their places in the load order) */
+ mod->order = mod->disabled || mod->refusal ? -1 : mod->position;
+ if (mod->order < 0)
+ continue;
+ port_mods.played_count++;
+ snprintf(path, sizeof(path), "%s/%s/tags", root, mod->folder);
+ files_list(mod_index, path, "", 0);
+ platform_log("mods: %s (%s%s%s) plays %ld tag files%s", mod->folder, mod->name, mod->version[0] ? " " : "",
+ mod->version, mod->file_count, mod->maps ? " on the maps it names" : "");
+ }
+ if (port_mods.mod_count)
+ {
+ char order[1024] = "";
+
+ for (mod_index = 0; mod_index < port_mods.mod_count; mod_index++)
+ {
+ const struct port_mod *mod = &port_mods.mods[port_mods.ordered[mod_index]];
+ size_t length = strlen(order);
+
+ snprintf(order + length, sizeof(order) - length, "%s%s%s", mod_index ? ", " : "", mod->folder,
+ mod->disabled ? " (off)" : mod->refusal ? " (not played)" : "");
+ }
+ platform_log("mods: load order %s", order);
+ }
+ return port_mods.played_count;
+}
+
+long port_mods_file_find(const char *tag_file, const char *map_name)
+{
+ char key[MAXIMUM_PATH_LENGTH];
+ unsigned long hash;
+ long found = -1;
+ long index;
+
+ if (!port_mods.file_count || !tag_file || !key_make(tag_file, key, sizeof(key)))
+ return -1;
+ hash = key_hash(key);
+ for (index = port_mods.buckets[hash % FILE_BUCKET_COUNT]; index >= 0; index = port_mods.files[index].next)
+ {
+ const struct port_mod_file *file = &port_mods.files[index];
+ const struct port_mod *mod = &port_mods.mods[file->mod];
+
+ if (file->hash == hash && !strcmp(file->key, key) && mod_for_map(mod, map_name) &&
+ (found < 0 || mod->order > port_mods.mods[port_mods.files[found].mod].order))
+ {
+ found = index;
+ }
+ }
+ return found;
+}
+
+int port_mods_file_stamp(long file, unsigned long *size, unsigned long stamp[2])
+{
+ struct posix_file_information information;
+
+ if (file < 0 || file >= port_mods.file_count || posix_stat(port_mods.files[file].path, &information) != 0 ||
+ information.size_high)
+ {
+ return 0;
+ }
+ *size = information.size_low;
+ stamp[0] = information.modification_seconds;
+ stamp[1] = information.modification_nanoseconds;
+ return 1;
+}
+
+int port_mods_file_read(long file, unsigned long offset, void *buffer, unsigned long size)
+{
+ const char *path;
+ int bounce = platform_is_contiguous(buffer);
+ unsigned char *staging = NULL;
+ unsigned long done = 0;
+ int read = 1;
+#ifdef HALO_ARM64_GUEST
+ FILE *stream;
+#else
+ SDL_IOStream *stream;
+#endif
+
+ if (file < 0 || file >= port_mods.file_count)
+ return 0;
+ path = port_mods.files[file].path;
+ if (bounce && !(staging = malloc(READ_BOUNCE_SIZE)))
+ return 0;
+#ifdef HALO_ARM64_GUEST
+ stream = fopen(path, "rb");
+ if (!stream || fseek(stream, (long)offset, SEEK_SET) != 0)
+ read = 0;
+#else
+ /* (SDL's, for UTF-8 paths on Windows) */
+ stream = SDL_IOFromFile(path, "rb");
+ if (!stream || SDL_SeekIO(stream, (Sint64)offset, SDL_IO_SEEK_SET) < 0)
+ read = 0;
+#endif
+ while (read && done < size)
+ {
+ unsigned long wanted = size - done;
+ unsigned char *destination = (unsigned char *)buffer + done;
+
+ if (bounce && wanted > READ_BOUNCE_SIZE)
+ wanted = READ_BOUNCE_SIZE;
+#ifdef HALO_ARM64_GUEST
+ read = fread(bounce ? staging : destination, 1, wanted, stream) == wanted;
+#else
+ read = SDL_ReadIO(stream, bounce ? staging : destination, wanted) == wanted;
+#endif
+ if (read && bounce)
+ {
+ memory_watch_prepare_write(destination, wanted);
+ memcpy(destination, staging, wanted);
+ }
+ done += wanted;
+ }
+ if (stream)
+ {
+#ifdef HALO_ARM64_GUEST
+ fclose(stream);
+#else
+ SDL_CloseIO(stream);
+#endif
+ }
+ free(staging);
+ return read;
+}
+
+long port_mods_file_order(long file)
+{
+ return file >= 0 && file < port_mods.file_count ? port_mods.mods[port_mods.files[file].mod].order : -1;
+}
+
+const char *port_mods_file_mod(long file)
+{
+ return file >= 0 && file < port_mods.file_count ? port_mods.mods[port_mods.files[file].mod].folder : "";
+}
+
+const char *port_mods_file_path(long file)
+{
+ return file >= 0 && file < port_mods.file_count ? port_mods.files[file].path : "";
+}
+
+long port_mods_count(void)
+{
+ return port_mods.mod_count;
+}
+
+const char *port_mods_folder(long index)
+{
+ return index >= 0 && index < port_mods.mod_count ? port_mods.mods[index].folder : "";
+}
+
+int port_mods_describe(long index, char *text, unsigned long size, const char *map_name)
+{
+ const struct port_mod *mod;
+
+ if (index < 0 || index >= port_mods.mod_count)
+ return 0;
+ mod = &port_mods.mods[index];
+ snprintf(text, size, "%s: %s%s%s%s%s, %s%s%s", mod->folder, mod->name, mod->version[0] ? " " : "",
+ mod->version, mod->authors[0] ? " by " : "", mod->authors,
+ mod->refusal ? "not played: " : mod->disabled ? "off" : !mod_for_map(mod, map_name) ? "on, not for this map" :
+ "on",
+ mod->refusal ? mod->refusal : "",
+ mod->order < 0 || mod->file_count ? "" : " (no tags folder, or nothing in it)");
+ return 1;
+}
+
+unsigned long port_mods_scans(void)
+{
+ return port_mods.scans;
+}
+
+long port_mods_played(void)
+{
+ return port_mods.played_count;
+}
+
+long port_mods_at(long position)
+{
+ return position >= 0 && position < port_mods.mod_count ? port_mods.ordered[position] : -1;
+}
+
+const char *port_mods_name(long index)
+{
+ return index >= 0 && index < port_mods.mod_count ? port_mods.mods[index].name : "";
+}
+
+int port_mods_on(long index)
+{
+ return index >= 0 && index < port_mods.mod_count && mod_on(&port_mods.mods[index]);
+}
+
+int port_mods_status(long index, char *text, unsigned long size)
+{
+ const struct port_mod *mod;
+ long other;
+ int before = 0;
+
+ if (text && size)
+ text[0] = 0;
+ if (index < 0 || index >= port_mods.mod_count)
+ return _port_mod_fine;
+ mod = &port_mods.mods[index];
+ if (mod->refusal)
+ {
+ if (text)
+ snprintf(text, size, "It cannot be played: %s.", mod->refusal);
+ return _port_mod_unplayable;
+ }
+ if ((other = mod_incompatible_with(index)) >= 0)
+ {
+ if (text)
+ snprintf(text, size, "%s says it cannot be played with this mod.", port_mods.mods[other].name);
+ return _port_mod_incompatible;
+ }
+ if ((other = mod_out_of_order_with(index, &before)) >= 0)
+ {
+ /* (as the Mods menu shows them: the mod loaded last at the top) */
+ if (text)
+ snprintf(text, size, "It asks to be %s %s, but is %s it.", before ? "below" : "above",
+ port_mods.mods[other].name, before ? "above" : "below");
+ return _port_mod_out_of_order;
+ }
+ return _port_mod_fine;
+}
+
+int port_mods_details(long index, char *text, unsigned long size)
+{
+ const struct port_mod *mod;
+ size_t length;
+ long map;
+
+ if (!text || !size)
+ return 0;
+ text[0] = 0;
+ if (index < 0 || index >= port_mods.mod_count)
+ return 0;
+ mod = &port_mods.mods[index];
+ if (mod->version[0])
+ snprintf(text, size, "Version %s\n", mod->version);
+ length = strlen(text);
+ if (mod->authors[0])
+ snprintf(text + length, size - length, "By %s\n", mod->authors);
+ length = strlen(text);
+ snprintf(text + length, size - length, "Folder: %s\n", mod->folder);
+ length = strlen(text);
+ if (mod->maps)
+ {
+ snprintf(text + length, size - length, "For:");
+ for (map = 0; map < mod->map_count; map++)
+ {
+ length = strlen(text);
+ snprintf(text + length, size - length, "%s %s", map ? "," : "", mod->maps[map]);
+ }
+ length = strlen(text);
+ snprintf(text + length, size - length, "\n");
+ }
+ length = strlen(text);
+ if (mod->description[0])
+ snprintf(text + length, size - length, "\n%s", mod->description);
+ return 1;
+}
+
+long port_mods_move(long position, long places)
+{
+ long count = port_mods.mod_count;
+ long target = position + places;
+ long mod;
+ long index;
+
+ if (position < 0 || position >= count)
+ return position;
+ if (target < 0)
+ target = 0;
+ if (target >= count)
+ target = count - 1;
+ mod = port_mods.ordered[position];
+ if (target > position)
+ {
+ for (index = position; index < target; index++)
+ port_mods.ordered[index] = port_mods.ordered[index + 1];
+ }
+ else
+ {
+ for (index = position; index > target; index--)
+ port_mods.ordered[index] = port_mods.ordered[index - 1];
+ }
+ port_mods.ordered[target] = mod;
+ for (index = 0; index < count; index++)
+ port_mods.mods[port_mods.ordered[index]].position = index;
+ return target;
+}
+
+void port_mods_turn(long index, int on)
+{
+ if (index >= 0 && index < port_mods.mod_count)
+ port_mods.mods[index].disabled = !on;
+}
+
+int port_mods_save(void)
+{
+ char root[MAXIMUM_PATH_LENGTH];
+ char path[MAXIMUM_PATH_LENGTH];
+ size_t size = 256 + (size_t)port_mods.mod_count * 2 * (TOML_CHARACTER_LENGTH * MAXIMUM_MOD_FOLDER_LENGTH + 4);
+ char *text = malloc(size);
+ long index;
+ int first = 1;
+ int written;
+
+ if (!text)
+ return 0;
+ snprintf(text, size, "# The mods' load order (the first loads first, and each plays over those\n"
+ "# before it: the Mods menu shows it the other way up), and the mods turned\n"
+ "# off. The Mods menu writes this.\norder = [");
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ strcat(text, index ? ", \"" : "\"");
+ toml_string_append(text, size, port_mods.mods[port_mods.ordered[index]].folder);
+ strcat(text, "\"");
+ }
+ strcat(text, "]\ndisabled = [");
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ const struct port_mod *mod = &port_mods.mods[port_mods.ordered[index]];
+
+ if (!mod->disabled)
+ continue;
+ strcat(text, first ? "\"" : ", \"");
+ toml_string_append(text, size, mod->folder);
+ strcat(text, "\"");
+ first = 0;
+ }
+ strcat(text, "]\n");
+ posix_make_directory(mods_root(root, sizeof(root)));
+ written = text_file_write(order_file(root, path, sizeof(path)), text);
+ /* (kept in memory when it cannot be written, so that the mods are still
+ played as chosen until the game quits) */
+ free(port_mods.unsaved_order);
+ port_mods.unsaved_order = NULL;
+ if (!written)
+ {
+ platform_log("mods: cannot write %s: the order and the mods turned off last until the game quits", path);
+ port_mods.unsaved_order = text;
+ return 0;
+ }
+ free(text);
+ return 1;
+}
+
+void port_mods_revert(void)
+{
+ char root[MAXIMUM_PATH_LENGTH];
+
+ mods_order(mods_root(root, sizeof(root)));
+}
+
+int port_mods_conflicts(char *text, unsigned long size)
+{
+ long index;
+ long count = 0;
+
+ if (!size)
+ text = NULL;
+ if (text)
+ text[0] = 0;
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ long other = mod_on(&port_mods.mods[index]) ? mod_incompatible_with(index) : -1;
+
+ if (other < 0)
+ continue;
+ if (text)
+ {
+ size_t length = strlen(text);
+
+ snprintf(text + length, size - length, "%s%s says it cannot be played with %s.", count ? "\n" : "",
+ port_mods.mods[other].name, port_mods.mods[index].name);
+ }
+ count++;
+ }
+ if (text)
+ utf8_trim(text);
+ return (int)count;
+}
+
+int port_mods_conflicting_names(char *text, unsigned long size)
+{
+ long index;
+ long count = 0;
+
+ if (!size)
+ text = NULL;
+ if (text)
+ text[0] = 0;
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ if (!mod_on(&port_mods.mods[index]) || mod_incompatible_with(index) < 0)
+ continue;
+ if (text)
+ {
+ size_t length = strlen(text);
+
+ snprintf(text + length, size - length, "%s%s", count ? ", " : "", port_mods.mods[index].name);
+ }
+ count++;
+ }
+ if (text)
+ utf8_trim(text);
+ return (int)count;
+}
+
+void port_mods_disable_conflicting(void)
+{
+ char marked[MAXIMUM_MODS];
+ long index;
+
+ /* (all of those named at once: turning one off may end what named
+ another) */
+ for (index = 0; index < port_mods.mod_count; index++)
+ marked[index] = mod_on(&port_mods.mods[index]) && mod_incompatible_with(index) >= 0;
+ for (index = 0; index < port_mods.mod_count; index++)
+ {
+ if (marked[index])
+ {
+ platform_log("mods: %s is turned off: a mod says it cannot be played with it", port_mods.mods[index].folder);
+ port_mods.mods[index].disabled = 1;
+ }
+ }
+}
+
+void port_mods_startup(void)
+{
+ char conflicts[2048];
+ char names[256];
+ char message[2400];
+ char disable[96];
+ const char *labels[3];
+ int answer;
+
+ port_mods_scan();
+ if (!port_mods_conflicts(conflicts, sizeof(conflicts)))
+ return;
+ port_mods_conflicting_names(names, sizeof(names));
+ platform_log("mods: %s", conflicts);
+ /* (not for runs nobody is watching, as platform_show_message's
+ messages are not: they play them) */
+ if (config_boolean("debug.hidden_window") || config_boolean("debug.null_renderer") ||
+ config_real("debug.exit_after") > 0.0 || *config_string("debug.network_test"))
+ {
+ platform_log("mods: played as they are, nobody being asked");
+ return;
+ }
+ snprintf(message, sizeof(message),
+ "Mods in the mods folder say they cannot be played together:\n\n%s\n\n"
+ "Play them all anyway, turn off %s, or quit? (The main menu's Mods turns mods on and off.)",
+ conflicts, names);
+ snprintf(disable, sizeof(disable), "Turn off %s", names);
+ /* (a long list of names is cut short on its button, at a whole
+ character) */
+ if (strlen(disable) > 40)
+ {
+ disable[37] = 0;
+ utf8_trim(disable);
+ strcat(disable, "...");
+ }
+ labels[0] = "Play anyway";
+ labels[1] = disable;
+ labels[2] = "Quit";
+ answer = platform_ask("Halo: mods that cannot be played together", message, labels, 3, 0);
+ if (answer == 1)
+ {
+ /* (played so even when load_order.toml cannot be written:
+ port_mods_save keeps it) */
+ port_mods_disable_conflicting();
+ port_mods_save();
+ port_mods_scan();
+ }
+ else if (answer == 2)
+ {
+ platform_log("mods: quitting, as asked, over mods that cannot be played together");
+#ifdef HALO_WEB
+ /* (the page starts again: a worker's exit does not end the page's
+ game) */
+ web_js_post(4, "quit");
+#endif
+ exit(EXIT_SUCCESS);
+ }
+ else if (answer < 0)
+ {
+ platform_log("mods: cannot ask (%s): played all anyway", SDL_GetError());
+ }
+ else
+ {
+ platform_log("mods: played all anyway");
+ }
+}
diff --git a/port/linux/src/port_mods.h b/port/linux/src/port_mods.h
new file mode 100644
index 0000000000..0b2cefc846
--- /dev/null
+++ b/port/linux/src/port_mods.h
@@ -0,0 +1,93 @@
+/*
+PORT_MODS.H
+
+The mods in the data root's mods folder and their tag files (port_mods.c),
+which the game plays over a map's own (port/linux/game/mods.c, which
+declares these for itself: keep the two the same).
+*/
+
+#ifndef PORT_MODS_H
+#define PORT_MODS_H
+
+/* finds the mods again, and lists the files of those that are on: how
+many are on. Every file index from before is gone */
+long port_mods_scan(void);
+/* how many times the mods have been found (port_mods_scan) */
+unsigned long port_mods_scans(void);
+/* how many mods the last port_mods_scan found that are on */
+long port_mods_played(void);
+
+/* the file a tag's path names (its name and extension: "sound\x.sound",
+any case, either slash) of the mods played on the map `map_name` (its
+file's name: "a10"), the one loaded last; -1 if none has it */
+long port_mods_file_find(const char *tag_file, const char *map_name);
+/* a file's size and modification time now; 0 if it has gone */
+int port_mods_file_stamp(long file, unsigned long *size, unsigned long stamp[2]);
+/* reads size bytes of a file at offset into buffer (the Xbox's memory too);
+0 unless all of them were read */
+int port_mods_file_read(long file, unsigned long offset, void *buffer, unsigned long size);
+/* the place in the order the mods' files were listed in of a file's mod
+(later plays over earlier), or -1 */
+long port_mods_file_order(long file);
+/* the folder name of a file's mod, and the file's path (for messages) */
+const char *port_mods_file_mod(long file);
+const char *port_mods_file_path(long file);
+
+/* the mods found, played or not, the folder of each (in the folders' order),
+and a line about each for the console */
+long port_mods_count(void);
+const char *port_mods_folder(long index);
+int port_mods_describe(long index, char *text, unsigned long size, const char *map_name);
+
+/* ---------- the load order (the Mods menu's)
+
+What these change is the mods' state as port_mods_save keeps it: the files
+played follow it at the next port_mods_scan. */
+
+/* what is wrong with a mod, worst first (port_mods_status) */
+enum
+{
+ _port_mod_fine,
+ /* it loads on the wrong side of a mod that is on, as it asks itself */
+ _port_mod_out_of_order,
+ /* a mod that is on says it cannot be played with it */
+ _port_mod_incompatible,
+ /* it cannot be played (made for a later build) */
+ _port_mod_unplayable,
+};
+
+/* the mod at a place in the load order (0 loads first), or -1 */
+long port_mods_at(long position);
+/* a mod's name (its mod.toml's, else its folder's), and whether it is on */
+const char *port_mods_name(long index);
+int port_mods_on(long index);
+/* what is wrong with a mod, and a line saying so into text (else empty) */
+int port_mods_status(long index, char *text, unsigned long size);
+/* what its mod.toml says of a mod, a line each (its version, authors,
+folder and maps), then its description; 0 if there is no such mod */
+int port_mods_details(long index, char *text, unsigned long size);
+/* moves the mod at `position` by `places` (later: positive): its place now */
+long port_mods_move(long position, long places);
+/* turns a mod on or off */
+void port_mods_turn(long index, int on);
+/* keeps the load order and the mods turned off in the mods folder's
+load_order.toml; 0 if it cannot be written */
+int port_mods_save(void);
+/* the load order and the mods turned off as load_order.toml keeps them */
+void port_mods_revert(void);
+
+/* The mods that are on and that other mods that are on say they cannot be
+played with: how many, and a line for each into text ("A says it cannot be
+played with B."), and their names into names. port_mods_disable_conflicting
+turns them off (port_mods_save keeps it). */
+int port_mods_conflicts(char *text, unsigned long size);
+int port_mods_conflicting_names(char *text, unsigned long size);
+void port_mods_disable_conflicting(void);
+
+/* At startup (platform_sdl_initialize, before the window opens): finds the
+mods, and when some that are on say they cannot be played with others that
+are on, asks the player whether to play them all anyway, turn the others
+off (and keep that), or quit. */
+void port_mods_startup(void);
+
+#endif
diff --git a/port/linux/src/sdl_platform.c b/port/linux/src/sdl_platform.c
index 7384b6040d..88f19af659 100644
--- a/port/linux/src/sdl_platform.c
+++ b/port/linux/src/sdl_platform.c
@@ -14,6 +14,7 @@ and the debug keyboard that the game's console reads.
#include "gl.h"
#include "port_config.h"
#include "p2p.h"
+#include "port_mods.h"
#include "xiso.h"
#include "touch_input.h"
@@ -143,6 +144,9 @@ BOOL platform_sdl_initialize(void)
/* (a new version looked for meanwhile, updater_poll asking about it) */
updater_start();
#endif
+ /* (the mods, and whether to play those that say they cannot be played
+ together, before the window opens: port_mods.c) */
+ port_mods_startup();
return TRUE;
}
@@ -1237,6 +1241,60 @@ void platform_show_message(const char *title, const char *message)
pthread_mutex_unlock(&platform_message_lock);
}
+#ifdef HALO_WEB
+/* web_library.js's: the page asks, and writes the answer in web_shared's
+_web_answer */
+void web_js_ask(const char *title, const char *text, const char *labels);
+#endif
+
+int platform_ask(const char *title, const char *text, const char *const *labels, int count, int default_index)
+{
+#ifdef HALO_WEB
+ char joined[512] = "";
+ int index;
+
+ /* (the page's dialog: the game's thread waits for the player there, as
+ SDL's box would, which a page's worker cannot show) */
+ for (index = 0; index < count; index++)
+ {
+ size_t length = strlen(joined);
+
+ snprintf(joined + length, sizeof(joined) - length, "%s%s", index ? "\n" : "", labels[index]);
+ }
+ __atomic_store_n(&web_shared[_web_answer], -1, __ATOMIC_SEQ_CST);
+ web_js_ask(title, text, joined);
+ while (__atomic_load_n(&web_shared[_web_answer], __ATOMIC_SEQ_CST) < 0)
+ SDL_Delay(50);
+ (void)default_index;
+ return __atomic_load_n(&web_shared[_web_answer], __ATOMIC_SEQ_CST);
+#else
+ SDL_MessageBoxButtonData buttons[3];
+ SDL_MessageBoxData question;
+ int answer = -1;
+ int index;
+
+ if (count > 3)
+ count = 3;
+ for (index = 0; index < count; index++)
+ {
+ buttons[index].flags = index == default_index ?
+ SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT | SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT : 0;
+ buttons[index].buttonID = index;
+ buttons[index].text = labels[index];
+ }
+ question.flags = SDL_MESSAGEBOX_WARNING;
+ question.window = NULL;
+ question.title = title;
+ question.message = text;
+ question.numbuttons = count;
+ question.buttons = buttons;
+ question.colorScheme = NULL;
+ if (!SDL_ShowMessageBox(&question, &answer))
+ return -1;
+ return answer;
+#endif
+}
+
static void platform_show_pending_message(void)
{
char title[sizeof(platform_message_title)];
diff --git a/port/linux/src/text_hires.c b/port/linux/src/text_hires.c
index b0603116aa..e11517b06f 100644
--- a/port/linux/src/text_hires.c
+++ b/port/linux/src/text_hires.c
@@ -83,7 +83,7 @@ static int names_differ(const char *a, const char *b)
return tolower((unsigned char)*a) != tolower((unsigned char)*b);
}
-static int text_enabled(void)
+int text_hires_enabled(void)
{
static int enabled;
static unsigned long read_at = (unsigned long)-1;
@@ -131,7 +131,7 @@ long text_hires_font(char const *tag_name, float cap_height, short const *letter
int x0, y0, x1, y1;
int letter;
- if (!text_enabled() || !tag_name || cap_height <= 0.0f || !letter_widths)
+ if (!text_hires_enabled() || !tag_name || cap_height <= 0.0f || !letter_widths)
return -1;
/* (text is never drawn with fewer pixels than the display's) */
if (oversample < 1.0f)
diff --git a/port/linux/src/text_hires.h b/port/linux/src/text_hires.h
index c4afddd765..4512d28f74 100644
--- a/port/linux/src/text_hires.h
+++ b/port/linux/src/text_hires.h
@@ -38,6 +38,9 @@ struct text_hires_glyph
/* the letters a font is fitted to its tag by: A to Z, then a to z */
#define TEXT_HIRES_FIT_LETTERS 52
+/* The committed setting; checked before looking up cached font choices. */
+int text_hires_enabled(void);
+
/* the font standing for a font tag, sized so that its capitals are
cap_height units tall and narrowed where its letters are wider than the
tag's letter_widths (TEXT_HIRES_FIT_LETTERS of them, 0 for a letter the tag
diff --git a/port/linux/src/voice_audio.c b/port/linux/src/voice_audio.c
index 297242fa4a..8a9e26a258 100644
--- a/port/linux/src/voice_audio.c
+++ b/port/linux/src/voice_audio.c
@@ -395,6 +395,10 @@ void voice_audio_play(int speaker, unsigned short sequence, const unsigned char
if (length <= 0 || length > VOICE_MAXIMUM_PACKET)
return;
+ /* (every build sends a frame of 20 ms: anything else, damaged, is
+ dropped before the decoder sees it, and made up for as lost) */
+ if (opus_packet_get_nb_samples(packet, length, VOICE_RATE) != VOICE_FRAME_SAMPLES)
+ return;
pthread_mutex_lock(&voice_lock);
slot = voice_slot_for(speaker, TRUE);
if (!slot)
diff --git a/port/linux/src/xbox_textures.c b/port/linux/src/xbox_textures.c
index 9db40e1014..af7b402877 100644
--- a/port/linux/src/xbox_textures.c
+++ b/port/linux/src/xbox_textures.c
@@ -710,9 +710,12 @@ static void upload(GLuint texture, GLenum target, const struct xgpu_texture_desc
decode_compressed = description->compressed && !xgpu_capabilities.s3tc;
#endif
#ifdef HALO_WEB
- /* WebGL's S3TC takes 2D textures whose sides are multiples of 4 */
+ /* WebGL's S3TC takes 2D textures whose sides are multiples of 4; and a
+ texture whose channels are where Halo PC keeps them is decoded, to put
+ them in order (WebGL 2 has no texture swizzle) */
decode_compressed |= description->compressed &&
- (target == GL_TEXTURE_3D || (description->width & 3) || (description->height & 3));
+ (target == GL_TEXTURE_3D || (description->width & 3) || (description->height & 3) ||
+ channel_order != _custom_edition_channels_xbox);
#endif
converted = description->compressed && !decode_compressed ? NULL : malloc(largest * sizeof(unsigned long));
if (!converted && !(description->compressed && !decode_compressed))
@@ -726,7 +729,8 @@ static void upload(GLuint texture, GLenum target, const struct xgpu_texture_desc
/* the channel of the texels each channel is sampled from, set on every
upload: a texture object can be reused for other texels (WebGL 2 has no
texture swizzle: the converted texels' channels are put in order before
- they are uploaded instead, and Custom Edition maps do not run there) */
+ they are uploaded instead, below; there it is only mods' textures, as
+ Custom Edition maps do not run there) */
#ifndef HALO_WEB
{
GLint channels[4] = { GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA };
@@ -794,14 +798,32 @@ static void upload(GLuint texture, GLenum target, const struct xgpu_texture_desc
}
#ifdef HALO_WEB
{
- /* BGRA (32-bit ARGB words) to the RGBA WebGL takes */
+ /* BGRA (32-bit ARGB words) to the RGBA WebGL takes, each
+ channel from where the texels keep it */
unsigned long texel, count = (unsigned long)width * (unsigned long)height * (unsigned long)depth;
for (texel = 0; texel < count; texel++)
{
unsigned long value = converted[texel];
- converted[texel] = (value & 0xff00ff00UL) | ((value >> 16) & 0xffUL) | ((value & 0xffUL) << 16);
+ if (channel_order == _custom_edition_channels_xbox)
+ {
+ converted[texel] = (value & 0xff00ff00UL) | ((value >> 16) & 0xffUL) | ((value & 0xffUL) << 16);
+ }
+ else
+ {
+ /* (red, green, blue and alpha, as stored) */
+ unsigned long stored[4];
+ unsigned long channel;
+
+ stored[0] = (value >> 16) & 0xffUL;
+ stored[1] = (value >> 8) & 0xffUL;
+ stored[2] = value & 0xffUL;
+ stored[3] = (value >> 24) & 0xffUL;
+ converted[texel] = 0;
+ for (channel = 0; channel < 4; channel++)
+ converted[texel] |= stored[custom_edition_channel_sources[channel_order][channel]] << (8 * channel);
+ }
}
}
#endif
@@ -831,6 +853,9 @@ struct texture_entry
unsigned long last_used_frame;
/* the high-res HUD texture drawn in its place (hud_hires.h), or -1 */
long override;
+ GLuint hud_upscale;
+ unsigned long hud_upscale_levels;
+ BOOL hud_upscale_checked;
/* the newest generation of its pages (memory_watch_generation) as of the
memory watch serial read before it was found: the same while no watched
page has been written since (0: never found) */
@@ -934,6 +959,38 @@ static GLuint texture_entry_result(struct texture_entry *entry, GLenum *target,
return texture;
}
}
+ /* These sheets keep the map's art and coordinates. Decode through the
+ * normal format reader; the HUD module owns only the interpolation. */
+ if (config_boolean("display.high_res_hud"))
+ {
+ if (!entry->hud_upscale_checked)
+ {
+ long kind = hud_hires_upscale_at(entry->address, entry->description.width, entry->description.height);
+
+ entry->hud_upscale_checked = TRUE;
+ if (entry->size && !entry->description.compressed && entry->target == GL_TEXTURE_2D &&
+ format_information(entry->description.format).kind != _texel_p8 &&
+ entry->description.width <= 256 && entry->description.height <= 256 &&
+ kind &&
+ custom_edition_texels_order(entry->address) == _custom_edition_channels_xbox &&
+ platform_is_contiguous((void *)entry->address) &&
+ platform_is_contiguous((void *)(entry->address + entry->size - 1)))
+ {
+ unsigned long *pixels = malloc(entry->description.width * entry->description.height * 4);
+
+ if (pixels && decode_level(&entry->description, 0, (const unsigned char *)entry->address, NULL, pixels))
+ entry->hud_upscale = hud_hires_upscale_texture((const unsigned char *)pixels,
+ entry->description.width, entry->description.height, kind, &entry->hud_upscale_levels);
+ free(pixels);
+ }
+ }
+ if (entry->hud_upscale)
+ {
+ description->hires = TRUE;
+ description->levels = entry->hud_upscale_levels;
+ return entry->hud_upscale;
+ }
+ }
return entry->texture;
}
@@ -1043,6 +1100,13 @@ GLuint xgpu_texture_get(const DWORD *resource, const D3DCOLOR *palette, GLenum *
if (!entry->generation)
entry->generation = 1;
/* (which bitmap is here may have changed with the pixels) */
+ if (entry->hud_upscale)
+ {
+ glDeleteTextures(1, &entry->hud_upscale);
+ entry->hud_upscale = 0;
+ xgpu_gl_state_invalidate();
+ }
+ entry->hud_upscale_checked = FALSE;
entry->override = -1;
if (entry->size && !palettized && !entry->description.cube_map && entry->description.depth == 1)
{
@@ -1092,7 +1156,26 @@ GLuint xgpu_texture_get(const DWORD *resource, const D3DCOLOR *palette, GLenum *
void xgpu_texture_cache_begin_frame(void)
{
unsigned long index;
+ unsigned long changed = hud_hires_settings_changed();
+ /* Refresh only map bitmaps belonging to the setting that changed. Keep
+ * decoded replacement art and world textures; rebuild native pixels on
+ * demand too, since an override may have skipped their first upload. */
+ if (changed)
+ {
+ for (index = 0; index < TEXTURE_BUCKET_COUNT; index++)
+ {
+ struct texture_entry *entry;
+
+ for (entry = texture_buckets[index]; entry; entry = entry->next)
+ if (hud_hires_bitmap_affected(entry->address,
+ (long)entry->description.width, (long)entry->description.height, changed))
+ entry->generation = 0;
+ }
+ /* Recent hits bypass the generation check. Forget lookups, not textures. */
+ memset(recent_textures, 0, sizeof(recent_textures));
+ xgpu_gl_state_invalidate();
+ }
texture_frame++;
if (texture_frame % 600)
return;
@@ -1109,6 +1192,8 @@ void xgpu_texture_cache_begin_frame(void)
{
*link = entry->next;
glDeleteTextures(1, &entry->texture);
+ if (entry->hud_upscale)
+ glDeleteTextures(1, &entry->hud_upscale);
xgpu_gl_state_invalidate();
texture_drop_serial++;
free(entry);
diff --git a/port/web/README.md b/port/web/README.md
index d68820d226..fa359ecceb 100644
--- a/port/web/README.md
+++ b/port/web/README.md
@@ -40,6 +40,14 @@ and `foot` signatures, the Xbox cache version and the build of a retail disc,
`01.10.12.2276`, or of the decompilation, `01.01.14.2342`). A disc image is
untrusted input: the reader checks every offset and size in it.
+Mods (`docs/mods.md`): under **Mods**, **Add a mod folder…** copies a mod's
+`mod.toml` and `tags` folder into the browser's storage (`mods/`, which the
+game reads as a native build reads the data root's), replacing a mod of the
+same folder's name, and **Remove** deletes one. The folder's paths are
+checked as it is copied (`mods-worker.js`). Every mod is on; the game's main
+menu, Mods, orders them and turns them off. The game asks in a dialog of the
+page when mods say they cannot be played together (`web_js_ask`).
+
The page asks the browser to keep the site's data. A browser that is short
of space may delete it otherwise; **Settings and data** says which. The
storage belongs to the site's address: `halocombatevolved.com` and the
@@ -395,7 +403,7 @@ a first visit downloads about 10 MB, compressed).
| `port/web/src/web_stubs.c` | UPnP and an MSVC intrinsic |
| `port/web/src/web_shared.h`, `web_touch.c` | The words of memory the page reads and writes: frames shown, the touch controls' state |
| `port/web/src/web_library.js`, `web_pre.js` | The JavaScript halves: messages to the page, the brokers' WebSockets, the workers' errors |
-| `port/web/site/` | The page (`index.html`, `app.js`, `style.css`), the touch controls (`touch.js`), rooms (`net.js`), internet play's WebRTC (`p2p.js`), its service worker (`sw.js`), the importer (`xiso-worker.js`), the web app's manifest and icons |
+| `port/web/site/` | The page (`index.html`, `app.js`, `style.css`), the touch controls (`touch.js`), rooms (`net.js`), internet play's WebRTC (`p2p.js`), its service worker (`sw.js`), the importers (`xiso-worker.js`, the maps; `mods-worker.js`, the mods), the web app's manifest and icons |
| `port/web/stamp_version.py`, `licenses.py` | `version.json` and `licenses.txt` |
| `port/web/tests/` | The tests above |
| `tools/web_serve.py` | A local server for the site |
diff --git a/port/web/licenses.py b/port/web/licenses.py
index b5e2d6df72..e8ef775c67 100644
--- a/port/web/licenses.py
+++ b/port/web/licenses.py
@@ -18,14 +18,14 @@
("zlib", "port/third_party/zlib/LICENSE"),
("KCP (internet play's streams)", "port/third_party/kcp/LICENSE"),
("Monocypher (internet play's signatures)", "port/third_party/monocypher/LICENCE.md"),
- ("tomlc17 (config.toml)", "port/third_party/tomlc17/LICENSE"),
+ ("tomlc17 (config.toml, mod.toml, load_order.toml)", "port/third_party/tomlc17/LICENSE"),
("musl (the maths functions)", "port/third_party/musl-math/COPYRIGHT"),
- ("stb_vorbis (loose sounds)", "port/third_party/stb_vorbis/LICENSE"),
+ ("stb_vorbis (Custom Edition maps' and mods' sounds)", "port/third_party/stb_vorbis/LICENSE"),
("extract-xiso (how the disc image is read)", "port/third_party/extract-xiso/LICENSE.TXT"),
("Overpass (the menus' text and the page's)", "port/assets/fonts/Overpass-OFL.txt"),
("OpenCE (the title font, from Newtown)", "port/assets/fonts/OpenCE-OFL.txt"),
("Newtown", "port/assets/fonts/Newtown-LICENSE.txt"),
- ("Lucide (voice chat's speaker icons)", "port/assets/icons/lucide/LICENSE"),
+ ("Lucide (voice chat's speaker icons, the Mods menu's warning icons)", "port/assets/icons/lucide/LICENSE"),
]
diff --git a/port/web/site/app.js b/port/web/site/app.js
index 860b5869aa..5b1a45bd13 100644
--- a/port/web/site/app.js
+++ b/port/web/site/app.js
@@ -2,8 +2,8 @@
APP.JS
The launcher page (index.html): it checks that the browser can run the game,
-copies the game data in from the player's disc image (xiso-worker.js), and
-starts the game (halo.js and halo.wasm: tools/web_build.py), whose canvas
+copies the game data in from the player's disc image (xiso-worker.js) and
+the player's mods (mods-worker.js), and starts the game (halo.js and halo.wasm: tools/web_build.py), whose canvas
then fills the page.
- The game's threads need SharedArrayBuffer, so a cross-origin isolated
@@ -301,6 +301,92 @@ $('folder-input').addEventListener('change', (event) => {
if (files.length) importData({ type: 'folder', files });
});
+// ---------- mods (mods-worker.js): /data/mods, all of them played unless
+// the game's main menu's Mods turns them off (port/linux/src/port_mods.c)
+
+// the name a mod's mod.toml gives it, else its folder's
+async function modName(directory, folder) {
+ try {
+ const text = await (await (await directory.getFileHandle('mod.toml')).getFile()).text();
+ const match = /^\s*name\s*=\s*"([^"\n]*)"/m.exec(text);
+ if (match && match[1].trim()) return match[1].trim();
+ } catch {
+ // (none: not a mod the game plays)
+ }
+ return folder;
+}
+
+async function refreshMods() {
+ const list = $('mods-list');
+ const found = [];
+ try {
+ const root = await navigator.storage.getDirectory();
+ const mods = await root.getDirectoryHandle('mods');
+ for await (const [folder, handle] of mods.entries()) {
+ if (handle.kind === 'directory') found.push({ folder, name: await modName(handle, folder) });
+ }
+ } catch {
+ // no mods folder yet
+ }
+ // (as the game orders them by default: their folders' names, whatever the
+ // case of their A to Z, port_mods.c's names_compare)
+ const key = (folder) => folder.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
+ found.sort((first, second) => (key(first.folder) < key(second.folder) ? -1 : 1));
+ list.replaceChildren(...found.map(({ folder, name }) => {
+ const item = document.createElement('li');
+ const label = document.createElement('span');
+ const detail = document.createElement('span');
+ const remove = document.createElement('button');
+ label.textContent = name;
+ detail.className = 'detail';
+ detail.textContent = ` ${folder}`;
+ label.append(detail);
+ remove.textContent = 'Remove';
+ remove.className = 'danger';
+ remove.addEventListener('click', () => {
+ if (confirm(`Remove the mod ${name} from this browser?`)) modTask({ type: 'remove', name: folder });
+ });
+ item.append(label, remove);
+ return item;
+ }));
+ $('mods-status').textContent = found.length ? `${found.length} mod${found.length === 1 ? '' : 's'} in this browser.` :
+ 'No mods yet.';
+ await refreshStorage();
+}
+
+function modTask(message) {
+ const worker = new Worker('mods-worker.js');
+ $('mod-label').classList.add('disabled');
+ $('mod-input').disabled = true;
+ worker.onmessage = async (event) => {
+ const data = event.data;
+ if (data.type === 'progress') {
+ $('mods-status').textContent = `Copying ${data.file}: ${formatBytes(data.done)} of ${formatBytes(data.total)}`;
+ return;
+ }
+ worker.terminate();
+ $('mod-label').classList.remove('disabled');
+ $('mod-input').disabled = false;
+ await refreshMods();
+ if (data.type === 'error') $('mods-status').textContent = data.message;
+ else if (message.type === 'add') logLine(`mods: ${data.name}, ${data.files} files, ${data.bytes} bytes`);
+ };
+ worker.onerror = async (event) => {
+ worker.terminate();
+ $('mod-label').classList.remove('disabled');
+ $('mod-input').disabled = false;
+ await refreshMods();
+ $('mods-status').textContent = 'The copy failed: ' + event.message;
+ };
+ worker.postMessage(message);
+}
+
+$('mod-input').addEventListener('change', (event) => {
+ const files = [...event.target.files];
+ event.target.value = '';
+ if (files.length) modTask({ type: 'add', files });
+});
+
$('delete-data').addEventListener('click', async () => {
if (!confirm('Delete the maps copied into this browser? Saved games stay.')) return;
const root = await navigator.storage.getDirectory();
@@ -602,6 +688,28 @@ async function play() {
logLine(text);
if (kind === 2) showInvite(text);
if (kind === 3) showFatal(text);
+ // (the game quits, as the player asked: the page starts again)
+ if (kind === 4) location.reload();
+ },
+ // a question the game waits on (sdl_platform.c's platform_ask: the
+ // mods that cannot be played together), answered with a button's index
+ haloAsk: (title, text, labels, answer) => {
+ logLine(`${title}: ${text}`);
+ $('ask-title').textContent = title;
+ $('ask-text').textContent = text;
+ $('ask-buttons').replaceChildren(...labels.map((label, index) => {
+ const button = document.createElement('button');
+ button.textContent = label;
+ if (index === 0) button.className = 'primary';
+ button.addEventListener('click', () => {
+ $('ask').hidden = true;
+ answer(index);
+ });
+ return button;
+ }));
+ // (the dialog is the page's: out of the game's full screen)
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
+ $('ask').hidden = false;
},
onAbort: (what) => showFatal('The game stopped: ' + what),
onExit: (status) => {
@@ -713,8 +821,12 @@ document.addEventListener('visibilitychange', async () => {
$('checks-note').textContent = 'Desktop Chrome, Edge and Firefox run the game; Safari 17 or later may.';
$('checks-note').hidden = false;
}
- if (results.opfs === 'good') await refreshData();
- else updatePlay();
+ if (results.opfs === 'good') {
+ await refreshData();
+ await refreshMods();
+ } else {
+ updatePlay();
+ }
checkForUpdate();
if (/^[0-9a-f]{32}$/.test(params.get('room') || '')) await joinRoom(params.get('room'));
if (params.has('play')) play();
diff --git a/port/web/site/index.html b/port/web/site/index.html
index 1f1b8bc9bd..1a0784e456 100644
--- a/port/web/site/index.html
+++ b/port/web/site/index.html
@@ -46,6 +46,20 @@
Game data
Xbox) into this browser's storage for this site. Nothing is uploaded, and the site serves no game data.
+
+
Mods
+
No mods yet.
+
+
+
+
+
A mod is a folder with a mod.toml and a tags folder of Halo PC tag files: sounds, textures and
+ text that play over the maps' for you alone. Each is played once added: the game's main menu's Mods turns them
+ off and sets the order they load in.
+
+
Play together
Play system link with friends in their browsers: create a room and send them
@@ -120,6 +134,14 @@
Play together
+
+
+
+
+
+
+
+
The game stopped
diff --git a/port/web/site/mods-worker.js b/port/web/site/mods-worker.js
new file mode 100644
index 0000000000..e803203396
--- /dev/null
+++ b/port/web/site/mods-worker.js
@@ -0,0 +1,155 @@
+/*
+MODS-WORKER.JS
+
+Copies a mod into the site's Origin Private File System, where the game
+finds it (/data/mods/: port/linux/src/port_mods.c, docs/mods.md),
+from the folder the player chose: its mod.toml and its tags folder of Halo PC
+tag files (the rest of the folder is not the game's). It runs in a Worker,
+which can write with synchronous OPFS access handles, and replaces a mod of
+the same folder's name. The folder is the player's, so its paths are
+checked: no part of one empty, hidden, a parent's or with a separator in it,
+and no more files than the game lists; a folder without mod.toml at its top
+is not a mod.
+
+Messages from the page: { type: 'add', files } (the chosen folder's File
+objects, each with its webkitRelativePath) or { type: 'remove', name }. To
+the page: { type: 'progress', file, done, total }, then { type: 'done',
+name, files, bytes } or { type: 'error', message }.
+*/
+
+'use strict';
+
+// (port_mods.c's MAXIMUM_MOD_FOLDER_LENGTH and MAXIMUM_MOD_FILES)
+const MAXIMUM_FOLDER_LENGTH = 63;
+const MAXIMUM_FILES = 262144;
+const MAXIMUM_DEPTH = 24;
+const COPY_CHUNK = 4 * 1024 * 1024;
+
+// a part of a path a mod's file may have: a name of a folder or file, not
+// hidden (as port_mods.c skips those), no separator
+function plainPart(part) {
+ return part.length > 0 && part.length <= 255 && !part.startsWith('.') && !/[\\/:\u0000-\u001f]/.test(part);
+}
+
+// a mod's folder name, as load_order.toml names it (its length in UTF-8's
+// bytes, as port_mods.c counts it)
+function plainFolder(name) {
+ return plainPart(name) && new TextEncoder().encode(name).length <= MAXIMUM_FOLDER_LENGTH;
+}
+
+// The mod in a chosen folder's files: { name, files: [{ parts, file }] }
+// of its mod.toml and its tags folder's files; throws why it is not one.
+function modFiles(list) {
+ let name = null;
+ const files = [];
+ let toml = false;
+ for (const file of list) {
+ const parts = (file.webkitRelativePath || file.name).split('/');
+ if (!parts.every(plainPart)) continue;
+ if (name === null) name = parts[0];
+ if (parts[0] !== name) throw new Error('Choose one mod\'s folder.');
+ if (parts.length === 2 && parts[1] === 'mod.toml') {
+ toml = true;
+ files.push({ parts: parts.slice(1), file });
+ } else if (parts.length >= 3 && parts.length <= MAXIMUM_DEPTH + 3 && parts[1] === 'tags') {
+ files.push({ parts: parts.slice(1), file });
+ }
+ }
+ if (name === null) throw new Error('The folder is empty.');
+ if (!plainFolder(name)) throw new Error(`"${name}" cannot be a mod's folder name: it must not be hidden, and be ${MAXIMUM_FOLDER_LENGTH} bytes at most.`);
+ if (!toml) throw new Error(`${name} has no mod.toml: it is not a mod.`);
+ if (files.length > MAXIMUM_FILES) throw new Error(`${name} has more files than the game reads.`);
+ return { name, files };
+}
+
+async function directoryOf(root, parts) {
+ let directory = root;
+ for (const part of parts) directory = await directory.getDirectoryHandle(part, { create: true });
+ return directory;
+}
+
+async function writeFile(directory, name, file, onChunk) {
+ const handle = await directory.getFileHandle(name, { create: true });
+ const access = await handle.createSyncAccessHandle();
+ try {
+ access.truncate(0);
+ let position = 0;
+ while (position < file.size) {
+ const count = Math.min(COPY_CHUNK, file.size - position);
+ const bytes = new Uint8Array(await file.slice(position, position + count).arrayBuffer());
+ if (bytes.length !== count) throw new Error(`Could not read ${file.name}.`);
+ let written = 0;
+ while (written < count) {
+ const result = access.write(bytes.subarray(written), { at: position + written });
+ if (!result) throw new Error(`Could not write ${file.name}: is the device full?`);
+ written += result;
+ }
+ position += count;
+ onChunk(count);
+ }
+ access.flush();
+ } finally {
+ access.close();
+ }
+}
+
+async function add(list) {
+ const mod = modFiles(list);
+ const total = mod.files.reduce((sum, entry) => sum + entry.file.size, 0);
+ const storage = await navigator.storage.getDirectory();
+ if (navigator.storage.estimate) {
+ const { quota, usage } = await navigator.storage.estimate();
+ if (quota && quota - usage < total) {
+ throw new Error(`The browser lets this site keep ${Math.floor((quota - usage) / 1e6)} MB more, ` +
+ `and the mod takes ${Math.ceil(total / 1e6)} MB.`);
+ }
+ }
+ const mods = await storage.getDirectoryHandle('mods', { create: true });
+ // (a mod of the same name is replaced whole)
+ await mods.removeEntry(mod.name, { recursive: true }).catch(() => {});
+ const root = await mods.getDirectoryHandle(mod.name, { create: true });
+ let done = 0;
+ let lastReport = 0;
+ try {
+ for (const entry of mod.files) {
+ const directory = await directoryOf(root, entry.parts.slice(0, -1));
+ await writeFile(directory, entry.parts[entry.parts.length - 1], entry.file, (count) => {
+ done += count;
+ const now = Date.now();
+ if (now - lastReport > 100 || done === total) {
+ lastReport = now;
+ postMessage({ type: 'progress', file: entry.parts.join('/'), done, total });
+ }
+ });
+ }
+ } catch (error) {
+ // (no half of a mod: the game would play what was copied)
+ await mods.removeEntry(mod.name, { recursive: true }).catch(() => {});
+ throw error;
+ }
+ return { name: mod.name, files: mod.files.length, bytes: total };
+}
+
+async function remove(name) {
+ if (!plainFolder(name)) throw new Error('No such mod.');
+ const storage = await navigator.storage.getDirectory();
+ const mods = await storage.getDirectoryHandle('mods', { create: true });
+ await mods.removeEntry(name, { recursive: true });
+ return { name, files: 0, bytes: 0 };
+}
+
+if (typeof onmessage !== 'undefined' || typeof WorkerGlobalScope !== 'undefined') {
+ onmessage = async (event) => {
+ try {
+ const result = event.data.type === 'remove' ? await remove(event.data.name) : await add(event.data.files);
+ postMessage({ type: 'done', ...result });
+ } catch (error) {
+ postMessage({ type: 'error', message: error && error.message ? error.message : String(error) });
+ }
+ };
+}
+
+// (for the tests: port/web/tests)
+if (typeof module !== 'undefined') {
+ module.exports = { modFiles, plainPart, plainFolder };
+}
diff --git a/port/web/site/style.css b/port/web/site/style.css
index e7c696a18f..0a41842de2 100644
--- a/port/web/site/style.css
+++ b/port/web/site/style.css
@@ -127,6 +127,10 @@ button.primary {
}
button.danger:hover:not(:disabled) { border-color: var(--bad); color: var(--bad); }
+.mods { list-style: none; margin: 0 0 12px; padding: 0; }
+.mods li { display: flex; gap: 12px; align-items: center; justify-content: space-between; padding: 4px 0; }
+.mods .detail { color: var(--muted); font-size: 14px; }
+
.play-row { margin: 24px 0; }
.play-row .primary { min-width: 200px; min-height: 52px; font-size: 18px; }
@@ -219,6 +223,8 @@ button.danger:hover:not(:disabled) { border-color: var(--bad); color: var(--bad)
background: rgba(0, 0, 0, 0.75);
z-index: 10;
}
+#ask-text { white-space: pre-line; }
+
.sheet-body {
width: min(760px, 100%);
max-height: 100%;
diff --git a/port/web/src/web_library.js b/port/web/src/web_library.js
index 9425c041be..2aa6f994b6 100644
--- a/port/web/src/web_library.js
+++ b/port/web/src/web_library.js
@@ -20,7 +20,8 @@ addToLibrary({
emscripten_get_now: () => (webTimeOrigin ||= performance.timeOrigin) + performance.now(),
// kind: 0 a status, 1 a notice, 2 an invite link of a game hosted for the
- // internet (to offer), 3 a fatal error (the game stops)
+ // internet (to offer), 3 a fatal error (the game stops), 4 the game quits
+ // as the player asked (the page starts again: port_mods.c)
web_js_post__proxy: 'sync',
web_js_post: (kind, text) => {
var message = UTF8ToString(text);
@@ -29,6 +30,20 @@ addToLibrary({
else console.log(message);
},
+ // a question for the player (port/linux/src/sdl_platform.c's
+ // platform_ask): the page's dialog, its buttons' labels one to a line; the
+ // button chosen goes in the word the game waits on (web_shared.h's
+ // _web_answer). (async: the game's thread waits on the word, its strings
+ // kept until then)
+ web_js_ask__proxy: 'async',
+ web_js_ask__deps: ['web_state'],
+ web_js_ask: (title, text, labels) => {
+ var answer = (index) => Atomics.store(HEAP32, (_web_state() >> 2) + 49, index);
+ var buttons = UTF8ToString(labels).split('\n');
+ if (Module.haloAsk) Module.haloAsk(UTF8ToString(title), UTF8ToString(text), buttons, answer);
+ else answer(0);
+ },
+
// a frame for another browser of the room (port/web/src/web_net.c; the
// page's net.js carries it): the C side's copy, freed here once taken.
// (async: the game's thread does not wait for the page)
diff --git a/port/web/src/web_shared.h b/port/web/src/web_shared.h
index b4fee230d4..b6fe05408b 100644
--- a/port/web/src/web_shared.h
+++ b/port/web/src/web_shared.h
@@ -38,7 +38,10 @@ enum
click (app.js), as a request from the game's thread, which reaches the
page outside the click, is refused once the player has let it go */
_web_mouse_wanted = 48,
- WEB_SHARED_WORDS = 49,
+ /* the page's: the button the player chose in the dialog the game asked
+ for (platform_ask: web_library.js's web_js_ask), -1 while it waits */
+ _web_answer = 49,
+ WEB_SHARED_WORDS = 50,
};
#define WEB_TOUCH_INPUTS 17
diff --git a/port/web/tests/mods.test.js b/port/web/tests/mods.test.js
new file mode 100644
index 0000000000..6d91ddd71f
--- /dev/null
+++ b/port/web/tests/mods.test.js
@@ -0,0 +1,60 @@
+// The mods importer (port/web/site/mods-worker.js) on chosen folders made
+// here: what of a mod's folder it copies, and the names and folders it
+// refuses. node --test "port/web/tests/*.test.js"
+
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert');
+const mods = require('../site/mods-worker.js');
+
+// a chosen folder's files, as the browser gives them
+function chosen(paths) {
+ return paths.map((path) => ({ name: path.split('/').pop(), webkitRelativePath: path, size: 1 }));
+}
+
+test('a mod: its mod.toml and its tags folder, the rest left', () => {
+ const mod = mods.modFiles(chosen([
+ 'hd_audio/mod.toml',
+ 'hd_audio/readme.txt',
+ 'hd_audio/tags/sound/sfx/weapons/assault rifle/fire.sound',
+ 'hd_audio/tags/ui/hud/hud_item_messages.unicode_string_list',
+ 'hd_audio/.git/config',
+ 'hd_audio/tags/.DS_Store',
+ ]));
+ assert.strictEqual(mod.name, 'hd_audio');
+ assert.deepStrictEqual(mod.files.map((entry) => entry.parts.join('/')).sort(), [
+ 'mod.toml',
+ 'tags/sound/sfx/weapons/assault rifle/fire.sound',
+ 'tags/ui/hud/hud_item_messages.unicode_string_list',
+ ]);
+});
+
+test('a folder without mod.toml is not a mod', () => {
+ assert.throws(() => mods.modFiles(chosen(['textures/tags/a.bitmap'])), /no mod\.toml/);
+ assert.throws(() => mods.modFiles(chosen(['textures/tags/mod.toml'])), /no mod\.toml/);
+ assert.throws(() => mods.modFiles([]), /empty/);
+});
+
+test('one mod at a time, by a name the game takes (63 bytes of UTF-8 at most)', () => {
+ assert.throws(() => mods.modFiles(chosen(['a/mod.toml', 'b/mod.toml'])), /one mod/);
+ assert.throws(() => mods.modFiles(chosen([`${'x'.repeat(64)}/mod.toml`])), /cannot be a mod's folder name/);
+ assert.throws(() => mods.modFiles(chosen([`${'\u00e9'.repeat(32)}/mod.toml`])), /cannot be a mod's folder name/);
+ assert.ok(mods.plainFolder('x'.repeat(63)));
+ assert.ok(mods.plainFolder('a, b (HD)'));
+});
+
+test("a mod's files as deep as the game lists them (24 folders under tags)", () => {
+ const folders = (count) => Array.from({ length: count }, (_, index) => `d${index}`).join('/');
+ const mod = mods.modFiles(chosen(['m/mod.toml', `m/tags/${folders(24)}/a.sound`, `m/tags/${folders(25)}/b.sound`]));
+ assert.deepStrictEqual(mod.files.map((entry) => entry.parts[entry.parts.length - 1]), ['mod.toml', 'a.sound']);
+});
+
+test('no part of a path leaves the mod', () => {
+ for (const part of ['', '.', '..', '.hidden', 'a\\b', 'a:b', 'a\u0001']) {
+ assert.strictEqual(mods.plainPart(part), false, JSON.stringify(part));
+ }
+ for (const part of ['fire.sound', 'assault rifle', 'a..b', 'Ü']) assert.ok(mods.plainPart(part), part);
+ const mod = mods.modFiles(chosen(['m/mod.toml', 'm/tags/../../escape.sound', 'm/tags/a/../b.sound']));
+ assert.deepStrictEqual(mod.files.map((entry) => entry.parts.join('/')), ['mod.toml']);
+});
diff --git a/source/cache/cache_files.c b/source/cache/cache_files.c
index 820b24711c..5d5edd6e77 100644
--- a/source/cache/cache_files.c
+++ b/source/cache/cache_files.c
@@ -599,14 +599,14 @@ void scenario_tags_unload(
hud_hires_tags_unloaded();
}
sound_cache_close();
- /* port: the sounds of tag files go, after the sound cache that held them
- (port/linux/game/loose_sounds.c) */
+ texture_cache_close();
+ /* port: the mods' tags go, after the sound and texture caches that held
+ their sounds and bitmaps (port/linux/game/mods.c) */
{
- extern void loose_sounds_tags_unloaded(void);
+ extern void mods_tags_unloaded(void);
- loose_sounds_tags_unloaded();
+ mods_tags_unloaded();
}
- texture_cache_close();
/* port: the menus' tags go, and the map's own table comes back
(port/linux/game/menu_tags.c): after the texture cache, which writes to
the bitmaps it has loaded as it closes, theirs among them */
@@ -1207,11 +1207,11 @@ long scenario_tags_load(
menu_tags_loaded(cache_file_globals.header.name);
}
- /* (and the sounds of tag files, as below) */
+ /* (and the mods' tags, as below) */
{
- extern void loose_sounds_tags_loaded(void);
+ extern void mods_tags_loaded(char const *map_name);
- loose_sounds_tags_loaded();
+ mods_tags_loaded(stripped_scenario_name);
}
result = cache_file_globals.tag_header->scenario_tag_index;
}
@@ -1315,12 +1315,14 @@ long scenario_tags_load(
hud_hires_tags_loaded();
}
- /* port: the sounds of tag files played over the map's
- (audio.loose_sounds: port/linux/game/loose_sounds.c) */
+ /* port: the mods' sounds, bitmaps, text and models played over
+ the map's, last, over the menus' tags too, and with the high-res
+ HUD standing for the map's bitmaps, not a mod's (the main menu's
+ Mods: port/linux/game/mods.c) */
{
- extern void loose_sounds_tags_loaded(void);
+ extern void mods_tags_loaded(char const *map_name);
- loose_sounds_tags_loaded();
+ mods_tags_loaded(stripped_scenario_name);
}
result = cache_file_globals.tag_header->scenario_tag_index;
}
diff --git a/source/cache/xbox_sound_cache.c b/source/cache/xbox_sound_cache.c
index e723617929..bcbb7b4d0a 100644
--- a/source/cache/xbox_sound_cache.c
+++ b/source/cache/xbox_sound_cache.c
@@ -606,12 +606,12 @@ static void sound_cache_start_loading_sound(
sound->cache_block_index = cache_block_index;
sound->cache_base_address = (unsigned long)cache_address;
cache_sound->sound = sound;
- /* port: or, the sound of a tag file played over the map's, from
- memory, at once (port/linux/game/loose_sounds.c) */
+ /* port: or, the sound of a mod played over the map's, from memory,
+ at once (port/linux/game/mod_sounds.c) */
{
- extern boolean loose_sounds_read(struct sound_permutation const *permutation, void *buffer);
+ extern boolean mod_sounds_read(struct sound_permutation const *permutation, void *buffer);
- if (loose_sounds_read(sound, cache_address))
+ if (mod_sounds_read(sound, cache_address))
{
cache_sound->loaded = TRUE;
}
diff --git a/source/cache/xbox_texture_cache.c b/source/cache/xbox_texture_cache.c
index 6249b078bf..fee3b46a2c 100644
--- a/source/cache/xbox_texture_cache.c
+++ b/source/cache/xbox_texture_cache.c
@@ -769,16 +769,18 @@ static boolean texture_cache_bitmap_valid(
compressed_flag==compressed_format;
}
/* port: a Custom Edition map's linear rows need not be whole steps of the
- pitch: they are padded to them as its pixels load
- (port/linux/game/custom_edition_bitmaps.c) */
+ pitch, nor a mod's: they are padded to them as its pixels load
+ (port/linux/game/custom_edition_bitmaps.c, mod_bitmaps.c) */
if (valid && linear)
{
+ extern boolean mod_bitmaps_contain(struct bitmap_data const *bitmap);
long row_pitch = bitmap_mipmap_get_row_pitch(bitmap, 0);
long steps = (row_pitch+D3DTEXTURE_PITCH_ALIGNMENT-1)/D3DTEXTURE_PITCH_ALIGNMENT;
valid =
row_pitch>0 &&
- (row_pitch%D3DTEXTURE_PITCH_ALIGNMENT==0 || custom_edition_cache_tags_loaded()) &&
+ (row_pitch%D3DTEXTURE_PITCH_ALIGNMENT==0 || custom_edition_cache_tags_loaded() ||
+ mod_bitmaps_contain(bitmap)) &&
steps<=(long)((D3DSIZE_PITCH_MASK>>D3DSIZE_PITCH_SHIFT)+1);
}
if (!valid && !reported)
@@ -841,13 +843,27 @@ static boolean texture_cache_start_loading_bitmap(
bitmap->base_address = base_address;
texture->bitmap = bitmap;
texture_cache_initialize_hardware_format(bitmap, &texture->hardware_format);
- texture->read_request_handle = cache_file_read(
- bitmap->tag_index,
- bitmap->pixels_offset,
- bitmap->pixels_size,
- base_address,
- &texture->loaded,
- block);
+ /* port: or, the bitmap of a mod played over the map's, from its
+ file, at once (port/linux/game/mod_bitmaps.c) */
+ {
+ extern boolean mod_bitmaps_read(struct bitmap_data *bitmap, void *buffer);
+
+ if (mod_bitmaps_read(bitmap, base_address))
+ {
+ texture->loaded = TRUE;
+ texture->read_request_handle = NONE;
+ }
+ else
+ {
+ texture->read_request_handle = cache_file_read(
+ bitmap->tag_index,
+ bitmap->pixels_offset,
+ bitmap->pixels_size,
+ base_address,
+ &texture->loaded,
+ block);
+ }
+ }
return TRUE;
}
diff --git a/source/game/cheats.c b/source/game/cheats.c
index fbba41ce1e..5f579ac99b 100644
--- a/source/game/cheats.c
+++ b/source/game/cheats.c
@@ -63,8 +63,12 @@ symbols in this file:
#include "main/console.h"
#include "math/real_math.h"
#include "objects/objects.h"
+#include "objects/object_types.h"
+#include "physics/bsp3d.h"
+#include "physics/collisions.h"
#include "rasterizer/rasterizer_console_vars.h"
#include "scenario/scenario.h"
+#include "tag_files/tag_files.h"
#include "tag_files/tag_groups.h"
#include "units/units.h"
@@ -481,3 +485,309 @@ void cheat_all_vehicles(
return;
}
+
+/* port: whether a tag path belongs to allowed spawn categories (vehicles, powerups, weapons) */
+static boolean cheat_tag_is_spawnable(
+ char const *full_name)
+{
+ if (!full_name || !full_name[0])
+ return FALSE;
+
+ if (!_strnicmp(full_name, "vehicles\\", 9) || !_strnicmp(full_name, "vehicles/", 9) ||
+ !_strnicmp(full_name, "powerups\\", 9) || !_strnicmp(full_name, "powerups/", 9) ||
+ !_strnicmp(full_name, "weapons\\", 8) || !_strnicmp(full_name, "weapons/", 8))
+ {
+ return TRUE;
+ }
+
+ return FALSE;
+}
+
+/* port: finds an object definition tag matching the given tag identifier (name or index) */
+static long cheat_find_object_tag(
+ char const *tag_identifier)
+{
+ struct tag_iterator iterator;
+ long tag_index;
+ long exact_match_tag = NONE;
+ long stripped_match_tag = NONE;
+ long full_substring_match_tag = NONE;
+ char search_lower[128];
+ char *endptr;
+ unsigned long parsed_id;
+
+ if (!tag_identifier || !tag_identifier[0])
+ return NONE;
+
+ /* 0. Apply aliases: tank -> scorpion, jeep -> warthog */
+ if (!_stricmp(tag_identifier, "tank"))
+ tag_identifier = "scorpion";
+ else if (!_stricmp(tag_identifier, "jeep"))
+ tag_identifier = "warthog";
+
+ /* 1. Check if the identifier is a numerical or hexadecimal tag ID */
+ parsed_id = strtoul(tag_identifier, &endptr, 0);
+ if (endptr != tag_identifier && *endptr == 0)
+ {
+ if (tag_index_is_group(parsed_id, OBJECT_DEFINITION_TAG) ||
+ object_definition_index_to_object_type(parsed_id) != NONE)
+ {
+ char const *full_name = tag_get_name((long)parsed_id);
+ if (cheat_tag_is_spawnable(full_name))
+ return (long)parsed_id;
+ return NONE;
+ }
+ }
+
+ /* Prepare lowercase search string for case-insensitive matching */
+ csstrncpy(search_lower, tag_identifier, sizeof(search_lower) - 1);
+ search_lower[sizeof(search_lower) - 1] = 0;
+ strlwr(search_lower);
+
+ /* Iterate all tags in the loaded cache */
+ tag_iterator_new(&iterator, NONE);
+ for (tag_index = tag_iterator_next(&iterator);
+ tag_index != NONE;
+ tag_index = tag_iterator_next(&iterator))
+ {
+ short obj_type = object_definition_index_to_object_type(tag_index);
+ char const *full_name;
+ char const *stripped_name;
+ char full_lower[256];
+ char stripped_lower[128];
+
+ if (obj_type == NONE)
+ continue;
+
+ full_name = tag_get_name(tag_index);
+ if (!full_name || !full_name[0])
+ continue;
+
+ if (!cheat_tag_is_spawnable(full_name))
+ continue;
+
+ stripped_name = tag_name_strip_path(full_name);
+
+ csstrncpy(full_lower, full_name, sizeof(full_lower) - 1);
+ full_lower[sizeof(full_lower) - 1] = 0;
+ strlwr(full_lower);
+
+ csstrncpy(stripped_lower, stripped_name, sizeof(stripped_lower) - 1);
+ stripped_lower[sizeof(stripped_lower) - 1] = 0;
+ strlwr(stripped_lower);
+
+ /* Exact match on stripped name (e.g. "warthog" or "mp_warthog") */
+ if (!csstrcmp(stripped_lower, search_lower))
+ {
+ exact_match_tag = tag_index;
+ break;
+ }
+
+ /* Exact match on full tag path */
+ if (!csstrcmp(full_lower, search_lower))
+ {
+ exact_match_tag = tag_index;
+ break;
+ }
+
+ /* Substring match on stripped name */
+ if (strstr(stripped_lower, search_lower))
+ {
+ if (stripped_match_tag == NONE || obj_type == _object_type_vehicle)
+ stripped_match_tag = tag_index;
+ }
+ /* Substring match on full name */
+ else if (strstr(full_lower, search_lower))
+ {
+ if (full_substring_match_tag == NONE || obj_type == _object_type_vehicle)
+ full_substring_match_tag = tag_index;
+ }
+ }
+
+ if (exact_match_tag != NONE)
+ return exact_match_tag;
+ if (stripped_match_tag != NONE)
+ return stripped_match_tag;
+ return full_substring_match_tag;
+}
+
+/* port: spawns an object near the host if in a valid position or reports FAIL if no space */
+boolean cheat_spawn_object(
+ char const *tag_identifier)
+{
+ long definition_index;
+ short object_type;
+ struct object_definition *object_definition;
+ real bounding_radius;
+ long player_index;
+ struct player_datum *player;
+ long host_unit_index;
+ real_point3d host_origin;
+ real_vector3d host_forward, host_up;
+ real_vector3d horizontal_forward;
+ real spawn_distance;
+ real_point3d spawn_pos;
+ struct collision_result ground_collision;
+ struct collision_result line_collision;
+ struct location spawn_loc;
+ real_vector3d down_vector;
+ real_vector3d host_to_spawn;
+ long nearby_objects[32];
+ short nearby_count;
+ short i;
+ struct object_placement_data placement_data;
+ long new_object_index;
+
+ if (!tag_identifier || !tag_identifier[0])
+ {
+ console_warning("spawn: give a tag identifier or name");
+ return FALSE;
+ }
+
+ definition_index = cheat_find_object_tag(tag_identifier);
+ if (definition_index == NONE)
+ {
+ console_warning("spawn: FAIL - object tag \"%s\" not found", tag_identifier);
+ return FALSE;
+ }
+
+ if (!cheat_tag_is_spawnable(tag_get_name(definition_index)))
+ {
+ console_warning("spawn: FAIL - only vehicles, powerups, or weapons can be spawned");
+ return FALSE;
+ }
+
+ object_type = object_definition_index_to_object_type(definition_index);
+ if (object_type == NONE)
+ {
+ console_warning("spawn: FAIL - tag \"%s\" is not a valid object", tag_identifier);
+ return FALSE;
+ }
+
+ object_definition = object_definition_get(definition_index);
+ bounding_radius = object_definition->object.bounding_radius;
+ if (bounding_radius <= 0.1f)
+ bounding_radius = 1.0f;
+
+ player_index = cheat_player_index();
+ if (player_index == NONE)
+ {
+ console_warning("spawn: FAIL - no player in game");
+ return FALSE;
+ }
+
+ player = player_get(player_index);
+ if (player->unit_index == NONE)
+ {
+ console_warning("spawn: FAIL - host player is not alive");
+ return FALSE;
+ }
+
+ host_unit_index = player->unit_index;
+ object_get_origin(host_unit_index, &host_origin);
+ object_get_orientation(host_unit_index, &host_forward, &host_up);
+
+ horizontal_forward = host_forward;
+ horizontal_forward.k = 0.0f;
+ if (magnitude_squared3d(&horizontal_forward) > 0.0001f)
+ normalize3d(&horizontal_forward);
+ else
+ horizontal_forward = host_forward;
+
+ spawn_distance = bounding_radius + 2.0f;
+ if (spawn_distance < 2.5f)
+ spawn_distance = 2.5f;
+
+ spawn_pos.x = host_origin.x + horizontal_forward.i * spawn_distance;
+ spawn_pos.y = host_origin.y + horizontal_forward.j * spawn_distance;
+ spawn_pos.z = host_origin.z + 0.5f;
+
+ /* Ground detection via downward collision trace */
+ down_vector.i = 0.0f;
+ down_vector.j = 0.0f;
+ down_vector.k = -4.0f;
+ if (collision_test_vector(
+ FLAG(_collision_test_front_facing_surfaces_bit) | FLAG(_collision_test_structure_bit),
+ &spawn_pos,
+ &down_vector,
+ host_unit_index,
+ &ground_collision))
+ {
+ spawn_pos.z = ground_collision.point.z + 0.1f;
+ }
+
+ // fix for crashes prob
+ /* 1. Location and cluster validation */
+ scenario_location_from_point(&spawn_loc, &spawn_pos);
+ if (spawn_loc.cluster_index == NONE)
+ {
+ console_warning("spawn: FAIL - no space (position outside cluster/BSP)");
+ return FALSE;
+ }
+
+ if (global_bsp3d_get() && bsp3d_test_point(global_bsp3d_get(), 0, &spawn_pos) == NONE)
+ {
+ console_warning("spawn: FAIL - no space (position outside BSP leaf)");
+ return FALSE;
+ }
+
+ /* 2. Line of sight / obstacle check between host and spawn position */
+ vector_from_points3d(&host_origin, &spawn_pos, &host_to_spawn);
+ if (collision_test_vector(
+ FLAG(_collision_test_front_facing_surfaces_bit) | FLAG(_collision_test_structure_bit),
+ &host_origin,
+ &host_to_spawn,
+ host_unit_index,
+ &line_collision))
+ {
+ console_warning("spawn: FAIL - no space (line of sight blocked by structure)");
+ return FALSE;
+ }
+
+ // MAIN CHECK
+ /* 3. Collision sphere check against structure BSP */
+ if (collision_test_sphere(&spawn_pos, bounding_radius * 0.75f, host_unit_index))
+ {
+ console_warning("spawn: FAIL - no space (intersects structure)");
+ return FALSE;
+ }
+
+ /* 4. Overlap check with nearby objects */
+
+ nearby_count = objects_in_sphere(
+ 0,
+ _object_mask_vehicle | _object_mask_biped | _object_mask_scenery | _object_mask_machine,
+ &spawn_loc,
+ &spawn_pos,
+ bounding_radius,
+ nearby_objects,
+ NUMBEROF(nearby_objects));
+
+/*
+ for (i = 0; i < nearby_count; i++)
+ {
+ if (nearby_objects[i] != host_unit_index)
+ {
+ short nearby_type = object_get_type(nearby_objects[i]);
+ console_warning("spawn: FAIL - no space (blocked by nearby %s)",
+ object_type_get_name(nearby_type));
+ return FALSE;
+ }
+ }
+*/
+ /* Instantiate object */
+ object_placement_data_new(&placement_data, definition_index, NONE);
+ placement_data.position = spawn_pos;
+ placement_data.forward = horizontal_forward;
+ placement_data.up = *global_up3d;
+
+ new_object_index = object_new(&placement_data);
+ if (new_object_index == NONE)
+ {
+ console_warning("spawn: FAIL - object creation failed");
+ return FALSE;
+ }
+
+ console_printf("spawn: created %s", tag_get_name(definition_index));
+ return TRUE;
+}
diff --git a/source/game/cheats.h b/source/game/cheats.h
index f595e92b06..2f099df2e1 100644
--- a/source/game/cheats.h
+++ b/source/game/cheats.h
@@ -56,6 +56,9 @@ void cheats_load(
void);
void cheat_active_camouflage_local_player(
short player_index);
+/* port: spawns an object matching the given tag near the host if valid space exists */
+boolean cheat_spawn_object(
+ char const *tag_identifier);
/* ---------- globals */
diff --git a/source/game/players.c b/source/game/players.c
index 04dcec5aa9..fac2f1d133 100644
--- a/source/game/players.c
+++ b/source/game/players.c
@@ -3102,36 +3102,121 @@ static long players_coop_host_unit(
return NONE;
}
-/* port: the co-op host's bringto command (hs.c). Every other player comes
-to the host's first player in the level, or, with no room there, beside one
-who already came. Returns whether anyone came. */
-boolean players_coop_bring_to_host(
- void)
+/* port: check if a players name matches the argument */
+static boolean player_name_matches(
+ wchar_t const *player_name,
+ char const *text,
+ boolean exact_only)
+{
+ char a;
+ char b;
+
+ for (; *text; player_name++, text++)
+ {
+ a = player_name_character_ascii(*player_name);
+ b = *text;
+
+ if (a >= 'A' && a <= 'Z')
+ a = (char)(a - 'A' + 'a');
+ if (b >= 'A' && b <= 'Z')
+ b = (char)(b - 'A' + 'a');
+
+ if (!*player_name || a != b)
+ return FALSE;
+ }
+ return !exact_only || !*player_name;
+}
+
+/* port: find a player's unit index by name for bringto */
+static long players_find_target_unit(
+ char const *target_name)
+{
+ struct data_iterator iterator;
+ struct player_datum *player;
+ long exact_unit = NONE;
+ long partial_unit = NONE;
+ short exact_count = 0;
+ short partial_count = 0;
+
+ data_iterator_new(&iterator, player_data);
+ while ((player = data_iterator_next(&iterator)) != NULL)
+ {
+ if (player->quit_out_of_game || player->unit_index == NONE)
+ continue;
+
+ if (player_name_matches(player->name, target_name, TRUE))
+ {
+ exact_unit = player->unit_index;
+ exact_count++;
+ }
+ if (player_name_matches(player->name, target_name, FALSE))
+ {
+ partial_unit = player->unit_index;
+ partial_count++;
+ }
+ }
+
+ if (exact_count == 1)
+ return exact_unit;
+ if (exact_count > 1 || partial_count > 1)
+ {
+ // shouldnt happen due to player.c handling this but just in case?
+ console_warning("bringto: multiple players match \"%s\", please be more specific", target_name);
+ return NONE;
+ }
+ if (partial_count == 1)
+ return partial_unit;
+
+ console_warning("bringto: no active player named \"%s\"", target_name);
+ return NONE;
+}
+
+/* port: the host's bringto command (hs.c). Every other player comes
+to the specified player, or the host's first player in the level if no name
+is provided, or, with no room there, beside one who already came.
+Returns whether anyone came. */
+boolean players_bring_to_player(
+ char const *target_name)
{
struct data_iterator iterator;
struct player_datum *player;
long arrived_unit_indices[NETWORK_GAME_MAXIMUM_PLAYER_COUNT + 1];
short arrived_count = 0;
short missed_count = 0;
+ long target_unit = NONE;
if (!network_coop_active() || game_connection() != _game_connection_network_server)
{
- console_warning("bringto: only the host of a co-op game brings the players");
+ console_warning("bringto: only the host can teleport players");
return FALSE;
}
- arrived_unit_indices[arrived_count++] = players_coop_host_unit();
- if (arrived_unit_indices[0] == NONE)
+
+ if (target_name && target_name[0])
+ {
+ target_unit = players_find_target_unit(target_name);
+ }
+ else
+ {
+ target_unit = players_coop_host_unit();
+ }
+
+ if (target_unit == NONE)
{
- console_warning("bringto: the host has no player in the level");
+ if (!target_name || !target_name[0])
+ console_warning("bringto: the host has no player in the level");
return FALSE;
}
+
+ arrived_unit_indices[arrived_count++] = target_unit;
+
data_iterator_new(&iterator, player_data);
while ((player = data_iterator_next(&iterator)) != NULL)
{
short arrived_index;
- if (player->unit_index == NONE || player->unit_index == arrived_unit_indices[0])
+ if (player->unit_index == NONE || player->unit_index == target_unit)
continue;
+
for (arrived_index = 0; arrived_index < arrived_count; arrived_index++)
{
long beside_index = arrived_unit_indices[arrived_index];
@@ -3139,11 +3224,13 @@ boolean players_coop_bring_to_host(
if (player_teleport(iterator.datum_index, beside_index, &object_get(beside_index)->object.bounding_sphere_center))
break;
}
+
if (arrived_index < arrived_count)
arrived_unit_indices[arrived_count++] = player->unit_index;
else
missed_count++;
}
+
console_printf(FALSE, "bringto: %d brought, %d with no room", arrived_count - 1, missed_count);
return arrived_count > 1;
diff --git a/source/game/players.h b/source/game/players.h
index a2ea43ba72..369053fc3c 100644
--- a/source/game/players.h
+++ b/source/game/players.h
@@ -385,13 +385,9 @@ cutscene ends and a teammate has been free on foot a few seconds (not
riding a vehicle, nor held by the scripts). */
boolean players_coop_waiting_to_start(
long player_index);
-/* port: the co-op host's bringto command: everyone comes to the host */
-boolean players_coop_bring_to_host(
- void);
-/* port: TRUE if `position` is inside a switch trigger for the BSP that is
-loaded. A player left there switches the level on the next tick. */
-boolean player_position_switches_bsp(
- real_point3d const *position);
+/* port: the host's bringto command: everyone comes to the specified player or the host */
+boolean players_bring_to_player(
+ char const *target_name);
void players_note_checkpoint(
void);
void players_respawn_at_checkpoint(
diff --git a/source/hs/hs.c b/source/hs/hs.c
index c93adb8f00..3f95765f46 100644
--- a/source/hs/hs.c
+++ b/source/hs/hs.c
@@ -3382,7 +3382,7 @@ typedef void (*hs_token_enumerator)(
struct hs_function_table_storage
{
- struct hs_function_definition const *functions[418 + 4];
+ struct hs_function_definition const *functions[418 + 5];
struct profile_section profile;
hs_token_enumerator token_enumerators[18];
};
@@ -11702,42 +11702,56 @@ static struct hs_function_definition const sv_end_game_definition=
0,
};
-/* port: the sounds of tag files played over the map's, for those making
-them (audio.loose_sounds: port/linux/game/loose_sounds.c), at the console */
-void loose_sounds_reload(void);
-void loose_sounds_enable(boolean enabled);
+/* port: the mods' tags played over the map's on this machine
+(the main menu's Mods: port/linux/game/mods.c), at the console */
+void mods_reload(void);
+void mods_enable(boolean enabled);
+void mods_list(void);
-HS_EVALUATE_NO_ARGUMENTS(hs_loose_sounds_reload_evaluate, loose_sounds_reload)
-HS_EVALUATE_VOID_BOOLEAN(hs_loose_sounds_evaluate, loose_sounds_enable)
+HS_EVALUATE_NO_ARGUMENTS(hs_mods_reload_evaluate, mods_reload)
+HS_EVALUATE_VOID_BOOLEAN(hs_mods_enable_evaluate, mods_enable)
+HS_EVALUATE_NO_ARGUMENTS(hs_mods_list_evaluate, mods_list)
-static struct hs_function_definition const loose_sounds_reload_definition=
+static struct hs_function_definition const mods_reload_definition=
{
_hs_type_void,
0,
- "loose_sounds_reload",
+ "mods_reload",
hs_macro_function_parse,
- hs_loose_sounds_reload_evaluate,
- "reads the sound tag files under the data root's tags folder again (all sounds stop if any changed).",
+ hs_mods_reload_evaluate,
+ "finds the mods again and reads their tag files again (all sounds stop if any changed).",
NULL,
0,
};
-static struct hs_function_definition_with_1_parameter const loose_sounds_definition=
+static struct hs_function_definition_with_1_parameter const mods_enable_definition=
{
{
_hs_type_void,
0,
- "loose_sounds",
+ "mods_enable",
hs_macro_function_parse,
- hs_loose_sounds_evaluate,
- "plays the map's sounds from the tags folder's sound tag files, or (false) from the map, until the map changes.",
+ hs_mods_enable_evaluate,
+ "plays the mods' sounds, bitmaps, text and models, or (false) the map's own, until the map changes.",
NULL,
1,
{ _hs_type_boolean },
},
};
-long const hs_function_table_count= 418 + 4;
+static struct hs_function_definition const mods_list_definition=
+{
+ _hs_type_void,
+ 0,
+ "mods_list",
+ hs_macro_function_parse,
+ hs_mods_list_evaluate,
+ "lists the mods in the mods folder, and what they play on this map.",
+ NULL,
+ 0,
+};
+
+long const hs_function_table_count= 418 + 5;
struct hs_enum_definition const hs_enum_table[]=
{
@@ -12171,8 +12185,9 @@ struct hs_function_table_storage hs_function_table=
&xbox_set_machine_name_definition.definition,
&sv_say_definition.definition,
&sv_end_game_definition,
- &loose_sounds_reload_definition,
- &loose_sounds_definition.definition,
+ &mods_reload_definition,
+ &mods_enable_definition.definition,
+ &mods_list_definition,
},
{
"hs_update",
@@ -12675,9 +12690,10 @@ static boolean const hs_function_allowed_in_maps[]=
TRUE, /* sv_say */
TRUE, /* sv_end_game: the host's */
- /* the port's, for those making sounds */
- FALSE, /* loose_sounds_reload */
- FALSE, /* loose_sounds */
+ /* the port's, for this machine's mods */
+ FALSE, /* mods_reload */
+ FALSE, /* mods_enable */
+ FALSE, /* mods_list */
};
typedef char verify_hs_function_allowed_in_maps_size[
NUMBEROF(hs_function_allowed_in_maps) == NUMBEROF(hs_function_table.functions) ? 1 : -1];
@@ -15304,6 +15320,8 @@ static boolean hs_expression_changes_no_game(
"player0_look_yaw_rate", "player1_look_yaw_rate", "player2_look_yaw_rate", "player3_look_yaw_rate",
"player0_look_pitch_rate", "player1_look_pitch_rate", "player2_look_pitch_rate",
"player3_look_pitch_rate",
+ /* (a machine's mods are its own: mods.c) */
+ "mods_reload", "mods_enable", "mods_list",
"true", "false", "on", "off",
};
char const *character = expression;
@@ -15414,9 +15432,56 @@ static boolean hs_compile_and_evaluate_command(
return TRUE;
#endif
/* port: the co-op host's bringto, which brings every player to the host
- (players.c; a client is told it is the host's) */
- if (hs_host_player_command(expression, "bringto"))
- return players_coop_bring_to_host();
+ (players.c; a client is told it is the host's) or a player typed after the
+
+ */
+ {
+ char const *text = hs_host_player_command(expression, "bringto");
+
+ if (text)
+ {
+ char name[64];
+ long length = 0;
+
+ while (*text == ' ' || *text == '\t' || *text == '"')
+ text++;
+ while (*text && *text != '"' && *text != ')' && length < (long)sizeof(name) - 1)
+ name[length++] = *text++;
+ while (length > 0 && (name[length - 1] == ' ' || name[length - 1] == '\t'))
+ length--;
+ name[length] = 0;
+
+ return players_bring_to_player(name[0] ? name : NULL);
+ }
+ }
+
+ /* port: the host's spawn command, which spawns an object (such as a vehicle) near the host */
+ {
+ char const *text = hs_host_player_command(expression, "spawn");
+
+ if (text)
+ {
+ char name[128];
+ long length = 0;
+
+ if (hs_playing_in_anothers_game())
+ {
+ console_warning("spawn: only the host of the game can spawn objects");
+ return FALSE;
+ }
+
+ while (*text == ' ' || *text == '\t' || *text == '"')
+ text++;
+ while (*text && *text != '"' && *text != ')' && length < (long)sizeof(name) - 1)
+ name[length++] = *text++;
+ while (length > 0 && (name[length - 1] == ' ' || name[length - 1] == '\t'))
+ length--;
+ name[length] = 0;
+
+ return cheat_spawn_object(name);
+ }
+ }
+
/* port: the players' vote to kick a player ("votekick ",
or the name's start: Tab completes it), on any machine of a network
game: the host counts (network_votekick.c) */
diff --git a/source/interface/hud_messaging.c b/source/interface/hud_messaging.c
index 29248cc42e..65138af6f0 100644
--- a/source/interface/hud_messaging.c
+++ b/source/interface/hud_messaging.c
@@ -465,6 +465,11 @@ static void render_state_text(
rectangle2d *cursor_bounds,
wchar_t const *text,
boolean custom);
+/* port: original Xbox high-resolution glyphs, port/linux/game/hud_hires_tags.c */
+boolean hud_hires_button_draw(struct icon_hud_element_definition const *icon,
+ rectangle2d *cursor, pixel32 color);
+struct bitmap_data const *hud_hires_sprite_bitmap(struct bitmap_data const *bitmap, short sequence_index);
+
static void render_state_bitmap(
rectangle2d *bounds,
rectangle2d *cursor_bounds,
@@ -543,6 +548,10 @@ static void render_state_bitmap(
if (bitmap && _texture_cache_bitmap_get_hardware_format(
(struct bitmap_data *)bitmap, FALSE, TRUE))
{
+ /* port: keep gameplay prompts on the high-resolution HUD path. */
+ if (hud_hires_button_draw(icon, cursor_bounds, color))
+ return;
+ bitmap = hud_hires_sprite_bitmap(bitmap, icon->sequence_index);
scale = local_player_count() > 1 ? 0.75f : 1.0f;
point.x = (short)(icon->offset.x * scale + cursor_bounds->x0);
point.y = (short)(cursor_bounds->y1 - icon->offset.y * scale);
@@ -891,6 +900,9 @@ void hud_render_timer(
numbers.placement.offset = timer->position;
numbers.placement.scale.i = 1.0f;
numbers.placement.scale.j = 1.0f;
+ /* port: this scripted element has no authored scaling flags. The PC
+ * digit renderer reads them, so stack data must not halve its glyphs. */
+ numbers.placement.multiplayer_scaling_flags = 0;
hud_number_index = interface_get_tag_index(_interface_hud_digits);
if (hud_number_index != NONE)
diff --git a/source/interface/ui_widget.c b/source/interface/ui_widget.c
index 33d925152f..3355b6a4d2 100644
--- a/source/interface/ui_widget.c
+++ b/source/interface/ui_widget.c
@@ -6661,12 +6661,14 @@ static boolean ui_mouse_menus_active(
}
/* port: a row of a list to choose from (the PC version's menus' lists of
-gametypes, maps, profiles, levels and games: port/assets/menus) */
+gametypes, maps, profiles, levels and games, and the port's of mods:
+port/assets/menus) */
static boolean ui_mouse_selection_row(
struct widget_instance *widget)
{
return widget && widget->parent && pc_menu_tag(widget->parent->definition_tag_index) &&
- (!strncmp(widget->name, "list_item_", 10) || !strncmp(widget->name, "server_item_", 12));
+ (!strncmp(widget->name, "list_item_", 10) || !strncmp(widget->name, "server_item_", 12) ||
+ !strncmp(widget->name, "op_mod_", 7));
}
/* logs a tap the menus resolved: where it was and the target the click acts
diff --git a/source/objects/objects.c b/source/objects/objects.c
index 299ac38734..c65d378cb5 100644
--- a/source/objects/objects.c
+++ b/source/objects/objects.c
@@ -4470,8 +4470,11 @@ void objects_update(
{
if (TEST_FLAG(object_header->flags, _object_header_active_bit))
{
- match_assert("c:\\halo\\SOURCE\\objects\\objects.c", 369, object_header->cluster_index!=NONE);
- if (!BIT_VECTOR_TEST_FLAG(active_cluster_bits, object_header->cluster_index))
+ if (object_header->cluster_index == NONE)
+ {
+ /* Safely delete the out-of-bounds object to prevent a crash */
+ object_delete_initial_recursive(i, FALSE);
+ } else if (!BIT_VECTOR_TEST_FLAG(active_cluster_bits, object_header->cluster_index))
{
if (TEST_FLAG(object_header->datum->object.flags, _object_deleted_when_deactivated_bit))
{
diff --git a/source/rasterizer/rasterizer_text.c b/source/rasterizer/rasterizer_text.c
index dff839c553..6835dddb32 100644
--- a/source/rasterizer/rasterizer_text.c
+++ b/source/rasterizer/rasterizer_text.c
@@ -89,6 +89,7 @@ struct text_hires_glyph
};
#define TEXT_HIRES_FIT_LETTERS 52
+int text_hires_enabled(void); /* port: port/linux/src/text_hires.c */
long text_hires_font(char const *tag_name, float cap_height, short const *letter_widths, float oversample);
int text_hires_covers(long font, unsigned long code);
int text_hires_glyph(long font, unsigned long code, struct text_hires_glyph *glyph);
@@ -822,7 +823,9 @@ static long hires_text_font_get(
short row;
long hires_font;
- if (font_index == NONE)
+ /* port: consult the committed setting before the font cache. In particular,
+ * never cache NONE merely because high-res text was disabled. */
+ if (font_index == NONE || !text_hires_enabled())
return NONE;
font = font_definition_get(font_index);
for (font_slot = 0; font_slot < MAXIMUM_HIRES_TEXT_FONTS; font_slot++)
diff --git a/source/rasterizer/xbox/rasterizer_xbox_environment_fog.c b/source/rasterizer/xbox/rasterizer_xbox_environment_fog.c
index 69081c37e5..5e29c5b957 100644
--- a/source/rasterizer/xbox/rasterizer_xbox_environment_fog.c
+++ b/source/rasterizer/xbox/rasterizer_xbox_environment_fog.c
@@ -138,6 +138,7 @@ symbols in this file:
#include "rasterizer/rasterizer_model_types.h"
#include "rasterizer/rasterizer_transparent_geometry.h"
#include "render/render.h"
+#include "view_fov.h" /* port: port/linux/game/view_fov.c */
#include "shaders/shader_definitions.h"
#include "shaders/shaders.h"
#include
@@ -1325,8 +1326,15 @@ void _rasterizer_environment_fog_screen_begin(
{
boolean success;
boolean clear_z_buffer;
+ boolean stick_to_drawn_surface;
real vsh_constants__texscale[VSH_CONSTANTS__TEXSCALE_COUNT][4];
+ /* port: at the stock field of view the redrawn canopy lands on the
+ canopy. At any other angle, less or equal still accepts that
+ redraw where it has slid onto the sky */
+ stick_to_drawn_surface =
+ render_fov_reticle_scale(render.local_player_index) != 1.0f;
+
if (pass == 0)
{
if (TEST_FLAG(screen->flags, _fog_screen_no_environment_multipass_bit))
@@ -1349,6 +1357,8 @@ void _rasterizer_environment_fog_screen_begin(
{
clear_z_buffer = FALSE;
}
+ if (stick_to_drawn_surface)
+ clear_z_buffer = FALSE;
success = IDirect3DDevice8_Clear(
global_d3d_device,
@@ -1365,7 +1375,7 @@ void _rasterizer_environment_fog_screen_begin(
IDirect3DDevice8_SetRenderState(global_d3d_device, D3DRS_ALPHATESTENABLE, FALSE);
IDirect3DDevice8_SetRenderState(global_d3d_device, D3DRS_ZENABLE, TRUE);
IDirect3DDevice8_SetRenderState(global_d3d_device, D3DRS_ZFUNC,
- pass != 0 ? D3DCMP_EQUAL : D3DCMP_LESSEQUAL);
+ pass != 0 || stick_to_drawn_surface ? D3DCMP_EQUAL : D3DCMP_LESSEQUAL);
IDirect3DDevice8_SetRenderState(global_d3d_device, D3DRS_ZWRITEENABLE, pass == 0);
IDirect3DDevice8_SetRenderState(global_d3d_device, D3DRS_ZBIAS, 0);
diff --git a/tools/android_asm_convert.py b/tools/android_asm_convert.py
index 9d87bf6275..da8699c497 100644
--- a/tools/android_asm_convert.py
+++ b/tools/android_asm_convert.py
@@ -15,7 +15,13 @@
import re
import sys
-IDENT = re.compile(r'"[^"]*"|[A-Za-z_.$][\w.$]*')
+# a quoted name (its escapes kept: \" does not end it) or a plain one
+IDENT = re.compile(r'"(?:[^"\\]|\\.)*"|[A-Za-z_.$][\w.$]*')
+
+# directives whose operands are string literals, the same in both
+# syntaxes: they go through as they are, never renamed as symbols (data
+# starting with "_" would lose that byte)
+STRING_DIRECTIVES = {".ascii", ".asciz", ".string"}
DROP_DIRECTIVES = {
".subsections_via_symbols", ".build_version", ".watchos_version_min",
@@ -217,6 +223,9 @@ def convert_line(self, raw: str):
if op == ".align":
self.emit(f"\t.p2align {args}")
return
+ if op in STRING_DIRECTIVES:
+ self.emit(f"\t{op} {args}")
+ return
if op.startswith("."):
self.emit(f"\t{op} {self.operands(args)}" if args else f"\t{op}")
return
diff --git a/tools/button_assets.py b/tools/button_assets.py
index 46cd78f929..1cd811ada6 100644
--- a/tools/button_assets.py
+++ b/tools/button_assets.py
@@ -88,6 +88,52 @@
MESSAGE_ICONS = "ui\\hud\\bitmaps\\hud_msg_icons_sm"
MESSAGE_BUTTONS = {0: "a_butn.svg", 1: "b_butn.svg", 2: "x_butn.svg", 3: "y_butn.svg"}
+# Standalone Xbox faces for gameplay prompts, using the existing redraws.
+HUD_BUTTONS = [dict(name=f"{button}_butn__0", tag=BITMAPS + f"{button}_butn",
+ bitmap=0, width=32, height=32, format="dxt3", scale=8,
+ crc=0, svg=f"{button}_butn.svg", place=[1, 0, 0])
+ for button in ("black", "white")]
+
+# Plain gameplay faces use the same hand-drawn oval and letter as the
+# message stencils, with solid colour and no rim or shadow.
+CLASSIC_BUTTONS = [dict(name=f"classic_{button}__0", tag=f"ui\\hud\\buttons\\classic_{button}",
+ bitmap=0, width=32, height=32, format="dxt3", scale=8,
+ crc=0, svg=f"{button}_butn.svg", place=[1, 0, 0], classic=color)
+ for button, color in (("a", "#40eb40"), ("b", "#fa5946"),
+ ("x", "#3998fa"), ("y", "#f9e928"),
+ ("black", "#000000"), ("white", "#ffffff"))]
+
+# Supplied Xbox gameplay gems, fitted to the existing 21x26 gameplay cell.
+# Black and White retain the existing gem redraws in both gem styles.
+GEM_BUTTONS = [dict(name=f"gem_{button}__0", tag=f"ui\\hud\\buttons\\gem_{button}",
+ bitmap=0, width=32, height=32, format="dxt3", scale=8, crc=0,
+ source_png=f"source/{button}_gem.png") for button in "abxy"]
+
+
+# Supplied Xbox trigger and three thumbstick press states, shared by every style.
+CONTROL_BUTTONS = [dict(name=f"{name}__0", tag=f"ui\\hud\\buttons\\{name}",
+ bitmap=0, width=64, height=64, format="a8r8g8b8", scale=8,
+ crc=0, source_png=f"source/{name}.png", control_cell=cell)
+ for name, cell in [("trigger_left", [50,35]), ("trigger_right", [50,35])] +
+ [(f"thumbstick{state}_{side}", [36,40])
+ for state in ("", "-halfway", "-full") for side in ("left", "right")]]
+
+
+def gameplay_control(entry: dict) -> np.ndarray:
+ """Keep supplied artwork proportional inside a padded legacy-sized cell."""
+ source = Image.open(ASSETS / entry["source_png"]).convert("RGBA")
+ face = source.crop(source.getchannel("A").getbbox())
+ scale = entry["scale"]
+ width, height = [n * scale for n in entry["control_cell"]]
+ face.thumbnail((width - 2 * scale, height - 2 * scale), Image.Resampling.LANCZOS)
+ image = Image.new("RGBA", (entry["width"] * scale, entry["height"] * scale))
+ image.paste(face, ((width - face.width) // 2, (height - face.height) // 2))
+ # The original HUD uses ONE / INVSRCALPHA. Store premultiplied edges
+ # so that blend preserves its brightness without exposing transparent RGB.
+ pixels = np.asarray(image).copy()
+ pixels[..., :3] = (pixels[..., :3].astype(np.uint16) * pixels[..., 3:4] + 127) // 255
+ return pixels
+
def document(width: int, height: int, body: str) -> str:
return (f'')
+def classic(svg: str, color: str, letter_move: list = (0, 0)) -> str:
+ """The existing redraw's plain oval and black letter in the same cell."""
+ root = ElementTree.parse(ASSETS / "svg" / svg).getroot()
+ parents = {child: parent for parent in root.iter() for child in parent}
+ oval, letter = root.find(".//*[@id='oval']"), root.find(".//*[@id='letter']")
+ oval.set("fill", color)
+ oval.set("transform", parents[oval].get("transform", ""))
+ body = ElementTree.tostring(oval, encoding="unicode")
+ if letter is not None:
+ letter.set("fill", "#000000")
+ letter.set("transform", f"translate({letter_move[0]} {letter_move[1]}) {letter.get('transform', '')}")
+ body += ElementTree.tostring(letter, encoding="unicode")
+ return document(32, 32, body)
+
+
+def gameplay_gem(entry: dict) -> np.ndarray:
+ """Fit the supplied transparent oval to the shared gameplay icon cell."""
+ source = Image.open(ASSETS / entry["source_png"]).convert("RGBA")
+ bounds = source.getchannel("A").getbbox()
+ if bounds is None:
+ raise ValueError(f"{entry['source_png']}: empty button")
+ scale = entry["scale"]
+ reference = Image.open(ASSETS / "a_butn__0.png").convert("RGBA")
+ # Keep the menu redraw's outer bounds and transparent inset, rather than
+ # filling its whole cropped cell and making the new faces look larger.
+ factor = entry["width"] * scale / reference.width
+ left, top, right, bottom = [round(n * factor) for n in reference.getchannel("A").getbbox()]
+ cell = (right - left, bottom - top)
+ face = source.crop(bounds)
+ face.thumbnail(cell, Image.Resampling.LANCZOS)
+ image = Image.new("RGBA", (entry["width"] * scale, entry["height"] * scale))
+ image.paste(face, (left + (cell[0] - face.width) // 2, top + (cell[1] - face.height) // 2))
+ return bleed(np.asarray(image).copy())
+
+
def render(text: str, scale: int) -> np.ndarray:
"""An SVG document at scale times its size."""
with tempfile.TemporaryDirectory() as directory:
@@ -296,7 +377,10 @@ def layout(arguments) -> None:
# (the sprites it stands for, not the whole bitmap)
"sprites": sprites,
})
- LIST.write_text(json.dumps({"assets": entries}, indent=1) + "\n")
+ classic_buttons = [dict(entry) for entry in CLASSIC_BUTTONS]
+ for entry, sprite in zip(classic_buttons, sprites):
+ entry["letter"] = [round(n / sprite["place"][0], 4) for n in sprite["letter"]]
+ LIST.write_text(json.dumps({"assets": entries + HUD_BUTTONS + classic_buttons + GEM_BUTTONS + CONTROL_BUTTONS}, indent=1) + "\n")
def build(arguments) -> None:
@@ -306,7 +390,13 @@ def build(arguments) -> None:
if stale.name not in names:
stale.unlink()
for entry in description["assets"]:
- if "sprites" in entry:
+ if "control_cell" in entry:
+ image = gameplay_control(entry)
+ elif "source_png" in entry:
+ image = gameplay_gem(entry)
+ elif "classic" in entry:
+ image = bleed(render(classic(entry["svg"], entry["classic"], entry.get("letter", (0, 0))), entry["scale"]))
+ elif "sprites" in entry:
body = "\n".join(stencil(sprite["svg"], sprite["cell"], sprite["place"], index,
sprite.get("letter", (0, 0)))
for index, sprite in enumerate(entry["sprites"]))
diff --git a/tools/ce_menus.py b/tools/ce_menus.py
index be0bf6fc13..6eeb8c67bb 100644
--- a/tools/ce_menus.py
+++ b/tools/ce_menus.py
@@ -69,15 +69,17 @@
"ui\\shell\\main_menu\\multiplayer_type_select\\checking_updates_screen_create",
"ui\\shell\\main_menu\\multiplayer_type_select\\checking_updates_screen_join",
}
-# children moved: (parent, child) to (x, y); the main menu's Quit up into
-# Credits' place; Multiplayer's Edit Gametypes and its line down a row, for
-# Co-op (port_settings.WIDGET_PATCHES)
+# children moved: (parent, child) to (x, y); the main menu's Quit a row below
+# Mods, which takes Credits' place; Multiplayer's Edit Gametypes and divider
+# below Co-op, keeping its 32-unit highlight inside the description panel's
+# bottom curve (port_settings.WIDGET_PATCHES)
CHILD_OFFSETS = {
- ("ui\\shell\\main_menu\\main_menu_select_list", "ui\\shell\\main_menu\\main_menu_item_quit_game"): (192, 391),
+ ("ui\\shell\\main_menu\\main_menu_select_list", "ui\\shell\\main_menu\\main_menu_item_quit_game"):
+ port_settings.QUIT_ITEM_PLACE,
("ui\\shell\\main_menu\\multiplayer_type_select\\multiplayer_type_select_list",
- "ui\\shell\\main_menu\\multiplayer_type_select\\multiplayer_type_gametypes_item"): (0, 375),
+ "ui\\shell\\main_menu\\multiplayer_type_select\\multiplayer_type_gametypes_item"): (0, 358),
("ui\\shell\\main_menu\\multiplayer_type_select\\multiplayer_type_select_screen",
- "ui\\shell\\main_menu\\blueline"): (64, 358),
+ "ui\\shell\\main_menu\\blueline"): (64, 349),
# the browser's rows up under the column titles, into the place of the
# scroll up button (hidden: the list does not scroll)
**{("ui\\shell\\main_menu\\multiplayer_type_select\\join_game\\join_game_items_list",
@@ -542,6 +544,19 @@ def engine_names(source: Path, start: str, count: int) -> list:
return re.findall(r'"([^"\\]*)"', text[:text.index("}")])[:count]
+def pause_art(art):
+ lines = []
+ for piece in ("left", "center", "right"):
+ heights = [port_settings.pause_box_height(buttons) for buttons in port_settings.PAUSE_BOX_BUTTONS]
+ heights += list(port_settings.PAUSE_BOX_EXTRA_HEIGHTS)
+ lines.append(art.port_picture(f"pause/pausebox_{piece}", [
+ (port_settings.pause_box_svg(piece, height), 4 if piece == "center" else 16, 256) for height in heights]))
+ for piece in ("left", "center", "right"):
+ lines.append(art.port_picture(f"pause/objectives_{piece}", [
+ (port_settings.pause_box_svg(piece, 195, header=True), 4 if piece == "center" else 16, 256)]))
+ return lines
+
+
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--tags", type=Path, required=True)
@@ -621,18 +636,19 @@ def main() -> None:
" (NON_HANDDRAWN.md; tools/ce_menus.py) -->", ""]
lines += [art.bitmap(tag) for tag in sorted(bitmaps)]
lines += [art.title(name, text) for name, text in sorted(port_settings.TITLES.items())]
- # (the in-game pause menu's taller box: menu_tags.c's pause_patch)
- for piece in ("left", "center", "right"):
- heights = [port_settings.pause_box_height(buttons) for buttons in port_settings.PAUSE_BOX_BUTTONS]
- heights += list(port_settings.PAUSE_BOX_EXTRA_HEIGHTS)
- lines.append(art.port_picture(f"pause/pausebox_{piece}", [
- (port_settings.pause_box_svg(piece, height), 4 if piece == "center" else 16, 256) for height in heights]))
+ lines += pause_art(art)
# (voice chat's speaker, of a player who talks and of one muted: Lucide's
# icons, port/assets/icons/lucide, white for network_voice.c to tint)
lines.append(art.port_picture("voice/speaker", [
((ROOT / "port/assets/icons/lucide" / f"{icon}.svg").read_text().replace('stroke="currentColor"',
'stroke="#FFFFFF"'), 24, 24)
for icon in ("volume-2", "volume-x")]))
+ # (the main menu's MODS, in OpenCE as the redraws' items are, and the mods'
+ # status icons: Lucide's, port/assets/icons/lucide)
+ lines.append(art.port_picture("main_menu/menu_mods", port_settings.menu_item_frames("MODS")))
+ lines.append(art.port_picture("mods/status", port_settings.mod_status_frames(ROOT / "port/assets/icons/lucide")))
+ lines.append(art.port_picture("mods/status_small",
+ port_settings.mod_status_frames(ROOT / "port/assets/icons/lucide", 16)))
# (nothing: what a game map draws for a frame of ui.map's it has not,
# menu_tags.c)
lines.append(art.port_picture("blank", [
diff --git a/tools/ci_build.py b/tools/ci_build.py
index cae5ec7148..a0f6335ffc 100644
--- a/tools/ci_build.py
+++ b/tools/ci_build.py
@@ -137,8 +137,9 @@ def main() -> int:
# voice chat's codec (port/third_party/opus), in every build, whose BSD
# license asks binaries to carry its notice
shutil.copy2(ROOT / "port/third_party/opus/COPYING", dist / "opus-COPYING.txt")
- # voice chat's speaker icons (port/assets/icons/lucide, drawn into the
- # menus' bitmaps), whose ISC license asks copies to carry its notice
+ # voice chat's speaker icons and the Mods menu's warning icons
+ # (port/assets/icons/lucide, drawn into the menus' bitmaps), whose ISC
+ # license asks copies to carry its notice
shutil.copy2(ROOT / "port/assets/icons/lucide/LICENSE", dist / "lucide-LICENSE.txt")
# internet play's MQTT brokers, a file beside the game (network.brokers_file;
# Android's APK has its own copy)
diff --git a/tools/embed_assets.py b/tools/embed_assets.py
index 04c3bbf300..4010b62c74 100644
--- a/tools/embed_assets.py
+++ b/tools/embed_assets.py
@@ -22,7 +22,7 @@
The data are 32-bit words, not bytes: the Android build passes the guest's
assembly through tools/android_asm_convert.py, which rewrites identifiers in
-operands and would garble a long .ascii string of binary data.
+operands (it keeps .ascii strings as they are, but once garbled them).
"""
import json
diff --git a/tools/harness/tests/loose_sound_paths.c b/tools/harness/tests/loose_sound_paths.c
deleted file mode 100644
index 56fa5f6005..0000000000
--- a/tools/harness/tests/loose_sound_paths.c
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
-LOOSE_SOUND_PATHS.C (test)
-
-The real checks of port/linux/game/loose_sounds.c that stand between a map's
-sound tag names (a Custom Edition map's are anyone's) and the files opened,
-and between a tag file's counts and offsets and the memory read:
-test_loose_sound_paths.py takes the folder and extension (config.inc) and the
-code (under_test.inc).
-*/
-
-#include "harness.h"
-#include "config.inc"
-
-#include "under_test.inc"
-
-static char const *path_of(char const *name)
-{
- static char path[256];
-
- return tag_file_path(name, path, sizeof(path)) ? path : NULL;
-}
-
-int main(int argc, char **argv)
-{
- char const *case_name = argc > 1 ? argv[1] : "";
-
- /* a tag's name is a file of the tags folder */
- CASE("accepts-tag-names")
- {
- char const *path = path_of("sound\\sfx\\weapons\\assault rifle\\fire");
-
- CHECK(path != NULL, "a stock sound's name was refused");
- CHECK(!strcmp(path, TAG_FILE_FOLDER "sound\\sfx\\weapons\\assault rifle\\fire" TAG_FILE_EXTENSION),
- "made %s", path);
- CHECK(path_of("sound\\dialog\\a..b") != NULL, "two dots inside a name were refused");
- CHECK(path_of("sound/sfx/x") != NULL, "a name with forward slashes was refused");
- return 0;
- }
- /* no name reaches outside it */
- CASE("refuses-escapes")
- {
- static char const *const names[] =
- {
- "", "..\\x", "../x", "sound\\..\\..\\config", "sound/../../x", "..",
- "c:\\x", "d:x", "\\x", "/x", "sound\\\\x", "sound//x", "sound\\", "sound/",
- };
- int index;
-
- for (index = 0; index < (int)(sizeof(names) / sizeof(names[0])); index++)
- {
- CHECK(path_of(names[index]) == NULL, "the name \"%s\" was taken", names[index]);
- }
- CHECK(!tag_file_path(NULL, (char[16]){ 0 }, 16), "no name was taken");
- return 0;
- }
- /* nor past the path's room */
- CASE("refuses-long-names")
- {
- char name[300];
-
- memset(name, 'a', sizeof(name) - 1);
- name[sizeof(name) - 1] = 0;
- CHECK(path_of(name) == NULL, "a name longer than the path was taken");
- name[256 - sizeof(TAG_FILE_FOLDER TAG_FILE_EXTENSION)] = 0;
- CHECK(path_of(name) != NULL, "the longest name that fits was refused");
- return 0;
- }
- /* a count and offset from a file lie within it, however large */
- CASE("bounds")
- {
- CHECK(in_file(10, 5, 15), "the last bytes of the file were refused");
- CHECK(in_file(15, 0, 15), "nothing at the end was refused");
- CHECK(!in_file(10, 6, 15), "a byte past the end was taken");
- CHECK(!in_file(16, 0, 15), "an offset past the end was taken");
- CHECK(!in_file(0xFFFFFFF0UL, 0x20, 0x100), "an offset that wraps was taken");
- CHECK(!in_file(0x10, 0xFFFFFFF8UL, 0x100), "a size that wraps was taken");
- return 0;
- }
- fprintf(stderr, "unknown case: %s\n", case_name);
- return 2;
-}
diff --git a/tools/harness/tests/mixer.c b/tools/harness/tests/mixer.c
index c337600cfe..752ba26aa3 100644
--- a/tools/harness/tests/mixer.c
+++ b/tools/harness/tests/mixer.c
@@ -12,7 +12,8 @@ reverb and limiter; test_mixer.py takes them as under_test.inc) on known
the band, and what else comes out (images and aliases) under the tone;
- a voice out of earshot, which is not resampled, moves on as one heard
does, and is heard the same once it comes into earshot;
- - mono voices heard as stereo ones with both channels the same are.
+ - mono voices heard as stereo ones with both channels the same are;
+ - audio.resampler = "linear": each frame between the two around it.
"render" writes voices at each rate and a scene's whole mix as raw floats
(test_mixer.py compares the resampler adding four taps at a time with it
adding tap by tap), "metrics" prints the figures and "bench" times a game's
@@ -46,6 +47,14 @@ typedef struct
#define DS3DMODE_HEADRELATIVE 0x00000001
#define DS3DMODE_DISABLE 0x00000002
+/* the settings (port_config.c): audio.resampler, which a case sets */
+static const char *resampler_setting;
+
+static const char *config_string(const char *name)
+{
+ return !strcmp(name, "audio.resampler") ? resampler_setting : NULL;
+}
+
/* the players' voices (voice_audio.c): none */
static void voice_audio_mix(float *output, unsigned long frames)
{
@@ -613,6 +622,51 @@ int main(int argc, char **argv)
free(signal);
return 0;
}
+ /* audio.resampler = "linear": each frame the two source frames around
+ it, weighted by how near it is to each */
+ CASE("linear")
+ {
+ double *signal = noise(30000);
+ short *samples = malloc(30000 * sizeof(short));
+ double *exact = malloc(30000 * sizeof(double));
+ float *output = calloc(50000, sizeof(float));
+ unsigned long n;
+
+ resampler_setting = "linear";
+ resampler_mode = resampler_mode_read();
+ CHECK(resampler_mode == RESAMPLER_LINEAR, "audio.resampler = \"linear\" is not read as linear");
+ quantize(signal, samples, exact, 30000);
+ for (index = 0; index < sizeof(rates) / sizeof(rates[0]); index++)
+ {
+ double step = (double)(rates[index][1] ? rates[index][1] : rates[index][0]) / OUTPUT_RATE;
+ unsigned long outputs = (unsigned long)((30000 - 1) / step);
+ struct sdl_stream *stream = voice_new(samples, 30000, 1, rates[index][0], rates[index][1]);
+
+ if (outputs > 50000)
+ outputs = 50000;
+ render(stream, output, outputs, FALSE);
+ for (n = 0; n < outputs; n++)
+ {
+ double time = (double)n * step, fraction = time - floor(time);
+ long first = (long)floor(time);
+ double expected = exact[first] + (first + 1 < 30000 ? exact[first + 1] - exact[first] : -exact[first]) *
+ fraction;
+
+ CHECK(fabs(output[n] - expected) < 1.0e-5, "%lu Hz played at %lu: frame %lu is %f, not %f",
+ rates[index][0], rates[index][1], n, output[n], expected);
+ }
+ voice_delete(stream);
+ }
+ resampler_setting = "sinc";
+ CHECK(resampler_mode_read() == RESAMPLER_SINC, "audio.resampler = \"sinc\" is not read as sinc");
+ resampler_setting = NULL;
+ CHECK(resampler_mode_read() == RESAMPLER_SINC, "audio.resampler unset is not read as sinc");
+ free(output);
+ free(exact);
+ free(samples);
+ free(signal);
+ return 0;
+ }
CASE("metrics")
{
static const double tones[] = { 100, 1000, 5000, 8000, 10000, 10500, 15000, 18000, 20000, 21000 };
diff --git a/tools/harness/tests/mod_files.c b/tools/harness/tests/mod_files.c
new file mode 100644
index 0000000000..e7eb44de9c
--- /dev/null
+++ b/tools/harness/tests/mod_files.c
@@ -0,0 +1,136 @@
+/*
+MOD_FILES.C (test)
+
+The real checks that stand between a mod's files and the game: the keys a
+map's tag names are looked up by in the mods' files
+(port/linux/src/port_mods.c), the bounds of a tag file's counts and offsets
+(port/linux/game/mods.c), and the comparison that keeps a mod's string from
+being printed as a format that reads arguments the game does not pass
+(port/linux/game/mod_strings.c): test_mod_files.py takes the code
+(under_test.inc).
+*/
+
+#include "harness.h"
+
+#define MIN(a, b) ((a) < (b) ? (a) : (b))
+
+/* (mod_strings.c's struct conversion and MAXIMUM_CONVERSIONS) */
+#include "config.inc"
+
+#include "under_test.inc"
+
+/* a string's bytes, as a tag file has them: UTF-16 little-endian or not */
+static unsigned long string_bytes(char const *text, boolean unicode, byte *bytes)
+{
+ unsigned long length = (unsigned long)strlen(text) + 1;
+ unsigned long index;
+
+ for (index = 0; index < length; index++)
+ {
+ if (unicode)
+ {
+ bytes[2 * index] = (byte)text[index];
+ bytes[2 * index + 1] = 0;
+ }
+ else
+ {
+ bytes[index] = (byte)text[index];
+ }
+ }
+ return unicode ? 2 * length : length;
+}
+
+static boolean same_formats(char const *first, char const *second, boolean unicode)
+{
+ byte first_bytes[512];
+ byte second_bytes[512];
+ unsigned long first_size = string_bytes(first, unicode, first_bytes);
+ unsigned long second_size = string_bytes(second, unicode, second_bytes);
+
+ return formats_match(first_bytes, first_size, second_bytes, second_size, unicode);
+}
+
+int main(int argc, char **argv)
+{
+ char const *case_name = argc > 1 ? argv[1] : "";
+
+ /* a map's tag name and a file's path make the same key, whatever their
+ case and slashes; one that does not fit is none */
+ CASE("keys")
+ {
+ char key[64];
+ char small[8];
+
+ CHECK(key_make("Sound/SFX\\Weapons/fire.Sound", key, sizeof(key)), "a name was refused");
+ CHECK(!strcmp(key, "sound\\sfx\\weapons\\fire.sound"), "made %s", key);
+ CHECK(key_hash("a\\b.sound") == key_hash(key_make("A/B.SOUND", key, sizeof(key)) ? key : ""),
+ "the same key hashed otherwise");
+ CHECK(!key_make("12345678", small, sizeof(small)), "a key longer than its room was taken");
+ CHECK(key_make("1234567", small, sizeof(small)), "the longest key that fits was refused");
+ CHECK(names_equal("HD_Audio", "hd_audio"), "a folder named in another case was not found");
+ CHECK(!names_equal("hd_audio", "hd_audio2"), "a folder was found by its start");
+ return 0;
+ }
+ /* a count and offset from a file lie within it, however large */
+ CASE("bounds")
+ {
+ CHECK(mods_in_file(10, 5, 15), "the last bytes of the file were refused");
+ CHECK(mods_in_file(15, 0, 15), "nothing at the end was refused");
+ CHECK(!mods_in_file(10, 6, 15), "a byte past the end was taken");
+ CHECK(!mods_in_file(16, 0, 15), "an offset past the end was taken");
+ CHECK(!mods_in_file(0xFFFFFFF0UL, 0x20, 0x100), "an offset that wraps was taken");
+ CHECK(!mods_in_file(0x10, 0xFFFFFFF8UL, 0x100), "a size that wraps was taken");
+ return 0;
+ }
+ /* a string replaces the map's only if it reads the same arguments */
+ CASE("formats")
+ {
+ boolean unicode;
+
+ for (unicode = FALSE; unicode <= TRUE; unicode++)
+ {
+ CHECK(same_formats("%s was killed by %s", "%s a tue %s", unicode), "a translation was refused");
+ CHECK(same_formats("Score", "Punkte", unicode), "a string with no conversions was refused");
+ CHECK(same_formats("%2d kills", "%d Abschuesse", unicode), "a width was taken for an argument");
+ CHECK(!same_formats("%s died", "%s died %s", unicode), "a string reading another argument was taken");
+ CHECK(!same_formats("%d lives", "%s lives", unicode), "a string reading a number as a string was taken");
+ CHECK(!same_formats("%d", "%ld", unicode), "a string reading a longer number was taken");
+ CHECK(!same_formats("Score", "%n", unicode), "a string writing through an argument was taken");
+ CHECK(!same_formats("%d", "%*d", unicode), "a string reading a width argument was taken");
+ CHECK(same_formats("100%%", "100 %%", unicode), "a percent sign was taken for a conversion");
+ CHECK(same_formats("Score: 100%%", "Punkte: 100", unicode), "a percent sign was taken for a conversion");
+ CHECK(!same_formats("100%%", "100%", unicode), "a lone percent sign at the end was missed");
+ }
+ return 0;
+ }
+ /* however many conversions or characters, the comparison ends */
+ CASE("long-formats")
+ {
+ char many[200];
+ byte bytes[64];
+ long index;
+
+ /* (one more conversion than are compared) */
+ for (index = 0; index < 2 * (MAXIMUM_CONVERSIONS + 1); index++)
+ many[index] = index % 2 ? 'd' : '%';
+ many[index] = 0;
+ CHECK(!same_formats(many, many, FALSE), "more conversions than are compared were taken");
+ /* (a string that is not terminated ends at its size: "%%%%" then a
+ '%' with nothing after it) */
+ memset(bytes, '%', sizeof(bytes));
+ CHECK(format_conversions(bytes, 5, FALSE, (struct conversion[MAXIMUM_CONVERSIONS]){ { 0 } }) == 1,
+ "a string was read past its size");
+ /* (a character past ASCII is no conversion's: U+016C after '%' is not
+ the 'l' of its low byte) */
+ {
+ byte wide[8] = { '%', 0, 0x6C, 0x01, 'd', 0, 0, 0 };
+ byte modifier[8] = { '%', 0, 'l', 0, 'd', 0, 0, 0 };
+
+ CHECK(!formats_match(wide, sizeof(wide), modifier, sizeof(modifier), TRUE),
+ "a wide character was read as its low byte");
+ }
+ return 0;
+ }
+ fprintf(stderr, "unknown case: %s\n", case_name);
+ return 2;
+}
diff --git a/tools/harness/tests/test_loose_sound_paths.py b/tools/harness/tests/test_loose_sound_paths.py
deleted file mode 100644
index 4ea149b1ff..0000000000
--- a/tools/harness/tests/test_loose_sound_paths.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""The real path and bounds checks of loose_sounds.c: no map's sound tag name opens a file outside the tags folder,
-and no count or offset from a tag file reads past it."""
-
-import re
-import sys
-from pathlib import Path
-
-import pytest
-
-sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
-from harness import CHECK_FAILED, build, function, mutated, read, run # noqa: E402
-
-CASES = ["accepts-tag-names", "refuses-escapes", "refuses-long-names", "bounds"]
-
-# a fault in loose_sounds.c, the function it is in, and the case that must catch it
-NEGATIVE_CONTROLS = {
- "lets-parents-through": (("(*character == '.' && character[1] == '.' &&", "(0 &&"), "tag_file_path",
- "refuses-escapes"),
- "lets-drives-through": (("if (*character == ':' ||", "if (0 ||"), "tag_file_path", "refuses-escapes"),
- "adds-offset-and-size": (("offset <= file_size && size <= file_size - offset", "offset + size <= file_size"),
- "in_file", "bounds"),
-}
-
-
-def generated(fault=None, faulty_function=None):
- source = read("port/linux/game/loose_sounds.c")
- config = ""
- for name in ("TAG_FILE_FOLDER", "TAG_FILE_EXTENSION"):
- match = re.search(r"#define " + name + r" (\".*\")", source)
- assert match, f"{name} not found in loose_sounds.c"
- config += f"#define {name} {match.group(1)}\n"
- text = ""
- for name in ("in_file", "tag_file_path"):
- code = function(source, name)
- if fault and name == faulty_function:
- code = mutated(code, *fault)
- text += code + "\n"
- return (("config.inc", config), ("under_test.inc", text))
-
-
-@pytest.mark.parametrize("case", CASES)
-def test_case(case):
- status, output = run(build("loose_sound_paths", generated()), case)
- assert status == 0, output
-
-
-@pytest.mark.parametrize("control", NEGATIVE_CONTROLS)
-def test_negative_control(control):
- fault, faulty_function, case = NEGATIVE_CONTROLS[control]
- status, output = run(build("loose_sound_paths", generated(fault, faulty_function)), case)
- assert status == CHECK_FAILED, f"loose_sounds.c with a fault ({control}) passed '{case}': the test cannot see it"
diff --git a/tools/harness/tests/test_mixer.py b/tools/harness/tests/test_mixer.py
index 63d95c4502..108468c617 100644
--- a/tools/harness/tests/test_mixer.py
+++ b/tools/harness/tests/test_mixer.py
@@ -1,5 +1,6 @@
"""The real mixer of dsound_sdl.c on known signals (mixer.c): its resampler against the same low pass worked out in
-double precision, sines through it as the ear hears them, voices out of earshot and mono voices.
+double precision, sines through it as the ear hears them, voices out of earshot, mono voices and the linear
+resampler.
python tools/harness/tests/test_mixer.py metrics the figures
python tools/harness/tests/test_mixer.py bench [voices] [out of earshot] [indoor] a game's worth of voices, timed
@@ -16,7 +17,7 @@
from harness import CHECK_FAILED, build, mutated, read, run # noqa: E402
SOURCE = "port/linux/src/dsound_sdl.c"
-CASES = ["reference", "impulse", "sines", "aliases", "silent-voice", "mono"]
+CASES = ["reference", "impulse", "sines", "aliases", "silent-voice", "mono", "linear"]
# a fault in the mixer, and the case that must catch it
NEGATIVE_CONTROLS = {
@@ -25,6 +26,7 @@
"phase-not-blended": (("for (phase = 0; phase <= RESAMPLER_TABLE_STEPS; phase++)",
"for (phase = 0; phase <= RESAMPLER_TABLE_STEPS; phase += 2)"), "impulse"),
"no-stretch": (("#define RESAMPLER_MAXIMUM_STRETCH 2", "#define RESAMPLER_MAXIMUM_STRETCH 1"), "aliases"),
+ "linear-nearest": (("left_frames[0] + (left_frames[1] - left_frames[0]) * fraction", "left_frames[0]"), "linear"),
}
diff --git a/tools/harness/tests/test_mod_files.py b/tools/harness/tests/test_mod_files.py
new file mode 100644
index 0000000000..856627b83f
--- /dev/null
+++ b/tools/harness/tests/test_mod_files.py
@@ -0,0 +1,62 @@
+"""The real checks between a mod's files and the game: the keys a map's tag names find files by (port_mods.c), a tag
+file's bounds (mods.c), and the format comparison that keeps a mod's string from reading arguments the game does not
+pass (mod_strings.c)."""
+
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+from harness import CHECK_FAILED, build, constant, function, mutated, read, run, structure # noqa: E402
+
+CASES = ["keys", "bounds", "formats", "long-formats"]
+
+SOURCES = {
+ "port/linux/src/port_mods.c": ["lower", "key_hash", "key_make", "names_equal"],
+ "port/linux/game/mods.c": ["mods_in_file"],
+ "port/linux/game/mod_strings.c": ["character_get", "character_in", "format_conversions", "formats_match"],
+}
+
+# a fault in the code, the function it is in, and the case that must catch it
+NEGATIVE_CONTROLS = {
+ "keeps-slashes": (("key[length++] = *text == '/' ? '\\\\' : lower(*text);", "key[length++] = lower(*text);"),
+ "key_make", "keys"),
+ "adds-offset-and-size": (("offset <= file_size && size <= file_size - offset", "offset + size <= file_size"),
+ "mods_in_file", "bounds"),
+ "ignores-stars": (("conversions[index].stars != map_conversions[index].stars ||", ""), "formats_match", "formats"),
+ "ignores-modifiers": (("strcmp(conversions[index].modifier, map_conversions[index].modifier)", "0"),
+ "formats_match", "formats"),
+ "counts-percent-signs": (("if (character_get(string, size, index, unicode) == '%')\n\t\t{",
+ "if (0)\n\t\t{"), "format_conversions", "formats"),
+ "reads-low-bytes": (("return character && character < 0x80 &&", "return character &&"), "character_in",
+ "long-formats"),
+}
+
+
+def generated(fault=None, faulty_function=None):
+ strings = read("port/linux/game/mod_strings.c")
+ config = structure(strings, "conversion") + "\n"
+ config += f"#define MAXIMUM_CONVERSIONS {constant(strings, 'MAXIMUM_CONVERSIONS')}\n"
+ text = ""
+ for path, names in SOURCES.items():
+ source = read(path)
+ for name in names:
+ code = function(source, name)
+ if fault and name == faulty_function:
+ code = mutated(code, *fault)
+ text += code + "\n"
+ return (("config.inc", config), ("under_test.inc", text))
+
+
+@pytest.mark.parametrize("case", CASES)
+def test_case(case):
+ status, output = run(build("mod_files", generated()), case)
+ assert status == 0, output
+
+
+@pytest.mark.parametrize("control", NEGATIVE_CONTROLS)
+def test_negative_control(control):
+ fault, faulty_function, case = NEGATIVE_CONTROLS[control]
+ status, output = run(build("mod_files", generated(fault, faulty_function)), case)
+ assert status == CHECK_FAILED, f"the code with a fault ({control}) passed '{case}': the test cannot see it"
diff --git a/tools/hud_assets.py b/tools/hud_assets.py
index 6a8381b62c..66ec154a3c 100644
--- a/tools/hud_assets.py
+++ b/tools/hud_assets.py
@@ -142,7 +142,7 @@
14: "reticle_30.svg", # (no weapon's; a small chevron)
},
HUD + "sniper\\hud_reticles_scope": {
- 0: "reticle_18.svg", # sniper rifle
+ 0: "sniper_diamond.svg", # Xbox sniper rifle, local redraw
},
}
# the zoom labels beside the sniper's reticle, by sequence and sprite: no
@@ -654,8 +654,9 @@ def reticles(arguments) -> None:
exact = sorted(redraws[sequence] for sequence, _ in numbers if sequence in redraws)
text = next((labels[number] for number in sorted(numbers) if number in labels), None)
if exact:
- svg = "hud2_left_reticles__" + exact[0]
- shutil.copyfile(source / exact[0], ASSETS / "svg" / svg)
+ svg = exact[0] if exact[0] == "sniper_diamond.svg" else "hud2_left_reticles__" + exact[0]
+ if svg != "sniper_diamond.svg":
+ shutil.copyfile(source / exact[0], ASSETS / "svg" / svg)
score, per_texel, corner = fit_reticle(ASSETS / "svg" / svg, alpha)
what = exact[0]
elif text:
@@ -761,15 +762,15 @@ def recipe(texels: np.ndarray, kind: str) -> np.ndarray:
return result
-def meter_thresholds(texels: np.ndarray, values: list) -> np.ndarray:
+def meter_thresholds(texels: np.ndarray, values: list, quantize: bool = False) -> np.ndarray:
"""Categorical segment data, independent of antialiased edge RGB/alpha.
Each solid-colour shape's opaque stroke supplies its exact threshold.
Assign that value to its whole core, then extend it to the nearest edge
and transparent texel. Low-alpha SVG texels cannot supply reliable RGB:
unpremultiplying 8-bit colour there can turn 150 into 128 (or 60 into 0).
- This is only for the explicitly listed discrete health sprites; shield
- and ammunition gradients retain their existing continuous data.
+ This is only for explicitly listed discrete health and ammunition
+ sprites; shield and other continuous gradients retain their data.
"""
from scipy import ndimage
@@ -782,9 +783,16 @@ def meter_thresholds(texels: np.ndarray, values: list) -> np.ndarray:
if not len(opaque):
continue
colours = np.unique(opaque, axis=0)
- if len(colours) != 1 or not np.all(colours[0] == colours[0, 0]):
- raise ValueError("discrete meter shape must have one solid grey threshold")
- value = int(colours[0, 0])
+ if quantize:
+ # Ammunition redraws use a gradient across separate bullets.
+ # The counter consumes whole bullets: keep each one's integer
+ # threshold constant through its antialiased edge and mip levels.
+ centre = float(np.median(opaque[:, 0]))
+ value = min(values, key=lambda candidate: abs(candidate - centre))
+ else:
+ if len(colours) != 1 or not np.all(colours[0] == colours[0, 0]):
+ raise ValueError("discrete meter shape must have one solid grey threshold")
+ value = int(colours[0, 0])
if value not in values:
raise ValueError(f"unexpected discrete meter threshold {value}")
exact[core] = value
@@ -809,7 +817,7 @@ def add_meter_thresholds(image: np.ndarray, entry: dict) -> np.ndarray:
# The meter's blue is still the original greyscale artwork data.
source = texels.copy()
source[..., :3] = source[..., 2:3]
- texels[..., 0] = meter_thresholds(source, cell["thresholds"])
+ texels[..., 0] = meter_thresholds(source, cell["thresholds"], cell.get("quantize_thresholds", False))
return image
diff --git a/tools/port_settings.py b/tools/port_settings.py
index 6e8028f85d..79c057ae57 100644
--- a/tools/port_settings.py
+++ b/tools/port_settings.py
@@ -13,9 +13,17 @@
mouse's controls, by the functions the PC version names for it.
"""
+from pathlib import Path
from xml.sax.saxutils import quoteattr
PE = "main_menu/settings_select/player_setup/player_profile_edit"
+# the mods' screen, and its rows (_mods); the main menu's items
+# (ce/main_menu.xml): MODS in Credits' place (Credits is left out:
+# tools/ce_menus.py's LEFT_OUT), Quit a row below it (its CHILD_OFFSETS)
+MODS = "main_menu/mods"
+MOD_ROWS = 11
+MODS_ITEM_PLACE = (192, 391)
+QUIT_ITEM_PLACE = (192, 427)
YES_NO = [("YES", "true"), ("NO", "false")]
ON_OFF = [("ON", "true"), ("OFF", "false")]
# the multiplayer armor colors, in the profile's order (player_profile.c)
@@ -71,6 +79,9 @@
"In first person, turn the view the moment the\nmouse moves, not up to two ticks later.", "desktop web"),
("HIGH-RES HUD:", "display.high_res_hud", ON_OFF,
"Draw the HUD from the high-res redraws; off\ndraws the game's own pictures.", None),
+ ("BUTTON STYLE:", "display.hud_button_style",
+ [("CLASSIC", "classic"), ("GEMS", "gems"), ("DARK GEMS", "menu_gems")],
+ "Classic: plain Xbox buttons. Gems: shaded Xbox\nbuttons. Dark Gems: the menu's button artwork.", None),
("HIGH-RES TEXT:", "display.high_res_text", ON_OFF,
"Draw text and titles with high-res fonts; off\ndraws the game's own.", None),
("ANTI-ALIASING:", "display.anti_aliasing",
@@ -194,7 +205,7 @@
# Video Setup's categories open ordinary settings screens. Each has the same
# pending edits, Defaults, OK and Cancel as the other settings screens.
_video = SCREENS["video_settings"]
-_graphics = {"display.high_res_hud", "display.high_res_text", "display.anti_aliasing",
+_graphics = {"display.high_res_hud", "display.hud_button_style", "display.high_res_text", "display.anti_aliasing",
"display.shadow_resolution", "display.per_pixel_lighting",
"display.shield_glow", "display.shield_glow_intensity"}
SCREENS["video_settings/graphics"] = {
@@ -226,6 +237,25 @@
],
}
+# Audio Setup's sound processing (port/linux/src/dsound_sdl.c): the reverb
+# and the resampler, a screen of their own (a category, as Video Setup's);
+# the devices and the volumes stay on Audio Setup
+_audio = SCREENS["audio_settings"]
+_processing = {"audio.reverb"}
+SCREENS["audio_settings/sound_processing"] = {
+ "screen": "sound_processing_settings_screen", "header": _audio["header"], "spacing": 30,
+ "rows": [row for row in _audio["rows"] if row[1] in _processing] + [
+ ("RESAMPLER:", "audio.resampler", [("SINC", "sinc"), ("LINEAR", "linear")],
+ "How sounds are converted to the output's rate:\nsinc is clean; linear is brighter, with aliasing.",
+ None),
+ ],
+}
+_audio["rows"] = [row for row in _audio["rows"] if row[1] not in _processing]
+_audio["categories"] = [
+ ("SOUND PROCESSING:", "audio_settings/sound_processing",
+ "The reverb, and how sounds are converted to the\noutput's rate."),
+]
+
# Network Setup's map sharing (port/linux/game/map_share.c): this machine's
# part in another's game, a screen of its own (a category, as Video Setup's)
SEED_UPLOADS = (64, 128, 256, 512, 1024, 2048)
@@ -618,6 +648,14 @@ def LOADOUT_HELPS(slot: str) -> list:
# changes to the PC version's widgets (by our names): attributes set, all
# their handlers replaced, children added, game data inputs added
WIDGET_PATCHES = {
+ # The host returns here after a round with no menu history. Back must
+ # reopen the main menu when it closes the last screen, as the lobby does.
+ f"{MT}/connected/connected_map_select_wrapper": {"set": {
+ "flags": "pass_unhandled_to_focused_child pause_game main_menu_if_no_history tag_controller_index force_handle_mouse",
+ }},
+ # (the main menu's MODS, before Quit: _mods)
+ "main_menu/main_menu_select_list": {"insert_before": {"main_menu/main_menu_item_quit_game": [
+ f'']}},
# (the profile settings' picture: on Gamepad Setup's row, the profile's
# button settings, BITMAP_FRAMES; menu_functions.c's
# profile_gamepad_layout)
@@ -720,6 +758,7 @@ def LOADOUT_HELPS(slot: str) -> list:
# the titles this port has that the PC version has not, set as its headers
# are (port/assets/menus/port_svg): bitmap name, text
TITLES = {
+ "main_menu/mods/header_mods": "MODS",
f"{MT}/join_game/header_server_browser": "SERVER BROWSER",
f"{MT}/join_game/header_direct_link": "DIRECT LINK",
f"{MT}/join_game/header_password": "PASSWORD",
@@ -761,23 +800,53 @@ def pause_box_height(buttons: int) -> int:
return 159 + (buttons - 2) * 35
-def pause_box_svg(piece: str, height: int) -> str:
+def pause_box_svg(piece: str, height: int, header: bool = False) -> str:
"""a piece of the pause box (left cap, centre strip, right cap; 9-slice:
the strip tiles across), height units high in its 256-high texture"""
width = 4 if piece == "center" else 16
lines = [f'", ""]
return "\n".join(lines)
@@ -1456,7 +1525,168 @@ def multiplayer_files() -> dict:
"main_menu/new_select".replace("/", ".") + ".port.xml": head + _map_kind() + ["", ""],
"main_menu/settings_select/multiplayer_setup/item_options_edit".replace("/", ".") + ".port.xml": head + _item_options_extras() + ["", ""],
TEAMPLAY_EDIT.replace("/", ".") + ".port.xml": head + _teamplay_options_extras() + ["", ""],
+ MODS.replace("/", ".") + ".xml": [head[0], "", head[2]] + _mods() + ["", ""],
}
REPLACED_FOLDERS = [f"{MT}/server_settings"]
+
+
+# ---------- the mods (port/linux/game/mods.c, docs/mods.md): the main menu's
+# MODS, which opens the list of the mods folder's mods, in their load order,
+# each turned on or off and moved up or down (menu_functions.c's "port mods"
+# functions)
+
+# the redraws' main menu items (svg/shell/main_menu/menu_*.svg): their text's
+# capitals' top and height, its colour dim (not chosen) and its glow chosen,
+# and where its middle is
+MENU_ITEM_TOP, MENU_ITEM_CAP, MENU_ITEM_MIDDLE = 6.57, 21.5 * 0.9037, 130.0
+TITLE_FONT_PATH = Path(__file__).resolve().parent.parent / "port/assets/fonts/OpenCE-Regular.ttf"
+# the mods' status icons (port/assets/icons/lucide): a mod out of the order it
+# asks for, and one that cannot be played (another mod says it cannot be
+# played with it, or this build cannot play it): menu_functions.c's
+# mods_update shows frame 1 or 2
+MOD_STATUS_ICONS = [("triangle-alert", "#FFB020"), ("circle-x", "#FF4040")]
+
+
+def menu_item_frames(text: str) -> list:
+ """the frames (svg, width, height) of a main menu item of the port's:
+ its text set in OpenCE (Newtown respaced: port/assets/fonts) as the
+ redraws' items are, dim, then white with their blue glow"""
+ from fontTools.pens.boundsPen import BoundsPen
+ from fontTools.pens.svgPathPen import SVGPathPen
+ from fontTools.pens.transformPen import TransformPen
+ from fontTools.ttLib import TTFont
+
+ font = TTFont(str(TITLE_FONT_PATH))
+ glyphs = font.getGlyphSet()
+ cmap = font.getBestCmap()
+ kerning = font["kern"].kernTables[0].kernTable if "kern" in font else {}
+ cap = glyphs["H"]
+ pen = BoundsPen(glyphs)
+ cap.draw(pen)
+ scale = MENU_ITEM_CAP / (pen.bounds[3] - pen.bounds[1])
+ names = [cmap[ord(character)] for character in text]
+ widths = [font["hmtx"][name][0] for name in names]
+ advance = sum(widths) + sum(kerning.get((first, second), 0) for first, second in zip(names, names[1:]))
+ x = MENU_ITEM_MIDDLE / scale - advance / 2
+ baseline = MENU_ITEM_TOP + MENU_ITEM_CAP
+ defs, uses = {}, []
+ for index, name in enumerate(names):
+ if name not in defs and name != "space":
+ path = SVGPathPen(glyphs, lambda value: f"{value:.2f}".rstrip("0").rstrip("."))
+ glyphs[name].draw(TransformPen(path, (1, 0, 0, -1, 0, 0)))
+ defs[name] = path.getCommands()
+ if name != "space":
+ uses.append(f'')
+ x += widths[index] + (kerning.get((name, names[index + 1]), 0) if index + 1 < len(names) else 0)
+ paths = [f' ' for name, commands in sorted(defs.items())]
+ head = ['", ""]
+ glow = [*head, ' ',
+ ' ', " ", " ",
+ f' ',
+ f' ', *[f" {use}" for use in uses], " ",
+ f' ', *[f" {use}" for use in uses], " ", "", ""]
+ return [("\n".join(dim), 256, 64), ("\n".join(glow), 256, 64)]
+
+
+def mod_status_frames(icons: Path, size: int = 24) -> list:
+ """the frames of the mods' status icon, `size` pixels square (the menus
+ draw a bitmap at its size): none, out of order, incompatible"""
+ blank = f'\n'
+ return [(blank, size, size)] + [
+ ((icons / f"{icon}.svg").read_text().replace('stroke="currentColor"', f'stroke="{color}"')
+ .replace('width="24"', f'width="{size}"').replace('height="24"', f'height="{size}"'), size, size)
+ for icon, color in MOD_STATUS_ICONS]
+
+
+def _text_button(name: str, text: str, handlers: list) -> list:
+ """a button of the button bar with its own words"""
+ pairs = [("type", "text"), ("width", 128), ("height", 24), ("bitmap", "bitmaps/text_button_background"),
+ ("text", text), ("font", "ui\\small_ui"), ("color", "#FFFFFFFF"), ("align", "center"), ("text_y", 2)]
+ return _widget(name, pairs, handlers + [''])
+
+
+def _mods() -> list:
+ """the main menu's MODS and its screen, laid out as the lobby is: on the
+ left two thirds the mods as a resource pack list has them (the one at the
+ top playing over those below it: the load order upside down), a row each
+ with its status icon, name and ON or OFF; on the right third, in the
+ lobby's frame, the mod chosen: its name, what is wrong with it, whether it
+ is on and its priority, and what its mod.toml says. A turns the mod chosen
+ on or off, left and right (and MOVE UP and MOVE DOWN, for the mouse) move
+ it; OK keeps it all (load_order.toml) and plays them, Cancel forgets it
+ (menu_functions.c)"""
+ base = MODS
+ lines = _widget("main_menu/main_menu_item_mods",
+ [("type", "text"), ("width", 256), ("height", 33), ("bitmap", "main_menu/menu_mods")],
+ [f'', f'',
+ ''])
+ lines += _widget(f"{base}/mods_screen", [("width", 640), ("height", 480),
+ ("flags", "pass_unhandled_to_focused_child pause_game"),
+ ("bitmap", "bitmaps/gradient")],
+ ['', '',
+ '',
+ f'',
+ f''])
+ lines += _header(f"{base}/header_mods", f"{base}/header_mods")
+ # (the lobby's rows, MOD_ROWS of them, scrolling over more mods)
+ rows = [f'' for index in range(MOD_ROWS)]
+ lines += _widget(f"{base}/mods_list", [("type", "column_list"), ("width", 640), ("height", 480),
+ ("flags", "pass_unhandled_to_focused_child up_down_tabs_children"),
+ ("description", f"{base}/mod_details")],
+ ['', '',
+ '', *rows,
+ f''])
+ for index in range(MOD_ROWS):
+ lines += _widget(f"{base}/op_mod_{index + 1}",
+ [("width", 390), ("height", 28), ("flags", "pass_unhandled_to_focused_child"),
+ ("bitmap", "bitmaps/sel_list_item_bkd_top" if index == 0 else "bitmaps/sel_list_item_bkd"),
+ ("color", "#FF2896FF")],
+ ['', '',
+ '', '',
+ '',
+ f'',
+ f'',
+ f''])
+ lines += _widget(f"{base}/mod_status", [("controller", 1), ("width", 24), ("height", 24),
+ ("bitmap", "mods/status")], [])
+ lines += _widget(f"{base}/mod_name",
+ [("type", "text"), ("controller", 1), ("width", 284), ("height", 22), ("font", "ui\\large_ui"),
+ ("color", "#FF2896FF"), ("text_y", 4)], [])
+ lines += _widget(f"{base}/mod_state",
+ [("type", "text"), ("controller", 1), ("width", 66), ("height", 22), ("font", "ui\\large_ui"),
+ ("color", "#FF2896FF"), ("align", "center"), ("text_y", 4), ("text_flags", "no_focus_test")], [])
+ # (in the lobby's frame, sel_list_desc_bkd's: the mod chosen's name, the
+ # icon of what is wrong with it, and the rest)
+ lines += _widget(f"{base}/mod_details", [("width", 640), ("height", 480)],
+ [f'', f'',
+ f''])
+ lines += _widget(f"{base}/mod_details_name",
+ [("type", "text"), ("controller", 1), ("left", 412), ("top", 84), ("width", 200), ("height", 44),
+ ("font", "ui\\large_ui"), ("color", "#FFFFFFFF")], [])
+ lines += _widget(f"{base}/mod_details_status", [("controller", 1), ("left", 411), ("top", 132), ("width", 16),
+ ("height", 16), ("bitmap", "mods/status_small")], [])
+ lines += _widget(f"{base}/mod_details_text",
+ [("type", "text"), ("controller", 1), ("left", 412), ("top", 130), ("width", 200),
+ ("height", 272), ("font", "ui\\small_ui"), ("color", "#FF2896FF")], [])
+ lines += _widget(f"{base}/button_bar",
+ [("type", "column_list"), ("width", 640), ("height", 28),
+ ("flags", "pass_unhandled_to_focused_child left_right_tabs_items")],
+ [f'', f'',
+ f'',
+ ''])
+ lines += _text_button(f"{base}/button_up", "MOVE UP", ['',
+ ''])
+ lines += _text_button(f"{base}/button_down", "MOVE DOWN", ['',
+ ''])
+ lines += _button(f"{base}/button_ok", 1, ['',
+ ''])
+ return lines
diff --git a/tools/test_android_asm_convert.py b/tools/test_android_asm_convert.py
new file mode 100644
index 0000000000..49ca09882f
--- /dev/null
+++ b/tools/test_android_asm_convert.py
@@ -0,0 +1,76 @@
+"""The Darwin to ELF assembly conversion of the ILP32 guest
+(tools/android_asm_convert.py): string literals keep every byte. It once took
+a quoted .ascii operand for a quoted symbol name and dropped its leading "_"
+(and, after an escaped quote, the "_" of any word after it), which shifted
+tables such as Opus's pulse cache by a byte: voice chat crashed in
+decode_pulses on the arm64 and Android builds."""
+import shutil
+import struct
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from tools.android_asm_convert import Converter
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def convert(*lines):
+ return Converter(list(lines)).run().splitlines()
+
+
+@pytest.mark.parametrize("directive", [
+ '.ascii\t"_c[}]L{s{"',
+ '.asciz\t"_transport_error_none"',
+ '.ascii\t"v\\177\\"O7v\\177_\\037\\374"',
+ '.ascii\t"=`\'\\\\_\\026"',
+ '.asciz\t"ltmp0 _x @PAGE ; not a comment"',
+])
+def test_string_literals_are_kept(directive):
+ op, args = directive.split("\t", 1)
+ assert convert("l_.str:", directive) == [".Ll_.str:", f"\t{op} {args}"]
+
+
+def test_symbols_are_still_renamed():
+ assert convert("\t.globl\t_table", "_table:", "\tadrp\tx8, _table@PAGE",
+ "\tadd\tx8, x8, _table@PAGEOFF", '\t.long\t"_odd name"') == [
+ "\t.globl table", "table:", "\tadrp x8, table", "\tadd x8, x8, :lo12:table", '\t.long "odd name"']
+
+
+def rodata(elf: bytes) -> bytes:
+ """the .rodata section of a 64-bit little-endian ELF object"""
+ shoff, = struct.unpack_from(" bytes:
+ """a tag file's 0x40-byte header: its group, its size, its group's version and its signature"""
+ data = bytearray(0x40)
+ data[0x24:0x28] = group
+ struct.pack_into(">I", data, 0x2C, 0x40)
+ struct.pack_into(">H", data, 0x38, version)
+ data[0x3C:0x40] = b"blam"
+ return bytes(data)
+
+
+def sound_file(frames: int = 2205) -> bytes:
+ """a sound of one pitch range of one permutation: mono 16-bit PCM at 22 kHz (sound_definitions.h)"""
+ samples = b"".join(struct.pack(">h", (index % 64 - 32) * 256) for index in range(frames))
+ root = bytearray(0xA4)
+ struct.pack_into(">h", root, 0x04, 4) # sound_class: weapon fire
+ struct.pack_into(">ff", root, 0x14, 1.0, 1.0) # random_pitch_bounds
+ struct.pack_into(">I", root, 0x70, int.from_bytes(b"snd!", "big")) # promotion_sound, none
+ struct.pack_into(">i", root, 0x7C, -1)
+ struct.pack_into(">I", root, 0x98, 1) # pitch_ranges
+ pitch_range = bytearray(0x48)
+ pitch_range[0:7] = b"default"
+ struct.pack_into(">f", pitch_range, 0x20, 1.0) # natural_pitch
+ struct.pack_into(">h", pitch_range, 0x2C, 1) # actual_permutation_count
+ struct.pack_into(">I", pitch_range, 0x3C, 1) # permutations
+ permutation = bytearray(0x7C)
+ permutation[0:4] = b"fire"
+ struct.pack_into(">f", permutation, 0x24, 1.0) # gain
+ struct.pack_into(">hh", permutation, 0x28, 0, -1) # compression: none; next_permutation_index
+ struct.pack_into(">I", permutation, 0x40, len(samples)) # samples
+ return header(b"snd!", 4) + bytes(root) + bytes(pitch_range) + bytes(permutation) + samples
+
+
+def bitmap_file(count: int = 1, size: int = 32) -> bytes:
+ """a bitmap of one sequence of `count` 2D A8R8G8B8 bitmaps (bitmap_group.h)"""
+ pixels_size = size * size * 4
+ pixels = b"".join(bytes([0x40 + index * 16, 0x80, 0xC0, 0xFF]) * (size * size) for index in range(count))
+ root = bytearray(0x6C)
+ struct.pack_into(">I", root, 0x30, len(pixels)) # processed pixel data
+ struct.pack_into(">I", root, 0x54, 1) # sequences
+ struct.pack_into(">I", root, 0x60, count) # bitmaps
+ sequence = bytearray(0x40)
+ sequence[0:5] = b"rocks"
+ struct.pack_into(">hh", sequence, 0x20, 0, count)
+ bitmaps = b""
+ for index in range(count):
+ bitmap = bytearray(0x30)
+ bitmap[0:4] = b"bitm"
+ struct.pack_into(">hhhhhH", bitmap, 4, size, size, 1, 0, 11, 1) # 2D, A8R8G8B8, power of two
+ struct.pack_into(">i", bitmap, 0x18, index * pixels_size)
+ bitmaps += bytes(bitmap)
+ return header(b"bitm", 7) + bytes(root) + pixels + bytes(sequence) + bitmaps
+
+
+def strings_file(texts: list) -> bytes:
+ """a unicode string list (text_group.h): its strings in UTF-16, terminated"""
+ encoded = [(text + "\0").encode("utf-16-le") for text in texts]
+ root = struct.pack(">I", len(texts)) + bytes(8)
+ entries = b"".join(struct.pack(">I", len(data)) + bytes(16) for data in encoded)
+ return header(b"ustr", 1) + root + entries + b"".join(encoded)
+
+
+class MapTags:
+ """an Xbox map's tags (assets/maps: compressed after their header), read as the game has them in memory"""
+
+ def __init__(self, path: Path):
+ data = path.read_bytes()
+ data = data[:0x800] + zlib.decompress(data[0x800:])
+ offset, size = struct.unpack_from(" str:
+ start = address - TAG_BASE
+ return self.tags[start:self.tags.index(b"\0", start)].decode()
+
+ def block(self, address: int, offset: int, size: int) -> list:
+ """a block's elements (the bytes of each) of the structure at `address`"""
+ count, elements = struct.unpack_from(" int:
+ for group_found, name_found, definition in self.instances.values():
+ if group_found == group and name_found == name.replace("/", "\\"):
+ return definition
+ raise LookupError(name)
+
+
+def model_file(tags: MapTags, name: str, move_marker: bool = False, rename_node: bool = False,
+ gbxmodel: bool = False) -> bytes:
+ """A model tag file (the Xbox's .model, as invader-extract writes one, or Halo PC's .gbxmodel) of the map's model
+ `name`: its nodes, regions, permutations and markers (on its permutations, as the tool keeps them), its detail
+ levels' cutoffs (from super high to super low, as a tag file has them), one shader of the map's, and geometry of
+ its own, one triangle on its first node, its links to other parts the tool's none (0 and 0); its first marker
+ moved, or its first node renamed."""
+ model = tags.find(b"mode", name)
+ root = tags.tags[model - TAG_BASE:][:0xE8]
+ nodes = tags.block(model, 0xB8, 0x9C)
+ regions = tags.block(model, 0xC4, 0x4C)
+ shader = tags.block(model, 0xDC, 0x20)[0][1]
+ # the map's markers, by their region and permutation: name, node, rotation, translation
+ markers = {}
+ for address, marker in tags.block(model, 0xAC, 0x40):
+ for _, instance in tags.block(address, 0x34, 0x20):
+ region, permutation, node = instance[0], instance[1], instance[2]
+ translation, rotation = struct.unpack_from("<3f", instance, 4), struct.unpack_from("<4f", instance, 16)
+ markers.setdefault((region, permutation), []).append((marker[:32], node, rotation, translation))
+ if move_marker:
+ first = min(markers)
+ name_bytes, node, rotation, translation = markers[first][0]
+ markers[first][0] = (name_bytes, node, rotation, (translation[0] + 0.5,) + translation[1:])
+ out = bytearray(header(b"mod2", 5) if gbxmodel else header(b"mode", 4))
+ flags, checksum = struct.unpack_from("II5f", flags, checksum, *reversed(struct.unpack_from("<5f", root, 8))) + bytes(0x30 - 0x1C)
+ out += struct.pack(">2f", *struct.unpack_from("<2f", root, 0x30)) + bytes(0xAC - 0x38)
+ for count in (0, len(nodes), len(regions), 1, 1):
+ out += struct.pack(">I", count) + bytes(8)
+ for index, (_, node) in enumerate(nodes):
+ node_name = (b"renamed" if rename_node and index == 0 else node[:32].split(b"\0")[0]).ljust(32, b"\0")
+ out += node_name + struct.pack(">3h2x3f4ff", *struct.unpack_from("<3h", node, 32),
+ *struct.unpack_from("<3f4ff", node, 40)) + bytes(0x9C - 0x48)
+ permutations = []
+ for region_index, (address, region) in enumerate(regions):
+ perms = tags.block(address, 0x40, 0x58)
+ permutations.append(perms)
+ out += region[:0x40] + struct.pack(">I", len(perms)) + bytes(8)
+ for region_index, perms in enumerate(permutations):
+ for permutation_index, (_, permutation) in enumerate(perms):
+ geometries = [0 if index != -1 else -1 for index in struct.unpack_from("<5h", permutation, 0x40)]
+ out += (permutation[:0x20] + struct.pack(">I", struct.unpack_from("5h2x", *geometries) +
+ struct.pack(">I", len(markers.get((region_index, permutation_index), []))) + bytes(8))
+ for permutation_index in range(len(perms)):
+ for name_bytes, node, rotation, translation in markers.get((region_index, permutation_index), []):
+ out += name_bytes + struct.pack(">h2x4f3f", node, *rotation, *translation) + bytes(16)
+ # one geometry of one part: a triangle on the first node, drawn with the map's first shader
+ out += bytes(0x24) + struct.pack(">I", 1) + bytes(8)
+ part = bytearray(0x84 if gbxmodel else 0x68)
+ struct.pack_into(">IhBB", part, 0, 0, 0, 0, 0)
+ struct.pack_into(">I", part, 0x20, 3)
+ struct.pack_into(">I", part, 0x38, 1)
+ out += part
+ for position in ((0, 0, 0), (0.1, 0, 0), (0, 0.1, 0)):
+ out += struct.pack(">3f3f3f3f2f2h2f", *position, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, -1, 1, 0)
+ out += struct.pack(">3H", 0, 1, 2)
+ shader_name = tags.string(struct.unpack_from("IIi", 0, len(shader_name), -1) + struct.pack(">h", 0) + bytes(14)
+ out += shader_name + b"\0"
+ return bytes(out)
+
+
+@pytest.fixture(scope="module")
+def game(tmp_path_factory) -> Path:
+ """the Linux build, copied with its SDL to a folder of its own: its config.toml, which it writes beside
+ itself, is the test's (not build/linux's)"""
+ if not GAME.is_file() or not (MAPS / "bloodgulch.map").is_file() or not shutil.which("xvfb-run"):
+ pytest.skip("needs build/linux/halo, assets/maps/bloodgulch.map and xvfb-run")
+ folder = tmp_path_factory.mktemp("game")
+ for name in ("halo", "libSDL3.so.0"):
+ if (GAME.parent / name).is_file():
+ shutil.copy2(GAME.parent / name, folder / name)
+ return folder / "halo"
+
+
+def run_game(game: Path, data: Path, saves: Path) -> str:
+ """Blood Gulch with the mods, for a few seconds: the mods' lines of the output and debug.txt"""
+ (data / "maps").symlink_to(MAPS.resolve())
+ (data / "init.txt").write_text(f"map_name {LEVEL}\n", encoding="ascii")
+ # (a window, hidden on a display of its own: debug.exit_after ends a
+ # game that has one, and the textures are loaded, a bitmap's from its
+ # file; such a run is never asked about mods that conflict)
+ environment = {**os.environ, "HALO_DATA_ROOT": str(data), "HALO_SAVE_ROOT": str(saves),
+ "HALO_EXIT_AFTER": "25", "HALO_HIDDEN_WINDOW": "1", "SDL_VIDEO_DRIVER": "x11",
+ "HALO_DISPLAY_MODE": "windowed", "HALO_WINDOW_SIZE": "640x480",
+ "HALO_RESOLUTION_SCALING": "original", "HALO_VOLUME": "0", "HALO_NET_ONLINE": "0"}
+ environment.pop("WAYLAND_DISPLAY", None)
+ process = subprocess.Popen(["xvfb-run", "-a", "-s", "-screen 0 640x480x24", str(game)], cwd=game.parent,
+ env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
+ start_new_session=True)
+ try:
+ output, _ = process.communicate(timeout=180)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.communicate()
+ pytest.fail("the game did not exit")
+ assert process.returncode == 0, output[-4000:]
+ # (the mods found at startup are logged before debug.txt opens: on the output)
+ log = output + (data / "debug.txt").read_text(errors="replace")
+ return "\n".join(line for line in log.splitlines() if "mods:" in line)
+
+
+def write_mod(data: Path, files: dict, folder: str = "test", toml: str = 'name = "Test"\nversion = "1"\n') -> None:
+ mod = data / "mods" / folder
+ (mod / "tags").mkdir(parents=True)
+ (mod / "mod.toml").write_text(toml, encoding="utf-8")
+ for path, content in files.items():
+ target = mod / "tags" / path
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(content)
+
+
+def test_a_mod_plays_over_the_map(game, tmp_path):
+ data, saves = tmp_path / "data", tmp_path / "saves"
+ data.mkdir()
+ saves.mkdir()
+ write_mod(data, {
+ f"{SOUND}.sound": sound_file(),
+ f"{BITMAP}.bitmap": bitmap_file(),
+ f"{STRINGS}.unicode_string_list": strings_file(["MOD TEXT"] * 4),
+ })
+ lines = run_game(game, data, saves)
+ assert "1 string lists from their files (1 new)" in lines, lines
+ assert "1 bitmaps from their files (1 new)" in lines, lines
+ assert "1 sounds from their files (1 new)" in lines, lines
+ assert "left out:" not in lines, lines
+
+
+def test_damaged_files_are_left_out(game, tmp_path):
+ data, saves = tmp_path / "data", tmp_path / "saves"
+ data.mkdir()
+ saves.mkdir()
+ sound = bytearray(sound_file())
+ struct.pack_into(">I", sound, 0x40 + 0x98, 0x7FFFFFFF) # more pitch ranges than the file holds
+ strings = bytearray(strings_file(["MOD TEXT"]))
+ struct.pack_into(">I", strings, 0x4C, 0x10000) # a string past the file's end
+ write_mod(data, {
+ f"{SOUND}.sound": bytes(sound),
+ f"{BITMAP}.bitmap": bitmap_file()[:-8], # cut short
+ f"{STRINGS}.unicode_string_list": bytes(strings),
+ })
+ lines = run_game(game, data, saves)
+ assert "0 string lists from their files (0 new), 0 given back, 1 left out" in lines, lines
+ assert "0 bitmaps from their files (0 new), 0 given back, 1 left out" in lines, lines
+ assert "0 sounds from their files (0 new), 0 given back, 1 left out" in lines, lines
+
+
+def folders(tmp_path):
+ data, saves = tmp_path / "data", tmp_path / "saves"
+ data.mkdir()
+ saves.mkdir()
+ return data, saves
+
+
+def test_the_load_order(game, tmp_path):
+ # alphabetical, but a_first asks to load after z_last, and b_middle asks to load before a_first: the sound of the
+ # mod loaded last (a_first's) plays, and b_middle's, damaged, is never read
+ data, saves = folders(tmp_path)
+ damaged = bytearray(sound_file())
+ struct.pack_into(">I", damaged, 0x40 + 0x98, 0x7FFFFFFF)
+ write_mod(data, {f"{SOUND}.sound": sound_file()}, "a_first", 'name = "First"\nload_after = ["last"]\n')
+ write_mod(data, {f"{SOUND}.sound": bytes(damaged)}, "b_middle", 'load_before = ["A_First"]\n')
+ write_mod(data, {}, "z_last", 'id = "last"\n')
+ lines = run_game(game, data, saves)
+ assert "mods: load order b_middle, z_last, a_first" in lines, lines
+ assert "1 sounds from their files (1 new), 0 given back, 0 left out" in lines, lines
+
+
+def test_load_order_toml(game, tmp_path):
+ # the player's order is kept, a mod it does not name goes where the default order puts it, and a mod turned off
+ # plays nothing
+ data, saves = folders(tmp_path)
+ for folder in ("a", "b", "c", "d"):
+ write_mod(data, {f"{SOUND}.sound": sound_file()} if folder == "c" else {}, folder, "")
+ (data / "mods" / "load_order.toml").write_text('order = ["c", "a", "d"]\ndisabled = ["c"]\n', encoding="utf-8")
+ lines = run_game(game, data, saves)
+ assert "mods: load order c (off), a, b, d" in lines, lines
+ assert "mods: c (" not in lines, lines
+
+
+def test_incompatible_mods(game, tmp_path):
+ # a run nobody watches is not asked: it plays them all, and says which conflict
+ data, saves = folders(tmp_path)
+ write_mod(data, {}, "german", 'name = "German text"\nincompatible = ["hd_audio"]\n')
+ write_mod(data, {}, "hd_audio", 'name = "HD audio"\n')
+ lines = run_game(game, data, saves)
+ assert "mods: German text says it cannot be played with HD audio." in lines, lines
+ assert "mods: played as they are, nobody being asked" in lines, lines
+
+
+def test_models(game, tmp_path):
+ # the map's model with geometry of its own plays (Halo PC's .gbxmodel); with a marker moved, or a node renamed
+ # (the Xbox's .model), it changes what the machines agree on, and is left out
+ data, saves = folders(tmp_path)
+ tags = MapTags(MAPS / "bloodgulch.map")
+ write_mod(data, {f"{FP_MODEL}.gbxmodel": model_file(tags, FP_MODEL, gbxmodel=True),
+ f"{MODEL}.model": model_file(tags, MODEL, move_marker=True),
+ f"{VEHICLE_MODEL}.model": model_file(tags, VEHICLE_MODEL, rename_node=True)})
+ lines = run_game(game, data, saves)
+ assert "its markers are not the map's" in lines, lines
+ assert "its nodes are not the map's" in lines, lines
+ assert "1 models from their files (1 new), 0 given back, 2 left out" in lines, lines