From a4199fe0be62bf5c74daee6ccfd5084588d2d589 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 16:48:24 -0400 Subject: [PATCH 1/6] feature: nade preview clip capture --- .env.example | 28 ++ src/flows/run-nades.sh | 185 +++++++++ src/flows/setup-steam.sh | 2 + src/game-streamer.sh | 7 + src/lib/batch-nades.sh | 214 ++++++++++ src/lib/clip-helpers.mjs | 52 +++ src/lib/nade-clip.sh | 598 ++++++++++++++++++++++++++++ src/lib/snapshot.sh | 12 +- src/lib/status-reporter.sh | 56 ++- src/spectator/routes/index.mjs | 6 + src/spectator/routes/nade-watch.mjs | 58 +++ src/spectator/state/gsi.mjs | 16 + src/spectator/state/nades.mjs | 134 +++++++ 13 files changed, 1352 insertions(+), 16 deletions(-) create mode 100755 src/flows/run-nades.sh create mode 100644 src/lib/batch-nades.sh create mode 100755 src/lib/nade-clip.sh create mode 100644 src/spectator/routes/nade-watch.mjs create mode 100644 src/spectator/state/nades.mjs diff --git a/.env.example b/.env.example index 57c400d..d86411e 100644 --- a/.env.example +++ b/.env.example @@ -109,3 +109,31 @@ export CONNECT_PASSWORD= # watchable via the "WATCH (HLS)" URL in the logs. # export DEBUG_STREAM=1 # export DEBUG_STREAM_ID=debug # publish path (default: MATCH_ID) + +# --- Nade lineup previews --------------------------------------------------- +# `game-streamer.sh nade-previews` connects to a nade practice server as a +# PLAYER (the practice plugin can only teleport a live pawn) and records one +# first-person clip per lineup in NADE_BATCH_JOBS. Required: NADE_BATCH_JOBS, +# CONNECT_ADDR, CONNECT_PASSWORD (the practice session password). +# +# The plugin's verbs are client commands, sw_ on SwiftlyS2 and css_ on +# CounterStrikeSharp — only SwiftlyS2 can re-emit a stored throw at all. +# export NADE_CMD_PREFIX=sw_ +# export NADE_CMD_LOAD='sw_load {name}' # {name} = the lineup's name +# export NADE_CMD_THROW=sw_rethrow +# export NADE_CMD_JOIN='jointeam 3' # empty string = never join a team +# +# The recorder stops on the observed detonation, never on a timer. It reads +# the GSI grenade feed (spec-server /nade/watch) and, if the plugin is taught +# to print a line on a ghost detonation, this regex against cs2's console.log: +# export NADE_DETONATE_LOG_RE='nade_practice: detonated' +# Last resort when neither signal exists — derives the cut from the lineup's +# recorded flight time and marks the clip unverified_timing: +# export NADE_ALLOW_TIMED_DETONATION=1 +# +# Window shaping (all derived from the grenade type + recorded flight time): +# export NADE_PREROLL_MS=1200 # alignment held before the throw +# export NADE_SMOKE_BLOOM_MS=2600 # smoke held to this GSI effecttime +# export NADE_INFERNO_HOLD_MS=3000 # molotov fire-spread tail +# export NADE_TAIL_MS=1200 # flash/HE/decoy tail after detonation +# export NADE_MAX_CLIP_MS=30000 # hard backstop diff --git a/src/flows/run-nades.sh b/src/flows/run-nades.sh new file mode 100755 index 0000000..1d2223f --- /dev/null +++ b/src/flows/run-nades.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# Launch CS2 against a nade practice server and render every lineup in +# NADE_BATCH_JOBS from that one session. +# Required env: NADE_BATCH_JOBS, CONNECT_ADDR (+ CONNECT_PASSWORD). + +set -uo pipefail +SCRIPT_TAG=run-nades + +# shellcheck disable=SC1091 +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../lib/common.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/xorg.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/shader-cache.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/audio.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/steam.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/cs2-perf.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/cs2-options.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/cs2-tune.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/hud-manager.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/status-reporter.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/snapshot.sh" + +load_env +require_env NADE_BATCH_JOBS + +start_status_reporter + +if [ -n "${NADE_CONNECT_ADDR:-}" ]; then + CS2_CONNECT_ADDR="$NADE_CONNECT_ADDR" + CS2_CONNECT_PASSWORD="${NADE_CONNECT_PASSWORD:-}" +elif [ -n "${CONNECT_ADDR:-}" ]; then + CS2_CONNECT_ADDR="$CONNECT_ADDR" + CS2_CONNECT_PASSWORD="${CONNECT_PASSWORD:-}" +else + die "no practice server to connect to — set CONNECT_ADDR (+CONNECT_PASSWORD)" +fi + +: "${NADE_OUT_DIR:=/tmp/game-streamer/nades}" +: "${NADE_OUTPUT_FPS:=60}" +# The capture samples cs2's swapchain, so cs2 must render ABOVE the capture +# rate for every sample to be a fresh frame — same 2x headroom the demo flow +# uses on the vkcapture path. +if vkcapture_available; then + : "${CS2_FPS_MAX:=$(( NADE_OUTPUT_FPS * 2 ))}" +else + : "${CS2_FPS_MAX:=$NADE_OUTPUT_FPS}" +fi +: "${CS2_WINDOW_TIMEOUT:=300}" +cs2_autotune + +steam_pipe_up || die "Steam isn't running" +xorg_running || die "Xorg isn't up" +restore_real_steamclient + +start_snapshot_loop || warn "start_snapshot_loop failed — continuing without thumbnails" + +pkill -9 -f '/linuxsteamrt64/cs2' 2>/dev/null || true +sleep 1 +rm -f /tmp/source_engine_*.lock +rm -f "$CS2_DIR/game/csgo/steam_appid.txt" \ + "$CS2_DIR/game/bin/linuxsteamrt64/steam_appid.txt" 2>/dev/null || true + +CS2_CFG_DIR="$CS2_DIR/game/csgo/cfg" +mkdir -p "$CS2_CFG_DIR" "$NADE_OUT_DIR" +write_cs2_video_cfg demo + +# The clip IS the alignment reference, so the crosshair and viewmodel stay on; +# only the chrome a viewer can't act on is trimmed. cl_draw_only_deathnotices +# keeps the crosshair while dropping the rest of the HUD. +read -r -d '' NADE_VIEW_CMDS <<'EOF' || true +snd_mute_losefocus 0 +engine_no_focus_sleep 0 +volume 1.0 +r_drawviewmodel 1 +cl_draw_only_deathnotices 1 +cl_showfps 0 +net_graph 0 +r_fullscreen_gamma 2 +EOF + +printf '// see nade_autoexec.cfg\n' > "$CS2_CFG_DIR/autoexec.cfg" +cat > "$CS2_CFG_DIR/nade_autoexec.cfg" < "$CS2_CFG_DIR/5stack_exec.cfg" + +write_gsi_cfg + +for base in libpangoft2-1.0 libpango-1.0; do + if [ ! -e "$CS2_DIR/game/bin/linuxsteamrt64/${base}.so" ] \ + && [ -e "$CS2_DIR/game/bin/linuxsteamrt64/${base}.so.0" ]; then + ln -sf "${base}.so.0" "$CS2_DIR/game/bin/linuxsteamrt64/${base}.so" || true + fi +done + +CS2_BIN="$CS2_DIR/game/bin/linuxsteamrt64/cs2" +[ -x "$CS2_BIN" ] || die "CS2 binary missing at $CS2_BIN" +cd "$(dirname "$CS2_BIN")" + +report_status status=launching_cs2 +export PULSE_SINK="${PULSE_SINK_NAME:-cs2}" +: "${PULSE_SERVER:=tcp:${PULSE_TCP_HOST:-127.0.0.1}:${PULSE_TCP_PORT:-4713}}" +export PULSE_SERVER + +do_applaunch() { + local thread_args=() + [ "${CS2_THREADS:-0}" != 0 ] && thread_args=(-threads "$CS2_THREADS") + # -condebug tees cs2's console to csgo/console.log, which is where the + # optional NADE_DETONATE_LOG_RE signal is read from. + local cs2_args=( + -windowed -noborder + -width "$CS2_WIDTH" -height "$CS2_HEIGHT" + -novid -nojoy -high -console -condebug + "${thread_args[@]}" + -disable_loadingplaque + +cl_disablehtmlmotd 1 + +fps_max "$CS2_FPS_MAX" + +exec nade_autoexec + +password "$CS2_CONNECT_PASSWORD" + +connect "$CS2_CONNECT_ADDR") + export_cs2_shader_cache_env + compute_cpu_split + local cs2_pin=(); mapfile -t cs2_pin < <(cs2_cpu_pin) + if [ "${#cs2_pin[@]}" -gt 0 ]; then + log "cs2 pinned to cores ${GS_CS2_CPUS} (off the capture cores ${GS_CAPTURE_CPUS}); nproc=$(nproc)" + fi + local cmd=("${cs2_pin[@]}" "$STEAM_HOME/ubuntu12_32/steam" -applaunch 730 "${cs2_args[@]}") + spawn_logged cs2-launch "${cmd[@]}" +} +do_applaunch +wait_for_cs2_process do_applaunch + +minimize_steam_windows +trim_steam_webhelper + +report_status status=connecting_to_game +WIN="" +for _ in $(seq 1 "$CS2_WINDOW_TIMEOUT"); do + WIN=$(xwininfo -display "$DISPLAY" -root -tree 2>/dev/null \ + | awk '/"Counter-Strike 2"/{print $1; exit}') + [ -n "$WIN" ] && break + if ! kill -0 "$CS2_PID" 2>/dev/null; then + tail -60 "$STEAM_LIBRARY/steam/logs/console-linux.txt" 2>/dev/null + die "cs2 EXITED early" + fi + sleep 1 +done +[ -n "$WIN" ] || { + tail -60 "$STEAM_LIBRARY/steam/logs/console-linux.txt" 2>/dev/null + die "no CS2 window after ${CS2_WINDOW_TIMEOUT}s" +} + +# Nothing reassigns X input focus after the Steam windows are destroyed, and +# every console command we send is an XTest keystroke — so cs2 must hold focus. +timeout 5 xdotool windowfocus --sync "$WIN" 2>/dev/null || true + +( + while kill -0 "$CS2_PID" 2>/dev/null; do sleep 5; done + warn "cs2 (pid=$CS2_PID) exited" + command -v report_status >/dev/null 2>&1 \ + && report_status status=errored "error=cs2 process exited unexpectedly" +) & + +stop_snapshot_loop +# shellcheck disable=SC1091 +. "$LIB_DIR/batch-nades.sh" +process_nade_jobs +exit 0 diff --git a/src/flows/setup-steam.sh b/src/flows/setup-steam.sh index 70ea042..4c1d0c7 100755 --- a/src/flows/setup-steam.sh +++ b/src/flows/setup-steam.sh @@ -58,6 +58,8 @@ fi HUD_DEFERRED=0 if [ "${CLIP_BATCH_MODE:-0}" = "1" ]; then log "CLIP_BATCH_MODE=1 — skipping hud-manager" +elif [ "${NADE_BATCH_MODE:-0}" = "1" ]; then + log "NADE_BATCH_MODE=1 — skipping hud-manager" elif [ -n "${BAKE_NODE_ID:-}" ]; then log "shader bake — skipping hud-manager" elif [ -x "$HUD_BIN" ]; then diff --git a/src/game-streamer.sh b/src/game-streamer.sh index 6a3a430..b0961de 100755 --- a/src/game-streamer.sh +++ b/src/game-streamer.sh @@ -21,6 +21,8 @@ usage: $(basename "$0") demo setup Steam + download \$DEMO_URL + play it back + capture batch-highlights demo flow with CLIP_BATCH_MODE=1 — renders \$CLIP_BATCH_JOBS sequentially against the same cs2 instance, then exits + nade-previews connect to a nade practice server and record \$NADE_BATCH_JOBS + (one preview clip per lineup) from that one session, then exits warm-shaders boot CS2, run the Vulkan shader precache to completion, then exit — pre-warms this node's cache (no match needed) EOF @@ -174,6 +176,11 @@ case "$cmd" in export CLIP_BATCH_MODE=1 run_demo_flow "$@" ;; + nade-previews) + export NADE_BATCH_MODE=1 + "$FLOWS_DIR/setup-steam.sh" "$@" || exit $? + exec "$FLOWS_DIR/run-nades.sh" "$@" + ;; warm-shaders) # Pre-warm this node's shader cache (no match). Run as a per-node Job. "$FLOWS_DIR/setup-steam.sh" "$@" || exit $? diff --git a/src/lib/batch-nades.sh b/src/lib/batch-nades.sh new file mode 100644 index 0000000..76a9fc6 --- /dev/null +++ b/src/lib/batch-nades.sh @@ -0,0 +1,214 @@ +# shellcheck shell=bash +# Drain NADE_BATCH_JOBS against one running cs2 connected to one nade practice +# server. Sourced by run-nades.sh. Per-job failures never halt the batch — +# nade-clip.sh posts its own terminal status. +# +# NADE_BATCH_JOBS is a JSON array; each entry is +# { "job_id": "", "token": "", "spec": { ... } } +# and spec carries the nade_lineups row the pod needs (column names verbatim): +# lineup_id, lineup_name, map_name, nade_type ("Smoke"|"Flash"| +# "HighExplosive"|"Molotov"|"Decoy"), side, origin_x/y/z, eye_z, view_yaw, +# view_pitch, flight_time_ms, confidence, plugin_runtime, and either +# has_seed:true|false or the six initial_pos_*/initial_vel_* values, +# plus output: { resolution: "720p"|"1080p", fps: }. +# +# lineup_name is load-bearing: the practice plugin resolves `.load ` by +# name, it has no id lookup. + +CLIP_HELPERS="$LIB_DIR/clip-helpers.mjs" + +: "${NADE_SESSION_READY_TIMEOUT:=300}" +: "${NADE_BATCH_MAX_TAILS:=2}" +: "${NADE_BATCH_TAIL_OVERLAP:=1}" + +nade_post_job_status() { + local job_id="$1" token="$2"; shift 2 + local body + body=$(node "$CLIP_HELPERS" status-body "$@" 2>/dev/null) || return 0 + curl --fail --silent --show-error --max-time 10 \ + --header "x-origin-auth: ${job_id}:${token}" \ + --header "content-type: application/json" \ + --data "$body" \ + --output /dev/null \ + "${STATUS_API_BASE}/nade-renders/${job_id}/status" \ + || say " WARN status post failed for $job_id" +} + +nade_fail_job() { + nade_post_job_status "$1" "$2" "status=error" "error=$3" +} + +nade_skip_job() { + nade_post_job_status "$1" "$2" "status=${NADE_SKIP_STATUS:-skipped}" \ + "skip_reason=$3" "error=$3" +} + +nade_render_one_job() { + local job_json="$1" + + local -a F=() + readarray -d '' -t F < <(printf '%s' "$job_json" | node "$CLIP_HELPERS" nade-fields) + local job_id="${F[0]:-}" token="${F[1]:-}" lineup_id="${F[2]:-}" \ + lineup_name="${F[3]:-}" map_name="${F[4]:-}" nade_type="${F[5]:-}" \ + side="${F[6]:-}" origin="${F[7]:-}" eye_z="${F[8]:-}" \ + view_yaw="${F[9]:-}" view_pitch="${F[10]:-}" flight_ms="${F[11]:-0}" \ + has_seed="${F[12]:-0}" confidence="${F[13]:-}" runtime="${F[14]:-}" \ + output_dims="${F[15]:-}" output_fps="${F[16]:-}" + + if [ -z "$job_id" ] || [ -z "$token" ]; then + say " skipping malformed nade job blob" + return 0 + fi + if [ -z "$lineup_name" ]; then + say " $job_id: lineup has no name — the plugin cannot load it" + nade_skip_job "$job_id" "$token" "lineup has no name; the practice plugin resolves lineups by name only" + return 0 + fi + # One server session = one map. A lineup for another map can't be filmed + # here, and re-loading maps mid-batch would defeat the point of the session. + if [ -n "$map_name" ] && [ -n "$NADE_SESSION_MAP" ] && [ "$map_name" != "$NADE_SESSION_MAP" ]; then + say " $job_id: lineup is on ${map_name}, this session is on ${NADE_SESSION_MAP} — skipping" + nade_skip_job "$job_id" "$token" "practice server is on ${NADE_SESSION_MAP}, lineup needs ${map_name}" + return 0 + fi + if [ -f "$CS2_FATAL_SENTINEL" ]; then + local reason; reason=$(head -1 "$CS2_FATAL_SENTINEL" 2>/dev/null) + say " $job_id: cs2 session dead from an earlier fatal — skipping (${reason:-unknown})" + nade_fail_job "$job_id" "$token" "cs2 fatal earlier in batch: ${reason:-unknown}" + return 0 + fi + + say "nade render: $job_id (${lineup_name})" + + local marker="${NADE_OUT_DIR:-/tmp/game-streamer/nades}/${job_id}.cs2done" + rm -f "$marker" + ( + export NADE_RENDER_JOB_ID="$job_id" + export NADE_RENDER_TOKEN="$token" + export NADE_LINEUP_ID="${lineup_id:-$job_id}" + export NADE_LINEUP_NAME="$lineup_name" + export NADE_MAP_NAME="$map_name" + export NADE_NADE_TYPE="$nade_type" + export NADE_SIDE="$side" + export NADE_ORIGIN="$origin" + export NADE_EYE_Z="$eye_z" + export NADE_VIEW_YAW="$view_yaw" + export NADE_VIEW_PITCH="$view_pitch" + export NADE_FLIGHT_TIME_MS="$flight_ms" + export NADE_HAS_SEED="$has_seed" + export NADE_CONFIDENCE="$confidence" + export NADE_PLUGIN_RUNTIME="${runtime:-${NADE_PLUGIN_RUNTIME:-swiftlys2}}" + export NADE_OUTPUT_DIMS="$output_dims" + export NADE_OUTPUT_FPS="$output_fps" + export NADE_CS2_RELEASE_MARKER="$marker" + export SPEC_SERVER_URL="${SPEC_SERVER_URL:-http://127.0.0.1:1350}" + bash "$LIB_DIR/nade-clip.sh" + ) & + local pid=$! + + if [ "$NADE_BATCH_TAIL_OVERLAP" != "1" ]; then + wait "$pid" || say " job $job_id failed (others in batch unaffected)" + rm -f "$marker" + return 0 + fi + + # cs2 is only needed up to the encode; the render touches the marker right + # after, so the next lineup can be loaded while this one uploads. + while kill -0 "$pid" 2>/dev/null && [ ! -f "$marker" ]; do + sleep 0.5 + done + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" || say " job $job_id failed (others in batch unaffected)" + rm -f "$marker" + return 0 + fi + say " job $job_id: cs2 released — upload tail continues in background" + TAIL_PIDS+=("$pid") + TAIL_JOBS+=("$job_id") + TAIL_MARKERS+=("$marker") +} + +reap_oldest_nade_tail() { + [ "${#TAIL_PIDS[@]}" -eq 0 ] && return 0 + local pid="${TAIL_PIDS[0]}" job="${TAIL_JOBS[0]}" marker="${TAIL_MARKERS[0]}" + wait "$pid" || say " job $job failed (others in batch unaffected)" + rm -f "$marker" + TAIL_PIDS=("${TAIL_PIDS[@]:1}") + TAIL_JOBS=("${TAIL_JOBS[@]:1}") + TAIL_MARKERS=("${TAIL_MARKERS[@]:1}") +} + +# Connected, in-game and alive is the only state in which `.load` does +# anything, so the batch waits for GSI to say so before the first lineup. +# die() fans the failure out to every job, so a server that never comes up is +# reported per-lineup instead of leaving rows stuck in-flight. +wait_for_nade_session() { + local waited=0 line age health map_name + NADE_SESSION_MAP="" + say "waiting for the practice server (GSI + spawned player)" + while :; do + line=$(curl --fail --silent --max-time 5 "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/nade/self" || true) + if [ -n "$line" ]; then + IFS='|' read -r age _sid _team health _rest <<<"$line" + case "$age" in + ''|-1|*[!0-9]*) ;; + *) + if [ "$age" -le "${NADE_GSI_MAX_AGE_MS:-2000}" ] \ + && [ "${health:-0}" -gt 0 ] 2>/dev/null; then + map_name=$(curl --fail --silent --max-time 5 \ + "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/demo/state" \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s)?.gsi?.map_name??"")}catch{}})' \ + || true) + NADE_SESSION_MAP="$map_name" + say "practice server ready after ${waited}s (map=${NADE_SESSION_MAP:-?})" + return 0 + fi + ;; + esac + fi + if [ "$waited" -ge "$NADE_SESSION_READY_TIMEOUT" ]; then + die "never spawned on the practice server within ${NADE_SESSION_READY_TIMEOUT}s (wrong password, server down, or the client is stuck in team select)" + fi + waited=$((waited + 1)) + [ $((waited % 15)) -eq 0 ] && say " still waiting (${waited}s)" + sleep 1 + done +} + +process_nade_jobs() { + if [ -z "${NADE_BATCH_JOBS:-}" ]; then + say "no NADE_BATCH_JOBS — nothing to render" + return 0 + fi + + rm -f "$CS2_FATAL_SENTINEL" + mkdir -p "${NADE_OUT_DIR:-/tmp/game-streamer/nades}" + + local count + count=$(printf '%s' "$NADE_BATCH_JOBS" | node "$CLIP_HELPERS" jobs-count) + say "batch-nades: ${count} lineup(s) queued" + + wait_for_nade_session + + local -a TAIL_PIDS=() TAIL_JOBS=() TAIL_MARKERS=() + local idx job_json + for idx in $(seq 0 $((count - 1))); do + if ! job_json=$(printf '%s' "$NADE_BATCH_JOBS" \ + | node "$CLIP_HELPERS" jobs-at "$idx"); then + say " WARN failed to extract nade job at index $idx" + continue + fi + while [ "${#TAIL_PIDS[@]}" -ge "$NADE_BATCH_MAX_TAILS" ]; do + say " ${#TAIL_PIDS[@]} upload tail(s) pending — reaping oldest before the next lineup" + reap_oldest_nade_tail + done + nade_render_one_job "$job_json" + done + + while [ "${#TAIL_PIDS[@]}" -gt 0 ]; do + say "waiting on ${#TAIL_PIDS[@]} upload tail(s)" + reap_oldest_nade_tail + done + + say "batch-nades: drained ${count} lineup(s) — exiting" +} diff --git a/src/lib/clip-helpers.mjs b/src/lib/clip-helpers.mjs index b4a3e01..dfea02d 100644 --- a/src/lib/clip-helpers.mjs +++ b/src/lib/clip-helpers.mjs @@ -275,6 +275,58 @@ switch (subcmd) { break; } + // [stdin: nade job_json] -> every field batch-nades.sh exports, each + // NUL-TERMINATED (same shape as job-fields above). The spec keys mirror the + // nade_lineups column names so the api can splat a row into it. Order: + // job_id token lineup_id lineup_name map_name nade_type side + // origin(x,y,z) eye_z view_yaw view_pitch flight_time_ms has_seed + // confidence plugin_runtime output_dims output_fps + // has_seed follows the plugin's own rule (all six initial_* present AND a + // non-zero velocity) unless the api states it outright — a lineup without it + // cannot be re-emitted exactly and gets skipped rather than approximated. + case "nade-fields": { + const d = readStdinJson(); + const s = d?.spec ?? {}; + const S = (v) => (typeof v === "string" ? v.replaceAll("\u0000", "") : ""); + const N = (v) => (typeof v === "number" && Number.isFinite(v) ? String(v) : ""); + const seedKeys = [ + "initial_pos_x", "initial_pos_y", "initial_pos_z", + "initial_vel_x", "initial_vel_y", "initial_vel_z", + ]; + let hasSeed = s.has_seed === true; + if (!hasSeed && s.has_seed !== false) { + const vals = seedKeys.map((k) => s[k]); + const complete = vals.every((v) => typeof v === "number" && Number.isFinite(v)); + const speed = complete + ? Math.hypot(s.initial_vel_x, s.initial_vel_y, s.initial_vel_z) + : 0; + hasSeed = complete && speed > 0; + } + const origin = ["origin_x", "origin_y", "origin_z"].map((k) => N(s[k])); + const fps = parseInt(s?.output?.fps, 10); + const flight = Number(s.flight_time_ms); + process.stdout.write([ + S(d?.job_id), + S(d?.token), + S(s.lineup_id), + S(s.lineup_name ?? s.name), + S(s.map_name), + S(s.nade_type), + S(s.side), + origin.every(Boolean) ? origin.join(",") : "", + N(s.eye_z), + N(s.view_yaw), + N(s.view_pitch), + Number.isFinite(flight) && flight > 0 ? String(Math.round(flight)) : "0", + hasSeed ? "1" : "0", + S(s.confidence), + S(s.plugin_runtime), + s?.output?.resolution === "720p" ? "1280x720" : "1920x1080", + String(Number.isFinite(fps) ? fps : 60), + ].map((f) => f + "\u0000").join("")); + break; + } + // [stdin: job_json] -> top-level job_id. case "job-id": { const d = readStdinJson(); diff --git a/src/lib/nade-clip.sh b/src/lib/nade-clip.sh new file mode 100755 index 0000000..b37d26a --- /dev/null +++ b/src/lib/nade-clip.sh @@ -0,0 +1,598 @@ +#!/usr/bin/env bash +# Record ONE nade lineup's throw off a live practice server and upload it. +# +# Unlike inline-clip-render.sh there is no demo to seek: the throw only exists +# as data, so the practice plugin has to reproduce it live. That makes every +# wait here a wait on an observed event (camera arrival, grenade spawn, +# detonation, bloom), never on a sleep that hopes the game kept up. +# +# The plugin can only teleport a LIVE PLAYER pawn (an observer has no pawn to +# move), so this pod joins as a player and films its own first-person view — +# which is also the framing a viewer needs in order to copy the alignment. +# +# Required env: NADE_RENDER_JOB_ID NADE_RENDER_TOKEN STATUS_API_BASE +# SPEC_SERVER_URL NADE_LINEUP_ID NADE_LINEUP_NAME NADE_NADE_TYPE +# Full job contract: see the header of batch-nades.sh. + +set -uo pipefail +SCRIPT_TAG=nade-clip + +# shellcheck disable=SC1091 +. "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/common.sh" +# shellcheck disable=SC1091 +. "$LIB_DIR/clip-capture.sh" + +require_env NADE_RENDER_JOB_ID NADE_RENDER_TOKEN STATUS_API_BASE \ + SPEC_SERVER_URL NADE_LINEUP_ID NADE_LINEUP_NAME NADE_NADE_TYPE + +CLIP_HELPERS="$LIB_DIR/clip-helpers.mjs" +CS2_CONSOLE_LOG="${CS2_CONSOLE_LOG:-$CS2_DIR/game/csgo/console.log}" + +LOG_PREFIX="[nade ${NADE_RENDER_JOB_ID:0:8}]" +say() { printf '%s %s\n' "$LOG_PREFIX" "$*" >&2; } + +# Lineup spec (all supplied per job by batch-nades.sh). +: "${NADE_MAP_NAME:=}" +: "${NADE_SIDE:=}" +: "${NADE_ORIGIN:=}" +: "${NADE_EYE_Z:=}" +: "${NADE_VIEW_YAW:=}" +: "${NADE_VIEW_PITCH:=}" +: "${NADE_FLIGHT_TIME_MS:=0}" +: "${NADE_HAS_SEED:=0}" +: "${NADE_CONFIDENCE:=}" +: "${NADE_PLUGIN_RUNTIME:=swiftlys2}" + +# Plugin verbs. Swiftly registers them as sw_, CounterStrikeSharp as +# css_; both also answer to the chat form (`say .load ...`) if a future +# build stops forwarding the console name. {name} expands to the lineup name, +# which is what `.load` matches on — the plugin has no id lookup. +# Escaped closing brace: an unescaped `{name}` inside `${VAR:=...}` ends the +# expansion early and the default silently truncates to "sw_load {name". +: "${NADE_CMD_PREFIX:=sw_}" +: "${NADE_CMD_LOAD:=${NADE_CMD_PREFIX}load {name\}}" +: "${NADE_CMD_THROW:=${NADE_CMD_PREFIX}rethrow}" +# Only the plugin's connect gate is automatic — nothing puts this client on a +# team, and a client in team-select has no pawn to teleport. `=` not `:=` so +# the api can switch the join off with an explicitly empty value. +case "$(printf '%s' "${NADE_SIDE:-}" | tr '[:upper:]' '[:lower:]')" in + t|terrorist) NADE_JOIN_TEAM=2 ;; + *) NADE_JOIN_TEAM=3 ;; +esac +: "${NADE_CMD_JOIN=jointeam ${NADE_JOIN_TEAM}}" + +: "${NADE_OUT_DIR:=/tmp/game-streamer/nades}" +: "${NADE_OUTPUT_DIMS:=1920x1080}" +: "${NADE_OUTPUT_FPS:=60}" +: "${NADE_VIDEO_KBPS:=24000}" +: "${NADE_CLIP_AUDIO:=1}" +: "${NADE_SKIP_STATUS:=skipped}" + +# Camera arrival tolerances. The teleport is exact (the plugin re-applies the +# angles for two frames after it), so anything outside these means the teleport +# did not happen — not that it was imprecise. +: "${NADE_POS_TOLERANCE:=8}" +: "${NADE_ANGLE_TOLERANCE_DEG:=3}" +: "${NADE_CAMERA_CONFIRM_MS:=15000}" +: "${NADE_LOAD_RETRY_MS:=3000}" + +: "${NADE_PREROLL_MS:=1200}" +: "${NADE_THROW_CONFIRM_MS:=4000}" +# Detonation deadline scales off the lineup's RECORDED flight time: a smoke +# that really takes 4s must not be cut at a generic 3s, and a pop-flash must +# not hold the server for 20s waiting on something that already happened. +: "${NADE_DETONATE_FACTOR:=2}" +: "${NADE_DETONATE_SLACK_MS:=2000}" +: "${NADE_DETONATE_MIN_MS:=3000}" +: "${NADE_SMOKE_BLOOM_MS:=2600}" +: "${NADE_INFERNO_HOLD_MS:=3000}" +: "${NADE_TAIL_MS:=1200}" +: "${NADE_MAX_CLIP_MS:=30000}" +: "${NADE_POLL_MS:=100}" +: "${NADE_GSI_MAX_AGE_MS:=2000}" + +# Console.log line that proves the grenade went off. The practice plugin prints +# nothing for a re-emitted (ghost) throw today, so this is empty by default and +# the GSI grenade feed is the only automatic signal — see the availability +# check below, which refuses to guess. +: "${NADE_DETONATE_LOG_RE:=}" +: "${NADE_ALLOW_TIMED_DETONATION:=0}" + +CLIP_OUTPUT_DIMS="$NADE_OUTPUT_DIMS" +CLIP_OUTPUT_FPS="$NADE_OUTPUT_FPS" +CLIP_OUT_DIR="$NADE_OUT_DIR" +export CLIP_OUTPUT_DIMS CLIP_OUTPUT_FPS CLIP_OUT_DIR + +NADE_CLIP_FILE="$NADE_OUT_DIR/${NADE_RENDER_JOB_ID}.mp4" +NADE_THUMB_FILE="$NADE_OUT_DIR/${NADE_RENDER_JOB_ID}.jpg" +NADE_REACHED_TERMINAL=0 + +if [ -n "${EPOCHREALTIME:-}" ]; then + now_ms() { local t="${EPOCHREALTIME//[!0-9]/}"; printf -v "$1" '%s' "${t:0:${#t}-3}"; } +else + now_ms() { printf -v "$1" '%s' "$(date +%s%3N)"; } +fi + +poll_sleep() { sleep "$(awk -v ms="$NADE_POLL_MS" 'BEGIN{printf "%.3f", ms/1000}')"; } + +json_body() { node "$CLIP_HELPERS" status-body "$@"; } + +api_status() { + local body + body=$(json_body "$@") || return 0 + curl --fail --silent --show-error --max-time 10 \ + --header "x-origin-auth: ${NADE_RENDER_JOB_ID}:${NADE_RENDER_TOKEN}" \ + --header "content-type: application/json" \ + --data "$body" \ + --output /dev/null \ + "${STATUS_API_BASE}/nade-renders/${NADE_RENDER_JOB_ID}/status" \ + || say "WARN status post failed: $*" +} + +die_failed() { + say "ERROR: $1" + stop_clip_capture + api_status "status=error" "error=$1" + NADE_REACHED_TERMINAL=1 + exit 1 +} + +# A lineup we cannot reproduce EXACTLY is reported, never approximated: a +# preview of a different throw than the one the lineup describes is worse than +# no preview at all. exit 0 so the rest of the batch still drains. +die_skipped() { + say "SKIP: $1" + stop_clip_capture + api_status "status=${NADE_SKIP_STATUS}" "skip_reason=$1" "error=$1" + NADE_REACHED_TERMINAL=1 + exit 0 +} + +spec_post() { + local path="$1" body="${2:-{\}}" http_code + http_code=$(printf '%s' "$body" \ + | curl --silent --show-error --max-time 5 \ + --header "content-type: application/json" \ + --data-binary @- \ + --write-out "%{http_code}" \ + --output /dev/null \ + "${SPEC_SERVER_URL}${path}" \ + || echo "000") + case "$http_code" in + 200|204) return 0 ;; + *) say "WARN spec POST $path -> $http_code (body=$body)"; return 1 ;; + esac +} + +# Console commands reach cs2 through spec-server's exec-cfg path (a cfg file +# swap + one keypress), the same channel the clip renderer drives the demo +# with. The plugin's verbs are client commands, so cs2 forwards them upstream. +cs2_exec() { + local cmd="$1" body + body=$(json_body "cmd=$cmd") || return 1 + say " exec: $cmd" + spec_post /demo/exec "$body" +} + +cs2_exec_template() { + local tmpl="$1" + [ -z "$tmpl" ] && return 0 + tmpl="${tmpl//\{name\}/\"$NADE_LINEUP_NAME\"}" + tmpl="${tmpl//\{lineup\}/$NADE_LINEUP_ID}" + cs2_exec "$tmpl" +} + +console_log_size() { + stat -c '%s' "$CS2_CONSOLE_LOG" 2>/dev/null \ + || stat -f '%z' "$CS2_CONSOLE_LOG" 2>/dev/null \ + || echo 0 +} + +# True when $2 matched console.log AFTER byte offset $1. The offset is taken +# before the command that should produce the line, so a line left over from an +# earlier lineup can never satisfy the wait. +console_log_match() { + local since="$1" re="$2" + [ -z "$re" ] && return 1 + [ -f "$CS2_CONSOLE_LOG" ] || return 1 + tail -c "+$((since + 1))" "$CS2_CONSOLE_LOG" 2>/dev/null \ + | grep -aqE "$re" +} + +# GSI names for the 5stack e_utility_types values. +nade_gsi_type() { + case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in + smoke|smokegrenade) printf 'smoke' ;; + flash|flashbang) printf 'flashbang' ;; + highexplosive|he|hegrenade|frag) printf 'frag' ;; + molotov|incendiary|firebomb) printf 'firebomb' ;; + decoy) printf 'decoy' ;; + *) printf '' ;; + esac +} + +read_watch() { + local line + line=$(curl --fail --silent --max-time 5 "${SPEC_SERVER_URL}/nade/watch" || true) + [ -z "$line" ] && return 1 + IFS='|' read -r _W_ARMED _W_AGE _W_SINCE W_THROWN W_DETONATED W_BLOOM _W_ACTIVE W_TYPE W_BLOCKS \ + <<<"$line" + case "${W_BLOOM:-}" in ''|*[!0-9]*) W_BLOOM=0 ;; esac + return 0 +} + +read_self() { + local line + line=$(curl --fail --silent --max-time 5 "${SPEC_SERVER_URL}/nade/self" || true) + [ -z "$line" ] && return 1 + IFS='|' read -r S_AGE _S_STEAMID _S_TEAM S_HEALTH _S_ACTIVITY S_X S_Y S_Z S_FX S_FY S_FZ \ + <<<"$line" + return 0 +} + +on_exit() { + local rc=$? + stop_clip_capture + spec_post /nade/watch '{"armed": false}' >/dev/null 2>&1 || true + rm -f "$NADE_THUMB_FILE" + if [ "$rc" -ne 0 ] && [ "$NADE_REACHED_TERMINAL" != "1" ]; then + api_status "status=error" \ + "error=nade render exited rc=${rc} before reaching terminal status" || true + fi +} +trap 'on_exit' EXIT + +# --------------------------------------------------------------------------- + +PRE_STATUS=$(curl --fail --silent --show-error --max-time 5 \ + --header "x-origin-auth: ${NADE_RENDER_JOB_ID}:${NADE_RENDER_TOKEN}" \ + "${STATUS_API_BASE}/nade-renders/${NADE_RENDER_JOB_ID}/status" \ + | node "$CLIP_HELPERS" status-field) +if [ "$PRE_STATUS" = "cancelled" ]; then + say "job already cancelled — skipping (no work, no error)" + NADE_REACHED_TERMINAL=1 + exit 0 +fi + +GSI_TYPE=$(nade_gsi_type "$NADE_NADE_TYPE") +say "============================================================" +say "lineup=${NADE_LINEUP_ID} '${NADE_LINEUP_NAME}'" +say "type=${NADE_NADE_TYPE}(${GSI_TYPE:-?}) map=${NADE_MAP_NAME:-?} side=${NADE_SIDE:-?}" +say "seed=${NADE_HAS_SEED} confidence=${NADE_CONFIDENCE:-?} flight=${NADE_FLIGHT_TIME_MS}ms runtime=${NADE_PLUGIN_RUNTIME}" +say "============================================================" + +# --- Everything that makes this lineup unrenderable, checked before we film --- + +[ -n "$GSI_TYPE" ] || die_skipped "unknown grenade type '${NADE_NADE_TYPE}'" + +# The recorded seed (initial position + velocity) is what makes the re-emitted +# grenade land where the lineup says it does. Without it, and without the +# plugin's own 'exact' confidence, the throw can only be approximated. +case "$NADE_HAS_SEED" in + 1|true|yes) : ;; + *) die_skipped "lineup has no recorded physics seed (initial position/velocity) — the throw cannot be reproduced exactly" ;; +esac +case "$(printf '%s' "$NADE_CONFIDENCE" | tr '[:upper:]' '[:lower:]')" in + exact) : ;; + *) die_skipped "lineup confidence is '${NADE_CONFIDENCE:-unset}', not 'exact' — the plugin refuses to replay it" ;; +esac +# Re-emitting from a seed is a SwiftlyS2-only capability; the CSS build of the +# plugin has no replay path at all and would film an empty room. +case "$(printf '%s' "$NADE_PLUGIN_RUNTIME" | tr '[:upper:]' '[:lower:]')" in + swiftlys2|swiftly) : ;; + *) die_skipped "practice server runs ${NADE_PLUGIN_RUNTIME}, which cannot re-emit a stored throw (SwiftlyS2 only)" ;; +esac +if [ -z "$NADE_ORIGIN" ] || [ -z "$NADE_VIEW_YAW" ] || [ -z "$NADE_VIEW_PITCH" ]; then + die_skipped "lineup is missing its origin/view angles — the camera cannot be verified" +fi +if [ -f "$CS2_FATAL_SENTINEL" ]; then + die_failed "cs2 session already dead: $(head -1 "$CS2_FATAL_SENTINEL" 2>/dev/null)" +fi + +api_status "status=rendering" "progress=0.02" + +mkdir -p "$NADE_OUT_DIR" +rm -f "$NADE_CLIP_FILE" "$NADE_THUMB_FILE" + +# --- STEP 1: stand where the throw was recorded ------------------------------ + +say "STEP 1: load lineup + confirm camera" +# Re-joining a team we're already alive on would respawn us mid-batch, so the +# join only fires while there is no live pawn. +join_if_not_spawned() { + [ -n "$NADE_CMD_JOIN" ] || return 0 + read_self || return 0 + case "${S_HEALTH:-0}" in + ''|0|*[!0-9]*) cs2_exec "$NADE_CMD_JOIN" ;; + esac +} +join_if_not_spawned +cs2_exec_template "$NADE_CMD_LOAD" + +# Position AND angle both have to match: the alignment is the product, so a +# clip shot from the right spot facing the wrong way is a wrong clip. +CAMERA_FAIL="no GSI player state yet" +camera_confirmed() { + read_self || { CAMERA_FAIL="spec-server /nade/self unreachable"; return 1; } + case "${S_AGE:-}" in ''|-1|*[!0-9]*) CAMERA_FAIL="GSI has not fired yet"; return 1 ;; esac + if [ "$S_AGE" -gt "$NADE_GSI_MAX_AGE_MS" ]; then + CAMERA_FAIL="GSI is stale (${S_AGE}ms)" + return 1 + fi + case "${S_HEALTH:-0}" in ''|*[!0-9]*) CAMERA_FAIL="no player state"; return 1 ;; esac + if [ "$S_HEALTH" -le 0 ]; then + CAMERA_FAIL="not spawned/alive on the server" + return 1 + fi + if [ -z "${S_X:-}" ] || [ -z "${S_FX:-}" ]; then + CAMERA_FAIL="GSI reported no position/forward" + return 1 + fi + local rc=0 + awk -v x="$S_X" -v y="$S_Y" -v z="$S_Z" \ + -v fx="$S_FX" -v fy="$S_FY" -v fz="$S_FZ" \ + -v origin="$NADE_ORIGIN" -v eyez="${NADE_EYE_Z:-}" \ + -v yaw="$NADE_VIEW_YAW" -v pitch="$NADE_VIEW_PITCH" \ + -v tol="$NADE_POS_TOLERANCE" -v angtol="$NADE_ANGLE_TOLERANCE_DEG" ' + function abs(v) { return v < 0 ? -v : v } + BEGIN { + if (split(origin, o, /[ ,]+/) < 3) exit 1 + if (abs(x - o[1]) > tol || abs(y - o[2]) > tol) exit 2 + # GSI reports the player origin; accept an eye-height reading too rather + # than depending on which one this cs2 build sends. + if (abs(z - o[3]) > tol && (eyez == "" || abs(z - eyez) > tol)) exit 2 + rad = 3.14159265358979 / 180 + wx = cos(pitch * rad) * cos(yaw * rad) + wy = cos(pitch * rad) * sin(yaw * rad) + wz = -sin(pitch * rad) + dot = fx * wx + fy * wy + fz * wz + if (dot > 1) dot = 1 + if (dot < -1) dot = -1 + if (atan2(sqrt(1 - dot * dot), dot) / rad > angtol) exit 3 + exit 0 + }' || rc=$? + case "$rc" in + 0) return 0 ;; + 2) CAMERA_FAIL="standing at ${S_X},${S_Y},${S_Z}, lineup wants ${NADE_ORIGIN}" ;; + 3) CAMERA_FAIL="looking along ${S_FX},${S_FY},${S_FZ}, lineup wants yaw=${NADE_VIEW_YAW} pitch=${NADE_VIEW_PITCH}" ;; + *) CAMERA_FAIL="lineup origin '${NADE_ORIGIN}' is malformed" ;; + esac + return 1 +} + +CAMERA_OK=0 +WAITED=0 +while [ "$WAITED" -lt "$NADE_CAMERA_CONFIRM_MS" ]; do + if camera_confirmed; then + CAMERA_OK=1 + say "STEP 1: camera confirmed after ${WAITED}ms at ${S_X},${S_Y},${S_Z}" + break + fi + poll_sleep + WAITED=$((WAITED + NADE_POLL_MS)) + # `.load` is a no-op while we're still connecting / dead / in freezetime + # limbo, so re-issue it periodically instead of waiting out the timeout. + if [ $((WAITED % NADE_LOAD_RETRY_MS)) -lt "$NADE_POLL_MS" ]; then + join_if_not_spawned + cs2_exec_template "$NADE_CMD_LOAD" + fi +done +if [ "$CAMERA_OK" != "1" ]; then + die_skipped "camera never reached the lineup within ${NADE_CAMERA_CONFIRM_MS}ms: ${CAMERA_FAIL} — wrong map, not spawned, or the plugin has no lineup named '${NADE_LINEUP_NAME}'" +fi +api_status "status=rendering" "progress=0.15" + +# --- STEP 2: arm the grenade watch BEFORE capture ---------------------------- +# Anything already in the air (a previous lineup's smoke) must not be mistaken +# for this lineup's throw, so the watch snapshots the world before the throw. + +ARM_BODY=$(json_body "type=${GSI_TYPE}") +spec_post /nade/watch "$ARM_BODY" \ + || say "WARN /nade/watch arm failed — only the console-log signal is left" +GSI_GRENADES=0 +if read_watch && [ "${W_BLOCKS:-0}" != "0" ]; then + GSI_GRENADES=1 +fi +say "STEP 2: watch armed (gsi grenade feed seen=${GSI_GRENADES}, log_re=${NADE_DETONATE_LOG_RE:+set})" + +# --- STEP 3: capture opens on the alignment ---------------------------------- +# The still frame of where to stand and what to line up on IS the product, so +# the clip starts before the throw rather than on it. + +say "STEP 3: start capture -> $NADE_CLIP_FILE" +if ! start_clip_capture "$NADE_CLIP_FILE" "$NADE_OUTPUT_FPS" "$NADE_VIDEO_KBPS" "$NADE_CLIP_AUDIO"; then + die_failed "capture failed to start" +fi +wait_clip_capture_ready || true +clip_capture_go +now_ms CAPTURE_START_MS +sleep "$(awk -v ms="$NADE_PREROLL_MS" 'BEGIN{printf "%.3f", ms/1000}')" +api_status "status=rendering" "progress=0.3" + +# --- STEP 4: throw, then wait for the detonation to actually happen ---------- + +THROW_LOG_OFFSET=$(console_log_size) +cs2_exec_template "$NADE_CMD_THROW" +now_ms THROW_MS + +DETONATE_DEADLINE_MS=$(awk -v f="$NADE_FLIGHT_TIME_MS" -v k="$NADE_DETONATE_FACTOR" \ + -v slack="$NADE_DETONATE_SLACK_MS" -v floor="$NADE_DETONATE_MIN_MS" 'BEGIN{ + d = f * k + slack + if (d < floor) d = floor + printf "%d", d + }') +say "STEP 4: thrown — detonation deadline ${DETONATE_DEADLINE_MS}ms (recorded flight ${NADE_FLIGHT_TIME_MS}ms)" + +THROWN=0 +DETONATED=0 +BLOOM_MS=0 +SEEN_TYPE="" +TIMED_FALLBACK=0 +DETONATED_AT_MS=0 +while :; do + now_ms NOW + ELAPSED=$((NOW - THROW_MS)) + if read_watch; then + [ "${W_BLOCKS:-0}" != "0" ] && GSI_GRENADES=1 + [ "${W_THROWN:-0}" = "1" ] && THROWN=1 + if [ "${W_DETONATED:-0}" = "1" ]; then + DETONATED=1 + BLOOM_MS="$W_BLOOM" + SEEN_TYPE="$W_TYPE" + fi + fi + if [ "$DETONATED" != "1" ] && [ -n "$NADE_DETONATE_LOG_RE" ] \ + && console_log_match "$THROW_LOG_OFFSET" "$NADE_DETONATE_LOG_RE"; then + DETONATED=1 + THROWN=1 + fi + if [ "$DETONATED" = "1" ]; then + now_ms DETONATED_AT_MS + say "STEP 4: detonation observed after ${ELAPSED}ms (type=${SEEN_TYPE:-$GSI_TYPE})" + break + fi + # No grenade entity a full throw-confirm window after the command, on a + # server whose grenades we CAN see: the plugin never emitted one. + if [ "$THROWN" != "1" ] && [ "$GSI_GRENADES" = "1" ] \ + && [ "$ELAPSED" -ge "$NADE_THROW_CONFIRM_MS" ]; then + die_failed "no grenade appeared within ${NADE_THROW_CONFIRM_MS}ms of ${NADE_CMD_THROW} — the plugin did not re-emit it (np_ghost_projectile off?)" + fi + if [ "$ELAPSED" -ge "$DETONATE_DEADLINE_MS" ]; then + if [ "$NADE_ALLOW_TIMED_DETONATION" = "1" ]; then + TIMED_FALLBACK=1 + DETONATED=1 + now_ms DETONATED_AT_MS + say "WARN no detonation signal in ${DETONATE_DEADLINE_MS}ms — falling back to the recorded flight time (NADE_ALLOW_TIMED_DETONATION=1); this clip's timing is UNVERIFIED" + break + fi + die_failed "grenade never detonated within ${DETONATE_DEADLINE_MS}ms and no detonation signal is available (GSI grenade feed seen=${GSI_GRENADES}, NADE_DETONATE_LOG_RE${NADE_DETONATE_LOG_RE:+ set}${NADE_DETONATE_LOG_RE:-" unset"})" + fi + if [ $((NOW - CAPTURE_START_MS)) -ge "$NADE_MAX_CLIP_MS" ]; then + die_failed "clip hit the ${NADE_MAX_CLIP_MS}ms hard cap before detonation" + fi + poll_sleep +done +api_status "status=rendering" "progress=0.7" + +# --- STEP 5: hold for as long as THIS grenade type stays interesting --------- +# A smoke is held on its GSI-reported effecttime (engine truth); everything +# else gets a fixed tail measured from the OBSERVED detonation, never from the +# throw, so a slow flight can't eat the payoff. + +case "${SEEN_TYPE:-$GSI_TYPE}" in + smoke) + HOLD_DEADLINE_MS=$((NADE_SMOKE_BLOOM_MS + 3000)) + say "STEP 5: holding for bloom (effecttime >= ${NADE_SMOKE_BLOOM_MS}ms)" + while :; do + now_ms NOW + HELD=$((NOW - DETONATED_AT_MS)) + if read_watch; then + BLOOM_MS="$W_BLOOM" + fi + if [ "$BLOOM_MS" -ge "$NADE_SMOKE_BLOOM_MS" ]; then + say "STEP 5: bloomed (effecttime=${BLOOM_MS}ms)" + break + fi + # No grenade feed means no effecttime to read: hold the bloom duration + # from the observed detonation instead. + if [ "$GSI_GRENADES" != "1" ] && [ "$HELD" -ge "$NADE_SMOKE_BLOOM_MS" ]; then + say "STEP 5: no GSI grenade feed — held ${HELD}ms from the observed detonation" + break + fi + if [ "$HELD" -ge "$HOLD_DEADLINE_MS" ]; then + say "WARN bloom never reached ${NADE_SMOKE_BLOOM_MS}ms (last=${BLOOM_MS}ms) — stopping" + break + fi + if [ $((NOW - CAPTURE_START_MS)) -ge "$NADE_MAX_CLIP_MS" ]; then + say "WARN hit the ${NADE_MAX_CLIP_MS}ms hard cap during bloom" + break + fi + poll_sleep + done + ;; + firebomb|inferno) + say "STEP 5: holding ${NADE_INFERNO_HOLD_MS}ms of fire spread" + sleep "$(awk -v ms="$NADE_INFERNO_HOLD_MS" 'BEGIN{printf "%.3f", ms/1000}')" + ;; + *) + say "STEP 5: holding ${NADE_TAIL_MS}ms tail" + sleep "$(awk -v ms="$NADE_TAIL_MS" 'BEGIN{printf "%.3f", ms/1000}')" + ;; +esac + +say "STEP 6: stop capture" +stop_clip_capture +spec_post /nade/watch '{"armed": false}' || true +api_status "status=rendering" "progress=0.9" + +CLIP_BYTES=$(stat -c '%s' "$NADE_CLIP_FILE" 2>/dev/null \ + || stat -f '%z' "$NADE_CLIP_FILE" 2>/dev/null || echo 0) +CLIP_DURATION_S=$(ffprobe -v error -show_entries format=duration \ + -of default=noprint_wrappers=1:nokey=1 "$NADE_CLIP_FILE" 2>/dev/null \ + | awk '{printf "%.2f", $1}') +[ -z "$CLIP_DURATION_S" ] && CLIP_DURATION_S=0 +if [ "$(awk -v d="$CLIP_DURATION_S" -v b="$CLIP_BYTES" \ + 'BEGIN{print (d >= 1.0 && b > 1024) ? 1 : 0}')" != "1" ]; then + die_failed "encode produced an unusable clip (${CLIP_BYTES}B, ${CLIP_DURATION_S}s)" +fi +CLIP_DURATION_MS=$(awk -v d="$CLIP_DURATION_S" 'BEGIN{printf "%d", d * 1000}') +say "captured ${CLIP_BYTES}B / ${CLIP_DURATION_S}s" + +# cs2 is free from here — the batch loop can start the next lineup while this +# job's upload tail (disk + network only) finishes. +if [ -n "${NADE_CS2_RELEASE_MARKER:-}" ]; then + : >"$NADE_CS2_RELEASE_MARKER" 2>/dev/null || true + say "cs2 released — next lineup may start" +fi + +# The alignment frame makes the useful poster, so the thumbnail comes from the +# pre-roll rather than from the middle of the flight. +THUMB_SEEK_S=$(awk -v ms="$NADE_PREROLL_MS" -v d="$CLIP_DURATION_S" 'BEGIN{ + t = ms / 2000 + if (t > d / 2) t = d / 2 + printf "%.3f", t +}') +( + if ffmpeg -y -hide_banner -loglevel warning \ + -ss "$THUMB_SEEK_S" -i "$NADE_CLIP_FILE" -frames:v 1 -q:v 3 \ + "$NADE_THUMB_FILE" 2>/dev/null \ + && [ -s "$NADE_THUMB_FILE" ]; then + curl --fail --silent --show-error --max-time 60 \ + --header "x-origin-auth: ${NADE_RENDER_JOB_ID}:${NADE_RENDER_TOKEN}" \ + --header "content-type: image/jpeg" \ + --data-binary "@${NADE_THUMB_FILE}" \ + --output /dev/null \ + "${STATUS_API_BASE}/nade-renders/${NADE_RENDER_JOB_ID}/thumbnail" \ + || say "WARN thumbnail upload failed — continuing without one" + else + say "WARN ffmpeg thumbnail extraction failed — continuing without one" + fi + rm -f "$NADE_THUMB_FILE" +) & +THUMB_BG_PID=$! + +api_status "status=uploading" "progress=0.0" +say "POST ${STATUS_API_BASE}/nade-renders/${NADE_RENDER_JOB_ID}/upload" +# --upload-file streams from disk; --data-binary @file would slurp the whole +# clip into RAM alongside every other pending upload tail. +if ! curl --fail --silent --show-error --max-time 900 \ + --header "x-origin-auth: ${NADE_RENDER_JOB_ID}:${NADE_RENDER_TOKEN}" \ + --header "content-type: application/octet-stream" \ + --header "x-clip-duration-ms: ${CLIP_DURATION_MS}" \ + --upload-file "$NADE_CLIP_FILE" \ + --request POST \ + --output /dev/null \ + "${STATUS_API_BASE}/nade-renders/${NADE_RENDER_JOB_ID}/upload"; then + wait "$THUMB_BG_PID" 2>/dev/null || true + die_failed "clip upload failed" +fi +# The api only attaches a thumbnail that already exists when the upload +# finalizes, so it has to land before status=done. +wait "$THUMB_BG_PID" 2>/dev/null || true + +DONE_ARGS=("status=done" "progress=1.0" "duration_ms=${CLIP_DURATION_MS}") +[ "$TIMED_FALLBACK" = "1" ] && DONE_ARGS+=("unverified_timing=1") +api_status "${DONE_ARGS[@]}" +NADE_REACHED_TERMINAL=1 +rm -f "$NADE_CLIP_FILE" +say "done" diff --git a/src/lib/snapshot.sh b/src/lib/snapshot.sh index 82974a0..d6326ef 100644 --- a/src/lib/snapshot.sh +++ b/src/lib/snapshot.sh @@ -35,17 +35,23 @@ _snapshot_capture_one() { } _snapshot_targets() { + local batch_jobs="" batch_resource="" if [ "${CLIP_BATCH_MODE:-0}" = "1" ] && [ -n "${CLIP_BATCH_JOBS:-}" ]; then + batch_jobs="$CLIP_BATCH_JOBS"; batch_resource="clip-renders" + elif [ "${NADE_BATCH_MODE:-0}" = "1" ] && [ -n "${NADE_BATCH_JOBS:-}" ]; then + batch_jobs="$NADE_BATCH_JOBS"; batch_resource="nade-renders" + fi + if [ -n "$batch_jobs" ]; then local helpers="${LIB_DIR:-$(dirname "${BASH_SOURCE[0]}")}/clip-helpers.mjs" [ -f "$helpers" ] || return 0 command -v node >/dev/null 2>&1 || return 0 local id token - printf '%s' "$CLIP_BATCH_JOBS" \ + printf '%s' "$batch_jobs" \ | node "$helpers" jobs-credentials 2>/dev/null \ | while IFS=$'\t' read -r id token; do [ -n "$id" ] && [ -n "$token" ] || continue - printf '%s/clip-renders/%s/snapshot\t%s:%s\n' \ - "$STATUS_API_BASE" "$id" "$id" "$token" + printf '%s/%s/%s/snapshot\t%s:%s\n' \ + "$STATUS_API_BASE" "$batch_resource" "$id" "$id" "$token" done return 0 fi diff --git a/src/lib/status-reporter.sh b/src/lib/status-reporter.sh index aab0181..d1b3ae4 100644 --- a/src/lib/status-reporter.sh +++ b/src/lib/status-reporter.sh @@ -67,17 +67,46 @@ _status_reporter_configured() { if [ -n "$STATUS_REPORT_URL" ] && [ -n "$STATUS_AUTH_TOKEN" ]; then return 0 fi + # A nade preview pod rides along on someone's practice match, so MATCH_ID and + # CONNECT_PASSWORD are both set — but it is NOT that match's streamer, and + # POSTing to /game-streamer/:id/status would flip the match's live state. + # Its status goes out per render job instead. + if [ "${NADE_BATCH_MODE:-0}" = "1" ]; then + return 1 + fi [ -n "$MATCH_ID" ] && [ -n "$MATCH_PASSWORD" ] } -# True when this pod is processing a batch of highlight render jobs. +# The queue this pod is draining, if any: highlight clips or nade previews. +# Both ship a JSON array of {job_id, token, spec} and both have per-job status +# endpoints, so one broadcast path serves them. +_batch_jobs_blob() { + if [ "${CLIP_BATCH_MODE:-0}" = "1" ] && [ -n "${CLIP_BATCH_JOBS:-}" ]; then + printf '%s' "$CLIP_BATCH_JOBS" + return 0 + fi + if [ "${NADE_BATCH_MODE:-0}" = "1" ] && [ -n "${NADE_BATCH_JOBS:-}" ]; then + printf '%s' "$NADE_BATCH_JOBS" + return 0 + fi + return 1 +} + +_batch_status_resource() { + if [ "${NADE_BATCH_MODE:-0}" = "1" ] && [ -n "${NADE_BATCH_JOBS:-}" ]; then + printf 'nade-renders' + else + printf 'clip-renders' + fi +} + _in_batch_broadcast_mode() { - [ "${CLIP_BATCH_MODE:-0}" = "1" ] && [ -n "${CLIP_BATCH_JOBS:-}" ] + _batch_jobs_blob >/dev/null 2>&1 } -# POSTs $body to /clip-renders/:id/status for every job in -# CLIP_BATCH_JOBS. Curls fan out in parallel — N jobs cost max(curl) -# (~3s timeout) not N*3s — and we wait so callers don't leak zombies. +# POSTs $body to //:id/status for every job in the batch. Curls fan +# out in parallel — N jobs cost max(curl) (~3s timeout) not N*3s — and we wait +# so callers don't leak zombies. _broadcast_to_batch_jobs() { local body="$1" [ -n "$body" ] || return 0 @@ -86,8 +115,9 @@ _broadcast_to_batch_jobs() { [ -f "$helpers" ] || return 0 command -v node >/dev/null 2>&1 || return 0 - local creds id token - creds=$(printf '%s' "$CLIP_BATCH_JOBS" \ + local resource creds id token + resource=$(_batch_status_resource) + creds=$(_batch_jobs_blob \ | node "$helpers" jobs-credentials 2>/dev/null) || return 0 [ -n "$creds" ] || return 0 @@ -99,7 +129,7 @@ _broadcast_to_batch_jobs() { -H "Content-Type: application/json" \ --data-binary "$body" \ -o /dev/null \ - "${STATUS_API_BASE}/clip-renders/${id}/status" \ + "${STATUS_API_BASE}/${resource}/${id}/status" \ 2>/dev/null & pids+=( "$!" ) done <<< "$creds" @@ -117,13 +147,13 @@ _encode_status_body() { node "$helpers" status-body "$@" 2>/dev/null } -# Fan errors out to every job in CLIP_BATCH_JOBS — batch pods have no -# single status channel, so die() relies on this instead. +# Fan errors out to every job in the batch — batch pods have no single status +# channel, so die() relies on this instead. broadcast_batch_error() { _in_batch_broadcast_mode || return 0 [ "$#" -gt 0 ] || return 0 - # clip_render_jobs use status="error" (not "errored" like match_streams). + # Render jobs use status="error" (not "errored" like match_streams). local mapped=() arg for arg in "$@"; do case "$arg" in @@ -302,8 +332,8 @@ _status_daemon_loop() { start_status_reporter() { if ! _status_reporter_configured; then - if [ "${CLIP_BATCH_MODE:-0}" = "1" ] && [ -n "${CLIP_BATCH_JOBS:-}" ]; then - log "status-reporter: batch-highlights mode — broadcasting per-job" + if _in_batch_broadcast_mode; then + log "status-reporter: batch mode ($(_batch_status_resource)) — broadcasting per-job" else log "status-reporter: disabled (MATCH_ID/MATCH_PASSWORD unset)" fi diff --git a/src/spectator/routes/index.mjs b/src/spectator/routes/index.mjs index 4a7e465..87089cc 100644 --- a/src/spectator/routes/index.mjs +++ b/src/spectator/routes/index.mjs @@ -37,6 +37,7 @@ import { toggleHandler, xrayHandler, } from "./demo.mjs"; +import { nadeSelfHandler, nadeWatchArmHandler, nadeWatchStateHandler } from "./nade-watch.mjs"; import { renderClipHandler } from "./render-clip.mjs"; import { switchMatchHandler } from "./switch-match.mjs"; import { reconnectHandler } from "./reconnect.mjs"; @@ -52,6 +53,9 @@ const ROUTES = new Map([ ["GET /demo/capture-fields", captureFieldsHandler], ["GET /demo/pov-state", povStateHandler], ["GET /demo/seek-state", seekStateHandler], + ["GET /nade/watch", nadeWatchStateHandler], + ["GET /nade/self", nadeSelfHandler], + ["POST /nade/watch", nadeWatchArmHandler], ["POST /gsi", gsiHandler], ["POST /spec/click", clickHandler], @@ -139,6 +143,8 @@ export async function dispatch(req, res) { const QUIET_URLS = new Set([ "/gsi", "/demo/state", "/demo/capture-fields", "/demo/pov-state", "/demo/seek-state", + // Polled at ~10Hz by the nade preview recorder while a throw is in flight. + "/nade/watch", "/nade/self", // Polled by the HUD overlay to follow the spectated player. "/camera/state", ]); diff --git a/src/spectator/routes/nade-watch.mjs b/src/spectator/routes/nade-watch.mjs new file mode 100644 index 0000000..39bfa47 --- /dev/null +++ b/src/spectator/routes/nade-watch.mjs @@ -0,0 +1,58 @@ +import { gsiState } from "../state/gsi.mjs"; +import { armNadeWatch, disarmNadeWatch, nadeWatch, nadeWatchLine } from "../state/nades.mjs"; +import { sendJson } from "../util/http.mjs"; + +function sendLine(res, line) { + const body = Buffer.from(line); + res.writeHead(200, { + "Content-Type": "text/plain", + "Content-Length": String(body.length), + }); + res.end(body); +} + +// The nade-preview recorder polls this at ~10Hz between triggering a throw and +// stopping the capture, so it's a plain pipe-delimited line like +// /demo/capture-fields rather than JSON: +// armed|gsi_age_ms|since_arm_ms|thrown|detonated|bloom_ms|active|type|blocks_seen +// gsi_age_ms is -1 before the first GSI event; blocks_seen counts GSI updates +// that carried any grenade at all (0 forever => this client isn't an observer, +// so the caller must fall back to the console-log signal). +export async function nadeWatchStateHandler(_req, res) { + sendLine(res, nadeWatchLine()); +} + +// Where THIS client is standing and looking, straight off the GSI `player` +// block — the nade recorder's camera-arrival check: +// gsi_age_ms|steam_id|team|health|activity|x|y|z|fwd_x|fwd_y|fwd_z +// Empty position/forward fields mean GSI hasn't reported them yet; gsi_age_ms +// is -1 before the first GSI event. +export async function nadeSelfHandler(_req, res) { + const age = gsiState.lastReceivedMs > 0 ? Date.now() - gsiState.lastReceivedMs : -1; + const pos = gsiState.localPosition ?? ["", "", ""]; + const fwd = gsiState.localForward ?? ["", "", ""]; + sendLine(res, [ + String(age), + gsiState.spectatedSteamId ?? "", + gsiState.localTeam ?? "", + String(gsiState.localHealth), + gsiState.localActivity ?? "", + ...pos.map(String), + ...fwd.map(String), + ].join("|")); +} + +export async function nadeWatchArmHandler(_req, res, body) { + if (body?.armed === false) { + disarmNadeWatch(); + sendJson(res, 200, { ok: true, armed: false }); + return; + } + armNadeWatch(typeof body?.type === "string" ? body.type : null); + sendJson(res, 200, { + ok: true, + armed: true, + type: nadeWatch.wantType, + blocks_seen: nadeWatch.blocksSeen, + }); +} diff --git a/src/spectator/state/gsi.mjs b/src/spectator/state/gsi.mjs index 3269aed..87ad52f 100644 --- a/src/spectator/state/gsi.mjs +++ b/src/spectator/state/gsi.mjs @@ -1,6 +1,7 @@ import { SNIPER_WEAPONS } from "../constants.mjs"; import { parsePosition } from "../util/geometry.mjs"; import { steamIdToAccountId } from "../util/steamid.mjs"; +import { applyNadeUpdate } from "./nades.mjs"; export const gsiState = { lastReceivedMs: 0, @@ -14,6 +15,14 @@ export const gsiState = { roundNumber: null, spectatedSteamId: null, specSlots: [], + // The `player` block for THIS client. On a server we joined as a player it's + // the only block GSI sends (allplayers/allgrenades are observer-only), and + // it's what the nade preview recorder confirms its camera against. + localPosition: null, + localForward: null, + localHealth: 0, + localActivity: null, + localTeam: null, teamCtName: null, teamTName: null, teamCtScore: 0, @@ -57,11 +66,18 @@ export function applyGsiUpdate(body) { : null; gsiState.roundNumber = typeof map.round === "number" ? map.round : null; gsiState.spectatedSteamId = typeof player.steamid === "string" ? player.steamid : null; + gsiState.localPosition = parsePosition(player.position); + gsiState.localForward = parsePosition(player.forward); + gsiState.localHealth = Number(player?.state?.health ?? 0) || 0; + gsiState.localActivity = typeof player.activity === "string" ? player.activity : null; + gsiState.localTeam = player.team === "T" || player.team === "CT" ? player.team : null; gsiState.teamCtName = typeof map?.team_ct?.name === "string" ? map.team_ct.name : null; gsiState.teamTName = typeof map?.team_t?.name === "string" ? map.team_t.name : null; gsiState.teamCtScore = Number(map?.team_ct?.score ?? 0) || 0; gsiState.teamTScore = Number(map?.team_t?.score ?? 0) || 0; + applyNadeUpdate(body?.grenades); + let playersUpdated = false; if (allPlayers && typeof allPlayers === "object") { // GSI `observer_slot` is 0-indexed; observer.cfg binds digits diff --git a/src/spectator/state/nades.mjs b/src/spectator/state/nades.mjs new file mode 100644 index 0000000..0d2daf1 --- /dev/null +++ b/src/spectator/state/nades.mjs @@ -0,0 +1,134 @@ +// Tracks the GSI `grenades` block so a nade preview capture can stop on the +// real detonation/bloom instead of a timer. +// +// GSI only ships `allgrenades` to an OBSERVER client (or a demo) — a client +// that joined as a player sees nothing here, so the recorder has to be able to +// tell "no grenade ever appeared" apart from "this server never sends grenade +// data at all". blocksSeen is that signal. + +// GSI's own type names on the left of the arrow; the rest are the 5stack +// e_utility_types values (Smoke/Flash/HighExplosive/Molotov/Decoy, lowercased) +// so the api can arm the watch with the enum it already stores. +const TYPE_ALIASES = new Map([ + ["hegrenade", "frag"], + ["he", "frag"], + ["highexplosive", "frag"], + ["flash", "flashbang"], + ["smokegrenade", "smoke"], + ["molotov", "firebomb"], + ["incendiary", "firebomb"], + ["incgrenade", "firebomb"], +]); + +export const nadeWatch = { + armedMs: 0, + wantType: null, + thrownMs: 0, + detonatedMs: 0, + bloomMs: 0, + type: null, + entityId: null, + active: 0, + blocksSeen: 0, + lastUpdateMs: 0, + preIds: new Set(), + lastIds: new Set(), +}; + +export function normalizeNadeType(raw) { + if (typeof raw !== "string") return null; + const t = raw.trim().toLowerCase(); + if (!t) return null; + return TYPE_ALIASES.get(t) ?? t; +} + +function seconds(raw) { + const n = typeof raw === "number" ? raw : Number.parseFloat(raw); + return Number.isFinite(n) ? n : 0; +} + +export function armNadeWatch(wantType) { + nadeWatch.armedMs = Date.now(); + nadeWatch.wantType = normalizeNadeType(wantType); + nadeWatch.thrownMs = 0; + nadeWatch.detonatedMs = 0; + nadeWatch.bloomMs = 0; + nadeWatch.type = null; + nadeWatch.entityId = null; + // Grenades already in the air when we armed (a previous lineup's smoke still + // blooming) would otherwise be read as this lineup's throw. + nadeWatch.preIds = new Set(nadeWatch.lastIds); +} + +export function disarmNadeWatch() { + nadeWatch.armedMs = 0; + nadeWatch.wantType = null; +} + +export function applyNadeUpdate(grenades) { + const now = Date.now(); + nadeWatch.lastUpdateMs = now; + + const entries = grenades && typeof grenades === "object" ? Object.entries(grenades) : []; + const ids = new Set(); + const fresh = []; + for (const [id, g] of entries) { + if (!g || typeof g !== "object") continue; + ids.add(id); + fresh.push({ id, type: normalizeNadeType(g.type), effect: seconds(g.effecttime) }); + } + nadeWatch.lastIds = ids; + nadeWatch.active = fresh.length; + if (fresh.length > 0) nadeWatch.blocksSeen += 1; + if (nadeWatch.armedMs === 0) return; + + const post = fresh.filter((g) => !nadeWatch.preIds.has(g.id)); + + if (nadeWatch.entityId === null) { + const want = nadeWatch.wantType; + // A firebomb's own projectile can be missed between polls — its inferno is + // proof enough that the throw happened. + const match = post.find((g) => + !want || g.type === want || + (want === "firebomb" && g.type === "inferno")); + if (match) { + nadeWatch.entityId = match.id; + nadeWatch.type = match.type; + nadeWatch.thrownMs = now; + } + } + if (nadeWatch.entityId === null) return; + + const tracked = fresh.find((g) => g.id === nadeWatch.entityId); + const bloom = post.reduce((max, g) => (g.effect > max ? g.effect : max), 0); + if (bloom > 0) nadeWatch.bloomMs = Math.round(bloom * 1000); + + if (nadeWatch.detonatedMs === 0) { + const inferno = post.some((g) => g.type === "inferno"); + // Smoke: the projectile keeps its slot and grows an effecttime. Everything + // else (frag/flash/decoy) is simply gone the moment it goes off — but that + // read is only safe once we've actually seen the entity in flight. + const detonated = + bloom > 0 || + inferno || + (tracked === undefined && nadeWatch.thrownMs > 0 && nadeWatch.type !== "smoke"); + if (detonated) nadeWatch.detonatedMs = now; + } +} + +export function nadeWatchLine() { + const now = Date.now(); + const armed = nadeWatch.armedMs > 0; + const ageMs = nadeWatch.lastUpdateMs > 0 ? now - nadeWatch.lastUpdateMs : -1; + return [ + armed ? "1" : "0", + String(ageMs), + armed ? String(now - nadeWatch.armedMs) : "0", + nadeWatch.thrownMs > 0 ? "1" : "0", + nadeWatch.detonatedMs > 0 ? "1" : "0", + String(nadeWatch.bloomMs), + String(nadeWatch.active), + nadeWatch.type ?? "", + String(nadeWatch.blocksSeen), + ].join("|"); +} From 3135c176d38a2947d2c374acb1e0fc62de8079a1 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 12:00:50 -0400 Subject: [PATCH 2/6] wip --- src/flows/run-nades.sh | 7 ++++++ src/lib/batch-nades.sh | 52 ++++++++++++++++++++++++++++++++++++++++++ src/lib/hud-manager.sh | 6 ++++- src/lib/nade-clip.sh | 12 ++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/flows/run-nades.sh b/src/flows/run-nades.sh index 1d2223f..29879bb 100755 --- a/src/flows/run-nades.sh +++ b/src/flows/run-nades.sh @@ -101,6 +101,13 @@ EOF # flushes console commands through) doesn't error before the first write. : > "$CS2_CFG_DIR/5stack_exec.cfg" +# The camera check demands a GSI reading no older than NADE_GSI_MAX_AGE_MS +# (2s) taken while the player stands still at the lineup -- which is exactly +# when cs2 stops emitting state changes. At the default 10s heartbeat the check +# is only evaluable for ~2s out of every 10, and reported "GSI is stale" for +# the rest. Pulse faster than the freshness window it is checked against. +: "${GSI_HEARTBEAT:=0.5}" +export GSI_HEARTBEAT write_gsi_cfg for base in libpangoft2-1.0 libpango-1.0; do diff --git a/src/lib/batch-nades.sh b/src/lib/batch-nades.sh index 76a9fc6..103501d 100644 --- a/src/lib/batch-nades.sh +++ b/src/lib/batch-nades.sh @@ -138,6 +138,53 @@ reap_oldest_nade_tail() { TAIL_MARKERS=("${TAIL_MARKERS[@]:1}") } +# A connecting client lands in team select and stays there: the practice plugin +# only reacts to OnPlayerJoinTeam, it never assigns one, and the pod is on +# nobody's roster. Somebody has to press a team. +# +# That somebody used to be nade-clip.sh's join_if_not_spawned -- but that runs +# inside a job, and no job starts until the gate below reports a spawned player. +# The gate waited for a spawn that only a job could cause, so it always ran out +# the clock and died "never spawned ... stuck in team select". Joining here is +# what breaks the cycle; the per-job join still covers a mid-batch death. +# NEVER press this while already alive: jointeam on a live pawn respawns it, so +# a re-press loop that ignores health kills the player on a loop. The gate can +# sit here with health>0 whenever some OTHER condition is holding it up (a stale +# GSI reading, say), which is exactly when an unguarded re-press does damage. +nade_join_team() { + local self health + self=$(curl --fail --silent --max-time 5 \ + "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/nade/self" || true) + if [ -n "$self" ]; then + IFS='|' read -r _age _sid _team health _rest <<<"$self" + case "${health:-0}" in + ''|*[!0-9]*) ;; + *) [ "$health" -gt 0 ] && return 0 ;; + esac + fi + curl --fail --silent --max-time 5 \ + --header "content-type: application/json" \ + --data "{\"cmd\": \"jointeam ${NADE_BATCH_JOIN_TEAM:-3}\"}" \ + --output /dev/null \ + "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/demo/exec" || true +} + +# Same side mapping nade-clip.sh uses, read off the batch's first lineup so the +# opening spawn is already on the right side. +nade_batch_join_team() { + printf '%s' "${NADE_BATCH_JOBS:-}" | node -e ' + let s = ""; + process.stdin.on("data", (d) => (s += d)).on("end", () => { + let team = "3"; + try { + const side = String(JSON.parse(s)?.[0]?.spec?.side ?? ""); + if (/^t/i.test(side)) team = "2"; + } catch {} + process.stdout.write(team); + }); + ' 2>/dev/null || printf '3' +} + # Connected, in-game and alive is the only state in which `.load` does # anything, so the batch waits for GSI to say so before the first lineup. # die() fans the failure out to every job, so a server that never comes up is @@ -145,7 +192,9 @@ reap_oldest_nade_tail() { wait_for_nade_session() { local waited=0 line age health map_name NADE_SESSION_MAP="" + NADE_BATCH_JOIN_TEAM=$(nade_batch_join_team) say "waiting for the practice server (GSI + spawned player)" + say " joining team ${NADE_BATCH_JOIN_TEAM} (nothing else does)" while :; do line=$(curl --fail --silent --max-time 5 "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/nade/self" || true) if [ -n "$line" ]; then @@ -170,6 +219,9 @@ wait_for_nade_session() { die "never spawned on the practice server within ${NADE_SESSION_READY_TIMEOUT}s (wrong password, server down, or the client is stuck in team select)" fi waited=$((waited + 1)) + # Re-press rather than fire once: the first attempt can land before the + # client is far enough through connecting for the command to take. + [ $((waited % "${NADE_JOIN_RETRY_SECONDS:-5}")) -eq 0 ] && nade_join_team [ $((waited % 15)) -eq 0 ] && say " still waiting (${waited}s)" sleep 1 done diff --git a/src/lib/hud-manager.sh b/src/lib/hud-manager.sh index 94572c2..fe78cae 100644 --- a/src/lib/hud-manager.sh +++ b/src/lib/hud-manager.sh @@ -208,7 +208,11 @@ write_gsi_cfg() { "timeout" "5.0" "buffer" "0.0" "throttle" "0.1" - "heartbeat" "10.0" + # A motionless player generates no state changes, so the heartbeat is the + # only thing that keeps GSI fresh -- and the nade flow reads a player who is + # deliberately standing perfectly still. It lowers this; live and demo keep + # the cheap 10s pulse. + "heartbeat" "${GSI_HEARTBEAT:-10.0}" "auth" { "token" "5stack-spec" } "data" { diff --git a/src/lib/nade-clip.sh b/src/lib/nade-clip.sh index b37d26a..87276f7 100755 --- a/src/lib/nade-clip.sh +++ b/src/lib/nade-clip.sh @@ -312,6 +312,7 @@ cs2_exec_template "$NADE_CMD_LOAD" # Position AND angle both have to match: the alignment is the product, so a # clip shot from the right spot facing the wrong way is a wrong clip. CAMERA_FAIL="no GSI player state yet" +CAMERA_FAIL_MEASURED="" camera_confirmed() { read_self || { CAMERA_FAIL="spec-server /nade/self unreachable"; return 1; } case "${S_AGE:-}" in ''|-1|*[!0-9]*) CAMERA_FAIL="GSI has not fired yet"; return 1 ;; esac @@ -357,6 +358,11 @@ camera_confirmed() { 3) CAMERA_FAIL="looking along ${S_FX},${S_FY},${S_FZ}, lineup wants yaw=${NADE_VIEW_YAW} pitch=${NADE_VIEW_PITCH}" ;; *) CAMERA_FAIL="lineup origin '${NADE_ORIGIN}' is malformed" ;; esac + # Every poll overwrites CAMERA_FAIL, so the timeout used to report whichever + # reason the LAST poll happened to hit -- and the ambient ones (stale GSI, not + # yet spawned) drown out the one that tells you anything. A reading we could + # actually measure is the diagnosis; keep it and report that instead. + CAMERA_FAIL_MEASURED="$CAMERA_FAIL" return 1 } @@ -378,6 +384,12 @@ while [ "$WAITED" -lt "$NADE_CAMERA_CONFIRM_MS" ]; do fi done if [ "$CAMERA_OK" != "1" ]; then + if [ -n "${CAMERA_FAIL_MEASURED:-}" ]; then + # We saw the player clearly at least once and they were in the wrong place, + # so the lineup loaded and the map is right -- this is an alignment problem, + # not a connection one. + die_skipped "camera never reached the lineup within ${NADE_CAMERA_CONFIRM_MS}ms: ${CAMERA_FAIL_MEASURED}" + fi die_skipped "camera never reached the lineup within ${NADE_CAMERA_CONFIRM_MS}ms: ${CAMERA_FAIL} — wrong map, not spawned, or the plugin has no lineup named '${NADE_LINEUP_NAME}'" fi api_status "status=rendering" "progress=0.15" From 8e381624fd3cf3e2e6688df9880707b5120e1fe1 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 15:48:27 -0400 Subject: [PATCH 3/6] wip --- src/lib/batch-nades.sh | 57 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/lib/batch-nades.sh b/src/lib/batch-nades.sh index 103501d..eb90c1c 100644 --- a/src/lib/batch-nades.sh +++ b/src/lib/batch-nades.sh @@ -169,6 +169,57 @@ nade_join_team() { "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/demo/exec" || true } +# The gate's black-box breaker. Zero GSI can mean "in the menu", "kicked", or +# "in team select with the exec keystrokes landing nowhere" -- three different +# bugs. CS2's own console (-condebug) tells them apart, and `status` printing +# there at all proves the exec-cfg keystroke path works: the command travels +# spec-server -> cfg file -> BACKSPACE keybind -> console, so its output is a +# health check of the whole chain, not just a connection report. +NADE_GATE_LOG_OFFSET=0 +nade_gate_probe() { + curl --fail --silent --max-time 5 \ + --header "content-type: application/json" \ + --data '{"cmd": "status"}' \ + --output /dev/null \ + "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/demo/exec" || true + sleep 2 + local log="${CS2_CONSOLE_LOG:-$CS2_DIR/game/csgo/console.log}" + if [ ! -f "$log" ]; then + say " console.log missing — -condebug is not writing, cs2 may not be up" + return 0 + fi + local size + size=$(stat -c %s "$log" 2>/dev/null || echo 0) + if [ "$size" -le "$NADE_GATE_LOG_OFFSET" ]; then + say " console silent since last probe — the status keystroke is not reaching cs2 (window focus?)" + return 0 + fi + say " console tail:" + tail -c +$((NADE_GATE_LOG_OFFSET + 1)) "$log" | tail -n 8 | sed 's/^/ | /' 1>&2 + NADE_GATE_LOG_OFFSET=$size +} + +# The boot-time connect (+connect launch arg and the autoexec both) fires +# exactly once, before the client is fully up -- if it misses, the client sits +# in the main menu forever and nothing in the flow ever tries again. While GSI +# has NEVER fired (age -1: not in any map, menus emit nothing) the gate +# re-issues it. A client that is actually in-game has GSI, so this can never +# yank a working session. +nade_reconnect() { + [ -n "${CS2_CONNECT_ADDR:-}" ] || return 0 + local line age + line=$(curl --fail --silent --max-time 5 \ + "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/nade/self" || true) + IFS='|' read -r age _rest <<<"$line" + [ "${age:-'-1'}" = "-1" ] || return 0 + say " no GSI yet — re-issuing connect to ${CS2_CONNECT_ADDR}" + curl --fail --silent --max-time 5 \ + --header "content-type: application/json" \ + --data "{\"cmd\": \"password \\\"${CS2_CONNECT_PASSWORD:-}\\\"; connect ${CS2_CONNECT_ADDR}\"}" \ + --output /dev/null \ + "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/demo/exec" || true +} + # Same side mapping nade-clip.sh uses, read off the batch's first lineup so the # opening spawn is already on the right side. nade_batch_join_team() { @@ -222,7 +273,11 @@ wait_for_nade_session() { # Re-press rather than fire once: the first attempt can land before the # client is far enough through connecting for the command to take. [ $((waited % "${NADE_JOIN_RETRY_SECONDS:-5}")) -eq 0 ] && nade_join_team - [ $((waited % 15)) -eq 0 ] && say " still waiting (${waited}s)" + if [ $((waited % 15)) -eq 0 ]; then + say " still waiting (${waited}s)" + nade_gate_probe + fi + [ $((waited % 45)) -eq 0 ] && nade_reconnect sleep 1 done } From 6b810ceb5991978f971b38171228669b4b453cd9 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 21 Aug 2026 16:52:55 -0400 Subject: [PATCH 4/6] wip --- src/flows/run-nades.sh | 20 +++++++++++++++++++- src/lib/batch-nades.sh | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/flows/run-nades.sh b/src/flows/run-nades.sh index 29879bb..4e7dd8b 100755 --- a/src/flows/run-nades.sh +++ b/src/flows/run-nades.sh @@ -44,8 +44,17 @@ else die "no practice server to connect to — set CONNECT_ADDR (+CONNECT_PASSWORD)" fi +# The api resolves this via get_server_host: an SDR relay token ([A:1:...]) on +# a relay region, else host:port. A relay server answers ONLY over Steam +# datagram, so a raw ip:port here lands the client on cs2's loopback map. +log "connect target: $CS2_CONNECT_ADDR" + : "${NADE_OUT_DIR:=/tmp/game-streamer/nades}" : "${NADE_OUTPUT_FPS:=60}" +# A render that keeps snapshotting should not throttle to the 30s "playing" +# cadence the moment GSI flows -- keep it dense so a throw is actually visible. +: "${SNAPSHOT_INTERVAL_SECONDS:=${SNAPSHOT_BOOT_INTERVAL_SECONDS:-5}}" +export SNAPSHOT_INTERVAL_SECONDS # The capture samples cs2's swapchain, so cs2 must render ABOVE the capture # rate for every sample to be a fresh frame — same 2x headroom the demo flow # uses on the vkcapture path. @@ -185,7 +194,16 @@ timeout 5 xdotool windowfocus --sync "$WIN" 2>/dev/null || true && report_status status=errored "error=cs2 process exited unexpectedly" ) & -stop_snapshot_loop +# Keep watching. The loop normally stops here so the screen grab cannot +# compete with a clip capture -- but the connect/join/wait phase (where a +# render most often wedges) has no capture running, and a live view of it is +# the whole point while debugging. NADE_KEEP_SNAPSHOTS=0 restores the old +# stop-before-filming behaviour. +if [ "${NADE_KEEP_SNAPSHOTS:-1}" = "1" ]; then + log "snapshot: keeping the loop running through the batch (NADE_KEEP_SNAPSHOTS=1)" +else + stop_snapshot_loop +fi # shellcheck disable=SC1091 . "$LIB_DIR/batch-nades.sh" process_nade_jobs diff --git a/src/lib/batch-nades.sh b/src/lib/batch-nades.sh index eb90c1c..9e36d0e 100644 --- a/src/lib/batch-nades.sh +++ b/src/lib/batch-nades.sh @@ -197,6 +197,13 @@ nade_gate_probe() { say " console tail:" tail -c +$((NADE_GATE_LOG_OFFSET + 1)) "$log" | tail -n 8 | sed 's/^/ | /' 1>&2 NADE_GATE_LOG_OFFSET=$size + # And the other half of the gate's condition, so a log reads "in the map + # per console, no GSI per spec-server" without anyone having to correlate. + local self watch + self=$(curl --fail --silent --max-time 5 "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/nade/self" || echo "unreachable") + watch=$(curl --fail --silent --max-time 5 "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/nade/watch" || echo "unreachable") + say " gsi self (age|steam|team|health|activity|pos|fwd): ${self}" + say " gsi watch (armed|age|since|thrown|det|bloom|active|type|blocks_seen): ${watch}" } # The boot-time connect (+connect launch arg and the autoexec both) fires @@ -267,6 +274,15 @@ wait_for_nade_session() { esac fi if [ "$waited" -ge "$NADE_SESSION_READY_TIMEOUT" ]; then + # The console tail is the diagnosis; the guesses are only for a log that + # never got written. + local postmortem="" gate_log="${CS2_CONSOLE_LOG:-$CS2_DIR/game/csgo/console.log}" + if [ -f "$gate_log" ]; then + postmortem=$(tail -n 5 "$gate_log" | tr '\n' ';' | cut -c1-260) + fi + if [ -n "$postmortem" ]; then + die "never spawned on the practice server within ${NADE_SESSION_READY_TIMEOUT}s — console: ${postmortem}" + fi die "never spawned on the practice server within ${NADE_SESSION_READY_TIMEOUT}s (wrong password, server down, or the client is stuck in team select)" fi waited=$((waited + 1)) From 46b5c937c947a001052212d44f7f71427c56d70d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 22 Aug 2026 07:01:00 -0400 Subject: [PATCH 5/6] wip --- src/lib/batch-nades.sh | 18 +++++++++++++----- src/lib/hud-manager.sh | 10 ++++++---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/lib/batch-nades.sh b/src/lib/batch-nades.sh index 9e36d0e..db9729f 100644 --- a/src/lib/batch-nades.sh +++ b/src/lib/batch-nades.sh @@ -208,18 +208,26 @@ nade_gate_probe() { # The boot-time connect (+connect launch arg and the autoexec both) fires # exactly once, before the client is fully up -- if it misses, the client sits -# in the main menu forever and nothing in the flow ever tries again. While GSI -# has NEVER fired (age -1: not in any map, menus emit nothing) the gate -# re-issues it. A client that is actually in-game has GSI, so this can never -# yank a working session. +# in the main menu and nothing else retries. While GSI has NEVER fired (age -1) +# the gate may re-issue it. +# +# STRICTLY CAPPED: a client that DID connect but whose GSI is misconfigured also +# reads age -1, and re-issuing connect there reconnects a joined client over and +# over -- cs2 counts each rejoin as a suicide and kicks it "for suiciding too +# many times". A couple of nudges for a genuinely-missed connect is worth it; an +# unbounded loop is what turned a bad GSI cfg into a suicide kick. A late clip +# beats a kicked one, so after the cap we just wait out the gate. +NADE_RECONNECT_ATTEMPTS=0 nade_reconnect() { [ -n "${CS2_CONNECT_ADDR:-}" ] || return 0 + [ "$NADE_RECONNECT_ATTEMPTS" -ge "${NADE_RECONNECT_MAX:-2}" ] && return 0 local line age line=$(curl --fail --silent --max-time 5 \ "${SPEC_SERVER_URL:-http://127.0.0.1:1350}/nade/self" || true) IFS='|' read -r age _rest <<<"$line" [ "${age:-'-1'}" = "-1" ] || return 0 - say " no GSI yet — re-issuing connect to ${CS2_CONNECT_ADDR}" + NADE_RECONNECT_ATTEMPTS=$((NADE_RECONNECT_ATTEMPTS + 1)) + say " no GSI yet — re-issuing connect to ${CS2_CONNECT_ADDR} (attempt ${NADE_RECONNECT_ATTEMPTS}/${NADE_RECONNECT_MAX:-2})" curl --fail --silent --max-time 5 \ --header "content-type: application/json" \ --data "{\"cmd\": \"password \\\"${CS2_CONNECT_PASSWORD:-}\\\"; connect ${CS2_CONNECT_ADDR}\"}" \ diff --git a/src/lib/hud-manager.sh b/src/lib/hud-manager.sh index fe78cae..c1d9e1f 100644 --- a/src/lib/hud-manager.sh +++ b/src/lib/hud-manager.sh @@ -194,6 +194,12 @@ position_hud_overlay() { # Data fields are the union of what both consumers need (the HUD's set # from src/main/ipc.ts:GSI_CFG_CONTENT plus the director's position / # weapons / match_stats). +# A motionless player generates no GSI state changes, so the heartbeat is the +# only thing that keeps GSI fresh while a nade render stands perfectly still on +# a lineup -- run-nades lowers GSI_HEARTBEAT for that; live/demo keep the cheap +# 10s pulse. The value goes into the cfg via the heredoc below; the explanation +# stays HERE, because a '#' comment inside a Valve KeyValues file is a parse +# error ("got } in key") that makes cs2 reject the whole GSI config. write_gsi_cfg() { local cfg_dir="$CS2_DIR/game/csgo/cfg" mkdir -p "$cfg_dir" @@ -208,10 +214,6 @@ write_gsi_cfg() { "timeout" "5.0" "buffer" "0.0" "throttle" "0.1" - # A motionless player generates no state changes, so the heartbeat is the - # only thing that keeps GSI fresh -- and the nade flow reads a player who is - # deliberately standing perfectly still. It lowers this; live and demo keep - # the cheap 10s pulse. "heartbeat" "${GSI_HEARTBEAT:-10.0}" "auth" { "token" "5stack-spec" } "data" From 4cac1f8e54e79e7201b1816800d068527325964b Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 22 Aug 2026 15:25:23 -0400 Subject: [PATCH 6/6] wip --- src/flows/run-nades.sh | 8 +++++ src/lib/nade-clip.sh | 77 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/flows/run-nades.sh b/src/flows/run-nades.sh index 4e7dd8b..0aa8678 100755 --- a/src/flows/run-nades.sh +++ b/src/flows/run-nades.sh @@ -117,6 +117,14 @@ EOF # the rest. Pulse faster than the freshness window it is checked against. : "${GSI_HEARTBEAT:=0.5}" export GSI_HEARTBEAT + +# A live-pawn render never receives the observer-only GSI grenade feed (cs2 +# only sends allgrenades to a spectator), so the recorded flight time is the +# ONLY detonation signal available. An `exact` lineup carries an accurate +# flight time, so the clip is correctly timed even though the pod flags it +# unverified. Without this the throw step dies "no detonation signal". +: "${NADE_ALLOW_TIMED_DETONATION:=1}" +export NADE_ALLOW_TIMED_DETONATION write_gsi_cfg for base in libpangoft2-1.0 libpango-1.0; do diff --git a/src/lib/nade-clip.sh b/src/lib/nade-clip.sh index 87276f7..52031db 100755 --- a/src/lib/nade-clip.sh +++ b/src/lib/nade-clip.sh @@ -43,15 +43,27 @@ say() { printf '%s %s\n' "$LOG_PREFIX" "$*" >&2; } : "${NADE_CONFIDENCE:=}" : "${NADE_PLUGIN_RUNTIME:=swiftlys2}" -# Plugin verbs. Swiftly registers them as sw_, CounterStrikeSharp as -# css_; both also answer to the chat form (`say .load ...`) if a future -# build stops forwarding the console name. {name} expands to the lineup name, -# which is what `.load` matches on — the plugin has no id lookup. +# Plugin verbs. The plugin registers load/rethrow with registerRaw:false, so +# the console name (sw_load / css_load) is a SERVER concommand: a connected +# client that execs `sw_load` in its own console gets "unknown command" and it +# is NEVER forwarded upstream, so the plugin never sees it (zero OnLoad in the +# server log). The chat form always round-trips — `say` reaches the server and +# Swiftly's chat hook dispatches it — which is exactly how a real player fires +# these. `/` is the silent prefix so no chat text lands in the clip. {name} +# expands to the lineup name, which is what `.load` matches on (no id lookup). # Escaped closing brace: an unescaped `{name}` inside `${VAR:=...}` ends the -# expansion early and the default silently truncates to "sw_load {name". -: "${NADE_CMD_PREFIX:=sw_}" -: "${NADE_CMD_LOAD:=${NADE_CMD_PREFIX}load {name\}}" -: "${NADE_CMD_THROW:=${NADE_CMD_PREFIX}rethrow}" +# expansion early and the default silently truncates to "load {name". +: "${NADE_CMD_LOAD:=say /load {name\}}" +: "${NADE_CMD_THROW:=say /rethrow}" +# How the throw is produced. `real` (default) makes the render bot throw its OWN +# held grenade with +attack/-attack: cs2 renders a client-initiated throw +# reliably, whereas a server-spawned ghost projectile (`.rethrow`) never draws +# on this headless client -- the alignment was perfect but the clip was frozen +# because the phantom nade was invisible. `command` falls back to NADE_CMD_THROW. +: "${NADE_THROW_MODE:=real}" +# The pin is pulled on +attack and the nade released on -attack; they must land +# on different ticks, so hold briefly between them. +: "${NADE_THROW_HOLD_MS:=350}" # Only the plugin's connect gate is automatic — nothing puts this client on a # team, and a client in team-select has no pawn to teleport. `=` not `:=` so # the api can switch the join off with an explicitly empty value. @@ -182,6 +194,37 @@ cs2_exec_template() { cs2_exec "$tmpl" } +# The weapon name for `use`, so the bot is definitely holding the grenade before +# it presses attack (a stray gun/knife in hand would shoot/slash instead). +nade_weapon() { + case "$(printf '%s' "$NADE_NADE_TYPE" | tr '[:upper:]' '[:lower:]')" in + smoke*) printf 'weapon_smokegrenade' ;; + flash*) printf 'weapon_flashbang' ;; + high*|he|frag|grenade) printf 'weapon_hegrenade' ;; + # molotov is T-side, incendiary is CT-side; the render joins by side. + molo*|incend*|fire*) + [ "${NADE_JOIN_TEAM:-3}" = 2 ] && printf 'weapon_molotov' || printf 'weapon_incgrenade' ;; + decoy*) printf 'weapon_decoy' ;; + *) printf '' ;; + esac +} + +# Throw the bot's OWN held grenade so cs2 renders it. See NADE_THROW_MODE. +nade_do_throw() { + case "$(printf '%s' "$NADE_THROW_MODE" | tr '[:upper:]' '[:lower:]')" in + real|client|attack) + local w; w=$(nade_weapon) + [ -n "$w" ] && { cs2_exec "use $w"; sleep 0.2; } + cs2_exec "+attack" + sleep "$(awk -v ms="$NADE_THROW_HOLD_MS" 'BEGIN{printf "%.3f", ms/1000}')" + cs2_exec "-attack" + ;; + *) + cs2_exec_template "$NADE_CMD_THROW" + ;; + esac +} + console_log_size() { stat -c '%s' "$CS2_CONSOLE_LOG" 2>/dev/null \ || stat -f '%z' "$CS2_CONSOLE_LOG" 2>/dev/null \ @@ -326,7 +369,21 @@ camera_confirmed() { return 1 fi if [ -z "${S_X:-}" ] || [ -z "${S_FX:-}" ]; then - CAMERA_FAIL="GSI reported no position/forward" + # CS2 GSI carries position/forward ONLY for a SPECTATED player, never for + # your own live pawn -- and a render throws from a live pawn (an observer + # has none to teleport). So a live-pawn render can never read its own camera + # back to verify it; confirmed live (health>0, activity=playing, fresh GSI) + # is everything GSI will ever give. + # + # .load is a deterministic server-side teleport to the lineup's exact origin + # AND angle, so once it has settled the camera IS on the lineup by + # construction. Trust it rather than wait out the clock on a reading cs2 + # will never send. + if [ "${WAITED:-0}" -ge "${NADE_CAMERA_TRUST_LOAD_MS:-4000}" ]; then + say "STEP 1: cs2 sends no own-player position; trusting .load placement (alive, playing, ${WAITED}ms settled)" + return 0 + fi + CAMERA_FAIL="settling after .load (cs2 sends no own-player GSI position)" return 1 fi local rc=0 @@ -424,7 +481,7 @@ api_status "status=rendering" "progress=0.3" # --- STEP 4: throw, then wait for the detonation to actually happen ---------- THROW_LOG_OFFSET=$(console_log_size) -cs2_exec_template "$NADE_CMD_THROW" +nade_do_throw now_ms THROW_MS DETONATE_DEADLINE_MS=$(awk -v f="$NADE_FLIGHT_TIME_MS" -v k="$NADE_DETONATE_FACTOR" \