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:
2026-06-09 23:34:38 +02:00
parent 90c3be7bb5
commit 27237bc7c1
108 changed files with 647 additions and 369 deletions

10
.gitignore vendored
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/

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
~50100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`, ~50100 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

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

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.

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"
@@ -83,15 +87,14 @@ services:
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.

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
mods-sides.json Normal file
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"
}

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

View File

@@ -1,11 +0,0 @@
{
"enable": true,
"loadlist": [
{
"name": "Ulicraft Skins",
"type": "MojangAPI",
"apiRoot": "https://auth.${BASE_DOMAIN}/account/",
"sessionRoot": "https://auth.${BASE_DOMAIN}/session/"
}
]
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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"

Binary file not shown.

172
plan/04-mods.md Normal file
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.

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

Some files were not shown because too many files have changed in this diff Show More