#!/usr/bin/env bash # Push a placement change into the running game. # # live-reload.sh # # The mod jar is deliberately NOT touched. NeoForge holds every mod jar open for # the whole session, and `cp` rewrites a file in place - same inode - so # overwriting the jar under a running game invalidates its view of its own # archive. The symptom is not a crash but modules quietly going missing: # # could not find module cmmodular:armor/socket/arm_left substituting with empty module # # ...and every part built on them vanishing off the wearer, with the jar on disk # still passing `unzip -t`. Only a restart clears it. # # So the module JSON goes into the world's own datapack folder instead, which is # read fresh on every `/reload` and loads after the mod's built-in packs, so the # copy there wins. Editing is live and nothing is held open. # # Models and textures under `assets/` are resourcepack, not datapack, and are # still read from the jar - those need a restart. The script says so rather than # pretending F3+T reached them. set -uo pipefail REPO=/home/themiro/code.dev/create-mekanism-modular INSTANCES=/home/themiro/.var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances export YDOTOOL_SOCKET=${YDOTOOL_SOCKET:-/run/user/1000/.ydotool_socket} SAVED=${1:-} PY=$REPO/tools/.venv/bin/python MCWIN="Minecraft NeoForge" PACK=cmmodular-live say() { printf '%s\n' "$*"; } # `pgrep -f java.*minecraft` also matches a Gradle daemon - the word turns up in # a classpath - so the instance is identified by the Prism path in the cmdline # and nothing else. Guessing wrong here means reloading an instance nobody is # looking at, or claiming the game is up when it is not. INSTANCE="" for p in $(pgrep -x java 2>/dev/null); do c=$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null) case "$c" in *PrismLauncher/instances/*) INSTANCE=${c#*PrismLauncher/instances/} INSTANCE=${INSTANCE%%/*} break ;; esac done [ -z "$INSTANCE" ] && { say "game not running (no Prism instance in any java process)"; exit 0; } MC="$INSTANCES/$INSTANCE/minecraft" WORLD=$(ls -t "$MC/saves" 2>/dev/null | head -1) [ -z "$WORLD" ] && { say "no world in $INSTANCE"; exit 1; } case "$SAVED" in *"/assets/"*) say "NOTE: $(basename "$SAVED") is a resourcepack file (models/textures)." say " Those load from the jar and cannot be hot-swapped - restart to see it." exit 0 ;; esac # Validate before shipping: a malformed module is dropped silently by MIAPI and # looks exactly like the part being mispositioned. if [ -n "$SAVED" ] && ! "$PY" -c "import json,sys;json.load(open(sys.argv[1]))" "$SAVED" 2>/tmp/live-json.err; then say "INVALID JSON - not deploying"; sed 's/^/ /' /tmp/live-json.err; exit 1 fi DEST="$MC/saves/$WORLD/datapacks/$PACK" FIRST=0; [ -d "$DEST" ] || FIRST=1 # a pack added mid-session needs enabling mkdir -p "$DEST" cat > "$DEST/pack.mcmeta" <<'META' { "pack": { "description": "cmmodular live placement edits - overrides the jar's modules while iterating", "pack_format": 48 } } META rm -rf "$DEST/data" for p in "$REPO"/src/main/resources/packs/*/data; do cp -r "$p/." "$DEST/data/" 2>/dev/null || { mkdir -p "$DEST/data"; cp -r "$p/." "$DEST/data/"; } done say "staged -> saves/$WORLD/datapacks/$PACK" wmctrl -a "$MCWIN" >/dev/null 2>&1 || { say "no game window; datapack staged"; exit 0; } sleep 0.6 menu_open() { grim /tmp/live-reload-probe.png 2>/dev/null || return 1 "$PY" - <<'EOF' import sys import numpy as np from PIL import Image a=np.array(Image.open('/tmp/live-reload-probe.png').convert('RGB')) h,w,_=a.shape b=a[h//6:h//2, w//3:2*w//3] g=((abs(b[...,0].astype(int)-b[...,1])<12)&(abs(b[...,1].astype(int)-b[...,2])<12) &(b[...,0]>110)&(b[...,0]<210)).mean() sys.exit(0 if g>0.15 else 1) EOF } # One Escape closes chat, closes the menu, or opens it from gameplay; after it # the only ambiguity left is whether the menu is up, which is checked. Without # this a stray open chat eats the T and the command posts as literal "t/reload". ydotool key 1:1 1:0; sleep 0.7 if menu_open; then ydotool key 1:1 1:0; sleep 0.7; fi # QWERTZ: ydotool speaks US keycodes, so the two punctuation marks these # commands need are sent by position - '/' is shift+7, '-' is where US puts '/'. slash() { ydotool key 42:1 8:1 8:0 42:0; sleep 0.2; } dash() { ydotool key 53:1 53:0; sleep 0.2; } ydotool key 20:1 20:0; sleep 0.7 # T -> chat slash if [ "$FIRST" = 1 ]; then # A datapack that appeared after the world was loaded is present but not # enabled; `/reload` alone will not pick it up. Enabling reloads too. ydotool type -d 28 "datapack enable file"; slash ydotool type -d 28 "cmmodular"; dash; ydotool type -d 28 "live" sleep 0.4; ydotool key 28:1 28:0; sleep 2.0 say "enabled datapack $PACK (first run this session)" else ydotool type -d 28 "reload"; sleep 0.4 ydotool key 28:1 28:0; sleep 1.5 say "/reload sent - $(basename "${SAVED:-all modules}") live" fi