Files
ulicraft-server-v1/plan/04-mods.md
Oier Bravo Urtasun 096ef82420 docs(04-mods): record first-cutover deploy gotchas
Document the two crash classes hit on first deploy: NeoForge pin must match
the distribution's version, and client-only mods that declare BOTH crash the
dedicated server with "invalid dist DEDICATED_SERVER" (tomllib can't catch
them — hand-set to client + re-sync). Mark the decommission checklist done;
flag the landing join-flow rework as the one remaining open item. Current
state: 24 client / 52 both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:16:48 +02:00

213 lines
10 KiB
Markdown

# 04 — Mods (custom volume, distribution-fed)
> **Supersedes `04-packwiz.md`.** packwiz is being removed. This describes the
> replacement: the server loads mods from a filtered local volume; clients keep
> getting the full set from the HeliosLauncher `distribution.json`.
## Why drop packwiz
- The packwiz index tracked only **9** mods while the real modset (76 jars) lives
in the distribution repo — packwiz was half-built and stale.
- Manual packwiz curation (`.tmpl` render → `refresh` → re-hash) is the flagged
pain point, and a single missing file (`custom/.gitkeep`) crashlooped the
server (404 → installer aborts → minecraft never starts).
- The launcher (HeliosLauncher) already distributes mods to clients via a
Nebula-generated `distribution.json`. That is the canonical client mod list.
The server just needs the same jars, minus client-only ones.
## Architecture
```
ulicraft-distribution/dist (another repo, = $DISTRIBUTION_WEB_ROOT)
servers/ulicraft-1.21.1/
forgemods/required/*.jar ← 76 jars, Nebula-managed (SOURCE OF TRUTH)
distribution.json ← generated by Nebula; CLIENT install list
│ │
│ caddy distribution. vhost │ HeliosLauncher reads it →
▼ ▼ installs ALL listed mods (client)
guests' launcher ◄───────────────────────────────────
ulicraft-server (this repo)
mods-sides.json ← { "<jar>": "client|server|both" } (committed, editable)
tooling/classify-mods ← tomllib: seeds/updates mods-sides.json from jar manifests
tooling/sync-server-mods ← copies both+server jars from $DIST → ./server-mods/
server-mods/ (gitignored) ──mount──► minecraft /mods (REMOVE_OLD_MODS=TRUE)
```
- **Client distribution: unchanged.** HeliosLauncher installs everything in
`distribution.json`. No packwiz, no client zip.
- **Server: filtered subset.** Only `both` + `server` jars are copied into the
mounted `server-mods/`. `client`-tagged jars never reach the server.
- **Single source of jars:** `$DISTRIBUTION_WEB_ROOT/.../forgemods/required/`.
This repo stores no jars — only `mods-sides.json`.
## Classification — tomllib only (no Modrinth)
`tooling/classify-mods.py` reads each jar's `META-INF/neoforge.mods.toml` with
Python `tomllib` and derives a side from the **`minecraft`/`neoforge` dependency
`side`** (the field modders use to declare a mod's environment):
| manifest signal | derived side |
|---|---|
| `minecraft` (or `neoforge`) dep `side = "CLIENT"` | `client` |
| dep `side = "SERVER"` | `server` |
| dep `side = "BOTH"`, unspecified, or no manifest | `both` (default) |
Output is **merged** into `mods-sides.json`: existing (human-edited) entries are
**preserved**; only new/unknown jars get a derived default. So the file is a
one-time seed + permanent manual override surface. New jars default to `both`
(your chosen default) unless their manifest says otherwise.
### Accuracy + the manual step (important)
tomllib reliably catches **hard client-only** mods — ones that declare their
`minecraft`/`neoforge` dep as `CLIENT` (e.g. `sodium`, `iris`). Those are exactly
the mods that refuse/break on a dedicated server, so the must-exclude set is
handled automatically.
It will **not** auto-exclude client-*cosmetic* mods that declare `BOTH` in their
manifest (e.g. `BetterF3`, `entityculling`, `sodium-extra`, `entity_texture_features`,
`entity_model_features`, `ImmediatelyFast`, `drippyloadingscreen`, `melody`,
`welcomescreen`, `Controlling`, `MouseTweaks`, `Searchables`, `tagtooltips`,
`JustEnoughResources`, `TravelersTitles`, `BridgingMod`, `ponderjs`,
`yeetusexperimentus`). These load on the server harmlessly (wasted RAM, no crash)
unless you mark them `client` in `mods-sides.json`. **That manual curation is the
"filter manually" step** — `classify-mods` seeds, you trim cosmetic-client mods.
Also worth an explicit decision in the override file:
- `CustomSkinLoader` — server doesn't need it (skins resolve via Drasl/authlib);
set `client` to keep it off the server.
## `mods-sides.json` format
```json
{
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
"ftb-essentials-neoforge-2101.1.9.jar": "both",
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "server"
}
```
Key = exact jar filename (matches `forgemods/required/*.jar`). Value ∈
`client | server | both`. Hand-edit freely; `classify-mods` won't clobber it.
## Tooling
### `tooling/classify-mods.py` (dev-time, no network)
1. Enumerate `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/forgemods/required/*.jar`.
2. For each jar not already in `mods-sides.json`: parse manifest → derive side
(table above) → add entry.
3. Report: counts per side, and **which jars were defaulted to `both`** (so you
know what to review). Never overwrites existing entries.
4. Optionally warn on entries in `mods-sides.json` whose jar no longer exists.
### `tooling/sync-server-mods.sh` (deploy-time, offline) — replaces `render-pack.sh`
1. Read `mods-sides.json`.
2. `mkdir -p server-mods`; for each `both`/`server` jar, copy from
`$DISTRIBUTION_WEB_ROOT/.../forgemods/required/``server-mods/`.
3. Remove any `server-mods/*.jar` not currently selected (keeps it authoritative;
`REMOVE_OLD_MODS=TRUE` in the container also enforces this on `/data/mods`).
4. **Halt** (non-zero exit) if a selected jar is missing from `$DIST`, or if
`mods-sides.json` is absent — cautious, no silent partial sync.
## docker-compose.yml changes
```yaml
# remove:
# PACKWIZ_URL: ...
# extra_hosts: pack.${BASE_DOMAIN}:host-gateway (auth. stays — authlib)
environment:
MODS_FILE: "" # ensure unset
REMOVE_OLD_MODS: "TRUE" # mounted folder is authoritative
volumes:
- mc_data:/data
- ./runtime:/extras:ro
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
```
`depends_on: caddy` can drop (server no longer fetches `pack.` at boot); keep
`drasl` (authlib). `pack.` caddy vhost + the `./pack`/`./custom` mounts become
dead — remove in the same pass.
## Deploy flow (updated `plan/14`)
```
git pull --ff-only
tooling/render-config.sh # drasl/nmsr/etc. (unchanged)
tooling/sync-server-mods.sh # NEW — replaces render-pack.sh
tooling/fetch-authlib.sh
docker compose down --remove-orphans
docker compose up -d --build
```
`$DISTRIBUTION_WEB_ROOT` must be present on the host (already is — caddy mounts
it). `classify-mods.py` is a dev step run when the modset changes; it is **not**
in the deploy path (keeps deploy offline + deterministic).
## Decommission checklist (on execution)
Done at first cutover (commits `27237bc`, `84196d5`, `c249172`):
- [x] `tooling/classify-mods.py` + generated initial `mods-sides.json`.
- [x] `tooling/sync-server-mods.sh`.
- [x] compose: dropped `PACKWIZ_URL`, `pack.` extra_host, `./pack`/`./custom`
mounts, `pack.` caddy vhost; added `server-mods` mount + `REMOVE_OLD_MODS`.
- [x] deleted `tooling/render-pack.sh` (+ dead `add-custom-mod.sh`).
- [x] deleted `pack/` and `custom/` (jars now sourced from `$DIST`).
- [x] `.gitignore`: dropped packwiz lines; added `server-mods/`.
- [x] superseded `plan/04-packwiz.md`; updated `05-minecraft.md`, `14-deploy.md`,
`CLAUDE.md`.
- [x] resolved the host-local divergence around `pack/`
([[cochi-host-local-divergence]]).
- [ ] **STILL OPEN (roadmap):** landing join steps still reference the dead
`pack.toml` URL (`site.ts packwizUrl`, `i18n/ui.ts` step 3) — rework around
the HeliosLauncher/`distribution.json` flow.
- [x] deployed to `cochi`: NeoForge bumped to 21.1.233, 52 server mods, `Done`,
mc-status `online:true`.
## Deployment gotchas (learned 2026-06-10, first cutover)
### NeoForge version MUST match the distribution
The distribution's mods are built against a specific NeoForge (in
`distribution.json``21.1.233`). Several hard-require it (ferritecore ≥21.1.218,
farmersdelight ≥21.1.219, configured ≥21.1.211). A lower `NEOFORGE_VERSION` pin
fails pre-load with `Missing or unsupported mandatory dependencies: neoforge`.
Keep `docker-compose.yml`'s pin in lockstep with the distribution on every mod
update.
### Client-only mods that declare `BOTH` crash the dedicated server
tomllib classifies by the `minecraft`/`neoforge` dependency `side`. Mods that
declare **`BOTH`** but are actually client-only (touch `net.minecraft.client.*`)
pass through as `both`, load on the server, and crash hard:
```
java.lang.RuntimeException: Attempted to load class
net/minecraft/client/gui/screens/Screen for invalid dist DEDICATED_SERVER
- <Mod> has failed to load correctly
```
This is **not** mere RAM waste — it's a fatal crashloop. tomllib cannot detect
it (the manifest lies). **Fix = hand-set the mod to `client` in
`mods-sides.json`, re-sync, recreate minecraft.** First cutover required marking
these (leaf client-GUI mods, nothing server-loaded depends on them):
`BetterF3, JustEnoughResources, MouseTweaks, Searchables, TravelersTitles,
drippyloadingscreen, entityculling, fancymenu, konkrete, melody, ponderjs,
welcomescreen, yeetusexperimentus` — on top of the 11 tomllib auto-caught
(sodium, sodium-extra, iris, ImmediatelyFast, appleskin, Controlling, BridgingMod,
CustomSkinLoader, entity_model_features, entity_texture_features, tagtooltips).
**Current state: 24 `client` / 52 `both`.** The 52 may still hide a client-only
mod that loaded without crashing; if a future restart trips another
`invalid dist DEDICATED_SERVER`, flip that one jar to `client` and re-sync — same
one-line loop. When excluding, prefer leaf mods; if a `both` mod hard-depends on
the one you exclude you'll get a `Missing mandatory dependency` instead — put it
back.
## Open questions / risks
- **No manifest** in a jar → defaults `both`. Acceptable; review the defaulted
list `classify-mods` prints.
- **Nebula regen** may rename jars (version bumps) → `mods-sides.json` keys go
stale; `classify-mods` re-seeds new names as `both`, and `sync` warns on
missing-from-DIST. Re-curate on big mod updates.