OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scripts / dev-run.sh

202 lines · bash ref:
#!/usr/bin/env bash
# dev-run — the SOURCE-TREE equivalent of the installed `hyper-tps` launcher.
#
#   scripts/dev-run.sh [extra godot args...]
#   scripts/dev-run.sh --daemon-only     # daemon, no game
#   scripts/dev-run.sh --no-daemon       # game only, as before
#
# WHY THIS EXISTS. Running godot against the source tree by hand gets you the
# game and nothing else:
#
#   godot.linuxbsd.editor.x86_64 --path <repo> --rendering-driver vulkan
#
# The installed launcher is `bare index.js`, and index.js starts keet-daemon
# BEFORE the game binary. Skip it and the game boots with no daemon: the UDS
# client retries /tmp/keet-daemon.sock forever, room state never loads
# ("room-state load timed out — proceeding with defaults"), and every durable
# write lives only in GameState's in-memory mirror until you quit. Which means
# persistence cannot be tested from source at all — you get a session that looks
# like it is saving and loses everything on exit.
#
# So: same two processes the launcher runs, but the SOURCE tree as the game
# rather than the exported .pck. Edit a .gd, restart, keep the daemon.
#
# THE ENGINE IS THE FORK, NOT SYSTEM godot4 (4.6.stable). Which fork build is
# decided in scripts/engine.sh, shared by every dev script -- see there for why
# it can differ from the one CI exports with.
#
# NOT a replacement for scripts/daemon-session.sh, which manages long-lived
# headless peers off the INSTALLED launcher. This is the interactive dev loop.

set -uo pipefail

REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
. "$REPO/scripts/engine.sh"
# NVIDIA's shader disk cache defaults to a 128 MB cap and stores nothing new once
# full; every launch then recompiles proc-nodes' and dc-terrain's ~115k-char
# shaders in the driver (6.8-7.3 s each, measured). See index.js.
export __GL_SHADER_DISK_CACHE_SIZE="${__GL_SHADER_DISK_CACHE_SIZE:-4294967296}"
# BARE / SOCK / LOG, ensure_daemon and stop_daemon_if_started: shared with
# dev-editor.sh since 2026-10-05.
DAEMON_TAG=dev-run
. "$REPO/scripts/daemon.sh"

_die() { echo "dev-run: $*" >&2; exit 1; }

want_daemon=1
daemon_only=0
godot_args=()
# Rooms to OPEN AND INDEX at daemon boot. StateBase is lazy — "a bare daemon
# never indexes a room on its own" (index.js) — so without a pin the room is not
# opened until the game first asks for it.
#
# MEASURED, because game.gd's _await_room_state comment claims 15-20 s for this
# and that is wrong by more than an order of magnitude:
#
#   cold daemon boot -> UDS socket      1.40 s
#   first state-list (opens the room)   1.18 s
#   subsequent state-list (warm)        0.05 s   (1014 keys)
#
# So pinning saves about a second, and is worth having, but it is NOT where a
# slow start goes. Reading local state was never the bottleneck.
pin_rooms=()
[ -n "${HYPER_TPS_PIN_ROOM:-}" ] && pin_rooms+=(--pin-room "$HYPER_TPS_PIN_ROOM")
for a in "$@"; do
  case "$a" in
    --no-daemon)   want_daemon=0 ;;
    --daemon-only) daemon_only=1 ;;
    --pin-room=*)  pin_rooms+=(--pin-room "${a#*=}") ;;
    *)             godot_args+=("$a") ;;
  esac
done

# REPAIR project.godot BEFORE LAUNCHING, not after.
#
# The editor rewrites project.godot on save and ConfigFile's writer cannot keep
# comments, so every run silently strips them. Repairing on the way IN means the
# damage never accumulates and never depends on this script exiting cleanly --
# a kill -9 would skip any cleanup trap. The guard restores only comment-and-
# reordering damage; a setting genuinely changed in the editor makes it refuse
# and print the diff, so an intentional edit is never thrown away.
if [ -x "$(dirname "$0")/../tools/project_godot_guard.sh" ]; then
  "$(dirname "$0")/../tools/project_godot_guard.sh" restore || true
fi

[ -x "$GODOT" ] || _die "engine missing at $GODOT (set HYPER_TPS_GODOT)"
[ -x "$BARE" ] || _die "bare missing (set HYPER_TPS_BARE)"

if [ "$want_daemon" = 1 ]; then
  ensure_daemon "${pin_rooms[@]}"
fi

# Only a daemon ensure_daemon started is stopped (scripts/daemon.sh).
trap stop_daemon_if_started EXIT INT TERM

if [ "$daemon_only" = 1 ]; then
  echo "dev-run: daemon only — Ctrl-C to stop"
  trap - EXIT            # a daemon asked for explicitly outlives this shell
  exit 0
fi

