toolchain: mingw container build, SEH shim, ABI rewrite, run scripts

This commit is contained in:
simplysanatori committed 2026-10-28 02:56:44 -04:00
1 parent 02a7210b4c
commit 4b85f24bb4
7 files changed
+358

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
FROM registry.fedoraproject.org/fedora:43
RUN dnf -y install --setopt=install_weak_deps=False \
mingw32-gcc mingw32-gcc-c++ mingw32-winpthreads-static mingw32-headers mingw32-crt \
mingw32-binutils meson ninja-build glslang git python3 make cmake file diffutils patch which findutils \
&& dnf clean all
WORKDIR /work
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Reproducible mingw build of the TRL VR mod (DXVK fork + d3d9 proxy + launcher).
# usage: build.sh <source-tree> <output-dir> [dxvk|proxy|all]
# source-tree: TRLVR layout (src/, dxvk-trlvr/, third_party/), mounted read-only.
# output-dir : receives dist/{d3d9.dll,d3d9_dxvk.dll,trlvr_launcher.exe}, work/, logs/
# Env: FIXES=<dir with dxvk-*.patch / proxy-*.patch applied to the in-container copy>
# JOBS, EXTRA_CXXFLAGS, EXTRA_LIBS. Needs podman and image legendframe-mingw
# (podman build -t legendframe-mingw <this dir>).
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
SRC=$(readlink -f "${1:?source tree}"); OUT=$(readlink -m "${2:?output dir}"); WHAT=${3:-all}
mkdir -p "$OUT"
FIXMNT=()
if [ -n "${FIXES:-}" ]; then FIXMNT=(-v "$(readlink -f "$FIXES"):/fixes:ro,Z"); fi
podman image exists localhost/legendframe-mingw || podman build -t legendframe-mingw "$HERE"
exec podman run --rm -e JOBS="${JOBS:-}" -e WHAT="$WHAT" -e EXTRA_CXXFLAGS="${EXTRA_CXXFLAGS:-}" -e EXTRA_LIBS="${EXTRA_LIBS:-}" \
-v "$SRC:/src:ro,Z" -v "$OUT:/out:Z" -v "$HERE/shims:/shims:ro,Z" -v "$HERE/container-build.sh:/container-build.sh:ro,Z" \
"${FIXMNT[@]}" localhost/legendframe-mingw bash /container-build.sh
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# Runs INSIDE the legendframe-mingw container. Invoked by build.sh.
# /src read-only source tree (TRLVR layout: src/, dxvk-trlvr/, third_party/)
# /out writable output dir
# /shims read-only toolchain/shims
# /fixes optional read-only dir of *.patch files applied to the COPY (patch -p1)
# Env: JOBS, WHAT=all|dxvk|proxy
set -euo pipefail
JOBS=${JOBS:-$(nproc)}
WHAT=${WHAT:-all}
W=/out/work
mkdir -p "$W" /out/dist /out/logs
ts() { date +%s; }
if [ "$WHAT" = all ] || [ "$WHAT" = dxvk ]; then
t0=$(ts)
echo "=== DXVK: copy tree"
rm -rf "$W/dxvk-trlvr" "$W/b-dxvk"
cp -r /src/dxvk-trlvr "$W/dxvk-trlvr"; rm -rf "$W/dxvk-trlvr/build-win32"
for p in /fixes/dxvk-*.patch; do [ -f "$p" ] && (cd "$W/dxvk-trlvr" && patch -p1 < "$p"); done || true
cd "$W/dxvk-trlvr"
# Same options as build_dxvk.bat (d3d9 only), mingw cross file, asserts off.
meson setup --backend ninja --cross-file build-win32.txt --buildtype release \
-Denable_d3d8=false -Denable_d3d10=false -Denable_d3d11=false -Denable_dxgi=false \
-Db_ndebug=if-release "$W/b-dxvk" .
ninja -C "$W/b-dxvk" -j"$JOBS"
i686-w64-mingw32-strip -o /out/dist/d3d9_dxvk.dll "$W/b-dxvk/src/d3d9/d3d9.dll"
echo "DXVK_SECONDS=$(( $(ts) - t0 ))"
fi
if [ "$WHAT" = all ] || [ "$WHAT" = proxy ]; then
t0=$(ts)
echo "=== proxy + launcher: copy tree"
rm -rf "$W/src" "$W/obj" "$W/third_party"; mkdir -p "$W/obj" "$W/third_party"
cp -r /src/src "$W/src"
for d in openvr openxr imgui; do [ -d /src/third_party/$d ] && cp -r /src/third_party/$d "$W/third_party/"; done
for p in /fixes/proxy-*.patch; do [ -f "$p" ] && (cd "$W" && patch -p1 < "$p"); done || true
cd "$W"
# Struct-returning OpenVR virtuals: MSVC and GCC disagree on thiscall+sret, so route the
# calls through trlvr_abi::call_sret (see shims/seh_compat.h). Mechanical rewrite, diffed.
mkdir -p "$W/abi-orig"; rm -rf "$W/abi-orig/src"; cp -r "$W/src" "$W/abi-orig/src"
SRET='GetEyeToHeadTransform|GetProjectionMatrix|GetSeatedZeroPoseToStandingAbsoluteTrackingPose|GetRawZeroPoseToStandingAbsoluteTrackingPose|GetMatrix34TrackedDeviceProperty|GetHiddenAreaMesh|GetCurrentFadeColor|GetHeadsetViewMode'
find "$W/src" -name '*.cpp' -o -name '*.h' | xargs sed -i -E \
"s/\b([A-Za-z_][A-Za-z0-9_]*)->($SRET)\(\)/trlvr_abi::call_sret(\1, \&std::remove_pointer_t<decltype(\1)>::\2)/g; \
s/\b([A-Za-z_][A-Za-z0-9_]*)->($SRET)\(/trlvr_abi::call_sret(\1, \&std::remove_pointer_t<decltype(\1)>::\2, /g"
(cd "$W" && diff -ru abi-orig/src src > /out/abi-fix.diff) || true
echo "ABI rewrite sites: $(grep -c '^+.*call_sret' /out/abi-fix.diff || true)"
# Source list, defines and include dirs come straight from build.bat.
SRCS=$(grep -E '^set SRC=%SRC% "%ROOT%[^"]+\.cpp"' /src/build.bat | sed -E 's/.*%ROOT%([^"]+)"/\1/; s#\\#/#g')
IMGUI_SRCS=""
if [ -d "$W/third_party/imgui" ]; then
for f in $(grep -oE 'for %%F in \(([a-z0-9_ ]+)\)' /src/build.bat | sed -E 's/.*\((.*)\)/\1/'); do IMGUI_SRCS="$IMGUI_SRCS third_party/imgui/$f.cpp"; done
fi
DEFS=$(grep -E '^set CFLAGS=' /src/build.bat | grep -oE '/D[A-Za-z_0-9]+' | sed 's#^/D#-D#' | tr '\n' ' ')
INC="-I$W/third_party/openvr/headers -I$W/third_party/openxr/include -I$W/third_party/imgui -I/src/dxvk-trlvr/src/d3d9 -I/src/dxvk-trlvr/include/vulkan/include"
# -include seh_compat.h: MSVC __try/__except shim + _ReturnAddress + sret helper; sources stay unmodified.
CXXFLAGS="-std=gnu++17 -O2 $DEFS -fno-strict-aliasing -include /shims/seh_compat.h $INC ${EXTRA_CXXFLAGS:-}"
echo "sources: $(echo $SRCS $IMGUI_SRCS | wc -w); defs: $DEFS"
objs=(); pids=()
for s in $SRCS $IMGUI_SRCS; do
s=${s%.cpp}
o="$W/obj/$(echo $s | tr / _).o"; objs+=("$o")
f="$W/$s.cpp"; [ -f "$f" ] || f="$W/src/${s#src/}.cpp"
i686-w64-mingw32-g++ $CXXFLAGS -c "$f" -o "$o" > "${o%.o}.log" 2>&1 &
pids+=($!)
while [ "$(jobs -r | wc -l)" -ge "$JOBS" ]; do sleep 0.2; done
done
rc=0; for p in "${pids[@]}"; do wait "$p" || rc=1; done
cat "$W"/obj/*.log | grep -E "error|Error" | head -80 || true
[ $rc = 0 ] || { echo "PROXY COMPILE FAILED"; exit 1; }
# GNU ld wants a name after a bare LIBRARY keyword (MSVC link does not).
sed 's/^LIBRARY[[:space:]]*$/LIBRARY "d3d9.dll"/' "$W/src/proxy/d3d9.def" > "$W/d3d9.mingw.def"
XRLIB=""; [ -f "$W/third_party/openxr/lib/win32/openxr_loader.lib" ] && XRLIB="$W/third_party/openxr/lib/win32/openxr_loader.lib"
# Not linking d3d9: this DLL is d3d9.dll. --kill-at = undecorated stdcall exports like MSVC.
i686-w64-mingw32-g++ -shared -static -static-libgcc -static-libstdc++ -s \
-o /out/dist/d3d9.dll "${objs[@]}" "$W/d3d9.mingw.def" $XRLIB \
-Wl,--kill-at -Wl,--enable-stdcall-fixup -Wl,--subsystem,windows -Wl,--large-address-aware \
-Wl,-Map,/out/dist/d3d9.map \
-ldxguid -luser32 -lkernel32 -ladvapi32 -lgdi32 -ldwmapi ${EXTRA_LIBS:-}
# Launcher: its own exe (large-address-aware bit must be in the file).
i686-w64-mingw32-g++ -std=gnu++17 -O2 -DWIN32 -DNDEBUG -D_CRT_SECURE_NO_WARNINGS \
-include /shims/seh_compat.h -static -static-libgcc -static-libstdc++ -s \
-o /out/dist/trlvr_launcher.exe "$W/src/launcher/main.cpp" \
-mwindows -municode -Wl,--large-address-aware -luser32 -lkernel32 -lshell32
# d3d9.dll links openxr_loader statically (MSVC delay-loads it): the loader must ship beside it.
if [ -f /src/third_party/openxr/bin/win32/openxr_loader.dll ]; then
cp /src/third_party/openxr/bin/win32/openxr_loader.dll /out/dist/
echo "NOTE: dist/openxr_loader.dll must be installed next to d3d9.dll (static import)"
fi
echo "PROXY_SECONDS=$(( $(ts) - t0 ))"
fi
ls -l /out/dist
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# usage: run-trio.sh <label> <dist dir> [key=value ini overrides...]
# Swaps the trio into the game dir, launches headless-style (DISPLAY=:1, 90 s), kills, restores.
LABEL=$1; DIST=$(readlink -f $2); shift 2
G="/home/steamos/.local/share/Steam/steamapps/common/Tomb Raider Legend"; ST=/home/steamos/.local/share/Steam
B=/home/steamos/Games/LegendFrame/build-m1; L=$B/logs/$LABEL; mkdir -p $L
pgrep trl_vr && { echo "game running"; exit 1; }
# verify shipped state
(cd $B/shipped/1.0.0 && grep -E " (d3d9.dll|d3d9_dxvk.dll|trlvr_launcher.exe|openvr_api.dll|trl_vr.exe)$" SHA256SUMS | sed "s# # $G/#;s# *$G/# $G/#" >/dev/null)
for f in d3d9.dll d3d9_dxvk.dll trlvr_launcher.exe; do
want=$(grep " $f\$" $B/shipped/1.0.0/SHA256SUMS | cut -d' ' -f1); have=$(sha256sum "$G/$f" | cut -d' ' -f1)
[ "$want" = "$have" ] || { echo "game-dir $f is not the shipped one; abort"; exit 2; }
done
mkdir -p $B/shipped/backup-$LABEL; cp -p "$G"/trlvr.ini "$G"/trlvr.log "$G"/trlvr.log.prev "$G"/trlvr_launcher.log $B/shipped/backup-$LABEL/ 2>/dev/null
restore() {
cp -p $B/shipped/1.0.0/d3d9.dll $B/shipped/1.0.0/d3d9_dxvk.dll $B/shipped/1.0.0/trlvr_launcher.exe "$G/"
cp -p $B/shipped/backup-$LABEL/trlvr.ini "$G/trlvr.ini"
rm -f "$G/openxr_loader.dll" "$G/trlvr_crash.dmp"
[ -f $B/shipped/backup-$LABEL/trlvr.log ] && cp -p $B/shipped/backup-$LABEL/trlvr.log "$G/"
[ -f $B/shipped/backup-$LABEL/trlvr.log.prev ] && cp -p $B/shipped/backup-$LABEL/trlvr.log.prev "$G/"
[ -f $B/shipped/backup-$LABEL/trlvr_launcher.log ] && cp -p $B/shipped/backup-$LABEL/trlvr_launcher.log "$G/"
(cd "$G" && sha256sum d3d9.dll d3d9_dxvk.dll trlvr_launcher.exe trlvr.ini) > $L/restore-sha.txt
}
trap restore EXIT
for kv in "$@"; do
k=${kv%%=*}; v=${kv#*=}
if grep -q "^$k=" "$G/trlvr.ini"; then sed -i "s|^$k=.*|$k=$v|" "$G/trlvr.ini"; else sed -i "/^\[vr\]/a $k=$v" "$G/trlvr.ini"; fi
done
cp $DIST/d3d9.dll $DIST/d3d9_dxvk.dll $DIST/trlvr_launcher.exe "$G/"
[ -f $DIST/openxr_loader.dll ] && cp $DIST/openxr_loader.dll "$G/"
T0=$(date +%s)
SP=$(pgrep -x steam | head -1)
(
while IFS= read -r -d '' kv; do export "$kv"; done < /proc/$SP/environ
export DISPLAY=:1 STEAM_COMPAT_DATA_PATH=$ST/steamapps/compatdata/7000 STEAM_COMPAT_CLIENT_INSTALL_PATH=$ST
export WINEDLLOVERRIDES="d3d9=n,b" SteamAppId=7000 SteamGameId=7000
timeout 90 $ST/steamapps/common/SteamLinuxRuntime_4-arm64/_v2-entry-point --verb=waitforexitandrun -- "$ST/steamapps/common/Proton 11.0 (ARM64)/proton" waitforexitandrun "$G/trlvr_launcher.exe" > $L/run.out 2>&1
)
pkill trl_vr; pkill trlvr_launch; sleep 3
cp "$G"/trlvr.log "$G"/trlvr_launcher.log $L/ 2>/dev/null; cp "$G"/trl_vr_d3d9.log $L/ 2>/dev/null; cp "$G"/trlvr_crash.dmp $L/ 2>/dev/null
for f in vrserver vrcompositor; do awk -v t="$(date -d @$T0 '+%b %d %H:%M:%S')" '1' ~/.local/share/Steam/logs/$f.txt | tail -n 4000 > $L/$f.tail.txt; done
echo "ran $((`date +%s`-T0)) s"
+106
View File
@@ -0,0 +1,106 @@
// seh_compat.h -- MSVC __try/__except(EXCEPTION_EXECUTE_HANDLER) for i686 mingw-w64.
//
// GCC has no __try/__except on 32-bit Windows. The TRL VR mod only uses the
// catch-everything form, with no filter expression use, no __finally, and no
// GetExceptionCode. This header maps that form onto a hand-registered x86 SEH
// frame (fs:[0] chain) + __builtin_setjmp/longjmp, so nesting with foreign SEH
// frames (the game's, the CRT's) keeps its order, exactly like MSVC's.
//
// __try { A } __except(EXCEPTION_EXECUTE_HANDLER) { B }
// becomes
// if (Scope s; (s.arm(), __builtin_setjmp(s.jb)) == 0) { A } else { B }
//
// Injected with -include so the mod's sources stay unmodified. Not applicable
// to x86_64 (native SEH there). Hardware faults only (not C++ throws).
#pragma once
#if defined(__MINGW32__) && defined(__i386__) && !defined(TRLVR_SEH_COMPAT_DONE)
#define TRLVR_SEH_COMPAT_DONE 1
// libstdc++ defines its own __try/__catch macros (bits/exception_defines.h) and
// would clobber ours if first included later; pull the whole library in now.
#ifndef MINGW_HAS_SECURE_API
#define MINGW_HAS_SECURE_API 1 // swprintf_s and friends, as MSVC provides
#endif
#ifdef __cplusplus
#include <bits/stdc++.h>
#endif
#include <windows.h>
#include <setjmp.h>
namespace seh_compat {
// Raw x86 SEH frame on the FS:[0] chain (what MSVC's __try does). On a fault the
// handler redirects the thread into resume(), which __builtin_longjmp's back to the
// guard and runs the else-branch. No C++ EH, no unwinding through ntdll frames.
// Design and code from the probe-clang lane (probe-clang/seh_compat.h).
struct Scope {
Scope* prev; // == EXCEPTION_REGISTRATION_RECORD layout
void* handler;
void* jb[5]; // __builtin_setjmp buffer
bool armed;
Scope() : armed(false) {}
~Scope() { disarm(); }
void arm();
void disarm() { if (armed) { asm volatile("movl %0, %%fs:0" :: "r"(prev) : "memory"); armed = false; } }
};
__attribute__((noreturn, noinline, used)) static void __cdecl resume(Scope* s) {
s->disarm();
__builtin_longjmp(s->jb, 1);
}
static EXCEPTION_DISPOSITION __cdecl handler(EXCEPTION_RECORD* rec, void* est, CONTEXT* ctx, void*) {
if (rec->ExceptionFlags & (EXCEPTION_UNWINDING | EXCEPTION_EXIT_UNWIND)) return ExceptionContinueSearch;
DWORD* sp = reinterpret_cast<DWORD*>(ctx->Esp) - 2; // fake cdecl call: [ret][arg]
sp[0] = 0; sp[1] = reinterpret_cast<DWORD>(est);
ctx->Esp = reinterpret_cast<DWORD>(sp);
ctx->Eip = reinterpret_cast<DWORD>(&resume);
return ExceptionContinueExecution;
}
__attribute__((noinline)) inline void Scope::arm() {
asm volatile("movl %%fs:0, %0" : "=r"(prev));
handler = reinterpret_cast<void*>(&seh_compat::handler);
asm volatile("movl %0, %%fs:0" :: "r"(this) : "memory");
armed = true;
}
}
#include <intrin.h>
// MSVC intrinsic; mingw's <intrin.h> declares it without a definition.
#undef _ReturnAddress
#define _ReturnAddress() __builtin_return_address(0)
#undef __try
#undef __except
// The asm barrier matters: without it GCC -O2 schedules __builtin_setjmp's save of ESP into jb[]
// AFTER the first guarded load, so a fault on that first load longjmps with a garbage ESP.
#define __try if (::seh_compat::Scope seh_scope_; (seh_scope_.arm(), __builtin_setjmp(seh_scope_.jb)) == 0 && ({ __asm__ __volatile__("" ::: "memory"); 1; }))
#define __except(x) else
// ---------------------------------------------------------------------------
// GCC/MSVC thiscall mismatch for virtuals that return a struct in memory:
// MSVC passes `this` in ECX and the hidden return pointer first on the stack;
// GCC puts the hidden pointer in ECX and `this` on the stack. OpenVR's
// openvr_api.dll (MSVC ABI) therefore needs these called by hand.
// container-build.sh rewrites `obj->GetEyeToHeadTransform(a)` (and the other
// struct-returning OpenVR methods) to
// trlvr_abi::call_sret(obj, &decltype(*obj)::GetEyeToHeadTransform, a)
// on its working copy and saves the result as abi-fix.diff.
#if __has_include(<openvr.h>)
#include <openvr.h>
#include <type_traits>
namespace trlvr_abi
{
template <class R, class C, class... A>
inline R call_sret(C* obj, R (C::*pmf)(A...), A... args)
{
static_assert(sizeof(pmf) == 2 * sizeof(void*), "unexpected pmf layout");
size_t words[2];
__builtin_memcpy(words, &pmf, sizeof(words));
// Itanium-style PMF to a virtual: first word = vtable byte offset + 1.
const size_t slot = (words[0] - 1) / sizeof(void*);
void** vtbl = *reinterpret_cast<void***>(obj);
typedef void (__attribute__((thiscall)) *Fn)(void*, R*, A...);
R out{};
reinterpret_cast<Fn>(vtbl[slot])(obj, &out, args...);
return out;
}
}
#endif
#endif
+68
View File
@@ -0,0 +1,68 @@
// Runtime check of shims/seh_compat.h (no game involved). Built with -include seh_compat.h.
// usage: seh_test.exe <logfile> (stdout is swallowed by Proton, so results go to the file too)
#include <windows.h>
#include <cstdio>
static FILE* g_log;
static void say(const char* fmt, int v = 0)
{
char b[256]; snprintf(b, sizeof b, fmt, v);
if (g_log) { fputs(b, g_log); fputc('\n', g_log); fflush(g_log); }
puts(b); fflush(stdout);
}
static volatile int* volatile g_null = nullptr;
static int __attribute__((noinline)) faulting_callee(int x) { return *g_null + x; }
typedef int (*Fn)(int);
static volatile Fn g_fn = faulting_callee;
static int __attribute__((noinline)) good_callee(int x) { return x * 2; }
static bool peek(volatile int* p, int* out)
{
__try { *out = *p; return true; }
__except (EXCEPTION_EXECUTE_HANDLER) { return false; }
}
int main(int argc, char** argv)
{
if (argc > 1) g_log = fopen(argv[1], "w");
int failures = 0;
say("start");
// 1. null read inside __try
int got = 0;
if (!peek(g_null, &got)) say("recovered (null read)"); else { say("FAIL null read not caught"); ++failures; }
// 2. guarded call through a function pointer into faulting code
volatile int r2 = 0;
__try { r2 = g_fn(1); say("FAIL call did not fault"); ++failures; }
__except (EXCEPTION_EXECUTE_HANDLER) { r2 = -1; }
if (r2 == -1) say("recovered (faulting call through function pointer)");
// 3. no fault: body runs, `return` leaves the frame cleanly
int ok = 0;
if (peek(&got, &ok) && ok == got) say("ok (no-fault path returns from inside __try)"); else { say("FAIL no-fault path"); ++failures; }
// 4. nested: inner fault is caught by the inner handler, outer body continues
int stage = 0;
__try {
__try { (void)*g_null; stage = 1; }
__except (EXCEPTION_EXECUTE_HANDLER) { stage = 2; }
stage += 10;
}
__except (EXCEPTION_EXECUTE_HANDLER) { stage = -99; }
if (stage == 12) say("recovered (nested)"); else { say("FAIL nested stage=%d", stage); ++failures; }
// 5. many faults in a row (frame chain must not leak or corrupt)
int n = 0;
for (int i = 0; i < 2000; ++i) { int t; if (!peek(g_null, &t)) ++n; }
if (n == 2000) say("recovered (2000 consecutive faults)"); else { say("FAIL repeated faults n=%d", n); ++failures; }
// 6. FS:[0] chain intact afterwards: a function pointer call that does NOT fault still works
if (g_fn != nullptr && good_callee(21) == 42) say("ok (chain intact)"); else ++failures;
// 7. unguarded fault still reaches the unhandled path? skipped on purpose (would kill the process)
say(failures ? "RESULT FAIL" : "RESULT PASS");
if (g_log) fclose(g_log);
return failures ? 1 : 0;
}
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# Build and run the seh_compat runtime check headless under Proton (scratch prefix, no game).
# usage: seh_test.sh <output dir>
set -uo pipefail
HERE=$(cd "$(dirname "$0")/.." && pwd)
OUT=$(readlink -m "${1:?output dir}"); mkdir -p "$OUT"
podman image exists localhost/legendframe-mingw || podman build -t legendframe-mingw "$HERE"
podman run --rm -v "$HERE:/tc:ro,Z" -v "$OUT:/out:Z" localhost/legendframe-mingw \
i686-w64-mingw32-g++ -std=gnu++17 -O2 -DWIN32 -DNDEBUG -include /tc/shims/seh_compat.h \
-static -static-libgcc -static-libstdc++ -o /out/seh_test.exe /tc/test/seh_test.cpp || exit 70
ST=$HOME/.local/share/Steam; SP=$(pgrep -x steam | head -1)
[ -n "$SP" ] || { echo "steam not running" >&2; exit 71; }
PFX=$HOME/Games/LegendFrame/build-m2c/pfx; mkdir -p "$PFX"
rm -f "$OUT/seh_test.log"
(
while IFS= read -r -d '' kv; do export "$kv"; done < "/proc/$SP/environ"
export DISPLAY=:1 STEAM_COMPAT_DATA_PATH="$PFX" STEAM_COMPAT_CLIENT_INSTALL_PATH="$ST" SteamAppId=0 SteamGameId=0
cd "$OUT"
timeout "${TIMEOUT:-120}" "$ST/steamapps/common/SteamLinuxRuntime_4-arm64/_v2-entry-point" --verb=waitforexitandrun -- \
"$ST/steamapps/common/Proton 11.0 (ARM64)/proton" waitforexitandrun "$OUT/seh_test.exe" "Z:/${OUT#/}/seh_test.log" > "$OUT/seh_test.out" 2>&1
echo $? > "$OUT/exitcode"
)
cat "$OUT/seh_test.log" 2>/dev/null || tail -20 "$OUT/seh_test.out"
echo "exit code $(cat "$OUT/exitcode")"
exit "$(cat "$OUT/exitcode")"