#!/usr/bin/bash
# SPDX-License-Identifier: GPL-2.0-only
#
# Start a Windows helper inside a game's Proton prefix, after the game
# itself is up, as a Steam launch-options wrapper:
#
#     logi-launch %command%
#
# Exists because the sims that matter here publish telemetry into a named
# Windows shared-memory section rather than over UDP: the Assetto Corsa
# family (including EVO), iRacing, RaceRoom, rFactor 2 and Le Mans Ultimate.
# Nothing on the Linux side can read that, and nothing on another machine
# can either, so remote SimHub, a buttkicker or a phone dashboard need a
# Windows process inside the same prefix to forward it.
#
# ORDER IS THE WHOLE POINT. Proton takes the prefix exclusively when it
# launches: it runs `wineserver -w` and waits for any existing wineserver to
# exit first. Start the helper before the game and the game does not start
# at all, it sits waiting for the helper to quit. So this wrapper execs the
# game immediately and starts the helper afterwards, from a background
# subshell, once the game's own wineserver exists.
#
# The helper is run with the SAME wine build the game is using, taken from
# the prefix's own config_info. Plain `wine` from the distribution would be
# a different build against a Proton-made prefix, which triggers prefix
# initialisation (the wine-mono prompt) and risks converting it.
set -uo pipefail

# Which titles publish to shared memory, keyed by Steam appid, with the
# name logi-tf-relay knows them by. A game that is not here needs nothing
# started, so this doubles as the "should I do anything at all" test.
relay_game_for() {
	case "$1" in
	266410)  echo "iracing" ;;
	211500)  echo "raceroom" ;;
	244210)  echo "assetto" ;;
	805550)  echo "acc" ;;
	3058630) echo "ac-evo" ;;
	365960)  echo "rf2" ;;
	2399420) echo "lmu" ;;
	*)       echo "" ;;
	esac
}

# With nothing configured this runs THIS project's own relay, with the game
# worked out from the appid Steam sets. That is the case worth making
# effortless: install the packages, put `logi-launch %command%` in the
# launch options, and simulated TrueForce has its telemetry.
#
# Set LOGI_LAUNCH_EXE to run something else instead, for example a bridge
# that forwards telemetry to SimHub on another machine.
HELPER_EXE="${LOGI_LAUNCH_EXE:-}"
HELPER_ARGS="${LOGI_LAUNCH_ARGS:-}"
# LOGI_LAUNCH_HELPERS runs things AS WELL AS the relay, rather than instead
# of it, as a semicolon-separated list of `exe args`:
#
#   LOGI_LAUNCH_HELPERS='c:\sim-teleport.exe source'
#
# Exists because these two wants are not alternatives. Someone running
# SimHub on a second machine needs its bridge inside the prefix, and still
# wants the rev lights and simulated TrueForce driven here; LOGI_LAUNCH_EXE
# made that an either/or and quietly cost them the second one.
#
# Several readers of the same telemetry is not a conflict: they only read,
# and a Windows file mapping takes any number of readers.
#
# The exe is whatever precedes the first space, so it cannot itself contain
# one. Helpers belong in the prefix's drive_c anyway, which is where the
# documentation puts them and where no path has spaces.
EXTRA_HELPERS="${LOGI_LAUNCH_HELPERS:-}"
# How long to wait for the game's wineserver before giving up, and how long
# to let the game settle afterwards so its maps exist before the first probe.
WAIT_SECONDS="${LOGI_LAUNCH_WAIT:-120}"
SETTLE_SECONDS="${LOGI_LAUNCH_SETTLE:-15}"
LOG="${LOGI_LAUNCH_LOG:-/tmp/logi-launch.log}"
# Shared with the tools this wrapper starts (logi-ffb reads it), so what
# they have to say lands in the same file as the plan that started them
# rather than on Steam's console, where nobody looks (#105).
export LOGI_LAUNCH_LOG="$LOG"

# Where the Windows-side pieces we stage into games live: the dinput8
# escape proxy, the telemetry relay, the recorded init burst.
#
# Candidates in order, because there are several kinds of install and only
# the fixed paths are obvious. LOGI_SHARE_DIR overrides the lot, for a
# layout nobody here has thought of. A distribution that puts nothing
# under /usr, NixOS being the one that found this, keeps the whole package
# together at <prefix>/{bin,share}, so deriving the prefix from this
# script's own location is what makes those installs work; without it the
# proxy is never staged, and on a title where this wrapper also turns raw
# HID on, that costs the game its force feedback as well as its engine
# texture (issue #70). The last candidate is a repo checkout, where these
# files sit beside the script in tools/.
share_file() {
	_sf_self=$(cd "$(dirname "$0")" 2>/dev/null && pwd)
	for _sf_c in "${LOGI_SHARE_DIR:-/nonexistent}/$1" \
		     "$_sf_self/../share/logitech-trueforce/$1" \
		     "/usr/share/logitech-trueforce/$1" \
		     "/usr/local/share/logitech-trueforce/$1" \
		     "$_sf_self/$1"; do
		if [ -r "$_sf_c" ]; then
			printf '%s\n' "$_sf_c"
			return 0
		fi
	done
	return 1
}

say() { printf '[logi-launch] %s\n' "$*" >>"$LOG"; }

# The file name the daemon's session marker takes for a live id: the id
# reduced to plain path characters, the same rule as logi-tf-sim's
# native_session::marker_path_in, so the two always name the same file.
# The dash is last in the class on purpose: anywhere else tr reads it as a
# range, and `_-\n` is a reversed one, which made tr fail, the id come out
# empty, and every marker land as `native.` where the daemon never looked
# (#91, two days of "the daemon ignores the marker").
native_marker_id() { printf '%s' "$1" | tr -c 'A-Za-z0-9._\n-' '-'; }

# First line of every run: which builds are in play. A daemon older than
# the module reads like a driver fault in every log after this one.
say "versions: module $(cat /sys/module/hid_logitech_dd/version 2>/dev/null || echo 'not loaded'), $(logi-tf-sim --version 2>/dev/null || echo 'logi-tf-sim not on PATH'), $(logi-ffb --version 2>/dev/null || echo 'logi-ffb not on PATH')"
# A package update installs the new module but cannot swap the one the
# kernel is running while a wheel is plugged in, so the apps move on and
# the module stays behind until a reload or a reboot. That log line above
# showed "module v0.41.0, logi-tf-sim 0.42.0" for a whole day of tests
# before anyone noticed (#105). Say it in words.
mod_ver=$(cat /sys/module/hid_logitech_dd/version 2>/dev/null || true)
app_ver=$(logi-tf-sim --version 2>/dev/null | awk '{print $2}')
case "$mod_ver" in
v*)
	mod_plain=${mod_ver#v}; mod_plain=${mod_plain%%-*}
	if [ -n "$app_ver" ] && [ "$mod_plain" != "$app_ver" ]; then
		say "note: the loaded module is $mod_ver but the apps are $app_ver. The new module"
		say "note: is installed but not running: unplug the wheel and run"
		say "note:   sudo modprobe -r hid_logitech_dd && sudo modprobe hid_logitech_dd"
		say "note: or reboot. Until then this log describes the old driver."
	fi
	;;
esac

# `logi-launch --game <name> %command%` names the title explicitly, for when
# the appid cannot identify it: a non-Steam shortcut (whose id Steam
# generates locally), a copy bought elsewhere, or a delisted game
# reinstalled from a backup. `logi-wheel --launch-plan --list` prints the
# names.
# --game names the title when the appid cannot identify it. --wheel names
# which wheel to set up for (--wheel dd, --wheel xbox, --wheel g923), which
# matters only when more than one kind is plugged in: the game chooses the
# wheel it uses in its own settings and never tells us, so with a
# direct-drive wheel and a G923 both attached we decline to guess rather
# than risk setting PROTON_ENABLE_HIDRAW on the G923 and costing it force
# feedback.
named_game=""
named_wheel=""
while :; do
	case "${1:-}" in
	--game)  named_game="${2:-}";  shift 2 ;;
	--wheel) named_wheel="${2:-}"; shift 2 ;;
	--list|--help) exec logi-wheel --launch-plan --list ;;
	*) break ;;
	esac
