Compare commits

..
Author SHA1 Message Date
Stefan MaierandClaude Opus 5.5 f13ccc98cb Test: our join state enum renamed beside upstream's join progress
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:11:10 +01:00
Stefan MaierandClaude Opus 5.5 499c548ac5 Test: merge upstream/main (network version 39) and OpenCE#486, the VR hand sent as fire origin
For the VR + flat network test of OpenCE#486: upstream's main (network
version 39, client builds as distributed message 92) and the PR's branch
(fire origins, distributed message 93). network_vr.c registers the
weapon hand as the fire origin source; our own host-side use of the VR
poses for the line of sight is dropped in favour of the PR's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:09:43 +01:00
Stefan MaierandClaude Opus 5.5 85dba477c3 Accept a client's fire origin only where a hand firing at the target can be, and let hosts turn it off (#486)
As reviewed: the host cannot tell a VR client from a modified flat one,
so a client could claim a hand 0.4 world units to any side and shoot
around a corner without its camera ever being exposed (the line from the
eye to the hand stops shooting through walls, not around edges).

- The hand must be where one firing at the target can be: no more than
  0.05 world units (0.15 m) behind the eye along the line to the target,
  0.2 (0.6 m: a lean and a hand held out) to its side, and 0.1 (0.3 m)
  above the eye (distributed_fire_origin_shape).
- The hands are looked from only after no eye (nor vehicle gun) had a
  line, and the hits only a hand let through are counted in the host's
  log of clients' hits.
- network.fire_origins (true by default; also a dedicated server's
  setting) turns the hand off: from the camera and vehicles' guns only.
- The message is distributed message 93 (92 is now the client's build).
- A client sends its fire origin when it changes and every eighth tick,
  not every tick.

Checked: ninja android builds without warnings in the changed files;
test_fire_origins.py (15 cases with 9 negative controls: ownership,
reach, the store, the resend and its record, and each of the shape's
three limits) passes, run on macOS as 64-bit; the rest of the harness
fails there only where it did before (32-bit layouts); network_format.py
--record adds message 93 and its structs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:00:40 +01:00
Stefan MaierandClaude Opus 5.5 ba1c3816bd Host: look for line of sight from where a player's shots leave, when a client says it is not the camera
The host's new line of sight check (#474) looks from the shooter's
camera, or a vehicle gun's muzzle. In a VR build (Mjolnir VR, which
plays with OpenCE builds of the same network version) the weapon fires
from the player's hand, and the view follows the head up to 0.35 m from
the body: a shot past a corner or over an edge that the unit's camera is
behind is refused, though the shooter saw and hit the target.

A client whose players' shots leave from elsewhere than their units'
cameras now sends the host, unreliably every tick, where from: an offset
from the camera (distributed message 93, struct distributed_fire_origin),
no further than 0.4 world units (a player leaning out and an arm). The
host takes it for that machine's own players only, held a second, and
the line of sight check looks from there as from a vehicle's gun: the
line from the eye to it must be clear, so a hand through a wall does not
hit. A build registers where its players fire from with
network_damage_set_fire_origin_source; none does here, so this build
sends nothing. No new network version: a host that does not know
message 93 drops it, and such a host checks as before.

Checked: ninja android builds; the new harness test
(tools/harness/tests/test_fire_origins.py: the host takes only a
machine's own players' origins within reach, a client sends only what
its source gives within reach, with negative controls) and the other
network harness tests pass (run on macOS as 64-bit; aim_assist and
latency_meter fail there with or without this change, as they assume
the 32-bit layout); network_format.py --record adds the message and its
structs. Not played in a network game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:00:40 +01:00
bnunu 3c7ed45e6f Merge pull request #434 from s3anyboy/controller_layouts
Recon and fishsticks
2026-10-26 08:35:50 -04:00
S3anyBoy 8bc9c179cb Merge branch 'OpenCommunityEdition:main' into controller_layouts 2026-10-26 06:57:42 -05:00
MrBruhandClaude Opus 5.5 5388f2f418 Write the join screen's menu file again with the browser word for a refused name (tools/port_settings.py)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 00:11:19 +13:00
MrBruhandClaude Opus 5.5 09123e997c Let a dedicated server keep words out of its chat and its players' names (server.chat_filter, off by default): streamer mode's list by group and severity (slurs from mild, swearing and insults off by default), "censor" starring a line's words, "block" refusing the line and refusing at the door a machine whose name has them (rejection code 7, "name", which an updated game shows as "The host refused: your player name").
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 00:00:42 +13:00
MrBruh d9e9c86e56 Merge pull request #494 from OpenCommunityEdition/client-build-info
Have a client tell the host its build, the GitHub repository it is ma…
2026-10-26 23:24:34 +13:00
MrBruhandClaude Opus 5.5 d0acb3bd61 Have a client tell the host its build, the GitHub repository it is made from and the mods it plays, which a dedicated server shows in /api/players (client) and logs once: a new distributed message older builds drop as a kind they do not know, so the network version stays 39.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 23:20:52 +13:00
MrBruh be29b20fe2 Merge pull request #492 from OpenCommunityEdition/browser-dedicated-masters
Show a game under the server browser's DEDICATED on a master server's…
2026-10-26 21:33:53 +13:00
S3anyBoy 75743bb5c7 Merge branch 'OpenCommunityEdition:main' into controller_layouts 2026-10-26 03:30:33 -05:00
MrBruhandClaude Opus 5.5 41473a55d2 Show a game under the server browser's DEDICATED on a master server's word alone, not its listing's own dedicated flag, which any host can set: a game the brokers alone list is shown as the players'.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 21:28:01 +13:00
MrBruh 51150d617c Merge pull request #491 from OpenCommunityEdition/dedicated-server-chat
Give a public dedicated server its text chat, network.text_chat's (on…
2026-10-26 20:33:45 +13:00
MrBruhandClaude Opus 5.5 1042811584 Give a public dedicated server its text chat, network.text_chat's (on by default, live with sv_set), while a public game a player hosts keeps none: a client follows the host's chat in a game joined from the server browser only when a master server lists it as a dedicated server's, an approved operator's, not on the listing's own word.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 20:33:04 +13:00
MrBruh 0a46688c3d Merge pull request #490 from OpenCommunityEdition/dedicated-server
Dedicated server
2026-10-26 19:49:37 +13:00
MrBruhandClaude Opus 5.5 686e7c8774 Build the server image only after a Build of main, weekly and by hand, not on pull requests, where it built the image of main's last release rather than the request's code.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 19:38:15 +13:00
MrBruhandClaude Opus 5.5 1fc3a35079 Log a failed SDL_Init once, and again only when its reason changes, rather than on every frame that asks for SDL (tens of thousands of lines a minute without a display).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 19:36:55 +13:00
MrBruhandClaude Opus 5.5 27f79d3eb9 Describe the master's /apply as taking the region, from a menu, and the number of servers as its options, its form keeping the display name, the public key, the proof and a note.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 19:09:27 +13:00
MrBruhandClaude Opus 5.5 5653960e78 Open the server browser on ALL GAMES, the dedicated servers first and then the games players host, rather than on DEDICATED: network.browser_source defaults to "all".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 18:11:47 +13:00
MrBruhandClaude Opus 5.5 a8b85a09c0 Let a fleet run the dedicated server: the owner's role over the web from loopback, POST /api/bans and sv_ban_address, sv_stop and sv_restart drains, the players' addresses in events, the server's identity in its status and capabilities in the RCON's hello, and each release's SHA256SUMS signed with the release key (deploy/release-key.pub, tools/release_sign.sh).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 17:21:00 +13:00
S3anyBoy bd362e1e2f Merge branch 'OpenCommunityEdition:main' into controller_layouts 2026-10-25 21:48:51 -05:00
bnunu b09a3ea739 Merge pull request #477 from PeterRichardsonGolf/fix/gun-game-plasma
Gun Game: the plasma rifle and plasma pistol doesn't fire
2026-10-25 16:40:58 -04:00
MrBruh 43c8f6eebe Read config_answer_only in the config_write test's file writer, so that its probe-writes negative control compiles under clang 23's -Wunused-but-set-global. 2026-10-26 02:44:19 +13:00
MrBruh 5177040890 Ask the official master server, master.opencommunityedition.com, built into the game and the web page, when masters.txt names no master. 2026-10-26 02:26:03 +13:00
MrBruh 815d3a0ca5 Add a dedicated server (halo --server) with its web admin page, RCON, master server listings and the server browser's DEDICATED tab, marking dedicated games in the LAN advertisement and internet listing in network version 39. 2026-10-25 21:37:29 +13:00
S3anyBoy 1095223b2b Merge branch 'OpenCommunityEdition:main' into controller_layouts 2026-10-24 20:48:00 -05:00
PeterRichardsonGolfandClaude Opus 5.5 0a8584b260 Test Gun Game's spent weapon check against battery weapons' magazines (#477)
The real gun_game_weapon_empty against a fake plasma rifle (one magazine
holding nothing), pistol and weapon without magazines: full and spent
batteries, rounds loaded, carried and none. Negative controls: the old
return, the empty magazine not skipped, and every magazine skipped, each
caught by a named case. Checked: the test's 7 pass and the whole harness
passes (867).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:33:02 -06:00
PeterRichardsonGolfandClaude Opus 5.5 8e8fa4d760 Gun Game: the plasma rifle and plasma pistol can fire
gun_game_weapon_empty used the battery (age) only for a weapon with no
magazines. Halo CE's plasma rifle and plasma pistol have one magazine
block with every count 0, so the loop found no rounds and called them
empty: gun_game_equip deleted the weapon and gave a new one on every
update, and those levels could never fire (bots holding them slid around).

A magazine whose definition holds no rounds is skipped; a weapon with no
magazine that holds rounds is judged by its battery, as one without
magazines is.

Found in Halo CE: Arena Evolved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:25:20 -06:00
s3anyboy d97c98c726 Recon and fishsticks
add controller layouts
2026-10-20 21:13:43 -05:00
219 changed files with 44250 additions and 421 deletions

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
name: Server image
# The dedicated server's container image (deploy/docker), made from a
# release's halo-linux-release.zip, and the Helm chart's checks
# (deploy/helm/opence-server).
#
# - After each successful Build of main (which publishes release build-<n>):
# the image of that release, checked, pushed to
# ghcr.io/opencommunityedition/opence-server as sha-<commit>, build-<n> and
# latest, and signed keyless with cosign (this workflow's GitHub identity).
# - Weekly, the latest release's image again, for Debian's fixes.
# - By hand, any release's (latest moves only for the newest).
#
# The checks (no game data in CI: it is never published): the image runs as
# 10001:10001; started on a read-only root without maps it writes its
# settings to /state, says "server: no usable maps" and exits 2; halo
# --version answers; the health check fails with nothing listening.
#
# Verify:
# cosign verify ghcr.io/opencommunityedition/opence-server:latest \
# --certificate-identity-regexp '^https://github\.com/OpenCommunityEdition/OpenCE/\.github/workflows/server-image\.yml@' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
on:
workflow_run:
workflows: [Build]
types: [completed]
branches: [main]
schedule:
- cron: "23 5 * * 1"
workflow_dispatch:
inputs:
release:
description: "The release to build the image of: a tag (build-206) or latest"
default: latest
concurrency:
group: server-image-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
env:
IMAGE: ghcr.io/opencommunityedition/opence-server
jobs:
chart:
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- uses: azure/setup-helm@v4
- name: Lint
run: |
set -euo pipefail
chart=deploy/helm/opence-server
for values in "$chart"/ci/*-values.yaml; do
helm lint "$chart" --strict -f "$values"
helm template ci "$chart" -f "$values" > /dev/null
done
# the maps are never in the image: without game data the chart refuses to render
if helm template ci "$chart" > /dev/null 2>&1; then
echo "::error::the chart renders without gameData"; exit 1
fi
- name: Unit tests
run: |
set -euo pipefail
# (Helm 4 checks plugins' signatures, which helm-unittest does not publish)
if helm version --short | grep -q '^v3'; then verify=; else verify=--verify=false; fi
helm plugin install https://github.com/helm-unittest/helm-unittest $verify
helm unittest deploy/helm/opence-server
image:
if: >-
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
packages: write
# keyless signing: the workflow's OIDC token gets a short-lived Sigstore certificate
id-token: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Release
id: release
env:
GH_TOKEN: ${{ github.token }}
EVENT: ${{ github.event_name }}
RUN_NUMBER: ${{ github.event.workflow_run.run_number }}
WANTED: ${{ inputs.release }}
run: |
set -euo pipefail
case "$EVENT" in
workflow_run) tag="build-$RUN_NUMBER" ;;
workflow_dispatch) tag="${WANTED:-latest}" ;;
*) tag=latest ;;
esac
newest="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)"
if [ "$tag" = latest ]; then
tag="$newest"
fi
commit="$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json targetCommitish -q .targetCommitish)"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern halo-linux-release.zip --dir deploy/docker
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "commit=$commit" >> "$GITHUB_OUTPUT"
echo "newest=$([ "$tag" = "$newest" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "Release $tag (commit $commit; the newest: $newest)"
- name: Build
env:
TAG: ${{ steps.release.outputs.tag }}
COMMIT: ${{ steps.release.outputs.commit }}
run: |
set -euo pipefail
docker build --pull -t opence-server:ci \
--build-arg RELEASE=halo-linux-release.zip \
--label org.opencontainers.image.source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
--label org.opencontainers.image.revision="$COMMIT" \
--label org.opencontainers.image.version="$TAG" \
--label org.opencontainers.image.licenses=AGPL-3.0-or-later \
--label org.opencontainers.image.title="OpenCE dedicated server" \
deploy/docker
- name: Smoke test
run: |
set -euo pipefail
[ "$(docker image inspect -f '{{.Config.User}}' opence-server:ci)" = 10001:10001 ] \
|| { echo "::error::the image does not run as 10001:10001"; exit 1; }
docker run --rm --read-only --entrypoint /opt/opence/halo opence-server:ci --version | tee version.txt
grep -Eq '^OpenCE build-[0-9]+ \(network version [0-9]+\)$' version.txt \
|| { echo "::error::halo --version did not answer"; exit 1; }
# without maps, on a read-only root with /state the one writable folder
docker volume create smoke-state > /dev/null
status=0
timeout 60 docker run --name smoke --read-only --cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp -v smoke-state:/state -e HALO_SERVER_WEB_PASSWORD=ci-smoke-password \
opence-server:ci > server.log 2>&1 || status=$?
cat server.log
docker rm -f smoke > /dev/null
[ "$status" = 2 ] || { echo "::error::the server without maps exited with $status, not 2"; exit 1; }
grep -qF 'server: no usable maps' server.log \
|| { echo "::error::no 'server: no usable maps' line"; exit 1; }
if grep -qF 'server: ready' server.log; then echo "::error::the server said it was ready"; exit 1; fi
docker run --rm -v smoke-state:/state --entrypoint /bin/bash opence-server:ci -c 'test -s /state/config.toml' \
|| { echo "::error::no /state/config.toml"; exit 1; }
# the health check, with nothing listening
if docker run --rm --entrypoint /opt/opence/healthz.sh opence-server:ci; then
echo "::error::the health check passed with no server"; exit 1
fi
docker volume rm smoke-state > /dev/null
- uses: docker/login-action@v3
if: github.repository == 'OpenCommunityEdition/OpenCE'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: sigstore/cosign-installer@v3
if: github.repository == 'OpenCommunityEdition/OpenCE'
- name: Push and sign
if: github.repository == 'OpenCommunityEdition/OpenCE'
env:
TAG: ${{ steps.release.outputs.tag }}
COMMIT: ${{ steps.release.outputs.commit }}
NEWEST: ${{ steps.release.outputs.newest }}
IDENTITY: ^https://github\.com/${{ github.repository }}/\.github/workflows/server-image\.yml@
run: |
set -euo pipefail
tags=("sha-${COMMIT:0:7}" "$TAG")
if [ "$NEWEST" = true ]; then
tags+=(latest)
fi
for tag in "${tags[@]}"; do
docker tag opence-server:ci "$IMAGE:$tag"
docker push "$IMAGE:$tag"
done
ref="$(docker image inspect -f '{{index .RepoDigests 0}}' "$IMAGE:$TAG")"
test -n "$ref"
cosign sign --yes "$ref"
cosign verify "$ref" --certificate-identity-regexp "$IDENTITY" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com > /dev/null
{
echo "Pushed and signed $ref as ${tags[*]}."
echo
echo "Verify: cosign verify $IMAGE:$TAG --certificate-identity-regexp '$IDENTITY' --certificate-oidc-issuer https://token.actions.githubusercontent.com"
} >> "$GITHUB_STEP_SUMMARY"
+13 -5
View File
@@ -15,19 +15,20 @@ 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`), 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, OpenCE's gametypes (`game_modes.c`, `infection.c`, `gun_game.c`), map sharing (`map_share*.c`), the menus (`menu_tags.c`, `menu_functions.c`), Custom Edition maps (`custom_edition_*.c`, `cache_file_formats.c`), the tag validator (`tag_validate.c`, `tag_schema_*.c`), mods (`mods.c`, `mod_*.c`), streamer mode's word filter (`text_filter*.c`), touch, FOV and more |
| `port/linux/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`), the dedicated server's platform side (`server_*.c`: the mode, the log, the rotation's grammar, HTTP, RCON, the master servers' publisher) and the master servers' list (`master_list.c`) |
| `port/linux/game/` | The port's game-side code, compiled as the game's sources are: the distributed netcode (`network_*.c`), co-op, OpenCE's gametypes (`game_modes.c`, `infection.c`, `gun_game.c`), map sharing (`map_share*.c`), the menus (`menu_tags.c`, `menu_functions.c`), Custom Edition maps (`custom_edition_*.c`, `cache_file_formats.c`), the tag validator (`tag_validate.c`, `tag_schema_*.c`), mods (`mods.c`, `mod_*.c`), streamer mode's word filter (`text_filter*.c`), the dedicated server's director, commands, gametypes and status (`server_*.c`), touch, FOV and more |
| `port/linux/include/` | The port's headers: the limits (`halo_port_limits.h`, with `HALO_PORT_NETWORK_VERSION`), capacities (`halo_port_capacity.h`), the prefix header, `halo_linux_source_fixups.h` (port functions the game's sources call) |
| `port/include/xdk/` | Stand-ins for the Xbox SDK's headers (declarations only) |
| `port/windows/` | The Windows build's own files (`win32_*.c`, headers) |
| `port/android/` | The Android app: the guest runtime, the host library (`host/`), the Java app (`app/`), host imports (`host_imports.list`) |
| `port/web/` | The web build: its runtime (`src/`: entry, sockets inside the page, memory watch), the page and its service worker (`site/`), the SDL port, tests |
| `port/assets/` | What the builds embed or ship: high-res HUD (`hud/`), fonts, titles, menus (`menus/`), icons, network brokers |
| `port/assets/` | What the builds embed or ship: high-res HUD (`hud/`), fonts, titles, menus (`menus/`), icons, network brokers and master servers, the dedicated server's web page (`server_ui/`) |
| `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`), tests, and `stagekit/` (scenarios staged and instrumented on the real engine, no retail assets) |
| `docs/` | Longer design notes (`custom_edition_caches.md`, `mods.md`) |
| `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`, `server_ui_embed.py`, `xdk_headers.py`), the dedicated server's protocol simulator and remote console (`server_sim.py`, `opence_rcon.py`), tests, and `stagekit/` (scenarios staged and instrumented on the real engine, no retail assets) |
| `docs/` | Longer design notes (`custom_edition_caches.md`, `mods.md`, `dedicated_server.md`, `master_protocol.md`) |
| `pgo/` | Profile-guided optimisation profiles |
| `deploy/` | Running a dedicated server: systemd, Docker, Pterodactyl, Helm, PufferPanel, MCSManager, example gametypes |
| `assets/` | Game data for local runs (gitignored: `maps/`, `custom_maps/`) |
| `.github/workflows/` | CI: builds every platform on each push, publishes releases from `main` |
@@ -94,6 +95,8 @@ python tools/test_death_timing.py
python -m pytest -q tools/test_web_build.py # the web build's graph, sockets, menus
node --test "port/web/tests/*.test.js" # the web page's importer and service worker
node port/web/tests/webrtc_native.mjs chromium firefox # the native builds' WebRTC with browsers (Playwright)
python -m pytest -q tools/test_dedicated_server.py # the dedicated server with clients (needs assets/maps)
python -m pytest -q tools/test_server_sim.py tools/test_egg.py # the server's web page and protocol simulator, the egg
python -m pytest -q tools/test_net_compare.py # net_compare.py's judging of logs, its copies and their processes
python tools/net_compare.py --a build/linux/halo --b <other build> --data <game data> --swap
# two builds in one network game, in step (retail maps: local only)
@@ -110,6 +113,9 @@ Running the game without a person at it:
several copies play on one machine on different loopback addresses
(`HALO_NET_ADDRESS`, `HALO_NET_BROADCAST`). `NETCODE.md` describes the
test settings.
- `halo --server` is a dedicated server (`docs/dedicated_server.md`), and
`halo --probe [<invite>]` prints a game's advertisement as JSON and exits;
`tools/test_dedicated_server.py` runs both with clients.
- `debug.exit_after`, `debug.hidden_window`, `debug.null_renderer`,
`debug.menu_open` (start on one menu screen), `debug.telnet_console`.
- Use a virtual display (Xvfb, with `SDL_VIDEO_DRIVER=x11`) for windowed
@@ -231,6 +237,8 @@ The decompilation's upstream, punpckhdq/halo, is AGPL 3.0 from 17 October
| [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/dedicated_server.md](docs/dedicated_server.md) | The dedicated server: its command line, exit codes, commands, HTTP API, RCON, events and files |
| [docs/master_protocol.md](docs/master_protocol.md) | The master servers' protocol: publishing, the probe, the signed list |
| [docs/mods.md](docs/mods.md) | Mods: folders of Halo PC tag files whose sounds, bitmaps, text and models play over a map's on one machine |
| [port/assets/text_filter/README.md](port/assets/text_filter/README.md) | Streamer mode's words: Surge AI's list, the review, the allowed words |
| [port/assets/menus/README.md](port/assets/menus/README.md) | The menu files' format and how to write them again |
+3
View File
@@ -0,0 +1,3 @@
*
!healthz.sh
!*.zip
+2
View File
@@ -0,0 +1,2 @@
# a release zip copied here to build the image from (README.md, "Building it")
*.zip
+60
View File
@@ -0,0 +1,60 @@
# syntax=docker/dockerfile:1
#
# The dedicated server (docs/dedicated_server.md) as a container image, made
# from a release's halo-linux-release.zip (the portable build: halo, its
# libSDL3.so.0, brokers.txt, masters.txt):
#
# docker build -t opence-server deploy/docker
# docker build -t opence-server --build-arg RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/download/build-206/halo-linux-release.zip deploy/docker
# docker build -t opence-server --build-arg RELEASE=halo-linux-release.zip deploy/docker (a zip in deploy/docker)
#
# The game data is never in the image: mount the folder that holds maps/ at
# /data, read-only. /state is the only folder the server writes (config.toml,
# the save root with its map cache, the logs), so the root can be read-only.
# deploy/docker/README.md says how to run it.
FROM debian:bookworm-slim AS release
RUN apt-get update \
&& apt-get install -y --no-install-recommends unzip \
&& rm -rf /var/lib/apt/lists/*
# a URL, or a file beside this Dockerfile
ARG RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip
ADD ${RELEASE} /tmp/halo-linux-release.zip
COPY healthz.sh /opt/opence/healthz.sh
RUN unzip -q /tmp/halo-linux-release.zip -d /opt/opence \
&& test -f /opt/opence/halo \
&& chmod 0755 /opt/opence/halo /opt/opence/healthz.sh \
&& if [ -f /opt/opence/halo-server ]; then chmod 0755 /opt/opence/halo-server; fi
FROM debian:bookworm-slim
# The 32-bit C runtime; the release's own libSDL3.so.0 needs nothing more for
# a server, which opens no window or sound device. ca-certificates: the
# master servers and the update check are HTTPS.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends libc6:i386 libgcc-s1:i386 ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -g 10001 opence \
&& useradd -u 10001 -g 10001 -d /state -M -s /usr/sbin/nologin opence \
&& mkdir -p /data /state \
&& chown 10001:10001 /state
COPY --from=release /opt/opence /opt/opence
ENV HALO_SERVER=1 \
HALO_CONFIG_FILE=/state/config.toml \
HALO_DATA_ROOT=/data \
HALO_SAVE_ROOT=/state/saves \
HALO_LOG_ROOT=/state/logs \
HALO_NET_TUNNEL_PORT=2302 \
HALO_SERVER_WEB_BIND=0.0.0.0 \
HALO_SERVER_WEB_PORT=8080 \
HOME=/state
VOLUME ["/state"]
# Internet play's tunnel, and the web page, API and RCON (publish it on the
# host's loopback, or behind a proxy with TLS). System link's 5150, 5151 and
# 5153 are for a local network only.
EXPOSE 2302/udp 8080/tcp
USER 10001:10001
WORKDIR /state
HEALTHCHECK --interval=15s --timeout=3s --start-period=60s --retries=3 CMD ["/opt/opence/healthz.sh"]
STOPSIGNAL SIGTERM
ENTRYPOINT ["/opt/opence/halo", "--server"]
+134
View File
@@ -0,0 +1,134 @@
# The dedicated server in a container
`ghcr.io/opencommunityedition/opence-server` is the dedicated server
(`docs/dedicated_server.md`) as a container image: a release's
`halo-linux-release.zip` on Debian 12 with the 32-bit C runtime, run as
`halo --server` by the user 10001. CI builds it from each release
(`.github/workflows/server-image.yml`) and signs it with cosign; the tags are
`latest`, the release's (`build-206`) and `sha-<commit>`.
The game data is never in the image. Mount the folder that holds `maps/`
(from your own copy of the game: port/linux/README.md, "Game data") at
`/data`, read-only. Without it the server exits with code 2 and says which
maps it looked for.
## Running it
```sh
docker run -d --name halo --restart unless-stopped \
--read-only --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
-p 2302:2302/udp -p 127.0.0.1:8080:8080 \
-v /srv/halo/data:/data:ro -v halo-state:/state \
-e HALO_SERVER_NAME="My server" \
-e HALO_SERVER_ROTATION="bloodgulch:ctf, sidewinder:team_slayer" \
-e HALO_SERVER_WEB_PASSWORD=changeme-please \
ghcr.io/opencommunityedition/opence-server:latest
docker logs -f halo
```
Or with Compose: `compose.yaml` beside this file runs the same, with the
settings taken from the shell or a `.env` file
(`HALO_DATA=/srv/halo/data`, `HALO_SERVER_NAME=...`). Its `https` profile
adds Caddy in front of the web port, with a certificate for
`HALO_WEB_DOMAIN`:
```sh
HALO_DATA=/srv/halo/data docker compose -f deploy/docker/compose.yaml up -d
HALO_WEB_DOMAIN=halo.example.org docker compose -f deploy/docker/compose.yaml --profile https up -d
```
The Compose file puts both on a network of its own, `172.30.80.0/24`, with
Caddy at the fixed address `172.30.80.10` and the server's
`HALO_SERVER_WEB_TRUSTED_PROXY` set to it: the server believes Caddy's
`X-Forwarded-For`, `-Host` and `-Proto`, so its rate limits and log name the
visitor rather than Caddy, and the session cookie is `Secure`. If that
subnet overlaps one of the host's, change the subnet, Caddy's
`ipv4_address` and the variable together.
`docker stop` sends SIGTERM: the game in progress goes to its postgame,
the players are told, the listing is withdrawn, and the server exits 0
within the 15 seconds Compose waits (`docker stop -t 15` otherwise).
## Folders
| Path | What |
| --- | --- |
| `/opt/opence` | The release: `halo`, `libSDL3.so.0`, `brokers.txt`, `masters.txt`, `healthz.sh`, the licences (read-only) |
| `/data` | The game data: `maps/`, and if you have them `custom_maps/`, `gametypes/` and `init.txt` (yours, read-only) |
| `/state` | The only folder written: `config.toml` (`HALO_CONFIG_FILE`), `publisher.key` beside it, the save root `saves/` with the map cache (about 115 MB), `logs/` (`debug.txt`, `bans.txt`, `cheaters.txt`) |
The image sets `HALO_CONFIG_FILE=/state/config.toml` rather than linking
`config.toml` beside the executable into the volume: the settings file is
then in the volume from the first start, and nothing in `/opt/opence` is
written. The server writes every setting there at the first start, with
the generated web password if none was given.
## Settings
Every setting is an environment variable (port/linux/README.md, "Dedicated
server", and docs/dedicated_server.md). A variable that is set wins over
`config.toml`, and the web page's settings cannot change it. The image sets:
| Variable | Value |
| --- | --- |
| `HALO_SERVER` | `1` |
| `HALO_CONFIG_FILE` | `/state/config.toml` |
| `HALO_DATA_ROOT` | `/data` |
| `HALO_SAVE_ROOT` | `/state/saves` |
| `HALO_LOG_ROOT` | `/state/logs` |
| `HALO_NET_TUNNEL_PORT` | `2302` |
| `HALO_SERVER_WEB_BIND` | `0.0.0.0` (within the container; publish the port on `127.0.0.1` or behind TLS) |
| `HALO_SERVER_WEB_PORT` | `8080` |
The ones most often given: `HALO_SERVER_NAME`, `HALO_SERVER_ROTATION`,
`HALO_SERVER_MAX_PLAYERS`, `HALO_SERVER_PASSWORD`, `HALO_SERVER_PUBLIC`,
`HALO_SERVER_MOTD`, `HALO_SERVER_WEB_PASSWORD`, `HALO_NET_PUBLIC_ADDRESS`,
`HALO_SERVER_PUBLISHER_KEY` (the master servers' key, as 64 hex
characters; `docker run --rm -v halo-state:/state <image> --keygen` makes one
into the volume instead), `HALO_SERVER_INSTANCE`, `HALO_SERVER_REGION`,
`HALO_SERVER_LOG_FORMAT=json`.
A tunnel port other than 2302 is published as the same number on both sides
(`-e HALO_NET_TUNNEL_PORT=2303 -p 2303:2303/udp`), as the server offers its
own port to joiners. Behind a NAT that STUN cannot see through, give the
public address and port in `HALO_NET_PUBLIC_ADDRESS`.
## Health
`HEALTHCHECK` runs `/opt/opence/healthz.sh`, which asks `GET /healthz` on
the web port (bash's `/dev/tcp`: the image has no curl) and is healthy on a
200: the server is hosting (lobby, loading, playing or postgame) and its
frames run. With `HALO_SERVER_WEB_PORT=0` it always passes.
## Building it
```sh
docker build -t opence-server deploy/docker # the latest release
docker build -t opence-server --build-arg RELEASE=https://github.com/OpenCommunityEdition/OpenCE/releases/download/build-206/halo-linux-release.zip deploy/docker
```
A build of your own (`python tools/ci_build.py linux release` leaves
`dist/halo-linux-release/`) goes in as a zip beside the Dockerfile:
```sh
(cd dist/halo-linux-release && zip -qr ../../deploy/docker/halo-linux-release.zip .)
docker build -t opence-server --build-arg RELEASE=halo-linux-release.zip deploy/docker
```
`RELEASE` is a URL or a file beside the Dockerfile. The image is 32-bit
x86 and runs on amd64 hosts only.
## Networking
Bridge networking works for internet play: the tunnel port is published one
to one, and the server needs no hole punching of its own. System link
(UDP 5150 and 5151, TCP 5150 and 5153) finds other machines only with
`--network host`.
Verify an image's signature:
```sh
cosign verify ghcr.io/opencommunityedition/opence-server:latest \
--certificate-identity-regexp '^https://github\.com/OpenCommunityEdition/OpenCE/\.github/workflows/server-image\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```
+92
View File
@@ -0,0 +1,92 @@
# The dedicated server with Docker Compose (deploy/docker/README.md):
#
# docker compose -f deploy/docker/compose.yaml up -d
# docker compose -f deploy/docker/compose.yaml --profile https up -d (with Caddy in front of the web port)
#
# HALO_DATA is the folder that holds maps/ (and custom_maps/, gametypes/),
# mounted read-only; settings come from the environment below or from a
# .env file beside this one.
name: opence
services:
server:
image: ${OPENCE_IMAGE:-ghcr.io/opencommunityedition/opence-server:latest}
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
tmpfs:
- /tmp
ports:
- "${HALO_NET_TUNNEL_PORT:-2302}:${HALO_NET_TUNNEL_PORT:-2302}/udp"
# (the web page on the host's loopback only; Caddy reaches it on the
# compose network)
- "127.0.0.1:${HALO_SERVER_WEB_PORT:-8080}:${HALO_SERVER_WEB_PORT:-8080}/tcp"
volumes:
- ${HALO_DATA:-./data}:/data:ro
- state:/state
# A variable named without a value is passed only when it is set (in the
# shell or .env): one that is set wins over config.toml, and sv_set
# cannot change it.
environment:
- HALO_NET_TUNNEL_PORT=${HALO_NET_TUNNEL_PORT:-2302}
- HALO_SERVER_WEB_PORT=${HALO_SERVER_WEB_PORT:-8080}
- HALO_SERVER_NAME
- HALO_SERVER_ROTATION
- HALO_SERVER_MAX_PLAYERS
- HALO_SERVER_PASSWORD
- HALO_SERVER_PUBLIC
- HALO_SERVER_MOTD
# (8 characters or more; unset: one is made at the first start, printed
# in the log and kept in the state volume's config.toml)
- HALO_SERVER_WEB_PASSWORD
- HALO_NET_PUBLIC_ADDRESS
- HALO_SERVER_PUBLISHER_KEY
- HALO_SERVER_INSTANCE
- HALO_SERVER_REGION
- HALO_SERVER_LOG_FORMAT
# Caddy's address on the network below (the https profile): its
# X-Forwarded-For, -Host and -Proto are believed, so the page's rate
# limits and log name the visitor, and its session cookie is Secure.
# Nothing else has this address.
- HALO_SERVER_WEB_TRUSTED_PROXY=172.30.80.10
networks:
- opence
stop_grace_period: 15s
# HTTPS for the web page, with a certificate from Let's Encrypt for
# HALO_WEB_DOMAIN (which must resolve to this host, ports 80 and 443 open).
caddy:
image: caddy:2
profiles: [https]
restart: unless-stopped
depends_on: [server]
ports:
- "80:80/tcp"
- "443:443/tcp"
- "443:443/udp"
command: ["caddy", "reverse-proxy", "--from", "${HALO_WEB_DOMAIN:-halo.example.org}", "--to", "server:${HALO_SERVER_WEB_PORT:-8080}"]
volumes:
- caddy-data:/data
- caddy-config:/config
networks:
opence:
# (server.web_trusted_proxy above; outside the range Docker hands
# out, so no other container takes it)
ipv4_address: 172.30.80.10
# A fixed subnet, so that Caddy's address is known in advance. Containers
# other than Caddy get addresses from ip_range only. Change it (and the two
# addresses above) if it overlaps a network of the host's.
networks:
opence:
ipam:
config:
- subnet: 172.30.80.0/24
ip_range: 172.30.80.128/25
gateway: 172.30.80.1
volumes:
state:
caddy-data:
caddy-config:
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# The image's health check: GET /healthz on the web port, healthy on a 200
# (the server answers 200 while it hosts and its frames run, 503 otherwise).
# Bash's /dev/tcp, as the image has no curl.
port="${HALO_SERVER_WEB_PORT:-8080}"
# (no web port: nothing to ask)
if [ -z "$port" ] || [ "$port" = 0 ]; then
exit 0
fi
{ exec 3<>"/dev/tcp/127.0.0.1/$port"; } 2>/dev/null || exit 1
printf 'GET /healthz HTTP/1.1\r\nHost: 127.0.0.1:%s\r\nConnection: close\r\n\r\n' "$port" >&3 || exit 1
IFS=' ' read -r -t 3 version status _ <&3 || exit 1
exec 3<&-
case "$version" in
HTTP/1.*) ;;
*) exit 1 ;;
esac
[ "$status" = 200 ]
+29
View File
@@ -0,0 +1,29 @@
# Big team slayer: first team to 50 kills, vehicles as Halo PC places them.
# Play it with server.rotation entries such as bloodgulch:btb_slayer.
base = "team_slayer"
name = "BTB Slayer"
[universal]
teams = true
score_to_win = 50
lives = 0
respawn_time = 5
health = 100
weapon_set = "normal"
vehicle_set = "default"
radar = true
friend_indicators = true
[options]
time_limit = 20
friendly_fire = "shields_only"
friendly_fire_penalty = 5
vehicle_respawn_time = 60
auto_team_balance = true
radar_players = "friends"
team_vehicles = "pc"
[slayer]
no_death_bonus = false
no_kill_penalty = false
kill_in_order = false
+27
View File
@@ -0,0 +1,27 @@
# Capture the flag as players remember it: three captures, the flag must be
# at home to score.
base = "ctf"
name = "Classic CTF"
[universal]
teams = true
score_to_win = 3
respawn_time = 5
weapon_set = "normal"
vehicle_set = "default"
radar = true
[options]
time_limit = 25
friendly_fire = "off"
vehicle_respawn_time = 90
auto_team_balance = true
radar_players = "friends"
team_vehicles = "pc"
[ctf]
assault = false
reset_on_capture = false
flag_must_reset = false
flag_at_home_to_score = true
single_flag_time = 0
+17
View File
@@ -0,0 +1,17 @@
# King of the hill whose hill moves about the map, for teams.
base = "team_king"
name = "Moving Hill"
[universal]
teams = true
score_to_win = 3
respawn_time = 5
radar = true
[options]
time_limit = 15
friendly_fire = "off"
auto_team_balance = true
[king]
moving_hill = true
+23
View File
@@ -0,0 +1,23 @@
# Free for all slayer to 25 kills, starting with a pistol and an assault rifle.
base = "slayer"
name = "Lone Wolves"
[universal]
teams = false
score_to_win = 25
respawn_time = 3
health = 100
radar = false
[options]
time_limit = 15
radar_players = "none"
loadout = "custom"
primary_weapon = "pistol"
secondary_weapon = "assault_rifle"
team_vehicles = "pc"
[slayer]
no_death_bonus = false
no_kill_penalty = false
kill_in_order = false
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec "$(dirname "$0")/halo" --server "$@"
+3
View File
@@ -0,0 +1,3 @@
# Not part of the packaged chart: unit tests and CI values files.
tests/
ci/
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v2
name: opence-server
description: >-
The OpenCE dedicated server: a headless Halo: Combat Evolved host that plays games one after
another, with no window, sound or player of its own. The game data (maps) is mounted from a
volume you provide.
type: application
version: 0.1.0
kubeVersion: ">=1.29.0-0"
# Informational only: which image runs is set by image.repository, image.tag and image.digest.
appVersion: "latest"
home: https://github.com/OpenCommunityEdition/OpenCE
sources:
- https://github.com/OpenCommunityEdition/OpenCE/tree/main/deploy/helm/opence-server
keywords:
- halo
- game-server
+111
View File
@@ -0,0 +1,111 @@
# opence-server
The OpenCE dedicated server (`docs/dedicated_server.md`) on Kubernetes: one
pod of the image `ghcr.io/opencommunityedition/opence-server`
(`deploy/docker`), its tunnel port behind a UDP Service, its web page behind
a ClusterIP Service, probes on `GET /healthz`, and a NetworkPolicy.
The game data is never in the image or the chart: give a volume that holds
`maps/` from your own copy of the game.
## Quick start
```sh
kubectl create namespace halo
# a claim (or an NFS export) holding maps/, read-only is enough
helm install halo deploy/helm/opence-server -n halo \
--set gameData.existingClaim=halo-maps \
--set server.name="My server" \
--set persistence.enabled=true
kubectl -n halo logs -f deploy/halo-opence-server
```
The admin password of the web page is made at the first install and kept in
the release's Secret (NOTES.txt prints the command that shows it), unless
`web.password` or `web.existingSecret` gives one. Reach the page with
`kubectl -n halo port-forward deploy/halo-opence-server 8080`, or put an
Ingress with TLS in front of the `-web` Service (and allow its namespace
in `networkPolicy.webFrom`).
## Values
`values.yaml` describes each value. Each one under `server`, `web`,
`network` and `publisherKey` is one of the server's `HALO_*` variables:
| Value | Variable |
| --- | --- |
| `server.name` | `HALO_SERVER_NAME` (15 characters) |
| `server.rotation` (a list) | `HALO_SERVER_ROTATION`, joined with commas |
| `server.maxPlayers` | `HALO_SERVER_MAX_PLAYERS` |
| `server.public` | `HALO_SERVER_PUBLIC` |
| `server.lobbySeconds` | `HALO_SERVER_LOBBY_SECONDS` |
| `server.motd` | `HALO_SERVER_MOTD` |
| `server.logFormat` | `HALO_SERVER_LOG_FORMAT` |
| `server.instance`, `server.region` | `HALO_SERVER_INSTANCE`, `HALO_SERVER_REGION` |
| `server.masterPublish` | `HALO_SERVER_MASTER_PUBLISH` |
| `server.password` | `HALO_SERVER_PASSWORD` (from a Secret) |
| `publisherKey` | `HALO_SERVER_PUBLISHER_KEY` (from a Secret) |
| `web.port` | `HALO_SERVER_WEB_PORT` (0: none, and no probes) |
| `web.password`, `web.existingSecret` | `HALO_SERVER_WEB_PASSWORD` (from a Secret) |
| `web.moderatorPassword` | `HALO_SERVER_WEB_MODERATOR_PASSWORD` (from a Secret) |
| `network.tunnelPort` | `HALO_NET_TUNNEL_PORT` |
| `network.publicAddress` | `HALO_NET_PUBLIC_ADDRESS` |
| `customMaps` | `HALO_CUSTOM_MAPS_ROOT=/custom_maps` |
The chart also sets `HALO_SERVER=1`, `HALO_CONFIG_FILE=/state/config.toml`,
`HALO_DATA_ROOT=/data`, `HALO_SAVE_ROOT=/state/saves`,
`HALO_LOG_ROOT=/state/logs`, `HALO_SERVER_CONSOLE=false` (a pod has no
standard input) and, with a web port, `HALO_SERVER_WEB_BIND=0.0.0.0` (the
kubelet's probes reach the pod's address). Any other setting goes in
`extraEnv`, which comes last and so overrides the chart's.
A variable the chart sets wins over `config.toml`, and neither `sv_set` nor
the web page's settings can change it. So the values under `server` are
unset by default (empty, `[]` or `null`): the chart then sets no variable,
and the setting is `config.toml`'s (or the server's default, which
`values.yaml` names), changeable from the web page and kept in the state
volume. Give a value only for a setting you want fixed.
## Web password
With neither `web.password` nor `web.existingSecret`, the chart makes an
admin password at the first install and keeps it in the release's Secret;
on an upgrade it finds that Secret again with `lookup`. `helm template`,
and GitOps tools that render with it (Argo CD, Flux's dry runs,
Kustomize's Helm support), have no cluster to look in: each render makes a
new password, and the Secret changes at every sync. There, create the Secret
yourself and name it:
```sh
kubectl -n halo create secret generic halo-web \
--from-literal=web-password="$(openssl rand -base64 18)" \
--from-literal=web-moderator-password="$(openssl rand -base64 18)"
helm template halo deploy/helm/opence-server -n halo \
--set gameData.existingClaim=halo-maps --set web.existingSecret=halo-web
```
(or a SealedSecret or an External Secrets object of the same name).
`web.key` and `web.moderatorKey` name its keys; the moderator's is
optional.
## Networking
`service.type` is `LoadBalancer` with `externalTrafficPolicy: Local` by
default, so that the server sees the players' own addresses (its bans and
limits are by address). `NodePort` works too; `ClusterIP` is for tests. The
tunnel's Service port and `network.tunnelPort` should be the same number, as
the server offers its own port to joiners; behind a NAT that STUN cannot see
through, give the forwarded address in `network.publicAddress`.
The NetworkPolicy lets anyone reach the tunnel port and only `webFrom` the
web port, and lets the pod reach DNS, TCP 1883 (the MQTT brokers) and 443
(the master servers) and any UDP port on public addresses.
## Tests
```sh
helm lint deploy/helm/opence-server --strict -f deploy/helm/opence-server/ci/full-values.yaml
helm unittest deploy/helm/opence-server
```
`.github/workflows/server-image.yml` runs both.
@@ -0,0 +1,87 @@
# Every option the chart has, for lint and the schema check (not installed: the NFS server is made
# up).
image:
tag: build-206@sha256:0000000000000000000000000000000000000000000000000000000000000000
imagePullSecrets:
- name: registry-credentials
gameData:
nfs:
server: nfs.example.com
path: /exports/halo
subPath: xbox
customMaps:
existingClaim: halo-custom-maps
server:
name: Example server
rotation: [bloodgulch:ctf_classic, sidewinder:team_slayer+score=50, hangemhigh:king_moving]
maxPlayers: 24
public: true
lobbySeconds: 20
motd: Welcome
logFormat: json
instance: eu-1
region: eu
masterPublish: true
password:
existingSecret: halo-join
key: password
publisherKey:
existingSecret: halo-publisher
key: seed
web:
port: 8080
existingSecret: halo-web
key: admin
moderatorKey: moderator
network:
tunnelPort: 2303
publicAddress: halo.example.com:2303
extraEnv:
- name: HALO_SERVER_EMPTY_SECONDS
value: "60"
persistence:
enabled: true
storageClass: standard
annotations:
example.com/keep: "true"
service:
type: LoadBalancer
port: 2303
nodePort: 30703
annotations:
example.com/address: 192.0.2.10
externalTrafficPolicy: Local
loadBalancerSourceRanges: [0.0.0.0/0]
tolerations:
- key: dedicated
operator: Equal
value: games
effect: NoSchedule
podAnnotations:
example.com/scrape: "true"
podLabels:
team: games
probes:
liveness:
periodSeconds: 30
readiness:
enabled: false
networkPolicy:
webFrom:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
extraEgress:
- to:
- ipBlock:
cidr: 203.0.113.0/24
ports:
- protocol: TCP
port: 8443
extraObjects:
- apiVersion: v1
kind: ConfigMap
metadata:
name: '{{ .Release.Name }}-extra'
data:
hello: world
@@ -0,0 +1,6 @@
# The smallest install that renders: no maps (an emptyDir in their place), so the server exits with
# code 2 and the pod goes into CrashLoopBackOff. A real install gives gameData.existingClaim or
# gameData.nfs instead.
gameData:
volume:
emptyDir: {}
@@ -0,0 +1,33 @@
OpenCE dedicated server {{ .Chart.Version }}: {{ include "opence-server.fullname" . }} in namespace {{ .Release.Namespace }}.
Rotation: {{ join ", " .Values.server.rotation | default "the server's default" }}.
{{- if kindIs "invalid" .Values.server.public }}
Listed in everyone's server browser{{ with .Values.server.name }} as "{{ . }}"{{ end }}, unless config.toml or the web page turns server.public off.
{{- else if .Values.server.public }}
Listed in everyone's server browser{{ with .Values.server.name }} as "{{ . }}"{{ end }}.
{{- else }}
Not listed: players join with the invite link from the log, or on the LAN.
{{- end }}
The log (the ready line, players, games):
kubectl -n {{ .Release.Namespace }} logs -f deploy/{{ include "opence-server.fullname" . }}
Players reach UDP port {{ .Values.service.port }} through Service {{ include "opence-server.fullname" . }} ({{ .Values.service.type }}).
{{- if eq .Values.service.type "ClusterIP" }}
A ClusterIP Service is not reachable from outside the cluster: set service.type to LoadBalancer or
NodePort for internet play.
{{- end }}
{{- if gt (int .Values.web.port) 0 }}
The web page, API and remote console:
kubectl -n {{ .Release.Namespace }} port-forward deploy/{{ include "opence-server.fullname" . }} {{ .Values.web.port }}
open http://127.0.0.1:{{ .Values.web.port }}/
{{- if not .Values.web.existingSecret }}
The admin password:
kubectl -n {{ .Release.Namespace }} get secret {{ include "opence-server.secretName" . }} -o jsonpath='{.data.{{ .Values.web.key }}}' | base64 -d
{{- end }}
{{- end }}
@@ -0,0 +1,84 @@
{{/* The objects' name: fullnameOverride, else the release name if it contains the chart's name,
else <release>-<chart>. */}}
{{- define "opence-server.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end }}
{{- define "opence-server.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "opence-server.labels" -}}
{{ include "opence-server.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "opence-server.selectorLabels" -}}
app.kubernetes.io/name: {{ include "opence-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/* repository:tag, repository:tag@digest, or repository@digest; an empty tag is the appVersion. */}}
{{- define "opence-server.image" -}}
{{- $image := .Values.image -}}
{{- $ref := $image.repository -}}
{{- $tag := toString ($image.tag | default .Chart.AppVersion) -}}
{{- if $image.digest }}{{ $tag = regexReplaceAll "@.*$" $tag "" }}{{ if hasPrefix "sha256:" $tag }}{{ $tag = "" }}{{ end }}{{ end -}}
{{- if hasPrefix "sha256:" $tag }}{{ $ref = printf "%s@%s" $ref $tag }}
{{- else if $tag }}{{ $ref = printf "%s:%s" $ref $tag }}{{ end -}}
{{- if and $image.digest (not (contains "@" $ref)) }}{{ $ref = printf "%s@%s" $ref $image.digest }}{{ end -}}
{{- $ref -}}
{{- end }}
{{- define "opence-server.stateClaim" -}}
{{- .Values.persistence.existingClaim | default (printf "%s-state" (include "opence-server.fullname" .)) -}}
{{- end }}
{{/* The chart's Secret: the web passwords it makes or is given, the join password, the publisher
key. */}}
{{- define "opence-server.secretName" -}}
{{- printf "%s-secrets" (include "opence-server.fullname" .) -}}
{{- end }}
{{/* The Secret holding the web password: web.existingSecret, else the chart's. */}}
{{- define "opence-server.webSecret" -}}
{{- .Values.web.existingSecret | default (include "opence-server.secretName" .) -}}
{{- end }}
{{- define "opence-server.hasGameData" -}}
{{- with .Values.gameData -}}
{{- if or .existingClaim (and .nfs.server .nfs.path) .volume }}true{{ end -}}
{{- end -}}
{{- end }}
{{- define "opence-server.hasCustomMaps" -}}
{{- with .Values.customMaps -}}
{{- if or .existingClaim (and .nfs.server .nfs.path) .volume }}true{{ end -}}
{{- end -}}
{{- end }}
{{/* A volume source of gameData or customMaps (the dict), read-only. */}}
{{- define "opence-server.volumeSource" -}}
{{- if .existingClaim }}
persistentVolumeClaim:
claimName: {{ .existingClaim }}
readOnly: true
{{- else if and .nfs.server .nfs.path }}
nfs:
server: {{ .nfs.server }}
path: {{ .nfs.path }}
readOnly: true
{{- else }}
{{- toYaml .volume }}
{{- end }}
{{- end }}
@@ -0,0 +1,198 @@
{{- if not (include "opence-server.hasGameData" .) }}
{{- fail "gameData: set existingClaim, nfs.server and nfs.path, or volume - the server needs the game's maps, which are never in the image" }}
{{- end }}
{{- $fullname := include "opence-server.fullname" . }}
{{- $secret := include "opence-server.secretName" . }}
{{- $webPort := int .Values.web.port }}
{{- $env := list }}
{{- $env = append $env (dict "name" "HALO_SERVER" "value" "1") }}
{{- $env = append $env (dict "name" "HALO_CONFIG_FILE" "value" "/state/config.toml") }}
{{- $env = append $env (dict "name" "HALO_DATA_ROOT" "value" "/data") }}
{{- $env = append $env (dict "name" "HALO_SAVE_ROOT" "value" "/state/saves") }}
{{- $env = append $env (dict "name" "HALO_LOG_ROOT" "value" "/state/logs") }}
{{- if include "opence-server.hasCustomMaps" . }}
{{- $env = append $env (dict "name" "HALO_CUSTOM_MAPS_ROOT" "value" "/custom_maps") }}
{{- end }}
{{- /* (a pod has no standard input to read commands from) */}}
{{- $env = append $env (dict "name" "HALO_SERVER_CONSOLE" "value" "false") }}
{{- $env = append $env (dict "name" "HALO_NET_TUNNEL_PORT" "value" (toString (int .Values.network.tunnelPort))) }}
{{- with .Values.network.publicAddress }}
{{- $env = append $env (dict "name" "HALO_NET_PUBLIC_ADDRESS" "value" .) }}
{{- end }}
{{- with .Values.server.name }}
{{- if gt (len .) 15 }}
{{- fail "server.name: 15 characters at most" }}
{{- end }}
{{- $env = append $env (dict "name" "HALO_SERVER_NAME" "value" .) }}
{{- end }}
{{- with .Values.server.rotation }}
{{- $env = append $env (dict "name" "HALO_SERVER_ROTATION" "value" (join ", " .)) }}
{{- end }}
{{- /* (a value left null is not set, so that config.toml, sv_set and the web page's settings can
change it) */}}
{{- if not (kindIs "invalid" .Values.server.maxPlayers) }}
{{- $env = append $env (dict "name" "HALO_SERVER_MAX_PLAYERS" "value" (toString (int .Values.server.maxPlayers))) }}
{{- end }}
{{- if not (kindIs "invalid" .Values.server.public) }}
{{- $env = append $env (dict "name" "HALO_SERVER_PUBLIC" "value" (toString .Values.server.public)) }}
{{- end }}
{{- if not (kindIs "invalid" .Values.server.lobbySeconds) }}
{{- $env = append $env (dict "name" "HALO_SERVER_LOBBY_SECONDS" "value" (toString (int .Values.server.lobbySeconds))) }}
{{- end }}
{{- with .Values.server.motd }}
{{- $env = append $env (dict "name" "HALO_SERVER_MOTD" "value" .) }}
{{- end }}
{{- with .Values.server.logFormat }}
{{- $env = append $env (dict "name" "HALO_SERVER_LOG_FORMAT" "value" .) }}
{{- end }}
{{- with .Values.server.instance }}
{{- $env = append $env (dict "name" "HALO_SERVER_INSTANCE" "value" .) }}
{{- end }}
{{- with .Values.server.region }}
{{- $env = append $env (dict "name" "HALO_SERVER_REGION" "value" .) }}
{{- end }}
{{- if not (kindIs "invalid" .Values.server.masterPublish) }}
{{- $env = append $env (dict "name" "HALO_SERVER_MASTER_PUBLISH" "value" (toString .Values.server.masterPublish)) }}
{{- end }}
{{- if .Values.server.password.existingSecret }}
{{- $env = append $env (dict "name" "HALO_SERVER_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" .Values.server.password.existingSecret "key" .Values.server.password.key))) }}
{{- else if .Values.server.password.value }}
{{- $env = append $env (dict "name" "HALO_SERVER_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" $secret "key" .Values.server.password.key))) }}
{{- end }}
{{- if .Values.publisherKey.existingSecret }}
{{- $env = append $env (dict "name" "HALO_SERVER_PUBLISHER_KEY" "valueFrom" (dict "secretKeyRef" (dict "name" .Values.publisherKey.existingSecret "key" .Values.publisherKey.key))) }}
{{- else if .Values.publisherKey.value }}
{{- $env = append $env (dict "name" "HALO_SERVER_PUBLISHER_KEY" "valueFrom" (dict "secretKeyRef" (dict "name" $secret "key" .Values.publisherKey.key))) }}
{{- end }}
{{- $env = append $env (dict "name" "HALO_SERVER_WEB_PORT" "value" (toString $webPort)) }}
{{- if gt $webPort 0 }}
{{- /* (the kubelet's probes reach the pod's own address, not its loopback) */}}
{{- $env = append $env (dict "name" "HALO_SERVER_WEB_BIND" "value" "0.0.0.0") }}
{{- $env = append $env (dict "name" "HALO_SERVER_WEB_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" (include "opence-server.webSecret" .) "key" .Values.web.key))) }}
{{- if .Values.web.existingSecret }}
{{- $env = append $env (dict "name" "HALO_SERVER_WEB_MODERATOR_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" .Values.web.existingSecret "key" .Values.web.moderatorKey "optional" true))) }}
{{- else if .Values.web.moderatorPassword }}
{{- $env = append $env (dict "name" "HALO_SERVER_WEB_MODERATOR_PASSWORD" "valueFrom" (dict "secretKeyRef" (dict "name" $secret "key" .Values.web.moderatorKey))) }}
{{- end }}
{{- end }}
{{- $probe := dict "httpGet" (dict "path" "/healthz" "port" "web") }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullname }}
labels:
{{- include "opence-server.labels" . | nindent 4 }}
spec:
# One server owns the tunnel port and the address players reach it at.
replicas: 1
strategy:
{{- toYaml .Values.strategy | nindent 4 }}
selector:
matchLabels:
{{- include "opence-server.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "opence-server.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
# The server never calls the Kubernetes API.
automountServiceAccountToken: false
enableServiceLinks: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.priorityClassName }}
priorityClassName: {{ . }}
{{- end }}
terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: opence-server
image: {{ include "opence-server.image" . | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
{{- toYaml $env | nindent 12 }}
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: tunnel
containerPort: {{ .Values.network.tunnelPort }}
protocol: UDP
{{- if gt $webPort 0 }}
- name: web
containerPort: {{ $webPort }}
protocol: TCP
{{- end }}
{{- if gt $webPort 0 }}
{{- range $name := list "startup" "readiness" "liveness" }}
{{- $p := index $.Values.probes $name }}
{{- if $p.enabled }}
{{ $name }}Probe:
{{- toYaml (merge (omit $p "enabled") $probe) | nindent 12 }}
{{- end }}
{{- end }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
volumeMounts:
- name: game-data
mountPath: /data
readOnly: true
{{- with .Values.gameData.subPath }}
subPath: {{ . }}
{{- end }}
# config.toml, publisher.key, the save root and its map cache, the logs
- name: state
mountPath: /state
{{- if include "opence-server.hasCustomMaps" . }}
- name: custom-maps
mountPath: /custom_maps
readOnly: true
{{- with .Values.customMaps.subPath }}
subPath: {{ . }}
{{- end }}
{{- end }}
- name: tmp
mountPath: /tmp
volumes:
- name: game-data
{{- include "opence-server.volumeSource" .Values.gameData | trim | nindent 10 }}
- name: state
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "opence-server.stateClaim" . }}
{{- else }}
emptyDir:
sizeLimit: {{ .Values.persistence.emptyDirSizeLimit }}
{{- end }}
- name: tmp
emptyDir:
sizeLimit: 64Mi
{{- if include "opence-server.hasCustomMaps" . }}
- name: custom-maps
{{- include "opence-server.volumeSource" .Values.customMaps | trim | nindent 10 }}
{{- end }}
@@ -0,0 +1,4 @@
{{- range .Values.extraObjects }}
---
{{ tpl (toYaml .) $ }}
{{- end }}
@@ -0,0 +1,66 @@
{{- if .Values.networkPolicy.enabled }}
{{- $np := .Values.networkPolicy }}
# In: the players' UDP on the tunnel port, and the web port from webFrom alone. Out: DNS, TCP to
# the MQTT brokers and the master servers, UDP to the internet (STUN, the players' ports); nothing
# on the private ranges.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "opence-server.fullname" . }}
labels:
{{- include "opence-server.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "opence-server.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
- ports:
- protocol: UDP
port: {{ .Values.network.tunnelPort }}
{{- with $np.from }}
from:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if and $np.webFrom (gt (int .Values.web.port) 0) }}
- ports:
- protocol: TCP
port: {{ .Values.web.port }}
from:
{{- toYaml $np.webFrom | nindent 8 }}
{{- end }}
{{- with $np.extraIngress }}
{{- toYaml . | nindent 4 }}
{{- end }}
egress:
- to:
{{- toYaml $np.dns.to | nindent 8 }}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- if or $np.internet.tcpPorts $np.internet.udp }}
- to:
- ipBlock:
cidr: {{ $np.internet.cidr }}
{{- with $np.internet.except }}
except:
{{- toYaml . | nindent 14 }}
{{- end }}
ports:
{{- range $np.internet.tcpPorts }}
- protocol: TCP
port: {{ . }}
{{- end }}
{{- if $np.internet.udp }}
# every UDP port: STUN (19302, 3478) and the players' own
- protocol: UDP
{{- end }}
{{- end }}
{{- with $np.extraEgress }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
@@ -0,0 +1,44 @@
{{- $data := dict }}
{{- if and (gt (int .Values.web.port) 0) (not .Values.web.existingSecret) }}
{{- $password := .Values.web.password }}
{{- if $password }}
{{- if lt (len $password) 8 }}
{{- fail "web.password: 8 characters or more (the server refuses a shorter one beyond loopback)" }}
{{- end }}
{{- else }}
{{- /* made once, then kept from the Secret the release already has */}}
{{- $existing := lookup "v1" "Secret" .Release.Namespace (include "opence-server.secretName" .) }}
{{- if and $existing $existing.data (hasKey $existing.data .Values.web.key) }}
{{- $password = index $existing.data .Values.web.key | b64dec }}
{{- else }}
{{- $password = randAlphaNum 24 }}
{{- end }}
{{- end }}
{{- $_ := set $data .Values.web.key ($password | b64enc) }}
{{- with .Values.web.moderatorPassword }}
{{- if lt (len .) 8 }}
{{- fail "web.moderatorPassword: 8 characters or more" }}
{{- end }}
{{- $_ := set $data $.Values.web.moderatorKey (. | b64enc) }}
{{- end }}
{{- end }}
{{- if and .Values.server.password.value (not .Values.server.password.existingSecret) }}
{{- $_ := set $data .Values.server.password.key (.Values.server.password.value | b64enc) }}
{{- end }}
{{- if and .Values.publisherKey.value (not .Values.publisherKey.existingSecret) }}
{{- if not (regexMatch "^[0-9a-f]{64}$" .Values.publisherKey.value) }}
{{- fail "publisherKey.value: the 64 lower-case hex characters of the seed (halo --server --keygen)" }}
{{- end }}
{{- $_ := set $data .Values.publisherKey.key (.Values.publisherKey.value | b64enc) }}
{{- end }}
{{- if $data }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "opence-server.secretName" . }}
labels:
{{- include "opence-server.labels" . | nindent 4 }}
type: Opaque
data:
{{- toYaml $data | nindent 2 }}
{{- end }}
@@ -0,0 +1,23 @@
{{- if and .Values.web.service.enabled (gt (int .Values.web.port) 0) }}
# The web page, its API and the remote console, inside the cluster only: for an Ingress with TLS,
# or kubectl port-forward.
apiVersion: v1
kind: Service
metadata:
name: {{ include "opence-server.fullname" . }}-web
labels:
{{- include "opence-server.labels" . | nindent 4 }}
{{- with .Values.web.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: ClusterIP
selector:
{{- include "opence-server.selectorLabels" . | nindent 4 }}
ports:
- name: web
port: {{ .Values.web.service.port }}
targetPort: web
protocol: TCP
{{- end }}
@@ -0,0 +1,40 @@
# The tunnel port, UDP: the one port players need.
apiVersion: v1
kind: Service
metadata:
name: {{ include "opence-server.fullname" . }}
labels:
{{- include "opence-server.labels" . | nindent 4 }}
{{- with .Values.service.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
{{- if ne .Values.service.type "ClusterIP" }}
{{- with .Values.service.externalTrafficPolicy }}
externalTrafficPolicy: {{ . }}
{{- end }}
{{- end }}
{{- if eq .Values.service.type "LoadBalancer" }}
{{- with .Values.service.loadBalancerClass }}
loadBalancerClass: {{ . }}
{{- end }}
{{- with .Values.service.loadBalancerSourceRanges }}
loadBalancerSourceRanges:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
selector:
{{- include "opence-server.selectorLabels" . | nindent 4 }}
ports:
- name: tunnel
port: {{ .Values.service.port }}
targetPort: tunnel
protocol: UDP
{{- if and (ne .Values.service.type "ClusterIP") .Values.service.nodePort }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}
@@ -0,0 +1,21 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "opence-server.stateClaim" . }}
labels:
{{- include "opence-server.labels" . | nindent 4 }}
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
{{- with .Values.persistence.storageClass }}
storageClassName: {{ . }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
@@ -0,0 +1,221 @@
suite: deployment
templates:
- templates/deployment.yaml
set:
gameData.existingClaim: halo-maps
tests:
- it: refuses to render without game data, which is never in the image
set:
gameData.existingClaim: ""
asserts:
- failedTemplate:
errorPattern: "gameData: set existingClaim"
- it: mounts an existing claim read-only at /data
asserts:
- equal:
path: 'spec.template.spec.volumes[0]'
value: { name: game-data, persistentVolumeClaim: { claimName: halo-maps, readOnly: true } }
- contains:
path: 'spec.template.spec.containers[0].volumeMounts'
content: { name: game-data, mountPath: /data, readOnly: true }
- it: mounts an NFS export read-only, with a subPath
set:
gameData: { existingClaim: "", nfs: { server: nfs.example.com, path: /halo }, subPath: xbox }
asserts:
- equal:
path: 'spec.template.spec.volumes[0]'
value: { name: game-data, nfs: { server: nfs.example.com, path: /halo, readOnly: true } }
- contains:
path: 'spec.template.spec.containers[0].volumeMounts'
content: { name: game-data, mountPath: /data, readOnly: true, subPath: xbox }
- it: takes any other volume source
set:
gameData: { existingClaim: "", volume: { emptyDir: {} } }
asserts:
- equal:
path: 'spec.template.spec.volumes[0]'
value: { name: game-data, emptyDir: {} }
- it: runs one replica, replaced not rolled, hardened
asserts:
- equal: { path: spec.replicas, value: 1 }
- equal: { path: spec.strategy.type, value: Recreate }
- equal: { path: spec.template.spec.automountServiceAccountToken, value: false }
- equal: { path: spec.template.spec.securityContext.runAsNonRoot, value: true }
- equal: { path: spec.template.spec.securityContext.runAsUser, value: 10001 }
- equal: { path: spec.template.spec.securityContext.seccompProfile.type, value: RuntimeDefault }
- equal: { path: 'spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem', value: true }
- equal: { path: 'spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation', value: false }
- equal: { path: 'spec.template.spec.containers[0].securityContext.capabilities.drop', value: [ALL] }
- equal: { path: spec.template.spec.nodeSelector, value: { kubernetes.io/os: linux, kubernetes.io/arch: amd64 } }
- equal: { path: spec.template.spec.terminationGracePeriodSeconds, value: 15 }
- exists: { path: 'spec.template.spec.containers[0].resources.limits.memory' }
- it: sets the server's paths and mode
asserts:
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER, value: "1" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_CONFIG_FILE, value: /state/config.toml } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_DATA_ROOT, value: /data } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SAVE_ROOT, value: /state/saves } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_LOG_ROOT, value: /state/logs } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_CONSOLE, value: "false" } }
- it: maps the values to the HALO_ variables
set:
server:
name: Test server
rotation: [bloodgulch:ctf, "sidewinder:team_slayer+score=50"]
maxPlayers: 24
public: false
lobbySeconds: 30
motd: Hello
logFormat: json
instance: eu-1
region: eu
masterPublish: false
network: { tunnelPort: 2303, publicAddress: "halo.example.com:2303" }
asserts:
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_NAME, value: Test server } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_ROTATION, value: "bloodgulch:ctf, sidewinder:team_slayer+score=50" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MAX_PLAYERS, value: "24" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_PUBLIC, value: "false" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_LOBBY_SECONDS, value: "30" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MOTD, value: Hello } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_LOG_FORMAT, value: json } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_INSTANCE, value: eu-1 } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_REGION, value: eu } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MASTER_PUBLISH, value: "false" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_NET_TUNNEL_PORT, value: "2303" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_NET_PUBLIC_ADDRESS, value: "halo.example.com:2303" } }
- equal: { path: 'spec.template.spec.containers[0].ports[0]', value: { name: tunnel, containerPort: 2303, protocol: UDP } }
- it: leaves empty values to config.toml
asserts:
- notContains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_NAME, value: "" } }
- notContains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MOTD, value: "" } }
- notContains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_NET_PUBLIC_ADDRESS, value: "" } }
- it: leaves the settings the values do not give to config.toml, sv_set and the web page
asserts:
- notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_ROTATION")]' }
- notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_MAX_PLAYERS")]' }
- notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_PUBLIC")]' }
- notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_LOBBY_SECONDS")]' }
- notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_LOG_FORMAT")]' }
- notExists: { path: 'spec.template.spec.containers[0].env[?(@.name == "HALO_SERVER_MASTER_PUBLISH")]' }
- it: sets a false or zero value that is given
set:
server: { public: false, masterPublish: false, lobbySeconds: 0 }
asserts:
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_PUBLIC, value: "false" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_MASTER_PUBLISH, value: "false" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_LOBBY_SECONDS, value: "0" } }
- it: refuses a name longer than the game's 15 characters (the values' schema, or the template)
set:
server.name: "A name too long!"
asserts:
- failedTemplate:
errorPattern: "server.name|/server/name|15 characters"
- it: takes the web password from the chart's Secret, bound beyond loopback for the probes
release: { name: opence-server }
asserts:
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_WEB_PORT, value: "8080" } }
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_WEB_BIND, value: 0.0.0.0 } }
- contains:
path: 'spec.template.spec.containers[0].env'
content: { name: HALO_SERVER_WEB_PASSWORD, valueFrom: { secretKeyRef: { name: opence-server-secrets, key: web-password } } }
- contains: { path: 'spec.template.spec.containers[0].ports', content: { name: web, containerPort: 8080, protocol: TCP } }
- it: takes the passwords and the publisher key from existing Secrets
set:
web: { existingSecret: halo-web, key: admin, moderatorKey: moderator }
server.password: { existingSecret: halo-join, key: password }
publisherKey: { existingSecret: halo-publisher, key: seed }
asserts:
- contains:
path: 'spec.template.spec.containers[0].env'
content: { name: HALO_SERVER_WEB_PASSWORD, valueFrom: { secretKeyRef: { name: halo-web, key: admin } } }
- contains:
path: 'spec.template.spec.containers[0].env'
content: { name: HALO_SERVER_WEB_MODERATOR_PASSWORD, valueFrom: { secretKeyRef: { name: halo-web, key: moderator, optional: true } } }
- contains:
path: 'spec.template.spec.containers[0].env'
content: { name: HALO_SERVER_PASSWORD, valueFrom: { secretKeyRef: { name: halo-join, key: password } } }
- contains:
path: 'spec.template.spec.containers[0].env'
content: { name: HALO_SERVER_PUBLISHER_KEY, valueFrom: { secretKeyRef: { name: halo-publisher, key: seed } } }
- it: probes GET /healthz on the web port
asserts:
- equal: { path: 'spec.template.spec.containers[0].startupProbe.httpGet', value: { path: /healthz, port: web } }
- equal: { path: 'spec.template.spec.containers[0].readinessProbe.httpGet', value: { path: /healthz, port: web } }
- equal: { path: 'spec.template.spec.containers[0].livenessProbe.httpGet', value: { path: /healthz, port: web } }
- equal: { path: 'spec.template.spec.containers[0].startupProbe.failureThreshold', value: 60 }
- it: drops a probe that is turned off
set:
probes.liveness.enabled: false
asserts:
- notExists: { path: 'spec.template.spec.containers[0].livenessProbe' }
- exists: { path: 'spec.template.spec.containers[0].readinessProbe' }
- it: has no web port, password or probes with web.port 0
set:
web.port: 0
asserts:
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_WEB_PORT, value: "0" } }
- notContains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_SERVER_WEB_BIND, value: 0.0.0.0 } }
- lengthEqual: { path: 'spec.template.spec.containers[0].ports', count: 1 }
- notExists: { path: 'spec.template.spec.containers[0].startupProbe' }
- notExists: { path: 'spec.template.spec.containers[0].livenessProbe' }
- it: puts extraEnv after the chart's variables, so it can override them
set:
extraEnv: [{ name: HALO_SERVER_LOBBY_SECONDS, value: "5" }]
asserts:
- equal:
path: 'spec.template.spec.containers[0].env[-1:]'
value: { name: HALO_SERVER_LOBBY_SECONDS, value: "5" }
- it: keeps the state in an emptyDir without persistence
asserts:
- contains: { path: spec.template.spec.volumes, content: { name: state, emptyDir: { sizeLimit: 1Gi } } }
- lengthEqual: { path: spec.template.spec.volumes, count: 3 }
- it: uses the claim with persistence
release: { name: opence-server }
set:
persistence.enabled: true
asserts:
- contains: { path: spec.template.spec.volumes, content: { name: state, persistentVolumeClaim: { claimName: opence-server-state } } }
- it: mounts a custom_maps claim read-only, with its variable
set:
customMaps: { existingClaim: halo-custom-maps, subPath: ce }
asserts:
- contains: { path: 'spec.template.spec.containers[0].env', content: { name: HALO_CUSTOM_MAPS_ROOT, value: /custom_maps } }
- contains:
path: spec.template.spec.volumes
content: { name: custom-maps, persistentVolumeClaim: { claimName: halo-custom-maps, readOnly: true } }
- contains:
path: 'spec.template.spec.containers[0].volumeMounts'
content: { name: custom-maps, mountPath: /custom_maps, readOnly: true, subPath: ce }
- it: takes a tag with a digest, and lets digest replace it
set:
image.tag: build-206@sha256:1111111111111111111111111111111111111111111111111111111111111111
image.digest: sha256:2222222222222222222222222222222222222222222222222222222222222222
asserts:
- equal:
path: 'spec.template.spec.containers[0].image'
value: ghcr.io/opencommunityedition/opence-server:build-206@sha256:2222222222222222222222222222222222222222222222222222222222222222
- it: uses the appVersion as the default tag
asserts:
- equal: { path: 'spec.template.spec.containers[0].image', value: "ghcr.io/opencommunityedition/opence-server:latest" }
@@ -0,0 +1,39 @@
suite: network policy
templates:
- templates/networkpolicy.yaml
tests:
- it: lets anyone reach the tunnel port, and nothing else in
asserts:
- equal: { path: spec.policyTypes, value: [Ingress, Egress] }
- equal: { path: spec.ingress, value: [{ ports: [{ protocol: UDP, port: 2302 }] }] }
- it: allows DNS, TCP 1883 and 443 and all UDP to public addresses only
asserts:
- equal: { path: 'spec.egress[0].ports', value: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }] }
- equal: { path: 'spec.egress[1].to[0].ipBlock.cidr', value: 0.0.0.0/0 }
- contains: { path: 'spec.egress[1].to[0].ipBlock.except', content: 10.0.0.0/8 }
- contains: { path: 'spec.egress[1].to[0].ipBlock.except', content: 192.168.0.0/16 }
- equal: { path: 'spec.egress[1].ports', value: [{ protocol: TCP, port: 1883 }, { protocol: TCP, port: 443 }, { protocol: UDP }] }
- it: lets webFrom reach the web port
set:
networkPolicy.webFrom: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } } }]
asserts:
- equal:
path: 'spec.ingress[1]'
value: { ports: [{ protocol: TCP, port: 8080 }], from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } } }] }
- it: narrows the players' sources and follows the tunnel port
set:
network.tunnelPort: 2303
networkPolicy.from: [{ ipBlock: { cidr: 198.51.100.0/24 } }]
asserts:
- equal:
path: spec.ingress
value: [{ ports: [{ protocol: UDP, port: 2303 }], from: [{ ipBlock: { cidr: 198.51.100.0/24 } }] }]
- it: renders nothing when off
set:
networkPolicy.enabled: false
asserts:
- hasDocuments: { count: 0 }
@@ -0,0 +1,21 @@
suite: persistence
templates:
- templates/state-pvc.yaml
release:
name: opence-server
tests:
- it: makes no claim without persistence
asserts:
- hasDocuments: { count: 0 }
- it: makes the state claim with persistence
set:
persistence: { enabled: true, size: 3Gi, storageClass: fast }
asserts:
- equal: { path: metadata.name, value: opence-server-state }
- equal: { path: spec.resources.requests.storage, value: 3Gi }
- equal: { path: spec.storageClassName, value: fast }
- it: makes none for an existing claim
set:
persistence: { enabled: true, existingClaim: mine }
asserts:
- hasDocuments: { count: 0 }
@@ -0,0 +1,48 @@
suite: secret
templates:
- templates/secret.yaml
release:
name: opence-server
tests:
- it: makes a web password when none is given
asserts:
- hasDocuments: { count: 1 }
- isKind: { of: Secret }
- equal: { path: metadata.name, value: opence-server-secrets }
- matchRegex: { path: data.web-password, pattern: "^[A-Za-z0-9+/]{32}$" }
- it: keeps a given web password, join password and publisher key
set:
web.password: correct-horse
server.password.value: letmein
publisherKey.value: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
asserts:
- equal: { path: data.web-password, value: Y29ycmVjdC1ob3JzZQ== }
- equal: { path: data.password, value: bGV0bWVpbg== }
- exists: { path: data.publisher-key }
- it: refuses a short web password
set:
web.password: short
asserts:
- failedTemplate:
errorPattern: "8 characters or more"
- it: refuses a publisher key that is not 64 hex characters
set:
publisherKey.value: not-a-key
asserts:
- failedTemplate:
errorPattern: "64 lower-case hex"
- it: makes none when every secret comes from elsewhere
set:
web.existingSecret: halo-web
asserts:
- hasDocuments: { count: 0 }
- it: makes none without a web port or secrets
set:
web.port: 0
asserts:
- hasDocuments: { count: 0 }
@@ -0,0 +1,43 @@
suite: service
templates:
- templates/service.yaml
- templates/service-web.yaml
release:
name: opence-server
tests:
- it: exposes the tunnel port over UDP on a load balancer that keeps the players' addresses
template: templates/service.yaml
asserts:
- equal: { path: metadata.name, value: opence-server }
- equal: { path: spec.type, value: LoadBalancer }
- equal: { path: spec.externalTrafficPolicy, value: Local }
- equal: { path: 'spec.ports[0]', value: { name: tunnel, port: 2302, targetPort: tunnel, protocol: UDP } }
- it: ignores externalTrafficPolicy and nodePort on a ClusterIP Service
template: templates/service.yaml
set:
service: { type: ClusterIP, nodePort: 30702 }
asserts:
- notExists: { path: spec.externalTrafficPolicy }
- notExists: { path: 'spec.ports[0].nodePort' }
- it: sets a fixed node port
template: templates/service.yaml
set:
service: { type: NodePort, nodePort: 30702 }
asserts:
- equal: { path: 'spec.ports[0].nodePort', value: 30702 }
- it: keeps the web port inside the cluster
template: templates/service-web.yaml
asserts:
- equal: { path: metadata.name, value: opence-server-web }
- equal: { path: spec.type, value: ClusterIP }
- equal: { path: 'spec.ports[0]', value: { name: web, port: 8080, targetPort: web, protocol: TCP } }
- it: has no web Service without a web port
template: templates/service-web.yaml
set:
web.port: 0
asserts:
- hasDocuments: { count: 0 }
@@ -0,0 +1,445 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "opence-server values",
"description": "Strict where the chart defines the structure; free-form where values pass through to Kubernetes. values.yaml describes each value.",
"type": "object",
"additionalProperties": false,
"definitions": {
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"volume": {
"type": "object",
"additionalProperties": false,
"properties": {
"existingClaim": {
"type": "string"
},
"nfs": {
"type": "object",
"additionalProperties": false,
"properties": {
"server": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"volume": {
"type": "object"
},
"subPath": {
"type": "string"
}
}
},
"secretValue": {
"type": "object",
"additionalProperties": false,
"properties": {
"value": {
"type": "string"
},
"existingSecret": {
"type": "string"
},
"key": {
"type": "string",
"minLength": 1
}
}
},
"probe": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"initialDelaySeconds": {
"type": "integer",
"minimum": 0
},
"periodSeconds": {
"type": "integer",
"minimum": 1
},
"timeoutSeconds": {
"type": "integer",
"minimum": 1
},
"failureThreshold": {
"type": "integer",
"minimum": 1
},
"successThreshold": {
"type": "integer",
"minimum": 1
},
"terminationGracePeriodSeconds": {
"type": "integer",
"minimum": 1
}
}
}
},
"properties": {
"global": {
"type": "object"
},
"nameOverride": {
"type": "string"
},
"fullnameOverride": {
"type": "string"
},
"image": {
"type": "object",
"additionalProperties": false,
"required": [
"repository"
],
"properties": {
"repository": {
"type": "string",
"minLength": 1
},
"tag": {
"type": [
"string",
"number"
]
},
"digest": {
"type": "string",
"pattern": "^(sha256:[0-9a-f]{64})?$"
},
"pullPolicy": {
"enum": [
"Always",
"IfNotPresent",
"Never"
]
}
}
},
"imagePullSecrets": {
"type": "array"
},
"gameData": {
"$ref": "#/definitions/volume"
},
"customMaps": {
"$ref": "#/definitions/volume"
},
"server": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"maxLength": 15,
"pattern": "^[ -~]*$"
},
"rotation": {
"type": "array",
"maxItems": 64,
"items": {
"type": "string",
"minLength": 1
}
},
"maxPlayers": {
"type": [
"integer",
"null"
],
"minimum": 2,
"maximum": 127
},
"public": {
"type": [
"boolean",
"null"
]
},
"lobbySeconds": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 300
},
"motd": {
"type": "string",
"maxLength": 127
},
"logFormat": {
"enum": [
"",
"text",
"json",
null
]
},
"instance": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{0,32}$"
},
"region": {
"type": "string",
"maxLength": 16
},
"masterPublish": {
"type": [
"boolean",
"null"
]
},
"password": {
"$ref": "#/definitions/secretValue"
}
}
},
"publisherKey": {
"$ref": "#/definitions/secretValue"
},
"web": {
"type": "object",
"additionalProperties": false,
"properties": {
"port": {
"type": "integer",
"minimum": 0,
"maximum": 65535
},
"password": {
"type": "string"
},
"existingSecret": {
"type": "string"
},
"key": {
"type": "string",
"minLength": 1
},
"moderatorPassword": {
"type": "string"
},
"moderatorKey": {
"type": "string",
"minLength": 1
},
"service": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"port": {
"$ref": "#/definitions/port"
},
"annotations": {
"type": "object"
}
}
}
}
},
"network": {
"type": "object",
"additionalProperties": false,
"properties": {
"tunnelPort": {
"$ref": "#/definitions/port"
},
"publicAddress": {
"type": "string"
}
}
},
"extraEnv": {
"type": "array"
},
"persistence": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"existingClaim": {
"type": "string"
},
"size": {
"type": "string"
},
"storageClass": {
"type": "string"
},
"accessModes": {
"type": "array",
"items": {
"type": "string"
}
},
"annotations": {
"type": "object"
},
"emptyDirSizeLimit": {
"type": "string"
}
}
},
"service": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"enum": [
"ClusterIP",
"NodePort",
"LoadBalancer"
]
},
"port": {
"$ref": "#/definitions/port"
},
"nodePort": {
"type": [
"string",
"integer"
]
},
"annotations": {
"type": "object"
},
"labels": {
"type": "object"
},
"externalTrafficPolicy": {
"enum": [
"",
"Cluster",
"Local"
]
},
"loadBalancerClass": {
"type": "string"
},
"loadBalancerSourceRanges": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"strategy": {
"type": "object"
},
"resources": {
"type": "object"
},
"nodeSelector": {
"type": "object"
},
"affinity": {
"type": "object"
},
"tolerations": {
"type": "array"
},
"priorityClassName": {
"type": "string"
},
"podAnnotations": {
"type": "object"
},
"podLabels": {
"type": "object"
},
"terminationGracePeriodSeconds": {
"type": "integer",
"minimum": 1
},
"podSecurityContext": {
"type": "object"
},
"securityContext": {
"type": "object"
},
"probes": {
"type": "object",
"additionalProperties": false,
"properties": {
"startup": {
"$ref": "#/definitions/probe"
},
"readiness": {
"$ref": "#/definitions/probe"
},
"liveness": {
"$ref": "#/definitions/probe"
}
}
},
"networkPolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"from": {
"type": "array"
},
"webFrom": {
"type": "array"
},
"dns": {
"type": "object"
},
"internet": {
"type": "object",
"additionalProperties": false,
"properties": {
"cidr": {
"type": "string"
},
"except": {
"type": "array",
"items": {
"type": "string"
}
},
"tcpPorts": {
"type": "array",
"items": {
"$ref": "#/definitions/port"
}
},
"udp": {
"type": "boolean"
}
}
},
"extraIngress": {
"type": "array"
},
"extraEgress": {
"type": "array"
}
}
},
"extraObjects": {
"type": "array"
}
}
}
+239
View File
@@ -0,0 +1,239 @@
# opence-server: the OpenCE dedicated server (docs/dedicated_server.md). One replica: the server
# owns its tunnel port and the address players reach it at.
nameOverride: ""
fullnameOverride: ""
image:
repository: ghcr.io/opencommunityedition/opence-server
# Empty: the chart's appVersion (latest). A release's tag (build-206) or sha-<commit> pins it;
# <tag>@sha256:... as Argo CD Image Updater writes it works too.
tag: ""
# sha256:... ; replaces any digest in tag.
digest: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
# The game data: the folder that holds maps/ (and custom_maps/, gametypes/ and init.txt if you have
# them), from your own copy of the game. It is never in the image. Mounted read-only at /data
# (HALO_DATA_ROOT). One source is required.
gameData:
# An existing PersistentVolumeClaim.
existingClaim: ""
# Or an NFS export.
nfs:
server: ""
path: ""
# Or any other volume source, as in a pod's volumes (without the name), e.g.
# {hostPath: {path: /srv/halo, type: Directory}}. Without maps/ui.map and the rotation's maps the
# server exits with code 2 and says which maps it looked for, and the pod goes into
# CrashLoopBackOff.
volume: {}
# The folder within the volume that holds maps/. Empty: the volume's root.
subPath: ""
# A custom_maps folder of its own (Custom Edition maps with bitmaps.map, sounds.map and loc.map),
# mounted read-only at /custom_maps (HALO_CUSTOM_MAPS_ROOT). Optional: without one the server uses
# gameData's custom_maps/.
customMaps:
existingClaim: ""
nfs:
server: ""
path: ""
volume: {}
subPath: ""
# The server's settings, as its HALO_* environment variables (docs/dedicated_server.md and
# port/linux/README.md, "Dedicated server"). A value given here wins over config.toml, and sv_set
# and the web page's settings cannot change it. Each is unset by default (empty, or null): the
# setting is then config.toml's, or the server's default (named below), and can be changed from
# the web page.
server:
# server.name: 15 characters of printable ASCII (default "OpenCE Server").
name: ""
# server.rotation: map[:gametype[+rule=value...]] entries, played in turn, e.g.
# [bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer] (the default).
rotation: []
# server.max_players: 2 to 127 (default 16).
maxPlayers: null
# server.public: list the server in everyone's server browser (default true).
public: null
# server.lobby_seconds (default 15).
lobbySeconds: null
# server.motd: a notice each player is shown on joining.
motd: ""
# server.log_format: text or json (default text).
logFormat: ""
# server.instance and server.region, as the master servers list them.
instance: ""
region: ""
# server.master_publish: publish the server to the official master servers (needs a publisher
# key, below; default true).
masterPublish: null
# server.password: the password the server browser asks for. value, or a key of an existing
# Secret; empty for none.
password:
value: ""
existingSecret: ""
key: password
# The master servers' publisher key (HALO_SERVER_PUBLISHER_KEY): the 64 hex characters of the seed
# halo --server --keygen writes. value, or a key of an existing Secret; empty: the state volume's
# publisher.key, if there is one.
publisherKey:
value: ""
existingSecret: ""
key: publisher-key
# The web page, its API and the remote console (server.web_port), on which the probes ask
# GET /healthz. 0 turns them off, and the probes with them.
web:
port: 8080
# The admin password (server.web_password, 8 characters or more), from existingSecret's key, else
# from password, else made at the first install and kept in the chart's Secret. The made one is
# found again with lookup on upgrades, which helm template and GitOps tools (Argo CD, Flux's
# dry runs) cannot do: they make a new one at each render. Give password or existingSecret
# there (README.md, "Web password").
password: ""
existingSecret: ""
key: web-password
# server.web_moderator_password, from the same Secret's moderatorKey when it has one.
moderatorPassword: ""
moderatorKey: web-moderator-password
# A ClusterIP Service for the web port, for an Ingress (with TLS) or kubectl port-forward.
service:
enabled: true
port: 8080
annotations: {}
network:
# network.tunnel_port: the UDP port of internet play, which the Service exposes.
tunnelPort: 2302
# network.public_address: host[:port] players reach the tunnel port at, when the cluster is behind
# a NAT that STUN cannot see through. Empty: what STUN finds.
publicAddress: ""
# More environment variables, as in a pod spec ({name, value} or {name, valueFrom}); they come after
# the chart's, so the same name overrides it.
extraEnv: []
# /state: config.toml (HALO_CONFIG_FILE), publisher.key, the save root with its map cache (about
# 115 MB for the multiplayer maps), the logs. Without persistence an emptyDir: the cache is built
# again at each start, and settings changed from the web page are lost.
persistence:
enabled: false
existingClaim: ""
size: 2Gi
storageClass: ""
accessModes:
- ReadWriteOnce
annotations: {}
emptyDirSizeLimit: 1Gi
# The tunnel port's Service. Players reach it from outside the cluster, so a real deployment needs
# LoadBalancer or NodePort.
service:
type: LoadBalancer
port: 2302
nodePort: ""
annotations: {}
labels: {}
# Local keeps the players' own addresses (no SNAT to a node), which bans and the server's limits
# rely on.
externalTrafficPolicy: Local
loadBalancerClass: ""
loadBalancerSourceRanges: []
strategy:
type: Recreate
resources:
requests:
cpu: 100m
memory: 192Mi
limits:
memory: 600Mi
# The image is 32-bit x86 Linux code, which runs on amd64 nodes only.
nodeSelector:
kubernetes.io/os: linux
kubernetes.io/arch: amd64
affinity: {}
tolerations: []
priorityClassName: ""
podAnnotations: {}
podLabels: {}
# SIGTERM ends the game in progress, tells the players and exits 0; a second signal, or 5 seconds
# on, exits at once.
terminationGracePeriodSeconds: 15
podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# GET /healthz on the web port: 200 while the server hosts (lobby, loading, playing, postgame) and
# its frames run. Each probe's fields override these; enabled: false drops it.
probes:
startup:
enabled: true
periodSeconds: 2
failureThreshold: 60
timeoutSeconds: 2
readiness:
enabled: true
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 2
liveness:
enabled: true
periodSeconds: 20
failureThreshold: 3
timeoutSeconds: 3
networkPolicy:
enabled: true
# Who may reach the tunnel port. Empty: anyone (players are on the internet).
from: []
# Who may reach the web port, as NetworkPolicy peers (an Ingress controller's namespace, say).
# Empty: no pod; kubectl port-forward and the kubelet's probes still reach it.
webFrom: []
dns:
to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
# The internet: TCP to the MQTT brokers (1883) and the master servers (443), and UDP to anywhere
# (STUN, and the players' own ports). The private ranges are excepted.
internet:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 100.64.0.0/10
- 169.254.0.0/16
tcpPorts:
- 1883
- 443
udp: true
extraIngress: []
extraEgress: []
# More objects to render with the release (templated).
extraObjects: []
+31
View File
@@ -0,0 +1,31 @@
# init.txt: commands a dedicated server runs once at its start, one a line,
# as if typed at its console (docs/dedicated_server.md, "Commands"). Copy
# this file to init.txt in the data folder (server.init_file), and take the
# "# " off the lines you want. Lines starting with # or ; are comments.
#
# Settings are better given in config.toml or as HALO_* variables; these are
# for what changes from one start to the next.
# The rotation, replacing server.rotation (and written to it):
# sv_rotation_set bloodgulch:ctf, sidewinder:team_slayer+score=50, hangemhigh:king_moving
# sv_rotation_add prisoner:slayer+time=10
# sv_rotation_begin 2
# The game's name, players and notice (sv_set of the server.* settings):
# sv_name My server
# sv_maxplayers 12
# sv_motd Welcome. Be nice; admins are watching.
# sv_public 1
# sv_password secret
# Any setting the server lists in sv_settings:
# sv_set server.lobby_seconds 20
# sv_set server.empty_seconds 60
# A first game other than the rotation's, which goes on after it:
# sv_map bloodgulch ctf_classic
# The state of the server, in the log:
# sv_status
# The game's own console commands run too, with the owner's role.
+40
View File
@@ -0,0 +1,40 @@
# The dedicated server on MCSManager
`mcsmanager-config.json` holds two MCSManager quick-install entries for the
dedicated server (`docs/dedicated_server.md`). Each unzips a release's
`halo-linux-release.zip` into the instance's folder and runs
`./halo --server` there, stopped with `quit`.
| Entry | How it runs |
| --- | --- |
| Host | On the node itself, which needs the 32-bit C runtime (Debian and Ubuntu: `dpkg --add-architecture i386 && apt install libc6:i386`) |
| Docker | In a container of `ghcr.io/ptero-eggs/games:source` (Debian 12 with the 32-bit libraries), the instance's folder mounted at `/workspace` |
## Using it
1. Add the entries to the node's quick-install list, and create an
instance from one.
2. Upload the `maps/` folder of your own copy of the game into the
instance's folder. Without `maps/ui.map` and the rotation's maps the
server exits with code 2 and says which maps it looked for.
3. Start it.
## Folders
The Docker entry's container is made again at each start, so it sets
`HALO_SAVE_ROOT=/workspace/saves` and `HALO_LOG_ROOT=/workspace/logs`: the
save root with the map cache (about 115 MB, built at the first start) and
the logs stay in the instance's folder rather than in the container. The
host entry keeps the save root in the home folder of the daemon's user
(`~/.local/share/halo-linux`), which every host instance of that user
shares; give each its own with
`HALO_SAVE_ROOT=saves` (in the instance's environment, or as
`env HALO_SAVE_ROOT=saves ./halo --server` for its start command).
`config.toml` is written beside `halo` at the first start.
## Ports
UDP 2302 is the tunnel port, the one players need; the web page, its API
and the remote console are on 127.0.0.1:8080. A second instance on the node
needs ports of its own: `HALO_NET_TUNNEL_PORT` and `HALO_SERVER_WEB_PORT`,
and for the Docker entry its published ports to match.
+139
View File
@@ -0,0 +1,139 @@
{
"_comment": "MCSManager quick-install entries for the OpenCE dedicated server (docs/dedicated_server.md). Each package unzips the release into the instance's folder; put the maps/ folder of your own copy of the game beside halo before the first start. Settings: config.toml beside halo, written at the first start, or HALO_* variables.",
"languages": [
{
"label": "English",
"value": "en_us"
}
],
"packages": [
{
"language": "en_us",
"platform": "Linux",
"title": "OpenCE Dedicated Server",
"category": "Halo: Combat Evolved",
"runtime": "Linux x86-64 with 32-bit libraries",
"hardware": "1 CPU core, 600 MB of memory",
"size": "45 MB, and the game data you upload",
"author": "OpenCE",
"targetLink": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip",
"description": "The dedicated server run on the node itself, which needs the 32-bit C runtime (Debian and Ubuntu: dpkg --add-architecture i386 && apt install libc6:i386). Open UDP 2302; the web page is on http://127.0.0.1:8080/.",
"remark": "Host",
"setupInfo": {
"nickname": "OpenCE Dedicated Server",
"startCommand": "./halo --server",
"stopCommand": "quit",
"ie": "utf-8",
"oe": "utf-8",
"type": "universal",
"tag": [
"halo",
"opence"
],
"processType": "general",
"updateCommand": "",
"crlf": 1,
"actionCommandList": [
{
"name": "Status",
"command": "sv_status"
},
{
"name": "Players",
"command": "sv_players"
},
{
"name": "Next map",
"command": "sv_map_next"
},
{
"name": "Reload settings",
"command": "sv_reload"
}
],
"eventTask": {
"autoStart": false,
"autoRestart": true,
"ignore": false
},
"terminalOption": {
"haveColor": false,
"pty": false
}
}
},
{
"language": "en_us",
"platform": "Linux",
"title": "OpenCE Dedicated Server",
"category": "Halo: Combat Evolved",
"runtime": "Linux x86-64 with 32-bit libraries",
"hardware": "1 CPU core, 600 MB of memory",
"size": "45 MB, and the game data you upload",
"author": "OpenCE",
"targetLink": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip",
"description": "The dedicated server in a container of ghcr.io/ptero-eggs/games:source (Debian 12 with the 32-bit libraries), the instance's folder mounted as its working folder; the save root (with the map cache) and the logs are kept in it, as the container is made again at each start. Publishes UDP 2302 and the web page on the node's 127.0.0.1:8080 (each instance on a node needs ports of its own).",
"remark": "Docker",
"setupInfo": {
"nickname": "OpenCE Dedicated Server",
"startCommand": "./halo --server",
"stopCommand": "quit",
"ie": "utf-8",
"oe": "utf-8",
"type": "universal",
"tag": [
"halo",
"opence"
],
"processType": "docker",
"updateCommand": "",
"crlf": 1,
"actionCommandList": [
{
"name": "Status",
"command": "sv_status"
},
{
"name": "Players",
"command": "sv_players"
},
{
"name": "Next map",
"command": "sv_map_next"
},
{
"name": "Reload settings",
"command": "sv_reload"
}
],
"eventTask": {
"autoStart": false,
"autoRestart": true,
"ignore": false
},
"terminalOption": {
"haveColor": false,
"pty": false
},
"docker": {
"image": "ghcr.io/ptero-eggs/games:source",
"ports": [
"2302:2302/udp",
"127.0.0.1:8080:8080/tcp"
],
"extraVolumes": [],
"networkMode": "bridge",
"networkAliases": [],
"env": [
"HALO_SERVER_WEB_BIND=0.0.0.0",
"HALO_SAVE_ROOT=/workspace/saves",
"HALO_LOG_ROOT=/workspace/logs"
],
"memory": 600,
"workingDir": "/workspace/",
"changeWorkdir": true
}
}
}
]
}
+94
View File
@@ -0,0 +1,94 @@
# The dedicated server on Pterodactyl and Pelican
`egg-opence-dedicated-server.json` is an egg (`PTDL_v2`) that installs a
release of OpenCE from GitHub and runs it as a dedicated server
(`docs/dedicated_server.md`). Pelican imports the same file.
## Importing it
1. Admin > Nests (Pelican: Eggs) > Import Egg, and choose the file.
2. Create a server with the egg. Its primary allocation is the tunnel port
(`network.tunnel_port`, UDP): the one port players need, from 1025 up
(the container cannot bind lower ports).
3. Upload the game data: the `maps/` folder of your own copy of the game
(port/linux/README.md, "Game data"), into the server's folder over SFTP
(the panel's SFTP details, on the Settings tab). Without `maps/ui.map`
and the rotation's maps the server stops at once with exit code 2 and
says which maps it looked for.
4. Start it. The panel shows it running once it prints its ready line
(`server: ready`, or `"msg":"ready (` with `server.log_format = "json"`).
## Files
| Path (in `/home/container`) | What |
| --- | --- |
| `halo`, `halo-server`, `libSDL3.so.0`, `brokers.txt`, `masters.txt`, the licences | The release (replaced on Reinstall) |
| `maps/` | The game data (yours) |
| `custom_maps/` | Custom Edition maps, with `bitmaps.map`, `sounds.map` and `loc.map` (shared with players who join) |
| `gametypes/*.toml` | Gametypes of your own (the release adds examples, never replacing yours) |
| `init.txt` | Commands run at each start (`init.txt.example` has examples) |
| `config.toml` | Every setting, written at the first start |
| `publisher.key` | The master servers' key: made by `./halo --server --keygen` on any machine with the release and uploaded, or given as the Publisher key variable instead |
| `logs/` | `debug.txt`, `bans.txt`, `cheaters.txt` |
Reinstall (Settings tab) downloads the release again, the one the Release
variable names, and keeps `config.toml`, `init.txt`, `publisher.key`,
`gametypes/`, `custom_maps/`, `maps/` and `logs/`.
## Variables
Every variable is one of the server's settings, by its environment name
(docs/dedicated_server.md and port/linux/README.md, "Dedicated server"). A
variable wins over `config.toml`, and the web page's settings cannot
change it.
| Variable | Setting |
| --- | --- |
| Release (`OPENCE_RELEASE`) | `latest` or a tag (`build-206`); used by Reinstall |
| Server name (`HALO_SERVER_NAME`) | `server.name`, 15 characters |
| Rotation (`HALO_SERVER_ROTATION`) | `server.rotation` |
| Max players (`HALO_SERVER_MAX_PLAYERS`) | `server.max_players` |
| Join password (`HALO_SERVER_PASSWORD`) | `server.password` |
| Public (`HALO_SERVER_PUBLIC`) | `server.public` |
| Message of the day (`HALO_SERVER_MOTD`) | `server.motd` |
| Lobby seconds (`HALO_SERVER_LOBBY_SECONDS`) | `server.lobby_seconds` |
| Web UI port (`HALO_SERVER_WEB_PORT`) | `server.web_port`; 0 for none |
| Web UI password (`HALO_SERVER_WEB_PASSWORD`) | `server.web_password` |
| Public address (`HALO_NET_PUBLIC_ADDRESS`) | `network.public_address` |
| Publisher key (`HALO_SERVER_PUBLISHER_KEY`) | The master servers' key itself, in place of `publisher.key` |
| Instance name (`HALO_SERVER_INSTANCE`) | `server.instance` |
| Region (`HALO_SERVER_REGION`) | `server.region` |
| Log format (`HALO_SERVER_LOG_FORMAT`) | `server.log_format` |
The startup command sets `HALO_NET_TUNNEL_PORT` to the primary allocation's
port and `HALO_LOG_ROOT` to `logs/`. Two variables are the administrator's
only: Web UI bind (`0.0.0.0`, as the allocation's address is the node's,
not the container's) and GitHub repository (`OpenCommunityEdition/OpenCE`).
## The web page
The admin page, its API and the remote console need a second allocation:
the node's administrator adds one to the server (or raises its allocation
limit so the owner can, on the Network tab). Give its port as Web UI port
and a password of 8 characters or more as Web UI password, restart, and
open `http://<allocation address>:<port>/`. The page has no TLS of its own:
put it behind a reverse proxy with HTTPS for anything but a private network.
## Stopping and restarting
The panel's Stop sends SIGINT, which the server takes as SIGTERM: the game
in progress goes to its postgame, the players are told, the listing is
withdrawn, and it exits 0. The console's `quit` command does the same, but
Wings counts an exit it did not ask for as a crash and starts the server
again; use Stop. `sv_restart` exits with code 5, which Wings also takes as a
crash and restarts, which is what it asks for.
Updates: `server.auto_update` is for servers that own their files; on a
panel, change the Release variable (or keep `latest`) and Reinstall, by hand
or as a schedule.
## Checking the egg
`python tools/egg_build.py` copies `install.sh` into the egg and checks it;
`python -m pytest -q tools/test_egg.py` checks the egg and runs `install.sh`
in a `debian:bookworm-slim` container against a release served locally.
@@ -0,0 +1,203 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2026-10-24T00:00:00+00:00",
"name": "OpenCE Dedicated Server",
"author": "opence@halocombatevolved.com",
"description": "A dedicated server of OpenCE, the port of Halo: Combat Evolved: hosts multiplayer games one after another, with no window, sound or player of its own. Installs a release from GitHub. The game data is yours to upload: put the maps/ folder of your own copy of the game in the server's folder over SFTP; the server does not start without it.",
"features": [],
"docker_images": {
"Debian 12 with 32-bit libraries (Pterodactyl)": "ghcr.io/ptero-eggs/games:source",
"Debian 12 with 32-bit libraries (Pelican)": "ghcr.io/pelican-eggs/games:source"
},
"file_denylist": [],
"startup": "HALO_NET_TUNNEL_PORT={{SERVER_PORT}} HALO_LOG_ROOT=/home/container/logs ./halo --server",
"config": {
"files": "{}",
"startup": "{\r\n \"done\": [\r\n \"server: ready\",\r\n \"\\\"msg\\\":\\\"ready (\"\r\n ]\r\n}",
"logs": "{}",
"stop": "^C"
},
"scripts": {
"installation": {
"script": "#!/bin/bash\n# The OpenCE dedicated server egg's installation script (Pterodactyl and\n# Pelican), run as root in ghcr.io/ptero-eggs/installers:debian with the\n# server's folder at /mnt/server. tools/egg_build.py copies it into\n# egg-opence-dedicated-server.json.\n#\n# OPENCE_RELEASE: \"latest\" or a release tag (build-206).\n# OPENCE_REPOSITORY: the GitHub repository (owner/name) to download from.\n#\n# A reinstall replaces the release's files and keeps the owner's:\n# config.toml, init.txt, publisher.key, gametypes/, custom_maps/, maps/ and\n# logs/. The panel ignores this script's exit code, so each failure says\n# what went wrong.\n\nset -u\n\nSERVER_DIR=\"${SERVER_DIR:-/mnt/server}\"\nRELEASE=\"${OPENCE_RELEASE:-latest}\"\nREPOSITORY=\"${OPENCE_REPOSITORY:-OpenCommunityEdition/OpenCE}\"\nGITHUB_URL=\"${OPENCE_GITHUB_URL:-https://github.com}\"\nGITHUB_API=\"${OPENCE_GITHUB_API:-https://api.github.com}\"\nASSET=halo-linux-release.zip\n\nfail() {\n\techo \"INSTALL FAILED: $*\"\n\texit 1\n}\n\nif ! [[ \"$REPOSITORY\" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then\n\tfail \"the repository \\\"$REPOSITORY\\\" is not owner/name\"\nfi\nif [ -z \"$RELEASE\" ] || [ \"$RELEASE\" = latest ]; then\n\t# (no API call, so no rate limit)\n\turl=\"$GITHUB_URL/$REPOSITORY/releases/latest/download/$ASSET\"\nelse\n\tif ! [[ \"$RELEASE\" =~ ^[A-Za-z0-9._-]+$ ]]; then\n\t\tfail \"the release \\\"$RELEASE\\\" is not a tag name (build-206) or latest\"\n\tfi\n\techo \"Looking up release $RELEASE of $REPOSITORY\"\n\trelease_json=\"$(curl -fsSL --retry 3 -H 'Accept: application/vnd.github+json' \\\n\t\t\"$GITHUB_API/repos/$REPOSITORY/releases/tags/$RELEASE\")\" \\\n\t\t|| fail \"no release $RELEASE in $REPOSITORY (or GitHub's API did not answer)\"\n\turl=\"$(printf '%s' \"$release_json\" | jq -r --arg name \"$ASSET\" \\\n\t\t'[.assets[]? | select(.name == $name) | .browser_download_url][0] // empty')\"\n\t[ -n \"$url\" ] || fail \"release $RELEASE has no $ASSET\"\nfi\n\nmkdir -p \"$SERVER_DIR\" || fail \"cannot make $SERVER_DIR\"\ncd \"$SERVER_DIR\" || fail \"cannot enter $SERVER_DIR\"\n\nwork=\"$(mktemp -d)\" || fail \"cannot make a temporary folder\"\ntrap 'rm -rf \"$work\"' EXIT\n\necho \"Downloading $url\"\ncurl -fL --retry 3 --progress-bar -o \"$work/$ASSET\" \"$url\" || fail \"the download of $url failed\"\nunzip -tq \"$work/$ASSET\" > /dev/null || fail \"$ASSET is not a zip file\"\nmkdir \"$work/release\"\nunzip -q \"$work/$ASSET\" -d \"$work/release\" || fail \"cannot unzip $ASSET\"\n[ -f \"$work/release/halo\" ] || fail \"$ASSET has no halo executable\"\n\n# the release's files, over the old release's; the owner's are never in it,\n# and gametypes/ only adds the examples not already there\nfor entry in \"$work/release\"/* \"$work/release\"/.[!.]*; do\n\t[ -e \"$entry\" ] || continue\n\tname=\"$(basename \"$entry\")\"\n\tcase \"$name\" in\n\tconfig.toml | init.txt | publisher.key | custom_maps | maps | logs)\n\t\tcontinue\n\t\t;;\n\tgametypes)\n\t\tmkdir -p gametypes\n\t\tcp -rn \"$entry\"/. gametypes/\n\t\tcontinue\n\t\t;;\n\tesac\n\trm -rf \"./$name\"\n\tcp -a \"$entry\" \"./$name\" || fail \"cannot copy $name\"\ndone\n\nchmod 0755 halo\nif [ ! -f halo-server ]; then\n\tprintf '#!/bin/sh\\nexec \"$(dirname \"$0\")/halo\" --server \"$@\"\\n' > halo-server\nfi\nchmod 0755 halo-server\nmkdir -p maps custom_maps gametypes logs\n\necho \"Checking that the server runs\"\nif version=\"$(HOME=\"$work\" HALO_SAVE_ROOT=\"$work/saves\" timeout 30 ./halo --version 2>&1)\"; then\n\techo \"$version\"\nelse\n\techo \"WARNING: ./halo --version did not run. The server needs the 32-bit C runtime\"\n\techo \"WARNING: (the egg's ghcr.io/ptero-eggs/games:source image has it):\"\n\techo \"$version\"\nfi\n\nif [ ! -f maps/ui.map ]; then\n\techo \"No game data yet: upload your maps/ folder (ui.map and the multiplayer maps)\"\n\techo \"over SFTP into the server's folder before starting it.\"\nfi\necho \"Installation completed\"\n",
"container": "ghcr.io/ptero-eggs/installers:debian",
"entrypoint": "bash"
}
},
"variables": [
{
"name": "Release",
"description": "A release tag (build-206) or latest. A change takes effect on Reinstall (Settings tab), which keeps config.toml, init.txt, gametypes/, custom_maps/ and maps/.",
"env_variable": "OPENCE_RELEASE",
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:40|regex:/^[A-Za-z0-9._-]+$/",
"field_type": "text"
},
{
"name": "Server name",
"description": "The game's name in the lobby and the server browser: 15 characters of printable ASCII.",
"env_variable": "HALO_SERVER_NAME",
"default_value": "OpenCE Server",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:15|regex:/^[\\x20-\\x7E]+$/",
"field_type": "text"
},
{
"name": "Rotation",
"description": "The games played one after another, separated by commas: map:gametype, with +rule=value after a gametype (bloodgulch:ctf, sidewinder:team_slayer+score=50). A gametype is a built-in's name or a gametypes/<name>.toml of yours.",
"env_variable": "HALO_SERVER_ROTATION",
"default_value": "bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:4096",
"field_type": "text"
},
{
"name": "Max players",
"description": "2 to 127.",
"env_variable": "HALO_SERVER_MAX_PLAYERS",
"default_value": "16",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer|between:2,127",
"field_type": "text"
},
{
"name": "Join password",
"description": "The password the server browser asks for; empty for none. The invite link joins without it.",
"env_variable": "HALO_SERVER_PASSWORD",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:64",
"field_type": "text"
},
{
"name": "Public",
"description": "List the server in everyone's server browser.",
"env_variable": "HALO_SERVER_PUBLIC",
"default_value": "true",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|in:true,false",
"field_type": "text"
},
{
"name": "Message of the day",
"description": "A notice each player is shown on joining (127 characters); empty for none.",
"env_variable": "HALO_SERVER_MOTD",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:127",
"field_type": "text"
},
{
"name": "Lobby seconds",
"description": "Seconds the lobby counts down before each game (0 to 300).",
"env_variable": "HALO_SERVER_LOBBY_SECONDS",
"default_value": "15",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer|between:0,300",
"field_type": "text"
},
{
"name": "Web UI port",
"description": "0: no web page. Else the port of an additional allocation (Network tab) for the admin page, its API and the remote console, at http://<allocation address>:<port>/; it needs a web UI password.",
"env_variable": "HALO_SERVER_WEB_PORT",
"default_value": "0",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer|between:0,65535",
"field_type": "text"
},
{
"name": "Web UI password",
"description": "The admin password of the web page, 8 characters or more.",
"env_variable": "HALO_SERVER_WEB_PASSWORD",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|min:8|max:64",
"field_type": "text"
},
{
"name": "Public address",
"description": "host[:port] offered to joiners first, for a node behind a NAT that STUN cannot see through; usually empty.",
"env_variable": "HALO_NET_PUBLIC_ADDRESS",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:253",
"field_type": "text"
},
{
"name": "Publisher key",
"description": "Your approved key for the master servers that list dedicated servers: the 64 hex characters of the seed ./halo --server --keygen writes to publisher.key. Empty: the key file, if there is one; the server is not listed on the masters without one.",
"env_variable": "HALO_SERVER_PUBLISHER_KEY",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|regex:/^[0-9a-f]{64}$/",
"field_type": "text"
},
{
"name": "Instance name",
"description": "Tells this server from your others on the master servers: letters, digits, dashes.",
"env_variable": "HALO_SERVER_INSTANCE",
"default_value": "main",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|alpha_dash|max:32",
"field_type": "text"
},
{
"name": "Region",
"description": "A label in the server browser (eu, na-east); empty for none.",
"env_variable": "HALO_SERVER_REGION",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|alpha_dash|max:16",
"field_type": "text"
},
{
"name": "Log format",
"description": "The console's lines: text, or json for a log collector.",
"env_variable": "HALO_SERVER_LOG_FORMAT",
"default_value": "text",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|in:text,json",
"field_type": "text"
},
{
"name": "Web UI bind",
"description": "The container's address the web port listens on: all of them, as the allocation's address is the node's.",
"env_variable": "HALO_SERVER_WEB_BIND",
"default_value": "0.0.0.0",
"user_viewable": false,
"user_editable": false,
"rules": "required|string|in:0.0.0.0",
"field_type": "text"
},
{
"name": "GitHub repository",
"description": "owner/name the installation downloads the release from.",
"env_variable": "OPENCE_REPOSITORY",
"default_value": "OpenCommunityEdition/OpenCE",
"user_viewable": false,
"user_editable": false,
"rules": "required|string|max:100|regex:/^[A-Za-z0-9._-]+\\/[A-Za-z0-9._-]+$/",
"field_type": "text"
}
]
}
+100
View File
@@ -0,0 +1,100 @@
#!/bin/bash
# The OpenCE dedicated server egg's installation script (Pterodactyl and
# Pelican), run as root in ghcr.io/ptero-eggs/installers:debian with the
# server's folder at /mnt/server. tools/egg_build.py copies it into
# egg-opence-dedicated-server.json.
#
# OPENCE_RELEASE: "latest" or a release tag (build-206).
# OPENCE_REPOSITORY: the GitHub repository (owner/name) to download from.
#
# A reinstall replaces the release's files and keeps the owner's:
# config.toml, init.txt, publisher.key, gametypes/, custom_maps/, maps/ and
# logs/. The panel ignores this script's exit code, so each failure says
# what went wrong.
set -u
SERVER_DIR="${SERVER_DIR:-/mnt/server}"
RELEASE="${OPENCE_RELEASE:-latest}"
REPOSITORY="${OPENCE_REPOSITORY:-OpenCommunityEdition/OpenCE}"
GITHUB_URL="${OPENCE_GITHUB_URL:-https://github.com}"
GITHUB_API="${OPENCE_GITHUB_API:-https://api.github.com}"
ASSET=halo-linux-release.zip
fail() {
echo "INSTALL FAILED: $*"
exit 1
}
if ! [[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
fail "the repository \"$REPOSITORY\" is not owner/name"
fi
if [ -z "$RELEASE" ] || [ "$RELEASE" = latest ]; then
# (no API call, so no rate limit)
url="$GITHUB_URL/$REPOSITORY/releases/latest/download/$ASSET"
else
if ! [[ "$RELEASE" =~ ^[A-Za-z0-9._-]+$ ]]; then
fail "the release \"$RELEASE\" is not a tag name (build-206) or latest"
fi
echo "Looking up release $RELEASE of $REPOSITORY"
release_json="$(curl -fsSL --retry 3 -H 'Accept: application/vnd.github+json' \
"$GITHUB_API/repos/$REPOSITORY/releases/tags/$RELEASE")" \
|| fail "no release $RELEASE in $REPOSITORY (or GitHub's API did not answer)"
url="$(printf '%s' "$release_json" | jq -r --arg name "$ASSET" \
'[.assets[]? | select(.name == $name) | .browser_download_url][0] // empty')"
[ -n "$url" ] || fail "release $RELEASE has no $ASSET"
fi
mkdir -p "$SERVER_DIR" || fail "cannot make $SERVER_DIR"
cd "$SERVER_DIR" || fail "cannot enter $SERVER_DIR"
work="$(mktemp -d)" || fail "cannot make a temporary folder"
trap 'rm -rf "$work"' EXIT
echo "Downloading $url"
curl -fL --retry 3 --progress-bar -o "$work/$ASSET" "$url" || fail "the download of $url failed"
unzip -tq "$work/$ASSET" > /dev/null || fail "$ASSET is not a zip file"
mkdir "$work/release"
unzip -q "$work/$ASSET" -d "$work/release" || fail "cannot unzip $ASSET"
[ -f "$work/release/halo" ] || fail "$ASSET has no halo executable"
# the release's files, over the old release's; the owner's are never in it,
# and gametypes/ only adds the examples not already there
for entry in "$work/release"/* "$work/release"/.[!.]*; do
[ -e "$entry" ] || continue
name="$(basename "$entry")"
case "$name" in
config.toml | init.txt | publisher.key | custom_maps | maps | logs)
continue
;;
gametypes)
mkdir -p gametypes
cp -rn "$entry"/. gametypes/
continue
;;
esac
rm -rf "./$name"
cp -a "$entry" "./$name" || fail "cannot copy $name"
done
chmod 0755 halo
if [ ! -f halo-server ]; then
printf '#!/bin/sh\nexec "$(dirname "$0")/halo" --server "$@"\n' > halo-server
fi
chmod 0755 halo-server
mkdir -p maps custom_maps gametypes logs
echo "Checking that the server runs"
if version="$(HOME="$work" HALO_SAVE_ROOT="$work/saves" timeout 30 ./halo --version 2>&1)"; then
echo "$version"
else
echo "WARNING: ./halo --version did not run. The server needs the 32-bit C runtime"
echo "WARNING: (the egg's ghcr.io/ptero-eggs/games:source image has it):"
echo "$version"
fi
if [ ! -f maps/ui.map ]; then
echo "No game data yet: upload your maps/ folder (ui.map and the multiplayer maps)"
echo "over SFTP into the server's folder before starting it."
fi
echo "Installation completed"
+49
View File
@@ -0,0 +1,49 @@
# The dedicated server on PufferPanel
`template.json` is a PufferPanel template for the dedicated server
(`docs/dedicated_server.md`), run in PufferPanel's host environment: it
downloads a release's `halo-linux-release.zip` into the server's folder,
runs `./halo --server` there, and stops it by writing `quit` to its
standard input.
## Using it
1. The node needs the 32-bit C runtime (Debian and Ubuntu:
`dpkg --add-architecture i386 && apt install libc6:i386`). The template
mounts `/lib32` and `/usr/lib32` into PufferPanel's sandbox, which does
not otherwise; without them the 32-bit `halo` cannot start.
2. Add the template (Templates, New, and paste the file), and create a
server from it.
3. Upload the `maps/` folder of your own copy of the game into the
server's folder. Without `maps/ui.map` and the rotation's maps the server
exits with code 2 and says which maps it looked for.
4. Start it.
## Ports
The **Tunnel port** (UDP, 2302 by default) is the one port players need.
The **Web UI port** is the web page, its API and the remote console, on
127.0.0.1 only. Its default is 8090, because PufferPanel itself listens on
8080. Each server on a node needs a tunnel port and a web port of its own
(or a web port of 0, for none).
## Files
Everything the server writes stays in its folder: `config.toml` (every
setting, written at the first start, with the web page's admin password if
none was given), `saves/` (`HALO_SAVE_ROOT`: the profiles and the map cache,
about 115 MB, built at the first start), `logs/` (`HALO_LOG_ROOT`:
`debug.txt`, `bans.txt`, `cheaters.txt`) and `status.json`
(`server.status_file`, the status every 2 seconds).
The template's variables are passed as `HALO_*` variables, which win over
`config.toml`: `sv_set` and the web page cannot change those settings.
## PufferPanel versions
The template is in the format of PufferPanel 2's templates (`run.command`
as a string, `run.stop` as the command written to standard input). We have
not confirmed how PufferPanel 3 imports it, nor the form of its standard
input settings; if a server made from it on PufferPanel 3 does not stop
with `quit`, set its stop to send `quit` to standard input (or SIGTERM,
which the server takes as well) in the server's settings.
+140
View File
@@ -0,0 +1,140 @@
{
"type": "opence",
"display": "OpenCE Dedicated Server",
"install": [
{
"type": "download",
"files": [
"${release_url}"
]
},
{
"type": "command",
"commands": [
"unzip -o halo-linux-release.zip",
"rm -f halo-linux-release.zip",
"chmod 0755 halo halo-server",
"mkdir -p maps custom_maps gametypes saves logs",
"./halo --version"
]
}
],
"run": {
"command": "./halo --server",
"stop": "quit",
"workingDirectory": "",
"pre": [],
"post": [],
"environmentVars": {
"HALO_NET_TUNNEL_PORT": "${port}",
"HALO_SERVER_NAME": "${name}",
"HALO_SERVER_ROTATION": "${rotation}",
"HALO_SERVER_MAX_PLAYERS": "${max_players}",
"HALO_SERVER_PUBLIC": "${public}",
"HALO_SERVER_WEB_PORT": "${web_port}",
"HALO_SERVER_LOG_FORMAT": "${log_format}",
"HALO_SERVER_STATUS_FILE": "status.json",
"HALO_SAVE_ROOT": "saves",
"HALO_LOG_ROOT": "logs"
},
"autostart": false,
"autorecover": true,
"autorestart": false
},
"data": {
"release_url": {
"type": "string",
"display": "Release",
"desc": "The release's halo-linux-release.zip: .../releases/latest/download/halo-linux-release.zip, or .../releases/download/<tag>/halo-linux-release.zip. Used when the server is installed again.",
"required": true,
"value": "https://github.com/OpenCommunityEdition/OpenCE/releases/latest/download/halo-linux-release.zip",
"userEdit": true
},
"port": {
"type": "integer",
"display": "Tunnel port",
"desc": "The UDP port of internet play (network.tunnel_port), the one port players need.",
"required": true,
"value": 2302,
"userEdit": true
},
"name": {
"type": "string",
"display": "Server name",
"desc": "The game's name in the server browser: 15 characters of printable ASCII.",
"required": true,
"value": "OpenCE Server",
"userEdit": true
},
"rotation": {
"type": "string",
"display": "Rotation",
"desc": "The games played one after another: map:gametype entries separated by commas.",
"required": true,
"value": "bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer",
"userEdit": true
},
"max_players": {
"type": "integer",
"display": "Max players",
"desc": "2 to 127.",
"required": true,
"value": 16,
"userEdit": true
},
"public": {
"type": "boolean",
"display": "Public",
"desc": "List the server in everyone's server browser.",
"required": true,
"value": true,
"userEdit": true
},
"web_port": {
"type": "integer",
"display": "Web UI port",
"desc": "The web page, its API and the remote console, on 127.0.0.1 (server.web_port); 0 for none. Each server on a node needs a port of its own, and not PufferPanel's own (8080). The admin password is printed at the first start and kept in config.toml.",
"required": true,
"value": 8090,
"userEdit": true
},
"log_format": {
"type": "option",
"display": "Log format",
"desc": "The console's lines.",
"required": true,
"value": "text",
"userEdit": true,
"options": [
{
"value": "text",
"display": "Text"
},
{
"value": "json",
"display": "JSON"
}
]
}
},
"environment": {
"type": "host",
"mounts": [
"/lib32",
"/usr/lib32"
]
},
"supportedEnvironments": [
{
"type": "host",
"mounts": [
"/lib32",
"/usr/lib32"
]
}
],
"requirements": {
"os": "linux",
"arch": "amd64"
}
}
+1
View File
@@ -0,0 +1 @@
d04e8b101007d1641cace8b95fcca76f7f44709f6885c3d031b52394dbdf9376
+52
View File
@@ -0,0 +1,52 @@
# The dedicated server under systemd
`halo-server@.service` runs the dedicated server (`docs/dedicated_server.md`)
as a templated unit: one instance for each folder
`/opt/opence/servers/<name>`, all sharing one release in `/opt/opence/bin`
and one read-only copy of the game data in `/opt/opence/data`.
| Path | What |
| --- | --- |
| `/opt/opence/bin/` | The release (`halo-linux-release.zip`, unzipped) |
| `/opt/opence/data/` | The game data, only read: `maps/`, `custom_maps/`, `gametypes/` |
| `/opt/opence/servers/<name>/` | The instance's own, the only folder it may write: `config.toml`, `init.txt`, `publisher.key`, `server.env`, `saves/` (the map cache), `logs/` |
## Installing
```sh
sudo useradd --system --home-dir /opt/opence --shell /usr/sbin/nologin halo
sudo install -d -o halo -g halo -m 0750 /opt/opence/servers/main
sudo cp deploy/systemd/halo-server@.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now halo-server@main
journalctl -u halo-server@main -f
```
Open UDP 2302 (the instance's `network.tunnel_port`) in the firewall.
## Settings
`server.env` in the instance's folder holds its settings as `HALO_*`
variables, one a line (port/linux/README.md, "Dedicated server"). A
variable set there wins over `config.toml`, and `sv_set` and the web page
cannot change it; leave out what you want to change from the web page.
A second instance on the machine needs ports of its own, and its own
loopback address for system link:
```sh
HALO_NET_TUNNEL_PORT=2303
HALO_SERVER_WEB_PORT=8081
HALO_NET_ADDRESS=127.0.0.2
HALO_NET_BROADCAST=127.0.0.1
```
The web port stays on 127.0.0.1 unless `server.env` says otherwise (and
then it needs a password, and TLS in front of it).
## Exit codes
Exit code 5 (`sv_restart`, or an update installed) restarts the server; 2
(no usable maps) and 3 (bad configuration) do not, as a restart would fail
the same way. The unit has no console (`StandardInput=null`): commands come
from `init.txt` and the web port.
+96
View File
@@ -0,0 +1,96 @@
# A dedicated server under systemd (docs/dedicated_server.md), one instance
# for each folder /opt/opence/servers/<name>:
#
# /opt/opence/bin/ the release (halo-linux-release.zip, unzipped)
# /opt/opence/data/ the game data, shared by every instance and
# only read: maps/, custom_maps/, gametypes/
# /opt/opence/servers/<name>/ the instance's own: config.toml, init.txt,
# publisher.key, server.env, logs/, saves/
#
# server.env holds the instance's settings as HALO_* variables, one a line.
# A second instance on the machine needs its own ports, and its own loopback
# address for system link:
#
# HALO_NET_TUNNEL_PORT=2303
# HALO_SERVER_WEB_PORT=8081
# HALO_NET_ADDRESS=127.0.0.2
# HALO_NET_BROADCAST=127.0.0.1
#
# Install:
# sudo useradd --system --home-dir /opt/opence --shell /usr/sbin/nologin halo
# sudo install -d -o halo -g halo -m 0750 /opt/opence/servers/main
# sudo cp deploy/systemd/halo-server@.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now halo-server@main
# journalctl -u halo-server@main -f
#
# Open UDP 2302 (each instance's network.tunnel_port) in the firewall. The
# web port stays on 127.0.0.1 unless server.env says otherwise.
#
# Exit code 5 (sv_restart, or an update installed) restarts the server;
# 2 (no usable maps) and 3 (bad configuration) do not, as a restart would
# fail the same way.
[Unit]
Description=OpenCE dedicated server %i
Documentation=https://github.com/OpenCommunityEdition/OpenCE/blob/main/docs/dedicated_server.md
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=halo
Group=halo
WorkingDirectory=/opt/opence/servers/%i
Environment=HOME=/opt/opence/servers/%i
Environment=HALO_CONFIG_FILE=/opt/opence/servers/%i/config.toml
Environment=HALO_DATA_ROOT=/opt/opence/data
Environment=HALO_SAVE_ROOT=/opt/opence/servers/%i/saves
Environment=HALO_LOG_ROOT=/opt/opence/servers/%i/logs
Environment=HALO_SERVER_INIT_FILE=/opt/opence/servers/%i/init.txt
Environment=HALO_NET_TUNNEL_PORT=2302
EnvironmentFile=-/opt/opence/servers/%i/server.env
ExecStartPre=/bin/mkdir -p /opt/opence/servers/%i/logs /opt/opence/servers/%i/saves
ExecStart=/opt/opence/bin/halo --server
# (no console: commands come from init.txt and the web port)
StandardInput=null
StandardOutput=journal
StandardError=journal
KillSignal=SIGTERM
TimeoutStopSec=15
Restart=on-failure
RestartSec=10
RestartForceExitStatus=5
RestartPreventExitStatus=2 3
# (no privileges, nothing writable but the instance's folder)
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
ProtectSystem=strict
ReadWritePaths=/opt/opence/servers/%i
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_INET AF_UNIX AF_NETLINK
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
# (a 32-bit x86 program)
SystemCallArchitectures=native x86
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
SystemCallErrorNumber=EPERM
UMask=0027
MemoryMax=600M
TasksMax=64
[Install]
WantedBy=multi-user.target
+762
View File
@@ -0,0 +1,762 @@
# The dedicated server: reference
`halo --server` runs the game as a host with no window, sound or player of
its own: other machines join it on the LAN, by its invite link or from the
server browser, as they join any host. `port/linux/README.md`, "Dedicated
server", says how to run one; this document is the reference for what it
takes and answers: the command line, the exit codes, the commands, the
HTTP API, the remote console (RCON) over WebSocket, the events, the log and
the files. `docs/master_protocol.md` is the master servers' protocol, which
the server publishes to and the server browser reads.
The code is `port/linux/src/server_*.c` (the platform side: the mode, the
standard input, the log, the rotation's grammar, the HTTP and WebSocket
server, the master servers' publisher) and `port/linux/game/server_*.c`
(the game side: the director that plays one game after another, the
commands, the gametypes, the status). The Android and web builds have no
dedicated server: they compile those files to nothing, but for
`server_json.c` (the JSON reader, which the server browser's list of master
servers uses in every build) and `server_rotation.c` (the rotation's
grammar and the gametype rules), which every build compiles.
## Panels and fleets
A game panel (Pterodactyl, PufferPanel, MCSManager: `deploy/`) runs one
server and reads its standard output; `deploy/systemd` and `deploy/docker`
run servers on one machine. For many servers on many machines, `opence-fleet`
([github.com/OpenCommunityEdition/opence-fleet](https://github.com/OpenCommunityEdition/opence-fleet))
is one control plane over them all: an agent on each machine runs the
servers and drives each through this document's API, as the owner from
the machine itself (`server.web_owner_password`, below). What it relies on
is named in the status's and the RCON's `capabilities`: `owner_web` (the
owner's role over the web, from loopback), `ban_address` (`sv_ban_address`,
`POST /api/bans`), `stop` (`sv_stop`, `POST /api/stop`, the `drain` event),
`event_addresses` (addresses in the player events) and `identity` (the
status's `pid`, `instance`...).
## Command line
| Argument | What it does |
| --- | --- |
| `--server` | Dedicated server mode (as `HALO_SERVER=1` or `server.enabled = true`) |
| `--config <path>` | The settings file; default `config.toml` beside the executable. `HALO_CONFIG_FILE`, when it is set, wins over `--config` |
| `--version` | Prints `OpenCE build-<n> (network version <v>)` and exits 0 |
| `--keygen` | Writes a new publisher key (`server.publisher_key_file`) unless one exists, prints its public key and key id, exits 0; exits 3 when the key cannot be made, read or written |
| `--sign <text>` | Prints the publisher key's Ed25519 signature of the text, in hex (an application's proof of key), exits 0; exits 3 without a text or a usable key |
| `--probe [<invite>]` | A look at a server, for a list site that shows its state: joins nothing and never starts a server (whatever `--server`, `HALO_SERVER` or `server.enabled` say). It finds the invite's game over its tunnel (without an invite, the LAN's first) and prints it as one line of JSON, the game of the invite's host alone (never another game that answers), and exits 0; when nothing answers in 30 seconds it prints `{"ok":false,"error":"no game answered in 30 seconds"}` and exits 1. Internet play is as set (`network.online`, on by default), which an invite needs |
| `--help` | Prints these, exits 0 |
`--version`, `--help`, `--keygen`, `--sign` and `--probe` write no settings: the
settings file is read, never completed or made. `--probe` wins over
`--server`, `HALO_SERVER` and `server.enabled`: a probe never writes to
`config.toml`.
A probe's line:
```json
{"ok":true,"name":"OpenCE Server","map":"bloodgulch","gametype":"ctf","players":5,"max_players":16,
"teams":true,"in_progress":true,"open":true,"dedicated":true,"network_version":38,"kind":0}
```
`gametype` is the game engine's name (`ctf`, `slayer`, `oddball`, `king`,
`race`, or `none`), `kind` the OpenCE gametype's kind the host sends (0 for
none), `open` whether it takes players now.
The release has a `halo-server` script beside `halo`, which runs
`halo --server` with its arguments.
## Exit codes and signals
| Code | Meaning |
| --- | --- |
| 0 | Stopped: `quit`, `sv_stop` (now, or at the end of its drain), SIGTERM or SIGINT |
| 1 | A fatal error in the game (a halt, a map that failed to load); the last error lines were logged. The process exits within 5 seconds of the error, even when what runs at the exit (a hook, a listing's withdrawal) does not return |
| 2 | No usable game data: `maps/ui.map`, or every rotation map, missing or not a cache file; nothing was bound |
| 3 | Bad server configuration: no rotation entry that can be played (and `server.rotation_fallback` off), `server.web_port` past 65535, a web port the server may not bind (a port below 1024 without the right to it) or whose `server.web_bind` is an address the machine does not have, or a web password (admin, moderator or viewer) shorter than 8 characters with the web port bound beyond loopback (on loopback, a warning), or the owner's (`server.web_owner_password`) shorter than 8 wherever it is bound; `--keygen` or `--sign` failing. An entry that cannot be played (a bad gametype file among them) is left out with a warning: the server stops only when none is left |
| 4 | A port in use: the tunnel's UDP port, or the web port (another program listens on it) |
| 5 | Restart asked for (`sv_restart`, now or at the end of its drain, or an update installed: `server.auto_update = "install"` installs a new release once the server has been empty for 10 minutes, on a thread of its own: the server goes on meanwhile, and stops with 5 once it is empty again (a player who joined during the install plays on). An install that fails is tried again at the next check, 6 hours on) |
| 128 + n | Stopped by signal n: a second SIGTERM or SIGINT, or the shutdown not done 5 seconds after the first |
SIGTERM and SIGINT stop the server: a game in progress goes to its
postgame, every machine is told, the listings are withdrawn, and it exits 0.
SIGHUP reloads the server's settings, the rotation and the gametype files
(`sv_reload`). SIGUSR1 prints the status on standard output. SIGPIPE and
SIGTTIN are ignored.
## Standard output
One line per event, for the operator and the panel:
```
2026-10-19T18:03:11Z INFO server: ready (tunnel 2302/udp, web 127.0.0.1:8080, listing public)
2026-10-19T18:03:40Z INFO player: Milenko joined (slot 0, machine 1)
2026-10-19T18:04:02Z INFO game: started: CTF on bloodgulch, 2 players
```
The fields are the time (UTC), the level (`INFO`, `WARN`, `ERROR`), the
category (`server`, `lobby`, `game`, `player`, `chat`, `moderation`, `net`,
`master`, `console`, `health`) and the text. `server: ready` is printed once,
when the lobby is open and, for a public server, its listing has been
published (or 10 seconds on, as the brokers are other people's): it is the
line a panel waits for. With `server.log_format = "json"` each line is an
object instead, and the line a panel waits for holds `"msg":"ready (`:
```json
{"time":"2026-10-19T18:03:11Z","level":"info","cat":"server","msg":"ready (tunnel 2302/udp, ...)"}
```
Every line is also kept in the log the web's viewers read (`/api/log`,
`sv_log`, the RCON's `log` events), but for these, which go to standard
output alone: the web password a first start makes, the invite (a
private server's lets anyone in; `/api/status` gives it to admins), and
the owner's console, the commands typed on standard input or read from the
init file and what they answered (a password's command is shown as `(a
password's command)` there too).
A health line comes once a minute: `health: players=3 frame_ms_max=4.1
frame_ms_avg=1.20 cpu=2.1% rss=96MB` (the longest and the mean frame of
the last minute, in milliseconds). Standard error keeps the platform
layer's `halo-linux:` lines, and `debug.txt` everything, as for a player's
game.
## Commands
The same commands come from standard input, `server.init_file` (run once at
the start, a command a line, `#`, `;` and `//` starting comments), the telnet
console (`debug.telnet_console`, loopback), the RCON, `POST /api/command`
and the web page. Each runs on the game's thread between frames. Every
command has a role; a caller may run it if their role is that one or above:
**viewer** < **moderator** < **admin** < **owner** (standard input, the
init file and the telnet console are the owner's: whoever holds the
process; so is a sign-in to the web or the RCON with
`server.web_owner_password` from the machine itself: "Signing in"). When
standard input ends and `server.console_exit_on_eof` is on, its last line
runs, whether or not a line's end ended it, and then the server stops.
A `<player>` is a slot number (`sv_players` lists them) or the start of a
name; a name that begins more than one player's is refused. A `<duration>`
is a number and a unit (`45s`, `30m`, `2h`, `7d`, `1w`), a bare number of
minutes, or `forever`; ten years at most. A word in quotes (`"two words"`)
is one word.
A command's text (a notice, a reason, `sv_set`'s value, a name) is kept to
printable ASCII: control characters (a line's end, a tab) and other bytes
outside it are dropped, as are the quotes around the whole text, and
standard input's and the init file's lines are cleaned so before they run.
The web is stricter where it takes a value as it is: `PUT /api/settings`
refuses a value with a control character (`schema`), and nothing is changed
for that setting. A command's reply is sent whole, however many lines.
Commands that are not the server's (the game's own console commands, the
scripts' globals and functions) need the owner role: from the web or the
RCON they are refused (`not a command of the server's`) but to the owner's
session. The server's
commands come first, so `kick` and `ban` are always the server's below, not
the game's console commands of those names.
| Command | Role | What it does |
| --- | --- | --- |
| `help [command]` | viewer | The commands, or one command's use |
| `sv_status [--json]` | viewer | The server's state, map, gametype, players, uptime, games played, network version, listing and next game |
| `sv_players [--json]` | viewer | Each player's slot, name, team, score, kills, deaths, assists, ping and machine; with the owner role or `server.log_addresses`, their address and hardware id |
| `sv_rotation` (`sv_mapcycle`), `sv_maps`, `sv_gametypes`, `sv_version`, `sv_log [n]` | viewer | Lists: the rotation, the maps there are, the gametypes, the build, the log's last n lines |
| `sv_bans` (`sv_banlist`), `sv_settings` | admin | Lists: the bans of `bans.txt`, the settings `sv_set` changes |
| `sv_masters`, `sv_publisher` | viewer | The master servers and how each answered; the publisher key's id and public key (never its seed) |
| `say <text>` (`sv_say`) | moderator | A notice on every machine's screen |
| `tell <player> <text>` | moderator | A notice on one player's machine |
| `kick <player>` (`sv_kick`) | moderator | Drops the player's machine; it may join again |
| `ban <player> [<duration>] [<reason>]` (`sv_ban`) | moderator (a day at most), admin | Drops the player's machine and bans its address and hardware id, for the duration (else for ever). A moderator must give a duration of a day or less: a ban for ever, or longer, is refused |
| `unban <number\|address\|hardware id>` (`sv_unban`) | admin | Takes a ban out of `bans.txt`; the machine may join again at once, by the LAN or the internet |
| `sv_ban_address <address\|hardware id> [<duration>] [<reason>]` | admin | Bans an IPv4 address (`a.b.c.d`) or a hardware id (up to 32 hex digits), its machine here or not: its line in `bans.txt` as `ban` writes it (the other part `none`), so that `sv_bans`, `unban` and `GET /api/bans` see it, and a machine present of that address or hardware id is dropped as `ban` drops it (`player_left`'s reason `banned`). Answers `ban <n> added` (`n`: its number in `sv_bans`) |
| `mute <player> [<minutes>]`, `unmute <player>` | moderator | The host's mute of a player's chat and voice for everyone |
| `sv_map <map> [<gametype>]`, `sv_map <entry>` | moderator | Plays that game (or a rotation entry, `map:gametype+rule=value`; a co-op one too), below |
| `sv_map_next` (`sv_next`) | moderator | Ends the game without its scores and plays the rotation's next |
| `sv_end_game` | moderator | Ends the game: its scores, then the next |
| `sv_rotation_set <entries>`, `sv_rotation_add <entry>`, `sv_rotation_del <n>`, `sv_rotation_begin [<n>]` (`sv_mapcycle_add`, `_del`, `_begin`) | admin | Changes the rotation (written to `server.rotation`, 4096 characters at most) |
| `sv_set <setting> <value>` | admin | Changes one of the server's settings, now where it can be, and in `config.toml` (below); a setting its environment variable sets is refused. A value is 256 characters at most |
| `sv_name <name>`, `sv_maxplayers <n>`, `sv_password [<password>]`, `sv_public <0\|1>`, `sv_motd <text>` | admin | `sv_set` of `server.name`, `server.max_players`, `server.password`, `server.public`, `server.motd` |
| `sv_reload` | admin | Reads `config.toml`'s server settings, the rotation and the gametype files again (as SIGHUP) |
| `sv_stop [now\|game\|empty] [<notice>]`, `sv_stop cancel` | owner | Stops the server (exit code 0): `now` (the default) as `quit` does; `game` once the game being played is over, `empty` once nobody plays (a drain, below); `cancel` the drain waiting. The notice is said to every machine first |
| `sv_restart [now\|game\|empty] [<notice>]`, `sv_restart cancel` | owner | As `sv_stop`, with exit code 5 (restart me) |
| `quit` (`exit`) | owner | Stops the server (exit code 0) |
`sv_map`'s game is played as soon as it can be, and its answer says when:
`next: <entry> (now)` in the lobby before its start was sent (the lobby
takes the entry, and the rotation's entry it replaced is played after it),
`(now: this game ends)` while a game is played (it ends at once, without
its scores), and `(once this game is over)` once the lobby's start was sent
or while a map loads (it is the next game). `sv_map` of the game being
played plays it again. The rotation goes on after it.
A drain (`sv_stop` or `sv_restart` with `game` or `empty`, `POST
/api/stop`) is a stop that waits: the listings are withdrawn as `sv_public
0` withdraws them (a new invite, the masters told), without the setting
changing; new machines are refused as while a map loads (the game is not
open: the LAN's search shows it closed, a join by the invite is told the
game is not open); then the server stops. With `game` it stops once no
game is played nor about to be: at once in the lobby with nobody in it, or
with no countdown running (fewer players than `server.min_players`);
otherwise once the game played (or about to start) is over and its
postgame shown. With `empty` it stops once nobody is on the server (at once
if nobody is now). `sv_stop cancel` (or `sv_restart cancel`) ends the
drain: the server is listed again as `server.public` says and takes new
players. A `drain` event says each (`after`: `now`, `game`, `empty` or
`cancel`), and the stop that ends a drain has the `shutdown` reason
`drained`. `/api/status`'s `drain` is the drain waiting, or `null`.
A co-op entry (`<level>:coop`) is ended as a game is: by `sv_end_game`,
`sv_map_next`, `sv_map`, `server.empty_seconds` and `server.idle_minutes`.
It has no scores: the server goes back to the lobby at once (no postgame),
and a level won ends it with `game_ended`'s reason `score`.
`sv_set` (and the settings of the web page, `PUT /api/settings`) changes
these settings, which `sv_settings` and `GET /api/settings` list:
`server.name`, `server.max_players`, `server.min_players`,
`server.password`, `server.public`, `server.rotation_fallback`,
`server.lobby_seconds`, `server.postgame_seconds`, `server.empty_seconds`,
`server.idle_minutes`, `server.team_start`, `server.motd`,
`server.frame_rate`, `server.web_password`,
`server.web_moderator_password`, `server.log_format`,
`server.log_addresses`, `server.status_file`, `server.auto_update`,
`server.coop_difficulty`, `server.region`, `server.master_publish`,
`server.chat_filter`, `server.chat_filter_slurs`, `server.chat_filter_swearing`,
`server.chat_filter_insults`,
`server.master_publish_address`, `server.metrics`, and `network.text_chat`,
`network.voice_lobby`, `network.voice_mode`, `network.voice_kbps`,
`network.votekick`, `network.votekick_minutes`,
`network.votekick_ban_minutes`, `network.map_share`,
`network.map_share_upload`, `network.public_address`,
`network.coop_friendly_fire`, `network.coop_player_collisions`,
`network.coop_ally_friendly_fire`, `network.shield_effects` and
`network.fire_origins`. Of them,
`server.status_file`, `server.auto_update` and `server.log_addresses` need
the owner role: from the web's admin, `sv_set` of one of them (`POST
/api/command`) answers `403 forbidden`, and `PUT /api/settings` leaves it
out, in its `errors` with the code `forbidden` and the message `<setting>
needs the owner role (the server's console)`; the owner's session changes
them both ways. A web password is empty or
8 characters or more. A value outside a setting's choices or range is
refused (`400 failed` from `sv_set`, `schema` in `PUT /api/settings`'s
`errors`), with `<setting> takes a whole number from <min> to <max>` (or
`from <min>`) or `<setting> is one of <a>|<b>|<c>`; a choice may be given
in either case, and is kept as the table spells it:
| Setting | Choices or range |
| --- | --- |
| `server.max_players` | 2 to 127 |
| `server.min_players` | 1 to 16 |
| `server.lobby_seconds`, `server.postgame_seconds` | 0 to 300 |
| `server.empty_seconds`, `server.idle_minutes` | 0 or more |
| `server.frame_rate` | 30 to 120 |
| `server.team_start` | `one`, `both`, `skip` |
| `server.log_format` | `text`, `json` |
| `server.auto_update` | `off`, `check`, `install` |
| `server.coop_difficulty` | `easy`, `normal`, `hard`, `impossible` |
| `network.text_chat` | `on`, `links`, `off` |
| `server.chat_filter` | `off`, `censor`, `block` |
| `server.chat_filter_slurs`, `server.chat_filter_swearing`, `server.chat_filter_insults` | `off`, `severe`, `strong`, `mild` |
| `network.voice_mode` | `off`, `team_proximity`, `team_enemy_proximity`, `team_global`, `team_global_enemy_proximity` |
| `network.voice_kbps` | 8 to 64 |
| `network.votekick_minutes` | 0 to 60 |
| `network.votekick_ban_minutes` | 1 to 1440 |
| `network.map_share_upload` | 16 to 20480 |
| `network.coop_friendly_fire` | `off`, `on`, `shields_only`, `explosives_only` |
| `network.shield_effects` | `stock`, `glow`, `light_show`, `overshield`, `glow_overshield`, `light_show_overshield`, `players` |
A dedicated server may also keep words out of its chat and its players'
names (`server.chat_filter`, off by default): the words of streamer mode's
list, by group, each group from the least severity its setting names
(`server.chat_filter_slurs`, `mild` by default: every slur;
`server.chat_filter_swearing` and `server.chat_filter_insults`, `off`).
`censor` shows a line's words as stars to everyone; `block` refuses the
line (its sender is told "Your line was not sent: it has words this server
does not allow") and refuses at the door a machine whose player's name has
them, which an updated game shows as "The host refused: your player name"
(an older one says the game is closed). Each is logged (`chat filter: …`,
`refusing a machine … its name has words the server's filter keeps out`).
Links are `network.text_chat`'s, refused by default. A list of words is
not a moderator: spaced letters and look-alikes get past it.
A dedicated server's text chat is its `network.text_chat`'s, listed or
not: `on` by default (lines with links refused), `links`, or `off`, live
(`sv_set network.text_chat off`). A game a player hosts has no text chat
while it is listed in the server browser; a dedicated server is the
exception, and the players who join it from the browser's dedicated servers
have the chat as it says, as long as a master server lists it (the
masters' word that it is an approved operator's; a listing's own flag is
not enough, so a dedicated server listed by the brokers alone, before its
operator is approved, has none for them).
The settings `sv_set` does not change (`server.web_viewer_password`,
`server.web_owner_password`, `server.web_port`, `server.web_bind`, `server.web_trusted_proxy`, the
paths, the master servers' files...) are changed in `config.toml` or their
environment variables: `sv_reload` reads the file's `server.*` settings
again, and the web port and its address are bound once, at the start. The
rotation is changed by the `sv_rotation_*` commands.
## HTTP
The server listens on `server.web_port` (8080; 0 for none) at
`server.web_bind` (`127.0.0.1`, an IPv4 address). Bound beyond loopback,
every web password that is set (admin, moderator, viewer) must have 8
characters or more, else the server exits with code 3; the owner's
(`server.web_owner_password`) must have 8 or more wherever the port is
bound. The page (`/`), the API
(`/api/...`), the RCON (`/ws`), `/healthz` and `/metrics` share the port.
Requests are HTTP/1.1 (or 1.0), `GET`, `HEAD`, `POST`, `PUT` or `DELETE`
(`HEAD` is answered as `GET` is, without the body, and counts as a read,
as a `GET`; `/api/login` takes `POST` alone), a head of 8 KB at most whose
lines end with CRLF (a bare LF, a CR inside a line, or more after the empty
line that ends it: `400 bad_request`) and a body of 64 KB at most, sent within 10 seconds of the
connection's opening or of its last answer (a connection that sends
nothing is closed then too). `Transfer-Encoding` and `Expect` are refused.
24 connections are taken at once; when they are all taken, the oldest that
waits for a request (no RCON, no request being answered) is closed for a
new one. A client that does not read its answers is read no further once
256 KB of them wait, and is closed past 2 MB.
Every request but `/healthz` counts against its address's allowance, the
page's files, `/metrics`, a sign-in and the RCON's upgrades (`/ws`) too: 60
a minute, or 600 with a session (429 `rate_limited` past it, with
`Retry-After: 60`). Behind `server.web_trusted_proxy`, a request's address
is the last entry of its `X-Forwarded-For` (the one the proxy appended: an
IPv4 address, `a.b.c.d` or `a.b.c.d:port`, or an IPv6 one, `v6`, `[v6]` or
`[v6]:port`), and its `X-Forwarded-Host` and `X-Forwarded-Proto` are
believed, whether or not it sends `X-Forwarded-For`; from any other
address they are ignored. IPv6 clients are counted by their /64: one
host's addresses share its allowance and its lockouts. Every answer is JSON
(`Content-Type: application/json`) unless said, with
`Cache-Control: no-store`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy: same-origin` (under `no-referrer` a browser would send
the page's own changes with `Origin: null`) and
`Content-Security-Policy: default-src 'self'; frame-ancestors 'none'`.
### Signing in
`POST /api/login` with `{"password":"…"}` answers
`{"ok":true,"token":"<64 hex>","role":"admin","csrf":"<32 hex>","expires":<unix seconds>}`
and sets the cookie `opence_session=<token>; HttpOnly; SameSite=Strict;
Path=/`, with `Secure` when the request came through
`server.web_trusted_proxy` with `X-Forwarded-Proto: https` (never from
another address, whatever it says). The admin password
(`server.web_password`) gives the admin role, the moderator password
(`server.web_moderator_password`) the moderator role, the viewer password
(`server.web_viewer_password`) the viewer role; `{"by":"…"}` beside the
password names who signed in, in the log. A session lasts 12 hours, or 30
minutes without a request (the RCON's messages do not count: a page left
open asks `GET /api/session` now and then); 16 are kept at most, of every
role.
The owner's password (`server.web_owner_password`, empty by default: none)
gives the owner role, for a supervisor on the server's own machine (a
fleet's agent, a panel's daemon): what standard input may do, over the
API and the RCON (`quit`, `sv_stop`, `sv_restart`, `POST /api/stop`, the
owner's settings by `sv_set` and `PUT /api/settings`, the game's own
console commands by `POST /api/command`, the players' addresses and
hardware ids in `/api/players` and the events). It signs in only when the
connection's own peer address is in `127.0.0.0/8` and is not the trusted
proxy's: never by `X-Forwarded-For`, and never through
`server.web_trusted_proxy`, even one on the machine itself. From any other
address the right owner's password is refused with `403 forbidden` (the
RCON's `auth`: `{"code":"forbidden"}`, and the connection closes 1008),
and counts toward the address's lockout as a wrong password does. An
owner's session is used from the machine itself alone, as it was begun
(from elsewhere its token is no session: `401`, the RCON's `auth` with it
`denied`). A password that is also another role's gives the owner role
from the machine itself and the other role from elsewhere: give the owner
a password of its own.
A script passes `Authorization: Bearer <token>`. The page passes the
cookie, and on every request that changes something the header
`X-CSRF-Token: <csrf>`; the `Origin` of such a request (and of the
WebSocket's) must be the server's own. An `Origin: null` is taken only
when the browser's `Sec-Fetch-Site` says `same-origin`.
`POST /api/logout` ends the session (with the `X-CSRF-Token` by the
cookie; `GET` answers 405). `GET /api/session` answers
`{"ok":true,"role":…,"csrf":…,"expires":…}`, or 401.
When `server.web_password`, `server.web_moderator_password`,
`server.web_viewer_password` or `server.web_owner_password` changes
(`sv_set` and the settings change the first two; `sv_reload` reads any of
them from `config.toml`), the sessions of its role end and that role's RCON
connections close (1008, `the password changed`).
Five wrong passwords from an address (an IPv6 one's /64) in 10 minutes
lock it out for 10 minutes (429), and no more than 30 passwords a minute
are checked in all. `/api/login` takes `POST` alone (any other method:
405).
### Errors
```json
{"ok":false,"error":{"code":"forbidden","message":"needs the admin role"}}
```
| Status | Codes |
| --- | --- |
| 400 | `bad_request`, `schema` (a field missing or out of range), `failed` (the command ran and failed: its lines in `lines`), `rejected` (rotation entries: `rejected[]`), `invalid` (a gametype file: `line`) |
| 401 | `denied` (no session, or a wrong password) |
| 403 | `forbidden` (the role; the owner's password from beyond the machine), `csrf`, `origin` |
| 404 | `not_found` (also a command that is not one, a player or a ban that is not there) |
| 405 | `method` |
| 409 | `pinned` (a setting its environment variable sets), `changed` (a player's action whose slot holds another player now: the current name in `name`, nothing done) |
| 413 | `too_large` |
| 426 | `bad_request` (the WebSocket's version is not 13) |
| 429 | `rate_limited`, `locked` (with `Retry-After`) |
| 500 | `internal` (out of memory, a file that cannot be written) |
| 503 | `loading` (the game did not answer in 20 seconds, as while a map loads; with `Retry-After: 5`), `starting`, `busy` (8 RCON connections already; with `Retry-After: 5`) |
### Endpoints
| Method, path | Role | Body → answer |
| --- | --- | --- |
| `GET /healthz` | none | `200 {"state":"lobby"}` (or `loading`, `playing`, `postgame`) while the server hosts and its frames run; `503` with `{"state":"starting"}`, `{"state":"stalled"}` (no frame for a minute) or the other state otherwise |
| `GET /metrics` | none (`server.metrics`) | Prometheus text |
| `GET /api/status` | viewer | The status (below) |
| `GET /api/players` | viewer | `{"ok":true,"players":[<player>…]}` (with `address` and `hwid` for the owner, or every role with `server.log_addresses`) |
| `POST /api/players/{slot}/kick` | moderator | `{"name":"<the player's name, checked>"}` → `{"ok":true,"lines":[…]}`, or `409 changed` when the slot holds another player |
| `POST /api/players/{slot}/ban` | moderator | `{"name":…,"duration":"1d","reason":"…"}` (no duration: for ever, which a moderator's ban cannot be: `400 failed`) |
| `POST /api/players/{slot}/mute` | moderator | `{"name":…,"minutes":10}` |
| `POST /api/players/{slot}/unmute` | moderator | `{"name":…}` |
| `POST /api/players/{slot}/tell` | moderator | `{"name":…,"text":"…"}` |
| `POST /api/say` | moderator | `{"text":"…"}` |
| `GET /api/rotation` | viewer | `{"ok":true,"index":0,"entries":[<entry>…],"next":<entry>}` |
| `PUT /api/rotation` | admin | `{"entries":["bloodgulch:ctf",…]}` → the rotation, or `400 rejected` with `"error":{…,"rejected":[{"index":1,"entry":"…","reason":"…"}]}` and nothing changed |
| `POST /api/rotation/next` | moderator | `{}`: `sv_map_next` |
| `POST /api/rotation/play` | moderator | `{"map":"…","gametype":"…"}`: `sv_map` (a map or gametype that cannot be played: `400 failed`) |
| `GET /api/gametypes` | viewer | `{"ok":true,"builtin":["slayer",…],"schema":{"fields":[<field>…]},"files":[{"name":…,"base":…,"description":…,"error":null}]}` (`builtin`: the Xbox's gametypes, then OpenCE's; a file that cannot be read has `base` and `description` empty and `error` saying why) |
| `GET /api/gametypes/{name}` | viewer | `{"ok":true,"name":…,"text":"<the TOML>"}` |
| `PUT /api/gametypes/{name}` | admin | `{"text":"<TOML>"}` → `{"ok":true}`, or `400 invalid` with `line` and `message` (a name is `a`–`z`, `0`–`9`, `_` and `-`, 32 at most) |
| `DELETE /api/gametypes/{name}` | admin | `{"ok":true}` |
| `GET /api/maps` | viewer | `{"ok":true,"maps":[{"name":"bloodgulch","level":"levels\\test\\bloodgulch\\bloodgulch","kind":"xbox","title":"Blood Gulch"}…]}` (`kind`: `xbox` or `custom`; `title` is the map's display name, else its `name`) |
| `GET /api/bans` | admin | `{"ok":true,"bans":[{"n":0,"when":"2026-10-19 18:00:00","ip":"…","hwid":"…","players":"…","reason":"…","until":<unix>\|null}…]}` |
| `POST /api/bans` | admin | `{"ip":"203.0.113.5","hwid":"…","names":"…","duration":"1d","reason":"…"}` (`ip` or `hwid`, or both; the rest may be left out: no duration is for ever) → `{"ok":true,"n":<the new ban's number in GET /api/bans>}`: `sv_ban_address`, a machine present of the address or the hardware id dropped; `400 schema` for an address that is not IPv4, a hardware id that is not hex, a duration that is not one |
| `DELETE /api/bans/{n}` | admin | `{"ok":true}` |
| `GET /api/settings` | admin | `{"ok":true,"settings":[<setting>…]}` |
| `PUT /api/settings` | admin | `{"server.name":"…","server.max_players":16,"server.public":true,…}` (a value as JSON, a boolean, a number or a string, or its text: `"true"`, `"16"`; `null` empties it) → `{"ok":true,"applied":["server.name"],"errors":[{"name":…,"code":…,"message":…}]}`; each setting is changed or refused on its own |
| `POST /api/command` | the command's | `{"line":"sv_status"}` (511 characters at most) → `{"ok":true,"lines":["…"]}`, or a refusal (`failed`, `forbidden`, `not_found`, `pinned`) with the command's `lines` |
| `POST /api/stop` | owner | `{"after":"now\|game\|empty","notice":"…","restart":false}` (`notice` and `restart` may be left out) or `{"cancel":true}` → `{"ok":true,"lines":["…"]}`: `sv_stop` (`sv_restart` with `restart`) `after notice`, or `sv_stop cancel` (`400 failed` with no drain waiting); `400 schema` for another `after` |
| `GET /api/log?after=<n>&limit=<m>` | viewer | `{"ok":true,"lines":[{"n":12,"time":<ms>,"level":"info","cat":"game","text":"…"}…],"next":12,"missed":0}` (`limit` 1 to 1024, 256 by default; the chat's lines for admins only) |
| `GET /api/events?after=<seq>` | viewer | `{"ok":true,"events":[<event>…],"next":<seq>,"missed":0}` (`player_joined` and `player_left` with `address` and `hwid` for the owner, or every role with `server.log_addresses`) |
| `GET /ws` | | The RCON (below) |
| `GET /`, `/app.js`, `/app.css`, `/icon.svg` | none | The page (`server.web_ui`) |
`next` is the `after` of the next request: the last line's `n` (or event's
`seq`) read. An `after` past the latest (as after a restart) reads from the
start. `missed` counts lines or events that left the ring (2048 lines, 512
events) before they were asked for.
### The status
```json
{"ok":true,"time":1760900000123,"name":"OpenCE Server","state":"playing",
"build":"build-206","network_version":38,"uptime":3600,"games_hosted":4,
"map":"bloodgulch","level":"levels\\test\\bloodgulch\\bloodgulch","gametype":"ctf","variant":"CTF","teams":true,
"players":5,"max_players":16,"machines":5,"countdown":-1,"game_seconds":312,"team_scores":[2,1],
"listing":{"online":true,"public":true,"password":false,"invite":"halo://join/…"},
"rotation":{"index":0,"count":3,"next":{"text":"sidewinder:team_slayer","map":"sidewinder","gametype":"team_slayer"}},
"frame_ms_max":4.1,"frame_ms_avg":1.2,"tick_ms_avg":1.2,
"masters":[{"url":"https://master1.example.org","listed":true,"probe":"ok","code":"","message":"","updated":1760899990}],
"update":{"available":false,"build":""},
"drain":null,
"pid":4021,"started":1760896400,"instance":"eu-1","region":"eu","tunnel_port":2302,
"publisher_key_id":"3f9a2c7d1e5b8a04","web_port":8080,
"capabilities":["events","status","rotation","settings","bans","owner_web","ban_address","stop","event_addresses","identity"]}
```
`state` is `waiting` (the menus settling), `hosting` (making the game),
`lobby`, `loading`, `playing`, `postgame` or `stopping`. `countdown` is the
lobby's seconds left, `-1` when it is not counting. `team_scores` is `null`
in a game without teams. `frame_ms_max` and `frame_ms_avg` are the longest
and the mean frame of the last minute (`tick_ms_avg` is the mean again, for
older readers). `invite` is given to admins only (empty for others). An entry of the rotation is
`{"text":"sidewinder:team_slayer+score=50","map":"sidewinder","gametype":"team_slayer","rules":{"score":50},"rejected":null}`
(`rejected` is why it cannot be played, else `null`). `drain` is the
drain waiting (`sv_stop game`...), `{"after":"game","restart":false,"since":<unix
seconds>}`, else `null`. `pid` is the server's process, `started` when it
started (seconds since 1970), `instance` the name the master servers know
it by (`server.instance`, else the slug of `server.name` they are given),
`region` `server.region`, `tunnel_port` the tunnel's UDP port
(`network.tunnel_port`; 0 when `network.online` is off), `publisher_key_id`
the publisher key's id (16 hex digits; empty without a key: `sv_publisher`)
and `web_port` `server.web_port`. `capabilities` is the RCON's hello's. The rotation's
`index` (in the status and `GET /api/rotation`) is the entry being played,
`-1` while an `sv_map` game is played or before the first lobby; `next` is
the entry after the one played last (the first while none has been).
A player is
`{"slot":0,"name":"Milenko","team":0,"score":3,"kills":3,"deaths":1,"assists":0,"ping":42,"machine":1,"joined":1760899000,"muted":false}`;
with the owner role, or `server.log_addresses`, also
`"address":"203.0.113.5","hwid":"…"`. `team` is 0 (red), 1 (blue) or -1
(no teams); `ping` is in milliseconds, `null` when it is not known yet.
`client` is the player's machine's build as its game tells it (shortly
after it joins), for every role:
`{"build":282,"flavor":"release","version":"OpenCE Linux | build 282 (release)","repository":"OpenCommunityEdition/OpenCE","mods":["trollface 1.0"]}`
(`build` 0 for a build CI did not number, `repository` empty when the build
does not know it, `mods` the mods it plays in their load order, each its
folder and its version), or `null` from a game that does not tell it. It is
what the player's game says, which a changed build can make anything.
A setting is
`{"name":"server.name","type":"string","value":"OpenCE Server","default":"OpenCE Server","comment":"…","env":"HALO_SERVER_NAME","pinned":false,"secret":false,"choices":null,"min":null,"max":null}`;
`type` is `boolean`, `integer`, `real` or `string`, and `value` and
`default` are of that type in JSON (`true`, `16`, `"OpenCE Server"`); a
reader should also take their text (`"true"`, `"16"`), which older servers
sent. `choices` lists a word setting's words, `min` and `max` bound a
number's, each `null` where there is none (the table above). `env` is
`null` for a setting no environment variable sets. A secret one (the
passwords) has `value` and `default` empty and `"set":true` when it is
set. `pinned` is a setting
its environment variable (`env`) sets: it cannot be changed.
A gametype schema field is
`{"key":"score_to_win","alias":"score","table":"universal","engine":"all","type":"integer","min":0,"max":999,"choices":null,"default":null,"description":"…"}`
(`table`: the gametype file's table it goes in, `universal`, `options`,
`ctf`, `slayer`, `oddball`, `king` or `race`; `engine`: `all`, or the game
engine whose table it is; `type`: `integer`, `boolean` or `choice`).
## The RCON
`GET /ws` with `Upgrade: websocket` and the subprotocol `opence-rcon-1`.
Every frame is text holding one JSON object. The server speaks first:
```
→ {"type":"hello","protocol":1,"server":"OpenCE Server","build":"build-206","network_version":38,
"capabilities":["events","status","rotation","settings","bans","owner_web","ban_address","stop","event_addresses","identity"]}
← {"type":"auth","password":"…","by":"paul"} or {"type":"auth","token":"<session token>"}
→ {"type":"auth","ok":true,"role":"admin"}
→ {"type":"auth","ok":false,"error":{"code":"denied"}} and the connection closes (1008)
← {"type":"command","id":1,"line":"kick Milenko"} or {"type":"command","id":1,"command":"say","args":["two words"]}
→ {"type":"reply","id":1,"ok":true,"lines":["Milenko kicked by the host"]}
→ {"type":"reply","id":2,"ok":false,"lines":["…why…"]} (the command ran and failed)
→ {"type":"reply","id":3,"ok":false,"lines":[],"error":{"code":"busy","message":"…"}} (not run)
← {"type":"subscribe","events":["player","chat","game","moderation","status","log","master"],"after":1830}
→ {"type":"event","seq":1831,"time":1760900000123,"event":"player_joined","slot":3,"name":"Milenko",…,"replay":true}
→ {"type":"event","seq":1831,"time":…,"event":"status","status":{…the status…}} (at the subscribe, then every 2 s)
→ {"type":"event","time":…,"event":"log","n":5120,"level":"info","cat":"game","text":"…"} (no seq: n is its sequence)
← {"type":"ping"}
→ {"type":"pong"}
→ {"type":"overflow"} and the connection closes (1013): the client read too slowly
→ {"type":"error","id":…,"error":{"code":"…","message":"…"}} (an unknown type, a malformed message)
```
A connection signed in with the page's cookie (on the same origin) needs no
`auth` message: its `hello` is followed by `{"type":"auth","ok":true,…}`.
The `capabilities` say what the server has beyond the first release's
(`events`, `status`, `rotation`, `settings`, `bans`): `owner_web`,
`ban_address`, `stop`, `event_addresses` and `identity` ("Panels and
fleets"); a client checks for one before it uses it. The owner's password
signs in from the server's own machine alone ("Signing in").
A command is a `line`, or a `command` and its `args`, which the server
joins with spaces, quoting an argument that holds a space or is empty (511
characters in all at most). Ids are the client's (a number or a string),
echoed in the reply; each command gets exactly one reply. A connection
runs one command at a time: a command sent before the last one's reply is
not run. A reply whose command ran has `ok` and the command's `lines`, and
no `error`, whether it succeeded or failed; one that was not run has `ok`
false, no lines and an `error`: `denied` (not signed in), `rate_limited`
(32 commands a second, in bursts of 64), `bad_request` (no command, or too
long), `busy` (another command's reply is awaited) or `loading` (the game
did not answer in 20 seconds; the command may still have run, and is never
sent again by the server). `after` replays the events still in the ring
since that sequence number, marked `"replay":true`. A connection holds 1024
messages for the client at most; 8 connections are taken at once (a ninth
upgrade answers `503 busy`), and each upgrade counts against its address's
allowance (above). The
event groups are `player` (`player_joined`, `player_left`, `map_share`), `chat`
(`chat`), `game` (`lobby_opened`, `countdown`, `game_started`,
`game_ended`, `postgame`, `score`, `kill`), `moderation` (`kick`, `ban`,
`unban`, `mute`, `unmute`, `speed_hack`), `status` (`status`), `log`
(`log`, each log line as `{"n","level","cat","text"}`), `master`
(`master`), and `server` (`server_started`, `reloaded`, `warning`,
`drain`, `shutdown`), which every subscriber gets.
The log's lines of the `chat` category go to the `chat` group, the others
to `log`.
`status` and `log` events go to their subscribers as they come and are
not kept in the ring: `after` does not replay them (`/api/log` reads the
log's own ring). A `status` event's `seq` is the latest event's (no new
number: the same one comes again until another event), and a `log` event
has no `seq`: its `n`, the log line's number and `/api/log`'s cursor, is
its sequence. A client keeps one cursor for the events (`seq`, of the
events of the ring) and another for the log's lines (`n`); a client that
reconnects with `after` may get events it had, and drops those whose `seq`
it has seen (a `status` event is never dropped so).
## Events
Every event has `seq` (rising, never reused while the server runs; a
`status` event has the latest one's, a `log` event none: above), `time`
(milliseconds since the epoch) and `event`:
| Event | Fields |
| --- | --- |
| `server_started` | `name, build, network_version, pid, started, instance, region, tunnel_port, publisher_key_id, web_port` (as the status's), `rotation[]` (the entries' texts) |
| `lobby_opened` | `map, gametype, variant` (the gametype's name as the game shows it), `teams, index` (the rotation's entry) |
| `countdown` | `seconds` (-1: paused) |
| `game_started` | `map, gametype, variant, teams, team_scores[]` (team games), `players[]` |
| `game_ended` | `map, gametype, variant, teams, reason` (`score`, `time`, `idle`, `empty`, `command`, `next`), `duration` (seconds), `team_scores[]` (team games), `players[]` |
| `postgame` | `seconds` |
| `player_joined` | `slot, name, team, machine`; `address, hwid` for the owner, or every role with `server.log_addresses` |
| `player_left` | `slot, name, reason` (`left`; `kicked` and `banned` when the server's `kick`, `ban` or `sv_ban_address` dropped the machine, `banned` for a speed hack's drop too); `address, hwid` (as it joined) as `player_joined`'s |
| `chat` | `channel` (`everyone`, `team`, `vehicle`), `slot, name, text` (admins only; the lines the host sends on) |
| `kill` | `killer, victim` (slots; -1 for none), `weapon` (the killer's weapon's or the killing object's tag name, or null), `friendly_fire` |
| `score` | `map, gametype, variant, teams, team_scores[]` (team games), `players[]` (once a second at most, on a change) |
| `kick`, `ban`, `unban`, `mute`, `unmute` | `name, by, reason?, until?` |
| `speed_hack` | `machine, names, rate, ahead_ticks, action` (`kicked`, or `unverified`: its datagrams alone said so, and it stays) |
| `map_share` | `machine, map, state` (`asked`, `downloading` at each tenth, `downloaded` or `failed` once for each request), `progress` (0 to 100: a percentage already) |
| `warning` | `text` (a newer release, with `server.auto_update`) |
| `reloaded` | `rotation_changed, rejected[]` (`{"entry","reason"}` of each entry left out) |
| `master` | `url, listed, probe, code, message` |
| `drain` | `after` (`now`, `game`, `empty`, `cancel`), `restart` (exit code 5), `notice` (empty for none) |
| `shutdown` | `reason` (`quit`, `sv_stop`, `sv_restart`, `drained`: a drain's end, `signal <n>`, `update installed`, `nothing to play`, `debug.exit_after`), `code` (the exit code) |
Players in events are as in `/api/players` for the viewer role (no
addresses), but for `player_joined` and `player_left`, which carry the
player's address and hardware id (`""` when not known) to the owner, and to
every role when `server.log_addresses` is on (`/api/players`' rule), in the
RCON's events, their replay and `GET /api/events` alike. `variant` is the gametype's name as the game shows it (`Co-op`
in a co-op entry).
## Bans
A ban is a line of `bans.txt` (in `paths.logs`, else the data root). A
timed ban is kept out by its line's `until=` alone, and ends then. A ban
for ever, and a machine dropped for cheating, also keep the machine out
for the rest of the run by its address, its real address or its hardware
id. `sv_ban_address` and `POST /api/bans` write such a line for an address
or a hardware id with no player of it needed (the part not given is
`none`). `unban` takes the line out and readmits the machine by
the line's address or hardware id, whether it joins on the LAN or by
internet play.
## The rotation
`server.rotation` (and `sv_rotation_set`, `PUT /api/rotation`) is a list of
entries, `map[:gametype[+rule=value...]]`, separated by commas, semicolons
or new lines; a line that begins with `#` is a comment (to its line's end,
commas and all). The text is 4096 characters at most (the entries past it
are left out, with a warning), an entry 255 characters and the list 64
entries. `config.toml` keeps a string setting of up to 4096 characters, so
a rotation that long is saved whole and read back after a restart.
- The map is a multiplayer map's name (`bloodgulch`: the Xbox's
`maps/<name>.map`, else a Custom Edition map of `custom_maps`),
`custom_maps\<name>` (a Custom Edition map; `/` is taken for `\`), or a
level's path (`levels\test\bloodgulch\bloodgulch`). A campaign level is
played as co-op only: `<level>:coop`, at `server.coop_difficulty`.
- The gametype (`slayer` when there is none) is a built-in's name
(`slayer`, `team_slayer`, `slayer_pro`, `elimination`, `phantoms`,
`endurance`, `rockets`, `snipers`, `stalker`, `ctf`, `ironctf`,
`ctf_pro`, `invasion`, `oddball`, `team_oddball`, `reverse_tag`,
`accumulation`, `juggernaut`, `king`, `team_king`, `king_pro`,
`crazy_king`, `race`, `team_race`, `rally`, `team_rally`, and Halo PC's
names `ffa`, `ffa_slayer`, `teamslayer`, `tslayer`, `koth`, `team_koth`,
`iron_ctf`, `teamrace`, `teamoddball`), an OpenCE gametype's (`swat`,
`infection`, `gun_game`, `hardcore`, `hardcore_ctf`, `hardcore_oddball`,
`hardcore_king`), a gametype file's name (`gametypes/<name>.toml`), or
`file=<name>` (a gametype a player's game saved, below). A gametype's
name is letters, digits, `_`, `-` and `.`, in any case.
- A rule is a gametype rule's name or its short name (`score`, `time`,
`weapons`, `ff`...; `GET /api/gametypes`'s schema lists them), with a
value it takes; a rule of another game engine than the gametype's is
refused. 48 rules at most; a rule given twice is the last.
An entry that cannot be played (a map that is not there, a bad gametype
file, a rule out of range) is left out with a warning, at the start and at
`sv_reload`; `sv_rotation_set` and `PUT /api/rotation` change nothing when
one of their entries cannot be played.
A change of the rotation (`sv_rotation_*`, `PUT /api/rotation`,
`sv_reload`) keeps its place: the next game is the entry after the one
played last, found again in the new list (the first entry only when none
has been played yet). `sv_reload` takes the rotation again when its text
changed, and also when only its entries' rejections changed (a map or a
gametype file added or taken out since).
`server.team_start` decides how a team game starts. `one` (the default)
starts it with the players of one team. `both` waits, saying nothing,
for a player on each team. `skip`, when one player waits for a team game
that `sv_map` did not ask for, plays the rotation's next entry without
teams instead (if it has one); it is checked when the lobby opens and
again when its countdown would begin. With no entry left, the server
stops with exit code 3 (2 when no rotation map is there), or plays
`bloodgulch:slayer` with `server.rotation_fallback`.
## Gametype files
A gametype file, `gametypes/<name>.toml` (the name `a`–`z`, `0`–`9`, `_`
and `-`), is a built-in or OpenCE gametype (`base`) and rules over it:
```toml
base = "team_king" # the gametype it starts from
name = "Moving Hill" # the name the game shows: 11 characters of printable ASCII
[universal]
score_to_win = 3
[options]
time_limit = 15
friendly_fire = "off"
[king]
moving_hill = true
```
Each rule goes in its table (the schema's `table`): `[universal]`,
`[options]`, and one game engine's, `[ctf]`, `[slayer]`, `[oddball]`,
`[king]` or `[race]`. A file gives each rule its full name
(`vehicle_set`, `team_vehicles`, `score_to_win`): the short names
(`vehicles`, `score`) are the rotation's alone. A file is 64 KB at most.
`deploy/gametypes` has examples. The server reads the `gametypes` folder
beside `config.toml` if there is one, else the data root's, and writes the
web page's files to it.
A gametype a player's game saved is played with `file=<name>`: copy the
saved gametype, as `<name>.gametype`, into the same `gametypes` folder
(beside `config.toml` if there is one, else the data root's).
## The listing
A public server (`server.public`, with `network.online` and
`network.public_lobby`) is published to each master server of
`masters.txt` (`docs/master_protocol.md`). A change that a list shows (the
listing made public or private, a player joining or leaving, the name, the
map) is published within 5 seconds. Made private, the server makes a new
invite and sends the masters a closed publish, which takes it off their
lists;
`network.public_lobby = false` stops the publishing too. A master's
`Retry-After` is kept (a day at most). `key_revoked` and
`publisher_revoked` stop the publishing to that master for the run;
`unknown_key`, `publisher_pending`, `application_denied` and
`listing_taken` are logged once a minute while they last.
## Files
| File | What |
| --- | --- |
| `config.toml` | Every setting (beside the executable; `HALO_CONFIG_FILE`, else `--config`, names another) |
| `init.txt` (`server.init_file`) | Commands run at the start, in the data root unless a full path |
| `gametypes/<name>.toml` | Gametype files: the `gametypes` folder beside `config.toml` if there is one, else the data root's |
| `gametypes/<name>.gametype` | Gametypes saved by a player's game (`file=<name>` in the rotation): the same `gametypes` folder |
| `publisher.key` | The publisher key for the master servers (beside `config.toml`). On Windows, a server's `config.toml` and `publisher.key` are given an access list of their owner alone (the entries inherited for Administrators and SYSTEM taken out) |
| `masters.txt` | The master servers (`server.masters_file`: beside `config.toml`, else beside the executable); one that names none leaves the official master, built in |
| `bans.txt`, `cheaters.txt`, `debug.txt` | In `paths.logs`, else the data root |
| `status.json` (`server.status_file`) | The status, written every 2 seconds |
| A password's file (`HALO_SERVER_WEB_PASSWORD_FILE`, `HALO_SERVER_WEB_OWNER_PASSWORD_FILE`, `HALO_SERVER_WEB_MODERATOR_PASSWORD_FILE`, `HALO_SERVER_WEB_VIEWER_PASSWORD_FILE`, `HALO_SERVER_PASSWORD_FILE`) | A container's secret: when the plain variable is not set, the file's first line (its CR and LF off) is the setting's value, which is then pinned as the plain variable's would be (`sv_set` and `PUT /api/settings` refuse it, `409 pinned`). Read once, at the start; a file that cannot be read is logged, and the setting is `config.toml`'s |
## Releases
Each release has `SHA256SUMS` (the SHA-256 of each of its files, as
`sha256sum` writes them: `<hex> <name>`, written by `tools/ci_build.py
sums`) and `SHA256SUMS.sig`: the Ed25519 signature of that file's exact
bytes by the project's release key, in 128 lowercase hex digits and a
newline (the form `opence-master-server`'s `cmd/release-sign` writes). The
public key is `deploy/release-key.pub` (64 hex digits:
`d04e8b101007d1641cace8b95fcca76f7f44709f6885c3d031b52394dbdf9376`).
`tools/release_sign.sh verify SHA256SUMS` checks the signature (OpenSSL 3),
and `sha256sum -c --ignore-missing SHA256SUMS` the files downloaded. The
build workflow signs with the `RELEASE_SIGNING_KEY` secret (the key's seed,
64 hex digits) and checks the signature against `deploy/release-key.pub`
before it publishes the release.
+650
View File
@@ -0,0 +1,650 @@
# The master servers' protocol, version 1
A master server lists dedicated servers and vouches for who publishes them.
An operator applies to a master once with the public half of an Ed25519
key, the *publisher key*, through the master's Discord bot; once an
approver approves the application, the
operator's servers publish to the master with requests signed by that key,
and the master lists them after probing their UDP port. The game's server
browser fetches each master's list, checks the master's signature with the
key pinned in `masters.txt`, and joins a listed game by the invite in its
listing, as it joins a game heard on the brokers.
A master carries no game traffic: each list entry holds the host-signed
listing the game publishes to the signalling brokers (`p2p_lobby.c`), so
the join path is unchanged and nobody needs the master to be honest about a
game's details.
This document is the contract between the game (the publisher in
`port/linux/src/server_master.c`, the probe's answer, the list client in
`port/linux/src/master_list.c`) and the masters' implementations. The
project's implementation is `opence-master`
(github.com/OpenCommunityEdition/opence-master-server, Go, AGPL-3.0-or-later),
whose `testdata/vectors.json` holds fixed keys and every value this
document derives from them, and whose `tests/conformance` is the test a
master must pass to be an official one. `docs/dedicated_server.md` is the
server's side: its settings, `--keygen` and `--sign`.
## Conventions
| Thing | Form |
| --- | --- |
| Hexadecimal | Lowercase, two digits a byte, no prefix |
| Base64 | The standard alphabet (RFC 4648 section 4) with `=` padding |
| Times | Unix seconds, decimal, UTC |
| JSON | UTF-8; every string the protocol defines is printable ASCII (0x20 to 0x7E) |
| Signatures | Ed25519 as RFC 8032 defines it (with SHA-512): Monocypher's `crypto_ed25519_sign` and `crypto_ed25519_check`, Go's `crypto/ed25519` |
| Transport | HTTPS. A master refuses publishes, key changes and peers' requests over plain HTTP except from loopback |
| Unknown JSON fields | Ignored by both sides, so later versions can add fields |
## Keys and identifiers
| Thing | Definition |
| --- | --- |
| Key file (`publisher.key`, `master.key`) | One line of 64 hexadecimal digits: the Ed25519 seed (32 bytes). Anything after `#` is a comment; blank lines are skipped. Mode 0600 |
| Public key, as text | `ed25519:` and the 32-byte public key in hexadecimal (64 digits) |
| Key id | The first 8 bytes of SHA-256 of the 32-byte public key. Written as 16 hexadecimal digits (`keyid=`, `key_id`, `Signature-Key`); in the probe's packets it is the 8 raw bytes |
| Instance | A name the operator gives each server process (`server.instance`): 1 to 32 characters of `a-z`, `0-9`, `-` and `_`, the first a letter or a digit |
| Server id | The first 16 hexadecimal digits (8 bytes) of SHA-256 of the ASCII text of the key id's 16 hexadecimal digits followed by the instance's text, with nothing between them |
The server id hashes the key id **as its 16 characters of text**, not its 8
bytes. With the vectors' publisher key (RFC 8032's first test seed):
```
seed 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
public key ed25519:d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
key id 21fe31dfa154a261 (SHA-256 of the 32 key bytes: 21fe31dfa154a261...)
instance main
hashed "21fe31dfa154a261main" (20 ASCII bytes)
server id 4c5f15237c866f6f (SHA-256 of those 20 bytes: 4c5f15237c866f6f...)
```
The same instance under another key is another server id, so a server
keeps its id across restarts as long as it keeps its key and its instance.
A master refuses a public key whose point has a small order (its X25519
form multiplied by a clamped scalar is zero, as `p2p_ed25519_verify`
checks), since anyone can make signatures that verify under one.
`masters.txt` (the one beside `config.toml`, else the one beside the
executable) lists the masters, one per line:
`https://master1.example.org ed25519:<64 hex>`; `#` starts a comment; the
first 4 are used. A file that names none (missing, empty, or comments alone,
as the release's) leaves the official master, built into the game
(`master_list_read_defaults` in `port/linux/src/master_list.c`) and the web
page (`servers.js`):
`https://master.opencommunityedition.com ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa`.
The web build reads its site's `masters.json` before the official master. The URL is an origin with no path, or a path
the master is served under as it is (a proxy must not rewrite paths, since
the path is signed).
## Signed requests
A publisher's requests carry a detached signature in one header:
```
Authorization: OpenCE-Ed25519 keyid=<16 hex>, ts=<unix seconds>, nonce=<32 hex>, sig=<base64 of 64 bytes>
```
The signature is the key's over the bytes of this string, its parts joined
by single line feeds (0x0A) and no line feed at the end:
```
OpenCE-Master-v1
<the receiving master's key id>
<METHOD>
<PATH>
<ts>
<nonce>
<SHA-256 of the body, in hexadecimal>
```
| Part | Rule |
| --- | --- |
| Master key id | The key id of the master the request is sent to: the first 8 bytes of SHA-256 of the master's list-signing public key (the key pinned for it in `masters.txt`), as 16 lowercase hexadecimal digits. It binds the request to that master: a request signed for one master does not verify at another, even within the replay window. A master checks the signature with its own key id |
| `METHOD` | The request's method in capitals: `PUT`, `DELETE`, `GET`, `POST` |
| `PATH` | The path exactly as the request line sends it, without the query string: `/v1/servers/4c5f15237c866f6f`, `/v1/me`, `/v1/publishers` |
| `ts` | The same decimal text as in the header: no sign, no leading zeros |
| `nonce` | 16 random bytes in hexadecimal (32 digits), new for each request |
| Body hash | Of the exact body bytes sent; an empty body's is SHA-256 of no bytes, `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` |
The four parameters come once each, in any order, separated by commas with
optional spaces; a publisher sends them in the order above. The master
checks, in this order, and answers the first failure:
| Check | Failure |
| --- | --- |
| The header parses (scheme, the four parameters and their forms) | `401 bad_signature` |
| `ts` is within 300 seconds of the master's clock | `401 clock` (the body's `master_time` is the master's clock, for the server to log) |
| The key id is a publisher's, or an application's | `403 unknown_key` |
| The signature verifies, for this master's key id | `401 bad_signature` |
| The nonce was not seen with this key in the last 600 seconds | `401 replay` |
Since `unknown_key` is answered before the signature is checked, anyone can
learn whether a key id is known to a master (a publisher's or an
application's) by sending it with any signature. This is a known property
of version 1: key ids are not secret (each is a hash of a public key, which
an operator hands out when applying), and the answer says nothing more of
the key than that the master has seen it.
The vectors' `GET /v1/me` to the vectors' master (key id
`39f713d0a644253f`) at 1760900001 with the nonce
`f0e0d0c0b0a090807060504030201000` signs
```
OpenCE-Master-v1\n39f713d0a644253f\nGET\n/v1/me\n1760900001\nf0e0d0c0b0a090807060504030201000\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```
and sends
```
Authorization: OpenCE-Ed25519 keyid=21fe31dfa154a261, ts=1760900001, nonce=f0e0d0c0b0a090807060504030201000, sig=duRZ+WKAI8hbvidtC63U1XHxOSoeRN5mcrZvw4qihhlilhZjbVBCLYjsQLr4+F2C1gy6lN9/ixqevFX7dfNGDA==
```
## Publishing
`PUT /v1/servers/{server_id}`, signed, with a JSON body of 16 KB at most.
A server publishes to every master in `masters.txt` every 30 seconds, and
within 5 seconds of a change (the lobby opened, a game started or ended, a
player joined or left, the name or map changed). On shutdown it publishes
`closed: true` (or `DELETE`s) and waits up to 2 seconds.
```json
{"protocol":1,"instance":"main","name":"OpenCE Dedicated","map":"bloodgulch","gametype":"CTF","variant":"CTF",
"players":5,"max_players":16,"teams":true,"in_progress":false,"password":false,"dedicated":true,
"network_version":38,"build":"build-206","region":"eu","tunnel_port":2302,
"listing":"<base64 of the host-signed listing>","closed":false,"serial":412}
```
(The vectors' publish, made at network version 38: "The listing" says
why the examples keep it.) The game's publisher sends `protocol`,
`instance`, `listing` (but in a tombstone), `tunnel_port`, `dedicated`,
`build`, `region` and `variant` when they are set, `address` with
`server.master_publish_address`, `serial` and `closed`; the listing holds
the rest. A server that goes private, or off the internet, sends one more
publish, `closed: true`, to each master that listed it.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `protocol` | number | yes | 1 |
| `instance` | string | yes | The instance; the path's server id must be the server id of the signing key's id and this instance, else `400 server_id` |
| `listing` | string | yes, but with `closed: true` | Base64 of the exact bytes `listing_make` makes, the bytes the game publishes to the brokers |
| `tunnel_port` | number | yes, unless `address` is given | The UDP port the game's tunnel socket is bound to (`network.tunnel_port`), 1 to 65535 |
| `address` | string | no | `"203.0.113.5:2302"`: an IP address (IPv4, or IPv6 in brackets) and a port to probe instead of the request's source address. Sent only with `server.master_publish_address`; a master serves it in its list only if its own policy allows (`MASTER_SERVE_ADDRESSES`). It must be globally routable (below); a private or loopback address is taken only from a private or loopback source |
| `dedicated` | boolean | no | A dedicated server. Also read from the listing's flag 32, which a dedicated server sets; either makes the entry dedicated. A player's public game, published with the player's publisher key, sends `false` |
| `variant` | string | no | The gametype's name as the server shows it; printable ASCII, cut to 24 |
| `build` | string | no | The game's build (`build-206`); cut to 32 |
| `region` | string | no | Up to 16 of `a-z`, `0-9` and `-` (`server.region`) |
| `serial` | number | no | A whole number the server raises at each change; kept and shown to admins |
| `closed` | boolean | no | `true`: unlist the server now |
| `name`, `map`, `gametype`, `players`, `max_players`, `teams`, `in_progress`, `password`, `network_version` | | no | Informational only: the listing is authoritative. A value that differs from the listing's is ignored, and the answer's `warnings` says so |
The master parses the listing and checks the host's signature (below), and
takes from it the name, the map, the gametype, the players and most
players, the gametype's engine, the game's kind, the open, in-progress,
teams and password flags, and the network version, so a publish cannot
claim more than the host signed. A listing that does not parse or verify
is `400 listing`. So is one whose own time is more than 3600 seconds from
the master's clock. A listing with the same host key and a lower sequence
than the one the master holds is not taken (the newer stays; a warning
says so). A listing with the closed flag unlists the server, as
`closed: true` does.
The master records the request's source address (behind a reverse proxy,
the address its trusted proxy forwards) to probe the server. It keeps the
addresses 7 days at most after a server's last publish, for probes and
abuse handling, and never serves them.
The addresses a master probes and serves are an allowlist of globally
routable unicast addresses: an IPv4 address outside IANA's special-purpose
ranges (`0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`,
`192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`,
`198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`); an IPv6 address in
`2000::/3` outside `2001::/23`, `2001:db8::/32`, `3fff::/20` and
`5f00::/16`; a 6to4 (`2002::/16`) or well-known NAT64 (`64:ff9b::/96`)
address whose embedded IPv4 address is global. An IPv4-mapped address is
judged as its IPv4 address. Everything else (link-local, site-local
`fec0::/10`, unique local, multicast, `64:ff9b:1::/48`, zones) is refused
(`400 schema`, `field: address`), except a private or loopback address
from a private or loopback source (a master on the same network). A
publish from a source address outside the allowlist without `address` is
refused the same way when the master probes.
A listing's host key is published by one publisher at a time: a listing
whose host key is that of a live server of another publisher is refused,
`403 listing_taken`, so a listing's bytes copied from a list cannot be
published by someone else. The same publisher may carry it under another
server id (another key or instance). A host makes a new listing (a higher
sequence) at each of its publishes, so a live server that has taken no newer
listing of its host key for 90 seconds (3 intervals) is replaying a copy:
a publish of a newer listing of that host key takes the host key from it,
and its server is unlisted. A server an admin hid, or whose publisher or key
is not approved, holds no host key. A master shows its admins the publishes
it refused with `listing_taken`, to settle (see "Known limitations").
### The answer
`200` once the publish is taken and the master's decision for now is made;
`202` while the first probe of the server's address is unanswered:
```json
{"ok":true,"server_id":"4c5f15237c866f6f","listed":true,"interval":30,"ttl":90,"probe":"ok","reachable":true,
"observed_address":"203.0.113.5","master_time":1760900000}
{"ok":true,"server_id":"4c5f15237c866f6f","listed":false,"interval":30,"ttl":90,"probe":"pending","reachable":false,
"observed_address":"203.0.113.5","probe_address":"203.0.113.5:2302","master_time":1760900000}
```
| Field | Meaning |
| --- | --- |
| `listed` | Whether the list shows the server now |
| `interval` | Seconds between publishes the master expects (30) |
| `ttl` | Seconds after its last publish that a server is unlisted (90): one lost publish does not unlist it, three do |
| `probe` | `pending` (no answer yet at this address), `ok`, `failed` (three attempts unanswered), `skipped` (an admin marked the server trusted), `off` (the master does not probe) |
| `reachable` | The probe was answered |
| `observed_address` | The address the master saw the request come from |
| `probe_address` | With `pending` and `failed`: the address and port the master probes |
| `hidden`, `reason` | An admin hid the server: it is listed to nobody, and `reason` says why. With `probe: failed`, `reason` is the hint to log: `let UDP port <n> through your firewall or NAT, or set network.public_address` |
| `closed` | `true` after `closed: true`, a tombstone or `DELETE` |
| `warnings` | Body fields the listing contradicts, which were ignored |
| `master_time` | The master's clock |
A server with a failed probe is unlisted unless the master's policy lists
unreachable servers (`MASTER_LIST_UNREACHABLE`), as `reachable: false`.
The server logs each change of outcome once (not every 30 seconds), and
the failed probe's hint once a minute until the probe passes.
`DELETE /v1/servers/{server_id}`, signed by any key of the server's
publisher with an empty body, unlists the server at once and answers
`200 {"ok":true,"server_id":…,"listed":false,"closed":true,…}`; `404
not_found` for a server that is not the publisher's.
### Limits
| Limit | Answer |
| --- | --- |
| A server publishes at most once each 5 seconds, with a burst of 2 | `429 rate_limited` with `Retry-After` |
| 120 publishes a minute per source address, and per key | `429 rate_limited` |
| A body is 16 KB at most | `413 too_large` |
| A publisher has at most its quota of live servers at once (default 4; live: published within `ttl` and not closed) | `403 quota_exceeded` |
| An instance is published by one key of a publisher at a time | `403 instance_taken` (another key's server of that instance is live) |
| A publisher holds at most its quota and 16 server records (one per server id); for a new server id past that, its oldest records that are not live are dropped, and if none can be | `403 too_many_instances` |
## The listing
The listing is `p2p_lobby.c`'s (`listing_make`, `listing_read`), in network
byte order. The master reads it as `listing_read` does and checks its
signature as `listing_signed` does.
| Offset | Size | Field |
| --- | --- | --- |
| 0 | 2 | `"HL"` |
| 2 | 1 | Format: 1 |
| 3 | 2 | Network version (`HALO_PORT_NETWORK_VERSION`, 39 now) |
| 5 | 1 | Flags (below) |
| 6 | 4 | Sequence: the host's count of its listings (the newest wins) |
| 10 | 4 | The host's Unix time when it made the listing |
| 14 | 32 | The host's Ed25519 public key (one per run of the game) |
| 46 | 16, or 56 with the password flag | The invite's token (zero in a tombstone); with the password flag the token sealed with the password's key: nonce (24), MAC (16), the token encrypted (16), XChaCha20-Poly1305 with the host's key as additional data (`p2p_seal_token`) |
| then | 1 | Players (at most 255) |
| | 1 | Most players |
| | 1 | The gametype's engine: 0 none (co-op), 1 CTF, 2 Slayer, 3 Oddball, 4 King of the Hill, 5 Race |
| | 1 + up to 32 | The game's name: a length byte, then that many bytes |
| | 1 + up to 32 | The map's name |
| | 1 + up to 24 | The gametype's name |
| | 8 | The stamp: the game's kind (`game_modes_kind`: the OpenCE mode, 1 SWAT, 2 Infection, 3 Gun Game, or 0; with 0x80 for Hardcore), then 7 reserved bytes, zero |
| | 64 | The Ed25519 signature, by the listing's key, of the 12 ASCII bytes `hceu-lobby-1` followed by every byte before the signature |
| Flag | Meaning |
| --- | --- |
| 1 | Open: it takes players now |
| 2 | In progress: a game is under way (or loading) |
| 4 | Teams |
| 8 | Closed: a tombstone, the host stopped |
| 16 | Password: the token is sealed |
| 32 | Dedicated: a dedicated server (`halo --server` sets it, since network version 39) |
A listing is 140 bytes at least (all names empty) and 268 at most (a
password and every name at its length). A reader refuses one outside those
sizes, without `"HL"` and format 1, with a name's length byte past its
field's maximum or past the end, or whose stamp and signature do not end it
exactly. The names are sanitised as the game's `sanitize` does: a byte
outside 0x20 to 0x7E becomes `?`, and a zero ends the name. A master also
refuses a listing whose key has a small order.
The vectors' first listing (an open team game of a dedicated server,
sequence 412, 5 of 16 players, CTF on bloodgulch, named `OpenCE Dedicated`)
is these 169 bytes, of which the first 105 are signed. The vectors were made
at network version 38 and are kept as they are (the bytes and signatures
are fixed): their listings carry version 38 with flag 32, which the game
sets from version 39 on, so a game of version 39 reads them, checks their
signatures and hides them from its browser as another version's games, as
it does any. The examples' `network_version` of 38 in this document are the
vectors' too.
| Field | Bytes |
| --- | --- |
| Magic, format | `484c` `01` |
| Network version 38, flags 37 (open, teams, dedicated) | `0026` `25` |
| Sequence 412, time 1760900000 | `0000019c` `68f533a0` |
| Host key | `fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025` |
| Token | `a0a1a2a3a4a5a6a7a8a9aaabacadaeaf` |
| Players 5, most 16, engine 1 (CTF) | `05` `10` `01` |
| Name, map, gametype | `10` `4f70656e434520446564696361746564`, `0a` `626c6f6f6467756c6368`, `03` `435446` |
| Stamp (kind 0) | `0000000000000000` |
| Signature | `633dc63eb27828d6e589e34dc4336dbb2d514b2018e4bf4a3adf5aeb774b6a523fe662c3d29241c6f91c8714a8ca2b5e493aacb32bcaeb9bce715f15b76d230f` |
(`testdata/vectors.json` has it whole, with a password game's, a
tombstone's and an altered one that must be refused.)
## The probe
Before it lists a server, the master proves that the server's UDP port is
reachable from the internet and that the publisher key's holder answers on
it. It sends a datagram to the probe address (the published `address`, or
the request's source address with `tunnel_port`) from its probe socket:
| Offset | Size | Probe (35 bytes) |
| --- | --- | --- |
| 0 | 10 | `"OPCE-PROBE"` |
| 10 | 1 | 1 |
| 11 | 8 | The master's key id, raw |
| 19 | 16 | A nonce |
The game's tunnel socket, which dispatches what it receives by its first
bytes, recognises the magic and answers from the same socket to the
address the probe came from:
| Offset | Size | Answer (103 bytes) |
| --- | --- | --- |
| 0 | 14 | `"OPCE-PROBE-ACK"` |
| 14 | 1 | 1 |
| 15 | 8 | The master's key id, as the probe had it |
| 23 | 16 | The nonce, as the probe had it |
| 39 | 64 | The publisher key's Ed25519 signature of the 29 bytes `"probe"`, the master's key id, the nonce |
The server answers only a probe of exactly 35 bytes whose key id is that of
a master in its `masters.txt`, only to the probe's source, a given nonce at
most once per second, at most 2 answers a second to one source address and
at most 10 answers a second in all, so the probe cannot be used to reflect
traffic (a 103-byte answer to a 35-byte probe is the whole amplification),
and probes forged from one address cannot use up the answers a master's
probe needs. A probe whose nonce was answered in the same second counts
against neither limit.
The master takes only an answer from the probed address and port, with its
own key id, a nonce it made for that address and key, and a signature that
verifies with the key that signed the publish.
| Rule | Value |
| --- | --- |
| Attempts | 3, 2 seconds apart; failed 2 seconds after the third |
| Again | Every 5 minutes while the server is reachable (it stays listed meanwhile); a failed server at its first publish a minute or more after the failure; at once when its probe address or key changes |
| Nonce | Opaque to the server. A master uses a fresh nonce for each probe round (the round's attempts share it), and never the same one for two rounds of one address and key. `opence-master`'s is stateless: the first 16 bytes of HMAC-SHA-256(seed, `"opence-probe-1"` 0x00 address 0x00 key id 0x00 step), the step the decimal Unix time of the round's start divided by 30; it accepts the current and the previous hour's random seed and the current and the previous step, and starts rounds of one address and key at least a step apart |
| Duplicates | A server answers a given nonce at most once per second: it remembers the nonces it answered (the last 64) with the Unix second they came in, and ignores a probe whose nonce it answered in the same second, whatever came between (A, B, A in one second: the second A is ignored). A master's retries, 2 seconds apart, are answered again; a nonce resent within the second is not |
With the vectors' master key (id `39f713d0a644253f`) and the nonce
`101112131415161718191a1b1c1d1e1f`, the probe is
`4f5043452d50524f42450139f713d0a644253f101112131415161718191a1b1c1d1e1f`, the
signed message `70726f626539f713d0a644253f101112131415161718191a1b1c1d1e1f`,
and the publisher's answer
`4f5043452d50524f42452d41434b0139f713d0a644253f101112131415161718191a1b1c1d1e1f`
followed by the signature
`a63af355db038dff90e6c82abc0e252eddbd1ed3562580d7becaf3e4edbdd39cef7ef3163b91849ba57f6e83240b92df1efbce15dfa42f4a12981590a0234109`.
## The list
`GET /v1/servers`, with the optional query `network_version=<n>`,
`dedicated=1` (or `0`), `region=<region>`, `q=<text>` (a case-insensitive
substring of the name, map, gametype or variant; 32 characters at most).
No signature is needed and plain HTTP is allowed: the body is signed.
```json
{"protocol":1,"master":{"name":"OpenCE Master 1","key_id":"39f713d0a644253f","time":1760900000},
"servers":[{"id":"4c5f15237c866f6f","publisher":{"id":"p_kz3c5lb2xvq7mdpa","name":"Milenko's servers","since":"2026-10"},
"name":"OpenCE Dedicated","map":"bloodgulch","gametype":"CTF","variant":"CTF","players":5,"max_players":16,
"teams":true,"in_progress":false,"password":false,"open":true,"dedicated":true,"kind":0,"engine":1,
"network_version":38,"build":"build-206","region":"eu","reachable":true,"updated":1760899990,
"listing":"<base64>"}]}
```
| Entry field | Meaning |
| --- | --- |
| `id` | The server id |
| `publisher` | The publisher's id (`p_` and up to 32 of `a-z0-9`), display name, and the year and month it was approved |
| `name`, `map`, `gametype`, `players`, `max_players`, `teams`, `in_progress`, `password`, `open`, `kind`, `engine`, `network_version` | From the listing |
| `variant`, `build`, `region` | From the publish |
| `dedicated` | The listing's flag 32 or the publish's `dedicated` |
| `reachable` | The last probe was answered (`false` for a trusted server or under `MASTER_LIST_UNREACHABLE`) |
| `updated` | The time of the server's last publish |
| `address` | Only with the operator's and the master's consent (above); absent otherwise |
| `listing` | Base64 of the listing's exact bytes, as the server published them |
The list holds at most 512 entries: dedicated servers first, then by
players. Its headers:
| Header | Value |
| --- | --- |
| `Signature` | `ed25519=` and the base64 of the master key's signature of the body's exact bytes (before any `Content-Encoding`) |
| `Signature-Key` | The master's key id, 16 hexadecimal digits |
| `Signature-Time` | The body's `master.time` |
| `ETag` | `"` and the first 16 hexadecimal digits of SHA-256 of the body, and `"`; `If-None-Match` with it answers `304` |
| `Cache-Control` | `public, max-age=5` (the master serves the same body for 5 seconds) |
| `Content-Encoding` | `gzip` when the request's `Accept-Encoding` asks for it |
| `Access-Control-Allow-Origin` | `*` (and `Access-Control-Expose-Headers` names the four above), for the web build |
A client:
1. checks `Signature` over the decompressed body bytes with the key in
`masters.txt`, and that `Signature-Key` is that key's id, and refuses
the whole body otherwise (a missing `Signature-Key` too; a client that
cannot check Ed25519, such as an old browser, refuses every list rather
than show one unchecked);
2. parses the body, and refuses it unless `protocol` is 1, `master.key_id`
is the key's id, and `master.time` is no older than 300 seconds and no
more than 300 seconds ahead of the client's clock (a list dated ahead
would be taken for longer when it is replayed later, and a clock that
far off is wrong). The time is in the signed body: `Signature-Time` only
repeats it, since a header is not signed, and a client need not read
it;
3. type-checks each entry, decodes its `listing`, runs `listing_read` and
the host signature's check on it, and skips an entry that fails rather
than the list; it shows the name, map, gametype and players of the
signed listing (an entry's own fields are the master's word, and may
differ); it filters control characters from every name, and takes at
most 512 entries (a 512-entry list is some 26,000 JSON tokens: the game
reads a list of 65,536 tokens at most);
4. merges the masters' lists: entries with the same `id` are one game (the
newest `updated` wins), and an entry whose listing key is also heard on
the brokers is one game, shown as master-listed;
5. never pings listed servers (that would tell every server the player's
address): it shows the `region`, and the ping once a game is joined.
The vectors' signed body is
`{"protocol":1,"master":{"name":"OpenCE Master 1","key_id":"39f713d0a644253f","time":1760900000},"servers":[]}`
with `Signature: ed25519=18dmQnIfmRnC+wUwRJ/loxAH1VfASRBVnZgCfRuHrAw/2uo4thTibWnNQFScgRju+5lQtt709blK86BLT0fYCw==`
and `ETag: "cd755a50c5cfc457"`.
`GET /v1/servers/{server_id}` answers one listed server the same way:
`{"protocol":1,"master":{…},"server":{<entry>}}` with the same headers, or
`404 not_found`.
## Other endpoints
| Request | Who | Answer |
| --- | --- | --- |
| `GET /v1/publishers/{id}` | anyone (the list's limit) | `{"ok":true,"id","name","since":"2026-10","status","servers":<listed now>}`, or `404` |
| `POST /v1/applications`, `/v1/applications/…` | anyone | `410 applications_by_discord`: applications are made with the master's Discord bot (below), not over HTTP |
| `POST /v1/keys` | signed, an approved publisher's active key | Body `{"public_key":"ed25519:…"}`: adds a key (8 active at most). `201 {"ok":true,"key_id","status":"active"}`; `409 key_exists`; `403 too_many_keys` |
| `GET /v1/me` | signed, any key the master knows | The publisher's record: `{"ok":true,"key_id","status","publisher":{"id","name","since","status","quota","origin","keys":[{"key_id","public_key","status","updated","last_used"}],"servers":[{"id","instance","key_id","listed","probe","reachable","hidden","trusted","updated","name","players","max_players"}]}}`; for a key still in an application, `"publisher":null` and `"application":{"id","status","reason","created"}`; `403 key_revoked` |
| `GET /v1/master` | anyone | The master's name, key id, public key, contact, data policy and limits (informational: clients use the key in `masters.txt`) |
| `GET /healthz` | anyone | `200 {"ok":true,…}` while the database answers |
| `GET /metrics` | anyone (the list's limit) | Prometheus text, read at most once each 5 seconds: `master_servers{reachable}`, `master_publishers{status}`, `master_publishes_total{result}`, `master_probes_total{result}`, `master_list_requests_total`, `master_peer_requests_total`, `master_build_info{version,protocol}` |
| `GET /`, `GET /apply`, `/admin/*` | people | The list page (the list's limit), how to apply (the Discord bot, and an invite to its server), the admin pages |
## Applications
An operator applies with the master's Discord bot, in the master's
Discord server (the `/apply` page links to it): the bot's `/apply` command
takes the region (a menu of the master's choices) and the number of servers
at once as its options, and answers the applicant alone with a nonce for
the proof of key,
`opence-apply-<unix>-<16 hex>`, good for 24 hours and bound to that Discord
user (a nonce given to another user, or to none, does not prove this one's
key), and a button that opens the application's form: a display name, the
public key, the proof, and a note. The application's name is 3 to 32 printable ASCII characters,
`servers` is 1 to 64, and the region up to 16 of `a-z`, `0-9` and `-`. The
proof of key is required unless the master's policy says otherwise
(`MASTER_REQUIRE_PROOF`, true by default): it is the publisher key's
Ed25519 signature of the nonce's ASCII text, in hexadecimal (128 digits,
what `halo --server --sign <nonce>` prints) or base64. An application
without one is shown to the approvers as unproven. A key already a
publisher's is refused, and so is a key with an application pending. The
applicant is answered on Discord, and told of the decision by a direct
message; the applicant's identity is their Discord user id and username.
Each application is posted to the master's log channel with Approve and
Deny buttons, which only the members of the master's approver roles may
press; the admin pages decide too. Once decided, anywhere, the message
shows the outcome and who decided. The master's operator can also add an
application on its command line (`opence-master challenge` for the nonce,
then `add-application`), as the conformance test does.
A master limits applications, since each one is posted to its approvers:
`opence-master` takes one pending application at a time and 3 a day per
Discord user, 3 a day per key, and, of applications without a proof of key,
100 an hour in all; one with a proof is not refused for the hour's number,
but past 100 applications in an hour the approvers are alerted once and the
next ones are not posted one by one (its settings change these numbers). It
deletes a denied application 30 days after its denial.
The earlier HTTP application (`POST /v1/applications` with an e-mail
address verified by a mailed link, `GET /v1/applications/challenge`, and
the application's status by its mailed token) is gone: its endpoints
answer `410 applications_by_discord`. A master keeps the applications made
that way readable to its admins.
## Approval sharing between masters
The official masters share approvals, so an operator applies once. Each
master fetches each peer's `GET /v1/publishers?since=<unix>` (every minute
in `opence-master`), signed with its own master key in the request scheme
above (`keyid` is its master key id, and the signed string's master key id
is the peer's, the one it fetches from; a master answers only the peers it
is configured with, else `403 not_a_peer`). The answer is signed as the list
is (`Signature`, `Signature-Key`, `Signature-Time`, the body's
`master.time`):
```json
{"protocol":1,"master":{"name","key_id","time"},
"publishers":[{"id","name","status","since","updated","origin","quota",
"keys":[{"key_id","public_key","status","updated"}]}]}
```
A master shares only what it holds a word of its own on: the publishers it
approved or moderated (`status` is its own word: `approved`, `suspended` or
`revoked`; `null` when it only vouches for keys), and the keys it added or
revoked (`active` or `revoked`). `origin` is the key id of the master that
approved the publisher, as the sharing master knows it (a master records a
publisher new to it as come from the peer it fetched the record from,
whatever origin the record claims, and only that peer's records rename
it); `since` its approval time; `updated` the time of
the newest word in the record; `since=` returns the records updated after
it. The next fetch's `since` is the answer's `master.time` less 120 seconds,
on the peer's own clock.
The importing master keeps each master's word apart and takes the most
restrictive of the trusted masters' words as the publisher's status
(`revoked` over `suspended` over `approved`) and the smallest quota given;
a newer word from a master replaces that master's older one. So a
suspension or revocation anywhere wins everywhere, and only the master that
suspended a publisher can lift its suspension. A key revoked by any trusted
master is revoked, for good; a key two publishers claim is refused. A
master may refuse a peer's words (`MASTER_PEERS_TRUST`); they then count
for nothing.
## Errors
Every error is `{"ok":false,"error":{"code":"…","message":"…","field":"…"?},"master_time":<unix>}`.
The message is for people and may change; programs read the code.
| Status | Code | Meaning | A server's publisher should |
| --- | --- | --- | --- |
| 400 | `schema` | A field is missing, of the wrong type or out of range (`field` names it), or the body is not one JSON object | Log it once; it is a bug |
| 400 | `server_id` | The path's id is not the key's and the instance's | Log it once |
| 400 | `listing` | The listing does not parse, its signature fails, its key has a small order, or its time is an hour off | Log it once |
| 401 | `bad_signature` | The Authorization header is malformed or its signature fails | Log it once |
| 401 | `clock` | `ts` is more than 300 seconds from `master_time` | Log the difference (from the answer's `master_time`): the machine's clock is wrong |
| 401 | `replay` | The nonce was used in the last 600 seconds | Send a new nonce |
| 403 | `unknown_key` | No publisher or application has the key | Log `master: apply with key <id>` once a minute |
| 403 | `publisher_pending` | The key's application waits for review | Log `master: publisher pending` once a minute; keep publishing every 30 s |
| 403 | `application_denied` | The key's application was denied (`message` says why) | Log it once a minute |
| 403 | `publisher_suspended` | The publisher is suspended | Log it; keep publishing slowly |
| 403 | `publisher_revoked`, `key_revoked` | Revoked for good | Log it; stop publishing to that master |
| 403 | `quota_exceeded` | The publisher has its quota of servers live | Log it |
| 403 | `instance_taken` | Another key of the publisher has a live server of this instance | Log it; the old server expires after `ttl` |
| 403 | `listing_taken` | The listing's host key is that of another publisher's live server, which has renewed it within 90 seconds | Log it once a minute; it is someone else's listing, or the host key is shared; keep publishing |
| 403 | `too_many_instances` | The publisher holds its quota and 16 server records, none of which can be dropped | Log it; use fewer instance names |
| 403 | `tls_required` | Plain HTTP from beyond loopback | Use the `https://` URL |
| 403 | `not_a_peer` | A peer's request from a master not configured as a peer | |
| 404 | `not_found` | No such server, publisher or endpoint | |
| 409 | `key_exists` | The key added (`POST /v1/keys`) is known already | |
| 410 | `applications_by_discord` | An application sent over HTTP: applications are made with the master's Discord bot | Log it: apply with the bot's `/apply` |
| 413 | `too_large` | The body is over 16 KB | |
| 429 | `rate_limited` | Too many requests; `Retry-After` gives the seconds to wait | Wait that long (the header's name read in any case; the game waits a day at most, and 30 seconds without one) |
| 500 | `internal` | The master failed | Try again at the next interval |
## Privacy and limits
| What | Rule |
| --- | --- |
| Servers' addresses | Kept 7 days after the server's last publish, for probes and abuse handling; never served unless the operator and the master both chose to; not in the master's log unless its operator asks (`MASTER_LOG_ADDRESSES`) |
| Applicants | Their Discord user id and username, seen by the master's admins and approvers (the log channel) only; no address is kept |
| Applications | A denied one: deleted 30 days after the denial |
| Servers' records | A closed server's: deleted an hour after it closed; one not published again: after 24 hours (unless an admin flagged it) |
| Contacts | Discord names (and the earlier HTTP applications' e-mail addresses) are seen by the master's admins and approvers only, and are not shared with peers |
| Strings | Printable ASCII, cut to the listing's lengths: name 32, map 32, gametype and variant 24, build 32 |
| Requests | 60 list requests a minute per address (the list, one server, a publisher's page, the list page and the metrics together); the application limits above; bodies 16 KB at most; the publish limits above. Every limit by address counts an IPv6 address by its /64 |
## Known limitations
A listing's host key is bound to the first publisher whose live server
publishes it, not to the host's own publisher: the listing does not say
which publisher key the host chose. A publisher that copies a listing from a
list while the host's publisher is not publishing (more than `ttl`) holds
the host key, and the host's publisher is refused `listing_taken`, until the
copy goes 90 seconds without a newer listing; one that keeps copying the
host's newest listings from other lists keeps it until an admin hides its
server or suspends it. The planned fix is in the listing: the host signs the
key id of the publisher key it publishes with into the listing (a new
listing format, with a new network version), and a master takes a listing
only from that key.
## Versions
This is protocol 1: `protocol` in bodies, `/v1/` in paths and
`OpenCE-Master-v1` in signed strings (followed by the receiving master's
key id). A change that an older client or
server would misread is a new version, with new paths; fields may be added
to version 1, and both sides ignore what they do not know. The listing's
own format follows the game's network version: a client hides entries
whose `network_version` is not its own, as it does for the brokers'
listings.
## Testing an implementation
`testdata/vectors.json` in the master's repository has, from fixed seeds:
three key pairs with their key ids (the publisher's, the master's, a
host's), a server id, a signed publish and a signed `GET /v1/me` to the
vectors' master (its key id, the canonical strings, the signatures, the
headers), a probe and its answer,
four listings (a dedicated team game, a password game in progress with its
sealed token, a tombstone, and an altered listing that must be refused), a
signed list body with its headers and ETag, a small-order key that must be
refused, and a proof of key. Monocypher's `crypto_ed25519_sign`,
`crypto_ed25519_check` and `crypto_aead_lock` give the same bytes.
`tests/conformance/opence_publisher.py` is a reference publisher in Python
(keys, signatures, listings, publishing, the probe's answer), and
`tests/conformance/test_master_protocol.py` runs a master through the whole
flow: an application (on the master's command line, since the Discord bot
is beyond a test's reach), the HTTP application's `410`, a pending
publish, approval, the probe, the listed
server's exact bytes in a signed list, closing, every refusal, and two
masters sharing an approval, a suspension and a revocation.
+8
View File
@@ -198,4 +198,12 @@ void host_touch_scene(int scene);
for the overlay's labels */
void host_touch_bindings(const int *controls);
/* ---------- the server browser's master servers (host_https.c) */
/* a GET of an https:// url (port/linux/src/master_list.c): 1 with the
status, the headers ("Name: value" lines) and up to body_capacity bytes of
the body (its size in body_size), else 0 and why in error */
int host_https_get(const char *url, int *status, char *headers, int headers_size, char *body, int body_capacity,
int *body_size, char *error, int error_size);
#endif
+35
View File
@@ -0,0 +1,35 @@
/*
HOST_HTTPS.C
The server browser's requests to the master servers (host_https_get,
port/android/guest/runtime/guest_host.h; port/linux/src/master_list.c), made
in the host with the desktop's HTTPS client (port/linux/src/posix_update.c's
update_https_request, Mbed TLS, the system's certificate authorities): the
guest has no TLS of its own, and its memory is the host's to read and write.
*/
#include "host.h"
#include "../../linux/src/update.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int host_https_get(const char *url, int *status, char *headers, int headers_size, char *body, int body_capacity,
int *body_size, char *error, int error_size)
{
struct update_response response;
*status = 0;
*body_size = 0;
if (!update_https_request("GET", url, NULL, NULL, 0, (unsigned long)body_capacity, &response, error, error_size))
return 0;
*status = response.status;
snprintf(headers, (size_t)headers_size, "%s", response.headers);
if (response.body_size > (unsigned long)body_capacity)
response.body_size = (unsigned long)body_capacity;
memcpy(body, response.body, response.body_size);
*body_size = (int)response.body_size;
free(response.body);
return 1;
}
+1
View File
@@ -85,5 +85,6 @@ host_touch_look_read
host_touch_rumble
host_touch_scene
host_touch_bindings
host_https_get
host_errno
+3 -8
View File
@@ -10,18 +10,13 @@ do (save a setting, join a game) does not happen; the screens open and close as
| Function | Uses | |
| --- | --- | --- |
| `common button bar update` | 28 | PC function |
| `common button bar update` | 29 | PC function |
| `direct ip connect init` | 1 | PC function |
| `direct ip connect update` | 1 | PC function |
| `direct ip edit field` | 4 | PC function |
| `gamespy screen update` | 1 | PC function |
| `gamespy select button` | 8 | PC function |
| `gamespy select header` | 22 | PC function |
| `gamespy select item` | 49 | PC function |
| `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` | 84 | PC function |
| `mouse spinner 1wide click` | 99 | 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 |
@@ -33,4 +28,4 @@ do (save a setting, join a game) does not happen; the screens open and close as
| `ss edit server name` | 2 | PC function |
| `ss start game` | 2 | PC function |
Wired: `campaign menu continue`, `campaign menu init`, `controls back handler`, `controls begin binding`, `controls screen change set`, `controls screen defaults`, `controls screen init`, `controls update menu`, `difficulty item select`, `direct ip connect go`, `emit custom activation event`, `gamespy back handler`, `gamespy dismiss error`, `gamespy dismiss filters`, `gamespy screen dispose`, `gamespy screen init`, `load game list update`, `load game menu activated`, `load game menu delete finish`, `load game menu delete request`, `load game menu dispose`, `load game menu init`, `main menu quit game`, `mouse emit accept event`, `mouse emit back event`, `mouse emit x event`, `mp type set mode`, `profile manager select`, `profile set edit begin`, `single prev cl item activated`, `solo map list update`, `ss edit server password`.
Wired: `campaign menu continue`, `campaign menu init`, `controls back handler`, `controls begin binding`, `controls screen change set`, `controls screen defaults`, `controls screen init`, `controls update menu`, `difficulty item select`, `direct ip connect go`, `emit custom activation event`, `gamespy back handler`, `gamespy dismiss error`, `gamespy dismiss filters`, `gamespy screen dispose`, `gamespy screen init`, `gamespy screen update`, `gamespy select button`, `gamespy select header`, `gamespy select item`, `gamespy update filter settings`, `load game list update`, `load game menu activated`, `load game menu delete finish`, `load game menu delete request`, `load game menu dispose`, `load game menu init`, `main menu quit game`, `mouse emit accept event`, `mouse emit back event`, `mouse emit x event`, `mp type set mode`, `profile manager select`, `profile set edit begin`, `single prev cl item activated`, `solo map list update`, `ss edit server password`.
@@ -3,7 +3,7 @@
<menus>
<widget name="main_menu/multiplayer_type_select/join_game/filters/empty_servers_label" type="text" width="300" height="22" string_list="main_menu/multiplayer_type_select/join_game/filters/filter_options" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/empty_servers_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" string_list="strings/var_boolean" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196">
<widget name="main_menu/multiplayer_type_select/join_game/filters/empty_servers_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196" strings="NO|YES" setting="network.browser_show_empty" values="false|true">
<on event="left_mouse" run="mouse spinner 1wide click"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/filters_button_bar" type="column_list" width="640" height="28" flags="pass_unhandled_to_focused_child left_right_tabs_items">
@@ -22,10 +22,8 @@
<data input="game settings lists text update"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/op_empty_servers" x="54" y="73"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/op_full_servers" x="54" y="103"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/op_ping" x="54" y="133"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/op_game_type" x="54" y="163"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/op_game_options" x="54" y="193"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/op_installed_maps" x="54" y="223"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/op_game_type" x="54" y="133"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/op_installed_maps" x="54" y="163"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/filters_button_bar" y="449"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/filters_screen" width="640" height="480" flags="pass_unhandled_to_focused_child force_handle_mouse" bitmap="bitmaps/gradient_big">
@@ -34,24 +32,19 @@
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/full_servers_label" type="text" width="300" height="22" string_list="main_menu/multiplayer_type_select/join_game/filters/filter_options" string_index="1" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/full_servers_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" string_list="strings/var_boolean" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196">
<on event="left_mouse" run="mouse spinner 1wide click"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/game_options_label" type="text" controller="1" width="300" height="22" string_list="main_menu/multiplayer_type_select/join_game/filters/filter_options" string_index="4" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/game_options_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" string_list="main_menu/multiplayer_type_select/join_game/filters/var_filter_game_options" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196">
<widget name="main_menu/multiplayer_type_select/join_game/filters/full_servers_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196" strings="NO|YES" setting="network.browser_show_full" values="false|true">
<on event="left_mouse" run="mouse spinner 1wide click"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/game_type_label" type="text" controller="1" width="300" height="22" string_list="main_menu/multiplayer_type_select/join_game/filters/filter_options" string_index="3" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/game_type_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" string_list="main_menu/multiplayer_type_select/join_game/filters/var_filter_gametype" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings one_tooltip" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196">
<widget name="main_menu/multiplayer_type_select/join_game/filters/game_type_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings one_tooltip" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196" strings="ANY|CTF|SLAYER|ODDBALL|KING|RACE" setting="network.browser_gametype" values="any|ctf|slayer|oddball|king|race">
<on event="left_mouse" run="mouse spinner 1wide click"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/header_filters" controller="1" left="35" top="11" width="395" height="59" bitmap="main_menu/multiplayer_type_select/join_game/filters/header_filters">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/installed_maps_label" type="text" width="300" height="22" string_list="main_menu/multiplayer_type_select/join_game/filters/filter_options" string_index="5" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/installed_maps_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" string_list="strings/var_boolean" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196">
<widget name="main_menu/multiplayer_type_select/join_game/filters/installed_maps_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196" strings="NO|YES|IF DOWNLOADABLE" setting="network.browser_maps" values="no|yes|downloadable">
<on event="left_mouse" run="mouse spinner 1wide click"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/op_empty_servers" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
@@ -62,10 +55,6 @@
<child widget="main_menu/multiplayer_type_select/join_game/filters/full_servers_label"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/full_servers_spinner" x="300" y="1"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/op_game_options" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/multiplayer_type_select/join_game/filters/game_options_label"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/game_options_spinner" x="300" y="1"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/op_game_type" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/multiplayer_type_select/join_game/filters/game_type_label"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/game_type_spinner" x="300" y="1"/>
@@ -74,13 +63,4 @@
<child widget="main_menu/multiplayer_type_select/join_game/filters/installed_maps_label"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/installed_maps_spinner" x="300" y="1"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/op_ping" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/multiplayer_type_select/join_game/filters/ping_label"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/ping_spinner" x="300" y="1"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/ping_label" type="text" controller="1" width="300" height="22" string_list="main_menu/multiplayer_type_select/join_game/filters/filter_options" string_index="2" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/filters/ping_spinner" type="spinner" left="3" top="2" width="187" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" string_list="main_menu/multiplayer_type_select/join_game/filters/var_filter_ping" font="ui\large_ui" color="#FF2896FF" align="center" list_flags="items_from_strings one_tooltip" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 190 19 196">
<on event="left_mouse" run="mouse spinner 1wide click"/>
</widget>
</menus>
@@ -52,6 +52,53 @@
<string text="That is not the game's password. Try again."/>
<string text="Could not join the game."/>
</strings>
<strings name="main_menu/multiplayer_type_select/join_game/browser_words">
<string text="of"/>
<string text="players"/>
<string text="player"/>
<string text="under way"/>
<string text="full or starting"/>
<string text="Dedicated server by"/>
<string text="listed on"/>
<string text="master server"/>
<string text="master servers"/>
<string text="Dedicated server"/>
<string text="Hosted by a player (anonymous listing)"/>
<string text="Password required"/>
<string text="Needs the newer build: Settings &gt; Network Setup &gt; Check for updates"/>
<string text="Runs an older build: it can be joined once it updates"/>
<string text="Asking the brokers for the host..."/>
<string text="The host answered: connecting (UDP hole punching)..."/>
<string text="Connected: joining the game..."/>
<string text="The host did not answer (offline, or its listing is stale)"/>
<string text="No route to the host: a forwarded port on either side helps"/>
<string text="The host refused: full"/>
<string text="The host refused: closed"/>
<string text="The host refused: banned"/>
<string text="The host refused: wrong password"/>
<string text="The host refused: another version"/>
<string text="The host refused: its invite changed"/>
<string text="Strict NAT: turn on UPnP, or forward a port"/>
<string text="Internet play is off (Settings)"/>
<string text="The server browser is off (network.public_lobby)"/>
<string text="Looking for public games..."/>
<string text="Games on a newer version (update the game to join them):"/>
<string text="No public games found"/>
<string text="No games match the filters"/>
<string text="No master server answered: showing player-hosted games only"/>
<string text="dedicated"/>
<string text="JOINING"/>
<string text="NEWER"/>
<string text="OLDER"/>
<string text="FAILED"/>
<string text="LIVE"/>
<string text="CLOSED"/>
<string text="Internet play is off:\nSettings &gt; Network Setup"/>
<string text="Neither the master servers\nnor the brokers answer:\ncheck the connection"/>
<string text="The host refused: your player name"/>
</strings>
<widget name="main_menu/multiplayer_type_select/join_game/error_words" type="text" controller="1" width="280" height="159" bitmap="bitmaps/alert_bkd" font="ui\large_ui" color="#FF2896FF" text_x="9" text_y="35" text_flags="no_focus_test">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/header_server_browser" controller="1" left="35" top="11" width="363" height="59" bitmap="main_menu/multiplayer_type_select/join_game/header_server_browser">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/header_direct_link" controller="1" left="35" top="11" width="363" height="59" bitmap="main_menu/multiplayer_type_select/join_game/header_direct_link">
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- The PC version's menus: ui\shell\main_menu\multiplayer_type_select\join_game (tools/ce_menus.py) -->
<menus>
<widget name="main_menu/multiplayer_type_select/join_game/browser_mode_spinner" type="spinner" left="3" top="2" width="97" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" string_list="main_menu/multiplayer_type_select/join_game/join_game_mode" font="ui\large_ui" color="#FFCCCCCC" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 100 19 106">
<widget name="main_menu/multiplayer_type_select/join_game/browser_mode_spinner" type="spinner" left="3" top="2" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" color="#FFCCCCCC" align="center" list_flags="items_from_strings" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" strings="ALL GAMES|DEDICATED|P2P GAMES" setting="network.browser_source" values="all|dedicated|players" width="117" footer_bounds="7 120 19 126" font="ui\small_ui" text_y="3">
<on event="left_mouse" run="mouse spinner 1wide click"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/gamespy_error_fullscreen" width="640" height="480" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/black">
@@ -9,6 +9,7 @@
<on event="start" run="gamespy dismiss error"/>
<on event="b" run="gamespy dismiss error"/>
<on event="back" run="gamespy dismiss error"/>
<child widget="main_menu/multiplayer_type_select/join_game/error_words" x="192" y="176"/>
<child widget="error/error_modal"/>
<child widget="error/error_modal_button_bar" x="194" y="309"/>
</widget>
@@ -110,8 +111,8 @@
<child widget="main_menu/multiplayer_type_select/join_game/gamespy_error_fullscreen"/>
<child widget="main_menu/multiplayer_type_select/join_game/filters/filters_screen"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/op_browser_mode" width="150" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds_sm2" color="#FF2896FF">
<child widget="main_menu/multiplayer_type_select/join_game/browser_mode_spinner" x="25" y="1"/>
<widget name="main_menu/multiplayer_type_select/join_game/op_browser_mode" width="150" height="28" flags="pass_unhandled_to_focused_child" color="#FF2896FF">
<child widget="main_menu/multiplayer_type_select/join_game/browser_mode_spinner" x="16" y="1"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/op_sort_header_classic" width="20" height="18" bitmap="bitmaps/server_header_background_0" string_list="main_menu/multiplayer_type_select/join_game/join_game_column_headers" string_index="1" font="ui\small_ui" color="#FFFFFFFF" text_y="1">
<on event="a" run="gamespy select header"/>
@@ -166,12 +167,12 @@
<child widget="main_menu/multiplayer_type_select/join_game/header_sort_arrows" x="24" y="4"/>
<child widget="main_menu/multiplayer_type_select/join_game/sort_header_type"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/page_count_label" type="text" controller="1" width="170" height="30" font="ui\small_ui" color="#FFFFFFFF" text_flags="no_focus_test">
<widget name="main_menu/multiplayer_type_select/join_game/page_count_label" type="text" controller="1" width="170" height="30" font="ui\small_ui" color="#FFFFFFFF" text_flags="no_focus_test" align="center">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/player_count_label" type="text" controller="1" width="170" height="30" font="ui\small_ui" color="#FFFFFFFF" text_flags="no_focus_test">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/screen_stats_container" controller="1" width="640" height="480" color="#FFFFFFFF" align="right">
<child widget="main_menu/multiplayer_type_select/join_game/server_count_label" x="450" y="60"/>
<child widget="main_menu/multiplayer_type_select/join_game/server_count_label" x="410" y="60"/>
<child widget="main_menu/multiplayer_type_select/join_game/player_count_label" x="15" y="60"/>
<child widget="main_menu/multiplayer_type_select/join_game/page_count_label" x="310" y="60"/>
</widget>
@@ -191,7 +192,7 @@
<on event="left_mouse" run="mouse emit accept event"/>
<child widget="main_menu/multiplayer_type_select/join_game/scroll_up_arrow" x="268" y="3"/>
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/server_count_label" type="text" controller="1" width="170" height="30" font="ui\small_ui" color="#FFFFFFFF" align="right" text_flags="no_focus_test">
<widget name="main_menu/multiplayer_type_select/join_game/server_count_label" type="text" controller="1" height="30" font="ui\small_ui" color="#FFFFFFFF" align="right" text_flags="no_focus_test" width="210">
</widget>
<widget name="main_menu/multiplayer_type_select/join_game/server_item_1" width="620" height="18" bitmap="bitmaps/server_item_bkds">
<on event="a" run="gamespy select item"/>
@@ -105,6 +105,9 @@
</widget>
<widget name="main_menu/multiplayer_type_select/lobby/lobby_game_data" type="text" controller="1" left="417" top="250" width="146" height="144" font="ui\small_ui" color="#FF2896FF">
</widget>
<strings name="main_menu/multiplayer_type_select/lobby/lobby_words">
<string text="dedicated"/>
</strings>
<widget name="main_menu/multiplayer_type_select/lobby/lobby_button_bar" type="column_list" width="640" height="28" flags="pass_unhandled_to_focused_child left_right_tabs_items">
<child widget="main_menu/multiplayer_type_select/lobby/lobby_button_team" x="120" y="1"/>
<child widget="main_menu/multiplayer_type_select/lobby/lobby_button_start" x="250" y="1"/>
@@ -216,12 +216,13 @@
<string text="Each team's vehicles and their respawn time, for\nthis game."/>
<string text="The motion tracker and nav points, for this game."/>
<string text="Friendly fire and team balance, for this game."/>
<string text="Anyone can see and join your game: it is listed\nin everyone's Server Browser."/>
<string text="Anyone can see and join your game: it is listed\nfor everyone through the brokers."/>
<string text="Only players with your invite link can join."/>
<string text="Friendly fire, the enemies and player collisions,\nfor this game."/>
<string text="Voice chat, text chat and votes to kick a player,\nfor this game."/>
<string text="Players from the Server Browser must type it to\njoin (an invite link needs none). Enter sets it."/>
<string text="Sending players who join without the game's\nCustom Edition map the map, and how fast."/>
<string text="Listed for everyone through the brokers, and on\nthe master servers with your publisher key."/>
</strings>
<widget name="main_menu/multiplayer_type_select/server_settings/coop_options/coop_options_screen" width="640" height="480" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/gradient">
<child widget="main_menu/multiplayer_type_select/server_settings/coop_options/header_coop_options"/>
@@ -70,7 +70,7 @@
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/gamepad_setup/button_preset_label" type="text" controller="1" width="300" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/gamepad_setup/labels" string_index="4" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/gamepad_setup/button_preset_spinner" type="spinner" left="3" top="2" width="147" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" strings="DEFAULT|SWAP TRIGGERS|SWAP A, L TRIGGER|SWAP B, L TRIGGER|SWAP B, R STICK|BUMPER JUMPER" setting="profile.button_preset" values="0|1|2|3|4|5" font="ui\large_ui" color="#FF2896FF" align="center" text_y="1" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 150 19 156">
<widget name="main_menu/settings_select/player_setup/player_profile_edit/gamepad_setup/button_preset_spinner" type="spinner" left="3" top="2" width="147" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" strings="DEFAULT|SWAP TRIGGERS|SWAP A, L TRIGGER|SWAP B, L TRIGGER|SWAP B, R STICK|BUMPER JUMPER|FISHSTICKS|RECON" setting="profile.button_preset" values="0|1|2|3|4|5|6|7" font="ui\large_ui" color="#FF2896FF" align="center" text_y="1" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 150 19 156">
<on event="created" run="port setting load"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/gamepad_setup/op_joystick_preset" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- The port's settings screen, in the PC version's style (tools/port_settings.py) -->
<menus>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/server_browser_settings_screen" width="640" height="480" flags="pass_unhandled_to_focused_child pause_game" bitmap="bitmaps/gradient">
<on event="b" back="true"/>
<on event="back" back="true"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/header_profile_network_settings"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/options_menu"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/header_profile_network_settings" controller="1" left="35" top="11" width="605" height="59" bitmap="main_menu/settings_select/player_setup/player_profile_edit/network_setup/header_profile_network_settings">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/help" type="text" controller="1" left="68" top="350" width="482" height="60" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/help_strings" font="ui\large_ui" color="#FFFFFFFF">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/options_menu" type="column_list" width="640" height="480" flags="pass_unhandled_to_focused_child up_down_tabs_children" description="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/help">
<data input="port settings help"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_listings" x="54" y="73"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_browser_show_empty" x="54" y="103"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_browser_show_full" x="54" y="133"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_browser_maps" x="54" y="163"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_browser_sort" x="54" y="193"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_master_servers" x="54" y="223"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/button_bar" y="414"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/button_bar" type="column_list" width="640" height="28" flags="pass_unhandled_to_focused_child left_right_tabs_items">
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/button_defaults" y="1"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/button_ok" x="380" y="1"/>
<child widget="common_button_cancel" x="510" y="1"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_listings" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/listings_label"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/listings_spinner" x="286" y="1"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/listings_label" type="text" controller="1" width="300" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/labels" string_index="0" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/listings_spinner" type="spinner" top="2" width="206" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" strings="ALL GAMES|DEDICATED|P2P GAMES" setting="network.listings" values="both|masters|brokers" font="ui\large_ui" color="#FF2896FF" align="center" text_y="4" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -13 19 -7" footer_bounds="7 208 19 214">
<on event="created" run="port setting load"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_browser_show_empty" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_show_empty_label"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_show_empty_spinner" x="320" y="1"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_show_empty_label" type="text" controller="1" width="300" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/labels" string_index="1" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_show_empty_spinner" type="spinner" left="3" top="2" width="147" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" strings="YES|NO" setting="network.browser_show_empty" values="true|false" font="ui\large_ui" color="#FF2896FF" align="center" text_y="1" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 150 19 156">
<on event="created" run="port setting load"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_browser_show_full" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_show_full_label"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_show_full_spinner" x="320" y="1"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_show_full_label" type="text" controller="1" width="300" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/labels" string_index="2" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_show_full_spinner" type="spinner" left="3" top="2" width="147" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" strings="YES|NO" setting="network.browser_show_full" values="true|false" font="ui\large_ui" color="#FF2896FF" align="center" text_y="1" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -6 19 0" footer_bounds="7 150 19 156">
<on event="created" run="port setting load"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_browser_maps" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_maps_label"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_maps_spinner" x="286" y="1"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_maps_label" type="text" controller="1" width="300" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/labels" string_index="3" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_maps_spinner" type="spinner" top="2" width="206" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" strings="NO|YES|IF DOWNLOADABLE" setting="network.browser_maps" values="no|yes|downloadable" font="ui\large_ui" color="#FF2896FF" align="center" text_y="4" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -13 19 -7" footer_bounds="7 208 19 214">
<on event="created" run="port setting load"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_browser_sort" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_sort_label"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_sort_spinner" x="286" y="1"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_sort_label" type="text" controller="1" width="300" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/labels" string_index="4" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/browser_sort_spinner" type="spinner" top="2" width="206" height="20" flags="pass_unhandled_to_focused_child left_right_tabs_items" strings="DEDICATED FIRST|PLAYERS|NAME|MAP|GAME TYPE|LOCKED FIRST" setting="network.browser_sort" values="servers|players|name|map|type|locked" font="ui\large_ui" color="#FF2896FF" align="center" text_y="4" header_bitmap="bitmaps/arrow_sm_left" footer_bitmap="bitmaps/arrow_sm_right" header_bounds="7 -13 19 -7" footer_bounds="7 208 19 214">
<on event="created" run="port setting load"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/op_master_servers" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/master_servers_label"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/master_servers_value" x="286" y="1"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/master_servers_label" type="text" controller="1" width="300" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/labels" string_index="5" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/master_servers_value" type="text" controller="1" width="212" height="22" font="ui\large_ui" color="#FF2896FF" align="center" text_y="1" text_flags="no_focus_test">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/button_defaults" type="text" width="128" height="24" bitmap="bitmaps/text_button_background" string_list="strings/common_button_captions" string_index="3" font="ui\small_ui" color="#FFFFFFFF" align="center" text_y="2">
<on event="a" run="port settings defaults"/>
<on event="start" run="port settings defaults"/>
<on event="left_mouse" run="mouse emit accept event"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/button_ok" type="text" width="128" height="24" bitmap="bitmaps/text_button_background" string_list="strings/common_button_captions" string_index="1" font="ui\small_ui" color="#FFFFFFFF" align="center" text_y="2">
<on event="a" run="port settings save" back="true"/>
<on event="start" run="port settings save" back="true"/>
<on event="left_mouse" run="mouse emit accept event"/>
</widget>
<strings name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/labels">
<string text="LISTS:"/>
<string text="SHOW EMPTY SERVERS:"/>
<string text="SHOW FULL SERVERS:"/>
<string text="SHOW MISSING MAPS:"/>
<string text="SORT BY:"/>
<string text="MASTER SERVERS:"/>
</strings>
<strings name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/words">
<string text="OF"/>
<string text="NONE LISTED"/>
<string text="NOT ASKED YET"/>
<string text="OFF"/>
</strings>
<strings name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/help_strings">
<string text=""/>
<string text="The master servers' dedicated servers, the games\nplayers host (through the brokers), or all of them."/>
<string text="List games no one is playing yet."/>
<string text="List games with no room left."/>
<string text="List games on maps you do not have: IF\nDOWNLOADABLE, those Map Sharing can download."/>
<string text="The list's order. Its column titles sort it too;\nchoosing one again reverses it."/>
<string text="The master servers (masters.txt) that answered\nwhen the Server Browser last asked."/>
</strings>
</menus>
@@ -22,6 +22,8 @@
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_multiplayer_hud" x="54" y="103" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_streamer_mode" x="54" y="253" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_streamer_mode" x="54" y="133" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_server_browser" x="54" y="283" platform="desktop android"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_server_browser" x="54" y="163" platform="web"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/button_bar" y="414"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/button_bar" type="column_list" width="640" height="28" flags="pass_unhandled_to_focused_child left_right_tabs_items">
@@ -89,6 +91,14 @@
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/streamer_mode_label" type="text" controller="1" width="512" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/labels" string_index="6" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/op_server_browser" width="512" height="28" flags="pass_unhandled_to_focused_child" bitmap="bitmaps/option_bkds" color="#FF2896FF">
<on event="a" open="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/server_browser_settings_screen"/>
<on event="start" open="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser/server_browser_settings_screen"/>
<on event="left_mouse" run="mouse emit accept event"/>
<child widget="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser_label"/>
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/server_browser_label" type="text" controller="1" width="512" height="22" string_list="main_menu/settings_select/player_setup/player_profile_edit/network_setup/labels" string_index="7" font="ui\large_ui" color="#FF2896FF" text_x="13" text_y="4">
</widget>
<widget name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/button_defaults" type="text" width="128" height="24" bitmap="bitmaps/text_button_background" string_list="strings/common_button_captions" string_index="3" font="ui\small_ui" color="#FFFFFFFF" align="center" text_y="2">
<on event="a" run="port settings defaults"/>
<on event="start" run="port settings defaults"/>
@@ -107,6 +117,7 @@
<string text="MAP SHARING:"/>
<string text="MULTIPLAYER HUD:"/>
<string text="STREAMER MODE:"/>
<string text="SERVER BROWSER:"/>
</strings>
<strings name="main_menu/settings_select/player_setup/player_profile_edit/network_setup/help_strings">
<string text=""/>
@@ -117,5 +128,6 @@
<string text="Downloading Custom Edition maps you do not have,\nand helping send them to other players."/>
<string text="Players' names above their heads, the\nscoreboard, and the text chat."/>
<string text="Hide offensive words in names and text from\nother players, maps and mods, as stars."/>
<string text="What the Server Browser lists and its order: the\nsame settings as its Filters screen."/>
</strings>
</menus>
+1 -13
View File
@@ -287,6 +287,7 @@
<string text="Show only servers running teamplay modes." map="8"/>
<string text="Show only servers with custom maps that are\ncurrently installed." map="9"/>
<string text="Show servers with custom maps that are not\ncurrently installed." map="10"/>
<string text="Show servers on maps you do not have if their\nhost shares them: they download as you join."/>
</strings>
<strings name="main_menu/multiplayer_type_select/join_game/filters/filter_options" map="ui\shell\main_menu\multiplayer_type_select\join_game\filters\filter_options">
<string text="SHOW EMPTY SERVERS" map="0"/>
@@ -296,11 +297,6 @@
<string text="TEAMPLAY OPTIONS" map="4"/>
<string text="SHOW MISSING MAPS" map="5"/>
</strings>
<strings name="main_menu/multiplayer_type_select/join_game/filters/var_filter_game_options" map="ui\shell\main_menu\multiplayer_type_select\join_game\filters\var_filter_game_options">
<string text="ANY" map="0"/>
<string text="FREE-FOR-ALL" map="1"/>
<string text="TEAMPLAY" map="2"/>
</strings>
<strings name="main_menu/multiplayer_type_select/join_game/filters/var_filter_gametype" map="ui\shell\main_menu\multiplayer_type_select\join_game\filters\var_filter_gametype">
<string text="ANY" map="0"/>
<string text="CTF" map="1"/>
@@ -309,14 +305,6 @@
<string text="KING" map="4"/>
<string text="RACE" map="5"/>
</strings>
<strings name="main_menu/multiplayer_type_select/join_game/filters/var_filter_ping" map="ui\shell\main_menu\multiplayer_type_select\join_game\filters\var_filter_ping">
<string text="ANY" map="0"/>
<string text="&lt; 50" map="1"/>
<string text="&lt; 100" map="2"/>
<string text="&lt; 150" map="3"/>
<string text="&lt; 200" map="4"/>
<string text="&lt; 300" map="5"/>
</strings>
<strings name="main_menu/multiplayer_type_select/join_game/join_game_buttons" map="ui\shell\main_menu\multiplayer_type_select\join_game\join_game_buttons">
<string text="GET LIST" map="0"/>
<string text="REFRESH" map="1"/>
+1
View File
@@ -52,6 +52,7 @@
"ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.in_game.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.map_sharing.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.multiplayer_hud.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.server_browser.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.streamer_mode.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.network_setup.xml",
"ce/main_menu.settings_select.player_setup.player_profile_edit.video_settings.extra_effects.xml",
+12
View File
@@ -0,0 +1,12 @@
# The master servers that list dedicated servers (docs/master_protocol.md):
# the server browser asks each for its signed list, and a dedicated server
# publishes its game to each. One master on each line: its https:// URL and
# its list-signing key, "ed25519:" and 64 hex digits; up to 4; "#" starts a
# comment. A list whose signature does not check with the key is refused.
# (The game reads this file beside config.toml: network.masters_file, and a
# dedicated server's server.masters_file. An update replaces it; keep a list
# of your own under another name.)
#
# A file that names no master, as this one, leaves the game the official
# master, built in: https://master.opencommunityedition.com. A file that
# names one or more is asked instead of it.
+261
View File
@@ -0,0 +1,261 @@
/* The admin page (index.html), in the look of the web site's launcher
(port/web/site/style.css): navy panels between a bright rule and a blue
edge, blue labels, the system's fonts. Tables become cards on a phone. */
:root {
color-scheme: dark; --ground: #02060c; --head: linear-gradient(180deg, #0b3466 0%, #072852 45%, #041a35 100%);
--stage: linear-gradient(180deg, #072852 0px, #062247 320px, #031122 960px, #02060c 1500px);
--panel: rgba(0, 10, 24, 0.55); --navy: #002244; --navy-deep: #000e23; --row: rgba(0, 17, 34, 0.6);
--edge: #2299ff; --edge-bright: #36afff; --edge-dim: #1d4f86; --label: #2896ff; --text: #ffffff; --soft: #c9daf0;
--glow: rgba(54, 175, 255, 0.45); --bad: #ff9b42; --bad-text: #ffd2a8; --bad-ground: #2a1404; --red: #ff6b5e;
--blue: #4aa8ff; --log: #00070f; --font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light; --ground: #eef3f9; --head: linear-gradient(180deg, #0b3466 0%, #0a3a73 100%);
--stage: linear-gradient(180deg, #dce8f6 0px, #eef3f9 400px); --panel: #ffffff; --navy: #e4eefa;
--navy-deep: #f4f8fd; --row: #f1f5fb; --edge: #1673d1; --edge-bright: #1f86e8; --edge-dim: #a9c4e4;
--label: #0d5fb3; --text: #0b1726; --soft: #43577a; --glow: rgba(31, 134, 232, 0.25); --bad: #c25a00;
--bad-text: #8a3d00; --bad-ground: #fff1e4; --red: #c8322a; --blue: #1663c7; --log: #f7f9fc;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--ground); color: var(--text); font: 500 15px/1.5 var(--font); }
body { min-height: 100vh; background: var(--stage); background-repeat: no-repeat; }
h1, h2, h3 { margin: 0; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--label); }
h2 { font-size: 17px; margin: 18px 0 10px; }
h2:first-child { margin-top: 0; }
h3 { font-size: 13px; margin: 14px 0 4px; }
.note { margin: 6px 0 0; color: var(--soft); font-size: 13px; }
.note:empty, .error:empty { display: none; }
.error { margin: 8px 0 0; color: var(--bad-text); font-size: 14px; }
.mono, code { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere; }
/* ---------- buttons and fields: the site's */
button {
display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 18px;
border: 2px solid var(--edge); border-radius: 10px; background: var(--navy); color: var(--text);
font: 700 13px/1 var(--font); letter-spacing: 0.06em; text-transform: uppercase; cursor: pointer;
}
button:hover:not(:disabled) { border-color: var(--edge-bright); box-shadow: 0 0 12px var(--glow); }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, .log:focus-visible {
outline: 2px solid var(--edge-bright); outline-offset: 2px;
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary { border-color: var(--edge-bright); box-shadow: 0 0 14px var(--glow); }
button.danger { border-color: var(--bad); background: var(--bad-ground); color: var(--bad-text); }
button.small { min-height: 32px; padding: 0 10px; font-size: 12px; border-radius: 8px; }
input, select, textarea {
min-height: 40px; padding: 6px 10px; border: 2px solid var(--edge-dim); border-radius: 10px;
background: var(--navy-deep); color: var(--text); font: 16px var(--font); max-width: 100%;
}
input[type="checkbox"] { min-height: 0; width: 18px; height: 18px; accent-color: var(--edge); }
input:disabled, select:disabled, textarea:read-only { opacity: 0.6; }
textarea { font: 13px/1.5 var(--mono); width: 100%; resize: vertical; }
label { color: var(--soft); font-size: 13px; }
.check { display: inline-flex; align-items: center; gap: 6px; }
.badge {
display: inline-block; padding: 2px 10px; border: 1px solid var(--edge-dim); border-radius: 999px;
color: var(--soft); font-size: 12px; letter-spacing: 0.05em; text-transform: uppercase; white-space: nowrap;
}
.badge:empty { display: none; }
.state-playing, .link-live { border-color: var(--edge-bright); color: var(--text); }
.state-loading, .state-stopping, .link-polling { border-color: var(--bad); color: var(--bad-text); }
/* ---------- the sign-in */
.login { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 16px; }
.login-panel { width: min(420px, 100%); display: flex; flex-direction: column; gap: 10px; }
.login-head { display: flex; align-items: center; gap: 14px; margin-bottom: 8px; }
.login-head h1 { font-size: 24px; }
/* ---------- the header and the tabs */
.top {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
padding: 12px max(16px, env(safe-area-inset-left)); background: var(--head);
border-bottom: 1px solid var(--edge-bright); color: #ffffff;
}
.top h1 { font-size: 20px; color: #4fb4ff; overflow-wrap: anywhere; }
.top .badge { color: #c9daf0; }
.top-end { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.tabs {
display: flex; gap: 4px; overflow-x: auto; padding: 10px 16px 0; border-bottom: 1px solid var(--edge-dim);
scrollbar-width: thin;
}
.tabs button {
flex: none; border-width: 2px 2px 0; border-color: transparent; border-radius: 12px 12px 0 0;
background: transparent; color: var(--label); font-size: 14px;
}
.tabs button.current { border-color: var(--edge); background: var(--navy-deep); color: var(--text); }
main { max-width: 1280px; margin: 0 auto; padding: 18px 16px 48px; }
/* ---------- panels */
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr)); gap: 18px; }
.grid .wide { grid-column: 1 / -1; }
.panel {
min-width: 0; padding: 18px 20px; background: var(--panel); border-top: 1px solid var(--edge-bright);
border-bottom: 1px solid var(--label);
}
.bar, .inline, .fields { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; }
.inline input { flex: 1 1 200px; min-width: 0; }
.fields label { display: flex; flex-direction: column; gap: 4px; flex: 1 1 160px; }
.fields button { align-self: flex-end; }
.facts { margin: 0; display: grid; gap: 4px; }
.facts > div {
display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 16px; padding: 6px 12px;
border-radius: 10px; background: var(--row);
}
.facts dt { color: var(--label); text-transform: uppercase; letter-spacing: 0.03em; font-size: 13px; }
.facts dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
.facts .notice { border: 1px solid var(--bad); }
.count { margin: 0; font-size: 40px; font-weight: 800; line-height: 1.1; }
.count .of { font-size: 18px; color: var(--soft); font-weight: 600; }
.spark { display: block; width: 100%; height: 64px; margin-top: 8px; border-radius: 8px; background: var(--row); }
.spark-line { fill: none; stroke: var(--edge-bright); stroke-width: 2; vector-effect: non-scaling-stroke; }
.spark-fill { fill: var(--glow); stroke: none; opacity: 0.5; }
.feed { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 420px;
overflow-y: auto; font-size: 14px; }
.feed li { padding: 3px 8px; border-left: 3px solid var(--edge-dim); }
.feed time { color: var(--soft); font: 12px var(--mono); }
.feed .feed-bad { border-left-color: var(--bad); color: var(--bad-text); }
.feed .feed-moderation { border-left-color: var(--label); }
.feed .feed-chat { border-left-color: transparent; color: var(--soft); }
/* ---------- tables, cards on a phone */
table { width: 100%; border-collapse: separate; border-spacing: 0 4px; }
th { padding: 4px 10px; color: var(--label); text-align: left; font-size: 12px; letter-spacing: 0.05em;
text-transform: uppercase; }
td { padding: 6px 10px; background: var(--row); vertical-align: middle; overflow-wrap: anywhere; }
td:first-child { border-radius: 10px 0 0 10px; }
td:last-child { border-radius: 0 10px 10px 0; }
tr.bad td, tr.muted td { color: var(--bad-text); }
.actions { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.row-actions { text-align: right; }
.dossier td { background: var(--navy-deep); border: 1px solid var(--edge-dim); }
.team { display: inline-block; width: 10px; height: 10px; margin-right: 8px; border-radius: 50%; background: var(--soft); }
.team-0 { background: var(--red); }
.team-1 { background: var(--blue); }
td .badge { margin-left: 8px; }
@media (max-width: 700px) {
table.cards thead { display: none; }
table.cards, table.cards tbody, table.cards tr, table.cards td { display: block; width: 100%; }
table.cards tr { margin-bottom: 10px; padding: 8px 12px; border: 2px solid var(--edge-dim); border-radius: 12px;
background: var(--row); }
table.cards td { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; background: none;
border-radius: 0; text-align: right; overflow-wrap: anywhere; }
table.cards td::before { content: attr(data-label); color: var(--label); font-size: 12px; text-transform: uppercase;
letter-spacing: 0.04em; text-align: left; }
table.cards td.row-actions { justify-content: flex-end; padding-top: 8px; }
table.cards td.row-actions::before { content: none; }
.dossier td { border: none; }
.top h1 { font-size: 17px; }
.top-end { margin-left: 0; }
.panel { padding: 14px; }
main { padding: 12px 10px 40px; }
}
/* ---------- the rotation */
.rotation { margin: 0; padding: 0 0 0 28px; display: flex; flex-direction: column; gap: 6px; }
.rotation li { padding: 6px 10px; border: 2px solid transparent; border-radius: 10px; background: var(--row); }
.rotation li::marker { color: var(--label); font-weight: 700; }
.rotation li { display: list-item; }
.rotation li > span { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
.rotation li .actions { margin-top: 6px; }
.rotation .current { border-color: var(--edge); background: var(--navy); }
.entry-text { font-family: var(--mono); overflow-wrap: anywhere; }
.rejected .entry-text { text-decoration: line-through; color: var(--soft); }
.reason { color: var(--bad-text); font-size: 13px; }
/* ---------- gametypes */
.list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.list li { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 6px 10px;
border-radius: 10px; background: var(--row); }
.editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
.schema-form fieldset, #settings-fields fieldset { margin: 0 0 12px; padding: 8px 12px 12px; border: 1px solid var(--edge-dim);
border-radius: 12px; }
legend { padding: 0 6px; color: var(--label); font: 700 13px var(--mono); }
.schema-form label { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 3px 0; }
.schema-form input, .schema-form select { width: 12em; }
.raw { display: flex; flex-direction: column; gap: 4px; font-family: var(--mono); }
@media (max-width: 900px) { .editor { grid-template-columns: 1fr; } }
/* ---------- the console */
.console { display: flex; flex-direction: column; gap: 10px; }
.console-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 4px 6px; }
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px 2px 4px; border: 1px solid var(--edge-dim);
border-radius: 999px; font-size: 12px; }
.chip input { width: 14px; height: 14px; }
.log {
height: min(62vh, 640px); overflow-y: auto; padding: 8px 10px; border: 2px solid var(--edge-dim);
border-radius: 10px; background: var(--log); color: var(--soft); font: 12.5px/1.5 var(--mono);
overflow-wrap: anywhere;
}
.line { white-space: pre-wrap; }
.line time { color: var(--edge-dim); }
.line .cat { color: var(--label); }
.level-warn { color: var(--bad-text); }
.level-error { color: var(--bad); font-weight: 700; }
.mine { color: var(--text); }
.command-line { color: var(--edge-bright); margin-top: 4px; }
.line.bad { color: var(--bad-text); }
.line.note { color: var(--edge-dim); font-style: italic; }
.hide-server .cat-server, .hide-lobby .cat-lobby, .hide-game .cat-game, .hide-player .cat-player,
.hide-chat .cat-chat, .hide-moderation .cat-moderation, .hide-net .cat-net, .hide-master .cat-master,
.hide-console .cat-console, .hide-health .cat-health { display: none; }
.command { display: flex; gap: 8px; }
.command input { flex: 1; min-width: 0; font-family: var(--mono); }
@media (max-width: 700px) {
.log { height: 55vh; font-size: 12px; }
.command { position: sticky; bottom: 0; padding: 8px 0 env(safe-area-inset-bottom); background: var(--ground); }
}
/* ---------- settings */
.setting { display: grid; grid-template-columns: minmax(0, 16em) minmax(0, 1fr) auto; align-items: center; gap: 4px 12px;
padding: 8px 0; border-bottom: 1px solid var(--row); }
.setting .note, .setting .error { grid-column: 1 / -1; margin: 0; }
.setting-name { font: 13px var(--mono); color: var(--text); overflow-wrap: anywhere; }
.setting input[type="text"], .setting input[type="number"], .setting select, .secret input[type="password"] { width: 100%; }
.secret { display: flex; gap: 10px; align-items: center; }
.pinned .setting-name { color: var(--soft); }
@media (max-width: 700px) { .setting { grid-template-columns: 1fr; } }
/* ---------- the dialog and the notice */
.sheet { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 16px;
background: rgba(0, 4, 10, 0.78); z-index: 10; }
.sheet-body { width: min(560px, 100%); max-height: 100%; overflow: auto; border: 2px solid var(--edge); border-radius: 16px;
background: var(--navy-deep); box-shadow: 0 0 32px var(--glow); }
.sheet h2 { margin: 0; padding: 16px 20px; border-bottom: 1px solid var(--edge-bright); background: var(--head); color: #4fb4ff; }
#dialog-body { padding: 14px 20px 0; display: flex; flex-direction: column; gap: 10px; }
#dialog-body p { margin: 0; }
#dialog-body label { display: flex; flex-direction: column; gap: 4px; }
.sheet .error { padding: 0 20px; }
.sheet .actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; padding: 12px 20px;
border-top: 1px solid var(--label); }
.toast { position: fixed; left: 50%; bottom: max(16px, env(safe-area-inset-bottom)); transform: translateX(-50%);
max-width: calc(100% - 32px); padding: 10px 18px; border: 2px solid var(--edge); border-radius: 12px;
background: var(--navy-deep); box-shadow: 0 0 24px var(--glow); z-index: 20; overflow-wrap: anywhere; }
.toast.bad { border-color: var(--bad); color: var(--bad-text); }
[hidden] { display: none !important; }
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="2" y="2" width="60" height="60" rx="14" fill="#002244" stroke="#2299ff" stroke-width="3"/>
<rect x="14" y="16" width="36" height="10" rx="3" fill="none" stroke="#36afff" stroke-width="3"/>
<rect x="14" y="30" width="36" height="10" rx="3" fill="none" stroke="#36afff" stroke-width="3"/>
<circle cx="20" cy="21" r="2" fill="#ffffff"/>
<circle cx="20" cy="35" r="2" fill="#ffffff"/>
<path d="M32 44v6M22 50h20" fill="none" stroke="#2896ff" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

+241
View File
@@ -0,0 +1,241 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="referrer" content="same-origin">
<meta name="color-scheme" content="dark light">
<title>OpenCE server</title>
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
<script src="app.js" defer></script>
</head>
<body>
<div id="login" class="login" hidden>
<form id="login-form" class="panel login-panel">
<div class="login-head"><img src="icon.svg" alt="" width="52" height="52"><h1>OpenCE server</h1></div>
<label for="login-password">Password</label>
<input id="login-password" type="password" autocomplete="current-password" required>
<p id="login-error" class="error" role="alert"></p>
<button type="submit" class="primary" id="login-button">Sign in</button>
<p class="note">server.web_password (admin) or server.web_moderator_password (moderator). The page keeps no
password: the session is a cookie for 12 hours, or 30 minutes unused.</p>
</form>
</div>
<div id="app" hidden>
<header class="top">
<img src="icon.svg" alt="" width="34" height="34">
<h1 id="server-name">OpenCE server</h1>
<span id="server-state" class="badge"></span>
<span class="top-end">
<span id="link-state" class="badge" title="How the page hears from the server"></span>
<span id="role" class="badge"></span>
<button id="logout" class="small">Sign out</button>
</span>
</header>
<nav class="tabs" role="tablist" aria-label="Sections">
<button role="tab" data-tab="status">Status</button>
<button role="tab" data-tab="players">Players</button>
<button role="tab" data-tab="rotation">Rotation</button>
<button role="tab" data-tab="gametypes">Gametypes</button>
<button role="tab" data-tab="bans" data-needs="admin">Bans</button>
<button role="tab" data-tab="console">Console</button>
<button role="tab" data-tab="settings" data-needs="admin">Settings</button>
<button role="tab" data-tab="about">About</button>
</nav>
<main>
<section id="tab-status" class="tab" role="tabpanel" hidden>
<div class="grid">
<div class="panel">
<h2>Game</h2>
<dl id="status-facts" class="facts"></dl>
</div>
<div class="panel">
<h2>Players</h2>
<p class="count"><span id="status-count">0</span><span class="of"> of <span id="status-max">0</span></span></p>
<svg id="spark" class="spark" viewBox="0 0 300 64" preserveAspectRatio="none" role="img"
aria-label="Players over the last 10 minutes"><polyline id="spark-fill" class="spark-fill" points=""/><polyline id="spark-line" class="spark-line" points=""/></svg>
<p class="note" id="spark-note">The last 10 minutes, as this page saw them.</p>
<h2>Actions</h2>
<div class="bar">
<button id="next-map" data-needs="moderator">Next map</button>
<button id="end-game" data-needs="moderator">End game</button>
</div>
<form id="say-form" class="inline">
<input id="say-text" maxlength="127" placeholder="A notice on every screen" aria-label="Notice"
data-needs="moderator">
<button data-needs="moderator">Say</button>
</form>
</div>
<div class="panel">
<h2>Invite</h2>
<div class="inline">
<input id="invite" readonly aria-label="The invite link">
<button id="invite-copy" data-needs="admin">Copy</button>
</div>
<p class="note">Anyone with the link joins: the game's Join Game, or ?join= on the web page.</p>
<h2>Master servers</h2>
<table id="masters" class="cards">
<thead><tr><th>Master</th><th>Listed</th><th>Probe</th><th>Answer</th><th>When</th></tr></thead>
<tbody></tbody>
</table>
<p id="masters-empty" class="note">No master servers.</p>
</div>
<div class="panel">
<h2>Recent events</h2>
<ol id="feed" class="feed"></ol>
</div>
</div>
</section>
<section id="tab-players" class="tab" role="tabpanel" hidden>
<div class="panel">
<h2>Players</h2>
<table id="players" class="cards">
<thead><tr><th>Slot</th><th>Name</th><th>Team</th><th>Score</th><th>K/D/A</th><th>Ping</th><th>Played</th>
<th>Actions</th></tr></thead>
<tbody></tbody>
</table>
<p id="players-empty" class="note">No players.</p>
<p class="note">Every action names the slot and the player: the server checks the slot is still theirs.</p>
</div>
</section>
<section id="tab-rotation" class="tab" role="tabpanel" hidden>
<div class="panel">
<h2>Rotation</h2>
<ol id="rotation" class="rotation"></ol>
<form id="rotation-add" class="fields">
<label>Map <select id="add-map" data-needs="admin"></select></label>
<label>Gametype <select id="add-gametype" data-needs="admin"></select></label>
<label>Rules <input id="add-rules" placeholder="score=50+time=15" data-needs="admin"></label>
<button data-needs="admin">Add</button>
</form>
<p id="rotation-note" class="note"></p>
<div class="bar">
<button id="rotation-save" class="primary" data-needs="admin">Save</button>
<button id="rotation-revert">Revert</button>
</div>
</div>
</section>
<section id="tab-gametypes" class="tab" role="tabpanel" hidden>
<div class="grid">
<div class="panel">
<h2>Gametypes</h2>
<ul id="gametype-files" class="list"></ul>
<h3>Built-in</h3>
<p id="gametype-builtins" class="note"></p>
<form id="gametype-new" class="fields">
<label>Name <input id="new-name" pattern="[a-z0-9_\-]{1,32}" placeholder="btb_ctf" required
data-needs="admin"></label>
<label>Base <select id="new-base" data-needs="admin"></select></label>
<button data-needs="admin">New</button>
</form>
</div>
<div class="panel wide" id="gametype-editor" hidden>
<h2 id="editor-title"></h2>
<div class="editor">
<div id="gametype-form" class="schema-form"></div>
<label class="raw"><span>gametypes/<span id="editor-file"></span>.toml</span>
<textarea id="gametype-text" spellcheck="false" autocapitalize="off" rows="22"></textarea></label>
</div>
<p id="gametype-error" class="error" role="alert"></p>
<div class="bar">
<button id="gametype-save" class="primary" data-needs="admin">Save</button>
<button id="gametype-delete" class="danger" data-needs="admin">Delete</button>
<button id="gametype-close">Close</button>
</div>
</div>
</div>
</section>
<section id="tab-bans" class="tab" role="tabpanel" hidden>
<div class="panel">
<h2>Bans</h2>
<table id="bans" class="cards">
<thead><tr><th>#</th><th>When</th><th>Players</th><th>Address</th><th>Hardware id</th><th>Reason</th>
<th>Until</th><th></th></tr></thead>
<tbody></tbody>
</table>
<p id="bans-empty" class="note">No bans.</p>
</div>
</section>
<section id="tab-console" class="tab" role="tabpanel" hidden>
<div class="panel console">
<div class="console-bar">
<label class="check"><input type="checkbox" id="follow" checked> Follow</label>
<span id="categories" class="chips"></span>
<button id="log-clear" class="small">Clear</button>
</div>
<div id="log" class="log" role="log" tabindex="0"></div>
<form id="command-form" class="command">
<input id="command" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send"
placeholder="A command: help lists them" aria-label="Command">
<button>Run</button>
</form>
<p id="command-hint" class="note">Tab completes, Up and Down go through what was run.</p>
</div>
</section>
<section id="tab-settings" class="tab" role="tabpanel" hidden>
<form id="settings-form" class="panel">
<h2>Settings</h2>
<p class="note">Written to config.toml and applied now where they can be. A setting its environment
variable sets cannot be changed here.</p>
<div id="settings-fields"></div>
<p id="settings-note" class="note" role="status"></p>
<div class="bar"><button class="primary" data-needs="admin">Save</button>
<button type="button" id="settings-revert">Revert</button></div>
</form>
</section>
<section id="tab-about" class="tab" role="tabpanel" hidden>
<div class="grid">
<div class="panel">
<h2>Ports to open</h2>
<dl class="facts" id="about-ports"></dl>
<p class="note">Only the tunnel's port needs to reach the internet. Keep the web port on loopback, or
behind a proxy that adds TLS (Caddy: reverse_proxy 127.0.0.1:8080), or reach it with
ssh -L 8080:127.0.0.1:8080.</p>
</div>
<div class="panel">
<h2>Where files are</h2>
<dl class="facts">
<div><dt>config.toml</dt><dd>Every setting: beside the executable, or --config</dd></div>
<div><dt>init.txt</dt><dd>Commands run at the start (server.init_file), in the data root</dd></div>
<div><dt>gametypes/</dt><dd>The operator's gametypes (.toml) and saved ones (.gametype), in the data
root</dd></div>
<div><dt>bans.txt, debug.txt</dt><dd>In paths.logs, else the data root</dd></div>
<div><dt>masters.txt</dt><dd>The master servers, beside the executable</dd></div>
<div><dt>publisher.key</dt><dd>The publisher key for the master servers, beside config.toml</dd></div>
<div><dt>status.json</dt><dd>The status every 2 seconds (server.status_file)</dd></div>
</dl>
</div>
<div class="panel">
<h2>This server</h2>
<dl class="facts" id="about-facts"></dl>
<p class="note">docs/dedicated_server.md is the reference of the commands, the API and the RCON.</p>
</div>
</div>
</section>
</main>
</div>
<div id="dialog" class="sheet" hidden>
<form id="dialog-form" class="sheet-body" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title"></h2>
<div id="dialog-body"></div>
<p id="dialog-error" class="error" role="alert"></p>
<div class="actions">
<button type="button" id="dialog-cancel">Cancel</button>
<button type="submit" id="dialog-ok" class="primary">OK</button>
</div>
</form>
</div>
<div id="toast" class="toast" role="status" hidden></div>
</body>
</html>
+86 -6
View File
@@ -270,7 +270,34 @@ browser names in its gametype column ("SWAT", "HC CTF"); a browser takes a
mode for a Slayer game only, and one it does not know as none. Then version 38
lets the host's shield effects be `players` (SHIELD EFFECTS' PLAYERS' OWN), a
value of distributed message 91 that tells each client to draw its own
settings', not synced.
settings', not synced. Then version 39 marks a dedicated server's game (a
host with no player of its own, `halo --server`: "Dedicated servers"
below) with a flag of its LAN advertisement (`HALO_PORT_ADVERTISED_DEDICATED_FLAG`,
0x04 of the third reserved byte) and of its internet listing (32), which
the server browser shows. With no new version, each client then tells the
host its build (distributed message 92, `_distributed_message_client_build`,
reliably once a game after its ready, over its connection alone): its
number, configuration, the label it shows ("OpenCE Linux | build 282
(release)"), the GitHub repository it is made from (owner/name, which the
build scripts take from CI's `GITHUB_REPOSITORY` or the checkout's origin
remote) and the mods it plays, a line each ("folder version", in the load
order); the host keeps it printable and logs it once, and a dedicated
server shows it in `/api/players` (`client`). A host without the message
drops it as a kind it does not know.
Distributed message 93 (`network_damage.c`) needs no new version either,
as a host that does not know it drops it: a client whose players' shots
leave from elsewhere than their units' cameras (a VR build, whose weapon
fires from the player's hand) sends the host where from, unreliably when it
changes and every eighth tick: an offset from the camera of no more than 0.4
world units (a player leaning out and an arm). The host keeps it a second,
for that machine's own players only. When none of the shooter's eyes (nor a
vehicle's gun) had a line to a reported hit's target, the host's line of
sight check then also looks from the hand, with the line from the eye to it
clear (a hand through a wall does not hit) and the hand where one firing at
the target can be: no more than 0.05 world units behind the eye along the
line to the target, 0.2 to its side and 0.1 above it (a client cannot claim
a hand far past a corner). Such hits are counted in the host's log, and the
host's `network.fire_origins` (true by default) turns the hand off.
`port/linux/network_format.txt` records the wire format of the current
version (`tools/network_format.py`): `test_linux_port.py` fails when the
@@ -329,7 +356,13 @@ hardware id is in it (a line taken out unbans). Both are as the player's
machine tells them: anyone with administrator or root access can change
them, and players behind one address share it. The console's `kick <player
name>` drops a player as `ban` does (every machine told), but adds no line
and keeps no address out: the player may join again at once.
and keeps no address out: the player may join again at once. Besides
`bans.txt`, the host keeps out a machine dropped for cheating or banned for
ever while it runs, by its address as it joined (an internet play peer's
stand-in), its real address and its hardware id, any of them; a dedicated
server's ban for a while (`ban <player> 30m`) is left to its `bans.txt`
line, whose `until=` ends it, and its `unban` lets the machine in again by
the line's address or hardware id (`network_game_server_port_forget_kicked_address`).
The players' votes to kick a player (`network_votekick.c`): a client sends
`_distributed_message_votekick` (the player's absolute index) reliably, and
@@ -384,8 +417,9 @@ names of the network game's players, which the lobby has too). A
machine's own scoreboard mute stays its own: it plays none of that
machine's voice and shows none of its players' lines, and tells no one.
The host's chat settings (`network.text_chat`: on, links allowed, or off;
always off while its game is listed in the server browser,
`p2p_hosting_listed`) go to every client in
always off while a player's game is listed in the server browser,
`p2p_hosting_listed`, but a dedicated server's own, `chat_listing_allows`)
go to every client in
`_distributed_message_chat_config` (one byte of flags), reliably, as they
change and every 3 seconds. A client without them (from a host without the
chat, or before they come) has the chat off, and so does a client in a
@@ -393,7 +427,10 @@ public game it joined from the server browser, whatever its host sends (a
changed host could turn the chat on): while the tunnel reaches the host of
the listing it joined (`p2p_join_listing`, `p2p_joined_listed_host`; any
other invite joined since forgets it), it takes no chat line or notice and
sends none. A client refuses a line the
sends none, unless the listing was a dedicated server's that a master
server lists (`p2p_joined_listed_dedicated`: the master's word, from an
approved operator's key; a listing's own dedicated flag, which any host can
set, does not count), whose settings it follows. A client refuses a line the
settings refuse before sending it, and the host refuses it again whatever
the client knew, telling its machine why: a changed build gets no further.
Links are found in the line's text (`chat_text_has_link`): a scheme, `www.`,
@@ -466,7 +503,8 @@ do (in free for all, a team of its own).
The game is closed to joins while its machines load and after it ends.
A machine must join within 10 seconds of connecting; in a game, the host
drops a machine it has heard nothing from for 15 seconds, and one that
drops a machine it has heard nothing from for 15 seconds
(`network.client_timeout_seconds`), and one that
joined the game in progress and has not loaded in two minutes.
Until it has loaded, the machine hears none of the game's messages (which a
@@ -474,6 +512,48 @@ machine in the pregame refuses, and which the others no longer need), only
a pregame keep-alive every five seconds from the host
(`network_server_manager.c`, `network_server_message_handler.c`).
## Dedicated servers
A dedicated server (`halo --server`: `docs/dedicated_server.md`) is a host
as any other, with these differences, all on the host's side:
- **No player of its own.** The host machine is in the game as machine 0
but has no player, so its lobby counts the joined machines' players only
(`network_server_manager.c`'s `server_ok_to_countdown`, which asks
`server_director_ok_to_countdown` for `server.min_players` and
`server.team_start`), and a game with one team's players may start (the
next players to join balance the teams). The host waits for no player of
its own to precache, and a client's player that quits the game does not
end the host's (`network_client_manager.c`: only a local player's quit
leaves).
- **No ticks of its own player.** The host's own action queue may hold no
action (`player_queues_new.c`), as a game with no local player has none.
- **The director.** `port/linux/game/server_director.c` drives the lobby as
a player at the pregame screen would: it sets the map and gametype of the
rotation's entry, starts the countdown once the server's own rule allows
it (`network_game_server_port_ok_to_countdown`, the rule the server checks
each tick, then `network_game_server_port_countdown_begin`), and after the postgame
goes back to the lobby with the next entry. The clients see a lobby, a
game and a postgame as with any host.
- **Shutdown.** On SIGTERM, a game in progress goes to its postgame, and
the host tells every machine it goes (`network_game_server_graceful_shutdown`)
before it exits, so that the clients show the scores rather than a lost
connection.
- **The flags.** Since version 39 a dedicated server's LAN advertisement
carries `HALO_PORT_ADVERTISED_DEDICATED_FLAG` and its internet listing the
dedicated flag (32); a listing published to the master servers is the
same signed listing (`docs/master_protocol.md`), and the master's probe
is answered on the tunnel's socket (`p2p.c`, `server_master_probe`).
- **The timeout.** `network.client_timeout_seconds` (15 seconds by
default) sets how long a host waits for a silent machine, and two more
waits with it: how long a stream's writes may stall before its connection
is dropped (`network_connection.c`), on every machine, clients too; and
how long a host in a game rides out its network going (an address
renewed, a wifi drop: `network_game_server_network_lost`). So it is not
the host's alone, and not a dedicated server's only: each machine reads
its own setting, and nothing on the wire changes (the machines need not
agree on it).
## Map sharing
A client may join a game on a Custom Edition map it does not have
+240 -8
View File
@@ -226,6 +226,12 @@ is held. The profile's button layout (Settings > Gamepads) is the
gamepads' only. Besides the Xbox's five, it has the Master Chief
Collection's Bumper Jumper: jump on the left bumper, melee on the right,
switching grenades on A, action and reload on B and the flashlight on X.
Fishsticks has zoom on the left trigger and melee on the right stick's
button, the grenade thrown with the right bumper, crouch on B and grenades
switched with the pad's left direction (which then does not strafe or turn).
Recon is the standard layout with action and reload on the right bumper and
grenades switched with the left bumper (the flashlight moves to X).
| Action | Keys and buttons (default) |
| --- | --- |
@@ -502,6 +508,8 @@ the setting for one start of the game. It has priority over the file.
| `bots.teams` | `"even"` | `HALO_BOT_TEAMS` | In a team game: `"even"` puts the bots on both teams, evening them with the players; `"against"` puts them all on the team the players are not on. |
| `paths.data` | `""` | `HALO_DATA_ROOT` | The data root. Refer to "Start the game". |
| `paths.saves` | `""` | `HALO_SAVE_ROOT` | The save root. Refer to "Files and folders". |
| `paths.logs` | `""` | `HALO_LOG_ROOT` | The folder of `debug.txt`, `bans.txt` and `cheaters.txt`. Empty: the data root. With it, the data root can be read-only (a dedicated server's maps mounted so). |
| `paths.custom_maps` | `""` | `HALO_CUSTOM_MAPS_ROOT` | The `custom_maps` folder (Custom Edition maps, with their `bitmaps.map`, `sounds.map` and `loc.map`). Empty: the one in the data root. |
| `network.address` | `""` | `HALO_NET_ADDRESS` | The IPv4 address of this machine for system link. Refer to "Play on one computer". |
| `network.broadcast` | `""` | `HALO_NET_BROADCAST` | IPv4 addresses, with commas between them, that get the broadcasts of the game. Empty: 255.255.255.255. |
| `network.online` | `true` | `HALO_NET_ONLINE` | `true`: internet play. `false`: system link on the local network only. |
@@ -523,11 +531,12 @@ the setting for one start of the game. It has priority over the file.
| `network.voice_mode` | `"team_global_enemy_proximity"` | `HALO_NET_VOICE_MODE` | When you host: who hears whom during a game. `"off"`; `"team_proximity"` (teammates who are near); `"team_enemy_proximity"` (all players who are near); `"team_global"` (all teammates); `"team_global_enemy_proximity"` (all teammates, and enemies who are near). Refer to "Voice chat". |
| `network.voice_kbps` | `24` | `HALO_NET_VOICE_KBPS` | When you host: the voice quality in the lobby and in a game, in kilobits per second, `8` to `64`. |
| `network.voice_proximity` | `15.0` | `HALO_NET_VOICE_PROXIMITY` | When you host: the distance in world units (1 unit is approximately 3 metres) at which players are near, for voice chat. `5` to `100`. |
| `network.text_chat` | `"on"` | `HALO_NET_TEXT_CHAT` | When you host: the text chat. `"on"`, lines with links refused; `"links"`, links allowed; `"off"`, no chat (in either case). Always off in a game listed in the server browser. TEXT CHAT in Server Setup > Teamplay Options (in co-op, Voice and Voting) writes its choice here. Refer to "Text chat". |
| `network.text_chat` | `"on"` | `HALO_NET_TEXT_CHAT` | When you host: the text chat. `"on"`, lines with links refused; `"links"`, links allowed; `"off"`, no chat (in either case). Always off in a game a player hosts that is listed in the server browser; a dedicated server's follows it, listed or not. TEXT CHAT in Server Setup > Teamplay Options (in co-op, Voice and Voting) writes its choice here. Refer to "Text chat". |
| `network.votekick` | `true` | `HALO_NET_VOTEKICK` | When you host: `true`, the players can vote to kick a player. Refer to "Security". `false`: no votes. VOTE KICK in Server Setup > Teamplay Options (in co-op, Voice and Voting) writes its choice here. |
| `network.votekick_minutes` | `5` | `HALO_NET_VOTEKICK_MINUTES` | When you host: the minutes that a player must play on the server before the player can start a vote to kick (`0` to `60`). To vote, a player must play for 2 minutes, or for this time if it is less. |
| `network.votekick_ban_minutes` | `30` | `HALO_NET_VOTEKICK_BAN_MINUTES` | When you host: the minutes that a player who is kicked by a vote cannot join again (`1` to `1440`). |
| `network.shield_effects` | `stock` | `HALO_NET_SHIELD_EFFECTS` | Host: the shield effects every player of a network game sees, whatever their own settings, so that none sees what another does not: `stock`, `glow` (`display.shield_glow`), `light_show` (its Light Show intensity), `overshield` (`display.overshield_effect`), `glow_overshield` or `light_show_overshield`; or `players`, not synced: each player sees their own settings' (PLAYERS' OWN). Sent to each client when it is ready and when it changes (`network_shield_effects.c`); a client draws stock until it has it. SHIELD EFFECTS in Server Setup > Player Options writes its choice here. |
| `network.fire_origins` | `true` | `HALO_NET_FIRE_ORIGINS` | Host: whether a client's reported hit that the shooter's camera had no line of sight to may still count from where the client says its player's shots leave, when that is not the camera (distributed message 93: a VR build's weapon hand, which may be past a corner the camera is behind). The hand must be within 0.4 world units of the camera, no more than 0.05 behind the eye along the line to the target, 0.2 to its side and 0.1 above it, with the line from the eye to it clear; the host logs how many hits counted only that way. `false`: from the camera and vehicles' guns only (`network_damage.c`). |
| `network.block_mouse_aim_assist` | `false` | `HALO_NET_BLOCK_MOUSE_AIM_ASSIST` | Host: Player Options > Aim Assist, Allowed or Block M+KB. Block overrides optional mouse magnetism while mouse/keyboard aims, including on the host, without changing the saved preference. Controllers and touch retain their existing behavior; bullet autoaim is unchanged. |
| `network.map_share` | `true` | `HALO_NET_MAP_SHARE` | When you host: send a player who joins without the game's Custom Edition map the map, and the resource maps that it needs and that the player does not have. SHARE MAPS in Server Setup > Map Sharing writes its choice here. Refer to "Map sharing". |
| `network.map_share_upload` | `1024` | `HALO_NET_MAP_SHARE_UPLOAD` | When you host: the most kilobytes per second that the game sends of maps (`16` to `20480`, 20 MB/s). UPLOAD LIMIT in Server Setup > Map Sharing writes its choice here. |
@@ -537,6 +546,55 @@ the setting for one start of the game. It has priority over the file.
| `network.coop_public` | `false` | `HALO_NET_COOP_PUBLIC` | `true`: an online co-op game (Create Game > Internet, a SINGLEPLAYER map) starts as PUBLIC. `false`: it starts as PRIVATE. LISTING in co-op's Server Setup writes its choice here. Refer to "Server browser". |
| `network.brokers_file` | `"brokers.txt"` | `HALO_NET_BROKERS_FILE` | The file of the public MQTT brokers that let the machines of an invite find each other, and that carry the listings of the server browser: next to `config.toml`, unless a full path. One `host:port` on each line, up to 4; `#` starts a comment. |
| `network.stun_servers` | Google and Cloudflare | `HALO_NET_STUN` | The public STUN servers (`host:port`, with commas between them) that give the internet address of a machine. |
| `network.client_timeout_seconds` | `15` | `HALO_NET_CLIENT_TIMEOUT_SECONDS` | Hosting: the seconds the host waits for a machine it hears nothing from before it drops it (5 to 300; the stock game waits 15). The same seconds are how long each machine's reliable stream waits for its peer to read before it gives up, and how long a host in a game waits for its own network to come back (an address renewed, a wifi drop) before it ends the game; each machine reads its own setting. A longer wait keeps players whose connection stalls (a browser tab in the background), but a machine that is really gone leaves its players frozen for longer. |
| `network.public_address` | `""` | `HALO_NET_PUBLIC_ADDRESS` | Hosting: the internet address the joiners reach this machine at, `host[:port]` (the port forwarded to `network.tunnel_port`), for a server behind a NAT with one forwarded port. It is offered first, and its name is looked up again every 5 minutes. Empty: what STUN and UPnP find. |
| `network.listings` | `"both"` | `HALO_NET_LISTINGS` | What the server browser lists. `"both"`: the dedicated servers of the master servers first, then the games players host (from the brokers). `"masters"`: the master servers' only. `"brokers"`: the brokers' only. Refer to "Server browser". |
| `network.masters_file` | `"masters.txt"` | `HALO_NET_MASTERS_FILE` | The file of the master servers the server browser asks for dedicated servers, next to `config.toml` unless a full path: on each line an `https://` URL and the master's key (`ed25519:<hex>`), 4 at most. A file that names none (missing, or comments alone, as the release's) leaves the official master, built in (`https://master.opencommunityedition.com`). Refer to "Dedicated server". |
| `network.browser_source` | `"all"` | `HALO_NET_BROWSER_SOURCE` | The server browser's games. `"all"` (ALL GAMES): the dedicated servers first, then the games players host; `"dedicated"`: the dedicated servers of the master servers; `"players"` (P2P GAMES): the games players host. The tab at the top left of the server browser changes it. |
| `network.browser_show_empty`, `network.browser_show_full` | `true` | `HALO_NET_BROWSER_SHOW_EMPTY`, `HALO_NET_BROWSER_SHOW_FULL` | `false`: the server browser does not list the games with no player, the full games. The server browser's Filters screen sets them. |
| `network.browser_maps` | `"yes"` | `HALO_NET_BROWSER_MAPS` | `"yes"`: the server browser lists the games on maps this computer does not have. `"no"`: it does not. `"downloadable"`: only those of a Custom Edition map that the host shares. |
| `network.browser_gametype` | `"any"` | `HALO_NET_BROWSER_GAMETYPE` | The server browser lists the games of one kind only: `"any"`, `"ctf"`, `"slayer"`, `"oddball"`, `"king"` or `"race"` (the Filters screen). |
| `network.browser_sort` | `"servers"` | `HALO_NET_BROWSER_SORT` | The server browser's order. `"servers"`: dedicated servers first, then by players. `"players"`, `"name"`, `"map"`, `"type"` or `"locked"` (games with a password first). A `-` before one reverses it (`"-players"`). A column's title sorts by it, and again the other way. |
| `server.enabled` | `false` | `HALO_SERVER` | `true`: run as a dedicated server, as `halo --server` does. Refer to "Dedicated server". The `server.*` settings are written to `config.toml` only by a dedicated server. |
| `server.name` | `"OpenCE Server"` | `HALO_SERVER_NAME` | The game's name in the lobby and the server browser: 15 characters of printable ASCII. |
| `server.max_players` | `16` | `HALO_SERVER_MAX_PLAYERS` | The most players (2 to 127). |
| `server.min_players` | `1` | `HALO_SERVER_MIN_PLAYERS` | The players in the lobby before it counts down to a game (1 to 16). |
| `server.password` | `""` | `HALO_SERVER_PASSWORD` | The password the server browser asks for (an invite link joins without it). Empty: none. It is never logged. `HALO_SERVER_PASSWORD_FILE` names a file whose first line is it instead (a container's secret; pinned, as the variable would be). |
| `server.public` | `true` | `HALO_SERVER_PUBLIC` | `true`: the server is in everyone's server browser (on the master servers, with `network.online`). `false`: players join with its invite link, or on the LAN. |
| `server.rotation` | `"bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer"` | `HALO_SERVER_ROTATION` | The games, played one after another: `map[:gametype[+rule=value...]]`, separated by commas, semicolons or new lines, 64 at most (an entry 255 characters, the whole 4096); a line that begins with `#` is a comment. A map is a multiplayer map's name (`bloodgulch`), `custom_maps\<name>` or a level's path; a gametype is a built-in gametype's name (`slayer`, `team_slayer`, `ctf`...), an OpenCE gametype's (`swat`...), a gametype file's (`gametypes/<name>.toml`) or `file=<saved gametype>`. An entry that cannot be played is left out with a warning. A change of the rotation (a command, the web page, `sv_reload`) keeps its place: the next game is the entry after the one played last. Refer to `docs/dedicated_server.md`, "The rotation". |
| `server.rotation_fallback` | `false` | `HALO_SERVER_ROTATION_FALLBACK` | `true`: with no entry of `server.rotation` that can be played, play `bloodgulch:slayer`. `false`: stop (exit code 3). |
| `server.lobby_seconds` | `15` | `HALO_SERVER_LOBBY_SECONDS` | The seconds the lobby counts down once `server.min_players` are in (0 to 300; `0`: the game starts at once). |
| `server.postgame_seconds` | `12` | `HALO_SERVER_POSTGAME_SECONDS` | The seconds the scores show after a game (0 to 300). |
| `server.empty_seconds` | `30` | `HALO_SERVER_EMPTY_SECONDS` | The seconds a game with nobody in it goes on before it ends. `0`: it does not end. A game whose last player leaves ends at once, as the game ends it. |
| `server.idle_minutes` | `0` | `HALO_SERVER_IDLE_MINUTES` | The minutes a game goes on with no score before it ends. `0`: it does not end. |
| `server.team_start` | `"one"` | `HALO_SERVER_TEAM_START` | A team game. `"one"`: it starts with the players of one team (the next players to join balance the teams). `"both"`: it waits, saying nothing, for a player on each team. `"skip"`: while one player waits, a team game (not one `sv_map` asked for) gives way to the rotation's next entry without teams, if it has one; checked when the lobby opens and again when its countdown would begin. |
| `server.motd` | `""` | `HALO_SERVER_MOTD` | A notice each machine sees when it joins (127 characters). Empty: none. |
| `server.frame_rate` | `60` | `HALO_SERVER_FRAME_RATE` | The frames a second (30 to 120); the game's ticks stay at 30 a second. A server with no other machine runs 10 frames a second. |
| `server.console`, `server.console_exit_on_eof` | `true`, `false` | `HALO_SERVER_CONSOLE`, `HALO_SERVER_CONSOLE_EXIT_ON_EOF` | `true`: the server takes commands from the standard input; it stops when the standard input ends if `server.console_exit_on_eof` is `true` (a panel can close it), after running the last line, whether or not a line's end ended it. |
| `server.init_file` | `"init.txt"` | `HALO_SERVER_INIT_FILE` | The commands run once at the start, one on each line: in the data root unless a full path. Empty: none. |
| `server.web_port`, `server.web_bind` | `8080`, `"127.0.0.1"` | `HALO_SERVER_WEB_PORT`, `HALO_SERVER_WEB_BIND` | The TCP port and IPv4 address of the web page, its API and the remote console (RCON). `0`: none. With an address beyond loopback, each web password that is set must have 8 characters or more, else the server stops (exit code 3, as for a port it may not bind or an address the machine does not have; a port in use: exit code 4); put a reverse proxy with TLS in front of it. Each server on a machine needs its own port. Read at the start only. |
| `server.web_password` | `""` | `HALO_SERVER_WEB_PASSWORD` | The admin password of the web page (8 characters or more). Empty: the server makes one at the first start, shows it once and writes it here. |
| `server.web_owner_password` | `""` | `HALO_SERVER_WEB_OWNER_PASSWORD` | A password with the owner role, for a supervisor on the server's own machine (a fleet's agent, a panel): over the API and the RCON it does what the standard input does (`quit`, `sv_stop`, `sv_restart`, the owner's settings, the game's console commands, the players' addresses). It signs in from `127.0.0.0/8` alone, never through `server.web_trusted_proxy`; from another address it is refused (`403 forbidden`) and counted as a wrong password. 8 characters or more (else the server exits with code 3). Empty: none. `sv_set` does not change it (`config.toml` and `sv_reload`). |
| `server.web_moderator_password`, `server.web_viewer_password` | `""` | `HALO_SERVER_WEB_MODERATOR_PASSWORD`, `HALO_SERVER_WEB_VIEWER_PASSWORD` | Other passwords: the moderator role (kick, mute, say, the next map, bans of one day at most) and the viewer role (the status, the players and the log; no changes). 8 characters or more when the web port is bound beyond loopback (else the server exits with code 3; on loopback, a warning). Empty: none. `sv_set` changes the moderator password, not the viewer password (`config.toml` and `sv_reload`). Each web password's variable has a `_FILE` form (`HALO_SERVER_WEB_PASSWORD_FILE`, `HALO_SERVER_WEB_OWNER_PASSWORD_FILE`...): when the variable is not set, the file's first line is the password, pinned as the variable would be; read at the start. |
| `server.web_ui` | `true` | `HALO_SERVER_WEB_UI` | `false`: the web port serves the API and RCON, but not the admin page. |
| `server.web_trusted_proxy` | `""` | `HALO_SERVER_WEB_TRUSTED_PROXY` | The address of a reverse proxy in front of the web port: its `X-Forwarded-For` (the last entry, the one the proxy added, an IPv4 or IPv6 address with or without a port), `-Host` and `-Proto` headers are believed, and a sign-in through it with `X-Forwarded-Proto: https` gets a `Secure` cookie. Empty: none. |
| `server.metrics` | `true` | `HALO_SERVER_METRICS` | `true`: the web port serves `/metrics` (Prometheus) without a password. |
| `server.log_format` | `"text"` | `HALO_SERVER_LOG_FORMAT` | The standard output's lines: `"text"` or `"json"`. |
| `server.log_addresses` | `false` | `HALO_SERVER_LOG_ADDRESSES` | `true`: the players' internet addresses and hardware IDs are in the log, the events and the API for every role (the owner's console always has them). |
| `server.log_debug_echo` | `false` | `HALO_SERVER_LOG_DEBUG_ECHO` | `true`: the lines of `debug.txt` are also written to the standard error. |
| `server.status_file` | `""` | `HALO_SERVER_STATUS_FILE` | A file the status (as `/api/status` gives it) is written to every 2 seconds, for a panel without HTTP. Empty: none. |
| `server.auto_update` | `"off"` | `HALO_SERVER_AUTO_UPDATE` | `"check"`: every 6 hours, the server looks for a newer release and logs it. `"install"`: it also installs it once the server has been empty for 10 minutes (on a thread of its own, the server playing on meanwhile), and stops with exit code 5, once it is empty, to be started again; an install that fails is tried again at the next check. `"off"`: neither. |
| `server.upnp` | `false` | `HALO_SERVER_UPNP` | `true`: ask the router (UPnP) to forward the tunnel's port (as `network.allow_upnp`, for a server at home). |
| `server.coop_difficulty` | `"normal"` | `HALO_SERVER_COOP_DIFFICULTY` | The difficulty of co-op entries of the rotation (`<level>:coop`): `"easy"`, `"normal"`, `"hard"` or `"impossible"`. |
| `server.list_on_brokers` | `false` | `HALO_SERVER_LIST_ON_BROKERS` | `true`: the server is also listed with the games players host (through the brokers), for server browsers without master servers. |
| `server.master_publish` | `true` | `HALO_SERVER_MASTER_PUBLISH` | `true`: a public server is published to the master servers, signed with the publisher key. |
| `server.masters_file` | `"masters.txt"` | `HALO_SERVER_MASTERS_FILE` | The master servers the server is published to, next to `config.toml` unless a full path: on each line an `https://` URL and the master's key (`ed25519:<hex>`). None named: the official master, built in. |
| `server.publisher_key_file` | `"publisher.key"` | `HALO_SERVER_PUBLISHER_KEY_FILE` | The operator's Ed25519 key for the master servers (`halo --server --keygen` makes it), next to `config.toml` unless a full path. In a container, `HALO_SERVER_PUBLISHER_KEY` can hold the key itself. |
| `server.instance` | `""` | `HALO_SERVER_INSTANCE` | A short name that tells this server from the operator's other servers on the master servers (letters, digits, `-` and `_`). Empty: made from `server.name`. |
| `server.region` | `""` | `HALO_SERVER_REGION` | A region the server browser shows (`eu`, `na-east`...): 16 characters. Empty: none. |
| `server.chat_filter` | `"off"` | `HALO_SERVER_CHAT_FILTER` | The words the server keeps out of its chat and its players' names (streamer mode's list): `"off"`; `"censor"`, a line's words shown as stars; `"block"`, a line with them refused (its sender told why) and a player whose name has them refused at the door. Links are `network.text_chat`'s. |
| `server.chat_filter_slurs`, `server.chat_filter_swearing`, `server.chat_filter_insults` | `"mild"`, `"off"`, `"off"` | `HALO_SERVER_CHAT_FILTER_SLURS`, `_SWEARING`, `_INSULTS` | With `server.chat_filter`, each group's words kept out: `"off"`, `"severe"` (the worst), `"strong"` or `"mild"` (all). |
| `server.master_publish_address` | `false` | `HALO_SERVER_MASTER_PUBLISH_ADDRESS` | `true`: the master servers can give this server's address to players. `false`: only their probe uses it. |
| `discord.application_id` | the application of the project | `HALO_DISCORD_APPLICATION` | The Discord application for invites. Empty: no Discord. |
| `update.auto` | `true` | `HALO_UPDATE_AUTO` | `true`: at start-up, the game looks for a new version. Refer to "Updates". `false`: the game does not look. |
| `streamer.enabled` | `false` | `HALO_STREAMER_MODE` | `true`: streamer mode hides offensive words, as stars, in the names and text of other players, maps and mods, at the levels below. Refer to "Streamer mode". STREAMER MODE in Settings > Network Setup > Streamer Mode. |
@@ -546,7 +604,7 @@ the setting for one start of the game. It has priority over the file.
| `crash_reports.upload` | `"ask"` | `HALO_CRASH_REPORTS` | Windows only. `"yes"`: the game sends a report of each crash to the developers. `"no"`: the game sends no reports. `"ask"`: the game asks at the next crash and writes the answer here. Refer to "Crash reports" in [port/windows/README.md](../windows/README.md#crash-reports). |
| `debug.update_answer` | `""` | `HALO_UPDATE_ANSWER` | The answer to the update question, for automatic tests: `yes`, `no` or `never`. Empty: the game asks. |
| `debug.fixed_step` | `false` | `HALO_FIXED_STEP` | `true`: a game of this computer alone runs one tick a frame, whatever time has passed, so that it plays the same every time (tests). |
| `debug.exit_after` | `0.0` | `HALO_EXIT_AFTER` | The game stops after this number of seconds (from when its window opens; with `debug.null_renderer`, or no display, from its first frame). `0`: never. |
| `debug.exit_after` | `0.0` | `HALO_EXIT_AFTER` | The game stops after this number of seconds (from when its window opens; with `debug.null_renderer`, or no display, from its first frame; a dedicated server stops as `quit` stops it). `0`: never. |
| `debug.screenshot_directory`, `debug.screenshot_every` | `""`, `0` | `HALO_SCREENSHOT_DIR`, `HALO_SCREENSHOT_EVERY` | The game writes each Nth frame to this folder as a BMP file. |
| `debug.hidden_window`, `debug.null_renderer` | `false` | `HALO_HIDDEN_WINDOW`, `HALO_NULL_RENDERER` | `true`: no visible window, or no graphics. |
| `debug.gpu_stats`, `debug.gpu_trace_frame`, `debug.gpu_trace_constants`, `debug.gpu_dump_shaders`, `debug.texture_dump_directory`, `debug.texture_log`, `debug.gl_debug`, `debug.texture_no_cache` | off | `HALO_GPU_STATS`, `HALO_GPU_TRACE`, `HALO_GPU_TRACE_CONSTANTS`, `HALO_GPU_DUMP_SHADERS`, `HALO_TEXTURE_DUMP`, `HALO_TEXTURE_LOG`, `HALO_GL_DEBUG`, `HALO_TEXTURE_NO_CACHE` | Tools to find problems in the graphics: counts for each frame, all the GL state of one frame, the GLSL code, the textures. |
@@ -868,14 +926,18 @@ ON (the default: a line with a link, such as `discord.gg/...`,
`example.com`, `www.`, an address, or `example [dot] com`, is refused),
WITH LINKS, or OFF. A domain that is a word as well (`.me`, `.to`, `.is`)
counts only with a path after it (`youtu.be/...`), so that `go.to` and
`he.is` are not refused. A game listed in
`he.is` are not refused. A game a player hosts that is listed in
the server browser (a public internet game) never has the text chat,
whatever it says (TEXT CHAT shows OFF there, and cannot be changed, while
LISTING is PUBLIC); a private game, joined by its invite, and a LAN game have
it as chosen. A player who joins a game from the server browser has no text
chat in it even if its host turns it on: their game takes no line from that
host and sends none. Pressing T where the chat is off says so, and a refused line
tells its sender why.
it as chosen. A player who joins such a game from the server browser has no
text chat in it even if its host turns it on: their game takes no line from
that host and sends none. A dedicated server is the exception: its own
`network.text_chat` decides (on by default, and its operator can turn it
off, or allow links), listed or not, and a player who joins it from the
server browser's dedicated servers (a master server's list, which names
approved operators alone) has the chat as it says. Pressing T where the chat
is off says so, and a refused line tells its sender why.
On the scoreboard (right-click, then click the player), Mute voice and chat
mutes a player for you alone: you no longer see their lines or hear their
@@ -983,6 +1045,74 @@ host sends with the game (network version 37), and otherwise the game
engine's (SLAYER, CTF, KING OF THE HILL). The browser of LAN games, and the
preview of a game before joining it, name it the same way.
The browser lists two kinds of games: the dedicated servers that the
master servers list (`masters.txt`, `network.masters_file`; the official
master, `https://master.opencommunityedition.com`, built in, when the file
names none), and the games
that players host, which come through the brokers. `network.listings`
chooses the kinds that the browser asks for: both (the default), only the
master servers, or only the brokers.
- The top-left tab chooses the games that the list shows: ALL GAMES (at
first), DEDICATED or P2P GAMES (left and right, `network.browser_source`).
When `network.listings` is `"masters"`, the tab shows DEDICATED only;
when it is `"brokers"`, P2P GAMES only.
PLAYERS, the sum of the players in the games shown, is in the middle tab.
- The column titles sort the list (A or Enter on a title, or a click).
The titles are: server name, map, game, players, the lock (the games
with a password first) and the dedicated icon (dedicated servers first).
A second choice of the same title reverses the order, and an arrow on
the title shows the direction. The default order is dedicated servers
first, then the games with the most players, then by name. The browser
keeps the order in `network.browser_sort`.
- FILTERS opens the Filters screen of Custom Edition. It has SHOW EMPTY
SERVERS, SHOW FULL SERVERS, GAME TYPE (ANY or one game engine) and SHOW
MISSING MAPS (NO, YES, or IF DOWNLOADABLE, for a Custom Edition map that
Map Sharing can download when you join). OK writes the
`network.browser_*` settings. Settings > Network Setup > SERVER BROWSER
writes the same settings, and also shows LISTS, SORT BY (DEDICATED FIRST,
PLAYERS, NAME, MAP, GAME TYPE or LOCKED FIRST; an order that a title reversed
shows as the same choice) and MASTER SERVERS
(the number of master servers that answered the last request of the
browser, for example `2 OF 2`). The PING and TEAMPLAY OPTIONS rows of
Custom Edition are not on the Filters screen.
- A dedicated server's row shows the dedicated icon of Custom Edition.
Instead of a ping, a dedicated server shows its region (EU, NA-E),
because the browser does not ping the servers that the master servers
list. The row shows JOINING while a join runs. A game of another network
version stays in the list in grey, after the other games, with NEWER or
OLDER. Its row does not join the game: it shows the message about the
version. The server count above the list adds the number of dedicated
servers (`SERVERS: 8 (5 dedicated)`) when the list shows all the games on
one page. PAGE shows only when the list has more than one page.
- The two lines below the list describe the selected game. The first line
shows its players, map, game and region. The second line shows where the
game comes from: "Dedicated server by <publisher>, listed on N master
servers" (the operator's name comes from the master server, and only this
line shows it), "Hosted by a player (anonymous listing)", "Password
required", or, for a game of another version, the update that it needs.
During a join, the second line shows the steps of the join, and how many
seconds it has taken. The steps are: the request to the brokers for the
host, the answer of the host and the connection attempt (UDP hole
punching), and the connection to the host. If the join fails, the line
shows the reason:
- the host did not answer;
- no route to the host (the line says strict NAT when that is the cause);
- the host refused the join: the game is full or closed, this machine is
banned, the password is wrong, the version is different, or the invite
changed.
The browser shows a refusal when it opens again after the main menu
error.
- If no master server answers but the brokers do, the second line shows "No
master server answered: showing player-hosted games only", and the list
shows the games from the brokers. An error panel covers the list in two
cases: internet play is off, or nothing answers (no master server and no
broker) after 15 seconds. OK, Enter or B closes the panel.
- In the lobby of a dedicated server, the panel at the right shows the
name of the server and "dedicated" on its first line. It shows "Waiting
for players" while the server waits for enough players
(`server.min_players`) to start its countdown.
How it operates (`src/p2p_lobby.c`):
- The host of a public game publishes a listing: the invite, and the name,
@@ -1234,6 +1364,108 @@ 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.
## Dedicated server
`halo --server` (or `server.enabled = true`) runs the game as a host with no
window, sound or player of its own, for a VPS, a container or a game panel.
It hosts the games of `server.rotation` one after another: the lobby counts
down when `server.min_players` are in, the game is played, its scores show,
and the lobby opens with the next entry. Players join it as they join any
host: on the LAN, with its invite link, or from the server browser.
`docs/dedicated_server.md` is the reference: the command line, the exit
codes, the commands, the HTTP API, the remote console, the events and the
files.
```sh
./halo --server # the settings of config.toml
HALO_SERVER_ROTATION="bloodgulch:ctf, prisoner:slayer+score=25" ./halo --server
./halo --server --config /srv/opence/config.toml
```
The server needs the game data (`maps/`, as for the game); without
`maps/ui.map` it stops at once with exit code 2. At the first start it
writes the `server.*` settings to `config.toml` (they are written only by a
server) and a web password if there is none. It writes one line for each
event to the standard output (`server.log_format = "json"` for JSON
lines), and the game's own log to `debug.txt` as the game does
(`paths.logs` moves it, so that the data root can be read-only).
- **Commands.** The standard input, `init.txt` (`server.init_file`) and the
web page take the same commands: `sv_status`, `sv_players`, `say`, `tell`,
`kick`, `ban <player> 2h <reason>`, `unban`, `mute`, `sv_map`,
`sv_map_next`, `sv_end_game`, `sv_rotation_set`, `sv_set <setting>
<value>`, `sv_reload`, `sv_ban_address <address|hardware id>`,
`sv_stop [now|game|empty] [notice]`, `sv_restart`, `quit`. `help` lists
them. `sv_stop game` (or `empty`) withdraws the listings, refuses new
players and stops once the game is over (or nobody plays), without
cutting a game short; `sv_stop cancel` undoes it. The game's own console
commands need the owner role: the standard input and `init.txt` take
them, and the web page and the remote console with
`server.web_owner_password` from the machine itself; with another
password they do not (and `kick` and `ban` are always the server's).
- **Web page.** `server.web_port` (8080, on `127.0.0.1`) serves an admin
page, a JSON API, a remote console over WebSocket, `/healthz` and
`/metrics`. Sign in with `server.web_password` (the admin); the moderator
and viewer passwords give fewer rights, and `server.web_owner_password`,
from the machine itself alone, the owner's. To reach it from another
computer, use an SSH tunnel, or a reverse proxy with TLS,
`server.web_bind` and `server.web_trusted_proxy`; bound beyond loopback,
every web password must have 8 characters or more.
- **Signals.** SIGTERM and SIGINT stop the server cleanly (a game in
progress shows its scores, every machine is told, the listings are
withdrawn); SIGHUP reads the settings and the rotation again.
- **Listing.** A public server (`server.public`) with `network.online` is
published to the master servers of `masters.txt`, signed with the
operator's key: `halo --server --keygen` makes `publisher.key` and prints
the public key, which the operator gives to a master server's Discord
bot: `/apply` in the master's Discord server (the master's `/apply` page
links to it), with `halo --server --sign <nonce>`'s proof of key. Until a
master approves it, the server is not listed
there (its log says so), but players can join it with its invite link
and on the LAN. A change the lists show (public or private, the
players, the name, the map) is published within 5 seconds; made
private, the server makes a new invite and takes itself off the
masters' lists, as `network.public_lobby = false` does
(`docs/dedicated_server.md`, "The listing"). `server.list_on_brokers`
also lists it with the games players host. A player's public game is published to the masters too
when `publisher.key` is next to `config.toml` (not as a dedicated
server), and withdrawn when it ends. `docs/master_protocol.md` is the
protocol.
- **Ports.** The tunnel's UDP port (`network.tunnel_port`, 2302 for a
server) must be reachable from the internet; `network.public_address`
names the address and forwarded port behind a NAT. The web port is TCP
and best kept private.
- **Updates.** `server.auto_update = "install"` installs a new release once
the server has been empty for 10 minutes (on a thread of its own: the
server goes on meanwhile), and stops with exit code 5, once it is empty,
so that what runs it (systemd, Docker, a panel) starts the new build. An
install that fails is tried again at the next check, 6 hours later.
- **Probe.** `halo --probe <invite>` prints the state of the invite's game
(a server's name, map, gametype and players) as one line of JSON, for a
list site, and exits: the game of the invite's host alone. It never
starts a server, whatever the settings say, and writes nothing to
`config.toml`.
`deploy/` has a systemd unit (`deploy/systemd`), a Docker image and compose
file (`deploy/docker`), a Pterodactyl and Pelican egg (`deploy/pterodactyl`),
a Helm chart (`deploy/helm/opence-server`), PufferPanel and MCSManager
templates (`deploy/pufferpanel`, `deploy/mcsmanager`), example gametype
files (`deploy/gametypes`, described in `docs/dedicated_server.md`,
"Gametype files": their rules have their full names, `team_vehicles`, not
the rotation's short names) and an example `init.txt`
(`deploy/init.txt.example`). The README of each of the first six folders
says how to use it. `deploy/release-key.pub` is the public key of the
releases' signed `SHA256SUMS` (`docs/dedicated_server.md`, "Releases").
**Fleets.** For many servers on many machines, beside the panels,
`opence-fleet`
([github.com/OpenCommunityEdition/opence-fleet](https://github.com/OpenCommunityEdition/opence-fleet))
is one control plane over them all: an agent on each machine runs its
servers and drives each through the API as the owner, from the machine
itself (`server.web_owner_password`), drains a server before an update
(`sv_stop game`, `POST /api/stop`) and bans an address on every server at
once (`POST /api/bans`); `docs/dedicated_server.md`, "Panels and fleets".
## Mods
A mod is a folder in `mods/` in the data root with a `mod.toml` and a
@@ -1471,7 +1703,7 @@ Other changes:
| `game/game_engine.c`, `game/game_engine.h`, `game/game_engine_slayer.c`, `game/game.c`, `game/aim_assist.c`, `game/players.c`, `objects/damage.c`, `objects/objects.c`, `units/units.c`, `items/weapons.c`, `saved games/playlist_profile.c`, `saved games/saved_game_files.c` | OpenCE's gametypes (`game/game_modes.c`, "OpenCE gametypes"): the gametype's PC options carry a mode and Hardcore with their options; the built-in ones are written after the Xbox's default gametypes; Infection's and Gun Game's game engines are Slayer's with their rules; SWAT's headshots, Hardcore's motion sensor, aim assist and restore of the killer; Infection's zombies' skulls, grenades, powerups, seats and melee. |
| `game/game_time.c`, `main/main.c` | With `debug.fixed_step`, a game of one machine runs a tick a frame, each frame taking a tick's time, so that it plays the same every time (`tools/stagekit`). |
| `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). |
| `saved games/player_profile.h`, `interface/player_ui.c` | A sixth button layout, the Master Chief Collection's Bumper Jumper (`profile.button_preset` 5). A layout the build does not know (a later build's) gets the standard buttons, not none, which moved start and back and stopped the game. |
| `saved games/player_profile.h`, `interface/player_ui.c` | A sixth button layout, the Master Chief Collection's Bumper Jumper (`profile.button_preset` 5), and a seventh, Fishsticks (6: zoom on the left trigger, melee on the right stick's button, grenade throw on the right bumper, crouch on B, grenade switch on the pad's left, which then does not move you), and an eighth, Recon (7: the standard buttons with action and reload on the right bumper, grenade switch on the left bumper, flashlight on X). A layout the build does not know (a later build's) gets the standard buttons, not none, which moved start and back and stopped the game. |
| `sound/sound_manager.c`, `interface/hud.c`, `game/game_engine.c` | The music's and the other sounds' volumes; the HUD's and the scoreboard's settings are read again when Settings changes them. |
| `rasterizer/xbox/rasterizer_xbox_shadows.c` | With larger shadow maps (`display.shadow_resolution`), the blur's taps stay half a texel apart, and more passes widen it to cover the same part of the map as on the Xbox. |
| `rasterizer/rasterizer.c` | The 128 KB of vertices of the debug display of a model's vertices are static, not on the stack: the compiler inlines the function in `render_model`, and the Windows build probed the stack a page at a time for every model drawn. |
+9 -1
View File
@@ -89,6 +89,7 @@ static boolean gun_game_weapon_empty(
struct weapon_datum *weapon = weapon_try_and_get(weapon_index);
struct weapon_definition *definition;
short magazine;
boolean holds_rounds = FALSE;
if (!weapon)
return FALSE;
@@ -98,10 +99,17 @@ static boolean gun_game_weapon_empty(
for (magazine = 0; magazine < MIN(definition->weapon.magazines.count, NUMBEROF(weapon->weapon.magazines));
magazine++)
{
struct weapon_magazine_definition const *magazine_definition =
TAG_BLOCK_GET_ELEMENT(&definition->weapon.magazines, magazine, struct weapon_magazine_definition);
/* (a battery weapon's magazine holds no rounds: the plasma rifle's and plasma pistol's) */
if (magazine_definition->rounds_loaded_maximum <= 0 && magazine_definition->rounds_total_maximum <= 0)
continue;
holds_rounds = TRUE;
if (weapon->weapon.magazines[magazine].rounds_loaded > 0 || weapon->weapon.magazines[magazine].rounds_total > 0)
return FALSE;
}
return TRUE;
return holds_rounds ? TRUE : weapon->weapon.age >= 1.0f;
}
/* every weapon of a unit gone, the one in its hand too (which
+22 -2
View File
@@ -66,6 +66,7 @@ what it says it has grows.
#include "custom_edition_cache.h"
#include "custom_edition_maps.h"
#include "map_share.h"
#include "server_director.h"
#include "../src/p2p.h"
#include <stdio.h>
@@ -499,6 +500,10 @@ struct host_machine
unsigned long requested_at;
boolean request_waiting;
struct distributed_map_share_request waiting_request;
/* the download's end (done, or failed) said, in the log and a dedicated
server's event: once a request (host_request clears it), however often
the machine says it */
boolean end_said;
};
struct map_share_globals
@@ -3234,6 +3239,7 @@ static void host_request(
machine->downloading = TRUE;
error(_error_silent, "map sharing: machine #%ld asks for %s (files 0x%X)", machine_index, request->level_name,
(unsigned)request->wanted);
server_event_map_share(machine_index, request->level_name, "asked", 0, 0);
}
/* a machine's request, as it comes: taken at once if the last it made was
@@ -3360,15 +3366,29 @@ static void host_progress(
(machine->done > machine->advanced_done && machine->done == machine->offered_total) ||
progress->state == _progress_done)
{
machine->advanced_done = machine->done;
/* (a dedicated server's event at each tenth it passes, going on:
what it says it has never goes back) */
if (progress->state == _progress_downloading && machine->offered_total &&
machine->done > machine->advanced_done &&
(unsigned long long)machine->done * 10 / machine->offered_total !=
(unsigned long long)machine->advanced_done * 10 / machine->offered_total)
{
server_event_map_share(machine_index, machine->request.level_name, "downloading", machine->done,
machine->offered_total);
}
if (machine->done > machine->advanced_done)
machine->advanced_done = machine->done;
machine->advanced_at = machine->heard_at | 1;
}
machine->downloading = progress->state != _progress_failed;
machine->downloaded = progress->state == _progress_done;
if (progress->state != _progress_downloading)
if (progress->state != _progress_downloading && !machine->end_said)
{
machine->end_said = TRUE;
error(_error_silent, "map sharing: machine #%ld %s %s", machine_index,
progress->state == _progress_done ? "downloaded" : "could not download", machine->request.level_name);
server_event_map_share(machine_index, machine->request.level_name,
progress->state == _progress_done ? "downloaded" : "failed", machine->done, machine->offered_total);
}
}
File diff suppressed because it is too large. Load diff
+171 -13
View File
@@ -38,13 +38,16 @@ without the chat drops the three kinds as unknown: its players neither send
nor see lines.
The host's settings (network.text_chat: on, links allowed, or off; off in a
game listed in the server browser) go to every client
(_distributed_message_chat_config). A line they refuse is refused by its
sender's machine and again by the host, which tells the sender why; a
game a player hosts that is listed in the server browser) go to every
client (_distributed_message_chat_config). A line they refuse is refused by
its sender's machine and again by the host, which tells the sender why; a
client without them (from a host without the chat) has the chat off. A
client in a public game it joined from the server browser has the chat off
whatever the host says (a changed host could turn it on): it takes no line
from it and sends none.
from it and sends none. A dedicated server is the exception on both sides
(chat_listing_allows): its network.text_chat holds when it is listed, and
a client that joined it from a master server's list (an approved
operator's, by its key, not the listing's own word) follows it.
display.text_chat hides the chat on this machine.
The host may type "/mute <player> [minutes]" (20 when it gives none, a day
@@ -82,6 +85,7 @@ mutes.
#include "network_chat.h"
#include "text_filter.h"
#include "halo_keyboard.h"
#include "server_director.h"
#include <stdio.h>
@@ -95,6 +99,10 @@ unsigned long config_changes(void);
/* p2p_lobby.c's, p2p.c's */
int p2p_hosting_listed(void);
int p2p_joined_listed_host(void);
int p2p_joined_listed_dedicated(void);
/* server_mode.c's: a dedicated server (halo --server) */
int halo_server_mode(void);
void platform_log(char const *format, ...);
/* ---------- constants */
@@ -927,8 +935,21 @@ static boolean chat_host(
return game_connection() == _game_connection_network_server;
}
/* whether a game's text chat may be on: not in a public game (listed in
the server browser), unless its host is a dedicated server, whose own
network.text_chat then decides. On the host, listed is its listing and
dedicated whether it runs halo --server; on a client, listed is the game
joined from the browser and dedicated whether a master server listed it as
a dedicated server's */
static boolean chat_listing_allows(
boolean listed,
boolean dedicated)
{
return !listed || dedicated;
}
/* (the host) its chat settings: network.text_chat's, the chat off in a game
listed in the server browser */
a player hosts that is listed in the server browser */
static byte chat_host_config_flags(
void)
{
@@ -936,7 +957,8 @@ static byte chat_host_config_flags(
byte flags = 0;
/* (in either case: HALO_NET_TEXT_CHAT=OFF is off) */
if (setting && !chat_names_equal(setting, "off") && !p2p_hosting_listed())
if (setting && !chat_names_equal(setting, "off") &&
chat_listing_allows(p2p_hosting_listed() != 0, halo_server_mode() != 0))
{
flags |= (byte)FLAG(_chat_config_allowed_bit);
if (chat_names_equal(setting, "links"))
@@ -946,8 +968,8 @@ static byte chat_host_config_flags(
}
/* (a client) whether its game is a public one it joined from the server
browser (p2p.c): no chat, whatever its host says. A public dedicated
server's listing, once there are those, would say it may have one */
browser (p2p.c): no chat, whatever its host says, unless a master server
lists it as a dedicated server's (chat_listing_allows) */
static boolean chat_joined_listed(
void)
{
@@ -955,13 +977,15 @@ static boolean chat_joined_listed(
}
/* the host's chat settings: the host's own, or as it sent them (none, and
no chat, till it does; none in a public game joined from the browser) */
no chat, till it does; none in a public game a player hosts, joined from
the browser) */
static byte chat_config_flags(
void)
{
if (chat_host())
return chat_host_config_flags();
return chat_config.known && !chat_joined_listed() ? chat_config.flags : 0;
return chat_config.known && chat_listing_allows(chat_joined_listed(), p2p_joined_listed_dedicated() != 0) ?
chat_config.flags : 0;
}
static void chat_send_to_clients(void *message, byte type, word size);
@@ -1021,6 +1045,107 @@ static char const *chat_refused(
return NULL;
}
/* ---------- the server's word filter (server.chat_filter)
A dedicated server may keep words out of its chat and its players' names:
the words of streamer mode's list (text_filter_match.c), by group (slurs,
swearing, insults) and the least severity each keeps out, its own settings'
(server.chat_filter_slurs...). "censor" stars a line's words before the
line goes on; "block" refuses the line (its sender told why) and a machine
whose player's name has them at the door
(network_game_server_accept_client_machine_into_game). Off by default. */
enum
{
_chat_filter_off,
_chat_filter_censor,
_chat_filter_block
};
/* server.chat_filter's mode (in either case; anything else: off) */
static short chat_filter_mode(
char const *setting)
{
if (setting && chat_names_equal(setting, "censor"))
return _chat_filter_censor;
if (setting && chat_names_equal(setting, "block"))
return _chat_filter_block;
return _chat_filter_off;
}
/* a group's level: the least severity kept out ("mild" keeps out every
term of the group; anything else: off) */
static unsigned char chat_filter_level(
char const *setting)
{
if (setting && chat_names_equal(setting, "mild"))
return _text_filter_mild;
if (setting && chat_names_equal(setting, "strong"))
return _text_filter_strong;
if (setting && chat_names_equal(setting, "severe"))
return _text_filter_severe;
return _text_filter_off;
}
/* a line's marked characters as stars, in place */
static void chat_filter_star(
char *text,
unsigned char const *marked,
long length)
{
long index;
for (index = 0; index < length && text[index]; index++)
{
if (marked[index] && text[index] != ' ')
text[index] = '*';
}
}
/* (the host) the filter's mode, off but on a dedicated server */
static short chat_filter_mode_now(
void)
{
return halo_server_mode() ? chat_filter_mode(config_string("server.chat_filter")) : _chat_filter_off;
}
/* which characters of an ASCII text are the filter's words, marked in
marked (as long as the text); how many */
static long chat_filter_marks(
char const *text,
unsigned char *marked)
{
unsigned short units[CHAT_TEXT_SIZE];
unsigned char levels[NUMBER_OF_TEXT_FILTER_GROUPS];
long length;
for (length = 0; length < CHAT_TEXT_SIZE && text[length]; length++)
units[length] = (unsigned char)text[length];
if (!length)
return 0;
levels[_text_filter_slurs] = chat_filter_level(config_string("server.chat_filter_slurs"));
levels[_text_filter_swearing] = chat_filter_level(config_string("server.chat_filter_swearing"));
levels[_text_filter_insults] = chat_filter_level(config_string("server.chat_filter_insults"));
levels[_text_filter_own] = _text_filter_off;
return text_filter_censor(units, length, TRUE, levels, marked);
}
boolean network_chat_name_blocked(
wchar_t const *name,
short length)
{
char text[CHAT_TEXT_SIZE];
unsigned char marked[CHAT_TEXT_SIZE];
short index;
if (chat_filter_mode_now() != _chat_filter_block)
return FALSE;
for (index = 0; index < length && index < CHAT_TEXT_SIZE - 1 && name[index]; index++)
text[index] = name[index] >= ' ' && name[index] <= '~' ? (char)name[index] : ' ';
text[index] = 0;
return chat_filter_marks(text, marked) > 0;
}
/* the characters of a player's name a line keeps (at most CHAT_NAME_SIZE - 1) */
static short chat_player_name_length(
struct player_datum const *player)
@@ -1220,6 +1345,7 @@ static void chat_host_send(
if (!sender || sender->quit_out_of_game)
return;
sender_vehicle = chat_player_vehicle(sender);
server_event_chat(player_index, channel, text);
chat_message_new(&message, player_index, chat_channel_flags(channel), text);
for (index = 0; index < count; index++)
{
@@ -1400,6 +1526,18 @@ void network_chat_host_mute(
chat_host_unmute_name(name);
}
boolean network_chat_host_mute_named(
char const *name,
short minutes,
boolean mute)
{
if (!chat_host() || !name || !name[0])
return FALSE;
if (!mute)
return chat_host_unmute_name(name);
return chat_host_mute_name(name, minutes > 0 ? minutes : CHAT_MUTE_DEFAULT_MINUTES);
}
boolean network_chat_host_name_muted(
wchar_t const *name)
{
@@ -1456,6 +1594,24 @@ void network_chat_handle_up(
chat_host_notice(machine_index, refused);
return;
}
/* (nor, on a dedicated server, one with words its filter keeps out:
refused, or its words starred) */
{
short mode = chat_filter_mode_now();
unsigned char marked[CHAT_TEXT_SIZE];
if (mode != _chat_filter_off && chat_filter_marks(text, marked) > 0)
{
if (mode == _chat_filter_block)
{
platform_log("chat filter: a line of %s not sent (blocked words)", name);
chat_host_notice(machine_index, "Your line was not sent: it has words this server does not allow");
return;
}
chat_filter_star(text, marked, CHAT_TEXT_SIZE);
platform_log("chat filter: a line of %s sent with its blocked words starred", name);
}
}
chat_host_send(line.player_index, chat_channel_from_flags(line.flags), text);
}
@@ -1474,9 +1630,11 @@ void network_chat_handle_down(
struct distributed_chat_line line;
char text[CHAT_TEXT_SIZE];
/* (none in a public game joined from the server browser, the host's
notices too: its chat is off, whatever the host says) */
if (game_connection() != _game_connection_network_client || !from_stream || chat_joined_listed())
/* (none in a public game a player hosts, joined from the server
browser, the host's notices too: its chat is off, whatever the host
says; a master's dedicated server's are taken) */
if (game_connection() != _game_connection_network_client || !from_stream ||
!chat_listing_allows(chat_joined_listed(), p2p_joined_listed_dedicated() != 0))
return;
csmemcpy(&line, entries, sizeof(line));
if (!chat_text_clean(text, line.text, CHAT_TEXT_SIZE))
+8
View File
@@ -26,6 +26,9 @@ enum
/* every frame (main.c): the keys that start typing a line, and those typed */
void network_chat_update(void);
/* (a dedicated server) whether a player's name has words its word filter
keeps out at the door (server.chat_filter "block") */
boolean network_chat_name_blocked(wchar_t const *name, short length);
/* the first local player says a line to a channel, as if typed and sent;
the automated tests' (network_test.c) */
void network_chat_say(short channel, char const *text);
@@ -50,6 +53,11 @@ short network_chat_host_mute_minutes(short player_index);
their lines and their voice, for /mute's minutes when it gives none (every
machine told), or unmuted */
void network_chat_host_mute(short player_index, boolean mute);
/* (the host: a dedicated server's mute and unmute commands,
server_commands.c) a player's name (ASCII, 11 characters) muted for everyone
for minutes (0: /mute's), every machine told, or unmuted: FALSE if it could
not be */
boolean network_chat_host_mute_named(char const *name, short minutes, boolean mute);
/* (the host: network_voice.c) whether a player's name (the network game's,
in the lobby too; at most 12 characters, ended or not) is muted for everyone:
their machine's voice goes no further */
+245 -52
View File
@@ -67,14 +67,6 @@ being hit looks and feels like on the clients).
#include "scenario/scenario.h"
#include "units/units.h"
#include "network_distributed.h"
#ifdef HALO_ANDROID
#include "halo_vr.h"
/* port (Mjolnir VR): how far from the unit's camera a VR player's weapon
hand may be for the line of sight (world units, 1.2 m: vr.lean_limit's
0.35 m and an arm) */
#define VR_SIGHT_HAND_REACH 0.4f
#endif
#include <math.h>
@@ -164,8 +156,30 @@ enum
REPORT_LINE_OF_SIGHT_POINTS = REPORT_LINE_OF_SIGHT_TICKS / PLAYER_TRAIL_INTERVAL_TICKS,
MAXIMUM_STUCK_PROJECTILES = 16,
STUCK_PROJECTILE_TICKS = REPORT_MAXIMUM_AGE_TICKS + 2 * TICKS_PER_SECOND,
/* how long a player's fire origin holds after the last came (a network
that delivers in bursts) */
FIRE_ORIGIN_FRESH_TICKS = TICKS_PER_SECOND,
};
/* a fire origin's offset from the unit's camera: in 1/4096 of a world unit,
no further from it than a player leaning out and an arm reach (1.2 m) */
#define FIRE_ORIGIN_SCALE 4096.0f
#define FIRE_ORIGIN_REACH 0.4f
/* ... and, for the line of sight, where a hand that fires at the target can
be: no more than this behind the eye along the line to the target (0.15 m),
this to its side (0.6 m: a lean and a hand held out), and this above the eye
(0.3 m), so that a client cannot claim a hand far past a corner to shoot
around it unseen */
#define FIRE_ORIGIN_BEHIND 0.05f
#define FIRE_ORIGIN_SIDEWAYS 0.2f
#define FIRE_ORIGIN_ABOVE 0.1f
/* how often a client sends an unchanged fire origin (ticks): well within
FIRE_ORIGIN_FRESH_TICKS, over a lost one or two */
#define FIRE_ORIGIN_RESEND_TICKS 8
int config_boolean(char const *name);
unsigned long config_changes(void);
enum
{
_damage_event_player_effect,
@@ -343,6 +357,23 @@ typedef char distributed_hit_report_size_assert[sizeof(struct distributed_hit_re
typedef char distributed_hit_report_host_time_offset_assert[
offsetof(struct distributed_hit_report, host_time) == 0x68 ? 1 : -1];
/* where a client's player's shots leave from, from their unit's camera
(_distributed_message_fire_origins) */
struct distributed_fire_origin
{
byte player_index;
byte pad;
struct distributed_vector offset;
};
typedef char distributed_fire_origin_size_assert[sizeof(struct distributed_fire_origin) == 8 ? 1 : -1];
struct distributed_fire_origin_message
{
struct distributed_message_header header;
struct distributed_fire_origin origins[MAXIMUM_LOCAL_PLAYERS];
};
struct distributed_damage_event_message
{
struct distributed_message_header header;
@@ -448,8 +479,27 @@ static struct damage_trail_point
real radius;
real eye;
boolean mounted;
/* (the muzzle the player's machine said: a hand, not a vehicle's gun) */
boolean hand;
real_point3d muzzle;
} damage_trails[MAXIMUM_TRACKED_PLAYERS][PLAYER_TRAIL_POINTS];
/* ... and where each player's shots leave from when their machine says it
is not their unit's camera (NONE for no time: from the camera) */
static struct damage_fire_origin
{
long time;
real_vector3d offset;
} damage_fire_origins[MAXIMUM_TRACKED_PLAYERS];
/* a client: where its players' shots leave from (NULL: their cameras), and
what it last sent of each local player's, when */
static network_damage_fire_origin_source damage_fire_origin_source;
static struct
{
long time;
struct distributed_vector offset;
} damage_fire_origins_sent[MAXIMUM_LOCAL_PLAYERS];
/* the host: the clients' hits only a hand's line of sight let through */
static long damage_hand_reports;
/* ... what each weapon or grenade deals, as the tags have it (their walk
is long): the hits a second of a damage effect from it, how it deals it
(the _damage_source flags), and how far from its firer */
@@ -489,10 +539,20 @@ static long damage_replaying_killer_score;
word network_damage_entry_size(
byte type)
{
if (type == _distributed_message_fire_origins)
return sizeof(struct distributed_fire_origin);
return type == _distributed_message_damage_events ?
sizeof(struct distributed_damage_event) : sizeof(struct distributed_hit_report);
}
/* (FALSE: no further from the camera than a player reaches) */
static boolean distributed_fire_origin_within_reach(
real_vector3d const *offset)
{
return offset->i * offset->i + offset->j * offset->j + offset->k * offset->k <=
FIRE_ORIGIN_REACH * FIRE_ORIGIN_REACH;
}
static void distributed_damage_from_data(
struct damage_data const *damage,
struct distributed_damage *result)
@@ -1496,6 +1556,7 @@ static void distributed_note_targets(
unit_get_camera_position(unit_index, &camera);
point->eye = camera.z - point->position.z;
point->mounted = FALSE;
point->hand = FALSE;
if (tick->objects[player_index][1].object_index != NONE)
{
long vehicle_index = tick->objects[player_index][1].object_index;
@@ -1510,33 +1571,19 @@ static void distributed_note_targets(
point->mounted = distributed_vehicle_muzzle(vehicle_index, &point->muzzle);
}
}
#ifdef HALO_ANDROID
/* port (Mjolnir VR): a VR player of another machine fires
from the weapon in their hand (network_vr.c), which may
be past a corner the unit's camera is behind: the hand is
a muzzle as a vehicle's gun is, with the line from the
eye to it clear (no hand through a wall), and no further
from the eye than a lean and an arm reach */
else
/* on foot, shots that leave from elsewhere than the
camera (a VR player's hand, which may be past a corner
the camera is behind): from there as from a vehicle's
gun, the line from the eye to it clear */
else if (damage_fire_origins[player_index].time != NONE &&
game_time_get() >= damage_fire_origins[player_index].time &&
game_time_get() - damage_fire_origins[player_index].time <= FIRE_ORIGIN_FRESH_TICKS)
{
real vr_origin[3];
if (halo_vr_network_weapon_origin(player_index, unit_index, vr_origin))
{
real dx = vr_origin[0] - camera.x;
real dy = vr_origin[1] - camera.y;
real dz = vr_origin[2] - camera.z;
if (dx * dx + dy * dy + dz * dz <= VR_SIGHT_HAND_REACH * VR_SIGHT_HAND_REACH)
{
point->muzzle.x = vr_origin[0];
point->muzzle.y = vr_origin[1];
point->muzzle.z = vr_origin[2];
point->mounted = TRUE;
}
}
point_from_line3d(&camera, &damage_fire_origins[player_index].offset, 1.0f,
&point->muzzle);
point->mounted = TRUE;
point->hand = TRUE;
}
#endif
}
}
}
@@ -2016,6 +2063,47 @@ static boolean distributed_report_sight_clear(
!collision_test_line(flags, impact, centre, NONE, &collision));
}
/* the host's network.fire_origins: whether a hand its client says a player
fires from may give the line of sight (read again when a setting changes) */
static boolean distributed_fire_origins_accepted(
void)
{
static unsigned long read_at = (unsigned long)-1;
static boolean accepted;
if (read_at != config_changes())
{
read_at = config_changes();
accepted = config_boolean("network.fire_origins") != 0;
}
return accepted;
}
/* whether a hand is where one that fires at the target can be, from the
eye: not behind it along the line to the target, nor far to its side or
above it (FIRE_ORIGIN_BEHIND, _SIDEWAYS, _ABOVE) */
static boolean distributed_fire_origin_shape(
real_point3d const *eye,
real_point3d const *hand,
real_point3d const *target)
{
real_vector3d offset, line;
real length, along, sideways_squared;
offset.i = hand->x - eye->x;
offset.j = hand->y - eye->y;
offset.k = hand->z - eye->z;
line.i = target->x - eye->x;
line.j = target->y - eye->y;
line.k = target->z - eye->z;
length = (real)sqrt(line.i * line.i + line.j * line.j + line.k * line.k);
if (length <= 0.0f || offset.k > FIRE_ORIGIN_ABOVE)
return FALSE;
along = (offset.i * line.i + offset.j * line.j + offset.k * line.k) / length;
sideways_squared = offset.i * offset.i + offset.j * offset.j + offset.k * offset.k - along * along;
return along >= -FIRE_ORIGIN_BEHIND && sideways_squared <= FIRE_ORIGIN_SIDEWAYS * FIRE_ORIGIN_SIDEWAYS;
}
/* whether the structure left the shooter a line to the target from the
newest of their living trail points in the window (none: not alive to fire
it, refused). The eye and the target position are matched over windows, not
@@ -2039,7 +2127,8 @@ static boolean distributed_report_line_of_sight(
long newest = game_time_get() / PLAYER_TRAIL_INTERVAL_TICKS;
long earliest;
long bucket;
short traced = 0;
short traced;
short pass;
real_point3d impact = *impact_point;
struct collision_result collision;
@@ -2058,23 +2147,36 @@ static boolean distributed_report_line_of_sight(
if (host_time < latest_made)
host_time = latest_made;
earliest = host_time - (long)ticks - TARGET_HISTORY_SLACK_TICKS - PLAYER_TRAIL_INTERVAL_TICKS;
for (bucket = newest; bucket >= 0 && newest - bucket < PLAYER_TRAIL_POINTS &&
(bucket + 1) * PLAYER_TRAIL_INTERVAL_TICKS > earliest && traced < REPORT_LINE_OF_SIGHT_POINTS; bucket--)
/* (first from the eyes and the vehicles' guns, the host's own; then,
only if none saw it, from the hands the player's machine said, counted) */
for (pass = 0; pass < 2; pass++)
{
struct damage_trail_point const *trail_point = &damage_trails[player_index][bucket & (PLAYER_TRAIL_POINTS - 1)];
real_point3d eye;
if (trail_point->time == NONE || trail_point->time / PLAYER_TRAIL_INTERVAL_TICKS != bucket)
continue;
traced++;
eye = trail_point->position;
eye.z += trail_point->eye;
if (distributed_report_sight_clear(flags, &eye, &impact, centre))
return TRUE;
if (trail_point->mounted && !collision_test_line(flags, &eye, &trail_point->muzzle, NONE, &collision) &&
distributed_report_sight_clear(flags, &trail_point->muzzle, &impact, centre))
if (pass == 1 && !distributed_fire_origins_accepted())
break;
traced = 0;
for (bucket = newest; bucket >= 0 && newest - bucket < PLAYER_TRAIL_POINTS &&
(bucket + 1) * PLAYER_TRAIL_INTERVAL_TICKS > earliest && traced < REPORT_LINE_OF_SIGHT_POINTS; bucket--)
{
return TRUE;
struct damage_trail_point const *trail_point =
&damage_trails[player_index][bucket & (PLAYER_TRAIL_POINTS - 1)];
real_point3d eye;
if (trail_point->time == NONE || trail_point->time / PLAYER_TRAIL_INTERVAL_TICKS != bucket)
continue;
traced++;
eye = trail_point->position;
eye.z += trail_point->eye;
if (pass == 0 && distributed_report_sight_clear(flags, &eye, &impact, centre))
return TRUE;
if (trail_point->mounted && trail_point->hand == (pass == 1) &&
(pass == 0 || distributed_fire_origin_shape(&eye, &trail_point->muzzle, centre)) &&
!collision_test_line(flags, &eye, &trail_point->muzzle, NONE, &collision) &&
distributed_report_sight_clear(flags, &trail_point->muzzle, &impact, centre))
{
if (pass == 1)
damage_hand_reports++;
return TRUE;
}
}
}
return FALSE;
@@ -2488,14 +2590,20 @@ static void distributed_log_reports(
{
static long logged_dealt;
static long logged_rejected;
static long logged_hand;
if (game_time_get() % REPORT_LOG_TICKS != 0 || damage_rejected_reports == logged_rejected)
if (game_time_get() % REPORT_LOG_TICKS != 0 ||
(damage_rejected_reports == logged_rejected && damage_hand_reports == logged_hand))
{
return;
error(_error_silent, "damage: clients' hits in the last %d seconds: %ld dealt, %ld refused",
}
error(_error_silent, "damage: clients' hits in the last %d seconds: %ld dealt, %ld refused, "
"%ld seen only from a hand its client said (network.fire_origins)",
REPORT_LOG_TICKS / TICKS_PER_SECOND, damage_dealt_reports - logged_dealt,
damage_rejected_reports - logged_rejected);
damage_rejected_reports - logged_rejected, damage_hand_reports - logged_hand);
logged_dealt = damage_dealt_reports;
logged_rejected = damage_rejected_reports;
logged_hand = damage_hand_reports;
}
/* the tick's killing blows once more, reliably, to every client: sent with
@@ -2682,6 +2790,86 @@ void network_damage_handle_events(
}
}
/* the host: where a client's players' shots leave from (checked: its own
players', no further than they reach) */
void network_damage_handle_fire_origins(
long machine_index,
void const *entries,
short count)
{
short index;
for (index = 0; index < count; index++)
{
struct distributed_fire_origin origin;
real_vector3d offset;
csmemcpy(&origin, (byte const *)entries + index * sizeof(origin), sizeof(origin));
if (origin.player_index >= MAXIMUM_TRACKED_PLAYERS || !distributed_player(origin.player_index) ||
!distributed_machine_has_player(machine_index, origin.player_index))
{
continue;
}
distributed_vector_unpack(&origin.offset, FIRE_ORIGIN_SCALE, &offset);
if (!distributed_fire_origin_within_reach(&offset))
continue;
damage_fire_origins[origin.player_index].offset = offset;
damage_fire_origins[origin.player_index].time = game_time_get();
}
}
void network_damage_set_fire_origin_source(
network_damage_fire_origin_source source)
{
damage_fire_origin_source = source;
}
/* a client: where its players' shots leave from, when not from their
cameras, to the host every tick (unreliably: a lost one is followed by the
next) */
static void distributed_send_fire_origins(
void)
{
struct distributed_fire_origin_message message;
short local_player_index;
short count = 0;
if (!damage_fire_origin_source)
return;
for (local_player_index = 0; local_player_index < MAXIMUM_LOCAL_PLAYERS; local_player_index++)
{
long player_index = local_player_get_player_index(local_player_index);
real_vector3d offset;
if (player_index == NONE || !damage_fire_origin_source(local_player_index, &offset) ||
!distributed_fire_origin_within_reach(&offset))
{
damage_fire_origins_sent[local_player_index].time = NONE;
continue;
}
csmemset(&message.origins[count], 0, sizeof(message.origins[count]));
message.origins[count].player_index = distributed_player_to_byte(player_index);
distributed_vector_pack(&offset, FIRE_ORIGIN_SCALE, &message.origins[count].offset);
/* (when it changes, else every few ticks) */
if (damage_fire_origins_sent[local_player_index].time != NONE &&
game_time_get() >= damage_fire_origins_sent[local_player_index].time &&
game_time_get() - damage_fire_origins_sent[local_player_index].time < FIRE_ORIGIN_RESEND_TICKS &&
!csmemcmp(&damage_fire_origins_sent[local_player_index].offset, &message.origins[count].offset,
sizeof(struct distributed_vector)))
{
continue;
}
damage_fire_origins_sent[local_player_index].time = game_time_get();
damage_fire_origins_sent[local_player_index].offset = message.origins[count].offset;
count++;
}
if (count)
{
distributed_send(&message, _distributed_message_fire_origins, count,
(word)(sizeof(message.header) + count * sizeof(struct distributed_fire_origin)), _distributed_to_host);
}
}
void network_damage_client_tick(
void)
{
@@ -2690,6 +2878,8 @@ void network_damage_client_tick(
short index;
short count = 0;
distributed_send_fire_origins();
damage_sent_reports += damage_report_count;
for (index = 0; index < damage_report_count; index++)
{
@@ -2787,7 +2977,10 @@ void network_damage_new_game(
damage_explosions[player_index].count = 0;
for (index = 0; index < PLAYER_TRAIL_POINTS; index++)
damage_trails[player_index][index].time = NONE;
damage_fire_origins[player_index].time = NONE;
}
for (player_index = 0; player_index < MAXIMUM_LOCAL_PLAYERS; player_index++)
damage_fire_origins_sent[player_index].time = NONE;
for (player_index = 0; player_index < DAMAGE_RATE_CACHE_SIZE; player_index++)
damage_rate_cache[player_index].source_index = NONE;
}
+237 -21
View File
@@ -82,6 +82,7 @@ machine (their datum identifiers need not be).
#include "map_share.h"
#include "latency_meter.h"
#include "profile_sections.h"
#include "server_director.h"
#include <limits.h>
#include <stdio.h>
@@ -116,6 +117,10 @@ unsigned long p2p_peer_endpoint_address(unsigned long virtual_address);
boolean network_connection_last_read_was_unreliable(void);
unsigned long system_milliseconds(void);
void platform_log(char const *format, ...);
/* main.c's and port_mods.c's: this build, and the mods it plays */
void main_build_identity(long *number, char *flavor, size_t flavor_size, char *repository, size_t repository_size,
char *version, size_t version_size);
int port_mods_list(char *text, unsigned long size);
void console_warning(const char *format, ...);
/* the host: a client's game run faster than its clock (a speed hack, which
@@ -677,6 +682,11 @@ kept only of what is allowed (p2p_discord_sanitize) */
static struct distributed_client_identity distributed_client_identities[HALO_PORT_MAXIMUM_NETWORK_MACHINES];
/* a client: its Discord user as last sent this game, and whether it was */
static struct distributed_client_identity distributed_sent_identity;
/* the host: each client's build, as it told it, and whether it did; a
client: whether it sent its own this game */
static struct distributed_client_build distributed_client_builds[HALO_PORT_MAXIMUM_NETWORK_MACHINES];
static boolean distributed_client_build_known[HALO_PORT_MAXIMUM_NETWORK_MACHINES];
static boolean distributed_build_sent;
static boolean distributed_identity_sent;
static struct distributed_client_clock
{
@@ -1400,6 +1410,8 @@ static boolean distributed_machine_loaded(
csmemset(&distributed_client_clocks[machine_index], 0, sizeof(distributed_client_clocks[machine_index]));
distributed_client_clocks[machine_index].window_tick = NONE;
csmemset(&distributed_client_identities[machine_index], 0, sizeof(distributed_client_identities[machine_index]));
csmemset(&distributed_client_builds[machine_index], 0, sizeof(distributed_client_builds[machine_index]));
distributed_client_build_known[machine_index] = FALSE;
for (player_index = 0; player_index < MAXIMUM_TRACKED_PLAYERS; player_index++)
distributed_seen[machine_index][player_index] = FALSE;
return TRUE;
@@ -3367,6 +3379,7 @@ void network_distributed_player_killed(
death->killed_by_vehicle = *killing_player_index == NONE && killing_object &&
killing_object->object.type == _object_type_vehicle;
distributed_statistics_due = TRUE;
server_event_player_killed(*killing_player_index, *killing_object_index, dead_player_index, *friendly_fire);
}
else if (game_connection() == _game_connection_network_client && death->valid)
{
@@ -3735,6 +3748,9 @@ void network_distributed_new_game(
csmemset(distributed_client_clocks, 0, sizeof(distributed_client_clocks));
csmemset(distributed_client_identities, 0, sizeof(distributed_client_identities));
distributed_identity_sent = FALSE;
csmemset(distributed_client_builds, 0, sizeof(distributed_client_builds));
csmemset(distributed_client_build_known, 0, sizeof(distributed_client_build_known));
distributed_build_sent = FALSE;
{
short machine_index;
@@ -4112,6 +4128,74 @@ void distributed_client_send_identity(
_distributed_to_host_reliably);
}
/* keeps of text what a build's field may hold: printable ASCII, and line
ends where lines says; ended, and its trailing spaces and line ends left
out (another machine's text is untrusted) */
static void distributed_build_text(
char *destination,
size_t size,
char const *source,
boolean lines)
{
size_t length = 0;
size_t index;
for (index = 0; source[index] && length < size - 1; index++)
{
char character = source[index];
if ((character >= ' ' && character <= '~') || (lines && character == '\n'))
destination[length++] = character;
}
while (length > 0 && (destination[length - 1] == ' ' || destination[length - 1] == '\n'))
length--;
destination[length] = 0;
}
/* (a client) its build, to the host: once a machine the host takes
messages of (its ready sent), once a game */
void distributed_client_send_build(
void)
{
struct
{
struct distributed_message_header header;
struct distributed_client_build build;
} message;
char flavor[DISTRIBUTED_CLIENT_BUILD_FLAVOR_SIZE];
char repository[DISTRIBUTED_CLIENT_BUILD_REPOSITORY_SIZE];
char version[DISTRIBUTED_CLIENT_BUILD_VERSION_SIZE];
char mods[DISTRIBUTED_CLIENT_BUILD_MODS_SIZE];
long number;
if (distributed_build_sent)
return;
csmemset(&message, 0, sizeof(message));
main_build_identity(&number, flavor, sizeof(flavor), repository, sizeof(repository), version, sizeof(version));
port_mods_list(mods, sizeof(mods));
snprintf(message.build.number, sizeof(message.build.number), "%ld", number);
distributed_build_text(message.build.flavor, sizeof(message.build.flavor), flavor, FALSE);
distributed_build_text(message.build.repository, sizeof(message.build.repository), repository, FALSE);
distributed_build_text(message.build.version, sizeof(message.build.version), version, FALSE);
distributed_build_text(message.build.mods, sizeof(message.build.mods), mods, TRUE);
distributed_build_sent = TRUE;
distributed_send(&message, _distributed_message_client_build, 1, (word)sizeof(message),
_distributed_to_host_reliably);
}
boolean distributed_machine_client_build(
long machine_index,
struct distributed_client_build *build)
{
if (machine_index < 0 || machine_index >= HALO_PORT_MAXIMUM_NETWORK_MACHINES ||
!distributed_client_build_known[machine_index])
{
return FALSE;
}
*build = distributed_client_builds[machine_index];
return TRUE;
}
/* (the host) a client machine's address (host byte order) as it really is:
an internet play peer's endpoint for its stand-in in 100.64.0.0/10 (p2p.c,
which numbers the stand-in from the peer's identifier: a peer makes a new
@@ -4187,23 +4271,27 @@ their address, Discord user and names, and why; separated by tabs, each
part kept to the characters allowed and their lengths (what a player could
tell: their Discord user and names). The Discord user of the machine at
the index, if it is one in the game (NONE: none) */
static boolean distributed_write_record(
char const *file_name,
char const *address,
char const *hardware_id,
char const *discord_id,
char const *discord_name,
char const *names,
char const *reason,
unsigned long until);
static void distributed_write_player_record(
char const *file_name,
char const *address,
long machine_index,
char const *names,
char const *reason)
char const *reason,
unsigned long until)
{
char discord_id[DISCORD_ID_SIZE] = "";
char discord_name[DISCORD_NAME_SIZE] = "";
char hardware_id[40] = "";
char kept_names[96];
char kept_reason[96];
char kept_address[32];
char when[32] = "";
time_t now = time(NULL);
struct tm *local = localtime(&now);
FILE *file;
if (machine_index >= 0 && machine_index < HALO_PORT_MAXIMUM_NETWORK_MACHINES && game_in_progress())
{
@@ -4213,6 +4301,31 @@ static void distributed_write_player_record(
}
if (machine_index >= 0 && machine_index < HALO_PORT_MAXIMUM_NETWORK_MACHINES)
p2p_hardware_id_sanitize(hardware_id, 33, network_game_server_machine_hardware_id(machine_index));
distributed_write_record(file_name, address, hardware_id, discord_id, discord_name, names, reason, until);
}
/* (the host) the line itself: its parts as distributed_write_player_record
says (a hardware id, a Discord id or name empty for none); TRUE if it was
written */
static boolean distributed_write_record(
char const *file_name,
char const *address,
char const *hardware_id,
char const *discord_id,
char const *discord_name,
char const *names,
char const *reason,
unsigned long until)
{
char kept_names[96];
char kept_reason[96];
char kept_address[32];
char when[32] = "";
time_t now = time(NULL);
struct tm *local = localtime(&now);
FILE *file;
boolean written;
distributed_printable(kept_names, sizeof(kept_names), names);
distributed_printable(kept_reason, sizeof(kept_reason), reason);
distributed_printable(kept_address, sizeof(kept_address), address);
@@ -4222,16 +4335,23 @@ static void distributed_write_player_record(
if (!file)
{
error(_error_log, "could not open %s to add a player to it", file_name);
return;
return FALSE;
}
/* (the Discord user as the machine told it, which it may say is anyone's:
marked so) */
fprintf(file, "%s\tip=%s\thwid=%s\tdiscord_username=%s%s\tdiscord_id=%s%s\tplayers=%s\treason=%s\n", when,
fprintf(file, "%s\tip=%s\thwid=%s\tdiscord_username=%s%s\tdiscord_id=%s%s\tplayers=%s\treason=%s", when,
kept_address, hardware_id[0] ? hardware_id : "none",
discord_name[0] ? discord_name : "none", discord_name[0] ? " (self-reported)" : "",
discord_id[0] ? discord_id : "none", discord_id[0] ? " (self-reported)" : "",
kept_names, kept_reason);
fclose(file);
/* (a ban for a while: its end, in seconds since 1970; a line without one
is for ever) */
if (until)
fprintf(file, "\tuntil=%lu", until);
fputc('\n', file);
written = !ferror(file);
written = fclose(file) == 0 && written;
return written;
}
/* (the host) a player dropped for cheating: in CHEATERS_FILE, and banned
@@ -4244,8 +4364,42 @@ static void distributed_log_cheater(
char address[32];
distributed_machine_address_text(machine_index, address, sizeof(address));
distributed_write_player_record(CHEATERS_FILE, address, machine_index, names, reason);
distributed_write_player_record(BANS_FILE, address, machine_index, names, reason);
distributed_write_player_record(CHEATERS_FILE, address, machine_index, names, reason, 0);
distributed_write_player_record(BANS_FILE, address, machine_index, names, reason, 0);
}
/* (the host: a dedicated server's ban command, server_commands.c) the next
ban's reason and end (seconds since 1970; 0: for ever), for its line */
static char distributed_next_ban_reason[96];
static unsigned long distributed_next_ban_until;
/* (sv_ban_address's: the next ban's line written already) */
static boolean distributed_next_ban_unrecorded;
void network_distributed_ban_details(
char const *reason,
unsigned long until)
{
snprintf(distributed_next_ban_reason, sizeof(distributed_next_ban_reason), "%s", reason ? reason : "");
distributed_next_ban_until = until;
}
void network_distributed_ban_unrecorded(
boolean unrecorded)
{
distributed_next_ban_unrecorded = unrecorded;
}
boolean network_distributed_ban_record(
char const *address,
char const *hardware_id,
char const *names,
char const *reason,
unsigned long until)
{
char kept_hardware_id[40];
p2p_hardware_id_sanitize(kept_hardware_id, 33, hardware_id);
return distributed_write_record(BANS_FILE, address, kept_hardware_id, "", "", names, reason, until);
}
/* (the host: network_server_manager.c) whether a machine of this address
@@ -4261,21 +4415,28 @@ boolean network_distributed_banned(
char kept_hardware_id[40];
FILE *file;
boolean banned = FALSE;
size_t length, hardware_id_length;
p2p_hardware_id_sanitize(kept_hardware_id, 33, hardware_id);
if (address)
distributed_address_text(address, text, sizeof(text));
else
text[0] = 0;
length = csstrlen(text);
hardware_id_length = csstrlen(kept_hardware_id);
file = fopen(BANS_FILE, "r");
if (!file)
return FALSE;
while (!banned && fgets(line, sizeof(line), file))
{
char const *ip = strstr(line, "ip=");
char const *ip = strstr(line, "\tip=");
char const *hwid = strstr(line, "hwid=");
size_t length = csstrlen(text);
size_t hardware_id_length = csstrlen(kept_hardware_id);
char const *until = strstr(line, "\tuntil=");
/* (a ban for a while that has ended) */
if (until && strtoul(until + 7, NULL, 10) <= (unsigned long)time(NULL))
continue;
ip = ip ? ip + 1 : strstr(line, "ip=");
/* (the whole address: 1.2.3.4 is not 1.2.3.45) */
if (length && ip && !strncmp(ip + 3, text, length) &&
@@ -4300,8 +4461,9 @@ boolean network_distributed_banned(
/* (the host: network_server_manager.c, its ban command) a machine banned
by the host: its line in BANS_FILE, and every machine told (the Discord
user of the machine at the index, if it is one in the game; its address,
host byte order) */
void network_distributed_ban(
host byte order). The ban's end (network_distributed_ban_details: 0, for
ever) */
unsigned long network_distributed_ban(
long machine_index,
unsigned long address,
char const *names)
@@ -4312,9 +4474,17 @@ void network_distributed_ban(
char discord_name[DISCORD_NAME_SIZE] = "";
char discord[DISCORD_ID_SIZE + DISCORD_NAME_SIZE + 16] = "";
char notice[MAXIMUM_NOTICE_LENGTH];
unsigned long until = distributed_next_ban_until;
distributed_address_text(address, text, sizeof(text));
distributed_write_player_record(BANS_FILE, text, machine_index, names, "banned by the host");
if (!distributed_next_ban_unrecorded)
{
distributed_write_player_record(BANS_FILE, text, machine_index, names,
distributed_next_ban_reason[0] ? distributed_next_ban_reason : "banned by the host", until);
}
distributed_next_ban_reason[0] = 0;
distributed_next_ban_until = 0;
distributed_next_ban_unrecorded = FALSE;
distributed_printable(kept_names, sizeof(kept_names), names);
if (game_in_progress() && machine_index >= 0 && machine_index < HALO_PORT_MAXIMUM_NETWORK_MACHINES)
{
@@ -4333,6 +4503,7 @@ void network_distributed_ban(
console_warning("%s", notice);
error(_error_log, "%s", notice);
}
return until;
}
/* (the host: network_server_manager.c, its kick command) players kicked by
@@ -4427,6 +4598,7 @@ static void distributed_note_client_clock(
names, discord, rate);
distributed_send_notice(text);
distributed_log_cheater(machine_index, names, reason);
server_event_speed_hack(machine_index, names, rate, ahead, "kicked");
}
network_game_server_kick_machine(machine_index, TRUE);
clock->fast_windows = 0;
@@ -4453,7 +4625,8 @@ static void distributed_note_client_clock(
snprintf(reason, sizeof(reason), "unverified speed hack (its datagrams only, not dropped: "
"game ran %.2f times as fast)", rate);
distributed_machine_address_text(machine_index, address, sizeof(address));
distributed_write_player_record(CHEATERS_FILE, address, machine_index, names, reason);
distributed_write_player_record(CHEATERS_FILE, address, machine_index, names, reason, 0);
server_event_speed_hack(machine_index, names, rate, ahead, "unverified");
clock->logged_unverified = TRUE;
}
}
@@ -4582,8 +4755,10 @@ void network_distributed_handle_message(
case _distributed_message_client_ready: entry_size = 0; break;
case _distributed_message_client_identity: entry_size = sizeof(struct distributed_client_identity); break;
case _distributed_message_vr_poses: entry_size = network_vr_entry_size(); break;
case _distributed_message_client_build: entry_size = sizeof(struct distributed_client_build); break;
case _distributed_message_damage_events:
case _distributed_message_hit_reports: entry_size = network_damage_entry_size(header.type); break;
case _distributed_message_hit_reports:
case _distributed_message_fire_origins: entry_size = network_damage_entry_size(header.type); break;
default: entry_size = network_objects_entry_size(header.type); break;
}
if (header.type == 0 || header.type >= NUMBER_OF_DISTRIBUTED_MESSAGES ||
@@ -4615,6 +4790,7 @@ void network_distributed_handle_message(
anyone can send as from it, they are not its) */
if ((header.type == _distributed_message_client_ready ||
header.type == _distributed_message_client_identity ||
header.type == _distributed_message_client_build ||
header.type == _distributed_message_hit_reports) &&
!distributed_handling_stream_message)
{
@@ -4638,12 +4814,14 @@ void network_distributed_handle_message(
case _distributed_message_player_prediction:
case _distributed_message_client_ready:
case _distributed_message_client_identity:
case _distributed_message_client_build:
case _distributed_message_hit_reports:
case _distributed_message_vehicle_prediction:
case _distributed_message_player_inputs:
case _distributed_message_coop_skip_vote:
case _distributed_message_votekick:
case _distributed_message_chat_up:
case _distributed_message_fire_origins:
if (machine_index == NONE || game_connection() != _game_connection_network_server)
return;
/* (heard from: the latency meter's connection problem) */
@@ -4809,6 +4987,41 @@ void network_distributed_handle_message(
sizeof(distributed_client_identities[machine_index].discord_name), identity.discord_name, 1);
}
break;
case _distributed_message_client_build:
/* (a client's build, as it tells it: its text kept printable, and
said once in the log) */
if (header.count >= 1 && machine_index >= 0 && machine_index < HALO_PORT_MAXIMUM_NETWORK_MACHINES)
{
struct distributed_client_build told;
struct distributed_client_build *kept = &distributed_client_builds[machine_index];
char names[64];
char mods[DISTRIBUTED_CLIENT_BUILD_MODS_SIZE];
long index;
csmemcpy(&told, entries, sizeof(told));
told.number[sizeof(told.number) - 1] = 0;
told.flavor[sizeof(told.flavor) - 1] = 0;
told.repository[sizeof(told.repository) - 1] = 0;
told.version[sizeof(told.version) - 1] = 0;
told.mods[sizeof(told.mods) - 1] = 0;
distributed_build_text(kept->number, sizeof(kept->number), told.number, FALSE);
distributed_build_text(kept->flavor, sizeof(kept->flavor), told.flavor, FALSE);
distributed_build_text(kept->repository, sizeof(kept->repository), told.repository, FALSE);
distributed_build_text(kept->version, sizeof(kept->version), told.version, FALSE);
distributed_build_text(kept->mods, sizeof(kept->mods), told.mods, TRUE);
distributed_client_build_known[machine_index] = TRUE;
distributed_machine_player_names(machine_index, names, sizeof(names));
snprintf(mods, sizeof(mods), "%s", kept->mods);
for (index = 0; mods[index]; index++)
{
if (mods[index] == '\n')
mods[index] = ';';
}
platform_log("network: machine %ld (%s) plays %s, from %s; mods: %s", machine_index,
names[0] ? names : "no player yet", kept->version[0] ? kept->version : "a build it does not name",
kept->repository[0] ? kept->repository : "a repository it does not name", mods[0] ? mods : "none");
}
break;
case _distributed_message_notice:
{
/* the host's text, in red on the console: printable, and ended */
@@ -4854,6 +5067,9 @@ void network_distributed_handle_message(
case _distributed_message_hit_reports:
network_damage_handle_reports(machine_index, entries, header.count);
break;
case _distributed_message_fire_origins:
network_damage_handle_fire_origins(machine_index, entries, header.count);
break;
case _distributed_message_vehicle_prediction:
network_objects_handle_vehicle_prediction(machine_index, entries, header.count);
break;
+50
View File
@@ -140,6 +140,15 @@ enum
/* the host's shield effects (network_shield_effects.c), to every client,
reliably, when it is ready and when they change */
_distributed_message_shield_effects = 91,
/* a client's build, as it tells it: its number, configuration, the
GitHub repository it is made from, its label and the mods it plays
(reliable, once a game, with its ready: distributed_client_send_build).
A host without it drops it as a kind it does not know */
_distributed_message_client_build = 92,
/* where a client's players' shots leave from when not from their
units' cameras (a VR build's hand), to the host, unreliably, every tick
while they do (network_damage.c): its line of sight check's */
_distributed_message_fire_origins = 93,
/* The VR port's own messages, numbered far past the game's (240 on),
so that messages upstream adds never take their numbers: a build
@@ -151,6 +160,25 @@ enum
NUMBER_OF_DISTRIBUTED_MESSAGES
};
/* a client's build, as text (every field printable ASCII and ended; mods a
line each, "folder version", in the load order) */
enum
{
DISTRIBUTED_CLIENT_BUILD_NUMBER_SIZE = 12,
DISTRIBUTED_CLIENT_BUILD_FLAVOR_SIZE = 16,
DISTRIBUTED_CLIENT_BUILD_REPOSITORY_SIZE = 144,
DISTRIBUTED_CLIENT_BUILD_VERSION_SIZE = 64,
DISTRIBUTED_CLIENT_BUILD_MODS_SIZE = 512,
};
struct distributed_client_build
{
char number[DISTRIBUTED_CLIENT_BUILD_NUMBER_SIZE];
char flavor[DISTRIBUTED_CLIENT_BUILD_FLAVOR_SIZE];
char repository[DISTRIBUTED_CLIENT_BUILD_REPOSITORY_SIZE];
char version[DISTRIBUTED_CLIENT_BUILD_VERSION_SIZE];
char mods[DISTRIBUTED_CLIENT_BUILD_MODS_SIZE];
};
/* where a message goes */
enum
{
@@ -227,6 +255,8 @@ a longer message is split where a batch is full */
unreliable ones gathered into one datagram a machine each tick */
void distributed_send(void *message, byte type, short count, word size, short destination);
void distributed_client_send_identity(void);
/* (a client) its build, to the host, once a game */
void distributed_client_send_build(void);
/* ... unreliably to one client (the host) */
void distributed_send_to_machine(long machine_index, void *message, byte type, short count, word size);
/* ... reliably to one client (the host) */
@@ -312,6 +342,17 @@ real distributed_vitality_unpack(word value);
(machine_index; NONE: the host's own) */
void distributed_send_notice(char const *text);
void distributed_send_notice_to_machine(long machine_index, char const *text);
/* (the host: a dedicated server's ban command) the next ban's reason and end
(seconds since 1970; 0: for ever), written in its bans.txt line */
void network_distributed_ban_details(char const *reason, unsigned long until);
/* (the host: a dedicated server's sv_ban_address) a ban's line in bans.txt
for an address (its text; "none" for none) and a hardware id (empty for
none), as the ban command writes one: TRUE if it was written */
boolean network_distributed_ban_record(char const *address, char const *hardware_id, char const *names,
char const *reason, unsigned long until);
/* whether the next ban drops its machine and keeps it out without its line
in bans.txt (network_distributed_ban_record wrote it) */
void network_distributed_ban_unrecorded(boolean unrecorded);
/* (the host) the names of a client machine's players, in ASCII */
void distributed_machine_player_names(long machine_index, char *names, long size);
/* (the host) a client machine's real IPv4 address (host byte order): an
@@ -319,6 +360,9 @@ internet play peer's endpoint, not its stand-in; 0 if not known */
unsigned long distributed_machine_real_address(long machine_index);
/* ... and the real address of a machine's address, as it joined from */
unsigned long distributed_real_address(unsigned long address);
/* (the host) a client machine's build as it told it: FALSE if it told none
(a build before the message, or not yet) */
boolean distributed_machine_client_build(long machine_index, struct distributed_client_build *build);
/* ---------- prototypes/NETWORK_VOTEKICK.C */
@@ -442,7 +486,13 @@ void network_damage_host_tick(void);
void network_damage_client_tick(void);
void network_damage_handle_events(void const *entries, short count);
void network_damage_handle_reports(long machine_index, void const *entries, short count);
void network_damage_handle_fire_origins(long machine_index, void const *entries, short count);
word network_damage_entry_size(byte type);
/* what says where a local player's shots leave from when that is not their
unit's camera: the offset from it (world units), FALSE when they leave from
the camera; NULL (the default) for a build whose shots always do */
typedef boolean (*network_damage_fire_origin_source)(short local_player_index, real_vector3d *offset);
void network_damage_set_fire_origin_source(network_damage_fire_origin_source source);
/* VR players' hands (network_vr.c) */
void network_vr_new_game(void);
+4 -1
View File
@@ -3445,10 +3445,13 @@ void network_objects_client_tick(
distributed_client_note_own_inventories();
distributed_client_send_vehicles();
distributed_client_carry_unsteered_vehicles();
/* (who it is, as its Discord told it: once its ready went, which makes
/* (who it is, as its Discord told it, and its build: once its ready went, which makes
it a machine the host takes messages of) */
if (objects_client_ready_time != NONE)
{
distributed_client_send_identity();
distributed_client_send_build();
}
}
/* ---------- the game */
+127 -12
View File
@@ -24,6 +24,11 @@ Automated system link sessions for testing the netcode without the menus
and then as "join". It logs what it lists.
- "log" hosts and joins nothing: the game that runs (map_name, in init.txt,
plays one without the menus) is logged, as below.
- "probe" (halo --probe [<invite>]: a list site's look at a server) searches
as "join" does, joins nothing, and prints the first game it finds (with
an invite, only its host's, over its tunnel; without, one on the LAN) on
the standard output as one line of JSON, then exits (1, with ok false, if
none is found in PROBE_SECONDS).
Once the game runs, every second each machine logs where every player's
unit is, so the machines' views of the game can be compared, and the host
@@ -104,6 +109,17 @@ void network_damage_statistics(long *sent_reports, long *dealt_reports, long *re
/* xinput_sdl.c's */
void test_input_hold_action(int hold);
void test_input_press(unsigned long buttons);
/* menu_functions.c's: whether the client's advertised game at the index is
an internet play peer's (over the tunnel), not the LAN's */
boolean menu_advertised_game_from_peer(long index);
enum
{
/* how long a probe looks for a game (halo --probe), and the client's
advertised games (network_client_manager.c's) */
PROBE_SECONDS = 30,
PROBE_MAXIMUM_GAMES = 9,
};
enum
{
@@ -112,6 +128,7 @@ enum
_network_test_join,
_network_test_browse,
_network_test_log,
_network_test_probe,
};
static struct
@@ -130,6 +147,11 @@ static struct
real start_delay;
real menu_seconds;
boolean set_up;
/* (the hosted game's whole invite logged, for the test that joins it:
the game's own lines give its first digits only, since players post
their logs. This is debug.network_test's host alone, a test's machine,
whose log nobody posts) */
boolean invite_logged;
real setup_seconds;
boolean started;
boolean joined;
@@ -151,6 +173,7 @@ static struct
boolean browsing;
boolean browse_listed;
float browse_seconds;
float probe_seconds;
/* debug.network_test_cues: each line and its tick from the cues' start,
the first tick every player of cue_players has a unit (NONE until then) */
struct
@@ -222,6 +245,10 @@ static void network_test_read_settings(
{
network_test.mode = _network_test_log;
}
else if (!strcmp(setting, "probe"))
{
network_test.mode = _network_test_probe;
}
network_test.public_game = config_boolean("debug.network_test_public") != 0;
network_test.start_delay = (real)config_real("debug.network_test_start");
network_test.kill_interval = (real)config_real("debug.network_test_kill");
@@ -391,9 +418,16 @@ static void network_test_log_players(
}
{
long sent, received, corrections;
struct object_iterator objects;
long ground_items = 0;
long creates, deletes, failures, removed;
long sent_reports, dealt_reports, rejected_reports, replayed_events;
/* this machine's player and where its camera is (a player that never
spawns leaves it where it began) */
long local_player_index = local_player_get_player_index(0);
struct observer_result const *camera = observer_get_camera(0);
long divergence_samples, divergence_apart;
real divergence_mean, divergence_largest;
object_iterator_new(&objects, _object_mask_weapon | _object_mask_equipment, 0);
while (object_iterator_next(&objects))
@@ -408,19 +442,8 @@ static void network_test_log_players(
}
}
network_distributed_statistics(&sent, &received, &corrections);
long creates, deletes, failures, removed;
long sent_reports, dealt_reports, rejected_reports, replayed_events;
network_distributed_item_statistics(&creates, &deletes, &failures, &removed);
network_damage_statistics(&sent_reports, &dealt_reports, &rejected_reports, &replayed_events);
/* this machine's player and where its camera is (a player that never
spawns leaves it where it began) */
long local_player_index = local_player_get_player_index(0);
struct observer_result const *camera = observer_get_camera(0);
long divergence_samples, divergence_apart;
real divergence_mean, divergence_largest;
network_distributed_divergence(&divergence_samples, &divergence_apart, &divergence_mean, &divergence_largest);
platform_log("network test: tick %ld%s | items %ld (+%ld -%ld !%ld x%ld) | %s to %ld | sent %ld received %ld corrected %ld"
@@ -1051,6 +1074,73 @@ static void network_test_pickup(
}
}
/* (a probe: halo --probe) the first game found, printed as JSON on the
standard output, and the end; when given_up, that none was. With an
invite (p2p.c joins the command line's), the invite's host's game alone:
the tunnel's peers are its host only, and a game on the LAN is another */
static void network_test_probe_answer(
boolean given_up)
{
static char const *const engines[] = { "none", "ctf", "slayer", "oddball", "king", "race" };
struct network_advertised_game_summary game;
long index;
int stage;
boolean invited = p2p_join_progress(&stage, NULL, 0) != P2P_JOIN_NONE;
for (index = 0; index < PROBE_MAXIMUM_GAMES; index++)
{
char name[2 * NUMBEROF(game.name) + 1];
char map[2 * sizeof(game.map_name) + 1];
char const *last;
long character, length = 0;
if (!network_game_client_port_advertised_game_summary(index, &game) ||
(invited && !menu_advertised_game_from_peer(index)))
{
continue;
}
/* (printable ASCII, quotes and backslashes escaped) */
for (character = 0; character < (long)NUMBEROF(game.name) && game.name[character]; character++)
{
char ascii = game.name[character] >= 0x20 && game.name[character] < 0x7F ? (char)game.name[character] : '?';
if (ascii == '"' || ascii == '\\')
name[length++] = '\\';
name[length++] = ascii;
}
name[length] = 0;
/* (the map's name, the last part of its path) */
last = strrchr(game.map_name, '\\');
last = last ? last + 1 : game.map_name;
for (length = 0, character = 0; last[character]; character++)
{
char ascii = (unsigned char)last[character] >= 0x20 && (unsigned char)last[character] < 0x7F ?
last[character] : '?';
if (ascii == '"' || ascii == '\\')
map[length++] = '\\';
map[length++] = ascii;
}
map[length] = 0;
printf("{\"ok\":true,\"name\":\"%s\",\"map\":\"%s\",\"gametype\":\"%s\",\"players\":%d,"
"\"max_players\":%d,\"teams\":%s,\"in_progress\":%s,\"open\":%s,\"dedicated\":%s,"
"\"network_version\":%u,\"kind\":%u}\n", name, map,
game.engine_type >= 0 && game.engine_type < (short)NUMBEROF(engines) ? engines[game.engine_type] : "none",
game.player_count, game.maximum_player_count, game.has_teams ? "true" : "false",
game.in_progress ? "true" : "false", game.open ? "true" : "false", game.dedicated ? "true" : "false",
(unsigned)game.version, (unsigned)game.kind);
fflush(stdout);
exit(0);
}
if (given_up)
{
printf("{\"ok\":false,\"error\":\"no game answered in %d seconds%s\"}\n", PROBE_SECONDS,
invited ? " from the invite's host" : "");
fflush(stdout);
exit(1);
}
}
void network_test_update(
boolean main_menu_loaded,
real seconds)
@@ -1466,6 +1556,16 @@ void network_test_update(
break;
}
case _network_test_host:
if (network_test.set_up && !network_test.local && !network_test.invite_logged)
{
char invite[256];
if (p2p_invite_link(invite, sizeof(invite)))
{
network_test.invite_logged = TRUE;
platform_log("network test: invite %s", invite);
}
}
if (!network_test.set_up)
{
network_test.set_up = TRUE;
@@ -1559,6 +1659,21 @@ void network_test_update(
}
}
break;
case _network_test_probe:
if (!network_test.set_up)
{
network_test.set_up = TRUE;
dispose_global_network_game_client();
dispose_global_network_game_server();
if (create_global_network_game_client())
game_connection_set(_game_connection_network_client);
}
else
{
network_test.probe_seconds += seconds;
network_test_probe_answer(network_test.probe_seconds >= PROBE_SECONDS);
}
break;
case _network_test_join:
if (!network_test.set_up)
{
+27 -2
View File
@@ -21,8 +21,9 @@ a VR player of another (players.c, weapons.c, units.c) as long as their
hands keep coming, else as before.
What the hands decide is only how the game looks: damage is the shooter's
report, which the host checks as any other (network_damage.c), except that
its line of sight may start at the weapon hand as well as the camera.
report, which the host checks as any other (network_damage.c); a VR
player's weapon hand also goes to the host as their fire origin, which its
line of sight check looks from too.
A machine that does not know the message (a build without this, of the same
network version) drops it, as it drops any type past its own
@@ -274,11 +275,35 @@ static boolean network_vr_local_pose(
return TRUE;
}
/* where a local VR player's shots leave from, from the unit's camera: the
weapon hand, on foot (network_damage.c's fire origins, which the host's
line of sight check also looks from) */
static boolean network_vr_fire_origin(
short local_player_index,
real_vector3d *offset)
{
long player_index = local_player_get_player_index(local_player_index);
struct player_datum *player = player_index != NONE ? player_try_and_get(player_index) : NULL;
struct unit_datum *unit = player && player->unit_index != NONE ? unit_try_and_get(player->unit_index) : NULL;
float vector[3];
if (!unit || !halo_vr_active() || unit->object.parent_object_index != NONE ||
!halo_vr_weapon_origin_offset(local_player_index, vector))
{
return FALSE;
}
offset->i = vector[0];
offset->j = vector[1];
offset->k = vector[2];
return TRUE;
}
/* ---------- public code */
void network_vr_new_game(
void)
{
network_damage_set_fire_origin_source(network_vr_fire_origin);
short index;
csmemset(network_vr_players, 0, sizeof(network_vr_players));
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+168
View File
@@ -0,0 +1,168 @@
/*
SERVER_DIRECTOR.H
A dedicated server's director (server_director.c), its commands
(server_commands.c) and its status (server_status.c): what the game's
sources call of them, and what they share. halo_server_mode() says whether
this run is a dedicated server (port/linux/src/server_mode.c).
*/
#ifndef __SERVER_DIRECTOR_H
#define __SERVER_DIRECTOR_H
#include "../src/server_mode.h" /* port: port/linux/src/server_mode.c */
struct server_json;
struct server_entry;
struct server_rotation;
/* the director's states (server_status.c names them) */
enum
{
_server_state_waiting,
_server_state_hosting,
_server_state_lobby,
_server_state_loading,
_server_state_playing,
_server_state_postgame,
_server_state_stopping,
NUMBER_OF_SERVER_STATES
};
/* why a game ended (game_ended's reason) */
enum
{
_server_end_score,
_server_end_time,
_server_end_idle,
_server_end_empty,
_server_end_command,
_server_end_next,
NUMBER_OF_SERVER_ENDS
};
/* when a stop asked for comes (sv_stop, sv_restart: drain's after) */
enum
{
_server_drain_now,
/* once the game being played is over (at once with none) */
_server_drain_game,
/* once nobody plays on the server */
_server_drain_empty,
/* (no stop: the one waiting cancelled) */
_server_drain_cancel,
NUMBER_OF_SERVER_DRAINS
};
/* ---------- the director (server_director.c) */
/* each frame, from the main loop (main.c), beside network_test_update */
void server_update(boolean main_menu_loaded, real seconds);
/* (network_server_manager.c) whether the lobby may count down: its entry
set, enough players, and teams as server.team_start says */
boolean server_director_ok_to_countdown(long player_count, boolean needs_more_teams);
/* the game's fatal error (main.c's halt_and_catch_fire, main_loop_of_death) */
void server_fatal(char const *text);
/* the director's state, and the seconds in it */
short server_director_state(void);
/* the rotation, the entry being played (its index; NONE for one sv_map
interposed, or before the first is set), and the next */
struct server_rotation const *server_director_rotation(void);
long server_director_index(void);
struct server_entry const *server_director_current(void);
struct server_entry const *server_director_next(void);
/* games hosted since the start, and this game's seconds */
long server_director_games_hosted(void);
real server_director_game_seconds(void);
/* the commands' (server_commands.c) */
/* the game ended now: with its scores (end_game), or without (skip) */
boolean server_director_end_game(short reason, boolean skip_postgame);
/* the entry played next (sv_map; now: the lobby's set again, or the game
ended at once if one runs; once the lobby's start was sent, after the game
it starts): TRUE and when in reason, else FALSE and why */
boolean server_director_interpose(char const *text, char *reason, long reason_size);
/* the rotation replaced (sv_rotation_set, PUT /api/rotation): FALSE with
the rejected entries said, and nothing changed */
boolean server_director_set_rotation(char const *text, boolean write, struct server_json *rejected);
/* the rotation's next game is the entry at index (sv_rotation_begin) */
boolean server_director_rotation_begin(long index);
/* the settings read again (sv_reload, SIGHUP) */
void server_director_reload(void);
/* the server stops: its game ended, every machine told, the listings
withdrawn; then the process ends with the code */
void server_director_quit(int exit_code, char const *why);
/* the settings the game takes from server.* applied now (the name, the
most players, the listing) */
void server_director_apply_settings(void);
/* a command dropped the machine's players: their leaving is said so
(player_left's reason: "kicked", "banned"; a static text) */
void server_director_player_removed(long machine_index, char const *reason);
/* the server stops (exit code 0, or 5 to be restarted) now, or once the
game is over or the server is empty (a drain): until then its listings are
withdrawn and new players refused. The notice was said already */
void server_director_drain(short after, boolean restart, char const *notice);
/* the drain waiting cancelled: listed and open again; FALSE if none was */
boolean server_director_drain_cancel(char const *notice);
/* (network_server_manager.c) whether new machines are refused: a drain */
boolean server_director_refuses_joins(void);
/* the status's "drain": null, or {after, restart, since} */
void server_director_drain_json(struct server_json *json);
/* ---------- the commands (server_commands.c) */
/* (hs.c) a command of the server's, run if the line is one, with the role
the line came with (server_commands_run's; the owner's otherwise): TRUE
with its result in result */
boolean server_command_try(char const *expression, boolean *result);
/* a line run with a role (standard input's, the web's): the console's
output it made, lines separated by newlines (free it), and whether it
succeeded */
char *server_commands_run(char const *line, int role, int origin, boolean *succeeded);
/* the role of the command being run (the owner's when none runs) */
int server_commands_role(void);
/* ---------- the game's events (server_status.c; nothing but on a dedicated
server) */
/* a player killed (network_distributed_player_killed's, on the host) */
void server_event_player_killed(long killing_player_index, long killing_object_index, long dead_player_index,
boolean friendly_fire);
/* a line of chat the host sends on (network_chat.c), by its sender's
absolute player index */
void server_event_chat(short player_index, short channel, char const *text);
/* a machine found running its game faster than the host's (action "kicked"
or "unverified") */
void server_event_speed_hack(long machine_index, char const *names, real rate, long ahead_ticks,
char const *action);
/* a machine's download of the game's map (map_share.c): state "asked",
"downloading", "downloaded" or "failed" */
void server_event_map_share(long machine_index, char const *map, char const *state, unsigned long done,
unsigned long total);
/* ---------- the status (server_status.c) */
/* the status (docs/dedicated_server.md, "The status"), as the role sees it */
void server_status_write(struct server_json *json, int role);
/* the server's identity, for the status and server_started: its process,
start, instance, region, ports and publisher key's id */
void server_status_identity(struct server_json *json);
/* the players, as the role sees them */
void server_status_players(struct server_json *json, int role);
/* one player, as the role sees him (a players[] element) */
void server_status_player(struct server_json *json, long slot, int role);
/* the maps there are */
void server_status_maps(struct server_json *json);
/* the gametypes there are, and their rules' schema */
void server_status_gametypes(struct server_json *json);
/* the state's name ("lobby"...) */
char const *server_status_state_name(short state);
/* the players' scores changed since this was last asked (score events) */
boolean server_status_scores_changed(void);
/* the network game's player at slot (its name in ASCII into name): TRUE */
boolean server_status_player_name(long slot, char *name, long size);
/* the team scores of a team game (two), else FALSE */
boolean server_status_team_scores(long *red, long *blue);
#endif
+508
View File
@@ -0,0 +1,508 @@
/*
SERVER_GAMETYPES.C
A dedicated server's gametypes (server_gametypes.h; docs/dedicated_server.md):
the variant of a built-in gametype by its name (the Xbox's default gametypes'
builders, game_engine_playlist.h), of OpenCE's (game_modes.c), of a gametype
file (gametypes/<name>.toml: its base, then its rules) or of a gametype a
player's game saved (gametypes/<name>.gametype), with a rotation entry's own
rules over it. Each rule is checked to be of the variant's game engine: a
bad gametype never reaches the wire, where the clients play by it.
*/
#include "cseries.h"
#include "game/game_engine.h"
#include "game/game_engine_playlist.h"
#include "saved games/playlist_profile.h"
#include "saved games/saved_game_files.h"
#include "text/unicode.h"
#include "game_modes.h"
#include "server_gametypes.h"
#include "../src/server_mode.h" /* port: port/linux/src/server_mode.c */
#include "../src/server_toml.h" /* port: port/linux/src/server_toml.c */
#include "../src/server_json.h" /* port: port/linux/src/server_json.c */
#include "../src/posix.h" /* port: port/linux/src/posix_files.c */
#include <stdio.h>
#include <string.h>
#if HALO_SERVER_BUILD
/* the built-in gametypes, by name, and what the lobby and the server
browser call them (11 characters) */
static struct
{
char const *name;
struct game_variant *(*build)(struct game_variant *variant);
wchar_t const *shown;
} const server_builtin_gametypes[] =
{
{ "slayer", build_game_variant_slayer, L"Slayer" },
{ "team_slayer", build_game_variant_team_slayer, L"Team Slayer" },
{ "slayer_pro", build_game_variant_slayer_pro, L"Slayer Pro" },
{ "elimination", build_game_variant_elimination, L"Elimination" },
{ "phantoms", build_game_variant_phantoms, L"Phantoms" },
{ "endurance", build_game_variant_endurance, L"Endurance" },
{ "rockets", build_game_variant_rockets, L"Rockets" },
{ "snipers", build_game_variant_snipers, L"Snipers" },
{ "stalker", build_game_variant_stalker, L"Stalker" },
{ "ctf", build_game_variant_ctf, L"CTF" },
{ "ironctf", build_game_variant_iron_ctf, L"Iron CTF" },
{ "ctf_pro", build_game_variant_ctf_pro, L"CTF Pro" },
{ "invasion", build_game_variant_invasion, L"Invasion" },
{ "oddball", build_game_variant_oddball, L"Oddball" },
{ "team_oddball", build_game_variant_team_oddball, L"Team Ball" },
{ "reverse_tag", build_game_variant_reverse_tag, L"Reverse Tag" },
{ "accumulation", build_game_variant_accumulation, L"Accumulate" },
{ "juggernaut", build_game_variant_juggernaut, L"Juggernaut" },
{ "king", build_game_variant_king, L"King" },
{ "team_king", build_game_variant_team_king, L"Team King" },
{ "king_pro", build_game_variant_king_pro, L"King Pro" },
{ "crazy_king", build_game_variant_crazy_king, L"Crazy King" },
{ "race", build_game_variant_race, L"Race" },
{ "team_race", build_game_variant_team_race, L"Team Race" },
{ "rally", build_game_variant_rally, L"Rally" },
{ "team_rally", build_game_variant_team_rally, L"Team Rally" },
};
/* Halo PC's dedicated server's names for them */
static struct
{
char const *alias;
char const *name;
} const server_gametype_aliases[] =
{
{ "ffa", "slayer" },
{ "ffa_slayer", "slayer" },
{ "teamslayer", "team_slayer" },
{ "tslayer", "team_slayer" },
{ "koth", "king" },
{ "team_koth", "team_king" },
{ "iron_ctf", "ironctf" },
{ "teamrace", "team_race" },
{ "teamoddball", "team_oddball" },
};
static char const *const server_opence_gametypes[] =
{
"swat", "infection", "gun_game", "hardcore", "hardcore_ctf", "hardcore_oddball", "hardcore_king",
};
char const *server_gametype_builtin_name(
long index)
{
if (index >= 0 && index < (long)NUMBEROF(server_builtin_gametypes))
return server_builtin_gametypes[index].name;
index -= (long)NUMBEROF(server_builtin_gametypes);
if (index >= 0 && index < (long)NUMBEROF(server_opence_gametypes))
return server_opence_gametypes[index];
return NULL;
}
char const *server_gametype_engine_name(
long game_engine_index)
{
switch (game_engine_index)
{
case game_engine_ctf: return "ctf";
case game_engine_slayer: return "slayer";
case game_engine_oddball: return "oddball";
case game_engine_king: return "king";
case game_engine_race: return "race";
default: return "none";
}
}
/* a built-in's or OpenCE's gametype by its name (or alias): TRUE */
static boolean server_gametype_base(
char const *name,
struct game_variant *variant,
struct game_variant_options *options)
{
struct game_variant temporary;
long index;
for (index = 0; index < (long)NUMBEROF(server_gametype_aliases); index++)
{
if (!strcmp(name, server_gametype_aliases[index].alias))
name = server_gametype_aliases[index].name;
}
for (index = 0; index < (long)NUMBEROF(server_builtin_gametypes); index++)
{
if (!strcmp(name, server_builtin_gametypes[index].name))
{
*variant = *server_builtin_gametypes[index].build(&temporary);
game_variant_options_default(variant, options);
if (!variant->human_readable_game_description[0])
{
ustrncpy(variant->human_readable_game_description, server_builtin_gametypes[index].shown,
NUMBEROF(variant->human_readable_game_description) - 1);
}
return TRUE;
}
}
return game_modes_gametype_by_name(name, variant, options);
}
static void server_gametype_flag(
struct game_variant *variant,
long bit,
long value)
{
SET_FLAG(variant->universal_variant.flags, bit, value != 0);
}
/* a rule over the variant and options (its engine already checked) */
static void server_gametype_rule(
struct server_rule const *rule,
struct game_variant *variant,
struct game_variant_options *options,
boolean *team_vehicles_given,
boolean *vehicle_counts_given)
{
char const *key = rule->key;
long value = rule->value;
struct universal_variant *universal = &variant->universal_variant;
static char const *const vehicles[NUMBER_OF_VARIANT_VEHICLES] =
{ "warthogs", "ghosts", "scorpions", "rocket_warthogs", "banshees", "gun_turrets" };
long index;
if (!strcmp(key, "score_to_win")) universal->score_to_win = value;
else if (!strcmp(key, "lives")) universal->lives = value;
else if (!strcmp(key, "respawn_time")) universal->respawn_time = value * TICKS_PER_SECOND;
else if (!strcmp(key, "respawn_time_growth")) universal->respawn_time_growth = value * TICKS_PER_SECOND;
else if (!strcmp(key, "suicide_penalty")) universal->suicide_penalty = value * TICKS_PER_SECOND;
else if (!strcmp(key, "health")) universal->health = (real)value / 100.0f;
else if (!strcmp(key, "teams")) universal->teams = (boolean)(value != 0);
else if (!strcmp(key, "odd_man_out")) universal->odd_man_out = (boolean)(value != 0);
else if (!strcmp(key, "radar")) server_gametype_flag(variant, _game_variant_draw_object_in_motion_sensor_bit, value);
else if (!strcmp(key, "friend_indicators")) server_gametype_flag(variant, _game_variant_allow_friendly_navpoints_bit, value);
else if (!strcmp(key, "infinite_grenades")) server_gametype_flag(variant, _game_variant_infinite_grenades_bit, value);
else if (!strcmp(key, "no_shields")) server_gametype_flag(variant, _game_variant_no_shields_bit, value);
else if (!strcmp(key, "invisible")) server_gametype_flag(variant, _game_variant_always_invisible_bit, value);
else if (!strcmp(key, "generic_equipment")) server_gametype_flag(variant, _game_variant_generic_starting_equipment_bit, value);
else if (!strcmp(key, "weapon_set")) universal->weapon_set = value;
else if (!strcmp(key, "vehicle_set")) universal->vehicle_set = value;
else if (!strcmp(key, "goal_radar")) universal->goal_radar = value;
else if (!strcmp(key, "time_limit")) options->time_limit = (short)value;
else if (!strcmp(key, "friendly_fire")) options->friendly_fire = (short)value;
else if (!strcmp(key, "friendly_fire_penalty")) options->friendly_fire_penalty = (short)value;
else if (!strcmp(key, "vehicle_respawn_time")) options->vehicle_respawn_time = (short)value;
else if (!strcmp(key, "auto_team_balance")) options->auto_team_balance = (boolean)(value != 0);
else if (!strcmp(key, "radar_players")) options->radar_players = (byte)value;
else if (!strcmp(key, "loadout")) options->loadout = (byte)value;
else if (!strcmp(key, "primary_weapon")) options->primary_weapon = (byte)value;
else if (!strcmp(key, "secondary_weapon")) options->secondary_weapon = (byte)value;
else if (!strcmp(key, "no_map_weapons")) options->no_map_weapons = (boolean)(value != 0);
else if (!strcmp(key, "hardcore")) options->hardcore = (boolean)(value != 0);
else if (!strcmp(key, "team_vehicles"))
{
byte set = value == 1 ? VARIANT_VEHICLE_SET_PC : value == 2 ? VARIANT_VEHICLE_SET_CUSTOM :
(byte)universal->vehicle_set;
options->vehicle_set[0] = options->vehicle_set[1] = set;
*team_vehicles_given = TRUE;
}
else if (!strcmp(key, "assault")) variant->game_engine_variant.ctf.assault = (boolean)(value != 0);
else if (!strcmp(key, "reset_on_capture")) variant->game_engine_variant.ctf.reset_on_capture = (boolean)(value != 0);
else if (!strcmp(key, "flag_must_reset")) variant->game_engine_variant.ctf.flag_must_reset = (boolean)(value != 0);
else if (!strcmp(key, "flag_at_home_to_score")) variant->game_engine_variant.ctf.flag_at_home_to_score = (boolean)(value != 0);
else if (!strcmp(key, "single_flag_time")) variant->game_engine_variant.ctf.single_flag_time = value * TICKS_PER_SECOND;
else if (!strcmp(key, "no_death_bonus")) variant->game_engine_variant.slayer.no_death_bonus = (boolean)(value != 0);
else if (!strcmp(key, "no_kill_penalty")) variant->game_engine_variant.slayer.no_kill_penalty = (boolean)(value != 0);
else if (!strcmp(key, "kill_in_order")) variant->game_engine_variant.slayer.kill_in_order = (boolean)(value != 0);
else if (!strcmp(key, "moving_hill")) variant->game_engine_variant.king.moving_hill = (boolean)(value != 0);
else if (!strcmp(key, "random_start")) variant->game_engine_variant.oddball.random_start = (boolean)(value != 0);
else if (!strcmp(key, "ball_spawn_delay")) variant->game_engine_variant.oddball.ball_spawn_delay = (boolean)(value != 0);
else if (!strcmp(key, "speed_with_ball")) variant->game_engine_variant.oddball.speed_with_ball = value;
else if (!strcmp(key, "trait_with_ball")) variant->game_engine_variant.oddball.trait_with_ball = value;
else if (!strcmp(key, "trait_without_ball")) variant->game_engine_variant.oddball.trait_without_ball = value;
else if (!strcmp(key, "ball_type")) variant->game_engine_variant.oddball.oddball_ball_type = value;
else if (!strcmp(key, "ball_spawn_count")) variant->game_engine_variant.oddball.ball_spawn_count = value;
else if (!strcmp(key, "race_type")) variant->game_engine_variant.race.race_type = value;
else if (!strcmp(key, "team_scoring")) variant->game_engine_variant.race.team_scoring = value;
else
{
for (index = 0; index < NUMBER_OF_VARIANT_VEHICLES; index++)
{
if (!strcmp(key, vehicles[index]))
{
options->vehicle_counts[0][index] = options->vehicle_counts[1][index] = (byte)value;
*vehicle_counts_given = TRUE;
}
}
}
}
/* the rules over the variant, each of its engine: FALSE and why if one is
another's */
static boolean server_gametype_apply_rules(
struct server_rule const *rules,
long rule_count,
struct game_variant *variant,
struct game_variant_options *options,
boolean *team_vehicles_given,
boolean *vehicle_counts_given,
char *reason,
long reason_size)
{
long engine_bit = variant->game_engine_index >= game_engine_ctf && variant->game_engine_index <= game_engine_race ?
1L << (variant->game_engine_index - game_engine_ctf) : 0;
long index;
for (index = 0; index < rule_count; index++)
{
struct server_rule_definition const *definition = server_rule_find(rules[index].key);
if (!definition)
{
snprintf(reason, (size_t)reason_size, "no rule is named %s", rules[index].key);
return FALSE;
}
if (!(definition->engines & engine_bit))
{
snprintf(reason, (size_t)reason_size, "%s is a %s gametype's rule, and this one is %s", definition->key,
definition->table, server_gametype_engine_name(variant->game_engine_index));
return FALSE;
}
server_gametype_rule(&rules[index], variant, options, team_vehicles_given, vehicle_counts_given);
}
return TRUE;
}
boolean server_gametype_build(
char const *gametype,
struct server_rule const *rules,
long rule_count,
struct game_variant *variant,
struct game_variant_options *options,
char *reason,
long reason_size)
{
boolean team_vehicles_given = FALSE;
boolean vehicle_counts_given = FALSE;
struct universal_variant before;
csmemset(variant, 0, sizeof(*variant));
csmemset(options, 0, sizeof(*options));
reason[0] = 0;
if (!strncmp(gametype, "file=", 5))
{
/* a gametype a player's game saved, copied into the gametypes folder
(the .toml files' own: beside config.toml first, server_toml.c's
server_gametypes_folder) */
byte block[SAVED_GAME_FILE_BLOCK_SIZE];
int size;
if (!server_gametype_file_name_valid(gametype + 5))
{
snprintf(reason, (size_t)reason_size, "\"%s\" is not a gametype file's name", gametype + 5);
return FALSE;
}
size = server_gametype_saved_read(gametype + 5, block, sizeof(block));
if (size < 0 || !playlist_profile_get_from_data_with_options(block, (unsigned long)size, variant, options))
{
snprintf(reason, (size_t)reason_size, "gametypes/%s.gametype is missing, or not a saved gametype",
gametype + 5);
return FALSE;
}
if (variant->game_engine_index < game_engine_ctf || variant->game_engine_index > game_engine_race)
{
snprintf(reason, (size_t)reason_size, "gametypes/%s.gametype has no game engine", gametype + 5);
return FALSE;
}
}
else if (!server_gametype_base(gametype, variant, options))
{
struct server_gametype_file file;
int absent;
if (!server_gametype_file_read(gametype, &file, &absent))
{
if (absent)
{
snprintf(reason, (size_t)reason_size,
"no gametype is named %s (a built-in's, OpenCE's, or gametypes/%s.toml)", gametype, gametype);
}
else if (file.error_line > 0)
{
snprintf(reason, (size_t)reason_size, "gametypes/%s.toml line %d: %s", gametype, file.error_line,
file.error);
}
else
{
snprintf(reason, (size_t)reason_size, "gametypes/%s.toml: %s", gametype, file.error);
}
return FALSE;
}
if (!server_gametype_base(file.base, variant, options))
{
snprintf(reason, (size_t)reason_size, "gametypes/%s.toml: its base, %s, is no built-in gametype", gametype,
file.base);
return FALSE;
}
if (file.engine_table[0] && strcmp(file.engine_table, server_gametype_engine_name(variant->game_engine_index)))
{
snprintf(reason, (size_t)reason_size, "gametypes/%s.toml: [%s] is not its base's game engine (%s)",
gametype, file.engine_table, server_gametype_engine_name(variant->game_engine_index));
return FALSE;
}
if (!server_gametype_apply_rules(file.rules, file.rule_count, variant, options, &team_vehicles_given,
&vehicle_counts_given, reason, reason_size))
{
char why[SERVER_REASON_SIZE];
snprintf(why, sizeof(why), "%s", reason);
snprintf(reason, (size_t)reason_size, "gametypes/%s.toml: %s", gametype, why);
return FALSE;
}
if (file.name[0])
{
long index;
csmemset(variant->human_readable_game_description, 0, sizeof(variant->human_readable_game_description));
for (index = 0; file.name[index] && index < (long)NUMBEROF(variant->human_readable_game_description) - 1;
index++)
{
variant->human_readable_game_description[index] = (wchar_t)(unsigned char)file.name[index];
}
}
}
before = variant->universal_variant;
if (!server_gametype_apply_rules(rules, rule_count, variant, options, &team_vehicles_given, &vehicle_counts_given,
reason, reason_size))
{
return FALSE;
}
/* (each team's vehicles: the vehicle set's unless said, a count's
making them custom) */
if (vehicle_counts_given && !team_vehicles_given)
options->vehicle_set[0] = options->vehicle_set[1] = VARIANT_VEHICLE_SET_CUSTOM;
else if (!team_vehicles_given && before.vehicle_set != variant->universal_variant.vehicle_set)
options->vehicle_set[0] = options->vehicle_set[1] = (byte)variant->universal_variant.vehicle_set;
/* (CTF always has teams) */
if (variant->game_engine_index == game_engine_ctf)
variant->universal_variant.teams = TRUE;
game_modes_options_sanitize(options);
return TRUE;
}
/* (the web's: server_http.c) a gametype file's text checked as the
rotation would take it: TRUE, else why and its line */
int server_gametype_check_text(
char const *text,
int length,
char *reason,
long reason_size,
int *line)
{
struct server_gametype_file file;
struct game_variant variant;
struct game_variant_options options;
boolean team_vehicles_given = FALSE, vehicle_counts_given = FALSE;
*line = 0;
if (!server_gametype_file_parse(text, length, &file))
{
snprintf(reason, (size_t)reason_size, "%s", file.error);
*line = file.error_line;
return FALSE;
}
if (!server_gametype_base(file.base, &variant, &options))
{
snprintf(reason, (size_t)reason_size, "base = \"%s\" is no built-in gametype", file.base);
return FALSE;
}
if (file.engine_table[0] && strcmp(file.engine_table, server_gametype_engine_name(variant.game_engine_index)))
{
snprintf(reason, (size_t)reason_size, "[%s] is not its base's game engine (%s)", file.engine_table,
server_gametype_engine_name(variant.game_engine_index));
return FALSE;
}
return server_gametype_apply_rules(file.rules, file.rule_count, &variant, &options, &team_vehicles_given,
&vehicle_counts_given, reason, reason_size);
}
/* (the web's) the gametype files there are: "files":[{name, base,
description}] */
void server_gametype_files_json(
struct server_json *json)
{
char folder[1024];
char name[256];
void *directory;
server_gametypes_folder(folder, sizeof(folder));
server_json_array_begin(json, "files");
directory = posix_directory_open(folder);
while (directory && posix_directory_next(directory, name, sizeof(name)))
{
size_t length = strlen(name);
struct server_gametype_file file;
char base_name[64];
int absent;
if (length < 6 || length - 5 >= sizeof(base_name) || strcmp(name + length - 5, ".toml"))
continue;
snprintf(base_name, sizeof(base_name), "%.*s", (int)(length - 5), name);
server_json_object_begin(json, NULL);
server_json_string(json, "name", base_name);
if (server_gametype_file_read(base_name, &file, &absent))
{
server_json_string(json, "base", file.base);
server_json_string(json, "description", file.name);
server_json_null(json, "error");
}
else
{
server_json_string(json, "base", "");
server_json_string(json, "description", "");
server_json_string(json, "error", file.error);
}
server_json_object_end(json);
}
if (directory)
posix_directory_close(directory);
server_json_array_end(json);
}
void server_gametype_check_entry(
struct server_entry *entry)
{
struct game_variant variant;
struct game_variant_options options;
char reason[SERVER_REASON_SIZE];
if (entry->rejected[0] || !strcmp(entry->gametype, "coop"))
return;
if (!server_gametype_build(entry->gametype, entry->rules, entry->rule_count, &variant, &options, reason,
sizeof(reason)))
{
snprintf(entry->rejected, sizeof(entry->rejected), "%s", reason);
}
}
#else
boolean server_gametype_build(char const *gametype, struct server_rule const *rules, long rule_count,
struct game_variant *variant, struct game_variant_options *options, char *reason, long reason_size)
{
return FALSE;
}
void server_gametype_check_entry(struct server_entry *entry)
{
}
char const *server_gametype_builtin_name(long index)
{
return NULL;
}
char const *server_gametype_engine_name(long game_engine_index)
{
return "none";
}
#endif
+31
View File
@@ -0,0 +1,31 @@
/*
SERVER_GAMETYPES.H
A dedicated server's gametypes (server_gametypes.c): a rotation entry's
gametype (a built-in's name, an OpenCE gametype's, a gametype file's, a saved
gametype's) and its rules made into the game's variant and PC options.
*/
#ifndef __SERVER_GAMETYPES_H
#define __SERVER_GAMETYPES_H
#include "../src/server_rotation.h" /* port: port/linux/src/server_rotation.c */
struct game_variant;
struct game_variant_options;
/* the variant and options of the gametype, the rules over it: TRUE, else
FALSE and why */
boolean server_gametype_build(char const *gametype, struct server_rule const *rules, long rule_count,
struct game_variant *variant, struct game_variant_options *options, char *reason, long reason_size);
/* the entry rejected if its gametype and rules cannot be built (or it is a
co-op entry: those need nothing more) */
void server_gametype_check_entry(struct server_entry *entry);
/* the built-in gametypes' names (game_engine_get_variant_by_name's and the
Xbox's other default gametypes'), and OpenCE's; NULL past the last */
char const *server_gametype_builtin_name(long index);
/* the name of a game engine (game_engine_index): "ctf", "slayer", "oddball",
"king", "race" */
char const *server_gametype_engine_name(long game_engine_index);
#endif
+709
View File
@@ -0,0 +1,709 @@
/*
SERVER_STATUS.C
A dedicated server's status (server_director.h; docs/dedicated_server.md,
"The status"): the server, its game and its players as JSON, for the API,
the remote console's status events, the status file, sv_status --json and
the master servers. Addresses and hardware ids are written only for the
owner, or with server.log_addresses; the invite only for admins.
*/
#include "cseries.h"
#include "networking/network_game_globals.h"
#include "networking/network_game_manager.h"
#include "networking/network_server_manager.h"
#include "networking/network_server_manager_internal.h"
#include "game/game.h"
#include "game/game_engine.h"
#include "game/players.h"
#include "objects/objects.h"
#include "tag_files/tag_files.h"
#include "units/units.h"
#include "network_chat.h"
#include "network_distributed.h"
#include "server_director.h"
#include "server_gametypes.h"
#include "../src/p2p.h" /* port: port/linux/src/p2p.c */
#include "../src/server_console.h" /* port: port/linux/src/server_console.c */
#include "../src/server_events.h" /* port: port/linux/src/server_events.c */
#include "../src/server_http.h" /* port: port/linux/src/server_http.c */
#include "../src/server_json.h" /* port: port/linux/src/server_json.c */
#include "../src/server_master.h" /* port: port/linux/src/server_master.c */
#include "../src/posix_server.h" /* port: port/linux/src/posix_server.c */
#include "../src/port_config.h" /* port: port/linux/src/port_config.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if HALO_SERVER_BUILD
/* server_director.c's */
long long server_director_joined_time(long slot);
/* port/linux/src/updater.c's */
long updater_server_available(void);
enum
{
MAXIMUM_SLOTS = HALO_PORT_MAXIMUM_NETWORK_PLAYERS,
};
static char const *const server_state_names[NUMBER_OF_SERVER_STATES] =
{ "waiting", "hosting", "lobby", "loading", "playing", "postgame", "stopping" };
/* the scores last seen (server_status_scores_changed) */
static long server_status_last_scores[MAXIMUM_SLOTS + 2];
char const *server_status_state_name(
short state)
{
return state >= 0 && state < NUMBER_OF_SERVER_STATES ? server_state_names[state] : "waiting";
}
static struct network_game *server_game(
void)
{
struct network_game_server *network_server = global_network_game_server_get();
return network_server ? network_game_server_get_game(network_server) : NULL;
}
boolean server_status_player_name(
long slot,
char *name,
long size)
{
struct network_game *game = server_game();
long index;
if (!game || slot < 0 || slot >= MAXIMUM_SLOTS || !network_player_is_valid(&game->players[slot]))
{
if (size > 0)
name[0] = 0;
return FALSE;
}
for (index = 0; index < (long)NUMBEROF(game->players[slot].name) && game->players[slot].name[index] &&
index < size - 1; index++)
{
name[index] = player_name_character_ascii(game->players[slot].name[index]);
}
name[index] = 0;
return TRUE;
}
/* (the web's: server_http.c) whether the slot holds the player of that name
(the page's view may be old); the slot's player's name in current either
way (empty for none) */
int server_status_player_matches(
long slot,
char const *name,
char *current,
long size)
{
if (!server_status_player_name(slot, current, size))
return FALSE;
return !strcmp(current, name);
}
boolean server_status_team_scores(
long *red,
long *blue)
{
struct network_game *game = server_game();
if (!game || !game->variant.universal_variant.teams || !game_engine_running())
return FALSE;
*red = game_engine_get_team_score(0);
*blue = game_engine_get_team_score(1);
return TRUE;
}
/* the player in the running game of the network game's slot, NULL if none */
static struct player_datum *server_status_datum(
long slot)
{
struct network_game *game = server_game();
if (!game || !game_engine_running() || !network_player_is_valid(&game->players[slot]))
return NULL;
return distributed_player(game->players[slot].player_list_index);
}
static long server_status_score(
long slot)
{
struct network_game *game = server_game();
if (!server_status_datum(slot) || !game_engine || !game_engine->get_player_score)
return 0;
return game_engine->get_player_score(DATUM_INDEX_TO_ABSOLUTE_INDEX(game->players[slot].player_list_index),
_get_score_individual);
}
boolean server_status_scores_changed(
void)
{
boolean changed = FALSE;
long slot, red = 0, blue = 0;
for (slot = 0; slot < MAXIMUM_SLOTS; slot++)
{
long score = server_status_score(slot);
if (score != server_status_last_scores[slot])
{
server_status_last_scores[slot] = score;
changed = TRUE;
}
}
server_status_team_scores(&red, &blue);
if (red != server_status_last_scores[MAXIMUM_SLOTS] || blue != server_status_last_scores[MAXIMUM_SLOTS + 1])
{
server_status_last_scores[MAXIMUM_SLOTS] = red;
server_status_last_scores[MAXIMUM_SLOTS + 1] = blue;
changed = TRUE;
}
return changed;
}
void server_status_player(
struct server_json *json,
long slot,
int role)
{
struct network_game *game = server_game();
struct network_player const *player;
struct player_datum *datum = server_status_datum(slot);
char name[16];
if (!server_status_player_name(slot, name, sizeof(name)))
return;
player = &game->players[slot];
server_json_object_begin(json, NULL);
server_json_integer(json, "slot", slot);
server_json_string(json, "name", name);
server_json_integer(json, "team", game->variant.universal_variant.teams ? (long long)player->team_index : -1);
server_json_integer(json, "score", server_status_score(slot));
server_json_integer(json, "kills", datum ? datum->statistics.kills[0] : 0);
server_json_integer(json, "deaths", datum ? datum->statistics.deaths : 0);
server_json_integer(json, "assists", datum ? datum->statistics.assists[0] : 0);
{
long ping = datum ? distributed_player_ping(player->player_list_index) : NONE;
if (ping == NONE)
server_json_null(json, "ping");
else
server_json_integer(json, "ping", ping);
}
server_json_integer(json, "machine", player->machine_index);
server_json_integer(json, "joined", server_director_joined_time(slot) / 1000);
{
wchar_t wide[12];
csmemcpy(wide, player->name, sizeof(wide));
server_json_boolean(json, "muted", network_chat_host_name_muted(wide));
}
/* (its machine's build, as it told it: null from a build that does not) */
{
struct distributed_client_build build;
if (distributed_machine_client_build(player->machine_index, &build))
{
char const *line = build.mods;
server_json_object_begin(json, "client");
server_json_integer(json, "build", strtol(build.number, NULL, 10));
server_json_string(json, "flavor", build.flavor);
server_json_string(json, "version", build.version);
server_json_string(json, "repository", build.repository);
server_json_array_begin(json, "mods");
while (*line)
{
char const *end = strchr(line, '\n');
int length = end ? (int)(end - line) : (int)strlen(line);
if (length > 0)
server_json_string_length(json, NULL, line, length);
line += length + (end ? 1 : 0);
}
server_json_array_end(json);
server_json_object_end(json);
}
else
{
server_json_null(json, "client");
}
}
if (role >= _server_role_owner || config_boolean("server.log_addresses"))
{
unsigned long address = distributed_machine_real_address(player->machine_index);
char text[32];
char const *hardware_id = network_game_server_machine_hardware_id(player->machine_index);
snprintf(text, sizeof(text), "%lu.%lu.%lu.%lu", (address >> 24) & 255, (address >> 16) & 255,
(address >> 8) & 255, address & 255);
server_json_string(json, "address", address ? text : "");
server_json_string(json, "hwid", hardware_id ? hardware_id : "");
}
server_json_object_end(json);
}
void server_status_players(
struct server_json *json,
int role)
{
struct network_game *game = server_game();
long slot;
server_json_array_begin(json, "players");
for (slot = 0; game && slot < MAXIMUM_SLOTS; slot++)
{
if (network_player_is_valid(&game->players[slot]))
server_status_player(json, slot, role);
}
server_json_array_end(json);
}
static void server_status_entry(
struct server_json *json,
char const *key,
struct server_entry const *entry)
{
long index;
if (!entry)
{
server_json_null(json, key);
return;
}
server_json_object_begin(json, key);
server_json_string(json, "text", entry->text);
server_json_string(json, "map", entry->map_name);
server_json_string(json, "gametype", entry->gametype);
server_json_object_begin(json, "rules");
for (index = 0; index < entry->rule_count; index++)
server_json_integer(json, entry->rules[index].key, entry->rules[index].value);
server_json_object_end(json);
if (entry->rejected[0])
server_json_string(json, "rejected", entry->rejected);
else
server_json_null(json, "rejected");
server_json_object_end(json);
}
/* the rotation: its entries, the one played and the next */
void server_status_rotation(
struct server_json *json)
{
struct server_rotation const *rotation = server_director_rotation();
long index;
server_json_integer(json, "index", server_director_index());
server_json_array_begin(json, "entries");
for (index = 0; index < rotation->count; index++)
server_status_entry(json, NULL, &rotation->entries[index]);
server_json_array_end(json);
server_status_entry(json, "next", server_director_next());
}
void server_status_identity(
struct server_json *json)
{
char instance[64];
char key_id[2 * SERVER_MASTER_KEY_ID_SIZE + 1];
server_master_identity(instance, sizeof(instance), key_id, sizeof(key_id));
server_json_integer(json, "pid", (long long)server_mode_process_id());
server_json_integer(json, "started", (long long)server_mode_started());
server_json_string(json, "instance", instance);
server_json_string(json, "region", config_string("server.region"));
server_json_integer(json, "tunnel_port", config_boolean("network.online") ? config_integer("network.tunnel_port") : 0);
server_json_string(json, "publisher_key_id", key_id);
server_json_integer(json, "web_port", config_integer("server.web_port"));
}
void server_status_write(
struct server_json *json,
int role)
{
struct network_game *game = server_game();
struct network_game_server *network_server = global_network_game_server_get();
struct server_entry const *current = server_director_current();
char variant[16] = "";
long players = 0, slot, red, blue;
long countdown = NONE;
double longest, mean;
for (slot = 0; game && slot < MAXIMUM_SLOTS; slot++)
players += network_player_is_valid(&game->players[slot]) != 0;
if (game)
{
long index;
for (index = 0; index < 11 && game->variant.human_readable_game_description[index]; index++)
variant[index] = player_name_character_ascii(game->variant.human_readable_game_description[index]);
variant[index] = 0;
}
if (network_server && server_director_state() == _server_state_lobby)
countdown = network_game_server_port_countdown_milliseconds(network_server);
server_json_object_begin(json, NULL);
server_json_boolean(json, "ok", TRUE);
server_json_integer(json, "time", posix_server_unix_milliseconds());
server_json_string(json, "name", config_string("server.name"));
server_json_string(json, "state", server_status_state_name(server_director_state()));
server_json_string(json, "build", server_mode_build());
server_json_integer(json, "network_version", HALO_PORT_NETWORK_VERSION);
server_json_integer(json, "uptime", (long long)server_mode_uptime());
server_json_integer(json, "games_hosted", server_director_games_hosted());
server_json_string(json, "map", current ? current->map_name : "");
server_json_string(json, "level", current ? current->level : "");
server_json_string(json, "gametype", current ? current->gametype : "");
server_json_string(json, "variant", variant);
server_json_boolean(json, "teams", game ? game->variant.universal_variant.teams : FALSE);
server_json_integer(json, "players", players);
server_json_integer(json, "max_players", game ? game->maximum_players : config_integer("server.max_players"));
server_json_integer(json, "machines", network_server ?
network_game_server_port_remote_machine_count(network_server, TRUE) : 0);
server_json_integer(json, "countdown", countdown == NONE ? -1 : (countdown + 999) / 1000);
server_json_integer(json, "game_seconds", (long long)server_director_game_seconds());
if (server_status_team_scores(&red, &blue))
{
server_json_array_begin(json, "team_scores");
server_json_integer(json, NULL, red);
server_json_integer(json, NULL, blue);
server_json_array_end(json);
}
else
{
server_json_null(json, "team_scores");
}
server_json_object_begin(json, "listing");
server_json_boolean(json, "online", config_boolean("network.online"));
server_json_boolean(json, "public", config_boolean("server.public"));
server_json_boolean(json, "password", config_string("server.password")[0] != 0);
{
char invite[P2P_LISTING_INVITE_SIZE] = "";
if (role >= _server_role_admin)
p2p_invite_link(invite, sizeof(invite));
server_json_string(json, "invite", invite);
}
server_json_object_end(json);
server_json_object_begin(json, "rotation");
{
struct server_rotation const *rotation = server_director_rotation();
server_json_integer(json, "index", server_director_index());
server_json_integer(json, "count", rotation->count);
server_status_entry(json, "next", server_director_next());
}
server_json_object_end(json);
server_mode_frame_times(&longest, &mean);
server_json_real(json, "frame_ms_max", longest);
server_json_real(json, "frame_ms_avg", mean);
server_json_real(json, "tick_ms_avg", mean);
server_master_status(json);
server_json_object_begin(json, "update");
{
/* (server.auto_update's last look: port/linux/src/updater.c) */
long available = updater_server_available();
char build[32] = "";
if (available)
snprintf(build, sizeof(build), "build-%ld", available);
server_json_boolean(json, "available", available != 0);
server_json_string(json, "build", build);
}
server_json_object_end(json);
server_director_drain_json(json);
server_status_identity(json);
server_http_capabilities(json, "capabilities");
server_json_object_end(json);
}
/* ---------- the maps and the gametypes */
/* the multiplayer maps' names as the game shows them (the Xbox's, and Halo
PC's own); another map's is its file's */
static struct
{
char const *name;
char const *title;
} const server_map_titles[] =
{
{ "beavercreek", "Battle Creek" },
{ "bloodgulch", "Blood Gulch" },
{ "boardingaction", "Boarding Action" },
{ "carousel", "Derelict" },
{ "chillout", "Chill Out" },
{ "damnation", "Damnation" },
{ "dangercanyon", "Danger Canyon" },
{ "deathisland", "Death Island" },
{ "gephyrophobia", "Gephyrophobia" },
{ "hangemhigh", "Hang 'Em High" },
{ "icefields", "Ice Fields" },
{ "infinity", "Infinity" },
{ "longest", "Longest" },
{ "prisoner", "Prisoner" },
{ "putput", "Chiron TL-34" },
{ "ratrace", "Rat Race" },
{ "sidewinder", "Sidewinder" },
{ "timberland", "Timberland" },
{ "wizard", "Wizard" },
};
static void server_status_map(
void *context,
char const *name,
char const *level,
char const *kind)
{
struct server_json *json = context;
char const *title = name;
long index;
/* (a Custom Edition map of a stock map's name is its own) */
for (index = 0; !strcmp(kind, "xbox") && index < (long)NUMBEROF(server_map_titles); index++)
{
if (!strcmp(name, server_map_titles[index].name))
title = server_map_titles[index].title;
}
server_json_object_begin(json, NULL);
server_json_string(json, "name", name);
server_json_string(json, "level", level);
server_json_string(json, "kind", kind);
server_json_string(json, "title", title);
server_json_object_end(json);
}
void server_status_maps(
struct server_json *json)
{
server_json_array_begin(json, "maps");
server_maps_each(server_status_map, json);
server_json_array_end(json);
}
void server_status_gametypes(
struct server_json *json)
{
char const *name;
long index;
server_json_array_begin(json, "builtin");
for (index = 0; (name = server_gametype_builtin_name(index)) != NULL; index++)
server_json_string(json, NULL, name);
server_json_array_end(json);
server_json_object_begin(json, "schema");
server_json_array_begin(json, "fields");
for (index = 0; index < server_rule_definition_count(); index++)
{
struct server_rule_definition const *rule = &server_rule_definitions[index];
server_json_object_begin(json, NULL);
server_json_string(json, "key", rule->key);
server_json_string(json, "alias", rule->alias);
server_json_string(json, "table", rule->table);
server_json_string(json, "engine", rule->engines == _server_engine_all ? "all" : rule->table);
server_json_string(json, "type", rule->type == _server_rule_integer ? "integer" :
rule->type == _server_rule_boolean ? "boolean" : "choice");
if (rule->type == _server_rule_integer)
{
server_json_integer(json, "min", rule->minimum);
server_json_integer(json, "max", rule->maximum);
server_json_null(json, "choices");
}
else
{
long choice;
server_json_null(json, "min");
server_json_null(json, "max");
if (rule->type == _server_rule_choice)
{
server_json_array_begin(json, "choices");
for (choice = 0; rule->choices[choice]; choice++)
server_json_string(json, NULL, rule->choices[choice]);
server_json_array_end(json);
}
else
{
server_json_null(json, "choices");
}
}
server_json_null(json, "default");
server_json_string(json, "description", rule->description);
server_json_object_end(json);
}
server_json_array_end(json);
server_json_object_end(json);
}
/* ---------- the game's events (server_director.h) */
/* the slot of the network game's player whose player is at the absolute
index; NONE if none */
static long server_status_slot_of(
long absolute_index)
{
struct network_game *game = server_game();
long slot;
for (slot = 0; game && absolute_index != NONE && slot < MAXIMUM_SLOTS; slot++)
{
if (network_player_is_valid(&game->players[slot]) &&
DATUM_INDEX_TO_ABSOLUTE_INDEX(game->players[slot].player_list_index) == absolute_index)
{
return slot;
}
}
return NONE;
}
/* the tag name of what killed: a unit's weapon in hand, else the object */
static char const *server_status_killing_tag(
long object_index)
{
struct object_datum *object = object_index != NONE ? object_try_and_get(object_index) : NULL;
if (!object)
return NULL;
if (TEST_FLAG(_object_mask_unit, object->object.type))
{
struct unit_datum *unit = unit_get(object_index);
long weapon_index = unit->unit.current_weapon_index != NONE ?
unit_inventory_get_weapon(object_index, unit->unit.current_weapon_index) : NONE;
struct object_datum *weapon = weapon_index != NONE ? object_try_and_get(weapon_index) : NULL;
return weapon ? tag_get_name(weapon->definition_index) : NULL;
}
return tag_get_name(object->definition_index);
}
void server_event_player_killed(
long killing_player_index,
long killing_object_index,
long dead_player_index,
boolean friendly_fire)
{
struct server_json json;
char const *weapon;
if (!halo_server_mode() || dead_player_index == NONE)
return;
weapon = server_status_killing_tag(killing_object_index);
server_json_begin(&json);
server_json_object_begin(&json, NULL);
server_json_string(&json, "event", "kill");
server_json_integer(&json, "killer", killing_player_index != NONE ?
server_status_slot_of(DATUM_INDEX_TO_ABSOLUTE_INDEX(killing_player_index)) : NONE);
server_json_integer(&json, "victim", server_status_slot_of(DATUM_INDEX_TO_ABSOLUTE_INDEX(dead_player_index)));
if (weapon)
server_json_string(&json, "weapon", weapon);
else
server_json_null(&json, "weapon");
server_json_boolean(&json, "friendly_fire", friendly_fire);
server_json_object_end(&json);
server_event_add("game", server_json_text(&json));
server_json_free(&json);
}
void server_event_chat(
short player_index,
short channel,
char const *text)
{
static char const *const channel_names[] = { "everyone", "team", "vehicle" };
struct server_json json;
long slot;
char name[16];
if (!halo_server_mode())
return;
slot = server_status_slot_of(player_index);
server_status_player_name(slot, name, sizeof(name));
server_json_begin(&json);
server_json_object_begin(&json, NULL);
server_json_string(&json, "event", "chat");
server_json_string(&json, "channel", channel >= 0 && channel < (short)NUMBEROF(channel_names) ?
channel_names[channel] : "everyone");
server_json_integer(&json, "slot", slot);
server_json_string(&json, "name", name);
server_json_string(&json, "text", text);
server_json_object_end(&json);
server_event_add("chat", server_json_text(&json));
server_json_free(&json);
}
void server_event_speed_hack(
long machine_index,
char const *names,
real rate,
long ahead_ticks,
char const *action)
{
struct server_json json;
if (!halo_server_mode())
return;
server_json_begin(&json);
server_json_object_begin(&json, NULL);
server_json_string(&json, "event", "speed_hack");
server_json_integer(&json, "machine", machine_index);
server_json_string(&json, "names", names);
server_json_real(&json, "rate", rate);
server_json_integer(&json, "ahead_ticks", ahead_ticks);
server_json_string(&json, "action", action);
server_json_object_end(&json);
server_event_add("moderation", server_json_text(&json));
server_json_free(&json);
/* (one dropped is kept out, its line in bans.txt: its players banned) */
if (!strcmp(action, "kicked"))
server_director_player_removed(machine_index, "banned");
}
void server_event_map_share(
long machine_index,
char const *map,
char const *state,
unsigned long done,
unsigned long total)
{
struct server_json json;
if (!halo_server_mode())
return;
server_json_begin(&json);
server_json_object_begin(&json, NULL);
server_json_string(&json, "event", "map_share");
server_json_integer(&json, "machine", machine_index);
server_json_string(&json, "map", map);
server_json_string(&json, "state", state);
server_json_integer(&json, "progress", total ? (long long)((double)done * 100.0 / (double)total) : 0);
server_json_object_end(&json);
server_event_add("player", server_json_text(&json));
server_json_free(&json);
}
#else
void server_event_player_killed(long killing_player_index, long killing_object_index, long dead_player_index,
boolean friendly_fire)
{
}
void server_event_chat(short player_index, short channel, char const *text)
{
}
void server_event_speed_hack(long machine_index, char const *names, real rate, long ahead_ticks, char const *action)
{
}
void server_event_map_share(long machine_index, char const *map, char const *state, unsigned long done,
unsigned long total)
{
}
#endif
@@ -93,6 +93,23 @@ void halo_shader_programs_map_disposed(void);
/* display.hdr's tone mapping of a window's 3D view, before its
anti-aliasing (source/render/render.c) */
void halo_screen_tone_map(short x0, short y0, short x1, short y1);
/* network.client_timeout_seconds, in milliseconds (port/linux/src/port_config.c) */
unsigned long network_client_timeout_milliseconds(void);
/* a dedicated server (halo --server: port/linux/src/server_mode.c,
port/linux/game/server_director.c; docs/dedicated_server.md): whether this
run is one; its start, before the game's (shell_xbox.c); its frames' pace
(main.c); the game's fatal error ended with (main.c); and the console's
output, which it logs and captures for its commands' replies (terminal.c) */
int halo_server_mode(void);
void server_mode_start(void);
void server_mode_pace_frame(void);
void server_fatal(char const *text);
void server_console_output(const char *text);
/* ... whether debug.txt's lines go to the standard error too
(server.log_debug_echo: errors.c) */
int halo_server_debug_echo(void);
/* ... the level its rotation plays next, NULL if none (game_engine.c) */
char const *server_director_first_level(void);
/* the mouse in the menus (source/interface/ui_widget.c) */
#include "halo_ui_pointer.h"
+4 -1
View File
@@ -66,7 +66,7 @@ is newer (network_client_manager.c). A host advertises it, with its netcode,
in its game's advertisement's reserved bytes (network_server_message_handler.c),
which hosts built before there was a version send as zeros: version 0.
Raise it with any change to what the machines send each other. */
#define HALO_PORT_NETWORK_VERSION 38
#define HALO_PORT_NETWORK_VERSION 39
/* ... the advertisement's reserved bytes: the version (a little-endian word),
then flags, then the game's kind (an OpenCE gametype's mode, and Hardcore:
port/linux/game/game_modes.h's game_modes_kind), for the server browser */
@@ -79,6 +79,9 @@ netcode was removed; hosts of version 4 built before then may not) */
/* ... the game is under way (loading, playing or over), not in its lobby:
the menus show it before joining it (hosts built before then never set it) */
#define HALO_PORT_ADVERTISED_IN_PROGRESS_FLAG 0x02
/* ... the host is a dedicated server, with no player of its own (halo
--server: port/linux/src/server_mode.c): the menus say so (from version 39) */
#define HALO_PORT_ADVERTISED_DEDICATED_FLAG 0x04
/* a message header's 12-bit length allows messages of up to 0xFFF bytes,
header included; the per-tick update of 128 players is 3,857 */
+12 -1
View File
@@ -1,7 +1,12 @@
version 38
version 39
bound CHAT_TEXT_SIZE 64
bound DISCORD_ID_SIZE 24
bound DISCORD_NAME_SIZE 40
bound DISTRIBUTED_CLIENT_BUILD_FLAVOR_SIZE 16
bound DISTRIBUTED_CLIENT_BUILD_MODS_SIZE 512
bound DISTRIBUTED_CLIENT_BUILD_NUMBER_SIZE 12
bound DISTRIBUTED_CLIENT_BUILD_REPOSITORY_SIZE 144
bound DISTRIBUTED_CLIENT_BUILD_VERSION_SIZE 64
bound DISTRIBUTED_INPUT_HISTORY 4
bound DISTRIBUTED_RELAYED_ACTION_MAXIMUM_SIZE 14 + 2 * (DISTRIBUTED_INPUT_HISTORY - 1)
bound DISTRIBUTED_RELAYED_ACTION_MINIMUM_SIZE 14
@@ -14,6 +19,7 @@ bound MAP_SHARE_HASH_BYTES 32
bound MAXIMUM_DEVICE_GROUPS 1024
bound MAXIMUM_DEVICE_STATES_PER_MESSAGE 64
bound MAXIMUM_ENTRIES_PER_MESSAGE 64
bound MAXIMUM_LOCAL_PLAYERS 4
bound MAXIMUM_OBJECT_LOOKS_PER_MESSAGE 64
bound MAXIMUM_OBJECT_NAMES_PER_SCENARIO 0x200
bound MAXIMUM_OBJECT_TRANSFORMS_PER_MESSAGE 64
@@ -55,6 +61,7 @@ enum _distributed_message_batch 16
enum _distributed_message_chat_config 89
enum _distributed_message_chat_down 88
enum _distributed_message_chat_up 87
enum _distributed_message_client_build 92
enum _distributed_message_client_identity 18
enum _distributed_message_client_ready 9
enum _distributed_message_coop_device_groups 68
@@ -69,6 +76,7 @@ enum _distributed_message_coop_skip_vote 70
enum _distributed_message_coop_sounds_retired 67
enum _distributed_message_damage_animations 75
enum _distributed_message_damage_events 10
enum _distributed_message_fire_origins 93
enum _distributed_message_game_state 7
enum _distributed_message_hit_reports 11
enum _distributed_message_inventories 4
@@ -238,6 +246,7 @@ struct distributed_actor_state 8bd5e771659d
struct distributed_actor_state_message 51a56e04a536
struct distributed_chat_config dfa4e32efe1e
struct distributed_chat_line cdab1468ffe0
struct distributed_client_build 503e4474b798
struct distributed_client_identity 4d9e62025616
struct distributed_coop_device_group 4aa3499be2cc
struct distributed_coop_device_groups_message b96ad0030ccc
@@ -261,6 +270,8 @@ struct distributed_damage_animation ae158e21c915
struct distributed_damage_animations_message 863e25ca3614
struct distributed_damage_event 659e4d49733b
struct distributed_damage_event_message c954363dcf26
struct distributed_fire_origin 700dfdb20582
struct distributed_fire_origin_message 7613e8f9d43e
struct distributed_hit_report 9a44ca43a88b
struct distributed_hit_report_message 37279f2addca
struct distributed_infection_player d8b98eecf26b
+5
View File
@@ -973,6 +973,11 @@ static struct
static void vertex_array_cache_invalidate(void);
#endif
int xgpu_gl_ready(void)
{
return device.gl_ready;
}
void xgpu_gl_state_invalidate(void)
{
memset(&gl_state, 0xff, sizeof(gl_state));
+11 -3
View File
@@ -428,11 +428,13 @@ static void upscale_outline(unsigned char *pixels, const unsigned char *bgra,
unsigned int hud_hires_upscale_texture(const unsigned char *bgra, unsigned long width,
unsigned long height, long kind, unsigned long *levels)
{
unsigned char *pixels = kind == 4 ? upscale_pixels(bgra, width, height, 512) :
hud_hires_upscale_pixels(bgra, width, height);
unsigned char *pixels;
unsigned long largest;
GLuint texture;
if (!xgpu_gl_ready())
return 0;
pixels = kind == 4 ? upscale_pixels(bgra, width, height, 512) : hud_hires_upscale_pixels(bgra, width, height);
if (!pixels)
return 0;
upscale_outline(pixels, bgra, width, height, kind);
@@ -584,6 +586,12 @@ static unsigned int png_pixels_texture(unsigned char *pixels, unsigned long widt
unsigned long largest;
GLuint texture;
/* (no OpenGL: a null renderer's postgame scoreboard asks for its icons) */
if (!pixels || !xgpu_gl_ready())
{
free(pixels);
return 0;
}
*levels = 1;
for (largest = width > height ? width : height; largest > 1; largest >>= 1)
(*levels)++;
@@ -625,7 +633,7 @@ unsigned int hud_hires_override_texture(long asset, unsigned long *levels)
{
const struct hud_hires_embedded *embedded;
if (asset < 0 || asset >= hud_hires_asset_count() || textures[asset].failed)
if (asset < 0 || asset >= hud_hires_asset_count() || textures[asset].failed || !xgpu_gl_ready())
return 0;
if (textures[asset].texture)
{
+704
View File
@@ -0,0 +1,704 @@
/*
MASTER_LIST.C
The server browser's master servers (master_list.h).
*/
#include "platform.h"
#include "port_config.h"
#include "master_list.h"
#include "p2p.h"
#include "p2p_internal.h"
#include "server_json.h"
#include <ctype.h>
#include <errno.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
#include <SDL3/SDL.h>
#include "update.h"
#endif
enum
{
/* seconds between rounds while browsing; a list older than this is
refused (a replayed one), and one dated further ahead of this machine's
clock (one made to be replayed later) */
ROUND_SECONDS = 30,
FRESH_SECONDS = 300,
MAXIMUM_ENTRIES = 512,
MAXIMUM_ANSWER = 1024 * 1024,
MAXIMUM_LISTING = 400,
};
static struct
{
pthread_mutex_t lock;
pthread_cond_t wake;
/* each opening of the browser's: a thread of an earlier one (still
asking a master when the browser was closed and opened again) ends at
its next look, without being waited for */
unsigned long generation;
int browsing;
int refresh;
int count;
int answered;
int asked;
char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE];
unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE];
} list = { PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER };
/* waits on the condition for seconds at most */
static void wait_seconds(int seconds)
{
struct timespec until;
clock_gettime(CLOCK_REALTIME, &until);
until.tv_sec += seconds;
pthread_cond_timedwait(&list.wake, &list.lock, &until);
}
static int hex_digit(char character)
{
if (character >= '0' && character <= '9')
return character - '0';
if (character >= 'a' && character <= 'f')
return character - 'a' + 10;
if (character >= 'A' && character <= 'F')
return character - 'A' + 10;
return -1;
}
/* ---------- masters.txt */
/* whether a master's URL may be asked: https, or plain http to this machine
alone (a test's master): http://127.0.0.1 or http://localhost, then a port,
a path or nothing (not http://127.0.0.1.example.net) */
static int master_url_allowed(const char *url)
{
static const char *const loopbacks[] = { "http://127.0.0.1", "http://localhost" };
size_t index;
if (!strncmp(url, "https://", 8))
return 1;
for (index = 0; index < sizeof(loopbacks) / sizeof(loopbacks[0]); index++)
{
size_t length = strlen(loopbacks[index]);
if (!strncmp(url, loopbacks[index], length) && (!url[length] || url[length] == ':' || url[length] == '/'))
return 1;
}
return 0;
}
static char *masters_file(const char *setting, char *path, int size)
{
char folder[1024];
size_t length;
char *text;
const char *base;
if (setting[0] == '/' || (setting[0] && setting[1] == ':'))
{
snprintf(path, (size_t)size, "%s", setting);
return config_file_read(path, &length);
}
config_folder(folder, sizeof(folder));
snprintf(path, (size_t)size, "%s%s", folder, setting);
text = config_file_read(path, &length);
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
base = SDL_GetBasePath();
if (!text && base)
{
snprintf(path, (size_t)size, "%s%s", base, setting);
text = config_file_read(path, &length);
}
#else
(void)base;
#endif
return text;
}
int master_list_read_file(const char *setting, char (*urls)[MASTER_LIST_URL_SIZE],
unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum)
{
char path[1200];
char *text = masters_file(setting, path, sizeof(path));
const char *line;
int count = 0;
if (!text)
return 0;
for (line = text; *line && count < maximum;)
{
const char *end = line + strcspn(line, "\n");
char copy[512];
char url[MASTER_LIST_URL_SIZE], key[96];
int index, ok = 1;
snprintf(copy, sizeof(copy), "%.*s", (int)(end - line), line);
copy[strcspn(copy, "#\r")] = 0;
line = *end ? end + 1 : end;
if (sscanf(copy, "%255s %95s", url, key) != 2)
continue;
/* (https, or plain http to this machine: a test's master) */
if (!master_url_allowed(url) ||
strncmp(key, "ed25519:", 8) || strlen(key) != 8 + 2 * MASTER_LIST_KEY_SIZE)
{
continue;
}
for (index = 0; index < MASTER_LIST_KEY_SIZE && ok; index++)
{
int high = hex_digit(key[8 + 2 * index]), low = hex_digit(key[8 + 2 * index + 1]);
ok = high >= 0 && low >= 0;
keys[count][index] = (unsigned char)(high << 4 | low);
}
if (!ok)
continue;
while (strlen(url) > 8 && url[strlen(url) - 1] == '/')
url[strlen(url) - 1] = 0;
snprintf(urls[count], MASTER_LIST_URL_SIZE, "%s", url);
count++;
}
free(text);
return count;
}
/* one master (a URL and its "ed25519:<hex>" key) checked and kept: 1 */
static int master_keep(const char *url, const char *key, char (*urls)[MASTER_LIST_URL_SIZE],
unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int index)
{
int digit;
if (!master_url_allowed(url) ||
strlen(url) >= MASTER_LIST_URL_SIZE || strncmp(key, "ed25519:", 8) || strlen(key) != 8 + 2 * MASTER_LIST_KEY_SIZE)
{
return 0;
}
for (digit = 0; digit < MASTER_LIST_KEY_SIZE; digit++)
{
int high = hex_digit(key[8 + 2 * digit]), low = hex_digit(key[8 + 2 * digit + 1]);
if (high < 0 || low < 0)
return 0;
keys[index][digit] = (unsigned char)(high << 4 | low);
}
snprintf(urls[index], MASTER_LIST_URL_SIZE, "%s", url);
while (strlen(urls[index]) > 8 && urls[index][strlen(urls[index]) - 1] == '/')
urls[index][strlen(urls[index]) - 1] = 0;
return 1;
}
/* the official master servers, asked when masters.txt names none (it is
missing, empty or has comments alone) */
int master_list_read_defaults(char (*urls)[MASTER_LIST_URL_SIZE], unsigned char (*keys)[MASTER_LIST_KEY_SIZE],
int maximum)
{
static const struct
{
const char *url;
const char *key;
} masters[] =
{
{ "https://master.opencommunityedition.com",
"ed25519:89f4eda71229f11f3538679f49ef7fb0c2b407b380093f7f4fe02105b92225aa" },
};
int index, count = 0;
for (index = 0; index < (int)(sizeof(masters) / sizeof(masters[0])) && count < maximum; index++)
count += master_keep(masters[index].url, masters[index].key, urls, keys, count);
return count;
}
int master_list_read(const char *setting, char (*urls)[MASTER_LIST_URL_SIZE],
unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum)
{
int count = master_list_read_file(setting, urls, keys, maximum);
return count ? count : master_list_read_defaults(urls, keys, maximum);
}
int master_list_read_json(const char *text, unsigned long size, char (*urls)[MASTER_LIST_URL_SIZE],
unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum)
{
struct server_json_document document;
int masters, index, count = 0;
if (!server_json_parse(&document, text, (int)size, 8))
return 0;
masters = server_json_member(&document, 0, "masters");
for (index = 0; count < maximum && server_json_element(&document, masters, index) >= 0; index++)
{
int entry = server_json_element(&document, masters, index);
char url[MASTER_LIST_URL_SIZE], key[96];
if (server_json_get_string(&document, server_json_member(&document, entry, "url"), url, sizeof(url)) &&
server_json_get_string(&document, server_json_member(&document, entry, "key"), key, sizeof(key)))
{
count += master_keep(url, key, urls, keys, count);
}
}
server_json_document_free(&document);
return count;
}
int master_list_policy(void)
{
const char *setting = config_string("network.listings");
if (!strcmp(setting, "masters"))
return _master_listings_masters;
if (!strcmp(setting, "brokers"))
return _master_listings_brokers;
return _master_listings_both;
}
/* ---------- a list's checks */
int master_list_verify(const char *body, unsigned long size, const char *signature_header, const char *key_header,
const unsigned char *key, unsigned long now, char *why, int why_size)
{
unsigned char signature[P2P_SIGNATURE_SIZE + 4];
unsigned char digest[P2P_SHA256_SIZE];
char key_id[2 * 8 + 1];
char text[96];
struct server_json_document document;
double protocol, time_value;
int master, ok;
p2p_sha256(key, MASTER_LIST_KEY_SIZE, digest);
p2p_hex(digest, 8, key_id);
if (strncmp(signature_header, "ed25519=", 8) ||
server_base64_decode(signature_header + 8, signature, sizeof(signature)) != P2P_SIGNATURE_SIZE)
{
snprintf(why, (size_t)why_size, "no signature");
return 0;
}
/* (the key the master says it signed with, which must be the one
pinned: a list without it is refused too) */
if (!key_header || strcmp(key_header, key_id))
{
snprintf(why, (size_t)why_size, "signed by another key (%.16s)", key_header ? key_header : "none said");
return 0;
}
if (!p2p_ed25519_verify(key, body, (int)size, signature))
{
snprintf(why, (size_t)why_size, "its signature does not check");
return 0;
}
if (!server_json_parse_tokens(&document, body, (int)size, 16, SERVER_JSON_MAXIMUM_LIST_TOKENS) ||
document.tokens[0].type != _server_json_object)
{
snprintf(why, (size_t)why_size, "not a list");
return 0;
}
master = server_json_member(&document, 0, "master");
ok = server_json_get_number(&document, server_json_member(&document, 0, "protocol"), &protocol) && protocol == 1 &&
server_json_get_string(&document, server_json_member(&document, master, "key_id"), text, sizeof(text)) &&
!strcmp(text, key_id) &&
server_json_get_number(&document, server_json_member(&document, master, "time"), &time_value);
server_json_document_free(&document);
if (!ok)
{
snprintf(why, (size_t)why_size, "not a list of protocol 1 by this master");
return 0;
}
if ((double)now - time_value > FRESH_SECONDS)
{
snprintf(why, (size_t)why_size, "a list %.0f seconds old", (double)now - time_value);
return 0;
}
if (time_value - (double)now > FRESH_SECONDS)
{
snprintf(why, (size_t)why_size, "a list dated %.0f seconds ahead (this machine's clock is wrong, or the "
"master's)", time_value - (double)now);
return 0;
}
return 1;
}
/* ---------- fetching */
struct fetched
{
int status;
char *body;
unsigned long size;
char signature[128];
char key[32];
};
/* whether the text begins with the name, in either case */
static int begins_with_name(const char *text, const char *name, size_t length)
{
size_t index;
for (index = 0; index < length; index++)
{
if (!text[index] || tolower((unsigned char)text[index]) != tolower((unsigned char)name[index]))
return 0;
}
return 1;
}
/* a response's header's value, from its "Name: value" lines */
static void header_value(const char *headers, const char *name, char *value, int size)
{
size_t length = strlen(name);
const char *line = headers;
value[0] = 0;
while (line && *line)
{
if (begins_with_name(line, name, length) && line[length] == ':')
{
const char *start = line + length + 1;
int count;
while (*start == ' ' || *start == '\t')
start++;
count = (int)strcspn(start, "\r\n");
snprintf(value, (size_t)size, "%.*s", count, start);
return;
}
line = strchr(line, '\n');
if (line)
line++;
}
}
#if defined(HALO_WEB)
/* the page's fetch (port/web/src/web_library.js's web_js_fetch), which this
thread waits for: its answer in the slot. The page claims the slot for its
answer by changing its id to -id (Atomics.compareExchange), writes it, then
sets done; the thread gives up on it by changing the id to 0 the same way,
so an answer and a give-up never cross */
struct web_fetch_slot
{
int id;
int done;
int status;
char *body;
int size;
char signature[128];
char key[32];
};
/* (url: a copy the page frees once read) */
void web_js_fetch(char *url, struct web_fetch_slot *slot, int id);
static int fetch(const char *url, struct fetched *fetched)
{
/* (one slot, which the page writes into: one fetch at a time, a thread
of an earlier opening of the browser's included) */
static pthread_mutex_t one_at_a_time = PTHREAD_MUTEX_INITIALIZER;
static struct web_fetch_slot slot;
static int next_id;
char *url_copy = strdup(url);
int id, waited, fetched_one;
if (!url_copy)
return 0;
pthread_mutex_lock(&one_at_a_time);
/* (no answer of an earlier fetch writes to it now: it was taken, or
given up on before the page claimed it) */
memset(&slot, 0, sizeof(slot));
if (++next_id <= 0)
next_id = 1;
id = next_id;
__atomic_store_n(&slot.id, id, __ATOMIC_SEQ_CST);
web_js_fetch(url_copy, &slot, id);
for (waited = 0; !__atomic_load_n(&slot.done, __ATOMIC_ACQUIRE) && waited < 500; waited++)
{
struct timespec pause = { 0, 20 * 1000 * 1000 };
nanosleep(&pause, NULL);
}
if (!__atomic_load_n(&slot.done, __ATOMIC_ACQUIRE))
{
int expected = id;
/* (given up on: an answer coming later finds another id, and the
page drops it) */
if (__atomic_compare_exchange_n(&slot.id, &expected, 0, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST))
{
pthread_mutex_unlock(&one_at_a_time);
return 0;
}
/* (the page claimed it as the time ran out: its answer is being
written, at once) */
while (!__atomic_load_n(&slot.done, __ATOMIC_ACQUIRE))
{
struct timespec pause = { 0, 1000 * 1000 };
nanosleep(&pause, NULL);
}
}
fetched->status = slot.status;
fetched->body = slot.body;
fetched->size = (unsigned long)slot.size;
snprintf(fetched->signature, sizeof(fetched->signature), "%s", slot.signature);
snprintf(fetched->key, sizeof(fetched->key), "%s", slot.key);
fetched_one = slot.status > 0;
pthread_mutex_unlock(&one_at_a_time);
return fetched_one;
}
#elif defined(HALO_ANDROID)
/* the app's host (port/android/host/host_https.c) */
int host_https_get(const char *url, int *status, char *headers, int headers_size, char *body, int body_capacity,
int *body_size, char *error, int error_size);
static int fetch(const char *url, struct fetched *fetched)
{
enum { HEADERS_SIZE = 4096 };
/* (each call's own: a thread of an earlier opening of the browser's may
be fetching too) */
char *headers = malloc(HEADERS_SIZE);
char error[256];
int body_size = 0;
fetched->body = malloc(MAXIMUM_ANSWER + 1);
if (!headers || !fetched->body || !host_https_get(url, &fetched->status, headers, HEADERS_SIZE, fetched->body,
MAXIMUM_ANSWER, &body_size, error, sizeof(error)))
{
free(headers);
free(fetched->body);
fetched->body = NULL;
return 0;
}
fetched->body[body_size] = 0;
fetched->size = (unsigned long)body_size;
header_value(headers, "Signature", fetched->signature, sizeof(fetched->signature));
header_value(headers, "Signature-Key", fetched->key, sizeof(fetched->key));
free(headers);
return 1;
}
#else
static int fetch(const char *url, struct fetched *fetched)
{
struct update_response response;
char error[256];
if (!update_https_request("GET", url, NULL, NULL, 0, MAXIMUM_ANSWER, &response, error, sizeof(error)))
{
platform_log("server browser: %s: %s", url, error);
return 0;
}
fetched->status = response.status;
fetched->body = response.body;
fetched->size = response.body_size;
header_value(response.headers, "Signature", fetched->signature, sizeof(fetched->signature));
header_value(response.headers, "Signature-Key", fetched->key, sizeof(fetched->key));
return 1;
}
#endif
/* the masters to ask (the list thread's): masters.txt's; else, in the web
build, its site's masters.json (the page's list of them, which its Servers
panel reads too); else the official ones */
static void read_masters(void)
{
char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE];
unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE];
int count = master_list_read_file(config_string("network.masters_file"), urls, keys, MASTER_LIST_MAXIMUM_MASTERS);
#if defined(HALO_WEB)
if (!count)
{
struct fetched fetched;
memset(&fetched, 0, sizeof(fetched));
if (fetch("masters.json", &fetched) && fetched.status == 200)
count = master_list_read_json(fetched.body, fetched.size, urls, keys, MASTER_LIST_MAXIMUM_MASTERS);
free(fetched.body);
}
#endif
if (!count)
count = master_list_read_defaults(urls, keys, MASTER_LIST_MAXIMUM_MASTERS);
pthread_mutex_lock(&list.lock);
memcpy(list.urls, urls, sizeof(urls));
memcpy(list.keys, keys, sizeof(keys));
list.count = count;
pthread_mutex_unlock(&list.lock);
}
/* a list's servers given to the browser: how many were taken */
static int take_list(const char *body, unsigned long size)
{
struct server_json_document document;
int servers, index, taken = 0;
if (!server_json_parse_tokens(&document, body, (int)size, 16, SERVER_JSON_MAXIMUM_LIST_TOKENS))
return 0;
servers = server_json_member(&document, 0, "servers");
for (index = 0; index < MAXIMUM_ENTRIES && server_json_element(&document, servers, index) >= 0; index++)
{
int entry = server_json_element(&document, servers, index);
char listing_text[600];
unsigned char listing[MAXIMUM_LISTING];
char publisher[64] = "", region[32] = "";
double version = 0;
int listing_size, character, dedicated;
/* (an entry that is not one is skipped, not the list) */
if (!server_json_get_string(&document, server_json_member(&document, entry, "listing"), listing_text,
sizeof(listing_text)))
{
continue;
}
listing_size = server_base64_decode(listing_text, listing, sizeof(listing));
if (listing_size <= 0)
continue;
server_json_get_string(&document, server_json_member(&document, server_json_member(&document, entry, "publisher"),
"name"), publisher, sizeof(publisher));
server_json_get_string(&document, server_json_member(&document, entry, "region"), region, sizeof(region));
server_json_get_number(&document, server_json_member(&document, entry, "network_version"), &version);
/* (a dedicated server's, as the entry says: a player's public game,
published with a publisher key, is not) */
dedicated = 0;
server_json_get_boolean(&document, server_json_member(&document, entry, "dedicated"), &dedicated);
/* (the publisher's name and the region kept to printable ASCII, cut
to their sizes: shown in the ticker) */
for (character = 0; publisher[character]; character++)
{
if ((unsigned char)publisher[character] < 0x20 || (unsigned char)publisher[character] >= 0x7F)
publisher[character] = '?';
}
publisher[P2P_LISTING_PUBLISHER_SIZE] = 0;
for (character = 0; region[character]; character++)
{
if (!(isalnum((unsigned char)region[character]) || region[character] == '-'))
region[character] = '?';
}
region[P2P_LISTING_REGION_SIZE] = 0;
taken += p2p_lobby_take_master_listing(listing, listing_size, publisher, region, (int)version, dedicated);
}
server_json_document_free(&document);
return taken;
}
/* under list.lock: whether the thread of that opening goes on */
static int list_current(unsigned long generation)
{
return list.browsing && list.generation == generation;
}
static void *list_thread_main(void *argument)
{
unsigned long generation = (unsigned long)(uintptr_t)argument;
char urls[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_URL_SIZE];
unsigned char keys[MASTER_LIST_MAXIMUM_MASTERS][MASTER_LIST_KEY_SIZE];
int count;
read_masters();
pthread_mutex_lock(&list.lock);
while (list_current(generation))
{
int index, answered = 0;
list.refresh = 0;
list.asked = 0;
/* (the masters as read, this thread's own copy) */
memcpy(urls, list.urls, sizeof(urls));
memcpy(keys, list.keys, sizeof(keys));
count = list.count;
pthread_mutex_unlock(&list.lock);
p2p_lobby_masters_heard();
for (index = 0; index < count; index++)
{
struct fetched fetched;
char url[MASTER_LIST_URL_SIZE + 64];
char why[128];
memset(&fetched, 0, sizeof(fetched));
snprintf(url, sizeof(url), "%s/v1/servers", urls[index]);
if (!fetch(url, &fetched))
{
platform_log("server browser: no answer from %s", urls[index]);
}
else if (fetched.status != 200)
{
platform_log("server browser: %s answered %d", urls[index], fetched.status);
}
else if (!master_list_verify(fetched.body, fetched.size, fetched.signature, fetched.key, keys[index],
(unsigned long)time(NULL), why, sizeof(why)))
{
platform_log("server browser: %s's list refused: %s", urls[index], why);
}
else
{
int taken = take_list(fetched.body, fetched.size);
answered++;
platform_log("server browser: %s lists %d server%s", urls[index], taken, taken == 1 ? "" : "s");
}
free(fetched.body);
pthread_mutex_lock(&list.lock);
list.asked++;
list.answered = answered;
if (!list_current(generation))
{
pthread_mutex_unlock(&list.lock);
break;
}
pthread_mutex_unlock(&list.lock);
}
pthread_mutex_lock(&list.lock);
list.answered = answered;
if (list_current(generation) && !list.refresh)
wait_seconds(ROUND_SECONDS);
}
pthread_mutex_unlock(&list.lock);
return NULL;
}
void master_list_browse(int on)
{
if (on && (master_list_policy() == _master_listings_brokers || !config_boolean("network.online")))
on = 0;
pthread_mutex_lock(&list.lock);
if (on && !list.browsing)
{
pthread_t thread;
list.answered = 0;
list.asked = 0;
list.browsing = 1;
list.generation++;
if (pthread_create(&thread, NULL, list_thread_main, (void *)(uintptr_t)list.generation) == 0)
pthread_detach(thread);
else
list.browsing = 0;
pthread_mutex_unlock(&list.lock);
return;
}
if (!on && list.browsing)
{
list.browsing = 0;
pthread_cond_broadcast(&list.wake);
}
pthread_mutex_unlock(&list.lock);
}
void master_list_refresh(void)
{
pthread_mutex_lock(&list.lock);
list.refresh = 1;
pthread_cond_broadcast(&list.wake);
pthread_mutex_unlock(&list.lock);
}
void master_list_counts(int *count, int *answered, int *asked)
{
pthread_mutex_lock(&list.lock);
*count = list.count;
*answered = list.answered;
*asked = list.asked;
pthread_mutex_unlock(&list.lock);
}
+68
View File
@@ -0,0 +1,68 @@
/*
MASTER_LIST.H
The server browser's master servers (master_list.c; docs/master_protocol.md,
"The list"): while the browser is open, each master of masters.txt is asked
for its list every 30 seconds (and on Refresh), on a thread of its own (the
official masters, built in, when masters.txt names none); a list whose
signature by the master's key (pinned in masters.txt, or built in) checks, and
that is fresh, gives its dedicated servers' host-signed listings to the
browser's games (p2p_lobby.c), each checked again there. network.listings
says whether the masters are asked at all.
*/
#ifndef __HALO_LINUX_MASTER_LIST_H
#define __HALO_LINUX_MASTER_LIST_H
enum
{
MASTER_LIST_MAXIMUM_MASTERS = 4,
MASTER_LIST_KEY_SIZE = 32,
MASTER_LIST_URL_SIZE = 256,
};
/* network.listings: what the server browser lists */
enum
{
_master_listings_both,
_master_listings_masters,
_master_listings_brokers,
};
/* the masters of a masters.txt (the setting's file: beside config.toml
unless a full path, else beside the executable): their URLs and Ed25519
keys, up to maximum; how many (none: no file, or one with no master) */
int master_list_read_file(const char *setting, char (*urls)[MASTER_LIST_URL_SIZE],
unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum);
/* ... the official master servers (built in): how many */
int master_list_read_defaults(char (*urls)[MASTER_LIST_URL_SIZE], unsigned char (*keys)[MASTER_LIST_KEY_SIZE],
int maximum);
/* ... masters.txt's masters, else (it names none) the official ones. The
server browser and a dedicated server's publisher ask these */
int master_list_read(const char *setting, char (*urls)[MASTER_LIST_URL_SIZE],
unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum);
/* ... the same of a masters.json ({"masters":[{"url":…,"key":"ed25519:…"}]},
the web site's) */
int master_list_read_json(const char *text, unsigned long size, char (*urls)[MASTER_LIST_URL_SIZE],
unsigned char (*keys)[MASTER_LIST_KEY_SIZE], int maximum);
/* the server browser open (asked every 30 seconds) or closed */
void master_list_browse(int on);
/* Refresh: asked again now */
void master_list_refresh(void);
/* network.listings' choice */
int master_list_policy(void);
/* the masters in masters.txt, how many answered with a list that checked
last time, and how many were asked yet (all: a round is done) */
void master_list_counts(int *count, int *answered, int *asked);
/* a signed list's checks (pure: the harness tests them): the body's
signature header's value ("ed25519=<base64>") over the body with the key,
the Signature-Key header's (the key's id: none is refused), and the body's
master and time (300 seconds old at most, and ahead of now at most); 1 if
it is a list to take */
int master_list_verify(const char *body, unsigned long size, const char *signature_header, const char *key_header,
const unsigned char *key, unsigned long now, char *why, int why_size);
#endif
+211 -9
View File
@@ -60,6 +60,7 @@ only look up and create stand-ins.
#include "platform.h"
#include "posix.h"
#include "server_master.h"
#include "port_config.h"
#include "p2p_internal.h"
#include "log_address.h"
@@ -422,6 +423,13 @@ static struct
unsigned long join_time;
/* how the latest join goes, for the menus (p2p_join_state) */
int join_state;
/* the join's progress (p2p_join_progress): one was asked for this run;
its host answered; it was given up (JOIN_TIMEOUT); the host's game
refused it (its rejection code, -1 if not) */
int join_started;
int join_answered;
int join_gave_up;
int join_refused;
char clipboard[P2P_LINK_SIZE];
int has_clipboard;
@@ -435,6 +443,14 @@ static struct
int upnp_released;
struct p2p_candidate upnp_candidate;
unsigned long upnp_time;
/* network.public_address, looked up (an address and port joiners reach
this machine at, behind a NAT with one forwarded port): offered first;
the text it was looked up from, and when */
struct p2p_candidate public_candidate;
int has_public;
char public_text[256];
unsigned long public_time;
int public_logged;
} p2p = { 0, 0, -1, 0, -1, .hosting_socket = -1 };
/* the proxy (its index + 1) with each local port (all of theirs are on
@@ -963,9 +979,9 @@ static void drop_peer(struct peer *peer, const char *reason)
int ask_again = p2p.joining && peer->is_host && !memcmp(peer->identifier, p2p.join_host, P2P_IDENTIFIER_SIZE);
if (peer->is_host && !memcmp(peer->identifier, p2p.join_host, P2P_IDENTIFIER_SIZE) &&
p2p.join_state != P2P_JOIN_NONE)
p2p.join_state != P2P_JOIN_STATE_NONE)
{
p2p.join_state = peer->connected ? P2P_JOIN_LOST : P2P_JOIN_BLOCKED;
p2p.join_state = peer->connected ? P2P_JOIN_STATE_LOST : P2P_JOIN_STATE_BLOCKED;
}
if (!peer->probe)
@@ -1127,6 +1143,12 @@ int p2p_peer_offered(const unsigned char *peer_identifier, const unsigned char *
p2p_key_hash(public_key, peer->key_hash);
peer->connection = -1;
peer->offered_time = p2p_now();
/* (the host of the join under way answered it: p2p_join_progress) */
if (offered == _p2p_offered_host && (p2p.joining || p2p.join_requested) &&
!memcmp(peer_identifier, p2p.join_host, P2P_IDENTIFIER_SIZE))
{
p2p.join_answered = 1;
}
if (!peer->probe)
{
platform_log("Internet play: reaching %s %s%s", offered == _p2p_offered_host ? "host" : "player",
@@ -1211,8 +1233,8 @@ static void peer_heard(struct peer *peer, unsigned long address, unsigned short
peer->name, log_address_ipv4(address, port, text, sizeof(text)));
if (peer->is_host && !peer->is_player)
{
if (!memcmp(p2p.join_host, peer->identifier, P2P_IDENTIFIER_SIZE) && p2p.join_state != P2P_JOIN_NONE)
p2p.join_state = P2P_JOIN_CONNECTED;
if (!memcmp(p2p.join_host, peer->identifier, P2P_IDENTIFIER_SIZE) && p2p.join_state != P2P_JOIN_STATE_NONE)
p2p.join_state = P2P_JOIN_STATE_CONNECTED;
if (p2p.joining && !memcmp(p2p.join_host, peer->identifier, P2P_IDENTIFIER_SIZE))
{
p2p.joining = 0;
@@ -1337,6 +1359,98 @@ static void stun_setup(void)
}
}
/* network.public_address ("host[:port]"; the port the router forwards to
the tunnel's, else the tunnel's own), looked up as it changes and every
PUBLIC_ADDRESS_SECONDS: a host name's address may change. Under p2p_lock,
on the p2p thread */
static void public_address_update(void)
{
enum { PUBLIC_ADDRESS_SECONDS = 300 };
const char *setting = config_string("network.public_address");
char host[256];
const char *colon;
unsigned long port = 0, address;
if (!setting[0])
{
p2p.has_public = 0;
p2p.public_text[0] = 0;
return;
}
if (!strcmp(setting, p2p.public_text) && p2p.public_time &&
!elapsed(p2p.public_time, PUBLIC_ADDRESS_SECONDS * 1000UL))
{
return;
}
snprintf(p2p.public_text, sizeof(p2p.public_text), "%s", setting);
p2p.public_time = p2p_now() | 1;
snprintf(host, sizeof(host), "%s", setting);
/* (an IPv6 address, bracketed or not, is no candidate: the tunnel is
IPv4's) */
colon = strchr(host, ':');
if (host[0] == '[' || (colon && strchr(colon + 1, ':')))
{
if (!p2p.public_logged)
platform_log("Internet play: network.public_address %s is IPv6, which the tunnel does not use: left "
"out", setting);
p2p.public_logged = 1;
p2p.has_public = 0;
return;
}
if (colon)
{
char *end;
port = strtoul(colon + 1, &end, 10);
host[colon - host] = 0;
if (*end || port == 0 || port > 65535)
{
if (!p2p.public_logged)
platform_log("Internet play: network.public_address %s has no port 1 to 65535: left out", setting);
p2p.public_logged = 1;
p2p.has_public = 0;
return;
}
}
address = p2p_resolve(host);
if (!address)
{
platform_log("Internet play: cannot look up network.public_address %s", host);
p2p.has_public = 0;
return;
}
p2p.public_candidate.address = address;
p2p.public_candidate.port = port ? network_short((unsigned short)port) : p2p.tunnel_port;
if (!p2p.has_public || !p2p.public_logged)
{
char text[64];
platform_log("Internet play: joiners are offered network.public_address, %s",
log_address_ipv4(p2p.public_candidate.address, p2p.public_candidate.port, text, sizeof(text)));
}
p2p.has_public = 1;
p2p.public_logged = 1;
}
/* (p2p.h) network.public_address as looked up, "a.b.c.d:port"; 0 if none */
int p2p_public_address(char *text, int size)
{
int has;
pthread_mutex_lock(&p2p_lock);
has = p2p.has_public;
if (has)
{
unsigned long address = network_long(p2p.public_candidate.address);
unsigned short port = network_short(p2p.public_candidate.port);
snprintf(text, (size_t)size, "%lu.%lu.%lu.%lu:%u", (address >> 24) & 255, (address >> 16) & 255,
(address >> 8) & 255, address & 255, (unsigned)port);
}
pthread_mutex_unlock(&p2p_lock);
return has;
}
static void stun_update(void)
{
int index;
@@ -1463,6 +1577,9 @@ int p2p_local_candidates(struct p2p_candidate *candidates, int maximum_count)
int count = 0;
int index;
/* (network.public_address first: the operator's word for it) */
if (p2p.has_public && count < maximum_count)
candidates[count++] = p2p.public_candidate;
if (lan && count < maximum_count)
{
candidates[count].address = lan;
@@ -2380,7 +2497,14 @@ static void tunnel_received(const unsigned char *packet, int size, const struct
its number, can be STUN's magic cookie) */
if (size < 1 || packet[0] != TUNNEL_MAGIC)
{
if (size >= 20 && packet[4] == 0x21 && packet[5] == 0x12 && packet[6] == 0xA4 && packet[7] == 0x42)
unsigned char answer[128];
int answer_size;
/* (a master server's probe of a dedicated server's port, answered
to where it came from: server_master.c) */
if (server_master_probe(packet, size, from->sin_addr.s_addr, answer, &answer_size))
posix_socket_sendto(p2p.tunnel_socket, answer, answer_size, 0, from, sizeof(*from));
else if (size >= 20 && packet[4] == 0x21 && packet[5] == 0x12 && packet[6] == 0xA4 && packet[7] == 0x42)
stun_received(packet, size, from);
return;
}
@@ -2657,6 +2781,19 @@ static int parse_invite(const char *text, unsigned char *host_hash, unsigned cha
(p2p_join_listing), till another invite is joined: its identifier */
static unsigned char listed_host[P2P_IDENTIFIER_SIZE];
static int listed_host_known;
/* the listing joined is a dedicated server's that a master server lists (an
approved operator's, by its key: a player's host cannot say it is one) */
static int listed_host_dedicated;
/* a new join's progress (p2p_join_progress): answered, its host reached
already (a probe's tunnel) */
static void join_progress_begin(int answered)
{
p2p.join_started = 1;
p2p.join_answered = answered;
p2p.join_gave_up = 0;
p2p.join_refused = -1;
}
/* under p2p_lock: parse_invite's result */
static int join_invite(const char *text)
@@ -2672,6 +2809,7 @@ static int join_invite(const char *text)
return parsed;
/* (any invite's game is no listed one, unless p2p_join_listing says) */
listed_host_known = 0;
listed_host_dedicated = 0;
p2p_identifier_from_hash(hash, host);
if (!memcmp(host, identifier, P2P_IDENTIFIER_SIZE))
return 1;
@@ -2693,6 +2831,7 @@ static int join_invite(const char *text)
memcpy(p2p.join_host, host, sizeof(host));
memcpy(p2p.join_host_hash, hash, sizeof(hash));
memcpy(p2p.join_token, token, sizeof(token));
join_progress_begin(1);
platform_log("Internet play: joining %s's game, reached already; it is listed under Multiplayer, "
"System Link", find_peer(host)->name);
return 1;
@@ -2710,7 +2849,8 @@ static int join_invite(const char *text)
memcpy(p2p.join_host_hash, hash, sizeof(hash));
memcpy(p2p.join_token, token, sizeof(token));
p2p.join_requested = 1;
p2p.join_state = P2P_JOIN_REACHING;
p2p.join_state = P2P_JOIN_STATE_REACHING;
join_progress_begin(0);
return 1;
}
@@ -2866,7 +3006,7 @@ int p2p_join_state(void)
int state;
pthread_mutex_lock(&p2p_lock);
state = p2p.running ? p2p.join_state : P2P_JOIN_NONE;
state = p2p.running ? p2p.join_state : P2P_JOIN_STATE_NONE;
pthread_mutex_unlock(&p2p_lock);
return state;
}
@@ -2890,6 +3030,7 @@ int p2p_join_listing(const struct p2p_listing *listing)
{
p2p_identifier_from_hash(hash, listed_host);
listed_host_known = 1;
listed_host_dedicated = listing->dedicated && listing->master;
}
pthread_mutex_unlock(&p2p_lock);
return 1;
@@ -2909,6 +3050,59 @@ int p2p_joined_listed_host(void)
return reached;
}
int p2p_joined_listed_dedicated(void)
{
int dedicated;
if (!p2p_joined_listed_host())
return 0;
pthread_mutex_lock(&p2p_lock);
dedicated = listed_host_dedicated;
pthread_mutex_unlock(&p2p_lock);
return dedicated;
}
int p2p_join_progress(int *stage, char *reason, int size)
{
/* (the game's rejection codes in their order: network_game_protocol.h's
version too old, too new, bad join token, bad password, full, closed,
blacklisted machine) */
static const char *const refusals[] = { "version", "version", "token", "password", "full", "closed", "banned",
"name" };
struct peer *peer;
const char *why = "";
pthread_mutex_lock(&p2p_lock);
peer = p2p.join_started ? find_peer(p2p.join_host) : NULL;
*stage = P2P_JOIN_NONE;
if (p2p.join_started && p2p.join_refused >= 0)
{
*stage = P2P_JOIN_FAILED;
why = p2p.join_refused < (int)(sizeof(refusals) / sizeof(*refusals)) ? refusals[p2p.join_refused] : "closed";
}
else if (peer && peer->connected && peer->is_host && !peer->is_player)
*stage = P2P_JOIN_CONNECTED;
else if (p2p.joining || p2p.join_requested)
*stage = peer || p2p.join_answered ? P2P_JOIN_ANSWERED : P2P_JOIN_ASKING;
else if (p2p.join_started && p2p.join_gave_up)
{
*stage = P2P_JOIN_FAILED;
why = p2p.join_answered ? "no_route" : "no_answer";
}
pthread_mutex_unlock(&p2p_lock);
if (reason && size > 0)
snprintf(reason, (size_t)size, "%s", why);
return *stage;
}
void p2p_join_refused(int rejection_code)
{
pthread_mutex_lock(&p2p_lock);
if (p2p.join_started && rejection_code >= 0)
p2p.join_refused = rejection_code;
pthread_mutex_unlock(&p2p_lock);
}
void p2p_invite_received(const char *text)
{
/* (an older version's is logged as such; a join code is looked up,
@@ -2941,9 +3135,10 @@ static void update_joining(void)
else if (p2p.joining && elapsed(p2p.join_time, JOIN_TIMEOUT))
{
p2p.joining = 0;
p2p.join_gave_up = 1;
p2p_signal_stop_joining();
if (p2p.join_state == P2P_JOIN_REACHING)
p2p.join_state = P2P_JOIN_NO_ANSWER;
if (p2p.join_state == P2P_JOIN_STATE_REACHING)
p2p.join_state = P2P_JOIN_STATE_NO_ANSWER;
platform_log("Internet play: no answer from the invite's host; it may have stopped hosting or quit");
}
}
@@ -3037,6 +3232,12 @@ static void make_invite(void)
snprintf(p2p.invite, sizeof(p2p.invite), "halo://join/%s", text);
}
void p2p_token_listed(const unsigned char *token)
{
if (p2p.has_token && !memcmp(token, p2p.token, P2P_TOKEN_SIZE))
p2p.token_listed = 1;
}
void p2p_new_invite_if_listed(void)
{
if (!p2p.has_token || !p2p.token_listed)
@@ -3770,6 +3971,7 @@ static void *p2p_thread(void *unused)
update_probes();
webrtc_busy = p2p_webrtc_update();
expire_proxies();
public_address_update();
stun_update();
update_hosting();
/* the server browser (p2p_lobby.c): signalling while browsing too */
+131 -8
View File
@@ -48,12 +48,12 @@ menus): reaching the host, connected (its game is in the system link list),
or why not */
enum
{
P2P_JOIN_NONE,
P2P_JOIN_REACHING,
P2P_JOIN_CONNECTED,
P2P_JOIN_NO_ANSWER,
P2P_JOIN_BLOCKED,
P2P_JOIN_LOST,
P2P_JOIN_STATE_NONE,
P2P_JOIN_STATE_REACHING,
P2P_JOIN_STATE_CONNECTED,
P2P_JOIN_STATE_NO_ANSWER,
P2P_JOIN_STATE_BLOCKED,
P2P_JOIN_STATE_LOST,
};
int p2p_join_state(void);
@@ -157,7 +157,8 @@ reached only by its invite. Going private makes a new invite, if the old one
was listed */
void p2p_set_hosting_public(int public);
/* whether the game this machine hosts is listed in everyone's server
browser (PUBLIC, and network.public_lobby): the text chat is off in one */
browser (PUBLIC, and network.public_lobby): the text chat is off in one a
player hosts (a dedicated server's follows network.text_chat) */
int p2p_hosting_listed(void);
/* the password of the game hosted for the internet (Server Setup's
PASSWORD; NULL or empty: none): its listing lets only those who know it
@@ -188,6 +189,9 @@ enum
P2P_LISTING_SIGNING_KEY_SIZE = 32,
P2P_LISTING_KEY_HASH_SIZE = 16,
P2P_LISTING_SEALED_TOKEN_SIZE = 56,
/* a master server's word on a listed server (master_list.c) */
P2P_LISTING_PUBLISHER_SIZE = 32,
P2P_LISTING_REGION_SIZE = 16,
};
struct p2p_listing
@@ -213,6 +217,18 @@ struct p2p_listing
unsigned char key_hash[P2P_LISTING_KEY_HASH_SIZE];
unsigned char signing_key[P2P_LISTING_SIGNING_KEY_SIZE];
unsigned char sealed_token[P2P_LISTING_SEALED_TOKEN_SIZE];
/* a dedicated server's, on a master server's word (its listing's own
flag, which its host sets, is not taken: a game the brokers alone list
is shown as the players'); listed by the master servers
(master_list.c): by how many, its publisher's name and its region
(empty: none said) */
unsigned char dedicated;
unsigned char master;
unsigned char master_count;
char publisher[P2P_LISTING_PUBLISHER_SIZE + 1];
char region[P2P_LISTING_REGION_SIZE + 1];
/* the network version its host plays (another's cannot be joined) */
unsigned short network_version;
};
/* joins a public game the server browser found (p2p_join_invite with its
invite), remembering its host: nonzero if it held an invite */
@@ -220,16 +236,123 @@ int p2p_join_listing(const struct p2p_listing *listing);
/* whether this machine is in the public game it joined from the server
browser: the tunnel still reaches its host, and no other invite was joined
since. Such a game has no text chat, whatever its host says
(port/linux/game/network_chat.c) */
(port/linux/game/network_chat.c), unless it is a dedicated server's: */
int p2p_joined_listed_host(void);
/* whether the public game joined (p2p_joined_listed_host) is a dedicated
server's that a master server lists: an approved operator's, whose chat
follows its network.text_chat (a listing's own dedicated flag, which any
host may set, is not enough) */
int p2p_joined_listed_dedicated(void);
/* a copy of the public games found, in the order shown (the most players
first; then those not failed, the open ones, by name); returns their count */
int p2p_lobby_games(struct p2p_listing *games, int maximum_count);
/* the master servers' (port/linux/src/master_list.c, server_master.c): a
fresh signed listing of the game this machine hosts for the internet, as
the brokers get it (0 if it hosts none, or none public); a master's entry's
listing taken into the server browser's games (its signature checked) with
the master's word on it, 1 if it was; and the masters' lists fetched again
(each game's count of them starts over) */
int p2p_lobby_host_listing(unsigned char *bytes, int size);
/* network.public_address as last looked up (p2p.c), "a.b.c.d:port": 0 if it
is unset or cannot be looked up (an IPv6 one, which the tunnel does not use) */
int p2p_public_address(char *text, int size);
int p2p_lobby_take_master_listing(const unsigned char *payload, int size, const char *publisher, const char *region,
int network_version_shown, int dedicated);
void p2p_lobby_masters_heard(void);
/* how many hosts list a game of a newer network version, which is not
shown (an old build sees none of the games) */
int p2p_lobby_newer_games(void);
/* a game the browser could not join: kept, marked failed, for this run */
void p2p_lobby_mark_failed(const unsigned char *identifier);
/* whether a signalling broker answers (the player-hosted games' lists come
through them) */
int p2p_lobby_brokers_reached(void);
/* ---- what the server browser shows of the games found, and in what order:
its source (its top-left tab), Filters and the column titles (the menus'
network.browser_* settings). Pure, over a copy of the games (the harness
tests them) */
/* network.browser_source */
enum
{
P2P_LOBBY_SOURCE_ALL,
P2P_LOBBY_SOURCE_DEDICATED,
P2P_LOBBY_SOURCE_PLAYERS,
};
/* network.browser_maps: games on maps this machine lacks shown, not, or
those it could download as it joins */
enum
{
P2P_LOBBY_MAPS_YES,
P2P_LOBBY_MAPS_NO,
P2P_LOBBY_MAPS_DOWNLOADABLE,
};
/* what a game's map is to this machine */
enum
{
P2P_LOBBY_MAP_INSTALLED,
P2P_LOBBY_MAP_DOWNLOADABLE,
P2P_LOBBY_MAP_MISSING,
};
/* network.browser_sort's orders (a column title's), each in its own first
direction: dedicated servers first, then the most players, then by name
(servers); the most players; by name, map or type (A to Z); those with a
password first */
enum
{
P2P_LOBBY_SORT_SERVERS,
P2P_LOBBY_SORT_PLAYERS,
P2P_LOBBY_SORT_NAME,
P2P_LOBBY_SORT_MAP,
P2P_LOBBY_SORT_TYPE,
P2P_LOBBY_SORT_LOCKED,
NUMBER_OF_P2P_LOBBY_SORTS,
};
struct p2p_lobby_choices
{
int source;
int show_empty, show_full;
/* one game engine's only (1 CTF, 2 Slayer, 3 Oddball, 4 King, 5 Race),
0 any */
int engine_type;
int maps;
};
/* the games the choices show, kept at the front in their order; returns
how many. map_state tells what a game's map is to this machine (NULL: every
map installed). A game of another network version is kept: shown, greyed,
not joinable */
int p2p_lobby_filter(struct p2p_listing *games, int count, const struct p2p_lobby_choices *choices,
int (*map_state)(const char *map));
/* the games in the order's (reverse: the other way), those of another
network version last; equal ones keep their order */
void p2p_lobby_sort(struct p2p_listing *games, int count, int order, int reverse);
/* network.browser_sort's text: an order's name, "-" before it reversed
("-players"); an unknown one is servers' */
int p2p_lobby_sort_parse(const char *text, int *reverse);
void p2p_lobby_sort_text(int order, int reverse, char *text, int size);
/* the join of an invite (or a listing) under way: its stage, and once it
failed why, as a word ("" while it goes on): "no_answer" (the host never
answered), "no_route" (it answered, and the tunnel never reached it); or
the host's game refused it (p2p_join_refused): "full", "closed", "banned",
"password", "version" or "token" (its invite was replaced). Returns the
stage */
enum
{
P2P_JOIN_NONE,
/* asking the brokers for the host */
P2P_JOIN_ASKING,
/* the host answered: the tunnel being made (hole punching) */
P2P_JOIN_ANSWERED,
/* the tunnel reaches the host: the game's own join follows */
P2P_JOIN_CONNECTED,
P2P_JOIN_FAILED,
};
int p2p_join_progress(int *stage, char *reason, int size);
/* the host of the game joined refused it (the game's rejection code:
network_game_protocol.h's _rejection_code_*), the game's client says */
void p2p_join_refused(int rejection_code);
/* a locked game's invite (into its invite), if the password is its own:
FALSE if not; TRUE for a game with none. It takes a few milliseconds */
int p2p_listing_unlock(struct p2p_listing *listing, const char *password);
+4
View File
@@ -132,6 +132,10 @@ void p2p_invite_received(const char *text);
server browser (going private: those who saw it must not get in); under
p2p_lock */
void p2p_new_invite_if_listed(void);
/* the token of the game hosted was listed elsewhere than the brokers (a
master server's listing: p2p_lobby_host_listing), so going private makes a
new invite too; under p2p_lock */
void p2p_token_listed(const unsigned char *token);
/* ---------- p2p_signal.c: signalling through public MQTT brokers */
+300 -9
View File
@@ -66,6 +66,9 @@ game with a password is not: its token is sealed.
#include "port_config.h"
#include "p2p_internal.h"
#include "halo_port_limits.h"
#include "server_mode.h"
#include "server_master.h"
#include "master_list.h"
#include <stdio.h>
#include <stdlib.h>
@@ -82,6 +85,8 @@ enum
_listing_closed = 8,
/* its token sealed with a password's key */
_listing_password = 16,
/* a dedicated server's (halo --server: server_mode.c) */
_listing_dedicated = 32,
STAMP_SIZE = 8,
/* the signed part's end: everything but the signature */
MAXIMUM_LISTING_SIZE = 2 + 1 + 2 + 1 + 4 + 4 + P2P_KEY_SIZE + P2P_SEALED_TOKEN_SIZE + 3 +
@@ -149,6 +154,13 @@ struct game
int ping;
int probing;
unsigned long probed_time;
/* listed by a master server (master_list.c) lately: not probed (the
player's address is not told to every server listed), and when */
int master;
/* (a master's entry's word that the game is a dedicated server's: a
player's public game a publisher key publishes is not) */
int master_dedicated;
unsigned long master_time;
};
struct tombstone
@@ -178,9 +190,15 @@ static struct
int kind;
int flags;
int player_count, maximum_player_count;
/* the counts the last publish was made with (a change publishes again) */
int published_player_count, published_maximum_player_count;
/* the password's key (p2p_set_hosting_password), if it has one */
int has_password;
unsigned char password_key[P2P_PASSWORD_KEY_SIZE];
/* hosting for the internet: its invite's token, for the master servers'
listing too (p2p_lobby_host_listing) */
int hosting_token_valid;
unsigned char hosting_token[P2P_TOKEN_SIZE];
/* listed: with this token; its listing as last published */
int listed;
unsigned char token[P2P_TOKEN_SIZE];
@@ -280,8 +298,8 @@ static int put_text(unsigned char *bytes, const char *text, int maximum)
}
/* the listing of the game hosted, signed: flags (_listing_closed for a
tombstone) */
static int listing_make(unsigned char *bytes, int flags)
tombstone), with the invite's token */
static int listing_make_token(unsigned char *bytes, int flags, const unsigned char *token)
{
unsigned char *signed_part = bytes;
unsigned char data[sizeof(signature_label) - 1 + MAXIMUM_LISTING_SIZE];
@@ -289,6 +307,8 @@ static int listing_make(unsigned char *bytes, int flags)
if (lobby.has_password && !(flags & _listing_closed))
flags |= _listing_password;
if (halo_server_mode())
flags |= _listing_dedicated;
bytes[size++] = 'H';
bytes[size++] = 'L';
bytes[size++] = LISTING_FORMAT;
@@ -303,7 +323,7 @@ static int listing_make(unsigned char *bytes, int flags)
size += P2P_KEY_SIZE;
if (flags & _listing_password)
{
p2p_seal_token(lobby.password_key, p2p_signing_key(), lobby.token, bytes + size);
p2p_seal_token(lobby.password_key, p2p_signing_key(), token, bytes + size);
size += P2P_SEALED_TOKEN_SIZE;
}
else
@@ -312,7 +332,7 @@ static int listing_make(unsigned char *bytes, int flags)
if (flags & _listing_closed)
memset(bytes + size, 0, P2P_TOKEN_SIZE);
else
memcpy(bytes + size, lobby.token, P2P_TOKEN_SIZE);
memcpy(bytes + size, token, P2P_TOKEN_SIZE);
size += P2P_TOKEN_SIZE;
}
bytes[size++] = (unsigned char)(lobby.player_count > 255 ? 255 : lobby.player_count);
@@ -330,6 +350,11 @@ static int listing_make(unsigned char *bytes, int flags)
return size + P2P_SIGNATURE_SIZE;
}
static int listing_make(unsigned char *bytes, int flags)
{
return listing_make_token(bytes, flags, lobby.token);
}
static int get_text(const unsigned char *bytes, int size, int *offset, char *text, int maximum)
{
int length;
@@ -445,18 +470,33 @@ static void stop_listing(void)
static void update_hosting(const unsigned char *token, int player_count, int maximum_player_count)
{
int want = token && lobby.public && config_boolean("network.public_lobby");
/* (a dedicated server is listed by the master servers, not among the
games players host: server_master.c; server.list_on_brokers, until
there are masters, lists it here too) */
int want = token && lobby.public && config_boolean("network.public_lobby") &&
(!halo_server_mode() || config_boolean("server.list_on_brokers"));
lobby.hosting_token_valid = token != NULL;
if (token)
memcpy(lobby.hosting_token, token, P2P_TOKEN_SIZE);
if (token)
{
lobby.player_count = player_count;
lobby.maximum_player_count = maximum_player_count;
}
if (lobby.listed && (!want || memcmp(token, lobby.token, P2P_TOKEN_SIZE)))
stop_listing();
if (!want)
return;
if (player_count != lobby.player_count || maximum_player_count != lobby.maximum_player_count)
if (player_count != lobby.published_player_count || maximum_player_count != lobby.published_maximum_player_count)
{
lobby.player_count = player_count;
lobby.maximum_player_count = maximum_player_count;
lobby.published_player_count = player_count;
lobby.published_maximum_player_count = maximum_player_count;
if (lobby.listed)
republish_soon(0);
/* (and to the master servers, if they list it) */
server_master_changed();
}
if (!lobby.listed)
{
@@ -676,6 +716,11 @@ static void listing_take(const struct queued *queued, const struct listing *list
shown->open = (listing->flags & _listing_open) != 0;
shown->in_progress = (listing->flags & _listing_in_progress) != 0;
shown->has_teams = (listing->flags & _listing_has_teams) != 0;
/* (a dedicated server's on a master's word alone: the listing's own
flag is its host's, which any host can set, so a game the brokers alone
list is the players' however it calls itself) */
shown->dedicated = game->master && game->master_dedicated;
shown->network_version = (unsigned short)listing->version;
}
/* the hosts' pings: those not known lately probed, while there is room (a
@@ -689,7 +734,7 @@ static void update_probes(void)
{
struct game *game = &lobby.games[index];
if (!game->used || !game->has_token || game->probing ||
if (!game->used || !game->has_token || game->probing || game->master ||
(game->probed_time && !elapsed(game->probed_time, PROBE_REFRESH)))
{
continue;
@@ -784,6 +829,11 @@ static void update_browsing(void)
{
if (lobby.games[index].used && elapsed(lobby.games[index].heard_time, GAME_EXPIRY))
lobby.games[index].used = 0;
if (lobby.games[index].used && lobby.games[index].master && elapsed(lobby.games[index].master_time, GAME_EXPIRY))
{
lobby.games[index].master = 0;
lobby.games[index].listing.master = 0;
}
if (lobby.newer[index].used && elapsed(lobby.newer[index].heard_time, GAME_EXPIRY))
lobby.newer[index].used = 0;
}
@@ -810,6 +860,90 @@ void p2p_lobby_probed(const unsigned char *identifier, int ping)
}
}
/* ---------- the master servers' (master_list.c, server_master.c) */
int p2p_lobby_host_listing(unsigned char *bytes, int size)
{
int made = 0;
if (size < MAXIMUM_LISTING_SIZE)
return 0;
pthread_mutex_lock(&p2p_lock);
/* (public, as the brokers' listing is: network.public_lobby too) */
if (lobby.hosting_token_valid && lobby.public && config_boolean("network.public_lobby"))
{
made = listing_make_token(bytes, lobby.flags, lobby.hosting_token);
/* (its token listed: going private makes a new invite, p2p.c) */
p2p_token_listed(lobby.hosting_token);
}
pthread_mutex_unlock(&p2p_lock);
return made;
}
int p2p_lobby_take_master_listing(const unsigned char *payload, int size, const char *publisher, const char *region,
int network_version_shown, int dedicated)
{
struct listing listing;
struct queued queued;
struct game *game;
int taken = 0;
(void)network_version_shown;
if (size < MINIMUM_LISTING_SIZE || size > MAXIMUM_LISTING_SIZE || !listing_read(payload, size, &listing) ||
(listing.flags & _listing_closed) || !signing_key_hash(listing.key, queued.key_hash) ||
!listing_signed(payload, &listing))
{
return 0;
}
memcpy(queued.payload, payload, (size_t)size);
queued.size = size;
queued.retained = 0;
pthread_mutex_lock(&p2p_lock);
if (lobby.browsing)
{
game = find_game(queued.key_hash);
/* (the same listing again: heard; a newer one: taken; another
version's is shown, greyed, by the menus: network_version) */
if (game && game->payload_size == size && !memcmp(game->payload, payload, (size_t)size))
{
game->heard_time = p2p_now();
}
else
{
if (game)
game->master = 1;
listing_take(&queued, &listing);
game = find_game(queued.key_hash);
}
if (game)
{
game->master = 1;
game->master_dedicated = dedicated != 0;
game->master_time = p2p_now();
game->listing.dedicated = dedicated != 0;
game->listing.master = 1;
snprintf(game->listing.publisher, sizeof(game->listing.publisher), "%s", publisher ? publisher : "");
snprintf(game->listing.region, sizeof(game->listing.region), "%s", region ? region : "");
if (game->listing.master_count < 255)
game->listing.master_count++;
taken = 1;
}
}
pthread_mutex_unlock(&p2p_lock);
return taken;
}
void p2p_lobby_masters_heard(void)
{
int index;
/* (each game's count of the masters listing it, counted again) */
pthread_mutex_lock(&p2p_lock);
for (index = 0; index < MAXIMUM_GAMES; index++)
lobby.games[index].listing.master_count = 0;
pthread_mutex_unlock(&p2p_lock);
}
/* ---------- p2p.c's side */
void p2p_lobby_update(const unsigned char *token, int player_count, int maximum_player_count)
@@ -836,12 +970,18 @@ void p2p_lobby_quit(void)
void p2p_set_hosting_public(int public)
{
int changed;
pthread_mutex_lock(&p2p_lock);
/* (a new invite going private: p2p.c) */
if (lobby.public && !public)
p2p_new_invite_if_listed();
changed = lobby.public != (public ? 1 : 0);
lobby.public = public ? 1 : 0;
pthread_mutex_unlock(&p2p_lock);
/* (the master servers told at once: listed, or withdrawn) */
if (changed)
server_master_changed();
}
int p2p_hosting_listed(void)
@@ -911,6 +1051,7 @@ void p2p_set_game_listing(const char *name, const char *map, const char *gametyp
if (lobby.listed)
republish_soon(0);
pthread_mutex_unlock(&p2p_lock);
server_master_changed();
}
void p2p_lobby_browse(int on)
@@ -930,6 +1071,8 @@ void p2p_lobby_browse(int on)
p2p_signal_lobby_topics(lobby.listed, on);
}
pthread_mutex_unlock(&p2p_lock);
/* (the master servers' lists too, while the browser is open) */
master_list_browse(on);
}
void p2p_lobby_refresh(void)
@@ -946,6 +1089,7 @@ void p2p_lobby_refresh(void)
lobby.games[index].probed_time = 0;
}
pthread_mutex_unlock(&p2p_lock);
master_list_refresh();
}
int p2p_lobby_browsing(void)
@@ -959,6 +1103,8 @@ static int listing_order(const void *first, const void *second)
{
const struct p2p_listing *a = first, *b = second;
if (a->dedicated != b->dedicated)
return b->dedicated - a->dedicated;
if (a->player_count != b->player_count)
return b->player_count - a->player_count;
if (a->failed != b->failed)
@@ -1029,3 +1175,148 @@ void p2p_lobby_mark_failed(const unsigned char *identifier)
memcpy(lobby.failed[lobby.failed_count++], identifier, P2P_IDENTIFIER_SIZE);
pthread_mutex_unlock(&p2p_lock);
}
int p2p_lobby_brokers_reached(void)
{
int reached;
pthread_mutex_lock(&p2p_lock);
reached = p2p_signal_connected();
pthread_mutex_unlock(&p2p_lock);
return reached;
}
/* ---------- what the server browser shows (pure: the harness tests these) */
static const char *const sort_names[NUMBER_OF_P2P_LOBBY_SORTS] =
{
"servers", "players", "name", "map", "type", "locked",
};
int p2p_lobby_filter(struct p2p_listing *games, int count, const struct p2p_lobby_choices *choices,
int (*map_state)(const char *map))
{
int read, written = 0;
for (read = 0; read < count; read++)
{
const struct p2p_listing *game = &games[read];
int state = map_state ? map_state(game->map) : P2P_LOBBY_MAP_INSTALLED;
if ((choices->source == P2P_LOBBY_SOURCE_DEDICATED && !game->dedicated) ||
(choices->source == P2P_LOBBY_SOURCE_PLAYERS && game->dedicated))
{
continue;
}
if (!choices->show_empty && game->player_count == 0)
continue;
if (!choices->show_full && game->maximum_player_count && game->player_count >= game->maximum_player_count)
continue;
if (choices->engine_type && game->engine_type != choices->engine_type)
continue;
if ((choices->maps == P2P_LOBBY_MAPS_NO && state != P2P_LOBBY_MAP_INSTALLED) ||
(choices->maps == P2P_LOBBY_MAPS_DOWNLOADABLE && state == P2P_LOBBY_MAP_MISSING))
{
continue;
}
if (written != read)
games[written] = games[read];
written++;
}
return written;
}
/* names A to Z, whatever their letters' case */
static int text_order(const char *first, const char *second)
{
for (;; first++, second++)
{
int a = *first >= 'A' && *first <= 'Z' ? *first + 'a' - 'A' : (unsigned char)*first;
int b = *second >= 'A' && *second <= 'Z' ? *second + 'a' - 'A' : (unsigned char)*second;
if (a != b || !a)
return a - b;
}
}
/* the order's: below 0 if first comes first */
static int sort_order(const struct p2p_listing *first, const struct p2p_listing *second, int order)
{
int difference = 0;
switch (order)
{
case P2P_LOBBY_SORT_PLAYERS:
difference = second->player_count - first->player_count;
break;
case P2P_LOBBY_SORT_MAP:
difference = text_order(first->map, second->map);
break;
case P2P_LOBBY_SORT_TYPE:
difference = first->engine_type != second->engine_type ? first->engine_type - second->engine_type :
first->kind != second->kind ? first->kind - second->kind : text_order(first->gametype, second->gametype);
break;
case P2P_LOBBY_SORT_LOCKED:
difference = second->locked - first->locked;
break;
case P2P_LOBBY_SORT_SERVERS:
/* (as p2p_lobby_games has them: then those joining has not failed,
and the open ones) */
difference = second->dedicated - first->dedicated;
if (!difference)
difference = second->player_count - first->player_count;
if (!difference)
difference = first->failed - second->failed;
if (!difference)
difference = second->open - first->open;
break;
}
return difference ? difference : text_order(first->name, second->name);
}
void p2p_lobby_sort(struct p2p_listing *games, int count, int order, int reverse)
{
int index;
/* (an insertion sort: stable, and the games are a few hundred at most) */
for (index = 1; index < count; index++)
{
struct p2p_listing game = games[index];
int other_version = game.network_version != HALO_PORT_NETWORK_VERSION;
int place = index;
while (place > 0)
{
const struct p2p_listing *before = &games[place - 1];
int before_other = before->network_version != HALO_PORT_NETWORK_VERSION;
int difference = sort_order(before, &game, order);
if (before_other != other_version ? !before_other : (reverse ? -difference : difference) <= 0)
break;
games[place] = games[place - 1];
place--;
}
games[place] = game;
}
}
int p2p_lobby_sort_parse(const char *text, int *reverse)
{
int order;
*reverse = text && *text == '-';
for (order = 0; text && order < NUMBER_OF_P2P_LOBBY_SORTS; order++)
{
if (!strcmp(text + *reverse, sort_names[order]))
return order;
}
*reverse = 0;
return P2P_LOBBY_SORT_SERVERS;
}
void p2p_lobby_sort_text(int order, int reverse, char *text, int size)
{
if (order < 0 || order >= NUMBER_OF_P2P_LOBBY_SORTS)
order = P2P_LOBBY_SORT_SERVERS;
snprintf(text, (size_t)size, "%s%s", reverse ? "-" : "", sort_names[order]);
}
+645 -24
View File
@@ -12,6 +12,11 @@ it exists, so that the player's edits and comments stay.
#include "platform.h"
#include "port_config.h"
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
/* (the command line: --config, --server, --probe...) */
#include "posix.h"
#include "posix_server.h"
#endif
#include "tomlc17.h"
#include <SDL3/SDL.h>
@@ -57,6 +62,9 @@ enum
_platform_windows = 4,
/* (the web build, which has the desktop's settings, and these) */
_platform_web = 8,
/* (a dedicated server's, of the Linux and Windows builds: written into
config.toml only in server mode) */
_platform_server = 16,
};
struct config_setting
@@ -564,6 +572,12 @@ static const struct config_setting config_settings[] =
"its light_show intensity, display.overshield_effect); \"players\": not\n"
"synced, each player sees their own settings'. SHIELD EFFECTS in Server\n"
"Setup > Player Options writes its choice here." },
{ "network.fire_origins", _config_boolean, "true", "HALO_NET_FIRE_ORIGINS", _environment_value, _platform_all,
"Host: whether a reported hit the shooter's camera had no line of sight\n"
"to may still count from where their machine says the shot left, when\n"
"that is not the camera (a VR build's hand: past a corner the camera is\n"
"behind), no further than a player leaning out and an arm, and in front\n"
"of the eye. false: from the camera and vehicles' guns only." },
{ "input.mouse_aim_assist", _config_boolean, "false", "HALO_MOUSE_AIM_ASSIST", _environment_value, _platform_desktop,
"Magnetism while aiming with the mouse, as with a controller: the view\n"
"slowed and dragged along by a target. The last of the mouse and the\n"
@@ -726,6 +740,13 @@ static const struct config_setting config_settings[] =
"A Halo Custom Edition install whose maps folder is looked in after the\n"
"custom_maps folder for Custom Edition maps and their bitmaps.map,\n"
"sounds.map and loc.map (game.custom_edition); empty for none." },
{ "paths.logs", _config_string, "\"\"", "HALO_LOG_ROOT", _environment_value, _platform_desktop,
"Where debug.txt, bans.txt and cheaters.txt go; empty for the data\n"
"folder. Lets the data folder be read-only (a dedicated server's maps\n"
"mounted so)." },
{ "paths.custom_maps", _config_string, "\"\"", "HALO_CUSTOM_MAPS_ROOT", _environment_value, _platform_desktop,
"The custom_maps folder (Custom Edition maps, and their bitmaps.map,\n"
"sounds.map and loc.map); empty for the one in the data folder." },
{ "network.address", _config_string, "\"\"", "HALO_NET_ADDRESS", _environment_value, _platform_all,
"This machine's IPv4 address for system link, for a machine on several\n"
@@ -749,6 +770,12 @@ static const struct config_setting config_settings[] =
{ "network.tunnel_port", _config_integer, "0", "HALO_NET_TUNNEL_PORT", _environment_value, _platform_all,
"The UDP port internet play uses; 0 picks one. A fixed one can be\n"
"forwarded on the router, for networks whose NAT stops connections." },
{ "network.client_timeout_seconds", _config_integer, "15", "HALO_NET_CLIENT_TIMEOUT_SECONDS", _environment_value,
_platform_all,
"Seconds a host waits for a machine it hears nothing from before it\n"
"drops it (5 to 300; the stock game's 15). Longer keeps players whose\n"
"connection stalls (a browser tab in the background) at the cost of\n"
"frozen players for longer when one really goes." },
{ "network.allow_upnp", _config_boolean, "true", "HALO_NET_ALLOW_UPNP", _environment_value, _platform_all,
"Let internet play ask the router (UPnP) to forward its port, for\n"
"networks whose NAT stops connections: when a player joins this\n"
@@ -782,8 +809,9 @@ static const struct config_setting config_settings[] =
"to be heard by proximity voice chat (5 to 100)." },
{ "network.text_chat", _config_string, "\"on\"", "HALO_NET_TEXT_CHAT", _environment_value, _platform_all,
"Hosting: the text chat: \"on\" (links in lines are refused), \"links\"\n"
"(links allowed) or \"off\", in either case. Always off in a game listed\n"
"in the server browser (a public internet game)." },
"(links allowed) or \"off\", in either case. Always off in a game a\n"
"player hosts that is listed in the server browser (a public internet\n"
"game); a dedicated server's follows this setting, listed or not." },
{ "network.votekick", _config_boolean, "true", "HALO_NET_VOTEKICK", _environment_value, _platform_all,
"Hosting: let the players vote to kick a player (the scoreboard's\n"
"right-click, or the console's votekick). More than half of the players\n"
@@ -884,6 +912,48 @@ static const struct config_setting config_settings[] =
"invite find each other (its messages are encrypted), beside this file\n"
"unless a full path: one host:port on each line, up to 4. Updates\n"
"replace brokers.txt: keep a list of your own under another name." },
{ "network.public_address", _config_string, "\"\"", "HALO_NET_PUBLIC_ADDRESS", _environment_value,
_platform_desktop,
"Hosting: the internet address joiners reach this machine at, as\n"
"host[:port] (the port: the one forwarded to network.tunnel_port), for a\n"
"server behind a NAT with one forwarded port; offered first, its name\n"
"looked up again every 5 minutes. Empty: what STUN and UPnP find." },
{ "network.listings", _config_string, "\"both\"", "HALO_NET_LISTINGS", _environment_value, _platform_all,
"What the server browser lists: \"both\" (dedicated servers from the\n"
"master servers first, then the games players host, from the brokers),\n"
"\"masters\" (the master servers' alone) or \"brokers\" (the brokers'\n"
"alone)." },
{ "network.masters_file", _config_string, "\"masters.txt\"", "HALO_NET_MASTERS_FILE", _environment_value,
_platform_all,
"The file of the master servers the server browser asks for dedicated\n"
"servers, beside this file unless a full path: an https:// URL and its\n"
"key (ed25519:<hex>) on each line, up to 4. None named (no file, or\n"
"comments alone): the official master, built in." },
{ "network.browser_source", _config_string, "\"all\"", "HALO_NET_BROWSER_SOURCE", _environment_value,
_platform_all,
"The server browser's games: \"all\" (ALL GAMES: the dedicated\n"
"servers first, then the games players host), \"dedicated\" (the\n"
"master servers' dedicated servers) or \"players\" (P2P GAMES: games\n"
"players host). Its top-left tab changes it." },
{ "network.browser_show_empty", _config_boolean, "true", "HALO_NET_BROWSER_SHOW_EMPTY", _environment_value,
_platform_all,
"The server browser lists games with no player (its Filters screen)." },
{ "network.browser_show_full", _config_boolean, "true", "HALO_NET_BROWSER_SHOW_FULL", _environment_value,
_platform_all,
"The server browser lists full games (its Filters screen)." },
{ "network.browser_maps", _config_string, "\"yes\"", "HALO_NET_BROWSER_MAPS", _environment_value, _platform_all,
"The server browser lists games on maps this machine lacks: \"yes\", \"no\",\n"
"or \"downloadable\" (those of a Custom Edition map its host shares)." },
{ "network.browser_gametype", _config_string, "\"any\"", "HALO_NET_BROWSER_GAMETYPE", _environment_value,
_platform_all,
"The server browser lists games of one kind only: \"any\", \"ctf\",\n"
"\"slayer\", \"oddball\", \"king\" or \"race\" (its Filters screen)." },
{ "network.browser_sort", _config_string, "\"servers\"", "HALO_NET_BROWSER_SORT", _environment_value,
_platform_all,
"The server browser's order: \"servers\" (dedicated servers first, then by\n"
"players), \"players\", \"name\", \"map\", \"type\" or \"locked\" (a password's\n"
"first); \"-\" before one reverses it (\"-players\"). A column title sorts by\n"
"it, and again the other way." },
{ "network.stun_servers", _config_string, "\"stun.l.google.com:19302,stun.cloudflare.com:3478\"",
"HALO_NET_STUN", _environment_value, _platform_all,
"Public STUN servers that tell this machine its internet address;\n"
@@ -902,6 +972,174 @@ static const struct config_setting config_settings[] =
{ "update.auto", _config_boolean, "true", "HALO_UPDATE_AUTO", _environment_value, _platform_all,
"Look for a new version when the game starts, and offer to update to it;\n"
"false never looks (the game's \"Do not ask again\" writes false here)." },
/* (a dedicated server's: halo --server, port/linux/src/server_mode.c;
written into the file only by one) */
{ "server.enabled", _config_boolean, "false", "HALO_SERVER", _environment_set_is_true, _platform_server,
"Run as a dedicated server (as halo --server does): host games with no\n"
"window, sound or player of its own, one after another from\n"
"server.rotation (docs/dedicated_server.md)." },
{ "server.name", _config_string, "\"OpenCE Server\"", "HALO_SERVER_NAME", _environment_value, _platform_server,
"The game's name in the lobby and the server browser: 15 characters of\n"
"printable ASCII." },
{ "server.max_players", _config_integer, "16", "HALO_SERVER_MAX_PLAYERS", _environment_value, _platform_server,
"The most players (2 to 127)." },
{ "server.min_players", _config_integer, "1", "HALO_SERVER_MIN_PLAYERS", _environment_value, _platform_server,
"Players in the lobby before it counts down to a game (1 to 16)." },
{ "server.password", _config_string, "\"\"", "HALO_SERVER_PASSWORD", _environment_value, _platform_server,
"The password the server browser asks for (the invite link still joins);\n"
"empty for none. Never logged." },
{ "server.public", _config_boolean, "true", "HALO_SERVER_PUBLIC", _environment_value, _platform_server,
"List the server in everyone's server browser (on the master servers,\n"
"with network.online); false: joined by its invite link and on the LAN." },
{ "server.rotation", _config_string, "\"bloodgulch:ctf, sidewinder:team_slayer, prisoner:slayer\"",
"HALO_SERVER_ROTATION", _environment_value, _platform_server,
"The games played one after another: map[:gametype[+rule=value...]],\n"
"separated by commas, 64 at most. A map is a multiplayer map's name\n"
"(bloodgulch), custom_maps\\<name> or a level's path; a gametype a\n"
"built-in's name (slayer, team_slayer, ctf...), an OpenCE gametype's\n"
"(swat...), a gametypes/<name>.toml or file=<saved gametype>." },
{ "server.rotation_fallback", _config_boolean, "false", "HALO_SERVER_ROTATION_FALLBACK", _environment_value,
_platform_server,
"With no playable rotation entry, play bloodgulch:slayer rather than\n"
"stop (exit code 3)." },
{ "server.lobby_seconds", _config_integer, "15", "HALO_SERVER_LOBBY_SECONDS", _environment_value,
_platform_server,
"Seconds the lobby counts down once min_players are in (0 to 300; 0\n"
"starts at once)." },
{ "server.postgame_seconds", _config_integer, "12", "HALO_SERVER_POSTGAME_SECONDS", _environment_value,
_platform_server,
"Seconds the scores show after a game (0 to 300)." },
{ "server.empty_seconds", _config_integer, "30", "HALO_SERVER_EMPTY_SECONDS", _environment_value,
_platform_server,
"Seconds a game nobody is in goes on before it ends (0: never). A game\n"
"whose last player leaves ends at once, as the game ends it." },
{ "server.idle_minutes", _config_integer, "0", "HALO_SERVER_IDLE_MINUTES", _environment_value, _platform_server,
"Minutes a game goes on with no score before it ends (0: never)." },
{ "server.team_start", _config_string, "\"one\"", "HALO_SERVER_TEAM_START", _environment_value, _platform_server,
"A team game: \"one\" starts with one team's players (the next joiners\n"
"balance), \"both\" waits for a player on each team, \"skip\" skips team\n"
"games while one player waits." },
{ "server.motd", _config_string, "\"\"", "HALO_SERVER_MOTD", _environment_value, _platform_server,
"A notice each machine is shown as it joins (127 characters); empty for\n"
"none." },
{ "server.frame_rate", _config_integer, "60", "HALO_SERVER_FRAME_RATE", _environment_value, _platform_server,
"Frames a second (30 to 120); the game's ticks stay 30 a second. A\n"
"server with no other machine runs 10." },
{ "server.console", _config_boolean, "true", "HALO_SERVER_CONSOLE", _environment_value, _platform_server,
"Take commands from the standard input." },
{ "server.console_exit_on_eof", _config_boolean, "false", "HALO_SERVER_CONSOLE_EXIT_ON_EOF", _environment_value,
_platform_server,
"Stop when the standard input ends; false goes on (a panel may close\n"
"it)." },
{ "server.init_file", _config_string, "\"init.txt\"", "HALO_SERVER_INIT_FILE", _environment_value,
_platform_server,
"Commands run once at the start, one a line: in the data folder unless a\n"
"full path; empty for none." },
{ "server.web_port", _config_integer, "8080", "HALO_SERVER_WEB_PORT", _environment_value, _platform_server,
"The TCP port of the web page, its API and the remote console (RCON);\n"
"0 for none." },
{ "server.web_bind", _config_string, "\"127.0.0.1\"", "HALO_SERVER_WEB_BIND", _environment_value,
_platform_server,
"The address the web port listens on. Any but 127.0.0.1 needs\n"
"web_password (behind a reverse proxy with TLS)." },
{ "server.web_password", _config_string, "\"\"", "HALO_SERVER_WEB_PASSWORD", _environment_value,
_platform_server,
"The web page's admin password (8 characters or more). Empty: one is\n"
"made at the first start, printed once and written here." },
{ "server.web_owner_password", _config_string, "\"\"", "HALO_SERVER_WEB_OWNER_PASSWORD", _environment_value,
_platform_server,
"A password with the owner role (quit, sv_restart, sv_stop, the owner's\n"
"settings, the game's console), for a supervisor on this machine: it\n"
"signs in from 127.0.0.0/8 alone. 8 characters or more; empty for none." },
{ "server.web_moderator_password", _config_string, "\"\"", "HALO_SERVER_WEB_MODERATOR_PASSWORD",
_environment_value, _platform_server,
"A second password, with the moderator role (kick, mute, say, the next\n"
"map, bans of a day at most); empty for none." },
{ "server.web_viewer_password", _config_string, "\"\"", "HALO_SERVER_WEB_VIEWER_PASSWORD", _environment_value,
_platform_server,
"A password with the viewer role (the status, the players, the log: no\n"
"changes); empty for none." },
{ "server.web_ui", _config_boolean, "true", "HALO_SERVER_WEB_UI", _environment_value, _platform_server,
"Serve the admin page on the web port; false: its API and RCON only." },
{ "server.web_trusted_proxy", _config_string, "\"\"", "HALO_SERVER_WEB_TRUSTED_PROXY", _environment_value,
_platform_server,
"The address of a reverse proxy in front of the web port, whose\n"
"X-Forwarded-For (its last entry, the proxy's), -Host and -Proto are\n"
"believed; empty for none." },
{ "server.metrics", _config_boolean, "true", "HALO_SERVER_METRICS", _environment_value, _platform_server,
"Serve /metrics (Prometheus) on the web port without a password." },
{ "server.log_format", _config_string, "\"text\"", "HALO_SERVER_LOG_FORMAT", _environment_value,
_platform_server,
"The standard output's lines: \"text\" or \"json\"." },
{ "server.log_addresses", _config_boolean, "false", "HALO_SERVER_LOG_ADDRESSES", _environment_value,
_platform_server,
"Players' internet addresses and hardware ids in the log, the events and\n"
"the API for every role (the owner's console has them always)." },
{ "server.log_debug_echo", _config_boolean, "false", "HALO_SERVER_LOG_DEBUG_ECHO", _environment_value,
_platform_server,
"Also write debug.txt's lines to the standard error." },
{ "server.status_file", _config_string, "\"\"", "HALO_SERVER_STATUS_FILE", _environment_value,
_platform_server,
"A file the status (as /api/status) is written to every 2 seconds, for a\n"
"panel without HTTP; empty for none." },
{ "server.auto_update", _config_string, "\"off\"", "HALO_SERVER_AUTO_UPDATE", _environment_value,
_platform_server,
"\"off\"; \"check\" logs a newer release; \"install\" installs it once the\n"
"server has been empty for 10 minutes, and stops (exit code 5) to be\n"
"started again." },
{ "server.upnp", _config_boolean, "false", "HALO_SERVER_UPNP", _environment_value, _platform_server,
"Ask the router (UPnP) to forward the tunnel's port (network.allow_upnp,\n"
"for a server at home)." },
{ "server.coop_difficulty", _config_string, "\"normal\"", "HALO_SERVER_COOP_DIFFICULTY", _environment_value,
_platform_server,
"Co-op rotation entries' (<level>:coop) difficulty: \"easy\", \"normal\",\n"
"\"hard\" or \"impossible\"." },
{ "server.list_on_brokers", _config_boolean, "false", "HALO_SERVER_LIST_ON_BROKERS", _environment_value,
_platform_server,
"Also list the server among the games players host (through the\n"
"signalling brokers), for server browsers with no master server." },
{ "server.master_publish", _config_boolean, "true", "HALO_SERVER_MASTER_PUBLISH", _environment_value,
_platform_server,
"Publish a public server to the official master servers, signed with\n"
"the publisher key (publisher_key_file)." },
{ "server.masters_file", _config_string, "\"masters.txt\"", "HALO_SERVER_MASTERS_FILE", _environment_value,
_platform_server,
"The master servers published to, beside this file unless a full path:\n"
"an https:// URL and its key (ed25519:<hex>) on each line. None named:\n"
"the official master, built in." },
{ "server.publisher_key_file", _config_string, "\"publisher.key\"", "HALO_SERVER_PUBLISHER_KEY_FILE",
_environment_value, _platform_server,
"The operator's Ed25519 key for the master servers (halo --server\n"
"--keygen makes it), beside this file unless a full path.\n"
"HALO_SERVER_PUBLISHER_KEY holds the key itself for containers." },
{ "server.instance", _config_string, "\"\"", "HALO_SERVER_INSTANCE", _environment_value, _platform_server,
"A short name telling this server from the operator's others on the\n"
"master servers (letters, digits, - and _); empty: from server.name." },
{ "server.region", _config_string, "\"\"", "HALO_SERVER_REGION", _environment_value, _platform_server,
"A region shown in the server browser (eu, na-east...): 16 characters;\n"
"empty for none." },
{ "server.chat_filter", _config_string, "\"off\"", "HALO_SERVER_CHAT_FILTER", _environment_value, _platform_server,
"The words the server keeps out of its chat and its players' names\n"
"(streamer mode's list, by chat_filter_slurs, _swearing and _insults):\n"
"\"off\"; \"censor\", a line's words shown as stars; \"block\", a line\n"
"with them refused (its sender is told why) and a player whose name has\n"
"them refused at the door. Links are network.text_chat's." },
{ "server.chat_filter_slurs", _config_string, "\"mild\"", "HALO_SERVER_CHAT_FILTER_SLURS", _environment_value,
_platform_server,
"With server.chat_filter, the slurs (race, sexuality, gender, disability)\n"
"kept out: \"off\", \"severe\" (the worst), \"strong\" or \"mild\" (all)." },
{ "server.chat_filter_swearing", _config_string, "\"off\"", "HALO_SERVER_CHAT_FILTER_SWEARING",
_environment_value, _platform_server,
"With server.chat_filter, the swearing and sexual words kept out: \"off\",\n"
"\"severe\", \"strong\" or \"mild\" (all)." },
{ "server.chat_filter_insults", _config_string, "\"off\"", "HALO_SERVER_CHAT_FILTER_INSULTS",
_environment_value, _platform_server,
"With server.chat_filter, the insults that are not slurs kept out: \"off\",\n"
"\"severe\", \"strong\" or \"mild\" (all)." },
{ "server.master_publish_address", _config_boolean, "false", "HALO_SERVER_MASTER_PUBLISH_ADDRESS",
_environment_value, _platform_server,
"Let the master servers give this server's address to players; false:\n"
"only their reachability probe uses it." },
{ "streamer.enabled", _config_boolean, "false", "HALO_STREAMER_MODE", _environment_value, _platform_all,
"Hide offensive words in names and text from other players, maps and mods\n"
"as stars, at the levels below (Settings > Network Setup > Streamer Mode)." },
@@ -1139,15 +1377,25 @@ static const struct config_setting config_settings[] =
#define NUMBER_OF_CONFIG_SETTINGS (sizeof(config_settings) / sizeof(config_settings[0]))
#ifdef HALO_ANDROID
#define CONFIG_PLATFORM _platform_android
#define CONFIG_BUILD_PLATFORM _platform_android
#elif defined(HALO_WEB)
#define CONFIG_PLATFORM (_platform_desktop | _platform_web)
#define CONFIG_BUILD_PLATFORM (_platform_desktop | _platform_web)
#elif defined(_WIN32)
#define CONFIG_PLATFORM (_platform_desktop | _platform_windows)
#define CONFIG_BUILD_PLATFORM (_platform_desktop | _platform_windows)
#else
#define CONFIG_PLATFORM _platform_desktop
#define CONFIG_BUILD_PLATFORM _platform_desktop
#endif
/* a dedicated server (server.enabled, --server, HALO_SERVER: decided as the
file is read): its settings are written into the file too */
static int config_server_mode;
/* a run that only answers its command line (--version...): nothing written */
static int config_answer_only;
/* halo --probe [<invite>]: a list site's look at a game (network_test.c's
"probe"), headless */
static int config_probe_mode;
#define CONFIG_PLATFORM (CONFIG_BUILD_PLATFORM | (config_server_mode ? _platform_server : 0))
struct config_value
{
int boolean;
@@ -1160,6 +1408,31 @@ static struct config_value config_values[NUMBER_OF_CONFIG_SETTINGS];
static int config_loaded = 0;
static pthread_mutex_t config_lock = PTHREAD_MUTEX_INITIALIZER;
/* ---------- the command line */
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
/* whether the command line has the argument name ("--server"), and with
value, the one after it (or after "=": --config=<path>) */
static int config_argument(const char *name, char *value, size_t size)
{
char text[1024];
size_t length = strlen(name);
int index;
for (index = 1; posix_command_line_argument(index, text, (posix_ulong)sizeof(text)); index++)
{
if (!strcmp(text, name))
return !value || posix_command_line_argument(index + 1, value, (posix_ulong)size);
if (value && !strncmp(text, name, length) && text[length] == '=')
{
snprintf(value, size, "%s", text + length + 1);
return 1;
}
}
return 0;
}
#endif
/* ---------- the file */
static void config_path(char *path, size_t size)
@@ -1171,9 +1444,19 @@ static void config_path(char *path, size_t size)
snprintf(path, size, "%s/config.toml", root && *root ? root : ".");
#else
/* the executable's folder, with its separator */
const char *base = SDL_GetBasePath();
/* HALO_CONFIG_FILE or --config <path> (a dedicated server's instance's
own), else the executable's folder's, with its separator */
const char *file = getenv("HALO_CONFIG_FILE");
const char *base;
if (file && *file)
{
snprintf(path, size, "%s", file);
return;
}
if (config_argument("--config", path, size) && *path)
return;
base = SDL_GetBasePath();
snprintf(path, size, "%sconfig.toml", base ? base : "");
#endif
}
@@ -1243,6 +1526,13 @@ static int config_write_file(const char *path, const char *text)
platform_log("settings: cannot replace %s (%s)", path, strerror(errno));
remove(temporary);
return 0;
#elif !defined(HALO_ANDROID) && !defined(HALO_WEB)
/* (a dedicated server's holds its passwords: its user's to read alone) */
if (!SDL_SaveFile(path, text, strlen(text)))
return 0;
if (config_server_mode)
posix_server_private_file(path);
return 1;
#else
return SDL_SaveFile(path, text, strlen(text));
#endif
@@ -1873,6 +2163,10 @@ static const struct
{ "game.console_log", { "important", "all", "none", NULL }, NULL },
{ "game.language", { "", "ja", "de", "fr", "es", "it", NULL }, NULL },
{ "network.text_chat", { "on", "links", "off", NULL }, NULL },
{ "server.chat_filter", { "off", "censor", "block", NULL }, NULL },
{ "server.chat_filter_slurs", { "off", "severe", "strong", "mild", NULL }, NULL },
{ "server.chat_filter_swearing", { "off", "severe", "strong", "mild", NULL }, NULL },
{ "server.chat_filter_insults", { "off", "severe", "strong", "mild", NULL }, NULL },
{ "network.voice_mode", { "off", "team_proximity", "team_enemy_proximity", "team_global",
"team_global_enemy_proximity", NULL }, NULL },
{ "network.coop_friendly_fire", { "off", "on", "shields_only", "explosives_only", NULL }, NULL },
@@ -1934,6 +2228,167 @@ static void config_check_words(void)
}
}
/* the passwords a file may hold instead of their variable (a container's
secrets): <variable>_FILE names it, and its first line is the value */
static const char *const config_secret_files[] =
{
"server.password", "server.web_password", "server.web_owner_password", "server.web_moderator_password",
"server.web_viewer_password",
};
#define NUMBER_OF_CONFIG_SECRET_FILES (sizeof(config_secret_files) / sizeof(config_secret_files[0]))
/* the value of the setting's <variable>_FILE (read once, at the first
question: a file changed after the start is not read again), NULL if
there is none, it cannot be read or its first line is empty */
static const char *config_environment_file(const struct config_setting *setting)
{
static char values[NUMBER_OF_CONFIG_SECRET_FILES][512];
static signed char read[NUMBER_OF_CONFIG_SECRET_FILES];
size_t index;
for (index = 0; index < NUMBER_OF_CONFIG_SECRET_FILES && strcmp(config_secret_files[index], setting->name); index++)
{
}
if (index == NUMBER_OF_CONFIG_SECRET_FILES || !setting->environment)
return NULL;
if (!read[index])
{
char variable[96];
const char *path;
char *text;
size_t size = 0, length;
read[index] = -1;
snprintf(variable, sizeof(variable), "%s_FILE", setting->environment);
path = getenv(variable);
if (!path || !path[0])
return NULL;
text = config_read_file(path, &size);
if (!text)
{
platform_log("settings: %s names %s, which cannot be read: %s is not set by it", variable, path,
setting->name);
return NULL;
}
/* (the first line, its CR and LF off) */
length = strcspn(text, "\n");
if (length && text[length - 1] == '\r')
length--;
if (length >= sizeof(values[index]))
{
platform_log("settings: the first line of %s (%s) is longer than %d characters: %s is not set by it",
path, variable, (int)sizeof(values[index]) - 1, setting->name);
length = 0;
}
memcpy(values[index], text, length);
values[index][length] = 0;
memset(text, 0, size);
free(text);
read[index] = length ? 1 : -1;
}
return read[index] > 0 ? values[index] : NULL;
}
/* a setting's environment variable, NULL if it is not set; a dedicated
server's settings' empty ones are not set either (a panel passes every
variable it has, empty ones too: an empty password must not stand for one).
A password's variable that is not set may be read from its file
(<variable>_FILE: config_environment_file) */
static const char *config_environment(const struct config_setting *setting)
{
const char *environment = setting->environment ? getenv(setting->environment) : NULL;
if (environment && !environment[0] &&
(setting->platforms == _platform_server || !strcmp(setting->name, "network.public_address")))
{
environment = NULL;
}
if (!environment)
environment = config_environment_file(setting);
return environment;
}
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
/* a dedicated server (port/linux/src/server_mode.c) has no window, sound,
microphone or player, never asks anything, and is reached only as its
settings say, whatever the file and the environment say of these (for this
run: nothing of it is written) */
/* halo --probe: no window, sound, update, Discord or menus' test; the
network test's probe, and a deadline (internet play as set: on by default,
which an invite needs) */
static void config_apply_probe_mode(void)
{
static const char *const forced[][2] =
{
{ "debug.null_renderer", "true" },
{ "debug.hidden_window", "true" },
{ "display.fullscreen", "false" },
{ "audio.enabled", "false" },
{ "audio.voice_chat", "off" },
{ "network.join_from_clipboard", "false" },
{ "update.auto", "false" },
{ "crash_reports.upload", "no" },
{ "discord.application_id", "" },
{ "debug.network_test", "probe" },
{ "server.enabled", "false" },
{ "debug.test_input", "" },
{ "debug.menu_open", "" },
{ "debug.exit_after", "45" },
};
size_t index;
for (index = 0; index < sizeof(forced) / sizeof(forced[0]); index++)
{
long found = config_setting_index(forced[index][0]);
if (found >= 0)
config_set_from_text(&config_values[found], config_settings[found].type, forced[index][1]);
}
}
static void config_apply_server_mode(void)
{
static const char *const forced[][2] =
{
{ "server.enabled", "true" },
{ "debug.null_renderer", "true" },
{ "debug.hidden_window", "true" },
{ "display.fullscreen", "false" },
{ "display.vsync", "false" },
{ "display.interpolation", "false" },
{ "display.high_res_hud", "false" },
{ "display.high_res_text", "false" },
{ "display.anti_aliasing", "off" },
{ "audio.enabled", "false" },
{ "audio.voice_chat", "off" },
{ "network.join_from_clipboard", "false" },
{ "update.auto", "false" },
{ "crash_reports.upload", "no" },
{ "discord.application_id", "" },
{ "debug.network_test", "" },
{ "debug.test_input", "" },
{ "debug.menu_open", "" },
};
const char *tunnel_port = getenv("HALO_NET_TUNNEL_PORT");
size_t index;
for (index = 0; index < sizeof(forced) / sizeof(forced[0]); index++)
{
long found = config_setting_index(forced[index][0]);
if (found >= 0)
config_set_from_text(&config_values[found], config_settings[found].type, forced[index][1]);
}
/* (the tunnel's port fixed, Halo PC's 2302, unless one is given: a
server's players and firewall need to know it; a router asked to
forward it only as server.upnp says) */
if ((!tunnel_port || !*tunnel_port) && !config_values[config_setting_index("network.tunnel_port")].integer)
config_values[config_setting_index("network.tunnel_port")].integer = 2302;
config_values[config_setting_index("network.allow_upnp")].boolean =
config_values[config_setting_index("server.upnp")].boolean;
}
#endif
static void config_load(void)
{
char path[1024];
@@ -1961,6 +2416,20 @@ static void config_load(void)
config_path(path, sizeof(path));
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
config_report_unread(path);
/* (a dedicated server's file has its settings too: asked for by the
command line or the environment, or by the file's server.enabled; a
probe is never one, whatever they say) */
config_probe_mode = config_argument("--probe", NULL, 0);
{
const char *environment = getenv("HALO_SERVER");
config_server_mode = !config_probe_mode && (config_argument("--server", NULL, 0) ||
(environment && config_environment_set(environment)));
}
/* (a run that only answers its command line, halo --version, writes no
settings: the file read is left as it is) */
config_answer_only = config_argument("--help", NULL, 0) || config_argument("--version", NULL, 0) ||
config_argument("--keygen", NULL, 0) || config_argument("--sign", NULL, 0) || config_probe_mode;
#endif
text = config_read_file(path, &size);
if (text)
@@ -1989,7 +2458,7 @@ static void config_load(void)
{
char *completed;
if (joined && !skipped)
if (joined && !skipped && !config_answer_only)
{
if (config_write_file(path, text))
platform_log("settings: joined the sections %s had twice", path);
@@ -1998,6 +2467,10 @@ static void config_load(void)
}
for (index = 0; index < NUMBER_OF_CONFIG_SETTINGS; index++)
config_set_from_file(&config_values[index], &config_settings[index], result.toptab);
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
if (config_values[config_setting_index("server.enabled")].boolean && !config_probe_mode)
config_server_mode = 1;
#endif
config_report_unknown_keys(result.toptab);
platform_log("settings: %s", path);
/* (a file with lines skipped is left as the player wrote it, to
@@ -2007,7 +2480,7 @@ static void config_load(void)
platform_log("settings: %d line%s of config.toml skipped; every other setting in it is used", skipped,
skipped == 1 ? "" : "s");
}
else
else if (!config_answer_only)
{
completed = config_add_missing(text, result.toptab);
if (completed && !config_write_file(path, completed))
@@ -2023,7 +2496,7 @@ static void config_load(void)
toml_free(result);
free(text);
}
else
else if (!config_answer_only)
{
char *defaults = config_default_text();
@@ -2037,7 +2510,7 @@ static void config_load(void)
for (index = 0; index < NUMBER_OF_CONFIG_SETTINGS; index++)
{
const struct config_setting *setting = &config_settings[index];
const char *environment = setting->environment ? getenv(setting->environment) : NULL;
const char *environment = config_environment(setting);
if (!environment || (setting->environment_style != _environment_value && !config_environment_set(environment)))
continue;
@@ -2054,14 +2527,18 @@ static void config_load(void)
break;
}
}
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
if (config_server_mode)
config_apply_server_mode();
else if (config_probe_mode)
config_apply_probe_mode();
#endif
config_check_words();
}
static const struct config_value *config_value(const char *name, enum config_type type)
/* the file read, the first time a setting is asked for */
static void config_ensure_loaded(void)
{
static const struct config_value none = { 0, 0, 0.0, "" };
long index;
pthread_mutex_lock(&config_lock);
if (!config_loaded)
{
@@ -2069,6 +2546,14 @@ static const struct config_value *config_value(const char *name, enum config_typ
config_loaded = 1;
}
pthread_mutex_unlock(&config_lock);
}
static const struct config_value *config_value(const char *name, enum config_type type)
{
static const struct config_value none = { 0, 0, 0.0, "" };
long index;
config_ensure_loaded();
index = config_setting_index(name);
if (index < 0 || config_settings[index].type != type)
{
@@ -2112,6 +2597,9 @@ static int config_line_section(const char *line, const char *end, char *section,
return 1;
}
/* the longest string config_write takes */
#define CONFIG_MAXIMUM_WRITTEN_STRING 4096
/* sets a setting, for now and in config.toml, from its value as text
("true", "60", "1.5", "all"): its line there is changed (or added), the rest
of the file kept as it is */
@@ -2119,43 +2607,70 @@ int config_write(const char *name, const char *value)
{
const char *dot = strchr(name, '.');
long index = config_setting_index(name);
char section[64], key[64], wanted[80], current[64] = "", line_text[600], path[1024];
char section[64], key[64], wanted[80], current[64] = "", path[1024];
struct config_text out = { 0 };
size_t size = 0;
char *text;
size_t size = 0, line_size;
char *text, *line_text;
const char *line;
int written = 0, in_section = 0, succeeded;
if (index < 0 || !dot || (size_t)(dot - name) >= sizeof(section) || strlen(value) > 256)
/* (a string as long as a server's rotation, server_rotation.h's
SERVER_ROTATION_MAXIMUM_TEXT; any other value is a word or a number) */
if (index < 0 || !dot || (size_t)(dot - name) >= sizeof(section) ||
strlen(value) > (config_settings[index].type == _config_string ? CONFIG_MAXIMUM_WRITTEN_STRING : 256))
{
return 0;
}
/* (the line: its key, and its value with each character escaped as
\uXXXX at most) */
line_size = strlen(dot + 1) + 6 * strlen(value) + 64;
line_text = malloc(line_size);
if (!line_text)
return 0;
/* (the file read first, as the other settings are) */
config_value(name, config_settings[index].type);
pthread_mutex_lock(&config_lock);
config_set_from_text(&config_values[index], config_settings[index].type, value);
/* (a run that only answers its command line, a probe's too, writes
nothing: the value is this run's alone) */
if (config_answer_only)
{
config_change_count++;
pthread_mutex_unlock(&config_lock);
free(line_text);
return 1;
}
snprintf(section, sizeof(section), "%.*s", (int)(dot - name), name);
snprintf(key, sizeof(key), "%s", dot + 1);
switch (config_settings[index].type)
{
case _config_boolean:
snprintf(line_text, sizeof(line_text), "%s = %s\n", key, config_values[index].boolean ? "true" : "false");
snprintf(line_text, line_size, "%s = %s\n", key, config_values[index].boolean ? "true" : "false");
break;
case _config_integer:
snprintf(line_text, sizeof(line_text), "%s = %ld\n", key, config_values[index].integer);
snprintf(line_text, line_size, "%s = %ld\n", key, config_values[index].integer);
break;
case _config_real:
/* (with its point: TOML reads 1 as an integer) */
snprintf(line_text, sizeof(line_text), "%s = %.15g", key, config_values[index].real);
snprintf(line_text, line_size, "%s = %.15g", key, config_values[index].real);
if (!strpbrk(line_text + strlen(key) + 3, ".en"))
strcat(line_text, ".0");
strcat(line_text, "\n");
break;
case _config_string:
{
char *end = line_text + snprintf(line_text, sizeof(line_text), "%s = \"", key);
char *end = line_text + snprintf(line_text, line_size, "%s = \"", key);
const char *character;
/* (a TOML basic string: quotes and backslashes escaped, and control
characters, so that a value cannot end its line) */
for (character = config_values[index].string; *character; character++)
{
if ((unsigned char)*character < 0x20 || *character == 0x7F)
{
end += sprintf(end, "\\u%04x", (unsigned)(unsigned char)*character);
continue;
}
if (*character == '"' || *character == '\\')
*end++ = '\\';
*end++ = *character;
@@ -2218,6 +2733,7 @@ int config_write(const char *name, const char *value)
pthread_mutex_unlock(&config_lock);
free(out.buffer);
free(text);
free(line_text);
return succeeded;
}
@@ -2295,6 +2811,100 @@ unsigned long config_changes(void)
return config_change_count;
}
int config_server_mode_enabled(void)
{
config_value("server.enabled", _config_boolean);
return config_server_mode;
}
int config_setting_info(int index, struct config_setting_info *info)
{
static const char *const types[] = { "boolean", "integer", "real", "string" };
const struct config_setting *setting;
const char *environment;
if (index < 0 || (size_t)index >= NUMBER_OF_CONFIG_SETTINGS)
return 0;
setting = &config_settings[index];
memset(info, 0, sizeof(*info));
info->name = setting->name;
info->type = types[setting->type];
info->environment = setting->environment;
info->comment = setting->comment;
config_default(setting->name, info->default_text, sizeof(info->default_text));
environment = config_environment(setting);
info->pinned = environment && (setting->environment_style == _environment_value || config_environment_set(environment));
info->written = (setting->platforms & CONFIG_PLATFORM) != 0;
return 1;
}
int config_reload(const char *prefix)
{
char path[1024];
size_t size = 0;
size_t length = strlen(prefix);
char *text;
toml_result_t result;
size_t index;
config_ensure_loaded();
config_path(path, sizeof(path));
text = config_read_file(path, &size);
if (!text)
return 0;
result = toml_parse(text, (int)size);
if (!result.ok)
{
platform_log("settings: %s cannot be read again (%s); the settings stay as they were", path, result.errmsg);
toml_free(result);
free(text);
return 0;
}
pthread_mutex_lock(&config_lock);
for (index = 0; index < NUMBER_OF_CONFIG_SETTINGS; index++)
{
const struct config_setting *setting = &config_settings[index];
const char *environment = config_environment(setting);
struct config_value value;
/* (the setting's own: those of the environment stay its) */
if (strncmp(setting->name, prefix, length) ||
(environment && (setting->environment_style == _environment_value || config_environment_set(environment))))
{
continue;
}
memset(&value, 0, sizeof(value));
if (setting->type == _config_string)
{
size_t default_length = strlen(setting->default_value);
value.string = default_length >= 2 ? config_copy(setting->default_value + 1, default_length - 2) : strdup("");
}
else
{
config_set_from_text(&value, setting->type, setting->default_value);
}
config_set_from_file(&value, setting, result.toptab);
/* (the old string kept, as config_set_from_text keeps it) */
config_values[index] = value;
}
#if !defined(HALO_ANDROID) && !defined(HALO_WEB)
/* (what the run forces, forced again: a server started by --server
stays one whatever the file's server.enabled says, and its router asked
as its server.upnp now says) */
if (config_server_mode)
config_apply_server_mode();
else if (config_probe_mode)
config_apply_probe_mode();
#endif
config_change_count++;
pthread_mutex_unlock(&config_lock);
toml_free(result);
free(text);
config_check_words();
return 1;
}
int config_default(const char *name, char *text, size_t size)
{
long index = config_setting_index(name);
@@ -2334,6 +2944,17 @@ long config_integer(const char *name)
return config_value(name, _config_integer)->integer;
}
unsigned long network_client_timeout_milliseconds(void)
{
long seconds = config_integer("network.client_timeout_seconds");
if (seconds < 5)
seconds = 5;
if (seconds > 300)
seconds = 300;
return (unsigned long)seconds * 1000UL;
}
double config_real(const char *name)
{
return config_value(name, _config_real)->real;
+28
View File
@@ -39,4 +39,32 @@ int config_default(const char *name, char *text, size_t size);
it again when this moves */
unsigned long config_changes(void);
/* whether this is a dedicated server (server.enabled, --server,
HALO_SERVER: port/linux/src/server_mode.c), whose settings the file holds */
int config_server_mode_enabled(void);
/* a setting, for a list of them (a dedicated server's Settings page): the
one at index; 0 past the last */
struct config_setting_info
{
const char *name;
/* "boolean", "integer", "real" or "string" */
const char *type;
/* NULL: none */
const char *environment;
const char *comment;
/* as text, a string's without its quotes */
char default_text[300];
/* its environment variable sets it for this run */
int pinned;
/* this build writes it into config.toml */
int written;
};
int config_setting_info(int index, struct config_setting_info *info);
/* the settings whose names begin with prefix ("server.") read from
config.toml again, but for those their environment variables set; 1 on
success (a file that cannot be read leaves them as they were) */
int config_reload(const char *prefix);
#endif
+34
View File
@@ -1104,6 +1104,40 @@ int port_mods_describe(long index, char *text, unsigned long size, const char *m
return 1;
}
int port_mods_list(char *text, unsigned long size)
{
long position;
int count = 0;
size_t length = 0;
if (!size)
return 0;
text[0] = 0;
for (position = 0; position < port_mods.mod_count; position++)
{
long index = port_mods.ordered[position];
const struct port_mod *mod;
int written;
if (index < 0 || index >= port_mods.mod_count)
continue;
mod = &port_mods.mods[index];
if (!mod_on(mod))
continue;
written = snprintf(text + length, size - length, "%s%s%s%s", count ? "\n" : "", mod->folder,
mod->version[0] ? " " : "", mod->version);
/* (a line that does not fit is left out whole) */
if (written < 0 || (size_t)written >= size - length)
{
text[length] = 0;
break;
}
length += (size_t)written;
count++;
}
return count;
}
unsigned long port_mods_scans(void)
{
return port_mods.scans;
+4
View File
@@ -12,6 +12,10 @@ declares these for itself: keep the two the same).
/* 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);
/* the mods that are on, in the load order, a line each: its folder, and
its version if its mod.toml says one ("trollface 1.0"); how many fitted
into text (a line that does not fit is left out whole) */
int port_mods_list(char *text, unsigned long size);
/* 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 */
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more