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>
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 HeliosLauncherdistribution.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 (
.tmplrender →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+serverjars are copied into the mountedserver-mods/.client-tagged jars never reach the server. - Single source of jars:
$DISTRIBUTION_WEB_ROOT/.../forgemods/required/. This repo stores no jars — onlymods-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); setclientto 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)
- Enumerate
$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/forgemods/required/*.jar. - For each jar not already in
mods-sides.json: parse manifest → derive side (table above) → add entry. - Report: counts per side, and which jars were defaulted to
both(so you know what to review). Never overwrites existing entries. - Optionally warn on entries in
mods-sides.jsonwhose jar no longer exists.
tooling/sync-server-mods.sh (deploy-time, offline) — replaces render-pack.sh
- Read
mods-sides.json. mkdir -p server-mods; for eachboth/serverjar, copy from$DISTRIBUTION_WEB_ROOT/.../forgemods/required/→server-mods/.- Remove any
server-mods/*.jarnot currently selected (keeps it authoritative;REMOVE_OLD_MODS=TRUEin the container also enforces this on/data/mods). - Halt (non-zero exit) if a selected jar is missing from
$DIST, or ifmods-sides.jsonis 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 initialmods-sides.json.tooling/sync-server-mods.sh.- compose: dropped
PACKWIZ_URL,pack.extra_host,./pack/./custommounts,pack.caddy vhost; addedserver-modsmount +REMOVE_OLD_MODS. - deleted
tooling/render-pack.sh(+ deadadd-custom-mod.sh). - deleted
pack/andcustom/(jars now sourced from$DIST). .gitignore: dropped packwiz lines; addedserver-mods/.- superseded
plan/04-packwiz.md; updated05-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.tomlURL (site.ts packwizUrl,i18n/ui.tsstep 3) — rework around the HeliosLauncher/distribution.jsonflow. - deployed to
cochi: NeoForge bumped to 21.1.233, 52 server mods,Done, mc-statusonline: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 listclassify-modsprints. - Nebula regen may rename jars (version bumps) →
mods-sides.jsonkeys go stale;classify-modsre-seeds new names asboth, andsyncwarns on missing-from-DIST. Re-curate on big mod updates.