done

# The prefix Steam is launching this game with. Without it there is nothing
# to attach to, so run the game and stay out of the way.
# No prefix means no in-prefix helper, but everything else still applies:
# a native Linux game, or one whose prefix does not exist yet, still wants
# the right HIDRAW setting, the proxy for DirectInput, and the daemon. An
# earlier version returned here and silently dropped all three.
prefix_root="${STEAM_COMPAT_DATA_PATH:-}"
if [ -n "$prefix_root" ] && [ ! -d "$prefix_root/pfx" ]; then
	prefix_root=""
fi

# The Proton build this prefix belongs to. config_info's later lines carry
# paths inside the Proton tree, e.g. .../proton-cachyos-slr/files/share/...,
# so the tree root is whatever sits above files/.
wine_bin=""
if [ -n "$prefix_root" ] && [ -r "$prefix_root/config_info" ]; then
	proton_root=$(sed -n 's#^\(/.*\)/files/.*#\1#p' "$prefix_root/config_info" | head -1)
	[ -n "$proton_root" ] && [ -x "$proton_root/files/bin/wine" ] && \
		wine_bin="$proton_root/files/bin/wine"
fi
# Without the prefix's own wine there is no safe way to run anything inside
# it. Refusing to fall back to the distribution's wine is deliberate: that
# is a different build against a Proton-made prefix, and it prompts to
# install wine-mono and can convert the prefix.
if [ -z "$wine_bin" ]; then
	[ -n "$prefix_root" ] && say "no usable wine for this prefix; skipping in-prefix helpers"
	HELPER_EXE=""
	EXTRA_HELPERS=""
fi

# Ask the app what this game needs on the wheel that is attached. The
# registry behind it is tested and already drives the Setup page; deciding
# any of it again in shell would be a second copy to drift, and the
# per-wheel half is exactly what went wrong when the Setup page described
# the wrong wheel.
plan=""
if command -v logi-wheel >/dev/null 2>&1; then
	set -- "$@"
	wheel_args=""
	[ -n "$named_wheel" ] && wheel_args="--wheel $named_wheel"
	if [ -n "$named_game" ]; then
		# shellcheck disable=SC2086
		plan=$(logi-wheel --launch-plan --game "$named_game" $wheel_args 2>/dev/null)
	else
		# shellcheck disable=SC2086
		plan=$(logi-wheel --launch-plan "${SteamAppId:-${SteamGameId:-0}}" $wheel_args 2>/dev/null)
	fi
fi

# A game we do not know yet is not a dead end. Anyone can describe one in
# ~/.config/logi-wheel/games.conf, a line per appid:
#
#   3058630  hidraw=1 relay=ac-evo tfsim=1
#   1234567  ffb=proxy tfsim=0
#
# A line wins for the keys it STATES; a key it does not state keeps the
# built-in plan's value. Per key rather than wholesale, because these lines
# outlive releases: an old `3058630 hidraw=1`, written before the kernel
# texture merge existed, must not silently turn `texture=merge` off for
# every release after it. To force a key off, state it (`texture=none`,
# `tfsim=0`). Getting a working line into that file is also exactly the
# report needed to add the game properly.
user_conf="${XDG_CONFIG_HOME:-$HOME/.config}/logi-wheel/games.conf"
this_app="${SteamAppId:-${SteamGameId:-0}}"
if [ -r "$user_conf" ]; then
	user_line=$(sed -n "s/^[[:space:]]*${this_app}[[:space:]]\+//p" "$user_conf" | head -1)
	if [ -n "$user_line" ]; then
		say "using your games.conf entry for appid $this_app"
		# plan_get below takes the FIRST match for a key, so the user's
		# tokens go in front of the computed plan: a stated key shadows
		# the built-in value, an unstated one falls through to it.
		# shellcheck disable=SC2086
		plan=$(printf '%s\n' $user_line; printf '%s\n' "$plan")
	fi
fi
plan_get() { printf '%s\n' "$plan" | sed -n "s/^$1=//p" | head -1; }

want_hidraw=$(plan_get hidraw)
want_ffb=$(plan_get ffb)
want_relay=$(plan_get relay)
want_tfsim=$(plan_get tfsim)
want_texture=$(plan_get texture)
# How the rev strip is mapped while the texture merge drives it: the
# bridge's own default is the full bar, so only `shift` needs acting on
# (LOGI_REV_MODE=shift in the bridge's environment, below). The app
# persists the choice in launch.conf and states it in the plan.
want_revleds=$(plan_get revleds)
# The tfsim default is 1 in BOTH places it is read (here and at the start
# below), because a plan that states nothing means no plan was produced at
# all, and an unidentified game still gets the daemon: it idles when nothing
# is streaming, and withholding it would leave every UDP-telemetry title
# unserved (`LaunchPlan::unknown`). This line said 0 while the code did 1,
# so the log contradicted the behaviour for exactly those games.
say "plan: wheel=$(plan_get wheel) game=$(plan_get game) hidraw=${want_hidraw:-unset} ffb=${want_ffb:-native} relay=${want_relay:-none} tfsim=${want_tfsim:-1} texture=${want_texture:-none} revleds=${want_revleds:-bar}"

# TrueForce in an SDK title needs the game to reach the wheel's raw HID
# interface. Set here so nobody has to remember it, and NEVER guessed: on a
# wheel that cannot take it this costs the owner force feedback, so it is
# set only when the plan says this wheel wants it for this game.
# The value is normally `0xVID/0xPID` naming the wheel, because Proton
# matches this variable as a substring against each device's own
# `0xVID/0xPID` (dlls/winebus.sys/main.c). The bare `1` short-circuits that
# test and hands EVERY HID device on the machine to the game: keyboards,
# headsets, other controllers. It is still accepted here, as the fallback
# when no wheel could be named and for anyone who set it by hand.
# Turning this on REMOVES a working force-feedback path, and only Logitech's
# own TrueForce files put one back.
#
# Without it, Proton hands the game an evdev-backed device and force feedback
# works with nothing installed. With it, the game gets the raw HID device
# instead, and this wheel's descriptor has no PID collection, so the older
# Windows force-feedback protocol has nowhere to land. What remains is
# Logitech's SDK, which is what those files are.
#
# So on a prefix without them, setting it costs the owner their force
# feedback and gives nothing back. That is issue #60, where it read as
# "logi-launch gives me no FFB". Checked here rather than in the plan
# because only this wrapper knows which prefix the game is launching with.
shim_dir="$prefix_root/pfx/drive_c/Program Files/Logi/Trueforce"
have_tf_files=0
# Whether this project's range-answering proxy stands in front of the SDK
# library (install-tf-shim.sh --proxy leaves the real one beside it as
# trueforce_real.dll). Raw HID is granted only with something answering
# the SDK's rotation question: that proxy, or the dinput8 escape proxy a
# texture-merge session stages. With Logitech's stock library alone the
# SDK opens the wheel, never gets its answer under Proton, and the game's
# DirectInput steering and force feedback die the moment a session starts
# (an RS50 in ACC, 2026-09-05), while nothing is gained, since that
# library does not stream under Proton without the answer either.
have_tf_proxy=0
if [ -n "$prefix_root" ]; then
	# Any version directory holding the SDK dll counts; the version numbers
	# are whatever that person's G HUB shipped.
	for f in "$shim_dir"/*/trueforce_sdk_x64.dll; do
		[ -f "$f" ] && have_tf_files=1 && break
	done
	for f in "$shim_dir"/*/trueforce_real.dll; do
		[ -f "$f" ] && have_tf_proxy=1 && break
	done
