diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 0332cd0d..7943bf1d 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -122,6 +122,8 @@ jobs: make -s shortcutcheck make -s launch-shell make -s envcheck + make -s featcheck + make -s featstagecheck make -s buildcheck make -s settingscheck make -s routecheck diff --git a/Makefile b/Makefile index 40ac5290..e95f76e3 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,7 @@ DEPS := $(OBJS:.o=.d) .PHONY: all clean rebuild dobby deploy dylib dylib-install sigcheck fonts fonts-install app-payload app app-zip \ anchorcheck callscheck sigdb-fixtures webpatch-fixtures peicon-fixtures panel-behavior app-tests \ + featcheck featstagecheck runnervariantcheck conf-fixtures confcheck \ tests-list overlay-shim overlay-shim-install overlay-shim-tests \ overlay-shim-bench iconmaker icon \ appinfo helpers-install ntdll-resolve bridge runcheck compatcheck \ @@ -122,6 +123,16 @@ bridgecheck: @if [ ! -f dylib/tests/bridgecheck.sh ]; then $(call SKIP,bridgecheck,dylib/tests/bridgecheck.sh); exit 0; fi; \ sh dylib/tests/bridgecheck.sh dylib/feats/compat_run.sh +featstagecheck: + @if [ ! -f dylib/tests/featstagecheck.sh ]; then \ + $(call SKIP,featstagecheck,dylib/tests/featstagecheck.sh); exit 0; fi; \ + sh dylib/tests/featstagecheck.sh dylib/feats/compat_run.sh + +runnervariantcheck: + @if [ ! -f dylib/tests/runnervariantcheck.sh ]; then \ + $(call SKIP,runnervariantcheck,dylib/tests/runnervariantcheck.sh); exit 0; fi; \ + sh dylib/tests/runnervariantcheck.sh dylib/feats/compat_run.sh + gamedrivecheck: @if [ ! -f dylib/tests/gamedrivecheck.sh ]; then $(call SKIP,gamedrivecheck,dylib/tests/gamedrivecheck.sh); exit 0; fi; \ sh dylib/tests/gamedrivecheck.sh dylib/feats/compat_run.sh @@ -217,6 +228,33 @@ callscheck: @if [ ! -f dylib/tests/callscheck.c ]; then $(call SKIP,callscheck,dylib/tests/callscheck.c); exit 0; fi; \ $(MAKE) -s $(CALLSCHECK) && $(CALLSCHECK) +CONF_FIXTURES := dylib/tests/conf-fixtures +CONFDUMP := $(OUT_DIR)/confdump + +$(CONFDUMP): dylib/tests/confdump.c dylib/feats/conf.c dylib/feats/conf.h + @mkdir -p $(dir $@) + $(TEST_CC) -O1 -o $@ dylib/tests/confdump.c + +# One corpus, both readers: webpatch.c through confdump, compat_run.sh through its own +# normaliser. A difference between them is a difference between the panel and the launch. +conf-fixtures: + @if [ ! -d $(CONF_FIXTURES) ] || [ ! -f dylib/tests/confdump.c ]; then \ + $(call SKIP,conf-fixtures,$(CONF_FIXTURES)); exit 0; fi; \ + $(MAKE) -s $(CONFDUMP) || exit 1; \ + tmp=$$(mktemp -d); \ + grep -v '^\#' $(CONF_FIXTURES)/EXPECTED | grep -v '^$$' > $$tmp/want; \ + $(CONFDUMP) $(CONF_FIXTURES) > $$tmp/got || { rm -rf $$tmp; exit 1; }; \ + if ! diff -u $$tmp/want $$tmp/got > $$tmp/diff; then \ + echo "FAIL: webpatch.c's reader does not read the corpus as EXPECTED says"; \ + head -40 $$tmp/diff; rm -rf $$tmp; exit 1; fi; \ + n=$$(grep -c . $$tmp/got); rm -rf $$tmp; \ + echo "==> conf fixtures: $$n normalised lines match EXPECTED" + +confcheck: + @if [ ! -f dylib/tests/confcheck.sh ]; then \ + $(call SKIP,confcheck,dylib/tests/confcheck.sh); exit 0; fi; \ + sh dylib/tests/confcheck.sh dylib/feats/compat_run.sh $(CONF_FIXTURES) + SIGDB_FIXTURES := dylib/tests/sigdb-fixtures sigdb-fixtures: @@ -258,11 +296,13 @@ peicon-fixtures: exit $$rc GATECHECK := $(OUT_DIR)/gatecheck +FEATCHECK := $(OUT_DIR)/featcheck WEBPATCH_FIXTURES := dylib/tests/webpatch-fixtures -$(GATECHECK): dylib/tests/gatecheck.c dylib/feats/webpatch.c dylib/feats/webpatch.h +$(GATECHECK): dylib/tests/gatecheck.c dylib/feats/webpatch.c dylib/feats/webpatch.h \ + dylib/util/file.c @mkdir -p $(dir $@) - $(TEST_CC) -O1 -o $@ dylib/tests/gatecheck.c + $(TEST_CC) -O1 -o $@ dylib/tests/gatecheck.c dylib/util/file.c NATIVEPLATFORMCHECK := $(OUT_DIR)/nativeplatformcheck @@ -270,6 +310,18 @@ $(NATIVEPLATFORMCHECK): dylib/tests/nativeplatformcheck.c dylib/feats/nativeplat @mkdir -p $(dir $@) $(TEST_CC) -O1 -DHELPER_TIMEOUT_MS=1000 -o $@ dylib/tests/nativeplatformcheck.c dylib/feats/nativeplatform.c dylib/util/file.c +$(FEATCHECK): dylib/tests/featcheck.c dylib/feats/webpatch.c dylib/feats/webpatch.h \ + dylib/feats/conf.c dylib/feats/conf.h \ + dylib/util/file.c + @mkdir -p $(dir $@) + $(TEST_CC) -O1 -o $@ dylib/tests/featcheck.c dylib/util/file.c + +featcheck: + @if [ ! -f dylib/tests/featcheck.c ]; then \ + $(call SKIP,featcheck,dylib/tests/featcheck.c); exit 0; fi; \ + $(MAKE) -s $(FEATCHECK) || exit 1; \ + $(FEATCHECK) + webpatch-fixtures: @if [ ! -d $(WEBPATCH_FIXTURES) ] || [ ! -f dylib/tests/gatecheck.c ]; then \ $(call SKIP,webpatch-fixtures,$(WEBPATCH_FIXTURES)); exit 0; fi; \ @@ -369,7 +421,7 @@ panel-behavior: echo "==> panel-behavior: node not found, skipped"; exit 0; fi; \ mkdir -p $(OUT_DIR) && \ $(TEST_CC) -O1 -o $(OUT_DIR)/panel-emit \ - $(PANEL_TESTS)/emit.c && \ + $(PANEL_TESTS)/emit.c dylib/util/file.c && \ node $(PANEL_TESTS)/behavior.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/switching.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/launch-options.js $(OUT_DIR)/panel-emit && \ diff --git a/dylib/feats/compat.c b/dylib/feats/compat.c index 7ac06724..a268c628 100644 --- a/dylib/feats/compat.c +++ b/dylib/feats/compat.c @@ -879,6 +879,22 @@ const char *np_compat_fallback_tool_name(void) { return g_tool_count > 0 ? g_tools[0].name : TOOL_DIR_NAME; } +// collects every installed tool, the CrossOver build it runs and its architecture target(s). +// The panel needs all three to work out which runner variants it can offer for a game. +size_t np_compat_tool_builds(const char **names, const char **builds, const char **archs, + size_t max) { + load_tool_list_once(); + size_t n = 0; + for (int i = 0; i < g_tool_count && n < max; i++) { + if (!*g_tools[i].build) continue; + names[n] = g_tools[i].name; + builds[n] = g_tools[i].build; + archs[n] = strcmp(g_tools[i].flavor, "fex") == 0 ? "aarch64" : "x86_64"; + n++; + } + return n; +} + size_t np_compat_fex_tools(const char **names, size_t max) { load_tool_list_once(); size_t n = 0; diff --git a/dylib/feats/compat.h b/dylib/feats/compat.h index 07d9c6e3..29922297 100644 --- a/dylib/feats/compat.h +++ b/dylib/feats/compat.h @@ -129,6 +129,8 @@ const char *np_compat_fallback_tool_name(void); // Names of the listed tools whose flavor is fex, up to max. Returns how many it wrote. size_t np_compat_fex_tools(const char **names, size_t max); +size_t np_compat_tool_builds(const char **names, const char **builds, const char **archs, + size_t max); uint32_t np_compat_manager_tools_max(void); diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index 30fe947f..18ab8031 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -154,7 +154,488 @@ case "$np_build" in *[!A-Za-z0-9.-]*) np_build="" ;; esac np_display=$(sed -n 's/.*"display_name"[[:space:]]*"\(.*\)".*/\1/p' \ "$np_tool_dir/compatibilitytool.vdf" 2>/dev/null | head -1) || np_display="" [ -n "$np_display" ] || np_display="CrossOver build ${np_build:-unknown}" +if [ -n "$STEAM_COMPAT_DATA_PATH" ]; then + log="$STEAM_COMPAT_DATA_PATH/notproton-run.log" +else + log=/dev/null +fi +[ "$(stat -f %z "$log" 2>/dev/null || echo 0)" -gt 262144 ] \ + && : > "$log" || true +# Plugin and variant descriptors, normalised exactly as dylib/feats/conf.c does, so the +# panel and the launch agree on every file: one "idfieldvalue" line per field +# in the order read, "!scopecode[:detail]" where one was refused, and a +# "== " line as each file is opened. +np_conf_normalise() ( + np_conf_dir=$1 + np_conf_shown=$2 + np_conf_kind=$3 + # Byte order, which is the order webpatch.c reads them in. A name carrying a tab or a + # newline is skipped rather than normalised: either would break the lines below. + np_conf_names=$( + for np_conf_file in "$np_conf_dir"/*.conf; do + if [ -f "$np_conf_file" ] && [ ! -r "$np_conf_file" ]; then + printf '!\t%s\tunreadable\n' "${np_conf_file##*/}" >&2 + fi + [ -f "$np_conf_file" ] && [ -r "$np_conf_file" ] || continue + case ${np_conf_file##*/} in + (*' '*) continue ;; + esac + printf '%s\n' "${np_conf_file##*/}" + done | LC_ALL=C sort + ) + [ -n "$np_conf_names" ] || return 0 + set -- + while IFS= read -r np_conf_name; do + [ -f "$np_conf_dir/$np_conf_name" ] || continue + set -- "$@" "$np_conf_dir/$np_conf_name" + done <= 192 || s == "") return 0 + if (kind == "plugin") { + if (substr(s, 1, 10) != "NOTPROTON_" || length(s) == 10) return 0 + return substr(s, 11) ~ /^[A-Z0-9_]+$/ + } + return length(s) <= 64 && s ~ /^[A-Za-z0-9][A-Za-z0-9._-]*$/ + } + function choiceval(s, eq) { + eq = index(s, "=") + return eq ? trim(substr(s, 1, eq - 1)) : s + } + function envok(s, eq) { + eq = index(s, "=") + return eq > 1 && substr(s, 1, eq - 1) ~ /^[A-Z_][A-Z0-9_]*$/ + } + function refuse(scope, c, d) { printf "!\t%s\t%s%s%s\n", scope, c, (d == "" ? "" : ":"), d } + function schema( i, f, t, values, mn, mx, st, df) { + for (i = 1; i <= nreq; i++) + if (!(req[i] in cnt)) { detail = req[i]; return "missing-required" } + if (kind == "variant") { + if (substr(first["dst"], 1, 1) == "/" || index(first["dst"], "..")) { + detail = "dst"; return "bad-value" + } + return "" + } + for (i = 1; i <= nkv; i++) + if (names[i] == "env" && !envok(vals[i])) { detail = "env"; return "bad-value" } + if (("section" in cnt) && first["section"] != "dev") { detail = "section"; return "bad-value" } + if (!("type" in cnt)) return "" + t = first["type"] + values = ("choice" in cnt) ? cnt["choice"] : 0 + mn = ("min" in cnt); mx = ("max" in cnt); st = ("step" in cnt); df = ("default" in cnt) + if (t == "choice") { + if (!values) { detail = "choice"; return "missing-required" } + return "" + } + if (t != "slider") { detail = t; return "bad-type" } + if ((values && (mn || mx)) || (!values && !(mn && mx)) || values == 1) return "slider-shape" + if (values) { + if (df) { for (i = 1; i <= nkv; i++) + if (names[i] == "choice" && choiceval(vals[i]) == first["default"]) return "" + return "bad-default" } + return "" + } + if (!isnum(first["min"])) { detail = "min"; return "bad-value" } + if (!isnum(first["max"]) || first["max"] + 0 <= first["min"] + 0) { detail = "max"; return "bad-value" } + if (st && !isnum(first["step"])) { detail = "step"; return "bad-value" } + if (df && !isnum(first["default"])) return "bad-default" + return "" + } + function flush( i, c) { + if (id == "") return + c = code + if (c == "") { detail = ""; c = schema() } + if (c == "") { + if (id in seen) { c = "duplicate-id"; detail = "" } + else if (records >= 32) { c = "limit"; detail = "" } + else { seen[id] = 1; records++ } + } + if (c != "") refuse(id, c, detail) + else for (i = 1; i <= nkv; i++) printf "%s\t%s\t%s\n", id, names[i], vals[i] + id = ""; code = ""; detail = ""; nkv = 0; bytes = 0 + split("", cnt); split("", first) + } + function startfile( base) { + base = FILENAME + sub(/^.*\//, "", base) + printf "== %s%s%s\n", shown, (shown == "" ? "" : "/"), base + path = shown (shown == "" ? "" : "/") base + header = 0; dropped = 0 + } + BEGIN { + nkv = 0; records = 0; id = ""; code = ""; detail = "" + if (kind == "plugin") { + split("label description description_on section on off depends requires inject env overrides stage type choice min max step default suffix", f, " ") + split("1 1 16 1 1 1 1 4 1 16 1 1 1 16 1 1 1 1 1", m, " ") + split("label", req, " "); nreq = 1 + } else { + split("label variant src dst", f, " ") + split("1 1 1 1", m, " ") + split("src dst", req, " "); nreq = 2 + } + for (i in f) most[f[i]] = m[i] + 0 + } + FNR == 1 { flush(); startfile() } + dropped { next } + { + if (length($0) >= 2048) { id = ""; refuse(path, "line-too-long", ""); dropped = 1; next } + k = $0 + sub(/^[ \t\r\n\v\f]+/, "", k) + if (k == "" || substr(k, 1, 1) == "#") next + if (substr(k, 1, 1) == "[") { + cl = 0 + for (i = length(k); i > 1; i--) if (substr(k, i, 1) == "]") { cl = i; break } + if (!cl) next + flush() + header = 1 + id = trim(substr(k, 2, cl - 2)) + if (!idok(id)) { code = "bad-id"; detail = "" } + next + } + eq = index(k, "=") + if (!eq) next + if (!header) { id = ""; refuse(path, "no-header", ""); dropped = 1; next } + if (code != "") next + name = trim(substr(k, 1, eq - 1)) + val = trim(substr(k, eq + 1)) + if (!(name in most)) { code = "unknown-field"; detail = name; next } + if (val == "") { code = "empty-value"; detail = name; next } + if (index(val, "\t")) { code = "tab-in-value"; detail = name; next } + if ((name in cnt ? cnt[name] : 0) >= most[name]) { + code = (most[name] == 1 ? "duplicate-field" : "limit"); detail = name; next + } + if (bytes + length(name) + length(val) + 2 > 4096) { code = "limit"; detail = name; next } + nkv++; names[nkv] = name; vals[nkv] = val + bytes += length(name) + length(val) + 2 + if (!(name in cnt)) { cnt[name] = 0; first[name] = val } + cnt[name]++ + } + END { flush() } + ' "$@" +) + +# The normalised table is built once per directory and read with these, so nothing below +# parses a descriptor itself. +np_conf_value() { + printf '%s\n' "$1" | awk -F'\t' -v i="$2" -v f="$3" '$1 == i && $2 == f { print $3; exit }' +} +np_conf_values() { + printf '%s\n' "$1" | awk -F'\t' -v i="$2" -v f="$3" '$1 == i && $2 == f { print $3 }' +} +np_conf_ids() { + printf '%s\n' "$1" | awk -F'\t' '$1 != "!" && $1 !~ /^== / && !seen[$1]++ { print $1 }' +} +np_conf_has() { + printf '%s\n' "$1" | awk -F'\t' -v i="$2" '$1 == i { found = 1 } END { exit !found }' +} +# Every refusal the reader found, logged the way the panel logs its own. +np_conf_log() { + printf '%s\n' "$1" | awk -F'\t' -v k="$2" '$1 == "!" { + printf "=== %s: %s refused, %s ===\n", k, $2, $3 + }' >> "${log:-/dev/null}" 2>&1 || true +} + +# A runner variant carries a different build of something that has to be builtin, which is +# the only reason to replace a whole runner rather than drop a DLL in the prefix. Each one +# is a descriptor naming a tree and where that tree lands in the runner, so nothing here +# knows what any particular variant contains. Variants are clones, so resolving per game +# means two games can use different ones at once. +np_variant_conf_dir="$np_support/variants" +np_variant_used="" +np_conf_var_table=$(np_conf_normalise "$np_variant_conf_dir" "" variant) +np_conf_log "$np_conf_var_table" variants +np_variant_say() { + echo "=== variants: $* ===" >> "${log:-/dev/null}" 2>&1 || true +} + +# Mirrors np_compat_tool_builds in compat.c: the panel and the launch path have to agree +# on which architecture a tool runs, or one offers a variant the other refuses. +np_variant_arch() { + case "$np_flavor" in + (fex) printf 'aarch64' ;; + (*) printf 'x86_64' ;; + esac +} + +# Whether a tree has anything for the architecture this runner runs. A tree naming no +# architecture at all is not filtered, since dst may be a flat directory. +np_variant_fits_arch() { + np_v_named=0 + np_v_fits=0 + np_v_want_arch=$(np_variant_arch) + for np_v_d in "$1"/*; do + [ -d "$np_v_d" ] || continue + np_v_d_arch=$(np_variant_dir_arch "$(basename "$np_v_d")") + [ -n "$np_v_d_arch" ] || continue + np_v_named=1 + [ "$np_v_d_arch" != "$np_v_want_arch" ] || np_v_fits=1 + done + [ "$np_v_named" -eq 0 ] || [ "$np_v_fits" -eq 1 ] +} + +# The architecture a directory in a tree is for, empty when it names none. A runner can +# hold more than one slot, so landing somewhere is not the same as being usable. +np_variant_dir_arch() { + case "$1" in + (x86_64|x86_64-*) printf 'x86_64' ;; + (aarch64|aarch64-*) printf 'aarch64' ;; + (i386|i386-*) printf 'i386' ;; + (arm64ec|arm64ec-*) printf 'arm64ec' ;; + esac +} + +# Requested names, split on +, sorted so a combination is named and overlaid the same way +# whichever order it was asked for in. +np_variant_names() { + printf '%s' "$1" | tr '+' '\n' | grep -v '^$' | LC_ALL=C sort -u | tr '\n' ' ' +} +np_variant_dir_name() { + np_v_joined="crossover-$np_build" + for np_v_n in $1; do + np_v_joined="$np_v_joined-$np_v_n" + done + printf '%s' "$np_v_joined" +} + +# Reads one descriptor into np_v_src and np_v_dst. Unknown keys are refused so a typo +# cannot quietly mean something else, and a descriptor that cannot be used drops the +# variant rather than stopping the game. +np_variant_read() { + np_v_src=$(np_conf_value "$np_conf_var_table" "$1" src) + np_v_dst=$(np_conf_value "$np_conf_var_table" "$1" dst) + if [ -z "$np_v_src" ] || [ -z "$np_v_dst" ]; then + np_variant_say "$1 has no usable descriptor" + return 1 + fi + # Leading parens: a case pattern's ) can otherwise close a $( ) around this. + case "$np_v_src" in + (/*) ;; + (*) np_v_src="$HOME/$np_v_src" ;; + esac + if [ ! -d "$np_v_src" ]; then + np_variant_say "$1 builds from $np_v_src, which is not there" + return 1 + fi + if ! np_variant_fits_arch "$np_v_src"; then + np_variant_say "$1 has nothing for $np_v_want_arch, which is what this runner runs, so it is refused" + return 1 + fi + return 0 +} + +# What the variant should be made of. Rebuilding is decided by comparing this against the +# copy recorded in the built runner, which is a handful of stat calls. +np_variant_manifest() { + for np_v_name in $1; do + np_variant_read "$np_v_name" || return 1 + printf '%s %s\n' "$np_v_name" "$np_v_dst" + ( cd "$np_v_src" && find . -type f -exec stat -f '%N %z %m' {} + ) | LC_ALL=C sort + done + return 0 +} + +# Wine keeps a wineserver running inside the runner, so a process running from there is a +# live session and the tree under it must not move. +np_variant_busy() { + # shellcheck disable=SC2009 # pgrep matches arguments; the executable path is the signal + ps -axww -o comm= 2>/dev/null | grep -qF "$1/" +} + +# winebuild --builtin writes this over the DOS stub, and it is the whole reason a tree can +# be shared as a zip: the mark travels in the file, so nobody downstream needs a toolchain. +np_variant_builtin() { + [ "$(dd if="$1" bs=1 skip=64 count=16 2>/dev/null)" = "Wine builtin DLL" ] +} + +# A tree can hold more architectures than a runner has slots for -- DXMT builds one arch +# per build directory but installs them into one prefix, so a shared tree may carry both. +# A directory the runner does not have is skipped rather than fatal, and a variant that +# lands nothing is refused, which is what catches a tree meant for the other machine. +np_variant_overlay() { + np_v_root="$1" + for np_v_name in $2; do + np_variant_read "$np_v_name" || return 1 + ( cd "$np_v_src" && find . -type f -print ) > "$np_v_root/.np-files" || return 1 + np_v_laid=0 + np_v_skipped="" + while IFS= read -r np_v_rel; do + np_v_rel=${np_v_rel#./} + np_v_at="$np_v_root/CrossOver/$np_v_dst/$np_v_rel" + np_v_dir=$(dirname "$np_v_rel") + if [ ! -d "$(dirname "$np_v_at")" ]; then + case " $np_v_skipped " in + *" $np_v_dir "*) ;; + *) np_v_skipped="$np_v_skipped $np_v_dir" ;; + esac + continue + fi + cp -f "$np_v_src/$np_v_rel" "$np_v_at" || return 1 + # A downloaded tree arrives quarantined and Gatekeeper can refuse to dlopen it. + xattr -d com.apple.quarantine "$np_v_at" 2>/dev/null || true + case "$np_v_rel" in + *.dll) + if ! np_variant_builtin "$np_v_at"; then + np_variant_say "$np_v_name/$np_v_rel is not marked as a Wine builtin, so it would load as native and fail; $np_v_name is refused" + return 1 + fi ;; + esac + np_v_laid=$(( np_v_laid + 1 )) + done < "$np_v_root/.np-files" + rm -f "$np_v_root/.np-files" + [ -z "$np_v_skipped" ] || + np_variant_say "$np_v_name:$np_v_skipped has no counterpart in this runner, skipped" + if [ "$np_v_laid" -eq 0 ]; then + np_variant_say "nothing in $np_v_name belongs in this runner, so it is refused" + return 1 + fi + done + return 0 +} + +# Builds into a scratch directory and moves it in, so a failure leaves the previous build +# standing and the game runs last-known-good rather than a half-written tree. +np_variant_build() { + np_v_target="$1" + np_v_names="$2" + np_v_want="$3" + np_v_base="$np_support/runners/crossover-$np_build" + np_v_lock="$np_v_target.lock" + np_v_new="$np_v_target.new" + + if [ ! -d "$np_v_base/CrossOver/lib/wine" ]; then + np_variant_say "no base runner at $np_v_base yet, so nothing can be built from it" + return 1 + fi + + np_v_waited=0 + while ! mkdir "$np_v_lock" 2>/dev/null; do + np_v_age=$(( $(date +%s) - $(stat -f %m "$np_v_lock" 2>/dev/null || date +%s) )) + if [ "$np_v_age" -gt 900 ]; then + np_variant_say "clearing a build lock left behind ${np_v_age}s ago" + rm -rf "$np_v_lock" + continue + fi + np_v_waited=$(( np_v_waited + 1 )) + if [ "$np_v_waited" -gt 120 ]; then + np_variant_say "another launch is still building $(basename "$np_v_target")" + return 1 + fi + sleep 1 + done + + np_v_built=1 + rm -rf "$np_v_new" + np_variant_say "building $(basename "$np_v_target") from $np_v_names" + if cp -c -R "$np_v_base" "$np_v_new" 2>/dev/null || cp -R "$np_v_base" "$np_v_new"; then + if np_variant_overlay "$np_v_new" "$np_v_names"; then + printf '%s\n' "$np_v_want" > "$np_v_new/CrossOver/.notproton-variant" \ + && np_v_built=0 + fi + fi + if [ "$np_v_built" = 0 ]; then + rm -rf "$np_v_target.old" + if [ -d "$np_v_target" ]; then + mv "$np_v_target" "$np_v_target.old" || np_v_built=1 + fi + if [ "$np_v_built" = 0 ]; then + mv "$np_v_new" "$np_v_target" || np_v_built=1 + fi + rm -rf "$np_v_target.old" + fi + [ "$np_v_built" = 0 ] || rm -rf "$np_v_new" + rm -rf "$np_v_lock" + if [ "$np_v_built" = 0 ]; then + np_variant_say "built $(basename "$np_v_target")" + return 0 + fi + np_variant_say "could not build $(basename "$np_v_target")" + return 1 +} + +# Sets np_variant_root to a runner carrying the requested variants, building it when the +# trees behind it have moved on. Returns 1 to leave the game on the base runner, so a +# stale launch option never stops it starting. +# One name in a request that has lost its descriptor must not take the rest with it: the +# panel keeps a launch option a game already carries, and a game outlives a descriptor. +np_variant_usable() { + np_v_keep="" + np_v_drop="" + np_v_declared="" + for np_v_name in $1; do + [ ! -r "$np_variant_conf_dir/$np_v_name.conf" ] || np_v_declared=1 + if np_variant_read "$np_v_name"; then + np_v_keep="$np_v_keep$np_v_name " + else + np_v_drop="$np_v_drop$np_v_name " + fi + done +} + +np_variant_resolve() { + np_v_asked=$(np_variant_names "$1") + [ -n "$np_v_asked" ] || return 1 + np_variant_usable "$np_v_asked" + [ -z "$np_v_drop" ] || np_variant_say "ignoring ${np_v_drop% }, which cannot be used" + + if [ -z "$np_v_keep" ]; then + # A runner built by hand still counts, which is what the install script produced + # before descriptors existed -- but only when nothing declared a descriptor. A + # descriptor that exists and was refused is an answer, not a gap to route around. + np_variant_root="$np_support/runners/$(np_variant_dir_name "$np_v_asked")" + if [ -z "$np_v_declared" ] && [ -d "$np_variant_root/CrossOver/lib/wine" ]; then + np_variant_say "using the prebuilt $(np_variant_dir_name "$np_v_asked")" + np_variant_root="$np_variant_root/CrossOver" + np_variant_used="$np_v_asked" + return 0 + fi + return 1 + fi + + np_v_sel=${np_v_keep% } + np_variant_used="$np_v_sel" + np_variant_root="$np_support/runners/$(np_variant_dir_name "$np_v_sel")" + np_v_have="$np_variant_root/CrossOver/lib/wine" + np_v_want=$(np_variant_manifest "$np_v_sel") || np_v_want="" + [ -n "$np_v_want" ] || return 1 + + np_v_at="$np_variant_root/CrossOver/.notproton-variant" + if [ -d "$np_v_have" ] && [ "${NOTPROTON_VARIANT_REBUILD:-}" != 1 ] \ + && [ "$(cat "$np_v_at" 2>/dev/null || true)" = "$np_v_want" ]; then + np_variant_root="$np_variant_root/CrossOver" + return 0 + fi + + if np_variant_busy "$np_variant_root"; then + if [ -d "$np_v_have" ]; then + np_variant_say "a game is running from $(np_variant_dir_name "$np_v_sel"), leaving it as it is" + np_variant_root="$np_variant_root/CrossOver" + return 0 + fi + return 1 + fi + + if np_variant_build "$np_variant_root" "$np_v_sel" "$np_v_want" || [ -d "$np_v_have" ]; then + np_variant_root="$np_variant_root/CrossOver" + return 0 + fi + return 1 +} + CX_ROOT="$np_support/runners/crossover-$np_build/CrossOver" +np_variant=${NOTPROTON_RUNNER_VARIANT:-} +case "$np_variant" in *[!A-Za-z0-9.+-]*) np_variant="" ;; esac +if [ -n "$np_variant" ]; then + if np_variant_resolve "$np_variant"; then + CX_ROOT="$np_variant_root" + else + np_variant="" + fi +fi export CX_ROOT # CrossOver initializes Rosetta's Windows thread-state support even without D3DMetal. if [ -f "$CX_ROOT/lib64/apple_gptk/external/libd3dshared.dylib" ]; then @@ -193,13 +674,6 @@ without_lock_fds() { export WINEDLLPATH="$CX_ROOT/lib/wine/x86_64-windows:$wine_unix${WINEDLLPATH:+:$WINEDLLPATH}" export PATH="$CX_ROOT/bin:$PATH" -if [ -n "$STEAM_COMPAT_DATA_PATH" ]; then - log="$STEAM_COMPAT_DATA_PATH/notproton-run.log" -else - log=/dev/null -fi -[ "$(stat -f %z "$log" 2>/dev/null || echo 0)" -gt 262144 ] \ - && : > "$log" || true { echo "=== notproton run $(date) ===" echo "verb=$verb" @@ -736,6 +1210,15 @@ if [ -z "$np_build" ] || [ ! -d "$CX_ROOT/lib/wine" ]; then exit 1 fi echo "runner: build $np_build ($np_display) at $CX_ROOT" >> "$log" 2>&1 || true +if [ -n "${NOTPROTON_RUNNER_VARIANT:-}" ]; then + if [ -n "$np_variant_used" ]; then + echo "runner: variant asked for $NOTPROTON_RUNNER_VARIANT, using $np_variant_used" \ + >> "$log" 2>&1 || true + else + echo "=== runner variant ${NOTPROTON_RUNNER_VARIANT} not installed, using the base runner ===" \ + >> "$log" 2>&1 || true + fi +fi # Each CrossOver build needs its own template. # FEX builds need two, one for FEX/arm64 Wine and one for Rosetta/AMD64 Wine @@ -1504,6 +1987,9 @@ if [ -n "\$STEAM_DYLD_INSERT_LIBRARIES" ]; then export DYLD_INSERT_LIBRARIES="\$STEAM_DYLD_INSERT_LIBRARIES" fi fi +if [ -n "\$NOTPROTON_FEATURE_DYLIBS" ]; then + export DYLD_INSERT_LIBRARIES="\${DYLD_INSERT_LIBRARIES:+\$DYLD_INSERT_LIBRARIES:}\$NOTPROTON_FEATURE_DYLIBS" +fi [ -n "\$NOTPROTON_GAME_CWD" ] && cd "\$NOTPROTON_GAME_CWD" "$WINELOADER" "\$@" exit \$? @@ -1595,9 +2081,146 @@ terminate() { echo "=== termination signal received, killing wine prefix ===" >> "$log" 2>&1 || true kill_wine_prefix [ -n "$open_pid" ] && kill "$open_pid" 2>/dev/null || true + [ -n "$open_pid" ] || exit 143 } +open_pid="" trap terminate TERM INT HUP +np_feat_dir="$np_support/plugins" +np_feat_log() { + echo "=== $* ===" >> "$log" 2>&1 || true +} +# One reader for plugins and variants alike: the grammar, the limits and every refusal +# are in np_conf_normalise, and webpatch.c is checked against the same corpus. +np_conf_feat_table=$(np_conf_normalise "$np_feat_dir" "" plugin) +np_conf_log "$np_conf_feat_table" plugins + +np_feat_first() { + np_conf_value "$np_conf_feat_table" "$1" "$2" +} +# The first file a plugin names that is actually there; relative ones hang off $HOME. +np_feat_requires() { + np_conf_values "$np_conf_feat_table" "$1" requires | while IFS= read -r np_feat_req; do + case "$np_feat_req" in + (/*) ;; + (*) np_feat_req="$HOME/$np_feat_req" ;; + esac + [ -f "$np_feat_req" ] && { printf '%s' "$np_feat_req"; break; } + done +} +np_feat_installed() { + [ -z "$(np_feat_first "$1" requires)" ] || [ -n "$(np_feat_requires "$1")" ] +} +np_feat_switched() { + eval "np_feat_sv=\${$1-}" + np_feat_son=$(np_feat_first "$1" on) + # shellcheck disable=SC2154 # eval assigns np_feat_sv above + [ "$np_feat_sv" = "${np_feat_son:-1}" ] +} +# A row is on only if everything it hangs off is, so a sub-option cannot outlive its parent. +np_feat_active() { + [ "$2" -le 32 ] || return 1 + np_conf_has "$np_conf_feat_table" "$1" || return 1 + np_feat_switched "$1" || return 1 + np_feat_installed "$1" || return 1 + np_feat_adep=$(np_feat_first "$1" depends) + [ -z "$np_feat_adep" ] || np_feat_active "$np_feat_adep" $(($2 + 1)) +} + +np_feat_list=$(np_conf_ids "$np_conf_feat_table") + +np_feat_dylibs="" +np_feat_envs="" +while IFS= read -r np_feat_key; do + [ -n "$np_feat_key" ] || continue + if ! np_feat_installed "$np_feat_key"; then + np_feat_switched "$np_feat_key" && + np_feat_log "$np_feat_key is on but is skipped as $np_feat_key's required files are missing" + continue + fi + np_feat_active "$np_feat_key" 0 || continue + + if [ "$(np_feat_first "$np_feat_key" inject)" = 1 ]; then + np_feat_lib=$(np_feat_requires "$np_feat_key") + if [ ! -f "$np_feat_lib" ]; then + np_feat_log "$np_feat_key is skipped as it asks to load a dylib but doesn't name one" + elif [ "${np_feat_lib#*:}" != "$np_feat_lib" ]; then + np_feat_log "$np_feat_key is skipped as $np_feat_lib has a colon in it" + else + case ":$np_feat_dylibs:" in + (*":$np_feat_lib:"*) ;; + (*) np_feat_dylibs="${np_feat_dylibs:+$np_feat_dylibs:}$np_feat_lib" + np_feat_log "$np_feat_key will be loaded from $np_feat_lib" ;; + esac + fi + fi + + np_feat_stage=$(np_feat_first "$np_feat_key" stage) + if [ -n "$np_feat_stage" ]; then + case "$np_feat_stage" in + (/*) ;; + (*) np_feat_stage="$HOME/$np_feat_stage" ;; + esac + np_feat_sys32="$WINEPREFIX/drive_c/windows/system32" + if [ ! -d "$np_feat_stage" ]; then + np_feat_log "$np_feat_key copies no DLLs as $np_feat_stage doesn't exist" + elif [ ! -d "$np_feat_sys32" ]; then + np_feat_log "$np_feat_key copies no DLLs as the prefix has no system32" + else + for np_feat_src in "$np_feat_stage"/*; do + [ -f "$np_feat_src" ] || continue + np_feat_dst="$np_feat_sys32/$(basename "$np_feat_src")" + if [ -d "$np_feat_dst" ]; then + np_feat_log "$np_feat_key skips $(basename "$np_feat_src") as system32 has a folder by that name" + continue + fi + cmp -s "$np_feat_src" "$np_feat_dst" && continue + np_feat_tmp="$np_feat_dst.np-stage.$$" + rm -f "$np_feat_tmp" + if { cp -c "$np_feat_src" "$np_feat_tmp" 2>/dev/null \ + || cp "$np_feat_src" "$np_feat_tmp"; } && mv -f "$np_feat_tmp" "$np_feat_dst"; then + np_feat_log "$np_feat_key copied $(basename "$np_feat_src") into system32" + else + rm -f "$np_feat_tmp" + np_feat_log "$np_feat_key failed to copy $(basename "$np_feat_src") into system32" + fi + done + fi + fi + + np_feat_over=$(np_feat_first "$np_feat_key" overrides) + if [ -n "$np_feat_over" ]; then + export WINEDLLOVERRIDES="${WINEDLLOVERRIDES:+$WINEDLLOVERRIDES;}$np_feat_over" + np_feat_log "$np_feat_key adds overrides $np_feat_over" + fi + + while IFS= read -r np_feat_env; do + [ -n "$np_feat_env" ] || continue + case "$np_feat_env" in + (*=*) ;; + (*) np_feat_log "$np_feat_key skips env line $np_feat_env as it has no ="; continue ;; + esac + if ! printf '%s\n' "${np_feat_env%%=*}" | LC_ALL=C grep -qx '[A-Z_][A-Z0-9_]*'; then + np_feat_log "$np_feat_key skips env ${np_feat_env%%=*} as it isn't a valid variable name" + continue + fi + case "${np_feat_env%%=*}" in + (DYLD_* | PATH | CX_ROOT | CX_HOME | WINE* | STEAM_COMPAT_* | STEAM_DYLD_INSERT_LIBRARIES | \ + NOTPROTON_FEATURE_DYLIBS | NOTPROTON_GAME_CWD | NOTPROTON_OVERLAY_SHIM) + np_feat_log "$np_feat_key skips env ${np_feat_env%%=*} as the compat_run script sets it" + continue ;; + esac + # Kept in reverse since they get prepended to open args (which flips the order) + np_feat_envs="$np_feat_env +$np_feat_envs" + np_feat_log "$np_feat_key sets $np_feat_env" + done <> "$log" 2>&1 || true @@ -1605,6 +2228,12 @@ else echo "=== client staged no overlay renderer, overlay disabled ===" >> "$log" 2>&1 || true fi set -- --args "$shim_exe" "$@" +# Prepended first so they end up last among the --env args since open keeps the last of a repeated name. +while IFS= read -r np_feat_env; do + [ -n "$np_feat_env" ] && set -- --env "$np_feat_env" "$@" +done <