# TEE TO A FILE, because the terminal cannot be trusted here. NGX's logger prints
# RAW BINARY ("called from module ... at <bytes>"), and those control codes wreck
# the terminal: everything the game prints afterwards is still emitted but is not
# readable on screen. That cost a full diagnosis cycle — the log LOOKED truncated
# at NGX init, so I concluded the game had died when it was running fine.
#
# Filtering the stream would drop legitimate UTF-8 from our own logs, so the file
# stays raw and is read with grep.
# ONE LOG PER RUN. This was a single fixed path that every launch TRUNCATED, so
# the log of the session you actually played in was destroyed by the next
# launch -- and anyone reading it afterwards could not tell which run they were
# looking at. That cost several rounds of "the log is empty" against a log that
# belonged to a different session.
GAME_LOG="${HYPER_TPS_GAME_LOG:-/tmp/hyper-tps-game-$(date +%H%M%S).log}"
ln -sfn "$GAME_LOG" /tmp/hyper-tps-game-dev.log
echo "dev-run: launching $GODOT against $REPO"
# Read from ci.yaml rather than typed here, so it cannot go stale.
_ci_godot="$(grep -m1 -o 'GODOT=[^ ]*' "$REPO/.pear-git/ci.yaml" 2>/dev/null | cut -d= -f2)"
if [ -n "$_ci_godot" ] && [ "$_ci_godot" != "$GODOT" ]; then
  echo "dev-run: ⚠ CI exports with a DIFFERENT engine: $_ci_godot"
fi
echo "dev-run: full output -> $GAME_LOG  (also /tmp/hyper-tps-game-dev.log -> latest)"
echo "dev-run: terminal is FILTERED (NGX/driver chatter hidden) — HYPER_TPS_RAW=1 for everything"
# ELAPSED SECONDS ON EVERY LINE. Without them the log says what happened and
# never when, so "it takes about five seconds" cannot be attributed to NGX init,
# shader compilation, the 1183-brush import or the state load — and guessing
# between those has already cost this session twice.
#
# Binary-safe on purpose: NGX prints raw bytes, so this reads and writes
# sys.stdin.buffer rather than decoding text, and flushes per line so a crash
# still leaves an accurate tail.
# STAMP WHAT IS ACTUALLY RUNNING. A dev run has no version.txt, so the game
# reported a bare "dev" and no screenshot or log could say which code it was.
# This reaches the [HyperTPS] Build: line AND the Network HUD.
_head="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo nogit)"
git -C "$REPO" diff --quiet 2>/dev/null || _head="${_head}-dirty"
export HYPER_TPS_BUILD="dev-${_head}"
echo "dev-run: source $REPO @ ${HYPER_TPS_BUILD}" >&2

DISPLAY="${DISPLAY:-:0}" "$GODOT" --path "$REPO" --rendering-driver vulkan "${godot_args[@]}" 2>&1 \
  | python3 -u -c '
import sys, time
t0 = time.monotonic()
out = sys.stdout.buffer
for raw in sys.stdin.buffer:
    out.write(("[%8.3f] " % (time.monotonic() - t0)).encode())
    out.write(raw)
    out.flush()
' | tee "$GAME_LOG" | python3 -u -c '
# THE TERMINAL GETS A READABLE STREAM; THE FILE STAYS RAW.
#
# The file above is the record and must not lose a byte. What reaches the
# SHELL used to be that same stream, and it is unusable: NGX prints raw binary
# and thousands of CollectGarbage/tid: lines, so the game every line you
# actually wanted -- map loads, generator compiles, warnings -- was either
# buried or invisible because the control codes had wrecked the terminal.
#
# So this filter sits AFTER the tee. It drops the NGX chatter and strips
# control bytes from what is left. Nothing here can affect $GAME_LOG.
#
# HYPER_TPS_RAW=1 turns it off when you want the unfiltered stream.
import sys, os, re

if os.environ.get("HYPER_TPS_RAW") == "1":
    for raw in sys.stdin.buffer:
        sys.stdout.buffer.write(raw); sys.stdout.buffer.flush()
    raise SystemExit(0)

# Matched against the line WITHOUT its timestamp prefix.
NOISE = re.compile(rb"(NGX|CollectGarbage|tid:0x|\[tid:|nvngx|nvsdk_ngx"
                   rb"|Releasing resource|Delayed destroy|App logging hooks"
                   rb"|Logging to requested file|Reading configuration file"
                   rb"|Telemetry not supported|NGXLoad|NGXSafe|openLogFileInPath"
                   rb"|setLogFilename)")
# Printable, plus tab and newline. NGX emits raw bytes that move the cursor.
CTRL = re.compile(rb"[\x00-\x08\x0b-\x1f\x7f]")

dropped = 0
try:
    for raw in sys.stdin.buffer:
        line = CTRL.sub(b"", raw)
        # The timestamp prefix is ours; the decision is about what follows it.
        # Testing the whole line kept every `[   6.774] ` whose body NGX had
        # emitted as pure control bytes -- hundreds of blank stamps, which is
        # the same unreadable screen by another route.
        body = line.split(b"] ", 1)[-1].strip() if b"] " in line else line.strip()
        if not body:
            dropped += 1
            continue
        if NOISE.search(body):
            dropped += 1
            continue
        sys.stdout.buffer.write(line)
        sys.stdout.buffer.flush()
except BrokenPipeError:
    # The reader went away (a `| head`, or the terminal closing). Not an error,
    # and a traceback here would be the last thing on screen.
    raise SystemExit(0)
if dropped:
    sys.stderr.write("dev-run: %d NGX/driver/blank lines hidden (full text in the log; HYPER_TPS_RAW=1 to see them)\n" % dropped)
'