fi

# Nonzero when the plan granted the game raw HID access (an SDK title):
# those sessions can leave the wheel's TrueForce engine started, so they
# get the teardown pair on exit (see send_teardown_pair below).
# Whether a dinput8.dll is this project's escape proxy: it carries its own
# environment-variable names, which no other dinput8 does.
is_our_proxy() {
	[ -f "$1" ] && grep -aq 'LOGI_ESCAPE_RELAY' "$1" 2>/dev/null
}
# The game's own directory, taken from the .exe in the command Steam hands
# us. A native Linux game or a bare test command has none, and then there
# is nowhere to stage the proxy.
game_exe=$(printf '%s\n' "$@" | grep -m1 -e '\.exe$' || true)
game_dir=""
[ -n "$game_exe" ] && game_dir=$(dirname "$game_exe")
# Where the game actually loads its DLLs from. Steam's command names the
# executable in the game's top folder, and for an Unreal title that is a
# small stub which starts the real binary from <Project>/Binaries/Win64;
# a dinput8.dll beside the stub is never loaded (ACC, 2026-09-05: the
# proxy sat in the top folder, the SDK got no rotation answer, and the
# game steered as if the wheel had three times its range). So the proxy
# goes into the top folder and into every shipping-binary folder below.
proxy_dirs=""
if [ -n "$game_dir" ]; then
	proxy_dirs="$game_dir"
	# Read line by line: game folders have spaces in their names, and a
	# word-split find result staged nothing (2026-09-05, second run).
	while read -r d; do
		[ -n "$d" ] || continue
		if ls "$d"/*-Shipping.exe >/dev/null 2>&1; then
			proxy_dirs="$proxy_dirs
$d"
		fi
	done <<-FOUND
	$(find "$game_dir" -maxdepth 3 -type d -path '*/Binaries/Win64' 2>/dev/null)
	FOUND
fi

hidraw_granted=""
case "$want_hidraw" in
"") ;;
0)
	export PROTON_ENABLE_HIDRAW=0
	say "set PROTON_ENABLE_HIDRAW=0"
	;;
*)
	# The SDK's rotation question must be answered or the stock library
	# takes the wheel and gives nothing back (steering and force stop on
	# track: an RS50 in ACC, 2026-09-05). Two things answer it: the dinput8
	# escape proxy this wrapper stages into the game's directory (below,
	# for every native-TrueForce session, texture merge or not), and the
	# SDK proxy install-tf-shim.sh --proxy puts in front of the library.
	# Raw HID is granted only when one of them is in play.
	can_stage_proxy=0
	if [ -n "$proxy_dirs" ] && \
	   [ -r "$(share_file dinput8-escape.dll 2>/dev/null || echo /nonexistent)" ]; then
		can_stage_proxy=1
	fi
	if [ "$have_tf_files" = "1" ] && [ "$have_tf_proxy" = "0" ] && \
	   [ "$can_stage_proxy" = "0" ]; then
		export PROTON_ENABLE_HIDRAW=0
		say "NOT setting PROTON_ENABLE_HIDRAW: Logitech's TrueForce files are in"
		say "this prefix, but nothing here can answer the SDK's rotation question"
		say "(no game directory to stage the dinput8 proxy into, and no SDK proxy"
		say "in the prefix). With raw HID the stock library takes the wheel and"
		say "steering and force feedback stop on track. Force feedback still"
		say "works; the game's own TrueForce does not."
	elif [ "$have_tf_files" = "1" ] || [ -z "$prefix_root" ]; then
		export PROTON_ENABLE_HIDRAW="$want_hidraw"
		hidraw_granted=1
		say "set PROTON_ENABLE_HIDRAW=$want_hidraw"
	else
		say "NOT setting PROTON_ENABLE_HIDRAW: this game wants it, but"
		say "Logitech's TrueForce files are not in this prefix, and turning"
		say "it on without them would take away the force feedback you have"
		say "and give nothing back. Install them from the app's Setup page"
		say "(TrueForce files), then start the game again."
		say "Force feedback still works; the game's own TrueForce does not."
		# Fall back to simulated TrueForce, which is exactly the recipe this
		# title gets on a wheel that cannot receive the native kind. Asking
		# for that answer rather than inventing one keeps the fallback in the
		# registry with everything else.
		if command -v logi-wheel >/dev/null 2>&1; then
			# Ask the same way the first query did. Using the appid
			# here throws away --game, and a title named that way
			# has no usable appid by definition, so the fallback
			# came back as "unknown": simulated TrueForce with no
			# relay, and therefore no telemetry to drive it.
			if [ -n "$named_game" ]; then
				fallback=$(logi-wheel --launch-plan --game "$named_game" --wheel classic 2>/dev/null)
			else
				fallback=$(logi-wheel --launch-plan "$this_app" --wheel classic 2>/dev/null)
			fi
			want_tfsim=$(printf '%s\n' "$fallback" | sed -n 's/^tfsim=//p' | head -1)
			want_relay=$(printf '%s\n' "$fallback" | sed -n 's/^relay=//p' | head -1)
			[ "${want_tfsim:-0}" = "1" ] && \
				say "using simulated TrueForce instead (relay=${want_relay:-none})"
		fi
	fi
	;;
esac

# ONE wheel per invocation, resolved here and used by everything below.
#
# Everything after this point acts on hardware: the merge switch, and the
# TrueForce teardown pair sent once the game is gone. Both used to find
# their own wheel, and on a rig with two they could disagree: the merge was
# armed on EVERY attached direct-drive wheel by a glob, while the teardown
# took whichever hidraw node happened to sort first, ignoring --wheel
# entirely. Then the exit path switched the merge off on every wheel again,
# including ones this session never touched.
#
# Priority: the plan's own hidraw scope (`0x046D/0xC276`), which is the app
# naming the wheel it built the recipe for and already honours --wheel; then
# --wheel on its own, for a plan that granted no hidraw; then every
# direct-drive product id, which is the single-wheel case.
DD_PIDS="C276 C272 C268"
G923_PIDS="C266 C267 C26E"
XBOX_PIDS="C26E"
wheel_pids="$DD_PIDS"
case "$named_wheel" in
"") ;;
dd|direct-drive|rs50|gpro) wheel_pids="$DD_PIDS" ;;
xbox|g923-xbox) wheel_pids="$XBOX_PIDS" ;;
g923|classic) wheel_pids="$G923_PIDS" ;;
*) say "unknown --wheel $named_wheel; treating it as a direct-drive wheel" ;;
esac
case "$want_hidraw" in
0x*/0x*) wheel_pids="${want_hidraw##*/0x}" ;;
esac

