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>
This commit is contained in:
oierandClaude Opus 4.8 committed 2026-06-09 23:34:38 +02:00
1 parent 90c3be7bb5
commit 27237bc7c1
108 files changed
+647 -369

No files matched your search

+4 -6
View File
@@ -6,15 +6,13 @@ runtime/authlib-injector.jar
# rendered configs (from tooling/render-config.sh) # rendered configs (from tooling/render-config.sh)
drasl/config/config.toml drasl/config/config.toml
nmsr/config.toml nmsr/config.toml
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars. # server mod subset synced from the distribution repo by
pack/mods/*.pw.toml # tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
pack/index.toml server-mods/
pack/CustomSkinLoader/CustomSkinLoader.json
# vendored binaries # vendored binaries
launcher/ launcher/
pack/mods-files/
# landing page: generated build output + deps # landing page: generated build output + deps
www/ www/
+27 -14
View File
@@ -1,7 +1,7 @@
# Ulicraft Server — Project Context # Ulicraft Server — Project Context
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins > Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
> (Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime > (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime
> monitoring. Public, internet-present deployment behind the host's nginx + TLS. > monitoring. Public, internet-present deployment behind the host's nginx + TLS.
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`. **`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
@@ -30,18 +30,17 @@ Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880
│ routes by Host: │ routes by Host:
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads ${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI) auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
pack.${BASE_DOMAIN} ─► packwiz files + /custom/ jars
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed) avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors) status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT) distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
minecraft :25565 (+ 24454/udp Simple Voice Chat) minecraft :25565 (+ 24454/udp Simple Voice Chat)
└ ONLINE_MODE=false, -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector └ ONLINE_MODE=false, -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
└ mods via PACKWIZ_URL → http://pack.${BASE_DOMAIN}/pack.toml └ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only) mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
``` ```
Bring-up: render configs → build landing → fetch launcher → `docker compose up -d --build`. Bring-up: render configs → sync server mods → build landing → fetch launcher → `docker compose up -d --build`.
See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi` See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi`
via the `deploy` skill. via the `deploy` skill.
@@ -55,8 +54,11 @@ via the `deploy` skill.
- **Manual curation** (user choice, flagged expensive) — if it gets painful, - **Manual curation** (user choice, flagged expensive) — if it gets painful,
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match). fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge. - **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
- **packwiz** for the modpack — `PACKWIZ_URL` drives server mod install; clients - **Distribution-fed mod volume (replaced packwiz)** — the modset's source of
import the same `pack.toml`. Jars come from the upstream CDN per `.pw.toml`. truth is the HeliosLauncher distribution repo (`$DISTRIBUTION_WEB_ROOT`).
Clients install the full set via `distribution.json`. The SERVER loads a
filtered subset (`both`/`server`) from a mounted `./server-mods` volume
(`REMOVE_OLD_MODS=TRUE`). See `plan/04-mods.md`.
## Critical Gotchas ## Critical Gotchas
@@ -81,10 +83,17 @@ Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
"21.1.209"`. **Verify against https://projects.neoforged.net/neoforged/neoforge "21.1.209"`. **Verify against https://projects.neoforged.net/neoforged/neoforge
before deploying.** Never `"latest"` for a stable server. before deploying.** Never `"latest"` for a stable server.
### Mod source preference ### Mod source + server filtering
Prefer **Modrinth over CurseForge** (`packwiz modrinth add <slug>`). CF needs The full modset lives in the distribution repo (`$DISTRIBUTION_WEB_ROOT/servers/
`CF_API_KEY` and is flakier. Tag client-only mods `side = "client"` so the server ulicraft-1.21.1/forgemods/required/*.jar`), managed by Nebula. Clients get
doesn't pull and crash on them. everything via `distribution.json`. The server runs a **filtered subset**:
`tooling/classify-mods.py` reads each jar's `neoforge.mods.toml` with tomllib and
seeds `mods-sides.json` (`client|server|both`, default `both`), then
`tooling/sync-server-mods.sh` copies the `both`/`server` jars into `./server-mods`
(mounted at `/mods`). Hard client-only mods (sodium/iris) classify as `client`
automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
classify/sync time. See `plan/04-mods.md`.
### Memory sizing ### Memory sizing
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`, ~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
@@ -93,9 +102,11 @@ doesn't pull and crash on them.
## Config (.env) ## Config (.env)
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`, `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
`DISTRIBUTION_WEB_ROOT`, the Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`, `DISTRIBUTION_WEB_ROOT` (source of the modset + caddy `distribution.` mount), the
`OVH_*`), optional `CF_API_KEY`. Rendered configs (drasl, nmsr, packwiz) come Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`, `OVH_*`). Rendered configs
from `*.tmpl` via `tooling/render-config.sh` (substitutes `${BASE_DOMAIN}` only). (drasl, nmsr) come from `*.tmpl` via `tooling/render-config.sh` (substitutes
`${BASE_DOMAIN}` only). Server mods are synced (not rendered) by
`tooling/sync-server-mods.sh`.
## Client Distribution (guests) ## Client Distribution (guests)
@@ -105,7 +116,9 @@ mirrored at apex `/launcher/`. Per guest:
2. Add an authlib-injector account, URL 2. Add an authlib-injector account, URL
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at `https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
`https://auth.${BASE_DOMAIN}`). `https://auth.${BASE_DOMAIN}`).
3. Import the pack `https://pack.${BASE_DOMAIN}/pack.toml`. 3. The launcher installs the modpack from the distribution (`distribution.json`).
(NOTE: packwiz/`pack.` removed — the landing join step still references a
packwiz URL and needs reworking around the launcher/distribution flow.)
4. Connect to `${BASE_DOMAIN}` (`:25565`). 4. Connect to `${BASE_DOMAIN}` (`:25565`).
## Open / Pending Work ## Open / Pending Work
+1 -1
View File
@@ -1,5 +1,5 @@
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets: # Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
# conf.d/00-core.caddy auth + packwiz # conf.d/00-core.caddy auth
# conf.d/10-static.caddy apex landing + launcher downloads # conf.d/10-static.caddy apex landing + launcher downloads
# conf.d/30-distribution.caddy static site from another repo # conf.d/30-distribution.caddy static site from another repo
# conf.d/40-avatar.caddy nmsr skin/avatar renderer # conf.d/40-avatar.caddy nmsr skin/avatar renderer
+1 -6
View File
@@ -1,9 +1,4 @@
# Core ingress — always mounted. Drasl auth + packwiz pack metadata. # Core ingress — always mounted. Drasl auth.
http://auth.{$BASE_DOMAIN} { http://auth.{$BASE_DOMAIN} {
reverse_proxy drasl:25585 reverse_proxy drasl:25585
} }
http://pack.{$BASE_DOMAIN} {
root * /srv/pack
file_server browse
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -20
View File
@@ -3,9 +3,9 @@
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# Single, unified compose. One file holds the whole stack: # Single, unified compose. One file holds the whole stack:
# drasl auth + skins (Yggdrasil), internal only # drasl auth + skins (Yggdrasil), internal only
# minecraft the server (itzg/NEOFORGE), installs mods via packwiz # minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
# mc-backup world backups every 6h via RCON # mc-backup world backups every 6h via RCON
# caddy internal ingress — routes auth./pack./apex/launcher/avatar. # caddy internal ingress — routes auth./apex/launcher/avatar.
# /distribution. by Host header (conf.d/*.caddy) # /distribution. by Host header (conf.d/*.caddy)
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN} # nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
# uptime-kuma status monitoring at status.${BASE_DOMAIN} # uptime-kuma status monitoring at status.${BASE_DOMAIN}
@@ -16,7 +16,7 @@
# Production TLS terminates at the HOST's nginx in front of caddy # Production TLS terminates at the HOST's nginx in front of caddy
# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a # (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a
# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach # localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach
# the stack's own names internally via caddy's mcnet aliases (auth./pack.). # the stack's own names internally via caddy's mcnet alias (auth.).
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
services: services:
@@ -71,8 +71,12 @@ services:
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+ ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
ALLOW_FLIGHT: "TRUE" # many tech mods need this ALLOW_FLIGHT: "TRUE" # many tech mods need this
# ── Mod source: packwiz pack served by caddy (alias pack.) ── # ── Mod source: filtered subset of the distribution modset ──
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml" # Jars are synced into ./server-mods by tooling/sync-server-mods.sh
# (both/server entries from mods-sides.json) and mounted at /mods below;
# itzg auto-syncs /mods → /data/mods. REMOVE_OLD_MODS makes that mirror
# authoritative so removed mods get pruned. See plan/04-mods.md.
REMOVE_OLD_MODS: "TRUE"
# ── RCON for remote admin ─────────────────────────────────── # ── RCON for remote admin ───────────────────────────────────
ENABLE_RCON: "TRUE" ENABLE_RCON: "TRUE"
@@ -82,16 +86,15 @@ services:
TZ: "Europe/Madrid" TZ: "Europe/Madrid"
volumes: volumes:
- mc_data:/data - mc_data:/data
- ./runtime:/extras:ro # authlib-injector.jar lives here - ./runtime:/extras:ro # authlib-injector.jar lives here
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
depends_on: depends_on:
- drasl - drasl # authlib (auth.) must resolve at boot
- caddy # pack./auth. must resolve at boot
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a # Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
# dead address for the public names. Pin auth./pack. to the host so HTTPS to # dead address for the public names. Pin auth. to the host so HTTPS to it
# them lands on the host's nginx (valid LE cert) -> caddy -> service. # lands on the host's nginx (valid LE cert) -> caddy -> drasl.
extra_hosts: extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway" - "auth.${BASE_DOMAIN}:host-gateway"
- "pack.${BASE_DOMAIN}:host-gateway"
networks: networks:
- mcnet - mcnet
@@ -114,7 +117,7 @@ services:
- mcnet - mcnet
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy): # Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
# auth. -> drasl, pack. -> packwiz files, apex -> landing + /launcher, # auth. -> drasl, apex -> landing + /launcher,
# avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site. # avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site.
# Published on a localhost-only port; the host's nginx terminates TLS in # Published on a localhost-only port; the host's nginx terminates TLS in
# front of it (nginx/ulicraft-caddy.conf.tmpl). # front of it (nginx/ulicraft-caddy.conf.tmpl).
@@ -135,13 +138,6 @@ services:
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro - ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
- ./www:/srv/www:ro - ./www:/srv/www:ro
- ./launcher:/srv/launcher:ro - ./launcher:/srv/launcher:ro
- ./pack:/srv/pack:ro
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
# doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/.
# Nested under the :ro /srv/pack mount — the mountpoint pack/custom/ must
# pre-exist on the host (tracked via pack/custom/.gitkeep) or Docker can't
# mkdir it in the read-only parent.
- ./custom:/srv/pack/custom:ro
# Static site from ANOTHER repo (absolute host path in .env). # Static site from ANOTHER repo (absolute host path in .env).
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro - ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
networks: networks:
@@ -149,7 +145,6 @@ services:
# Containers resolve the stack's own public names to caddy internally. # Containers resolve the stack's own public names to caddy internally.
aliases: aliases:
- "auth.${BASE_DOMAIN}" - "auth.${BASE_DOMAIN}"
- "pack.${BASE_DOMAIN}"
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls # Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080. # player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
+7 -1
View File
@@ -43,7 +43,13 @@ export const site = {
// Requires CORSAllowOrigins to include the apex in drasl config.toml. // Requires CORSAllowOrigins to include the apex in drasl config.toml.
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`, draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
// packwiz modpack entrypoint (public HTTPS, pack. subdomain). // TODO: packwiz removed — the `pack.` vhost no longer exists, so this URL 404s.
// The modpack is now distributed to clients by HeliosLauncher via the
// distribution.json (server loads a filtered subset from ./server-mods/; see
// plan/04-mods.md). The join flow still references this in the launcher step
// and needs to be redesigned around the launcher/distribution flow. Kept
// (non-empty) only so the Astro build + the CopyChip in [...lang].astro don't
// break; do not treat as live.
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`, packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not // NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
+78
View File
@@ -0,0 +1,78 @@
{
"BetterF3-11.0.3-NeoForge-1.21.1.jar": "both",
"BridgingMod-2.6.2+1.21.1.neoforge-release.jar": "client",
"Controlling-neoforge-1.21.1-19.0.5.jar": "client",
"CustomSkinLoader_Universal-14.28.jar": "client",
"ExplorersCompass-1.21.1-3.4.0-neoforge.jar": "both",
"FarmersDelight-1.21.1-1.3.2.jar": "both",
"FramedBlocks-10.6.0.jar": "both",
"ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar": "client",
"Jade-1.21.1-NeoForge-15.10.5.jar": "both",
"JadeAddons-1.21.1-NeoForge-6.1.0.jar": "both",
"JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar": "both",
"JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar": "both",
"MouseTweaks-neoforge-mc1.21-2.26.1.jar": "both",
"Necronomicon-NeoForge-1.6.0+1.21.jar": "both",
"Patchouli-1.21.1-93-NEOFORGE.jar": "both",
"Searchables-neoforge-1.21.1-1.0.2.jar": "both",
"TravelersTitles-1.21.1-NeoForge-5.1.3.jar": "both",
"YungsApi-1.21.1-NeoForge-5.1.6.jar": "both",
"appleskin-neoforge-mc1.21-3.0.9.jar": "client",
"architectury-13.0.8-neoforge.jar": "both",
"baguettelib-1.21.1-NeoForge-2.0.3.jar": "both",
"balm-neoforge-1.21.1-21.0.58.jar": "both",
"cardboardboxes-1.21.1-0.1.2.jar": "both",
"cloth-config-15.0.140-neoforge.jar": "both",
"comforts-neoforge-9.0.5+1.21.1.jar": "both",
"configured-neoforge-1.21.1-2.6.3.jar": "both",
"corpse-neoforge-1.21.1-1.1.13.jar": "both",
"corpsecurioscompat-1.21.1-NeoForge-4.0.1.jar": "both",
"curios-neoforge-9.5.1+1.21.1.jar": "both",
"drippyloadingscreen_neoforge_3.1.2_MC_1.21.1.jar": "both",
"entity_model_features-3.2.4-1.21-neoforge.jar": "client",
"entity_texture_features_1.21-neoforge-7.1.jar": "client",
"entityculling-neoforge-1.10.2-mc1.21.1.jar": "both",
"fancymenu_neoforge_3.9.1_MC_1.21.1.jar": "both",
"fastleafdecay-35.jar": "both",
"ferritecore-7.0.3-neoforge.jar": "both",
"ftb-chunks-neoforge-2101.1.17.jar": "both",
"ftb-essentials-neoforge-2101.1.9.jar": "both",
"ftb-library-neoforge-2101.1.31.jar": "both",
"ftb-teams-neoforge-2101.1.10.jar": "both",
"ftb-ultimine-neoforge-2101.1.14.jar": "both",
"functionalstorage-1.21.1-1.5.7.jar": "both",
"handcrafted-neoforge-1.21.1-4.0.3.jar": "both",
"inventoryessentials-neoforge-1.21.1-21.1.15.jar": "both",
"iris-neoforge-1.8.12+mc1.21.1.jar": "client",
"jamlib-neoforge-1.3.6+1.21.1.jar": "both",
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
"konkrete_neoforge_1.9.9_MC_1.21.jar": "both",
"kubejs-neoforge-2101.7.2-build.368.jar": "both",
"kubejsdelight-1.1.6.jar": "both",
"lithium-neoforge-0.15.3+mc1.21.1.jar": "both",
"lootr-neoforge-1.21.1-1.11.37.120.jar": "both",
"melody_neoforge_1.0.10_MC_1.21.jar": "both",
"morefunctionalstorage-1.2.3.jar": "both",
"polymorph-neoforge-1.1.0+1.21.1.jar": "both",
"ponderjs-neoforge-1.21.1-2.4.0.jar": "both",
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
"rhino-2101.2.7-build.85.jar": "both",
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both",
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
"sodium-extra-neoforge-0.8.7+mc1.21.1.jar": "client",
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
"sophisticatedcore-1.21.1-1.4.49.1976.jar": "both",
"spark-1.10.124-neoforge.jar": "both",
"supermartijn642configlib-1.1.8-neoforge-mc1.21.jar": "both",
"supermartijn642corelib-1.1.21-neoforge-mc1.21.jar": "both",
"tagtooltips-neoforge-1.21.1-1.2.0.jar": "client",
"titanium-1.21-4.0.43.jar": "both",
"torchmaster-neoforge-1.21.1-21.1.9.jar": "both",
"trashcans-1.0.18c-neoforge-mc1.21.jar": "both",
"utilitarian-1.21.1-0.19.2.jar": "both",
"voicechat-neoforge-1.21.1-2.6.18.jar": "both",
"welcomescreen-neoforge-1.0.0-1.21.1.jar": "both",
"yeetusexperimentus-neoforge-87.0.0.jar": "both",
"yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar": "both"
}
-3
View File
@@ -1,3 +0,0 @@
# Template sources for pack metadata/config — rendered to their real files by
# tooling/render-pack.sh. packwiz must NOT index the templates themselves.
*.tmpl
@@ -1,11 +0,0 @@
{
"enable": true,
"loadlist": [
{
"name": "Ulicraft Skins",
"type": "MojangAPI",
"apiRoot": "https://auth.${BASE_DOMAIN}/account/",
"sessionRoot": "https://auth.${BASE_DOMAIN}/session/"
}
]
}
-3
View File
@@ -1,3 +0,0 @@
# Ulicraft modpack (packwiz)
This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add <slug>` (prefer Modrinth over CurseForge); each mod produces a `mods/<slug>.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`. Clients and the itzg server fetch jars from the upstream CDN referenced in each `.pw.toml`. Curation is an ongoing manual task — no mods are added yet.
View File
Whitespace-only changes.
@@ -1,8 +0,0 @@
name = "balm-neoforge-1.21.1-21.0.58"
filename = "balm-neoforge-1.21.1-21.0.58.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/balm-neoforge-1.21.1-21.0.58.jar"
hash-format = "sha256"
hash = "aa2463747bad4c2351869226ea7bce7839510dfd2ac387f8d1588b1ce9309c67"
@@ -1,8 +0,0 @@
name = "CustomSkinLoader_Universal-14.28"
filename = "CustomSkinLoader_Universal-14.28.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/CustomSkinLoader_Universal-14.28.jar"
hash-format = "sha256"
hash = "0008fb0a91dd1e84d52162cfe96addb5f6aed3b76d426ea7305d7624e31f1894"
@@ -1,8 +0,0 @@
name = "inventoryessentials-neoforge-1.21.1-21.1.15"
filename = "inventoryessentials-neoforge-1.21.1-21.1.15.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/inventoryessentials-neoforge-1.21.1-21.1.15.jar"
hash-format = "sha256"
hash = "b7a5c5526a45256551543419ba2b5d54e56c585d76a645a8cea8cb8ea843ae2c"
@@ -1,8 +0,0 @@
name = "Jade-1.21.1-NeoForge-15.10.5"
filename = "Jade-1.21.1-NeoForge-15.10.5.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/Jade-1.21.1-NeoForge-15.10.5.jar"
hash-format = "sha256"
hash = "067bb4b007e1d6f6b79f0afe99c91252aa825472b99a76d33a60d24442f9e92d"
@@ -1,8 +0,0 @@
name = "JadeAddons-1.21.1-NeoForge-6.1.0"
filename = "JadeAddons-1.21.1-NeoForge-6.1.0.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/JadeAddons-1.21.1-NeoForge-6.1.0.jar"
hash-format = "sha256"
hash = "5f242668ad710092964d4d9afda8a94fa2401665ea13eea67d55d07e4dff8a64"
@@ -1,8 +0,0 @@
name = "jei-1.21.1-neoforge-19.27.0.340"
filename = "jei-1.21.1-neoforge-19.27.0.340.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/jei-1.21.1-neoforge-19.27.0.340.jar"
hash-format = "sha256"
hash = "8aaf547432f1b4958239b036356b910692fe40f858c3073d996f56bbf7c99826"
@@ -1,8 +0,0 @@
name = "JustEnoughProfessions-neoforge-1.21.1-4.0.5"
filename = "JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar"
hash-format = "sha256"
hash = "b320b47f1791f56df9e1a05138f0e312e6e54541945ff15f2f1fd398cd1d4d9d"
@@ -1,8 +0,0 @@
name = "JustEnoughResources-NeoForge-1.21.1-1.6.0.17"
filename = "JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar"
hash-format = "sha256"
hash = "7a47d69b5530704690d5a5f0726cd74a2d6c57df17dd4a8589c4ab5212f91460"
@@ -1,8 +0,0 @@
name = "MouseTweaks-neoforge-mc1.21-2.26.1"
filename = "MouseTweaks-neoforge-mc1.21-2.26.1.jar"
side = "both"
[download]
url = "http://pack.${BASE_DOMAIN}/custom/MouseTweaks-neoforge-mc1.21-2.26.1.jar"
hash-format = "sha256"
hash = "68e6f4201c5de97b77929a7215c9552495696ca6a3bf3ae4eacc34e135f6cc8b"
-13
View File
@@ -1,13 +0,0 @@
name = "Ulicraft"
author = "Oier"
version = "1.0.0"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "9bca0b1a4434050835cab46116d7c14aafec971ff5a95a1ee0c4232c8bcbc355"
[versions]
minecraft = "1.21.1"
neoforge = "21.1.209"
BIN
View File
Binary file not shown.
+172
View File
@@ -0,0 +1,172 @@
# 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.
+6 -36
View File
@@ -1,37 +1,7 @@
# packwiz — modpack source of truth # packwiz — modpack source of truth (SUPERSEDED)
## Summary > **Superseded by [`04-mods.md`](04-mods.md).** packwiz has been removed.
> The server now loads a filtered mod subset from a local `server-mods/` volume
`packwiz` (installed at `~/go/bin/packwiz`) authors the modpack. The `./pack` > (synced from the distribution repo by `tooling/sync-server-mods.sh`); clients
directory is the **single source of truth** for mods, consumed by both: > still get the full set from the HeliosLauncher `distribution.json`.
- the **server** (itzg `PACKWIZ_URL`), and > See `04-mods.md` for the current design.
- **clients** (Fjord launcher imports the pack / packwiz-installer).
Caddy serves `./pack` at `pack.ulicraft.net`. packwiz emits **metadata**
(`pack.toml`, `index.toml`, `mods/*.pw.toml`); the `.pw.toml` files reference
Modrinth/CF **CDN URLs**, so server and clients fetch the jars from those CDNs.
The stack assumes the internet is present.
## Behaviors confirmed
- **Prune**: packwiz-installer tracks a manifest and **removes** client mods no
longer in the index on re-run. (Server side via itzg likewise re-syncs.)
- **Download source**: jars come from the CDN URLs in `.pw.toml`, not the pack
host (which serves only metadata).
- **itzg side filtering**: itzg downloads **server-side mods only**. Client-only
mods MUST be tagged `side = "client"` (not `both`) or the server may pull and
crash on them.
## NeoForge pinning
`pack.toml` pins MC `1.21.1` + NeoForge `21.1.x`. Verify exact NeoForge build
against projects.neoforged.net before locking.
## Tasks
- [ ] `packwiz init` → `./pack` (MC 1.21.1, modloader neoforge, pinned version)
- [ ] Curate mod shortlist (perf/tech/magic/storage/QoL/worldgen/map/voice)
- [ ] Tag client-only mods `side = "client"`
- [ ] `packwiz refresh`; verify `index.toml` committed (clients fetch it)
- [ ] Point server `PACKWIZ_URL=http://pack.ulicraft.net/pack.toml`
- [ ] Test install end-to-end on a fresh client
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more