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

8.0 KiB

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" stepclassify-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

{
  "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

# 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.