# 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 ← { "": "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 - 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.