og-cod-plutainer/docs/games.md
Amos f9a7953652
Some checks failed
Build and Push Docker Image / build (amd64, Dockerfile, false, linux/amd64, ubuntu-latest) (push) Failing after 2s
Build and Push Docker Image / build (arm64, Dockerfile.arm64, true, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Image / merge (push) Has been cancelled
SteamCMD family: generalise 7DTD support, add CS2/L4D2/HL2:DM, unify the CoD side (#9)
* feat: add 7 Days to Die server support

* refactor(scripts): two table-driven families behind one entry script each

Plutainer had five "families" that were really four Call of Duty engines
plus a platform, and a separate entry script per engine. Adding 7 Days to
Die made the mismatch obvious: a SteamCMD game shares nothing with a
Quake-derived one, while a Plutonium T6 and a CoD4x server differ far
less from each other than either does from a Steam install.

There are now two families, and they are platforms:

  cod    servers Plutainer installs and runs from game files you supply
  steam  servers SteamCMD installs

Engine (plutonium/iw4x/alterware/cod4x, unity/srcds) drops to a field in
that family's game table, where it belongs. Each family gets one entry
script driven by that table, so the four CoD entry scripts collapse into
games/codentry.sh at 92 lines, and adding a game is a table row rather
than a new script. entrypoint.sh has two branches and gains none per game.

Per-engine behaviour is expressed as hooks resolved most-specific-first:

  cod_<hook>_<game> -> cod_<hook>_<base game> -> cod_<hook>_<engine>

so a game inherits its engine and overrides only what genuinely differs.
The same mechanism serves both families, which removed fifteen one-line
pass-through functions on the Steam side.

Hook names are strings, so plutainer_require_hooks verifies the mandatory
ones resolve at startup and prints what it tried. That is not theoretical:
during this refactor a comma-separated suffix list was passed where an
array was wanted, and every server died with no output whatsoever until
plutainer_hook was made to say what it could not find.

The library splits along the same line — core/fs/cod/steam — so a CoD
change never requires reading the Steam helpers. Audited: zero
cross-family calls in either direction. Anything both needed (the symlink
helpers) moved to fs.sh. Renamed from *-config.sh, which was inaccurate
once the files held install logic and launch arguments.

Verified on all ten CoD games (T4/T5/T6 MP+ZM, IW5, IW4x, T7x MP+ZM,
CoD4x): launch commands byte-identical to the originals, correct config
symlinks per engine, health and RCON working.

* feat(protocols): A2S, Source RCON and telnet, split by protocol

pyquake3.py was the only wire protocol Plutainer spoke, which was fine
while every game was Quake-derived. The SteamCMD games are not: 7DTD has
a telnet console and no RCON at all, Source games speak Valve's RCON over
TCP, and none of them answer getstatus.

Protocols now live in scripts/protocols/, split by protocol rather than
by game, because the mapping is many-to-many — Quake3 covers seven CoD
titles, A2S covers four Steam games across three engines, and querying is
a different concern from administering.

healthcheck.sh picks its probe from the family table (STEAM_QUERY) rather
than a hardcoded branch, and keeps the same bar for every game: the server
must name a map it is running. A2S reports one, so "healthy" does not
degrade to "a TCP connect succeeded" for the new family.

rcon-cli keeps its name because that is what people search for, but is now
one small class per protocol behind a dispatch table. It also distinguishes
"this game has no remote console" from "it has one and you have it switched
off", and tells you which setting to change in the second case.

Both try loopback and then the container's own address. Source 1 servers
answer only on the latter: an identical A2S query times out on 127.0.0.1
and replies immediately on the container IP, with the server healthy
throughout. Rather than encode which engines behave which way, try both.

pyquake3.py moves to protocols/quake3.py with its GPL attribution intact.

* fix(logs): let a family opt out of rotation, and skip huge install trees

Rotation is copy-truncate, which is only valid against a writer that
opened its log with O_APPEND — true of every CoD engine, and the reason
the existing implementation is safe. A Unity dedicated server's -logfile
writer keeps its own offset instead, so truncating would leave a sparse
hole and the file's apparent size would snap straight back over the limit,
re-triggering rotation on every poll.

That has not been measured against a running 7DTD, so PLUTAINER_LOG_ROTATE
lets the SteamCMD family turn rotation off. Not rotating is the safe
failure — an unbounded log, which is what the game does unmanaged — rather
than a rotation loop copying gigabytes every two seconds.

PLUTAINER_LOG_PRUNE_DIRS keeps the poller out of a SteamCMD install, which
is tens of thousands of files that would otherwise be walked every two
seconds for one log that does not live there.

* build: add SteamCMD, keep STOPSIGNAL SIGKILL

SteamCMD is fetched but deliberately not bootstrapped at build time.
Running `steamcmd.sh +quit` in the image pulls a few hundred MB of Steam
client into $HOME that every Call of Duty user would then carry forever,
and 340 MB of Xvfb was removed for exactly that reason. It also does not
reliably prevent the first-contact failure it appears to fix, which is
handled with a retry instead.

Placed after everything the CoD families need, so adding or bumping it
never invalidates their layers.

STOPSIGNAL stays SIGKILL. A game with world state to flush cannot be
served by a signal that cannot be trapped, but changing the image default
would alter shutdown for seven servers already running in production to
benefit one new family. Those games ask for SIGTERM per service instead:

    stop_signal: SIGTERM
    stop_grace_period: 90s

Measured: 7DTD forwards the signal, saves, and exits 0 in 3.5s; the same
image with no stop_signal stops in 254ms as before. The hang case needs no
code — Docker sends SIGKILL itself when the grace period expires.

chmod now covers every *.sh by find rather than a hand-maintained list
that silently rots as scripts move between directories.

* feat(steam): add CS2, L4D2 and Half-Life 2: Deathmatch

Three more games through the SteamCMD family, chosen to stress the table
in different directions rather than to pad the list. Each needed a table
row and, at most, one hook.

HL2:DM is the plain srcds case. L4D2 shares its hooks entirely. CS2 shares
seed, configure, stage and admin with them and overrides only its launch
arguments, because Source 2 has no srcds_run wrapper.

L4D2 needs a two-phase install and it is not optional. Valve restricted
anonymous Linux installation of app 222860: every depot including the
9.5 GB content one is flagged windows, so a plain app_update on Linux
fails with "Invalid platform". This is an open upstream issue
(ValveSoftware/steam-for-linux#11522) that takes LinuxGSM down with it, so
"this used to work" is true and not a local fault. Pulling the content as
Windows and re-running as Linux overlays the native binaries depot on top.
STEAM_INSTALL_PLATFORMS expresses that generically. The result is a genuine
native server — srcds_linux, no Wine, reports "os: Linux Dedicated".

CS2 exposed a real bug in the config handling. link_configs refuses to
replace a real file at the engine path, because for the CoD families that
file is the strongest signal of user intent. Inside a SteamCMD install the
same signal means the opposite: the directory is Steam's, so a real file is
a depot default that the next update restores anyway. CS2 ships a 33-byte
game/csgo/cfg/server.cfg reading "// Defaults in server_default.cfg", so
the fan-out skipped it with a warning and the server ran on stock settings
while app/configs/server.cfg sat there looking correct. Measured before
and after: hostname went from "Counter-Strike 2" to the configured name,
and the name a server browser shows changed with it.

SteamCMD's first contact in a fresh container is also unreliable — it
downloads its own client, re-execs, and an app_update issued before that
settles fails with "Missing configuration". Measured on both 7DTD and
HL2:DM, with the next attempt succeeding, so installs are retried.

Seed configs are hand-written rather than vendored: Valve ships no
server.cfg for any of these. All three ship empty rcon_password and
sv_password, like every other seed here.

* docs: rewrite for two families and eleven games

The docs described five families and a script-per-game layout that no
longer exists, and the 7DTD material from the original PR assumed it was
a one-off rather than the first member of a family.

Reworked around the two-platform model, with per-game specifics where a
reader looks for them: disk footprints (CS2 is 67 GB and will exceed the
health grace period on first start), the GSLT and masterserver-token
equivalence, why clean shutdown is opt-in per service, and what the
depot-config takeover does.

Audited rather than assumed: all fourteen game tags now appear in the
README table, the games reference, the configuration reference and a
working compose example; every environment variable the code reads is in
the reference table; every script is described in CLAUDE.md; no stale
references to the deleted entry scripts remain; all internal doc links
resolve. That audit found two undocumented variables, now added.

* chore: remove the EXAMPLE-docker-compose.yml signpost

The file had already been reduced to a stub pointing at examples/, kept on
the theory that older guides and forum posts link to it. Nothing in the
repo references it any more: every doc that shows a compose file points at
examples/ directly, so the stub was carrying its own rationale and nothing
else.

Also drops the two places that still named it — its .dockerignore entry
(examples/ and *.md already cover everything it excluded) and its
paths-ignore entry in the publish workflow, which was suppressing builds
for a file that no longer exists.

---------

Co-authored-by: Keyboard Sped <93077330+CoreyUK@users.noreply.github.com>
2026-08-16 17:12:31 +01:00

183 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Games
What each game needs from you, and what Plutainer supplies. Find your game, check the three columns, done.
## At a glance
| Game | `PLUTAINER_GAME` | Server key? | Default port | Config files seeded |
| --- | --- | --- | --- | --- |
| World at War | `t4mp` `t4sp` | yes | 28960 | `server.cfg`, `server_zm.cfg`, `server_coop.cfg` |
| Black Ops | `t5mp` `t5sp` | **yes, must be valid** | 28960 | `dedicated.cfg`, `dedicated_sp.cfg` |
| Black Ops II | `t6mp` `t6zm` | yes | 4976 | `dedicated.cfg`, `dedicated_zm.cfg` |
| Modern Warfare 3 | `iw5mp` | yes | 27016 | `server.cfg` |
| Modern Warfare 2 | `iw4x` | no | 28960 | `server.cfg`, `serverlan.cfg`, `partyserver.cfg`, `partyserverlan.cfg` |
| Black Ops III | `t7x` | no | 27017 | `server.cfg`, `server_zm.cfg`, `server_cp.cfg` |
| Modern Warfare | `cod4x` | token, to be listed | 28960 | `server.cfg` |
| 7 Days to Die | `7dtd` | no | 26900 | `serverconfig.xml`, from the installed server |
| Counter-Strike 2 | `cs2` | token, to be listed | 27015 | `server.cfg` |
| Left 4 Dead 2 | `l4d2` | no | 27015 | `server.cfg` |
| Half-Life 2: Deathmatch | `hl2dm` | no | 27015 | `server.cfg` |
`PLUTAINER_CONFIG_FILE` must name one of the seeded files, or a config you place in `app/configs/` yourself. Get it wrong and the container refuses to start with a hint listing what it found — including case-only mismatches like `Server.cfg` vs `server.cfg`.
The `sp` tags are how Plutonium runs zombies/co-op: **T4 zombies is `t4sp`** with `server_zm.cfg`, **T5 zombies is `t5sp`** with `dedicated_sp.cfg`.
## What goes in the gamefiles mount
Mounted read-only at `/home/plutainer/gamefiles`. One copy can be shared by any number of servers.
**The SteamCMD games are the exception:** do not mount game files for `7dtd` or `hl2dm`. Plutainer installs those servers anonymously through SteamCMD into `app/runtime/steam/<game>/`, and keeps everything you care about — worlds, saves, logs — in `app/runtime/gamedata/<game>/`, where a Steam update cannot reach it.
### Plutonium (T4, T5, T6, IW5)
That game's base install. Plutonium's updater fetches its own binaries into `app/runtime/plutonium/` on first start (~500 MB).
### IW4x
A stock MW2 install: `main/`, `zone/english/`, `zone/dlc/`, `binkw32.dll`, `localization.txt`, `mss32.dll`.
On first start `iw4x-launcher` fetches another 12 GB into `app/runtime/gamefiles/``iw4x.exe`, `iw4x.dll`, `zonebuilder.exe`, the `iw4x/` asset directory, all of `zone/patch/` and `zone/zonebuilder/`, and the DLC fastfiles. That download persists across container recreation.
`zone/patch/` and `zone/zonebuilder/` belong entirely to the launcher — copies in your mount are ignored, so a slimmed install is fine. Client-only assets (`main/video/`, `logo.bmp`, `splash.bmp`) are unused; [`mxve/shrink-iw4x`](https://github.com/mxve/shrink-iw4x) strips those and the media inside `main/*.iwd`, taking a full install from ~15 GB to ~6 GB.
> **Never put your own files in `zone/patch/` or `zone/zonebuilder/`.** A *symlink* there stops the launcher extracting at all, which silently disables updates. Custom scripts and assets go in `userraw/`, which the updater never touches.
### T7x
BO3 server files: `BlackOps3_UnrankedDedicatedServer.exe`, `zone/`, `machinecfg`, `codlogo.bmp`, and the `steam_api64` / `steamclient64` / `tier0_s64` / `vstdlib_s64` DLLs. Plutainer downloads `t7x.exe` itself and re-fetches only when upstream is newer.
### CoD4x
**Only `main/` and `zone/`.** The client binaries (`iw3mp.exe`, `iw3sp.exe`, `binkw32.dll`, `d3dx9_34.dll`, `mss32.dll`, the bitmaps) are never read by a dedicated server and can be deleted from your mount.
Plutainer ships the server binary and the two assets a stock install lacks — `cod4x_patchv2.ff` and `jcod4x_00.iwd` — so nothing is downloaded at runtime.
## Per-game notes
### The SteamCMD games (7DTD, CS2, L4D2, HL2:DM)
These work differently enough from the Call of Duty families to be worth reading once.
**You supply nothing.** No gamefiles mount, no Steam account, no key. SteamCMD downloads the dedicated server anonymously on first start and updates it on later starts, unless `PLUTAINER_AUTO_UPDATE=false`.
**Two directories, and the split matters.** The install goes in `app/runtime/steam/<game>/` and SteamCMD owns it — it may replace anything in there on an update. Everything you would be upset to lose (worlds, saves, logs) is kept in `app/runtime/gamedata/<game>/`, which SteamCMD never touches. Your config stays in `app/configs/` like every other game.
**Budget the disk, seriously.** CS2 is **67 GB** installed, 7DTD roughly 17 GB, L4D2 9.2 GB, HL2:DM about 6 GB. First start is almost entirely download, and CS2's will exceed the health check's five-minute grace period — the container shows `unhealthy` while it works, then recovers by itself. Watch `docker logs -f`, not `docker ps`.
**They are amd64-only**, because SteamCMD ships x86_64 binaries only. The arm64 image refuses with an explanation rather than failing obscurely.
**Clean shutdown is opt-in, per service.** The image's stop signal is `SIGKILL`, which is right for the Call of Duty engines — they have nothing to flush. A game with world state does have something to lose, so ask for `SIGTERM` in your compose file:
```yaml
stop_signal: SIGTERM
stop_grace_period: 90s
```
Plutainer then forwards the signal and waits for the server to save and exit. Measured on 7DTD: 3.5 s, exit code 0, save files written at stop. Without those two lines the container still stops correctly — instantly — just without the clean save. `stop_grace_period` is the hard limit either way: Docker sends `SIGKILL` itself when it expires, so a hung server cannot wedge a stop.
#### 7 Days to Die (`7dtd`)
Steam app `294420`. Config is `serverconfig.xml`, copied out of the installed server's own template on first start and never overwritten afterwards.
`PLUTAINER_PORT` and `PLUTAINER_SERVER_NAME` are written into the XML (`ServerPort`, `ServerName`) because that is where 7DTD reads them. Publish the port as TCP and UDP plus the next three UDP ports — for the default, TCP `26900` and UDP `26900-26903`.
Administration is 7DTD's telnet console, not RCON. `PLUTAINER_RCON_PASSWORD` sets `TelnetPassword` and switches `TelnetEnabled` on, after which `rcon-cli` works normally.
`PLUTAINER_USE_RAW_CONFIGS` and `PLUTAINER_MOD` do not apply. Put server mods in `app/runtime/steam/7dtd/Mods/`.
#### The Source games (`cs2`, `l4d2`, `hl2dm`)
Ordinary `srcds` dedicated servers, and they behave identically: Plutainer seeds a minimal `server.cfg` and symlinks it into the install so your edits survive Steam updates. `PLUTAINER_SERVER_NAME` sets `hostname`, `PLUTAINER_RCON_PASSWORD` sets `rcon_password`, and `PLUTAINER_MAX_CLIENTS` / `PLUTAINER_START_MAP` control slots and the boot map. Administration is Valve's RCON over TCP on the game port.
Adding another Source game — Day of Defeat, TF2, Garry's Mod — is a table row in `scripts/lib/steam.sh` plus four one-line hooks, not new code.
**Plutainer takes over the depot's own `server.cfg`.** Several of these ship a stub config in the install directory; CS2's is 33 bytes reading `// Defaults in server_default.cfg`. Yours from `app/configs/` replaces it, and the original is kept beside it as `server.cfg.depot-original`. Without this the server would quietly run on stock settings while your config sat there looking correct.
**CS2 specifics.** Set `PLUTAINER_CS2_GSLT` to a Game Server Login Token from <https://steamcommunity.com/dev/managegameservers> — same role as CoD4x's masterserver token: without it the server runs and accepts direct connections but never shows up in the browser. RCON needs no extra setup; Plutainer passes `-usercon` (CS2 refuses every RCON connection without it, regardless of password) and supplies the password on the command line as well as in your config, because CS2 starts RCON before it execs `server.cfg`.
One thing to know: `+game_alias competitive` makes CS2 exec its own `gamemode_competitive.cfg` *after* your `server.cfg`, so a handful of cvars that gamemode file also sets — `bot_quota` is the usual one — will be overridden. That is stock Source behaviour, not Plutainer. Change the mode with `PLUTAINER_CS2_GAME_ALIAS`, or edit the gamemode file in the install directly.
**L4D2 needs a two-phase install, and Plutainer does it for you.** Valve restricted anonymous Linux installation of app `222860`: every depot, *including the 9.5 GB content one*, is flagged `windows`, so a plain `app_update` on Linux fails with `Invalid platform`. This is an open Valve-side issue ([steam-for-linux#11522](https://github.com/ValveSoftware/steam-for-linux/issues/11522)) that also breaks [LinuxGSM](https://github.com/GameServerManagers/LinuxGSM/issues/4754), so if you have run L4D2 on Linux before and it stopped working, this is why.
The fix is to pull the content as Windows and then re-run the update as Linux, which overlays the native binaries depot on top. Plutainer expresses that as `STEAM_INSTALL_PLATFORMS="windows linux"` in the game table. The result is a genuinely native server — `srcds_linux`, no Wine, no Steam account — that reports `os: Linux Dedicated`. It costs ~9.2 GB and a longer first start, since the Windows content is downloaded before the Linux binaries land on top.
### T5 (Black Ops)
**A placeholder key will not work.** T4, T6 and IW5 start and play with any key string; T5 does not. It pulls `online_tu14_mp_english.wad` through Plutonium's authenticated service, so with an invalid key the log loops:
```
Error: Unable to fetch file online_tu14_mp_english.wad.
Early out of maprotate, waiting for WAD!
```
The server process runs, binds its port, and never loads a map — a client sees *"Server is not running a map"*. It looks like a broken server; it's an unauthenticated key. Use a real one from <https://platform.plutonium.pw/serverkeys>.
T5 also only answers status queries from localhost, so external query tools see nothing even when it's perfectly healthy. Plutainer's healthcheck runs inside the container, so it is unaffected.
### T7x (Black Ops III)
Launches with `-headless`, which is what removes the need for a virtual display — without it the server hangs on window creation and never binds its port. `-dedicated` is passed separately and is also required.
`PLUTAINER_MOD` here is a **Steam Workshop ID**, not a folder name.
### CoD4x (Modern Warfare)
Multiplayer only. This is the one family that does **not** run under Wine: upstream ships a native Linux server, and Plutainer runs it directly.
**A public server needs a masterserver token.** It isn't a Plutonium-style key — the server starts and plays without one — but without it CoD4x cannot register with the master:
```
Can not register server on the masterserver. Server needs to provide a valid token in cvar sv_authtoken.
```
An unregistered server **never appears in the in-game server browser**, so in practice nobody finds it; only players you give the address to can connect directly. Host migration also requires one.
Get a token from <http://cod4master.cod4x.ovh> and pass it as `PLUTAINER_COD4X_AUTH_TOKEN`. The engine expects exactly **32 characters**. With no token set, Plutainer passes `sv_authorizemode -1` so the server still runs rather than refusing to start.
**RCON passwords must be at least 8 characters.** Shorter ones are refused with `No rconpassword set on server or password is shorter than 8 characters`, which reads like a wrong password rather than a too-short one.
**The server self-updates.** CoD4x rewrites its own binary in `app/runtime/gamefiles/`, which is why Plutainer copies it there rather than symlinking it out of the image. Tested behaviour:
- A build the updater fetched is **kept** across container restarts.
- If a newer Plutainer image ships a newer pinned build, that one is restaged — briefly downgrading a self-updated binary, which the updater then corrects on the next start. Set `PLUTAINER_AUTO_UPDATE=false` to pin whatever is in your volume.
- A stale `autoupdate.lock` left behind by a killed container is harmless; startup time is unaffected.
### IW4x (Modern Warfare 2)
Four seeded configs: `server.cfg` is the normal dedicated one, the `partyserver*` pair run lobby mode from playlists, and the `*lan` variants are LAN mode.
Upstream ships `sv_maprotation` commented out, which would leave `+map_rotate` with nothing to load, so Plutainer's copy of `server.cfg` carries a stock-MW2 rotation under an `// Added by Plutainer` comment. Edit it freely, or set `PLUTAINER_MAP_ROTATE=false` and drive maps from a playlist.
## Architecture support
`linux/amd64` and `linux/arm64` are both published, with two exceptions:
- **IW4x does not work on arm64.** Upstream publishes `x86_64` binaries only, so the arm64 image builds the launcher from source, and that build is currently broken ([iw4x/launcher#76](https://github.com/iw4x/launcher/issues/76)). It refuses to start and says why. It will work again automatically once upstream builds.
- **CoD4x is amd64-only, permanently.** Its server is a 32-bit x86 Linux binary, which cannot execute on arm64 at all.
- **The SteamCMD games are amd64-only.** SteamCMD ships x86_64 binaries only, so the arm64 image carries no copy of it and refuses with an explanation. It is a capability check, not an architecture check: the day an arm64 SteamCMD exists, this starts working with no code change.
Plutonium and T7x work on both. If an arm64 build fails outright, `:latest` publishes amd64-only rather than being held back — check with `docker manifest inspect ghcr.io/ayymoss/plutainer:latest` before upgrading an arm64 host.
## Bundled configs
On first start Plutainer copies a working config into `app/configs/`. Existing files are **never** overwritten, so your edits survive every image update. Opt out entirely with `PLUTAINER_SKIP_SEED=true`.
| Game | Source |
| --- | --- |
| T4 | [xerxes-at/T4ServerConfigs](https://github.com/xerxes-at/T4ServerConfigs) |
| T5 | [xerxes-at/T5ServerConfig](https://github.com/xerxes-at/T5ServerConfig) |
| T6 | [xerxes-at/T6ServerConfigs](https://github.com/xerxes-at/T6ServerConfigs) |
| IW5 | [xerxes-at/IW5ServerConfig](https://github.com/xerxes-at/IW5ServerConfig) |
| T7x | [Dss0/t7-server-config](https://github.com/Dss0/t7-server-config) — includes the lobby scripts `sv_lobby_mode` needs |
| IW4x | [iw4x/iw4-server-configs](https://github.com/iw4x/iw4-server-configs) |
| CoD4x | Maintained in this repo, adapted from [matracey/docker-cod4](https://github.com/matracey/docker-cod4) |
These are vendored into the repository under `seed-configs/`, not downloaded at build time, so a build can't break because someone renamed a repo — which happens. Each game's `seed-configs/<game>/SOURCE` records the exact upstream commit.
Plutainer makes three changes to what upstream ships, each marked with a `[Plutainer]` comment:
- **All passwords blanked** — `rcon_password`, `g_password`, `sv_privatePassword`. A password shipped in a public image is a password everyone knows: as an RCON password it invites strangers in, and as a *join* password it locks your own players out.
- **Placeholder `rconWhitelistAdd` entries commented out** — they pointed at someone else's LAN and blocked the admin tools you'd actually connect. See [IW4MAdmin](iw4madmin.md#the-whitelist-rule-that-catches-everyone).
- **`rcon_localhost_bypass` forced to `1`** where the cvar exists, so `rcon-cli` works from inside the container.
Maintainers: update them with `tools/refresh-seeds.sh` (all games) or `tools/refresh-seeds.sh t6 iw4x` (a subset), then commit the diff.