# The one sysfs device directory carrying that wheel's attributes, or empty
# when none of the wanted product ids is attached (a G923 always lands here:
# it has no texture merge to arm and no TrueForce engine to tear down).
# Resolve the wheel's sysfs dir, USB device and force-feedback node into
# globals. A function because a TrueForce reset (below) re-enumerates the
# wheel and changes its hid ids, so this runs again after one.
resolve_wheel() {
	wheel_dir=""
	for d in /sys/bus/hid/devices/*046D:C2*; do
		[ -e "$d/wheel_tf_merge" ] || continue
		d_id="${d##*/}"			# 0003:046D:C276.0003
		d_pid="${d_id#*:*:}"; d_pid="${d_pid%%.*}"
		case " $wheel_pids " in
		*" $d_pid "*) wheel_dir="$d"; break ;;
		esac
	done
	[ -n "$wheel_dir" ] && say "acting on the wheel at $wheel_dir"

	# That wheel's force-feedback evdev node, for the helpers that would
	# otherwise take whichever eventN sorted first.
	#
	# --wheel already reaches the plan, the merge switch, the teardown and
	# logi-tf-sim, and stopped here: logi-ffb picked its own device by scanning
	# for the first node that looks like a wheel. On a two-wheel rig that is a
	# coin toss, and the one selection this script had already made was right
	# there. Scoped by USB device (the physical parent both interfaces hang off)
	# rather than by name, which is the same test logi-wheel-core's
	# `discover_wheel_input_under` makes.
	wheel_event=""
	if [ -n "$wheel_dir" ]; then
		# ../.. from the HID device directory is the USB device: the wheel's
		# interfaces (hidraw here, input there) are siblings under it.
		# readlink -f, not cd: /sys/bus/hid/devices/<id> is a symlink, and
		# the shell's cd resolves ".." logically, so "cd <id>/../.." landed
		# in /sys/bus/hid and nothing below matched. The proxy was never
		# aimed on any machine and fell back to its own scan (#105).
		wheel_usb=$(readlink -f "$wheel_dir/../.." 2>/dev/null) || wheel_usb=""
		for e in /sys/class/input/event*; do
			[ -d "$e/device" ] || continue
			[ -n "$wheel_usb" ] || break
			e_real=$(cd "$e/device" 2>/dev/null && pwd -P) || continue
			case "$e_real" in
			"$wheel_usb"/*) ;;
			*) continue ;;
			esac
			# Force feedback, not the wheel's keyboard-like sibling nodes.
			e_ff=$(cat "$e/device/capabilities/ff" 2>/dev/null || true)
			case "$e_ff" in
			""|0) continue ;;
			esac
			wheel_event="${e##*/}"
			break
		done
		[ -n "$wheel_event" ] && say "the wheel's force-feedback node is $wheel_event"
	fi
}
resolve_wheel

# Clear a latched TrueForce engine before the game opens the SDK: a session
# that ended without its teardown (a hard-killed or crashed game) can leave
# the wheel latched so the next SDK session loads but never streams, and
# steering and force go dead on track. Re-enumerating the wheel over USB
# clears it (wheel_reset does that from inside the driver; proven on the
# RS50 in ACC 2026-09-06, where the teardown pair and a full init burst did
# not recover it but a re-enumeration did). Native-TrueForce sessions only
# (raw HID granted), and off with LOGI_TF_RESET=0.
if [ "${LOGI_TF_RESET:-1}" = "1" ] && [ -n "$hidraw_granted" ] && \
   [ -n "$wheel_dir" ] && [ -w "$wheel_dir/wheel_reset" ]; then
	if echo 1 > "$wheel_dir/wheel_reset" 2>/dev/null; then
		say "reset the wheel to clear any latched TrueForce engine (LOGI_TF_RESET=0 to skip)"
		# usb_queue_reset_device is a fast in-place USB reset: it keeps the
		# same hid ids and the sysfs dir may not even disappear, so the
		# signal to wait on is the wheel answering again, not a new dir.
		# Read wheel_range until it comes back (it briefly errors during the
		# reset), bounded so a wheel that never returns does not hang the
		# launch - the game then starts against whatever is there.
		sleep 0.3
		waited=0
		while [ "$waited" -lt 80 ]; do
			resolve_wheel
			[ -n "$wheel_dir" ] && [ -r "$wheel_dir/wheel_range" ] && \
				[ -n "$(cat "$wheel_dir/wheel_range" 2>/dev/null)" ] && break
			sleep 0.1; waited=$((waited + 1))
		done
		if [ -n "$wheel_dir" ]; then
			say "the wheel answered after the reset ($wheel_dir)"
		else
			say "the wheel did not come back in time; starting anyway"
		fi
	fi
fi

# Whether the SDK shim relays the game's own TrueForce to logi-tf-sim.
#
# It is only ever wanted for a G923, which never receives TrueForce from
# Logitech's library at all. A direct-drive wheel is already being streamed
# to by that library, and a second writer on an endpoint carrying one packet
# per millisecond does not share it, it takes turns: the motor ends up
# square-modulated at 500 Hz. The shim works this out from sysfs on its own;
# this only passes on the answer this script already has. A value set by
# hand wins over both.
if [ -z "${LOGI_TF_CAPTURE:-}" ]; then
	if [ -n "$hidraw_granted" ]; then
		# Raw HID means the SDK's own stream already reaches the wheel,
		# so a captured copy must never be replayed on top of it.
		export LOGI_TF_CAPTURE=0
	else
		case "$named_wheel" in
		g923|classic) export LOGI_TF_CAPTURE=1 ;;
		dd|direct-drive|rs50|gpro) export LOGI_TF_CAPTURE=0 ;;
		esac
	fi
fi

# The kernel texture merge: the driver mixes an engine-note texture into
# the game's own TrueForce stream, on the wheel itself. Three pieces, all
# undone when the game exits:
#   - the dinput8 escape proxy staged into the game's directory. It answers
#     the SDK's range getters (without which the SDK's stream never comes
#     up under Proton) and relays the game's RPM telemetry over the Escape
#     channel as localhost UDP.
#   - logi-rpm-bridge, which turns that UDP into wheel_texture_rpm writes.
#   - wheel_tf_merge=1, which tells the driver to render the texture.
# Gated on the TrueForce files exactly like hidraw above: without the SDK
# there is no native stream to merge into. The no-prefix case passes for
# the same reason it does there: the prefix may simply not exist yet.
rpm_bridge_pid=""
# The daemon, when this session had to start it as a child (see start_tf_sim).
tfsim_child_pid=""
# The exact attribute paths this invocation wrote 1 to, one per line, so the
# exit path can undo those and only those.
merge_attrs=""
# The proxy this invocation staged, if any, so the exit path takes it out
# again. A proxy left behind keeps relaying the game's RPM on the next
# start, alongside whatever that start's plan feeds the daemon with, and
# two producers for one game make the lights and the screen take turns.
staged_proxy=""
# A proxy we stage below (any native-TrueForce session with raw HID) is
# refreshed there; one left in a game that gets no proxy is removed here.
if [ -z "$hidraw_granted" ] && [ "$want_texture" != "merge" ] && [ -n "$proxy_dirs" ]; then
	while read -r d; do
		[ -n "$d" ] && is_our_proxy "$d/dinput8.dll" || continue
		if rm -f "$d/dinput8.dll" 2>/dev/null; then
			say "removed a leftover dinput8 escape proxy from $d (this game gets no proxy, so it would only add a second telemetry sender)"
		else
			say "a leftover dinput8 escape proxy is in $d and could not be removed; expect two telemetry senders"
		fi
	done <<-PROXYDIRS
	$proxy_dirs
	PROXYDIRS
