ci+tools: macOS arm64 release package (scripts/package-macos.sh) + macos-build CI job, Mach-O symbol tooling, Deck levelbench/deckrunner harness, verify.sh ini extras

This commit is contained in:
James Dansereau committed 2026-10-10 12:16:26 -04:00
1 parent 859a777d61
commit 62c56cf886
10 files changed
+703 -22

No files matched your search

+95 -3
View File
@@ -294,6 +294,93 @@ jobs:
if-no-files-found: error
retention-days: 14
# Experimental macOS (Apple Silicon) build. Compiles + links with Homebrew's
# real GNU GCC (Apple Clang cannot compile the decomp's Plan 9 struct
# extensions) and packages the bundle with scripts/package-macos.sh: the
# non-system dylibs (sdl2-compat + SDL3, libstdc++, libgcc_s) are copied
# next to the exe with @executable_path install names, re-signed ad-hoc.
# The bundle is not notarized; the README inside it gives the one-line
# `xattr -dr com.apple.quarantine .` unblock. Like linux-build this is a
# "compiles + links + packages" signal, not a runtime claim. Not a required
# check. Mirrors the maintainer's local build-mac configuration (Unix
# Makefiles, RelWithDebInfo, newest Homebrew gcc-N).
macos-build:
runs-on: macos-14
env:
CCACHE_MAXSIZE: 500M
HOMEBREW_NO_AUTO_UPDATE: "1"
HOMEBREW_NO_INSTALL_CLEANUP: "1"
HOMEBREW_NO_ANALYTICS: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install dependencies
run: |
echo "CCACHE_DIR=$GITHUB_WORKSPACE/.ccache" >> "$GITHUB_ENV"
brew install gcc cmake sdl2 sdl3 ccache
ls "$(brew --prefix)"/bin/gcc-[0-9]* "$(brew --prefix)"/bin/g++-[0-9]*
- name: Restore ccache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .ccache
key: ccache-macos-arm64-${{ hashFiles('CMakeLists.txt') }}-${{ github.sha }}
restore-keys: |
ccache-macos-arm64-${{ hashFiles('CMakeLists.txt') }}-
ccache-macos-arm64-
- name: Regenerate ROM-asset symbol file (US)
run: python3 scripts/gen_romassets.py u
# Same frozen first-run converter as linux-build. The runner's python3
# is Homebrew's (PEP 668, no system pip installs), so use a venv.
- name: Freeze asset converter (PyInstaller)
run: |
python3 -m venv "$RUNNER_TEMP/venv"
"$RUNNER_TEMP/venv/bin/pip" install --quiet pyinstaller==6.22.3
"$RUNNER_TEMP/venv/bin/python" -m PyInstaller --onefile --noconfirm --clean --name ge007-convert --distpath tools_pc/dist/prepare-assets --workpath build-pyinst --specpath build-pyinst tools_pc/dist/prepare-assets/prepare-assets.py
ls -la tools_pc/dist/prepare-assets/ge007-convert
- name: Configure (ntsc-final)
run: |
GCC="$(ls "$(brew --prefix)"/bin/gcc-[0-9]* | sort -t- -k2 -n | tail -1)"
GXX="$(ls "$(brew --prefix)"/bin/g++-[0-9]* | sort -t- -k2 -n | tail -1)"
echo "using $GCC / $GXX"
cmake -S . -B build-mac -DROMID=ntsc-final -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_C_COMPILER="$GCC" -DCMAKE_CXX_COMPILER="$GXX"
- name: Build (ntsc-final)
run: cmake --build build-mac -j"$(sysctl -n hw.ncpu)"
- name: ccache stats
run: ccache -s
- name: Show binary
run: |
ls -la build-mac/ge007* || true
file build-mac/ge007* || true
otool -L build-mac/ge007.aarch64
- name: Package macOS bundle
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
V="${GITHUB_REF_NAME}"
else
V="0.0.0-ci.${GITHUB_SHA::7}"
fi
bash scripts/package-macos.sh build-mac dist "$V"
- name: Upload bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: goldeneye-pc-port-macos-arm64
path: |
dist/*.tar.gz
dist/*.tar.gz.sha256
if-no-files-found: error
retention-days: 14
# Real Windows (MSYS2 MINGW64) build — the only platform the port is
# developed and tested on. This compiles and LINKS the full executable.
#
@@ -417,14 +504,14 @@ jobs:
# -level_09 soak, a golden-frame diff) would run on a self-hosted runner
# instead. Not planned.
# Tags only. Attach the packaged Windows + Linux bundles to a DRAFT
# Tags only. Attach the packaged Windows + Linux + macOS bundles to a DRAFT
# pre-release so a human can do the ROM smoke-test on each platform before
# publishing. This job — and only this job — gets contents: write, and it
# is also the only job that attests build provenance (id-token: write +
# attestations: write; attest-build-provenance needs both).
release:
if: github.ref_type == 'tag'
needs: [ changes, validate, windows-build, linux-build ]
needs: [ changes, validate, windows-build, linux-build, macos-build ]
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -454,6 +541,11 @@ jobs:
name: goldeneye-pc-port-linux-x86_64
path: dist
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: goldeneye-pc-port-macos-arm64
path: dist
# SLSA build provenance for the release artifacts (in-toto, signed by
# GitHub's OIDC key for this repo's workflow at the tagged commit).
# Anyone can check a downloaded file:
@@ -474,7 +566,7 @@ jobs:
ASSETS=(dist/*.zip dist/*.zip.sha256 dist/*.tar.gz dist/*.tar.gz.sha256)
NOTES="$(mktemp)"
# Asset names are built from the tag with the leading v stripped
# (bundle-{linux,win}.sh), so substitute the same form -- a raw
# (bundle-{linux,win}.sh, package-macos.sh), so substitute the same form -- a raw
# TAG would make the notes reference goldeneye-pc-port-v*.* assets
# that don't exist.
NOTES_VERSION="${TAG#v}"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Convert ELF/PE GAS absolute symbols to Mach-O spelling.
Also accepts C-preprocessed input (`cc -E` on a .S, the dram_syms path on
Apple): gcc's `# N "file"` line markers pass through as harmless `#`
comments, and C-style integer suffixes in `.set` expressions are stripped
(`0x70000000UL` -> `0x70000000`) so the Mach-O assembler never has to parse
them."""
import pathlib
import re
import sys
def main() -> int:
argv = sys.argv[1:]
base = 0
if argv and argv[0] == "--base":
if len(argv) < 2:
print(f"usage: {sys.argv[0]} [--base N] INPUT OUTPUT", file=sys.stderr)
return 2
base = int(argv[1], 0)
argv = argv[2:]
if len(argv) != 2:
print(f"usage: {sys.argv[0]} [--base N] INPUT OUTPUT", file=sys.stderr)
return 2
source = pathlib.Path(argv[0])
output = pathlib.Path(argv[1])
lines = []
for line in source.read_text(encoding="utf-8").splitlines():
if re.match(r"^\s*\.section\s+\.data\s*$", line):
continue
match = re.match(r"^(\s*)\.global\s+([A-Za-z_]\w*)\s*$", line)
if match:
lines.append(f"{match.group(1)}.globl _{match.group(2)}")
continue
match = re.match(
r"^(\s*)\.set\s+([A-Za-z_]\w*)(\s*,.*)$", line
)
if match:
expr = re.sub(
r"\b(0[xX][0-9a-fA-F]+|\d+)(?:[uU][lL]{1,2}|[uUlL])\b", r"\1", match.group(3)
)
if base:
# Add the window base to the expression's first numeric
# literal (romassets lines are a single literal; dram lines
# never take --base — see the docstring).
expr = re.sub(
r"(0[xX][0-9a-fA-F]+|\d+)",
lambda m, _b=base: f"0x{(int(m.group(1), 0) + _b):X}",
expr,
count=1,
)
lines.append(
f"{match.group(1)}.set _{match.group(2)}{expr}"
)
continue
lines.append(line)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text("\n".join(lines) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
#
# Package a macOS (Apple Silicon) GoldenEye 007 PC port build for distribution.
# Runs ON a Mac, after the build:
#
# scripts/package-macos.sh BUILD_DIR OUT_DIR [VERSION]
#
# BUILD_DIR cmake build directory containing ge007.aarch64
# OUT_DIR where to write the bundle + tarball (created if missing)
# VERSION defaults to `git describe`
#
# Produces, under OUT_DIR:
# goldeneye-pc-port-<VERSION>-macos-arm64/ the unpacked bundle
# goldeneye-pc-port-<VERSION>-macos-arm64.tar.gz + .tar.gz.sha256
#
# Same shape as tools_pc/bundle-linux.sh: engine exe, the non-system dylibs it
# needs (copied next to the exe, install names rewritten to @executable_path /
# @loader_path so nothing resolves into Homebrew), README, license texts, the
# prepare-assets tool and tools/eep_convert.py. NO ROM and NO game data.
# Every touched Mach-O is re-signed ad-hoc (mandatory on arm64 after
# install_name_tool). The bundle is NOT notarized; see the README it writes.
#
# Env: GE_REPO_ROOT overrides the repo root (default: parent of this script).
# GE_ALLOW_NO_CONVERTER=1 permits packaging without the PyInstaller-frozen
# ge007-convert (local testing only; CI always freezes it).
set -euo pipefail
[ "$(uname -s)" = "Darwin" ] || { echo "error: this script runs on macOS only" >&2; exit 1; }
[ $# -ge 2 ] || { echo "usage: $0 BUILD_DIR OUT_DIR [VERSION]" >&2; exit 1; }
BUILD_DIR="$1"; OUT_DIR="$2"
[ -d "$BUILD_DIR" ] || { echo "error: build dir not found: $BUILD_DIR" >&2; exit 1; }
mkdir -p "$OUT_DIR"
BUILD_DIR="$(cd "$BUILD_DIR" && pwd)"; OUT_DIR="$(cd "$OUT_DIR" && pwd)"
REPO_ROOT="${GE_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
cd "$REPO_ROOT"
VERSION="${3:-$(git describe --tags --always --dirty 2>/dev/null || echo 0.0.0-dev)}"
VERSION="${VERSION#v}"
NAME="goldeneye-pc-port-${VERSION}-macos-arm64"
OUT="${OUT_DIR}/${NAME}"
EXE=""
for cand in "$BUILD_DIR"/ge007.aarch64 "$BUILD_DIR"/ge007*; do
if [ -f "$cand" ] && [ -x "$cand" ]; then EXE="$cand"; break; fi
done
[ -n "$EXE" ] || { echo "error: no ge007 binary in $BUILD_DIR - build first" >&2; exit 1; }
EXE_NAME="$(basename "$EXE")"
file "$EXE" | grep -q arm64 || { echo "error: $EXE is not an arm64 Mach-O" >&2; exit 1; }
echo "==> Bundling $EXE -> $NAME"
rm -rf "$OUT"
mkdir -p "$OUT/licenses"
cp "$EXE" "$OUT/$EXE_NAME"
chmod 755 "$OUT/$EXE_NAME"
# --- dylib bundling ----------------------------------------------------
is_system() { case "$1" in /usr/lib/*|/System/*) return 0 ;; *) return 1 ;; esac; }
deps_of() { otool -L "$1" | tail -n +2 | awk '{print $1}'; }
rpaths_of() { otool -l "$1" | awk '/cmd LC_RPATH/ {f=1} f && /path / {print $2; f=0}'; }
# Resolve an install name to a real file, searching the dependent's rpaths and
# the Homebrew gcc runtime dir (libgcc_s / libstdc++ are @rpath-linked there).
resolve() {
local dep="$1" from="$2" r cand
case "$dep" in
@rpath/*)
for r in $(rpaths_of "$from"); do
cand="${r/@loader_path/$(dirname "$from")}"
cand="${cand/@executable_path/$(dirname "$from")}/${dep#@rpath/}"
[ -f "$cand" ] && { echo "$cand"; return 0; }
done
for cand in /opt/homebrew/opt/gcc/lib/gcc/current/"${dep#@rpath/}" \
/opt/homebrew/lib/"${dep#@rpath/}" /usr/local/lib/"${dep#@rpath/}"; do
[ -f "$cand" ] && { echo "$cand"; return 0; }
done
return 1 ;;
@loader_path/*)
cand="$(dirname "$from")/${dep#@loader_path/}"
[ -f "$cand" ] && { echo "$cand"; return 0; }
return 1 ;;
/*) [ -f "$dep" ] && { echo "$dep"; return 0; }; return 1 ;;
esac
return 1
}
BUNDLED=" "
SIGN_LIST=()
bundle_dep() { # $1 = file (already inside $OUT) whose deps to process; $2 = new-name prefix
local file="$1" prefix="$2" dep real base
while IFS= read -r dep; do
is_system "$dep" && continue
case "$dep" in "@executable_path/"*|"@loader_path/"*) continue ;; esac
real="$(resolve "$dep" "$file")" || { echo "error: cannot resolve $dep (needed by $file)" >&2; exit 1; }
real="$(cd "$(dirname "$real")" && pwd -P)/$(basename "$real")"
base="$(basename "$dep")"
if [[ "$BUNDLED" != *" $base "* ]]; then
BUNDLED+="$base "
cp -L "$real" "$OUT/$base"; chmod 644 "$OUT/$base"
install_name_tool -id "@loader_path/$base" "$OUT/$base" 2>/dev/null
echo " + $base (from $real)"
SIGN_LIST+=("$OUT/$base")
bundle_dep "$OUT/$base" "@loader_path"
fi
install_name_tool -change "$dep" "$prefix/$base" "$file" 2>/dev/null
done < <(deps_of "$file")
}
# Drop any rpath that points outside the bundle (Homebrew, build tree).
strip_rpaths() { local r; for r in $(rpaths_of "$1"); do install_name_tool -delete_rpath "$r" "$1" 2>/dev/null; done; }
# Homebrew's sdl2 is sdl2-compat: libSDL2 dlopen()s @loader_path/libSDL3.dylib at runtime, so it
# is not in otool -L. Bundle it too; sdl2-compat looks next to itself first.
bundle_sdl3_if_needed() {
local sdl2 sdl3="" d
sdl2="$(ls "$OUT"/libSDL2-*.dylib 2>/dev/null | head -1 || true)"
[ -n "$sdl2" ] || return 0
strings -a "$sdl2" | grep -q 'libSDL3' || return 0
for d in /opt/homebrew/opt/sdl3/lib /opt/homebrew/lib /usr/local/lib; do
if [ -f "$d/libSDL3.0.dylib" ]; then sdl3="$d/libSDL3.0.dylib"; break; fi
done
[ -n "$sdl3" ] || { echo "error: sdl2-compat found but libSDL3.0.dylib is not installed" >&2; exit 1; }
cp -L "$sdl3" "$OUT/libSDL3.dylib"; chmod 644 "$OUT/libSDL3.dylib"
install_name_tool -id "@loader_path/libSDL3.dylib" "$OUT/libSDL3.dylib" 2>/dev/null
echo " + libSDL3.dylib (sdl2-compat runtime dependency, from $sdl3)"
SIGN_LIST+=("$OUT/libSDL3.dylib")
bundle_dep "$OUT/libSDL3.dylib" "@loader_path"
}
bundle_dep "$OUT/$EXE_NAME" "@executable_path"
bundle_sdl3_if_needed
strip_rpaths "$OUT/$EXE_NAME"
for f in ${SIGN_LIST[@]+"${SIGN_LIST[@]}"}; do strip_rpaths "$f"; done
# Ad-hoc sign everything we rewrote (libs first, exe last). Without this an
# arm64 binary is killed on launch ("Killed: 9").
for f in ${SIGN_LIST[@]+"${SIGN_LIST[@]}"} "$OUT/$EXE_NAME"; do
codesign --force -s - "$f" 2>&1 | sed 's/^/ /'
done
# Guard: nothing may still point into Homebrew / the build machine.
for f in ${SIGN_LIST[@]+"${SIGN_LIST[@]}"} "$OUT/$EXE_NAME"; do
if otool -L "$f" | tail -n +2 | awk '{print $1}' | grep -Eq '^(/opt/|/usr/local/|/Users/|/private/|@rpath/)'; then
echo "error: $f still references a non-system path:" >&2; otool -L "$f" >&2; exit 1
fi
codesign --verify "$f" || { echo "error: bad signature on $f" >&2; exit 1; }
done
echo " remaining system dependencies:"
otool -L "$OUT/$EXE_NAME" | tail -n +2 | awk '{print " " $1}' | grep -v '@executable_path' || true
# --- docs + licenses ---------------------------------------------------
DEPS_BLOCK=$'## macOS (Apple Silicon, experimental)\n\nThis is an experimental build for Macs with Apple Silicon (M1 or later,\nmacOS @MINOS@ or newer). SDL2 and the C++ runtime are bundled in this folder and\nfound automatically; nothing needs installing, and Homebrew is not required.\n\nThe app is not signed with an Apple Developer ID or notarized, so Gatekeeper\nblocks it when this folder came from a download. After unpacking, open Terminal\nin this folder and run, once:\n\n xattr -dr com.apple.quarantine .\n\nThen run `./@EXE@` from Terminal.\n'
MINOS="$(for f in ${SIGN_LIST[@]+"${SIGN_LIST[@]}"} "$OUT/$EXE_NAME"; do otool -l "$f" | awk '/LC_BUILD_VERSION/ {f=1} f && /minos/ {print $2; exit}'; done | sort -V | tail -1)"
echo " minimum macOS (highest minos of the bundled Mach-Os): $MINOS"
DEPS_BLOCK="${DEPS_BLOCK//@EXE@/$EXE_NAME}"
DEPS_BLOCK="${DEPS_BLOCK//@MINOS@/$MINOS}"
sed -e "s|@VERSION@|${VERSION}|g" \
-e "s|@PLATFORM@|macOS Apple Silicon (arm64, experimental)|g" \
-e "s|@EXE@|${EXE_NAME}|g" \
-e "s|@LICENSE_EXTRA@||g" \
tools_pc/dist/README.md.in > "$OUT/README.md.tmp"
# BSD awk rejects newlines in -v; pass the block via the environment.
DEPS_BLOCK="$DEPS_BLOCK" awk '{ if ($0 == "@DEPS@") print ENVIRON["DEPS_BLOCK"]; else if ($0 == "@DECK@") print ""; else print }' \
"$OUT/README.md.tmp" > "$OUT/README.md"
rm -f "$OUT/README.md.tmp"
cp NOTICE "$OUT/licenses/NOTICE"
cp LICENSE "$OUT/licenses/LICENSE-port-MIT.txt"
[ -f port/fast3d/LICENSE.txt ] && cp port/fast3d/LICENSE.txt "$OUT/licenses/LICENSE-fast3d.txt"
# SDL2 is zlib-licensed; carry its text when the Homebrew prefix has one.
for d in /opt/homebrew/opt/sdl2/LICENSE.txt /opt/homebrew/opt/sdl2-compat/LICENSE.txt /opt/homebrew/opt/sdl2/share/licenses/*/LICENSE.txt; do
if [ -f "$d" ]; then cp "$d" "$OUT/licenses/SDL2.txt"; break; fi
done
# --- save converter + asset-prep tool (same assembly as bundle-linux.sh) ---
mkdir -p "$OUT/tools"
cp tools_pc/eep_convert.py "$OUT/tools/"
PREP="$OUT/prepare-assets"
mkdir -p "$PREP/vendor/scripts" "$PREP/vendor/assets/obseg"
cp tools_pc/dist/prepare-assets/prepare-assets.py "$PREP/"
cp tools_pc/d43_emit.py tools_pc/d69_emit.py tools_pc/d88_emit.py tools_pc/d88_propdefs.py "$PREP/"
cp scripts/filelist.u.csv "$PREP/vendor/scripts/"
cp assets/obseg/file_resource_table.inc.c "$PREP/vendor/assets/obseg/"
# BSD cp has no --parents; cpio -pdm recreates the relative paths.
find assets -iname 'modelfileheader.inc.c' -print | cpio -pdm "$PREP/vendor" 2>/dev/null
nmh="$(find "$PREP/vendor/assets" -iname 'modelfileheader.inc.c' | wc -l | tr -d ' ')"
echo " + prepare-assets/ (emit scripts + $nmh model headers)"
[ "$nmh" -gt 400 ] || { echo "error: prepare-assets vendored only $nmh model headers - expected ~512" >&2; exit 1; }
CV="$REPO_ROOT/tools_pc/dist/prepare-assets/ge007-convert"
if [ -f "$CV" ]; then
cp "$CV" "$PREP/"; chmod 755 "$PREP/ge007-convert"
codesign --force -s - "$PREP/ge007-convert" 2>&1 | sed 's/^/ /' || true
echo " + prepare-assets/ge007-convert ($(du -h "$CV" | cut -f1))"
elif [ "${GE_ALLOW_NO_CONVERTER:-0}" = "1" ]; then
echo " ! ge007-convert missing (allowed by GE_ALLOW_NO_CONVERTER=1)"
else
echo "error: $CV not found - freeze it with PyInstaller first (see ci.yml macos-build)" >&2; exit 1
fi
# --- guards ------------------------------------------------------------
if find "$OUT" -type f \( -iname '*.z64' -o -iname '*.n64' -o -iname '*.v64' \) | grep -q .; then
echo "error: bundle contains a ROM image - aborting" >&2; exit 1
fi
KIB="$(du -sk "$OUT" | cut -f1)"
[ "$KIB" -le $((120 * 1024)) ] || { echo "error: bundle is ${KIB} KiB (> 120 MB) - likely contains game data" >&2; exit 1; }
# --- tarball + checksum ------------------------------------------------
# COPYFILE_DISABLE keeps AppleDouble ._* files out of the archive.
( cd "$OUT_DIR" && rm -f "${NAME}.tar.gz" "${NAME}.tar.gz.sha256" \
&& COPYFILE_DISABLE=1 tar -czf "${NAME}.tar.gz" "$NAME" \
&& shasum -a 256 "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256" )
echo "==> ${OUT_DIR}/${NAME}.tar.gz ($(du -h "${OUT_DIR}/${NAME}.tar.gz" | cut -f1))"
cat "${OUT_DIR}/${NAME}.tar.gz.sha256"
( cd "$OUT_DIR" && find "$NAME" -type f | sort )
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Remove ELF-style weak aliases from a C source copied for Mach-O."""
import pathlib
import re
import sys
def main() -> int:
if len(sys.argv) != 3:
print(f"usage: {sys.argv[0]} INPUT OUTPUT", file=sys.stderr)
return 2
source = pathlib.Path(sys.argv[1])
output = pathlib.Path(sys.argv[2])
text = source.read_text(encoding="utf-8")
text = re.sub(r"^[ \t]*#pragma[ \t]+weak[^\n]*\n", "", text,
flags=re.MULTILINE)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(text, encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -31,6 +31,8 @@ investigation artifacts.
| `framediff.py` | Visual regression: candidate `GE_PCDUMP` frames vs `tools_pc/golden/`, per-region divergence. Structural/tolerant by default; `--exact` after `GE_DETERM`. |
| `pixcount.py` | Count non-black pixels in a PPM dump — "did the scene render anything" as a number. |
| `level_sweep.sh` | Bare `-level_XX` boot of all 21 solo levels → PASS / NO-FRAMES / CRASH. Predates `verify.sh sweep`; kept as the battle-tested full-21-level runner until `verify.sh sweep` has done an equivalent soak. |
| `levelbench.sh` | Boot one solo level, run a scripted input pattern (`fight` / `turn` / `idle` / `raw:`) or a recorded demo (`--record` / `--replay`, pad input incl. dedicated use, pinned `GE_RSEED`), quit via `GE_QUITFRAME`, keep `ge007.log` + a cadence/SLOW summary. Windows, Linux, Deck. |
| `deckrunner.sh` | Steam Deck Game Mode job runner: add it to Steam as a non-Steam game and start it; it runs `levelbench.sh` jobs dropped into `~/lb/queue/` over ssh (gamescope hides ssh-launched windows). |
| `playtest.sh` | Launch a level for `docs/dev/LEVEL-PLAYTEST.md` human validation (WS6). |
| `audiodebug.ps1` | Audio counterpart to `debug.ps1` (that one is for crashes; this one is for audio behaviour). Drives the `GE_D204` health monitor + `GE_AUDIOTRACE`/`GE_AUDIODUMP` and prints a pass/fail verdict: real-time ratio, queue starvation, dropped blocks, oversized blocks, plus a soundIndex histogram. `-Play` for an instrumented interactive playtest (the D202 workflow), `-Soak` for a 5-min stability run, `-SyncData` to mirror `./data` first. |
| `debug.ps1` / `repro_gdb.sh` / `attach_animgen.sh` | Launch (or attach to) the game under gdb so a crash always leaves a backtrace. |
+15 -18
View File
@@ -2,10 +2,12 @@
"assets/animationtable_data.h": {
"-Wint-conversion": 25
},
"port/include/portaddr.h": {
"-Wpointer-to-int-cast": 1
},
"port/shim/PR/os.h": {
"-Wint-conversion": 3,
"-Wint-to-pointer-cast": 2,
"-Wpointer-to-int-cast": 1
"-Wint-to-pointer-cast": 1
},
"port/src/romdata.c": {
"-Wpointer-to-int-cast": 7
@@ -26,16 +28,15 @@
},
"src/game/bg.c": {
"-Wint-conversion": 3,
"-Wpointer-to-int-cast": 6
"-Wpointer-to-int-cast": 7
},
"src/game/blood_decrypt.c": {
"-Wint-conversion": 2,
"-Wpointer-to-int-cast": 2
},
"src/game/bondview2.c": {
"-Wint-conversion": 1,
"-Wint-to-pointer-cast": 2,
"-Wpointer-to-int-cast": 8
"-Wpointer-to-int-cast": 11
},
"src/game/bondview_r.c": {
"-Wpointer-to-int-cast": 1
@@ -52,9 +53,8 @@
"src/game/chrai.c": {
"-Wint-conversion": 2
},
"src/game/cleanup_objects.c": {
"-Wint-conversion": 1,
"-Wpointer-to-int-cast": 1
"src/game/front.c": {
"-Wpointer-to-int-cast": 2
},
"src/game/glass.c": {
"-Wpointer-to-int-cast": 2
@@ -63,7 +63,7 @@
"-Wint-conversion": 2
},
"src/game/image.c": {
"-Wint-conversion": 3,
"-Wint-conversion": 1,
"-Wpointer-to-int-cast": 3
},
"src/game/image_bank.c": {
@@ -107,7 +107,7 @@
},
"src/game/model.c": {
"-Wint-conversion": 9,
"-Wint-to-pointer-cast": 3,
"-Wint-to-pointer-cast": 1,
"-Wpointer-to-int-cast": 3
},
"src/game/mpmenu.c": {
@@ -117,12 +117,9 @@
"-Wint-to-pointer-cast": 1,
"-Wpointer-to-int-cast": 18
},
"src/game/othermodemicrocode.c": {
"-Wint-conversion": 1
},
"src/game/prop.c": {
"-Wint-conversion": 2,
"-Wpointer-to-int-cast": 21
"-Wpointer-to-int-cast": 20
},
"src/game/propobj.c": {
"-Wint-conversion": 1,
@@ -141,7 +138,7 @@
},
"src/game/stan.c": {
"-Wint-conversion": 3,
"-Wpointer-to-int-cast": 1
"-Wpointer-to-int-cast": 4
},
"src/game/tex.c": {
"-Wpointer-to-int-cast": 2
@@ -150,9 +147,9 @@
"-Wpointer-to-int-cast": 4
},
"src/game/title.c": {
"-Wint-conversion": 6,
"-Wint-to-pointer-cast": 2,
"-Wpointer-to-int-cast": 7
"-Wint-conversion": 3,
"-Wint-to-pointer-cast": 1,
"-Wpointer-to-int-cast": 6
},
"src/game/viewport.c": {
"-Wint-conversion": 1
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# deckrunner.sh -- Game Mode job runner for tools_pc/levelbench.sh.
# Gamescope only shows windows Steam launched, so a harness started over ssh
# runs hidden (and throttled). Add this script to Steam once as a non-Steam
# game and start it; it then runs queued benches as its own children, which
# gamescope shows and paces like a normal game.
#
# Queue a job over ssh: one line of levelbench.sh arguments per file, e.g.
# echo '--secs 45 bunker1' > ~/lb/queue/010-bunker1.job
# Jobs run in name order; results land in ~/lb/<job-name>/ (summary.txt),
# the job file moves to ~/lb/done/. `touch ~/lb/queue/stop` exits the runner
# (back to Steam). Idle-exits after 30 min with no jobs.
set -u
Q=~/lb/queue; D=~/lb/done
mkdir -p "$Q" "$D"
GAME=${GE_GAMEDIR:-$HOME/ge007-v050}
idle=0
echo "deckrunner: up $(date)" >> ~/lb/runner.log
while [ $idle -lt 1800 ]; do
if [ -e "$Q/stop" ]; then rm -f "$Q/stop"; break; fi
job=$(ls "$Q"/*.job 2>/dev/null | sort | head -1)
if [ -z "$job" ]; then sleep 2; idle=$((idle + 2)); continue; fi
idle=0
name=$(basename "$job" .job)
args=$(head -1 "$job")
mv "$job" "$D/"
echo "deckrunner: $name: $args" >> ~/lb/runner.log
rm -rf ~/lb/"$name"
# shellcheck disable=SC2086 # job line is word-split on purpose
bash ~/levelbench.sh --dir "$GAME" --out ~/lb/"$name" $args > ~/lb/"$name".console 2>&1
echo "deckrunner: $name done" >> ~/lb/runner.log
touch ~/lb/"$name".finished
done
echo "deckrunner: exit $(date)" >> ~/lb/runner.log
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# levelbench.sh -- boot one solo level, run a scripted input pattern, quit
# cleanly, and keep the log + a short summary. Runs on the machine with the
# game (Windows Git Bash/MSYS2, Linux, Steam Deck). For the Deck from the PC
# use tools_pc/deckbench.sh, which copies this script over and runs it.
#
# levelbench.sh [options] <level-number|name> [ENV=VAL ...]
# --secs N run length in game seconds (default 45; GE_QUITFRAME = 60*N)
# --script NAME fight (default) | idle | turn | raw:<GE_INPUTSCRIPT string>
# --exe PATH game binary (default: ./ge007.x86_64[.exe] in --dir)
# --dir DIR game dir holding data/ (default: cwd)
# --out DIR results dir (default: <dir>/levelbench/<level>-<time>)
# --record FILE human plays (no script); pad input saved to FILE
# --replay FILE replay a --record file instead of a script
# (both pin GE_RSEED so guards make the same choices)
# --gamescope wrap in nested `gamescope -r 90 -w 1280 -h 800 -f`
# (Deck desktop mode: approximates Game Mode pacing)
#
# Script timing is wall-clock ms since SDL init (GE_INPUTSCRIPT_MS=1), so
# load-time differences between machines don't shift it. "fight": taps Z
# every 1.5 s for the first 20 s (skips the intro cutscene; fires once in
# play), then 1.2 s Z holds every 4 s, so guards hear shots and start their
# patterns. Z is fire in every control style and harmless in cutscenes.
set -u
SECS=45; SCRIPT=fight; REC=; REP=; EXE=; DIR=$PWD; OUT=; GSCOPE=0
while [ $# -gt 0 ]; do
case $1 in
--secs) SECS=$2; shift 2;;
--script) SCRIPT=$2; shift 2;;
--exe) EXE=$2; shift 2;;
--dir) DIR=$2; shift 2;;
--out) OUT=$2; shift 2;;
--record) REC=$2; SCRIPT=none; shift 2;;
--replay) REP=$2; SCRIPT=none; shift 2;;
--gamescope) GSCOPE=1; shift;;
-h|--help) sed -n 2,22p "$0"; exit 0;;
*) break;;
esac
done
[ $# -ge 1 ] || { echo "usage: $0 [options] <level> [ENV=VAL ...]" >&2; exit 2; }
LVL=$1; shift
case ${LVL,,} in
dam) LVL=33;; facility) LVL=34;; runway) LVL=35;; surface1) LVL=36;;
bunker1) LVL=09;; silo) LVL=20;; frigate) LVL=26;; surface2) LVL=43;;
bunker2) LVL=27;; statue) LVL=22;; archives) LVL=24;; streets) LVL=29;;
depot) LVL=30;; train) LVL=25;; jungle) LVL=37;; control) LVL=23;;
caverns) LVL=39;; cradle) LVL=41;; aztec) LVL=28;; egypt) LVL=32;; cuba) LVL=54;;
esac
cd "$DIR" || exit 2
if [ -z "$EXE" ]; then
for c in ./ge007.x86_64 ./ge007.x86_64.exe; do [ -x "$c" ] && EXE=$c && break; done
fi
[ -n "$EXE" ] || { echo "no game binary in $DIR (use --exe)" >&2; exit 2; }
END=$((SECS * 1000))
gen_script() {
local s= t
case $SCRIPT in
none) ;;
idle) s="1000:SNONE";;
fight|turn)
for ((t = 4000; t < 20000 && t < END; t += 1500)); do s+="$t:Z;"; done
for ((t = 20000; t < END; t += 4000)); do
s+="$t:ZHOLD;$((t + 1200)):ZREL;"
if [ "$SCRIPT" = turn ]; then # sweep the view between bursts
s+="$((t + 1500)):SLEFT;$((t + 2300)):SRIGHT;$((t + 3100)):SNONE;"
fi
done;;
raw:*) s=${SCRIPT#raw:};;
*) echo "unknown --script $SCRIPT" >&2; exit 2;;
esac
printf '%s' "${s%;}"
}
INPUT=$(gen_script)
[ -n "$OUT" ] || OUT="levelbench/L$LVL-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$OUT"
# The game writes $S/ge007.log next to ge007.ini; find it after the run by mtime.
STAMP="$OUT/.start"; : > "$STAMP"
CMD=("$EXE" "-level_$LVL")
if [ "$GSCOPE" = 1 ]; then
CMD=(gamescope -r 90 -w 1280 -h 800 -W 1280 -H 800 -f -- "${CMD[@]}")
fi
{
echo "level=$LVL secs=$SECS script=$SCRIPT gamescope=$GSCOPE exe=$EXE"
echo "binary md5=$(md5sum "$EXE" | cut -c1-8)"
echo "extra env: $*"
echo "GE_INPUTSCRIPT=$INPUT"
} > "$OUT/run.txt"
IENV=(GE_INPUTSCRIPT_MS=1 "GE_INPUTSCRIPT=$INPUT")
[ "$SCRIPT" = none ] && IENV=("GE_RSEED=${GE_RSEED:-0123456789abcdef}")
[ -n "$REC" ] && IENV+=("GE_INPUTRECORD=$REC")
[ -n "$REP" ] && IENV+=("GE_INPUTREPLAY=$REP")
echo "input env: ${IENV[*]}" >> "$OUT/run.txt"
env GE_QUITFRAME=$((SECS * 60)) "${IENV[@]}" "$@" \
timeout $((SECS + 90)) "${CMD[@]}" > "$OUT/stdout.txt" 2>&1
RC=$?
echo "exit=$RC" >> "$OUT/run.txt"
LOG=$(find . -maxdepth 3 -name ge007.log -newer "$STAMP" 2>/dev/null | head -1)
[ -z "$LOG" ] && [ -f "${XDG_DATA_HOME:-$HOME/.local/share}/ge007/ge007.log" ] &&
LOG="${XDG_DATA_HOME:-$HOME/.local/share}/ge007/ge007.log"
[ -n "$LOG" ] && cp "$LOG" "$OUT/ge007.log"
for f in ge007.crash.log; do [ -f "$f" ] && [ "$f" -nt "$STAMP" ] && cp "$f" "$OUT/"; done
rm -f "$STAMP"
L="$OUT/ge007.log"; [ -f "$L" ] || L="$OUT/stdout.txt"
{
cat "$OUT/run.txt"
echo "--- exit: $(grep -m1 -E 'quit requested|video: exiting' "$L" || echo 'no clean quit line')"
[ -f "$OUT/ge007.crash.log" ] && echo "!!! CRASH LOG PRESENT"
echo "--- D578 cadence (one per ~10 s)"
grep -E 'D578 cadence' "$L" | sed 's/^.*D578 cadence/cadence/'
echo "--- D583 decisions"
grep -E 'D583 decisions' "$L" | sed 's/^.*D583 decisions/decisions/' | tail -5
n=$(grep -c 'D583 SLOW' "$L")
echo "--- D583 SLOW frames: $n (worst 8 by draw time)"
grep 'D583 SLOW' "$L" | awk '{ for (i = 1; i <= NF; i++) if ($i == "draw") { print $(i+1), $0; break } }' |
sort -rn | head -8 | cut -d' ' -f2- | sed 's/^.*D583 SLOW/SLOW/'
} > "$OUT/summary.txt"
cat "$OUT/summary.txt"
echo "results: $OUT"
+114
View File
@@ -0,0 +1,114 @@
/*
* Offline test harness for the update-check parsing helpers
* (port/include/updatecheck_parse.h). No network, no SDL: it exercises
* versionNewer() and extractTag() on a table of cases and prints PASS/FAIL
* per case.
*
* Build and run:
* gcc -O2 -Wall -o <temp>/uctest tools_pc/updatecheck_test.c
* <temp>/uctest
*
* Exits nonzero if any case fails.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../port/include/updatecheck_parse.h"
static int g_fail = 0;
static void checkVer(const char *tag, const char *running, int want)
{
int got = versionNewer(tag, running);
if (got == want) {
printf("PASS versionNewer(\"%s\", \"%s\") = %d\n", tag, running, got);
} else {
printf("FAIL versionNewer(\"%s\", \"%s\") = %d, expected %d\n",
tag, running, got, want);
g_fail++;
}
}
static void checkTag(const char *body, size_t outN, int wantOk, const char *want)
{
char out[64];
memset(out, 0x5a, sizeof(out));
int got = extractTag(body, out, outN);
if (wantOk && got && strcmp(out, want) == 0) {
printf("PASS extractTag -> \"%s\"\n", out);
} else if (!wantOk && got == 0) {
printf("PASS extractTag -> fail\n");
} else {
printf("FAIL extractTag: returned %d (out \"%s\"), expected %s\n",
got, got ? out : "(unwritten)", wantOk ? want : "fail");
g_fail++;
}
}
int main(void)
{
/* versionNewer(tag, running) */
checkVer("v0.5.1", "0.5.0", 1);
checkVer("v0.5.0", "0.5.0", 0);
checkVer("v0.4.9", "0.5.0", 0);
checkVer("v0.10.0", "0.9.9", 1);
checkVer("v1.0", "0.9.9", 1);
checkVer("v1.0.0", "1.0", 0);
checkVer("V0.6.0", "0.5.1", 1);
checkVer("v0.5.1-rc1", "0.5.1", 0);
checkVer("garbage", "0.5.0", 0);
checkVer("v0.5.1", "", 0);
/* extractTag(body) */
checkTag("{\"tag_name\":\"v0.5.1\"}", 64, 1, "v0.5.1");
checkTag("{\"tag_name\" : \"v0.5.1\"}", 64, 1, "v0.5.1");
checkTag("{\"tag_name\":\n \"v0.5.1\"}", 64, 1, "v0.5.1");
checkTag("{\"name\":\"x\"}", 64, 0, NULL);
checkTag("{\"tag_name\":\"v0.5.1\x1b[31m\"}", 64, 0, NULL);
checkTag("{\"tag_name\":\"\"}", 64, 0, NULL);
checkTag("{\"tag_name\":\"v0.5.1", 64, 0, NULL);
/* 40-char tag, out size 32: must fail and must not overflow. */
{
char body[80];
snprintf(body, sizeof(body), "{\"tag_name\":\"%040d\"}", 1);
char out[64];
memset(out, 0x5a, sizeof(out));
int got = extractTag(body, out, 32);
if (got == 0 && out[32] == (char)0x5a && out[63] == (char)0x5a) {
printf("PASS extractTag 40-char tag, out size 32 -> fail, no overflow\n");
} else {
printf("FAIL extractTag 40-char tag, out size 32: returned %d "
"(out[32]=%02x out[63]=%02x)\n",
got, (unsigned char)out[32], (unsigned char)out[63]);
g_fail++;
}
}
/* A ~300 KB body: "tag_name" in the first ~24 bytes, 300 KB of padding
* after it. extractTag must still find the tag (and never needs the
* tail, which is what the fetch path may have truncated). */
{
static const char pre[] = "{\"tag_name\":\"v0.5.1\",\"notes\":\"";
static const char post[] = "\"}";
enum { PAD = 300000 };
char *body = malloc(strlen(pre) + PAD + strlen(post) + 1);
if (!body) {
printf("FAIL out of memory\n");
return 1;
}
strcpy(body, pre);
memset(body + strlen(pre), 'x', PAD);
strcpy(body + strlen(pre) + PAD, post);
checkTag(body, 64, 1, "v0.5.1");
free(body);
}
if (g_fail) {
printf("%d case(s) FAILED\n", g_fail);
return 1;
}
printf("all cases passed\n");
return 0;
}
+6 -1
View File
@@ -270,7 +270,12 @@ stage_workdir() {
cp "$ROOT/data/ge007.$ROMID.z64" "$d/data/" &&
cp -r "$ROOT/data/pccg-$ROMID" "$ROOT/data/pcmodels-$ROMID" "$d/data/" &&
cp "$CANON_EEP" "$d/data/ge007.eep" &&
printf '[Window]\nWidth = 640\nHeight = 480\n' > "$d/data/ge007.ini"
# Video.DeckPresetApplied: the D283 Steam Deck preset would otherwise switch
# a fresh ini to fullscreen 1280x800 on a Deck, and no frame would compare.
printf '[Window]\nWidth = 640\nHeight = 480\n[Video]\nDeckPresetApplied = 1\n' > "$d/data/ge007.ini" &&
# VERIFY_INI_EXTRA: extra ini lines for an option-specific gate (D578:
# '[Video]\nFpsCap = 120\nVSync = 0'); the goldens are captured without it.
{ [ -z "${VERIFY_INI_EXTRA:-}" ] || printf '%b\n' "$VERIFY_INI_EXTRA" >> "$d/data/ge007.ini"; }
}
# EXIT only (D534): INT/TERM just exit, which fires EXIT.
CUR_CAPDIR=""