Files
ulicraft-server-v1/plan/04-mods.md
Oier Bravo Urtasun 27237bc7c1 feat(mods): replace packwiz with distribution-fed custom mod volume
Drop packwiz entirely. The server now loads a filtered mod subset from a
local ./server-mods volume instead of fetching a packwiz pack at boot.

New flow:
- tooling/classify-mods.py (tomllib only, no network) reads each jar's
  META-INF/neoforge.mods.toml and seeds mods-sides.json with a side
  (client|server|both; default both). Existing entries are preserved.
- mods-sides.json: committed, keyed by jar filename, hand-editable override
  surface. Initial seed: 65 both, 11 client, 0 server (76 jars).
- tooling/sync-server-mods.sh mirrors the both/server jars from
  $DISTRIBUTION_WEB_ROOT into ./server-mods/ (authoritative; prunes strays;
  halts on a missing jar). Replaces render-pack.sh.
- compose: minecraft mounts ./server-mods:/mods:ro with REMOVE_OLD_MODS=TRUE
  (itzg auto-syncs /mods -> /data/mods). Removed PACKWIZ_URL, the pack.
  extra_host (auth. kept for authlib), and depends_on caddy (drasl kept).

Both classify-mods.py and sync-server-mods.sh resolve their source dir as:
CLI arg / MODS_DIST_DIR env / $DISTRIBUTION_WEB_ROOT (in that order).

Removed: tooling/render-pack.sh + add-custom-mod.sh (packwiz tooling),
pack/ (packwiz source), custom/ (76 jars now sourced from the distribution),
the pack. caddy vhost + its mounts/alias, and the packwiz .gitignore block.

Client distribution is UNCHANGED: HeliosLauncher still installs the full
modset from distribution.json. Docs (CLAUDE.md, plan/04/05/14, runtime)
updated; plan/04-packwiz.md stubbed as superseded by plan/04-mods.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:34:38 +02:00

173 lines
8.0 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)
- [ ] `tooling/classify-mods.py` + generate initial `mods-sides.json` (review it).
- [ ] `tooling/sync-server-mods.sh`.
- [ ] compose: drop `PACKWIZ_URL`, `pack.` extra_host, `./pack`/`./custom` mounts,
`pack.` caddy vhost; add `server-mods` mount + `REMOVE_OLD_MODS`.
- [ ] delete `tooling/render-pack.sh`; remove its call from docs/deploy.
- [ ] delete `pack/` (pack.toml, index.toml, `*.pw.toml*`, `.packwizignore`,
CustomSkinLoader templates if duplicated in DIST) and `custom/` (jars now
sourced from `$DIST`).
- [ ] `.gitignore`: drop packwiz lines; add `server-mods/`.
- [ ] supersede `plan/04-packwiz.md`; update `05-minecraft.md`, `14-deploy.md`,
`CLAUDE.md` (mod source section), and the landing join steps if they still
reference `pack.toml`.
- [ ] resolves the host-local divergence around `pack/` ([[cochi-host-local-divergence]]).
## Open questions / risks
- **Cosmetic-client mods** load on the server unless manually set `client` — RAM
waste, not a crash. Curate `mods-sides.json` over time.
- **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.