fi
# Stage the dinput8 escape proxy into every directory the game may load it
# from (proxy_dirs above) and make Wine load it. Returns non-zero when
# there is nowhere to put it.
stage_escape_proxy() {
	proxy_src=$(share_file dinput8-escape.dll || true)
	if [ -z "$proxy_dirs" ] || [ ! -r "$proxy_src" ]; then
		return 1
	fi
	staged_any=0
	while read -r d; do
		[ -n "$d" ] && [ -d "$d" ] || continue
		# cmp, not a timestamp: Steam validation rewrites files and a
		# stale proxy looks exactly like a missing one.
		if ! cmp -s "$proxy_src" "$d/dinput8.dll" 2>/dev/null; then
			if cp -f "$proxy_src" "$d/dinput8.dll" 2>/dev/null; then
				say "staged dinput8 proxy into $d"
			else
				say "could not copy the dinput8 proxy into $d"
				continue
			fi
		fi
		if is_our_proxy "$d/dinput8.dll"; then
			staged_proxy="$staged_proxy
$d/dinput8.dll"
			staged_any=1
		fi
	done <<-PROXYDIRS
	$proxy_dirs
	PROXYDIRS
	[ "$staged_any" = "1" ] || return 1
	# Merge with whatever the user already set; never clobber it.
	case "${WINEDLLOVERRIDES:-}" in
	*dinput8*) ;;
	*)
		export WINEDLLOVERRIDES="dinput8=n,b${WINEDLLOVERRIDES:+;$WINEDLLOVERRIDES}"
		say "set WINEDLLOVERRIDES=$WINEDLLOVERRIDES"
		;;
	esac
	return 0
}

# For ACC the in-prefix shared-memory helper is the telemetry source, and
# a second sender on the same port made the lights and the screen take
# turns (an RS50 in ACC, 2026-09-05), so the proxy's relay is switched
# off. For AC EVO the proxy IS the telemetry source, on every wheel, so
# there it stays on.
if [ -n "$hidraw_granted" ] && [ "$want_texture" != "merge" ] && \
   [ "$have_tf_proxy" = "0" ]; then
	if stage_escape_proxy; then
		if [ "$want_relay" = "ac-evo" ]; then
			say "dinput8 proxy answers the SDK's rotation question and relays telemetry (this game has no other source)"
		else
			export LOGI_ESCAPE_RELAY=0
			say "dinput8 proxy answers the SDK's rotation question only (LOGI_ESCAPE_RELAY=0)"
		fi
	fi
fi

if [ "$want_texture" = "merge" ] && \
   { [ "$have_tf_files" = "1" ] || [ -z "$prefix_root" ]; }; then
	if ! stage_escape_proxy; then
		say "not staging the dinput8 proxy (no game dir or dll found);"
		say "the texture merge will idle without its RPM feed"
	fi
	bridge_bin=""
	if command -v logi-rpm-bridge >/dev/null 2>&1; then
		bridge_bin="logi-rpm-bridge"
	elif [ -x "$(dirname "$0")/logi-rpm-bridge" ]; then
		bridge_bin="$(dirname "$0")/logi-rpm-bridge"
	fi
	if [ -n "$bridge_bin" ]; then
		# The rev-light mapping is the bridge's to apply: bar is its
		# default, shift is opted into per plan. Set only on the
		# bridge's own environment, not exported to the game.
		#
		# Pointed at the wheel resolved above, rather than left to
		# take the first one sysfs lists: its rev-light target is the
		# neighbour of this attribute, so an unscoped bridge on a
		# two-wheel rig feeds one wheel's texture and lights the
		# other one's strip.
		#
		# Through env with an array, because both settings are
		# optional: an assignment built by expanding a variable is
		# not recognised as one, it is read as the command name.
		bridge_env=()
		[ "$want_revleds" = "shift" ] && bridge_env+=(LOGI_REV_MODE=shift)
		[ -n "$wheel_dir" ] && \
			bridge_env+=("LOGI_RPM_SYSFS=$wheel_dir/wheel_texture_rpm")
		env "${bridge_env[@]}" "$bridge_bin" >>"$LOG" 2>&1 &
		rpm_bridge_pid=$!
		say "started logi-rpm-bridge (pid $rpm_bridge_pid, rev lights ${want_revleds:-bar})"
	else
		say "logi-rpm-bridge is not installed; the texture merge has no RPM feed"
	fi
	# The resolved wheel only, and remembered by path: switching the merge
	# on for every wheel in sysfs armed hardware this game never uses, and
	# a bare "it was on" flag then had the exit path switch it off on all
	# of them, including one another session had armed for itself.
	if [ -z "$wheel_dir" ]; then
		say "no wheel with a texture merge attached; nothing to arm"
	elif [ -w "$wheel_dir/wheel_tf_merge" ] && echo 1 > "$wheel_dir/wheel_tf_merge"; then
		merge_attrs="$wheel_dir/wheel_tf_merge"
		say "texture merge enabled ($wheel_dir/wheel_tf_merge)"
	else
		say "cannot write $wheel_dir/wheel_tf_merge; the texture merge stays off"
	fi
fi

# Work out what to start in the prefix, if the caller did not say.
if [ -z "$HELPER_EXE" ] && [ -n "$prefix_root" ] && [ -n "$wine_bin" ]; then
	game="$want_relay"
	[ "$game" = "none" ] && game=""
	if [ -z "$game" ]; then
		# Deliberately "relay", not "helper": LOGI_LAUNCH_HELPERS may
		# still start something, and a line claiming nothing was needed
		# would be contradicted moments later.
		say "no in-prefix relay needed for this game"
	else
		# Stage or refresh the relay from the packaged master copy. The
		# prefix copy is a snapshot from whenever it was installed, and a
		# stale one fails in ways that look like telemetry problems: an
		# old build exits instead of waiting for the game, or does not
		# know the game id at all (#59). cmp, not a timestamp, same
		# reason as the dinput8 proxy above.
		relay_src=$(share_file logi-tf-relay.exe || true)
		relay_dst="$prefix_root/pfx/drive_c/logi-tf-relay.exe"
		if [ -r "$relay_src" ] && \
		   ! cmp -s "$relay_src" "$relay_dst" 2>/dev/null; then
			if cp -f "$relay_src" "$relay_dst" 2>/dev/null; then
				say "staged logi-tf-relay into the prefix"
			else
				say "could not copy logi-tf-relay into the prefix"
			fi
		fi
		if [ ! -f "$relay_dst" ]; then
			say "this game needs logi-tf-relay in its prefix and it is not there."
			say "Install it from the app's Setup page (Install relay), then start the game again."
			game=""
		else
			HELPER_EXE='c:\logi-tf-relay.exe'
			HELPER_ARGS="--game $game"
		fi
	fi
fi

# The relay only carries telemetry OUT of the prefix. Something has to
# read it and drive the wheel, and that is logi-tf-sim. Leaving it to
# the user is the remaining manual step in an otherwise automatic
# chain, and forgetting it looks exactly like the relay not working:
# the game runs, the wheel behaves normally, and the rev lights stay
# dark. Started only if it is not already up, and left running, since
# it idles when nothing is streaming.
# The daemon's session marker: while raw HID is granted to a title whose
# own TrueForce reaches the wheel, the daemon must not synthesise haptics
# for it (it would play over the real thing) and runs for the rev lights
# and the screen only. The daemon's own rule covers a direct-drive wheel;
# the marker is what tells it on the G923 Xbox edition, and it reaches a
# daemon that is already running. Same directory as the daemon's stream
# lease, same fallbacks (logi-tf-sim's native_session and lease modules).
# Written whenever raw HID is granted, whether or not this launch is the
# one starting the daemon: the marker describes the session, not that.
native_marker=""
if [ -n "${LOGI_WHEEL_RUNTIME_DIR:-}" ]; then
	marker_dir="$LOGI_WHEEL_RUNTIME_DIR"
