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

10 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)

Done at first cutover (commits 27237bc, 84196d5, c249172):

  • tooling/classify-mods.py + generated initial mods-sides.json.
  • tooling/sync-server-mods.sh.
  • compose: dropped PACKWIZ_URL, pack. extra_host, ./pack/./custom mounts, pack. caddy vhost; added server-mods mount + REMOVE_OLD_MODS.
  • deleted tooling/render-pack.sh (+ dead add-custom-mod.sh).
  • deleted pack/ and custom/ (jars now sourced from $DIST).
  • .gitignore: dropped packwiz lines; added server-mods/.
  • superseded plan/04-packwiz.md; updated 05-minecraft.md, 14-deploy.md, CLAUDE.md.
  • 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.
  • 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.json21.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.