diff --git a/Makefile b/Makefile index ebcc0355..e95f76e3 100644 --- a/Makefile +++ b/Makefile @@ -67,7 +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 \ + 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 \ @@ -128,6 +128,11 @@ featstagecheck: $(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 @@ -223,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: @@ -267,9 +299,10 @@ 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 @@ -277,9 +310,11 @@ $(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 +$(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 + $(TEST_CC) -O1 -o $@ dylib/tests/featcheck.c dylib/util/file.c featcheck: @if [ ! -f dylib/tests/featcheck.c ]; then \ @@ -386,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 545a7ac6..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 @@ -1607,121 +2090,58 @@ np_feat_dir="$np_support/plugins" np_feat_log() { echo "=== $* ===" >> "$log" 2>&1 || true } -np_feat_field() { - LC_ALL=C sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\(.*[^[:space:]]\)[[:space:]]*$/\1/p" "$1" -} +# 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_feat_field "$1" "$2" | head -1 + 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_feat_field "$1" requires | head -n 4 | while IFS= read -r np_feat_req; do + 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 - return 0 } np_feat_installed() { [ -z "$(np_feat_first "$1" requires)" ] || [ -n "$(np_feat_requires "$1")" ] } -np_feat_file_of() { - printf '%s\n' "$np_feat_list" | while IFS= read -r np_feat_entry; do - [ "${np_feat_entry%% *}" = "$1" ] && { printf '%s' "${np_feat_entry#* }"; break; } - done - return 0 -} -# webpatch.c skips files with overlong lines or values, so they're skipped here too. -np_feat_too_long() { - LC_ALL=C awk 'length($0) >= 2047 { f = 1 } END { exit !f }' "$1" && return 0 - for np_feat_spec in key:192:1 label:192:1 description:512:1 section:32:1 on:32:1 \ - off:32:1 depends:192:1 requires:192:4; do - np_feat_max=${np_feat_spec#*:} - np_feat_field "$1" "${np_feat_spec%%:*}" | head -n "${np_feat_spec##*:}" | - LC_ALL=C awk -v m="${np_feat_max%:*}" 'length($0) >= m { f = 1 } END { exit !f }' && - return 0 - done - return 1 -} np_feat_switched() { eval "np_feat_sv=\${$1-}" - np_feat_son=$(np_feat_first "$2" on) + np_feat_son=$(np_feat_first "$1" on) # shellcheck disable=SC2154 # eval assigns np_feat_sv above [ "$np_feat_sv" = "${np_feat_son:-1}" ] } -# Same rules as the panel. +# 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_feat_af=$(np_feat_file_of "$1") - [ -n "$np_feat_af" ] || return 1 - np_feat_switched "$1" "$np_feat_af" || return 1 - np_feat_installed "$np_feat_af" || return 1 - np_feat_adep=$(np_feat_first "$np_feat_af" depends) + 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_feat_count=0 -# Same order as webpatch.c. -np_feat_files=$(printf '%s\n' "$np_feat_dir"/*.conf | LC_ALL=C sort) -while IFS= read -r np_feat_file; do - case "$np_feat_file" in - ("$np_feat_dir"/*.conf) ;; - (*) continue ;; - esac - [ -f "$np_feat_file" ] && [ -r "$np_feat_file" ] || continue - if np_feat_too_long "$np_feat_file"; then - np_feat_log "plugins: $np_feat_file has a line or value that's too long, ignored" - continue - fi - np_feat_key=$(np_feat_first "$np_feat_file" key) - if [ -z "$np_feat_key" ]; then - np_feat_log "plugins: $np_feat_file has no key, ignored" - continue - fi - if ! printf '%s\n' "$np_feat_key" | LC_ALL=C grep -qx 'NOTPROTON_[A-Z0-9_]\{1,\}'; then - np_feat_log "plugins: $np_feat_file has an invalid key $np_feat_key, ignored" - continue - fi - if [ -z "$(np_feat_first "$np_feat_file" label)" ]; then - np_feat_log "plugins: $np_feat_file has no label, ignored" - continue - fi - case "$(np_feat_first "$np_feat_file" section)" in - ("" | dev) ;; - (*) np_feat_log "plugins: $np_feat_file is not in the dev section, ignored"; continue ;; - esac - if [ -n "$(np_feat_file_of "$np_feat_key")" ]; then - np_feat_log "plugins: $np_feat_file repeats key $np_feat_key, ignored" - continue - fi - if [ "$np_feat_count" -ge 32 ]; then - np_feat_log "plugins: $np_feat_file is over the 32 plugin limit, ignored" - continue - fi - np_feat_count=$((np_feat_count + 1)) - np_feat_list="$np_feat_list -$np_feat_key $np_feat_file" -done <