elif [ -n "${XDG_RUNTIME_DIR:-}" ]; then
	marker_dir="$XDG_RUNTIME_DIR/logi-wheel"
else
	marker_dir="${TMPDIR:-/tmp}/logi-wheel-$(id -u)"
fi
if [ -n "$want_relay" ] && [ "$want_relay" != "none" ]; then
	# A stale marker from a launcher that died would keep the haptics
	# off for this title; it is ours to clear before deciding afresh.
	safe_id=$(native_marker_id "$want_relay")
	if [ -z "$safe_id" ]; then
		say "could not derive a session marker name from '$want_relay'; the daemon may play its own texture over the game's"
		want_relay_marker=0
	else
		want_relay_marker=1
		rm -f "$marker_dir/native.$safe_id" 2>/dev/null
	fi
	if [ -n "$hidraw_granted" ] && [ "$want_relay_marker" = 1 ]; then
		mkdir -p "$marker_dir" 2>/dev/null
		if : > "$marker_dir/native.$safe_id" 2>/dev/null; then
			native_marker="$marker_dir/native.$safe_id"
			say "marked this session's TrueForce as the game's own ($native_marker); the daemon drives lights and screen only"
		else
			say "could not write $marker_dir/native.$safe_id; the daemon may play its own texture over the game's"
		fi
	fi
fi

if [ "${LOGI_LAUNCH_TF_SIM:-1}" = "1" ] && [ "${want_tfsim:-1}" = "1" ]; then
	# Both at once is a recipe only a hand-written games.conf line can
	# ask for, and it works: the two read the same relay port, so
	# whichever has it forwards to the other. Worth one line anyway,
	# because the daemon's own log will name a port nobody configured.
	if [ -n "$rpm_bridge_pid" ]; then
		say "note: logi-rpm-bridge has the relay port for this session and forwards it to"
		say "note: logi-tf-sim, so both are fed. The bridge drives the texture merge and the"
		say "note: rev lights; the daemon adds its synthesized engine note on top."
	fi
	# Steam runs the launch command under a subreaper, so anything this
	# wrapper starts and leaves behind is handed to Steam when the game
	# exits, and Steam then reports the game as still running until that
	# process dies: a daemon started here kept DiRT Rally 2.0 "running"
	# after exit until it was killed by hand (#105). setsid does not
	# escape a subreaper; a transient user service does, because systemd
	# becomes its parent. Where systemd-run is unavailable (a Flatpak
	# Steam, a system without a user manager), the daemon runs as a child
	# of this wrapper instead and is stopped when the game exits, which
	# is the honest alternative: a later session starts it again.
	start_tf_sim() {
		if command -v systemd-run >/dev/null 2>&1 && \
		   systemd-run --user --quiet --collect \
			--description="logi-tf-sim (started by logi-launch)" \
			--property=StandardOutput=append:"$LOG" \
			--property=StandardError=append:"$LOG" \
			env "$@" logi-tf-sim 2>/dev/null; then
			say "logi-tf-sim runs as a user service, outside Steam's process tree"
			return 0
		fi
		setsid env "$@" logi-tf-sim >>"$LOG" 2>&1 </dev/null &
		tfsim_child_pid=$!
		say "logi-tf-sim runs as a child of this session (no user service manager here); it stops when the game exits"
	}
	# A daemon left running across an update keeps serving the old build
	# for as long as nobody stops it, and a run that then behaves like the
	# old build reads as a driver fault (#91). A replaced binary shows up
	# in /proc as "(deleted)", and a copy elsewhere on PATH as a different
	# path; either means the running daemon is not the installed one.
	if pgrep -x logi-tf-sim >/dev/null 2>&1; then
		installed=$(readlink -f "$(command -v logi-tf-sim 2>/dev/null)" 2>/dev/null || true)
		stale_daemon=0
		for pid in $(pgrep -x logi-tf-sim); do
			exe=$(readlink "/proc/$pid/exe" 2>/dev/null) || continue
			case "$exe" in *' (deleted)') stale_daemon=1 ;; esac
			if [ -n "$installed" ] && [ "${exe% (deleted)}" != "$installed" ]; then
				stale_daemon=1
			fi
		done
		if [ "$stale_daemon" -eq 1 ]; then
			say "logi-tf-sim is running from a build that is no longer the installed one; stopping it so this session gets the current build"
			pkill -x logi-tf-sim 2>/dev/null || true
			sleep 1
		fi
	fi
	if pgrep -x logi-tf-sim >/dev/null 2>&1; then
		say "logi-tf-sim is already running"
		# It was started for some other session, possibly aimed at the
		# other wheel. Say so rather than let a named wheel look like it
		# was honoured when the running daemon never saw it.
		if [ -n "$named_wheel" ]; then
			say "note: it was already running, so --wheel $named_wheel did not reach it."
			say "note: stop it and start the game again to aim it at that wheel."
		fi
	elif command -v logi-tf-sim >/dev/null 2>&1; then
		# The daemon drives ONE wheel and has its own picker, which
		# defaults to preferring a G923. Naming a wheel here and leaving
		# the daemon to its own default would be two answers to one
		# question, and on a two-wheel rig they would disagree: the game
		# on the direct-drive wheel, the haptics on the G923.
		if [ -n "$named_wheel" ]; then
			say "starting logi-tf-sim, aimed at $named_wheel"
			start_tf_sim LOGI_TF_SIM_WHEEL="$named_wheel"
		else
			say "starting logi-tf-sim"
			start_tf_sim
		fi
	else
		say "logi-tf-sim is not installed; the rev lights and simulated"
		say "TrueForce need it. Install the logi-wheel package."
	fi
fi

# Everything to run inside the prefix, as parallel exe/args arrays. Kept
# apart rather than joined into one string so an exe path containing spaces
# still works, which is what quoting "$HELPER_EXE" bought before this
# supported more than one.
helper_exes=()
helper_argv=()
if [ -n "$HELPER_EXE" ]; then
	helper_exes+=("$HELPER_EXE")
	helper_argv+=("$HELPER_ARGS")
fi
if [ -n "$EXTRA_HELPERS" ]; then
	# `;` between helpers, first space inside one separating exe from args.
	saved_ifs="$IFS"
	IFS=';'
	for entry in $EXTRA_HELPERS; do
		# Leading and trailing blanks, so a list can be written spaced out.
		entry="${entry#"${entry%%[![:space:]]*}"}"
		entry="${entry%"${entry##*[![:space:]]}"}"
		[ -z "$entry" ] && continue
		exe="${entry%% *}"
		if [ "$entry" = "$exe" ]; then
			args=""
		else
			args="${entry#* }"
		fi
		helper_exes+=("$exe")
		helper_argv+=("$args")
	done
	IFS="$saved_ifs"
fi

helper_group_pid=""
# Where the in-prefix helpers record their wine pids for the exit cleanup.
# Per invocation, so two games running at once never read each other's.
HELPER_PIDS="${TMPDIR:-/tmp}/logi-launch-helpers.$$"
if [ ${#helper_exes[@]} -gt 0 ] && [ -n "$prefix_root" ] && [ -n "$wine_bin" ]; then
(
	# Wait for the game to take the prefix. Keying on the wineserver rather
	# than the game process on purpose: the game runs inside Steam's
	# pressure-vessel container and its process is not visible from here,
	# while the wineserver is.
	waited=0
	while [ "$waited" -lt "$WAIT_SECONDS" ]; do
		if pgrep -x wineserver >/dev/null 2>&1; then
			break
		fi
		sleep 1
		waited=$((waited + 1))
	done
	if [ "$waited" -ge "$WAIT_SECONDS" ]; then
		say "game's wineserver never appeared after ${WAIT_SECONDS}s; not attaching"
		exit 0
	fi
	# Let the game finish creating its shared-memory sections. Attaching
	# during startup is harmless but the first probes would find nothing.
	sleep "$SETTLE_SECONDS"

	# One wine process each, started together. Waiting for the first to
	# exit before starting the second would mean the second never runs:
	# these are long-lived bridges that stay up for the whole session.
	#
	# The sync flags must match the wineserver Proton started for the
	# game, or wine refuses to join it and the helper dies before its
	# first instruction ("Server is running with WINEFSYNC but this
	# process is not", #59). Proton enables fsync and esync unless the
	# user opted out, so mirror exactly that.
	helper_fsync=1; helper_esync=1
	[ "${PROTON_NO_FSYNC:-0}" = "1" ] && helper_fsync=0
	[ "${PROTON_NO_ESYNC:-0}" = "1" ] && helper_esync=0
	i=0
	while [ "$i" -lt ${#helper_exes[@]} ]; do
		exe="${helper_exes[$i]}"
		args="${helper_argv[$i]}"
		(
			say "starting $exe${args:+ $args} in $prefix_root/pfx"
			WINEPREFIX="$prefix_root/pfx" WINEDEBUG="${WINEDEBUG:--all}" \
				WINEFSYNC="$helper_fsync" WINEESYNC="$helper_esync" \
				"$wine_bin" "$exe" $args >>"$LOG" 2>&1 &
			wine_pid=$!
			# Recorded so the exit cleanup can end this helper by pid.
			# A group kill is not available here: without job control
			# these jobs share the wrapper's process group, which the
			# game is in too.
			echo "$wine_pid" >> "$HELPER_PIDS"
			wait "$wine_pid"
			# Named, because "helper exited" says nothing about which one
			# when two are running.
			say "$exe exited"
		) &
		i=$((i + 1))
	done
	wait
) &
helper_group_pid=$!
fi

# A DirectInput title drives force feedback through the older Windows path,
# which needs the logi-ffb proxy in front of the game. Chained rather than
# asked of the user, so one prepend really is enough.
if [ "$want_ffb" = "proxy" ] && command -v logi-ffb >/dev/null 2>&1; then
	say "launching through logi-ffb for DirectInput force feedback"
	# Aimed at the wheel resolved above. Without this the proxy runs its
	# own scan and takes the first eventN that looks like a wheel, which
	# on a two-wheel rig can be the one this session is not setting up:
	# the game would then push force into the other wheel. A value set by
	# hand still wins, same as everywhere else here.
	if [ -n "$wheel_event" ] && [ -z "${LOGI_FFB_DEVICE:-}" ]; then
		export LOGI_FFB_DEVICE="$wheel_event"
		say "aiming logi-ffb at $wheel_event"
	fi
	set -- logi-ffb "$@"
fi

# The captured 0x04+0x03 teardown pair, sent to the wheel's interface-2
# hidraw node once the game is gone. An SDK title that exits (or is
# killed) mid-stream leaves the wheel's TrueForce engine started and fed
# by nobody - the abort-capture state that whines until power cycle -
# while every clean Windows session ends with exactly this pair, then
# silence. python3 rather than shell printf, deliberately: the packets
# are raw 64-byte binaries full of NUL bytes with a 2 ms gap between
# them, and the interface-2 lookup is a sysfs walk (the same one
# logi-tf-init.py's find_tf_hidraw does, keyed on bInterfaceNumber so it
# survives hidraw renumbering). All of that is exact and readable in
# python; as printf escapes it needs a NUL-safe printf, a fractional
# sleep, and a realpath chain that differ across shells. logi-tf-init.py
# already makes python3 part of this tool set.
#
# Scoped to the wheel resolved at the top: the interface-2 node must belong
# to the SAME wheel this session set up, or a two-wheel rig gets its other
# wheel's TrueForce engine stopped while the one that was actually played
# keeps whining. WHEEL_DIR pins it to one USB device; WHEEL_PIDS is the
# fallback when the driver exposed no attribute directory to resolve. A
# wheel with no direct-drive product id (a G923) has no TrueForce engine to
# tear down, so nothing matches and nothing is sent.
send_teardown_pair() {
	if ! command -v python3 >/dev/null 2>&1; then
		say "python3 not found; cannot send the wheel teardown pair"
		return 0
	fi
	# Direct-drive only. The pair is this family's TrueForce engine
	# protocol; a G923 speaks the classic one and has no such engine, so
	# there is nothing here to send it.
	case " $wheel_pids " in
	*" C276 "*|*" C272 "*|*" C268 "*) ;;
	*) say "the wheel for this session has no TrueForce engine; teardown pair skipped"
	   return 0 ;;
	esac
	WHEEL_DIR="$wheel_dir" WHEEL_PIDS="$wheel_pids" python3 - >>"$LOG" 2>&1 <<'PYEOF'
import glob, os, time

# The USB device the resolved wheel's attributes hang off, so siblings of
# THAT device are the only candidates: /sys/bus/hid/devices/<id> is a link
# into .../<usb device>/<interface>/<hid id>.
def wanted_usb_device():
    d = os.environ.get("WHEEL_DIR", "")
    if not d:
        return None
    try:
        return os.path.realpath(os.path.join(d, "..", ".."))
    except OSError:
        return None

def find_tf_hidraw():
    usb = wanted_usb_device()
    pids = [p for p in os.environ.get("WHEEL_PIDS", "").upper().split() if p]
    for h in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
        dev = os.path.join(h, "device")
        try:
            iface = os.path.realpath(os.path.join(dev, ".."))
            if usb:
                if os.path.realpath(os.path.join(iface, "..")) != usb:
                    continue
            else:
                hid_id = ""
                for line in open(os.path.join(dev, "uevent")):
                    if line.startswith("HID_ID="):
                        hid_id = line.strip().split("=", 1)[1]
                up = hid_id.upper()
                if "046D" not in up or not any(p in up for p in pids):
                    continue
            bnum = open(os.path.join(iface, "bInterfaceNumber")).read().strip()
            if int(bnum, 16) == 2:
                return "/dev/" + os.path.basename(h)
        except (OSError, ValueError):
            continue
    return None

node = find_tf_hidraw()
if not node:
    print("[logi-launch] no direct-drive interface-2 hidraw; teardown pair skipped")
    raise SystemExit(0)
try:
    fd = os.open(node, os.O_WRONLY)
except OSError as e:
    print("[logi-launch] cannot open %s (%s); teardown pair skipped" % (node, e))
    raise SystemExit(0)
# 0x04 stop/clear, then 0x03 arm, ~2 ms apart like the captures. The
# sequence byte (byte 5) is left 0: control packets are accepted with
# any sequence, and the SDK's own counter is unknowable from out here.
for cmd in (0x04, 0x03):
    pkt = bytearray(64)
    pkt[0] = 0x01
    pkt[4] = cmd
    os.write(fd, bytes(pkt))
    time.sleep(0.002)
os.close(fd)
print("[logi-launch] sent TrueForce teardown pair to %s" % node)
PYEOF
}

# Experimental (LOGI_TF_REARM=1): reset-and-rearm the wheel's TrueForce
# engine before the game starts. A session that died without its teardown
# reaching the wheel (hard-killed game, crash) leaves the next SDK session
# opening successfully but never streaming; today only a power cycle
# recovers it. This replays what a clean boot gives the wheel: the
# captured 0x04+0x03 teardown pair, then G HUB's 68-packet init twice
# (tools/tf-init.bin, generated from libtrueforce's tf_init_data.h).
# Off by default until a hardware A/B proves it replaces the power cycle;
# harmless bytes either way - a healthy wheel gets the same init dupes a
# real session start sends.
send_tf_rearm() {
	rearm_blob=""
	rearm_blob=$(share_file tf-init.bin || true)
	if [ -z "$rearm_blob" ] || ! command -v python3 >/dev/null 2>&1; then
		say "TF re-arm requested but tf-init.bin or python3 missing; skipped"
		return 0
	fi
	# Same wheel scoping as send_teardown_pair, and for the same reason:
	# these bytes must reach the wheel this session is for, not whichever
	# one sorts first.
	REARM_BLOB="$rearm_blob" WHEEL_DIR="$wheel_dir" WHEEL_PIDS="$wheel_pids" \
		python3 - >>"$LOG" 2>&1 <<'PYEOF'
import glob, os, time

def wanted_usb_device():
    d = os.environ.get("WHEEL_DIR", "")
    if not d:
        return None
    try:
        return os.path.realpath(os.path.join(d, "..", ".."))
    except OSError:
        return None

def find_tf_hidraw():
    usb = wanted_usb_device()
    pids = [p for p in os.environ.get("WHEEL_PIDS", "").upper().split() if p]
    for h in sorted(glob.glob("/sys/class/hidraw/hidraw*")):
        dev = os.path.join(h, "device")
        try:
            iface = os.path.realpath(os.path.join(dev, ".."))
            if usb:
                if os.path.realpath(os.path.join(iface, "..")) != usb:
                    continue
            else:
                hid_id = ""
                for line in open(os.path.join(dev, "uevent")):
                    if line.startswith("HID_ID="):
                        hid_id = line.strip().split("=", 1)[1]
                up = hid_id.upper()
                if "046D" not in up or not any(p in up for p in pids):
                    continue
            if int(open(os.path.join(iface, "bInterfaceNumber")).read().strip(), 16) == 2:
                return "/dev/" + os.path.basename(h)
        except (OSError, ValueError):
            continue
    return None

node = find_tf_hidraw()
if not node:
    print("[logi-launch] TF re-arm: no interface-2 hidraw node; skipped")
    raise SystemExit(0)
blob = open(os.environ["REARM_BLOB"], "rb").read()
pkts = [blob[i:i+64] for i in range(0, len(blob), 64)]
fd = os.open(node, os.O_WRONLY)
stop = bytearray(64); stop[0] = 0x01; stop[4] = 0x04
arm = bytearray(64); arm[0] = 0x01; arm[4] = 0x03; arm[5] = 0x01
os.write(fd, bytes(stop)); time.sleep(0.002); os.write(fd, bytes(arm))
time.sleep(0.002)
for _ in range(2):
    for p in pkts:
        os.write(fd, p)
os.close(fd)
print("[logi-launch] TF re-arm: teardown pair + %dx2 init packets to %s" % (len(pkts), node))
PYEOF
}
if [ "${LOGI_TF_REARM:-0}" = "1" ] && [ -n "$hidraw_granted" ]; then
	send_tf_rearm
fi

# With the texture merge armed or raw HID granted there is teardown to do
# after the game exits, so the game runs as a child rather than by exec:
# the bridge dies with the session, the merge switches off so a later
# non-SDK session does not inherit a texture with no RPM behind it, and
# the wheel gets the teardown pair an SDK title never sends under Proton.
# Everything else keeps the historical exec, which leaves no wrapper
# process behind.
if [ -n "$rpm_bridge_pid" ] || [ -n "$merge_attrs" ] || \
   [ -n "$hidraw_granted" ] || [ -n "$helper_group_pid" ] || \
   [ -n "$tfsim_child_pid" ]; then
	session_cleanup() {
		[ -n "$native_marker" ] && rm -f "$native_marker" 2>/dev/null
		[ -n "$rpm_bridge_pid" ] && kill "$rpm_bridge_pid" 2>/dev/null
		if [ -n "$tfsim_child_pid" ]; then
			kill "$tfsim_child_pid" 2>/dev/null
			say "logi-tf-sim stopped with the session (it ran as a child of this wrapper)"
		fi
		if [ -n "$staged_proxy" ]; then
			while read -r f; do
				[ -n "$f" ] && is_our_proxy "$f" && rm -f "$f" 2>/dev/null && \
					say "dinput8 proxy removed from $(dirname "$f")"
			done <<-STAGED
			$staged_proxy
			STAGED
		fi
		# The in-prefix helper is a wine process, and wine keeps the
		# prefix's wineserver alive for as long as one runs. Leaving it
		# behind therefore leaves Steam believing the game is still
		# running: the library entry stays green until the session is
		# killed by hand (#59). Kill the whole helper job, not just its
		# supervisor, so the wine process goes with it.
		if [ -n "$helper_group_pid" ]; then
			kill "$helper_group_pid" 2>/dev/null
			if [ -r "$HELPER_PIDS" ]; then
				while read -r hp; do
					[ -n "$hp" ] && kill "$hp" 2>/dev/null
				done < "$HELPER_PIDS"
			fi
			rm -f "$HELPER_PIDS"
			say "in-prefix helper stopped"
		fi
		# Exactly the attributes this invocation wrote 1 to. Undoing
		# the glob instead switched the merge off on wheels this
		# session never armed, including one another session had just
		# armed for itself.
		if [ -n "$merge_attrs" ]; then
			while read -r d; do
				[ -n "$d" ] || continue
				[ -w "$d" ] && echo 0 > "$d"
				say "texture merge disabled ($d)"
			done <<-MERGEATTRS
			$merge_attrs
			MERGEATTRS
		fi
		# The pair is for sessions that could have left the wheel's
		# TrueForce engine started: raw HID, the texture merge, the
		# bridge or an in-prefix helper. A session that ran as a child
		# only because it had to start the daemon (no user service
		# manager) had none of those, and gets no packets it never asked
		# for.
		if [ -n "$hidraw_granted" ] || [ -n "$merge_attrs" ] || \
		   [ -n "$rpm_bridge_pid" ] || [ -n "$helper_group_pid" ]; then
			send_teardown_pair
		fi
	}
	trap session_cleanup EXIT
	# Signal hardening: a bare "$@" would make SIGTERM/SIGINT hit only
	# this wrapper while the game keeps running (bash defers signals
	# until a foreground child exits). Run the game in the background,
	# forward TERM/INT to it, and wait. The second wait matters: a
	# trapped signal interrupts the first wait before the game has
	# actually exited, and bash then reports the game's real status
	# from the re-wait (statuses of reaped background jobs are
	# remembered). The EXIT trap above still runs at exit, after
	# cleanup here, composing with this.
	"$@" &
	game_pid=$!
	trap 'kill -TERM "$game_pid" 2>/dev/null' TERM INT
	wait "$game_pid"
	wait "$game_pid"
	exit $?
fi

exec "$@"
