From 7ce4476bf609d6acba728239a62eda26d6dcd7ed Mon Sep 17 00:00:00 2001 From: Nathaniel Brown Date: Wed, 21 Oct 2026 08:11:38 -0400 Subject: [PATCH 1/4] Assemble runner variants from descriptors and offer them in the panel --- Makefile | 19 +- dylib/feats/compat.c | 16 ++ dylib/feats/compat.h | 2 + dylib/feats/compat_run.sh | 334 ++++++++++++++++++++++++- dylib/feats/webpatch.c | 286 ++++++++++++++++++++- dylib/feats/webpatch.h | 7 +- dylib/hooks/hook_webpatch.c | 4 + dylib/tests/featcheck.c | 125 ++++++++- dylib/tests/panel-behavior/behavior.js | 133 +++++++++- dylib/tests/panel-behavior/emit.c | 1 + dylib/tests/panel-behavior/fex.js | 4 +- dylib/tests/panel-behavior/harness.js | 8 +- dylib/tests/panel-behavior/shell.js | 2 +- dylib/tests/runnervariantcheck.sh | 254 +++++++++++++++++++ tools/install-dxmt.sh | 200 +++++++++++++++ 15 files changed, 1363 insertions(+), 32 deletions(-) create mode 100755 dylib/tests/runnervariantcheck.sh create mode 100755 tools/install-dxmt.sh diff --git a/Makefile b/Makefile index ebcc0355..dc1c480f 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 \ 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 @@ -267,9 +272,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 +283,10 @@ $(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/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 +393,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..aad2d4ad 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -154,7 +154,325 @@ 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 +# 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_variant_field() { + sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\(.*\)\$/\1/p" "$1" +} +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_v_dst="" + np_v_conf="$np_variant_conf_dir/$1.conf" + [ -r "$np_v_conf" ] || { np_variant_say "$1 has no descriptor at $np_v_conf"; return 1; } + np_v_bad=$(sed -n 's/^[[:space:]]*\([A-Za-z][A-Za-z-]*\)[[:space:]]*=.*/\1/p' "$np_v_conf" \ + | grep -vx -e label -e variant -e src -e dst | LC_ALL=C sort -u | tr '\n' ' ') || true + if [ -n "$np_v_bad" ]; then + np_variant_say "$np_v_conf has unknown keys ($np_v_bad), so $1 is refused" + return 1 + fi + np_v_src=$(np_variant_field "$np_v_conf" src | head -1) + np_v_dst=$(np_variant_field "$np_v_conf" dst | head -1) + if [ -z "$np_v_src" ] || [ -z "$np_v_dst" ]; then + np_variant_say "$np_v_conf needs both src and dst, so $1 is refused" + 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 + case "$np_v_dst" in + (/*|*..*) + np_variant_say "$np_v_conf dst must be a relative path inside the runner, so $1 is refused" + return 1 ;; + 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 +511,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 +1047,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 diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index b9705c4b..de4653af 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -1,6 +1,7 @@ // Enables Steam Play in the UI/enables the Compatibility tab in game properties #include "webpatch.h" #include "nativeplatform.h" +#include "../util/file.h" #include "../util/log.h" #include @@ -31,6 +32,8 @@ // MSCXReady and MSCXFeat. #define NP_READY "\027" #define NP_FEATS "\030" +// Stands for the runner variants read from the support directory. +#define NP_VARS "\031" #define NP_TOOL_NAME_MAX 128 @@ -263,7 +266,10 @@ static _Thread_local char g_feat_ready[NP_FEAT_MAX * (NP_FEAT_STR + 8) + 4] = "{ static _Thread_local char g_feat_rows[NP_FEAT_MAX * (2 * (3 * NP_FEAT_STR + NP_FEAT_DESC + 64) + 32) + 4] = "[]"; static _Thread_local int g_feat_ready_done; static _Thread_local int g_feat_pinned; +static _Thread_local char g_var_rows[8192] = "[]"; +static _Thread_local int g_var_done; static void feat_build(void); +static void var_build(void); static const char *marker_text(char c) { if (c == NP_FALLBACK_TOOL[0]) return g_fallback_tool; @@ -272,6 +278,10 @@ static const char *marker_text(char c) { if (!g_feat_ready_done) feat_build(); return c == NP_READY[0] ? g_feat_ready : g_feat_rows; } + if (c == NP_VARS[0]) { + if (!g_var_done) var_build(); + return g_var_rows; + } return NULL; } @@ -423,6 +433,253 @@ void np_webpatch_set_features(const char *ready, const char *rows) { g_feat_pinned = 1; } +// Runner variants are declared in the support directory's variants/*.conf and assembled by +// compat_run.sh at launch. +#define NP_VAR_MAX 24 +#define NP_VAR_STR 192 + +typedef struct { + char name[NP_VAR_STR]; + char label[NP_VAR_STR]; + char variant[NP_VAR_STR]; + char src[1024]; + char dst[NP_VAR_STR]; +} np_var_t; + +#define NP_VAR_TOOLS_MAX 8 +static _Thread_local char g_var_tool[NP_VAR_TOOLS_MAX][NP_TOOL_NAME_MAX]; +static _Thread_local char g_var_build[NP_VAR_TOOLS_MAX][NP_TOOL_NAME_MAX]; +static _Thread_local char g_var_arch[NP_VAR_TOOLS_MAX][NP_TOOL_NAME_MAX]; +static _Thread_local size_t g_var_tools; + +void np_webpatch_set_tool_builds(const char *const *names, const char *const *builds, + const char *const *archs, size_t count) { + g_var_tools = 0; + for (size_t i = 0; i < count && g_var_tools < NP_VAR_TOOLS_MAX; i++) { + if (!names[i] || !*names[i] || !builds[i] || !*builds[i]) continue; + if (!is_tool_name(names[i]) || !is_tool_name(builds[i])) continue; + snprintf(g_var_tool[g_var_tools], NP_TOOL_NAME_MAX, "%s", names[i]); + snprintf(g_var_build[g_var_tools], NP_TOOL_NAME_MAX, "%s", builds[i]); + snprintf(g_var_arch[g_var_tools], NP_TOOL_NAME_MAX, "%s", + archs && archs[i] ? archs[i] : ""); + g_var_tools++; + } +} + +// A directory named for an architecture, such as x86_64-windows, only serves the tool +// that runs that architecture +static const char *var_arch_of(const char *dir) { + static const char *const known[] = { "x86_64", "aarch64", "i386", "arm64ec" }; + for (size_t i = 0; i < sizeof(known) / sizeof(*known); i++) { + size_t n = strlen(known[i]); + if (strncmp(dir, known[i], n) == 0 && (dir[n] == 0 || dir[n] == '-')) + return known[i]; + } + return NULL; +} + +static int var_is_dir(const char *path) { + struct stat st; + return stat(path, &st) == 0 && S_ISDIR(st.st_mode); +} + +// Every directory in src that holds a file, relative to src; "" is src itself. Bounded, +// since this runs while Steam waits for a page. +static int var_dirs(const char *root, const char *rel, char dirs[][NP_VAR_STR], int n, + int depth) { + char path[2048]; + struct dirent *de; + DIR *d; + int files = 0; + + if (depth > 3 || n >= 16) return n; + if (snprintf(path, sizeof(path), "%s%s%s", root, *rel ? "/" : "", rel) >= (int)sizeof(path)) + return n; + if (!(d = opendir(path))) return n; + while ((de = readdir(d))) { + char sub[NP_VAR_STR]; + if (de->d_name[0] == '.') continue; + if (snprintf(sub, sizeof(sub), "%s%s%s", rel, *rel ? "/" : "", de->d_name) + >= (int)sizeof(sub)) continue; + if (snprintf(path, sizeof(path), "%s/%s", root, sub) >= (int)sizeof(path)) continue; + if (var_is_dir(path)) n = var_dirs(root, sub, dirs, n, depth + 1); + else files = 1; + } + closedir(d); + if (files && n < 16) snprintf(dirs[n++], NP_VAR_STR, "%s", rel); + return n; +} + +static void var_read_file(const char *path, const char *home, np_var_t *list, int *count) { + char line[2048]; + np_var_t v; + const char *base = strrchr(path, '/'); + FILE *fp; + size_t len; + + memset(&v, 0, sizeof(v)); + base = base ? base + 1 : path; + snprintf(v.name, sizeof(v.name), "%s", base); + len = strlen(v.name); + if (len > 5) v.name[len - 5] = 0; // drop .conf + for (const char *p = v.name; *p; p++) + if (!((*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z') || + (*p >= '0' && *p <= '9') || *p == '.' || *p == '-')) { + NP_WARN("variants: %s is not a usable variant name, ignored", base); + return; + } + + if (!(fp = fopen(path, "r"))) return; + while (fgets(line, sizeof(line), fp)) { + char *eq, *val, *k = line; + while (*k == ' ' || *k == '\t') k++; + if (*k == '#' || !(eq = strchr(k, '='))) continue; + *eq = 0; + val = eq + 1; + feat_trim(k); + while (*val == ' ' || *val == '\t') val++; + feat_trim(val); + if (!*val) continue; + if (strcmp(k, "label") == 0) snprintf(v.label, sizeof(v.label), "%s", val); + else if (strcmp(k, "variant") == 0) snprintf(v.variant, sizeof(v.variant), "%s", val); + else if (strcmp(k, "src") == 0) snprintf(v.src, sizeof(v.src), "%s", val); + else if (strcmp(k, "dst") == 0) snprintf(v.dst, sizeof(v.dst), "%s", val); + else { + // Refused rather than ignored, as in features.d: a typo that looks like it + // worked is worse than a variant that does not appear. + NP_WARN("variants: %s declares '%s', which is not a variant field; " + "the fields are label, variant, src and dst", path, k); + fclose(fp); + return; + } + } + fclose(fp); + + if (!*v.src || !*v.dst) { + NP_WARN("variants: %s needs both src and dst, ignored", path); + return; + } + if (v.dst[0] == '/' || strstr(v.dst, "..")) { + NP_WARN("variants: %s dst must be a relative path inside the runner, ignored", path); + return; + } + if (v.src[0] != '/') { + char abs[1024]; + if (snprintf(abs, sizeof(abs), "%s/%s", home, v.src) >= (int)sizeof(abs)) return; + snprintf(v.src, sizeof(v.src), "%s", abs); + } + if (!*v.label) snprintf(v.label, sizeof(v.label), "%s", v.name); + if (!*v.variant) snprintf(v.variant, sizeof(v.variant), "Runner"); + if (*count >= NP_VAR_MAX) { + NP_WARN("variants: more than %d descriptors, %s ignored", NP_VAR_MAX, path); + return; + } + list[(*count)++] = v; +} + +static int var_load(const char *home, np_var_t *list) { + char dir[2048]; + struct dirent *de; + DIR *d; + int count = 0; + char **names = NULL; + int nnames = 0; + + np_support_path(dir, sizeof(dir), home, "variants"); + if (!(d = opendir(dir))) return 0; + while ((de = readdir(d))) { + char **grown; + if (de->d_name[0] == '.') continue; + if (!has_suffix(de->d_name, ".conf")) continue; + grown = realloc(names, (size_t)(nnames + 1) * sizeof(*names)); + if (!grown) break; + names = grown; + names[nnames] = strdup(de->d_name); + if (!names[nnames]) break; + nnames++; + } + closedir(d); + for (int i = 1; i < nnames; i++) + for (int j = i; j > 0 && strcmp(names[j - 1], names[j]) > 0; j--) { + char *t = names[j - 1]; names[j - 1] = names[j]; names[j] = t; + } + for (int i = 0; i < nnames; i++) { + char path[4096]; + if (snprintf(path, sizeof(path), "%s/%s", dir, names[i]) < (int)sizeof(path)) + var_read_file(path, home, list, &count); + free(names[i]); + } + free(names); + return count; +} + +static void var_build(void) { + const char *home = getenv("HOME"); + np_var_t list[NP_VAR_MAX]; + np_out_t rows = { 0 }; + int n, wrote = 0; + + g_var_done = 1; + if (!home) return; + n = var_load(home, list); + if (!out_put(&rows, "[", 1)) goto done; + for (int i = 0; i < n; i++) { + char dirs[16][NP_VAR_STR]; + int ndirs = var_dirs(list[i].src, "", dirs, 0, 0); + int fits = 0; + + if (!ndirs) { + NP_WARN("variants: %s holds no files at %s, not offered", + list[i].name, list[i].src); + continue; + } + if (wrote && !out_put(&rows, ",", 1)) goto done; + if (!out_put(&rows, "{k:", 3) || !out_jsstr(&rows, list[i].name)) goto done; + if (!out_put(&rows, ",l:", 3) || !out_jsstr(&rows, list[i].label)) goto done; + if (!out_put(&rows, ",v:", 3) || !out_jsstr(&rows, list[i].variant)) goto done; + if (!out_put(&rows, ",t:[", 4)) goto done; + for (size_t t = 0; t < g_var_tools; t++) { + char runner[2048]; + int lands = 0, named = 0, matched = 0; + np_support_path(runner, sizeof(runner), home, "runners"); + for (int j = 0; j < ndirs; j++) { + char at[2048]; + const char *arch = var_arch_of(dirs[j]); + if (snprintf(at, sizeof(at), "%s/crossover-%s/CrossOver/%s%s%s", runner, + g_var_build[t], list[i].dst, *dirs[j] ? "/" : "", dirs[j]) + >= (int)sizeof(at)) continue; + if (arch) named = 1; + if (!var_is_dir(at)) continue; + lands = 1; + // A FEX runner holds both slots, so a directory landing is not enough: + // arm64 Wine reads the aarch64 one and never looks at the other. + if (arch && strcmp(arch, g_var_arch[t]) == 0) matched = 1; + } + if (!lands || (named && !matched)) continue; + if (fits++ && !out_put(&rows, ",", 1)) goto done; + if (!out_jsstr(&rows, g_var_tool[t])) goto done; + } + if (!out_put(&rows, "]}", 2)) goto done; + wrote++; + if (!fits) + NP_LOG("variants: %s fits no installed runner, so it is offered nowhere", + list[i].name); + } + if (!out_put(&rows, "]", 1)) goto done; + if (rows.len < sizeof(g_var_rows)) + snprintf(g_var_rows, sizeof(g_var_rows), "%.*s", (int)rows.len, rows.buf); + else + NP_WARN("variants: descriptors are too large to render, ignoring them"); +done: + free(rows.buf); +} + +// A check supplies the list rather than reading whatever an install happens to carry +void np_webpatch_set_variants(const char *rows) { + snprintf(g_var_rows, sizeof(g_var_rows), "%s", rows ? rows : "[]"); + g_var_done = 1; +} + static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { for (const char *r = replace; *r; r++) { const char *text = marker_text(*r); @@ -545,9 +802,29 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "fex=" NP_FEX_TOOLS ".indexOf(t.strCompatToolName||\"" NP_FALLBACK_TOOL "\")>=0," \ "npZh=" NP_WEB_IS_ZH ",npIt=" NP_WEB_IS_IT ",npL=(en,zh,it)=>npZh?zh:npIt?it:en," \ "L=MSCXLaunch(o,t.unAppID),g=L.g,s=L.s," \ - "MSCXReady=" NP_READY ",MSCXFeat=" NP_FEATS "," \ + "MSCXReady=" NP_READY ",MSCXFeat=" NP_FEATS ",MSCXVar=" NP_VARS "," \ "on1=(k,n=0)=>{const p=MSCXFeat.find(x=>x.k===k);" \ "return!!p&&n<32&&MSCXReady[k]!==0&&g(k)===p.on&&(!p.dep||on1(p.dep,n+1))}," \ + "vav=MSCXVar.filter(v=>v.t.indexOf(t.strCompatToolName||\"" NP_FALLBACK_TOOL "\")>=0)," \ + "vcur=g(\"NOTPROTON_RUNNER_VARIANT\").split(\"+\").filter(x=>x)," \ + "vids=vav.map(v=>v.v).filter((d,i,a)=>a.indexOf(d)===i)," \ + "vset=(id,val)=>{const keep=vcur.filter(x=>MSCXVar.some(v=>v.k===x&&v.v!==id))," \ + "all=(val?keep.concat([val]):keep).filter((x,i,a)=>a.indexOf(x)===i).sort();" \ + "s([[\"NOTPROTON_RUNNER_VARIANT\",all.join(\"+\")]])}," \ + "V=id=>{const vd=vav.filter(v=>v.v===id)," \ + "sel=vcur.find(x=>vd.some(v=>v.k===x))||\"\";" \ + "return(0," RT ".jsx)(" BARREL ".m," \ + "{label:id+npL(\" Variant\",\" 变体\",\" Variant\")," \ + "layout:\"inline\"," \ + "rgOptions:[{data:\"\",label:npL(\"Default\",\"默认\",\"Default\")}]" \ + ".concat(vd.map(v=>({data:v.k,label:v.l})))," \ + "selectedOption:sel,onChange:x=>vset(id,x.data)},id)}," \ + "TD=(k,l,on,off,d,dep)=>{const rd=MSCXReady[k]!==0;" \ + "return(0," RT ".jsx)(" BARREL ".y4," \ + "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "description:d||undefined,checked:rd&&g(k)===on," \ + "indentLevel:dep?1:undefined," \ + "onChange:v=>s([[k,v?on:(off||\"\")]])},k)}," \ "T=(ks,l,on,off,d,r)=>{const rd=r!==0;" \ "return(0," RT ".jsx)(" BARREL ".Yh," \ "{className:\"MSCXRow\"+(d?\" MSCXRowDesc\":\"\")," \ @@ -605,9 +882,10 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "\"Lascia che i giochi leggano direttamente i controller (pu\\u00f2 interferire con Steam Input, sconsigliato)\"),\"1\",\"\")," \ "T([\"NOTPROTON_DISABLE_HIDRAW\"],npL(\"Disable hidraw\",\"停用 hidraw\",\"Disable hidraw\"),\"1\",\"\")" \ "]},\"ctl\")," \ - "(MSCXFeat.length?(0," RT ".jsx)(" BARREL ".XY,{label:npL(\"Developer\",\"开发者\",\"Developer\")," \ - "children:MSCXFeat.filter(f=>!f.dep||on1(f.dep))" \ - ".map(f=>T([f.k],f.l,f.on,f.off,f.d,MSCXReady[f.k]))},\"dev\"):null)" \ + "(MSCXFeat.length||vids.length?(0," RT ".jsx)(" BARREL ".J1,{children:(0," RT ".jsx)(" BARREL ".XY," \ + "{label:npL(\"Developer\",\"开发者\",\"Developer\")," \ + "children:vids.map(V).concat(MSCXFeat.filter(f=>!f.dep||on1(f.dep))" \ + ".map(f=>TD(f.k,f.l,f.on,f.off,f.d,f.dep)))})},\"dev\"):null)" \ "]})})}" #define NP_CX_OPTIONS_CAPTURE(RT, BARREL) \ diff --git a/dylib/feats/webpatch.h b/dylib/feats/webpatch.h index 9c87b593..4734d07c 100644 --- a/dylib/feats/webpatch.h +++ b/dylib/feats/webpatch.h @@ -16,10 +16,15 @@ void np_webpatch_set_fallback_tool(const char *name); // Sets the tools this thread's CrossOver options panel treats as FEX. Names the // fallback would drop are skipped, as is anything past NP_WEBPATCH_FEX_TOOLS_MAX. void np_webpatch_set_fex_tools(const char *const *names, size_t count); +// Compat tool names, the CrossOver build each one runs and the architecture it runs it +// at, used to work out which runner variants can be offered for a game. +void np_webpatch_set_tool_builds(const char *const *names, const char *const *builds, + const char *const *archs, size_t count); -// For tests, so they don't depend on the plugins installed on the machine. +// For tests, so they don't depend on the plugins or variants installed on the machine. void np_webpatch_set_features(const char *ready, const char *rows); +void np_webpatch_set_variants(const char *rows); // The compat UIs, named so a caller can compare against the table rather than a literal // of its own that a rename would leave behind. forcetool calls SpecifyCompatTool straight // out; selecttool reads its list from the CompatManager routes. diff --git a/dylib/hooks/hook_webpatch.c b/dylib/hooks/hook_webpatch.c index 122bbe71..90060afe 100644 --- a/dylib/hooks/hook_webpatch.c +++ b/dylib/hooks/hook_webpatch.c @@ -123,6 +123,10 @@ static int open_patched(const char *path) { np_webpatch_set_fallback_tool(np_compat_fallback_tool_name()); const char *fex[NP_WEBPATCH_FEX_TOOLS_MAX]; np_webpatch_set_fex_tools(fex, np_compat_fex_tools(fex, NP_WEBPATCH_FEX_TOOLS_MAX)); + const char *tools[NP_WEBPATCH_FEX_TOOLS_MAX], *builds[NP_WEBPATCH_FEX_TOOLS_MAX]; + const char *archs[NP_WEBPATCH_FEX_TOOLS_MAX]; + np_webpatch_set_tool_builds(tools, builds, archs, + np_compat_tool_builds(tools, builds, archs, NP_WEBPATCH_FEX_TOOLS_MAX)); size_t patched_len = 0; const char *shape = NULL; char *patched = np_webpatch_transform((const uint8_t *)raw, raw_len, &patched_len, diff --git a/dylib/tests/featcheck.c b/dylib/tests/featcheck.c index fd2d8730..fc2edac0 100644 --- a/dylib/tests/featcheck.c +++ b/dylib/tests/featcheck.c @@ -14,6 +14,7 @@ char *np_nativeplatform_javascript(void) { static int g_failed; static char g_home[1024]; static char g_featdir[1200]; +static char g_vardir[1200]; static void ok(int cond, const char *what) { printf(" %s %s\n", cond ? "ok " : "FAIL", what); @@ -37,6 +38,35 @@ static void write_desc(const char *name, const char *body) { fclose(f); } +static void write_var(const char *name, const char *body) { + char path[2048]; + FILE *f; + snprintf(path, sizeof(path), "%s/%s", g_vardir, name); + if (!(f = fopen(path, "w"))) { shout("cannot write %s", path); g_failed++; return; } + fputs(body, f); + fclose(f); +} + +// Variants are offered per compat tool, and a tool is only a build whose runner is on +// disk, so a check has to build both sides of that. +static void make_dir(const char *fmt, const char *a, const char *b) { + char cmd[4096]; + snprintf(cmd, sizeof(cmd), fmt, a, b); + if (system(cmd) != 0) { shout("cannot run %s", cmd); g_failed++; } +} + +static char *vars_json(void) { + g_var_done = 0; + var_build(); + return g_var_rows; +} + +static void clear_vars(void) { + char cmd[2600]; + snprintf(cmd, sizeof(cmd), "rm -f '%s'/*.conf", g_vardir); + if (system(cmd) != 0) { shout("cannot clear %s", g_vardir); g_failed++; } +} + static void clear_descs(void) { char cmd[2600]; snprintf(cmd, sizeof(cmd), "rm -f '%s'/*.conf", g_featdir); @@ -70,8 +100,12 @@ int main(void) { snprintf(g_home, sizeof(g_home), "%s", dir); snprintf(g_featdir, sizeof(g_featdir), "%s/" NP_FEAT_DIR, g_home); - snprintf(cmd, sizeof(cmd), "mkdir -p '%s'", g_featdir); - if (system(cmd) != 0) { fputs("cannot make the plugins folder\n", stderr); return 2; } + np_support_path(g_vardir, sizeof(g_vardir), g_home, "variants"); + snprintf(cmd, sizeof(cmd), "mkdir -p '%s' '%s'", g_featdir, g_vardir); + if (system(cmd) != 0) { + fputs("cannot make the plugins and variants folders\n", stderr); + return 2; + } setenv("HOME", g_home, 1); puts("== an empty plugins folder"); @@ -218,10 +252,95 @@ int main(void) { ok(strcmp(feats_json(), "[]") == 0, "renders no rows, since nothing ships by default"); ok(strcmp(ready_json(), "{}") == 0, "reports no readiness"); + puts("== runner variants"); + { + const char *tools[] = { "notproton", "notproton-26.3", "notproton-fex-rosetta" }; + const char *builds[] = { "27.0-fex", "26.3.0", "27.0-fex" }; + const char *archs[] = { "aarch64", "x86_64", "x86_64" }; + np_webpatch_set_tool_builds(tools, builds, archs, 3); + } + // The 26.3 runner carries one slot. The FEX runner carries both, as a real one does, + // which is why the slot a tool actually runs has to decide and not the directory. + make_dir("mkdir -p '%s/Library/Application Support/notproton/runners/" + "crossover-26.3.0/CrossOver/lib/dxmt/x86_64-windows'%s", g_home, ""); + make_dir("mkdir -p '%s/Library/Application Support/notproton/runners/" + "crossover-27.0-fex/CrossOver/lib/dxmt/'{x86_64,aarch64}'-windows'%s", + g_home, ""); + make_dir("mkdir -p '%s/trees/one/x86_64-windows'%s", g_home, ""); + make_asset("trees/one/x86_64-windows/d3d11.dll"); + + ok(strcmp(vars_json(), "[]") == 0, "an empty directory offers nothing"); + + write_var("one.conf", + "label = A build\nvariant = Backend\n" + "src = trees/one\ndst = lib/dxmt\n"); + ok(strcmp(vars_json(), + "[{k:\"one\",l:\"A build\",v:\"Backend\"," + "t:[\"notproton-26.3\",\"notproton-fex-rosetta\"]}]") == 0, + "an x86_64 tree is offered to the tools that run x86_64, and not to the arm64 one"); + + puts("== a tree for the other architecture"); + clear_vars(); + make_dir("mkdir -p '%s/trees/otherarch/aarch64-windows'%s", g_home, ""); + make_asset("trees/otherarch/aarch64-windows/d3d11.dll"); + write_var("otherarch.conf", "src = trees/otherarch\ndst = lib/dxmt\n"); + ok(strstr(vars_json(), "t:[\"notproton\"]") != NULL, + "an arm64 tree is offered only to the tool that runs arm64 Wine"); + ok(strstr(vars_json(), "l:\"otherarch\"") != NULL, + "and is labelled with its file name when the descriptor gives no label"); + ok(strstr(vars_json(), "v:\"Runner\"") != NULL, + "and names no variant of its own, so it is offered as a plain runner variant"); + + puts("== a tree carrying both architectures"); + clear_vars(); + make_dir("mkdir -p '%s/trees/both/'{x86_64,aarch64}'-windows'%s", g_home, ""); + make_asset("trees/both/x86_64-windows/d3d11.dll"); + make_asset("trees/both/aarch64-windows/d3d11.dll"); + write_var("bothslots.conf", "src = trees/both\ndst = lib/dxmt\n"); + // A cross build produces one architecture at a time but installs them into one + // prefix, so a shared src holds both -- and the first runner has only the x86_64 slot. + ok(strstr(vars_json(), "t:[\"notproton\",\"notproton-26.3\",\"notproton-fex-rosetta\"]") + != NULL, "is offered everywhere, each runner taking the slot it has"); + + puts("== rejections"); + clear_vars(); + write_var("ok-one.conf", "src = trees/one\ndst = lib/dxmt\n"); + write_var("typo.conf", "src = trees/one\ndst = lib/dxmt\nsrcc = elsewhere\n"); + write_var("nowhere.conf", "label = No source\n"); + write_var("escape.conf", "src = trees/one\ndst = ../../lib/wine\n"); + write_var("absolute.conf", "src = trees/one\ndst = /lib/wine\n"); + { + const char *j = vars_json(); + ok(strstr(j, "ok-one") != NULL, "a good descriptor still loads"); + ok(strstr(j, "typo") == NULL, "an unknown field is refused"); + ok(strstr(j, "No source") == NULL, "a descriptor with no src is refused"); + ok(strstr(j, "escape") == NULL, "a dst that climbs out of the runner is refused"); + ok(strstr(j, "absolute") == NULL, "an absolute dst is refused"); + } + + puts("== a tree holding nothing"); + clear_vars(); + make_dir("mkdir -p '%s/trees/empty/x86_64-windows'%s", g_home, ""); + write_var("hollow.conf", "src = trees/empty\ndst = lib/dxmt\n"); + ok(strcmp(vars_json(), "[]") == 0, "is not offered at all"); + + puts("== a label cannot break the page it renders into"); + clear_vars(); + write_var("quoted.conf", + "label = He said \"go\" \\ onwards\nsrc = trees/one\ndst = lib/dxmt\n"); + ok(strstr(vars_json(), "l:\"He said \\\"go\\\" \\\\ onwards\"") != NULL, + "quotes and backslashes are escaped"); + + puts("== a missing variant directory"); + clear_vars(); + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", g_vardir); + if (system(cmd) != 0) { shout("cannot remove %s", g_vardir); g_failed++; } + ok(strcmp(vars_json(), "[]") == 0, "offers nothing, since nothing ships by default"); + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", g_home); if (system(cmd) != 0) shout("note: could not remove %s", g_home); if (g_failed) { printf("\n%d failure(s)\n", g_failed); return 1; } - puts("\n==> featcheck: plugins load, rank, validate and report readiness"); + puts("\n==> featcheck: plugins and variants load, validate and report readiness"); return 0; } diff --git a/dylib/tests/panel-behavior/behavior.js b/dylib/tests/panel-behavior/behavior.js index 7070d21f..4c57d91e 100644 --- a/dylib/tests/panel-behavior/behavior.js +++ b/dylib/tests/panel-behavior/behavior.js @@ -10,7 +10,7 @@ for (const form of Object.keys(FORMS)) { const { render: P, written } = panel(emit, form); const t = runner(form); const nodes = opts => walk(P({ details: details(opts + ' %command%') })); - const toggles = ns => ns.filter(x => x.type === 'Toggle').map(x => x.props.label); + const toggles = ns => ns.filter(x => x.type === 'Checkbox').map(x => x.props.label); const sections = ns => ns.filter(x => x.type === 'Section').map(x => x.props.label.split(' ')[0]); const last = () => (written.length ? written[written.length - 1].opts : ''); @@ -78,7 +78,7 @@ for (const form of Object.keys(FORMS)) { const root = P({ details: details('') }); t.ok(root.props.className === 'MSCXPanel', 'the panel root carries its own class'); - const classes = nodes('').filter(x => x.type === 'Toggle').map(x => x.props.className); + const classes = nodes('').filter(x => x.type === 'Checkbox').map(x => x.props.className); t.ok(classes.length === 7 && classes.every(c => c === 'MSCXRow'), 'every checkbox carries the row class'); const raw = o => nodes(o).find(x => x.props.label === 'Let games read controllers directly (May break Steam Input. Not recommended)'); t.ok(sections(nodes('')).includes('Controllers') && raw('').props.checked === false, @@ -125,12 +125,19 @@ for (const form of Object.keys(FORMS)) { { ready: '{"NOTPROTON_ALPHA":0}', feats: FEATS }); const fnodes = opts => walk(PF({ details: details(opts) })); const frows = opts => fnodes(opts).filter(x => x.type === 'Toggle'); + const grows = opts => fnodes(opts).filter(x => x.type === 'Checkbox'); const flabels = opts => frows(opts).map(x => x.props.label); const alpha = o => frows(o).find(x => x.props.label.startsWith('Alpha')); t.ok(fnodes('').filter(x => x.type === 'Section') .some(x => x.props.label === 'Developer'), 'plugins add a Developer section'); + // y4 is the theme's ToggleField, the switch Steam's own settings rows use. Yh is + // DialogCheckbox, which nothing in Steam's UI renders any more. + t.ok(frows('').length > 0 && frows('').every(x => x.type === 'Toggle'), + 'Developer rows render as toggles, not checkboxes'); + t.ok(grows('').length > 0, + 'the Graphics and Controllers rows keep the checkbox they always had'); t.ok(!flabels('').some(l => l.startsWith('Beta:')), 'a dependent row stays hidden while its parent is off'); t.ok(flabels('NOTPROTON_BETA=1 %command%').some(l => l.startsWith('Beta:')), @@ -147,10 +154,25 @@ for (const form of Object.keys(FORMS)) { 'only that one is disabled'); t.ok(frows('').find(x => x.props.label === 'Beta').props.disabled === false, 'a plugin with no readiness entry stays enabled'); - t.ok(frows('').find(x => x.props.label === 'Beta').props.className === 'MSCXRow MSCXRowDesc', - 'a row with a description opts out of the row-direction rule'); - t.ok(frows('').find(x => x.props.label.startsWith('Alpha')).props.className === 'MSCXRow', - 'a row without one keeps the plain row class'); + // The ToggleField lays out label, description and the switch itself, so a Developer + // row carries no class of ours -- the row CSS used to wrap the control onto its own + // line, which is what made the switch sit under the description. + t.ok(frows('').every(x => x.props.className === undefined), + 'Developer rows bring no styling of their own'); + // Without this provider BARREL.m resolves to a bare fallback that ignores layout, + // description and the fixed-width container -- which is what TODO 19 was chasing. + t.ok(fnodes('').some(x => x.type === 'FieldProvider'), + 'the Developer section is wrapped in the field provider'); + t.ok(!nodes('').some(x => x.type === 'FieldProvider'), + 'and the Graphics dropdowns are left outside it'); + // A row that only exists while its parent is on is a sub-setting, and the field + // indents it the way Steam's own nested settings are shown. + t.ok(frows('NOTPROTON_BETA=1 %command%') + .find(x => x.props.label.startsWith('Beta:')).props.indentLevel === 1, + 'a dependent row is indented under its parent'); + t.ok(frows('NOTPROTON_BETA=1 %command%') + .find(x => x.props.label === 'Beta').props.indentLevel === undefined, + 'and a top-level row is not'); t.ok(frows('').find(x => x.props.label === 'Beta').props.description === 'Why Beta.', "a plugin's description reaches the row"); t.ok(frows('').find(x => x.props.label.startsWith('Alpha')).props.description === undefined, @@ -181,6 +203,105 @@ for (const form of Object.keys(FORMS)) { .every(x => x.props.disabled !== true), 'readiness never disables a built-in row'); + // Runner variants are a single choice per dimension, so they render as a dropdown + // rather than as toggles: nothing has to keep two rows from being on at once, and the + // value written is the one compat_run.sh resolves. + const VARS = JSON.stringify([ + { k: 'one', l: 'Build one', v: 'Backend', t: ['notproton'] }, + { k: 'two', l: 'Build two', v: 'Backend', t: ['notproton', 'notproton-26.3'] }, + { k: 'three', l: 'Build three', v: 'Loader', t: ['notproton'] }, + { k: 'elsewhere', l: 'For another runner', v: 'Backend', t: ['notproton-26.3'] }, + ]).replace(/"(\w+)":/g, '$1:'); + + const { render: PV, written: vwritten } = panel(emit, form, undefined, { vars: VARS }); + const vnodes = opts => walk(PV({ details: details(opts) })); + // The picker is the DropDownField itself: it lays out its own label, so there is no + // wrapper row to look inside. + const vpicks = opts => vnodes(opts).filter( + x => x.type === 'Dropdown' && typeof x.props.label === 'string' + && x.props.label.endsWith(' Variant')); + const vdrop = (opts, id) => + vpicks(opts).find(x => x.props.label === id + ' Variant'); + const vwrite = (opts, id, data) => { + vwritten.length = 0; + vdrop(opts, id).props.onChange({ data }); + return vwritten.length ? vwritten[0].opts : null; + }; + + t.ok(vpicks('').length === 2, 'one row per variant the tool has builds for'); + t.ok(vpicks('').map(x => x.props.label).join(',') === 'Backend Variant,Loader Variant', + 'each row is labelled for the variant it chooses'); + // Inside the provider the field lays itself out, so none of the hard spaces or row + // CSS that stood in for that survives. + t.ok(vpicks('').every(x => !/\u00a0/.test(x.props.label)), + 'with no hard spaces faking a gap'); + t.ok(vpicks('').every(x => x.props.layout === 'inline'), + 'the label sits beside the control rather than above it'); + t.ok(vpicks('').every(x => x.props.strClassName === undefined), + 'and no row class faking a flex row'); + t.ok(vnodes('').filter(x => x.type === 'Section') + .some(x => x.props.label === 'Developer'), + 'variants alone are enough for a Developer section'); + t.ok(vdrop('', 'Backend').props.rgOptions.map(o => o.data).join(',') === ',one,two', + 'it offers the default and that variant’s builds, in descriptor order'); + t.ok(vdrop('', 'Backend').props.rgOptions[1].label === 'Build one', + 'a build is named by its label, not its key'); + t.ok(!vdrop('', 'Backend').props.rgOptions.some(o => o.data === 'elsewhere'), + 'a variant that does not fit this tool is not offered'); + t.ok(vdrop('', 'Backend').props.rgOptions[0].label === 'Default', + 'the empty choice is named for what it means: whatever the runner ships'); + t.ok(vdrop('', 'Loader').props.rgOptions.map(o => o.data).join(',') === ',three', + 'the other variant offers only its own'); + t.ok(vdrop('', 'Backend').props.selectedOption === '', + 'nothing selected shows as the default'); + t.ok(vdrop('NOTPROTON_RUNNER_VARIANT=two %command%', 'Backend') + .props.selectedOption === 'two', + 'a launch option selects its variant'); + t.ok(vdrop('NOTPROTON_RUNNER_VARIANT=two %command%', 'Loader') + .props.selectedOption === '', + 'and leaves another variant alone'); + + t.ok(vwrite('', 'Backend', 'one') === 'NOTPROTON_RUNNER_VARIANT=one %command%', + 'picking one writes the launch option'); + t.ok(vwrite('NOTPROTON_RUNNER_VARIANT=one %command%', 'Backend', '') === '', + 'picking the default clears it'); + t.ok(vwrite('NOTPROTON_RUNNER_VARIANT=one %command%', 'Backend', 'two') + === 'NOTPROTON_RUNNER_VARIANT=two %command%', + 'picking another build of the same variant replaces it'); + t.ok(vwrite('NOTPROTON_RUNNER_VARIANT=one %command%', 'Loader', 'three') + === 'NOTPROTON_RUNNER_VARIANT=one+three %command%', + 'a second variant joins rather than replaces'); + t.ok(vwrite('NOTPROTON_RUNNER_VARIANT=one+three %command%', 'Backend', '') + === 'NOTPROTON_RUNNER_VARIANT=three %command%', + 'clearing one variant keeps the other'); + // compat_run.sh sorts the names before naming the runner, so the panel sorts too and + // one combination cannot end up with two runner directories. + t.ok(vwrite('NOTPROTON_RUNNER_VARIANT=three %command%', 'Backend', 'one') + === 'NOTPROTON_RUNNER_VARIANT=one+three %command%', + 'the written order is sorted, whichever order it was picked in'); + // A build that exists but belongs to another runner is kept: switching the compat tool + // back has to restore it. + t.ok(vwrite('NOTPROTON_RUNNER_VARIANT=elsewhere %command%', 'Loader', 'three') + === 'NOTPROTON_RUNNER_VARIANT=elsewhere+three %command%', + 'a build for another runner is left in place'); + // One no descriptor exists for is not: it can never be chosen or restored here, and + // leaving it in made every later choice resolve to nothing at all. + t.ok(vwrite('NOTPROTON_RUNNER_VARIANT=missing %command%', 'Backend', 'one') + === 'NOTPROTON_RUNNER_VARIANT=one %command%', + 'a name with no descriptor is dropped'); + t.ok(vwrite('NOTPROTON_RUNNER_VARIANT=elsewhere %command%', 'Backend', 'one') + === 'NOTPROTON_RUNNER_VARIANT=one %command%', + 'and so is another runner\u2019s build of the variant being set'); + + t.ok(!css[0].props.children.includes('MSCXRowPick'), + 'no hand-rolled picker row styling survives'); + // layout:"inline" does not reach this field in the compat page -- without MSCXRow's + // display:flex the label stacks above the control -- so the flex row has to stay. + t.ok(/\.MSCXRow\{[^}]*display:flex/.test(css[0].props.children), + 'the row keeps the flex that actually puts label and control on one line'); + t.ok(!sections(nodes('')).includes('Developer'), + 'no variants and no descriptors still means no Developer section'); + const { render: S } = panel(emit, form, undefined, { names: ['o', 't'] }); const shadowed = walk(S({ details: details('CX_GRAPHICS_BACKEND=dxmt %command%') })); t.ok(JSON.stringify(shadowed) === JSON.stringify(nodes('CX_GRAPHICS_BACKEND=dxmt')), diff --git a/dylib/tests/panel-behavior/emit.c b/dylib/tests/panel-behavior/emit.c index ddcf2236..088b8534 100644 --- a/dylib/tests/panel-behavior/emit.c +++ b/dylib/tests/panel-behavior/emit.c @@ -28,6 +28,7 @@ int main(int argc, char **argv) { np_webpatch_set_fex_tools((const char *const *)argv + 3, argc > 3 ? (size_t)(argc - 3) : 0); np_webpatch_set_features(getenv("NP_TEST_READY"), getenv("NP_TEST_FEATS")); + np_webpatch_set_variants(getenv("NP_TEST_VARS")); put_panel(strcmp(argv[1], "component") == 0 ? NP_CX_OPTIONS_COMPONENT : NP_CX_OPTIONS_STATEMENT); return 0; diff --git a/dylib/tests/panel-behavior/fex.js b/dylib/tests/panel-behavior/fex.js index 74a5efd3..e866e953 100644 --- a/dylib/tests/panel-behavior/fex.js +++ b/dylib/tests/panel-behavior/fex.js @@ -19,7 +19,7 @@ for (const form of Object.keys(FORMS)) { const tool = fex ? 'notproton-fex' : 'notproton-rosetta'; const nodes = opts => walk(P({ details: details(opts + ' %command%', { strCompatToolName: tool }) })); - const toggles = ns => ns.filter(x => x.type === 'Toggle').map(x => x.props.label); + const toggles = ns => ns.filter(x => x.type === 'Checkbox').map(x => x.props.label); const backends = ns => ns.find(x => x.type === 'Dropdown').props.rgOptions.map(o => o.data); let r = nodes(''); @@ -53,7 +53,7 @@ for (const form of Object.keys(FORMS)) { written.length = 0; const kept = 'ROSETTA_ADVERTISE_AVX=1 CX_GRAPHICS_BACKEND=dxvk'; r = nodes(kept); - r.find(x => x.type === 'Toggle' && x.props.label === 'Metal HUD').props.onChange(true); + r.find(x => x.type === 'Checkbox' && x.props.label === 'Metal HUD').props.onChange(true); t.ok(written[written.length - 1].opts.includes('ROSETTA_ADVERTISE_AVX=1'), 'editing another row keeps the AVX2 value'); failed += t.failed; diff --git a/dylib/tests/panel-behavior/harness.js b/dylib/tests/panel-behavior/harness.js index c36808d8..2f8ac860 100644 --- a/dylib/tests/panel-behavior/harness.js +++ b/dylib/tests/panel-behavior/harness.js @@ -15,7 +15,8 @@ const expand = (s, caps) => s.replace(/[\u0001-\u0008\u0013-\u0016]/g, c => { const mk = (type, props) => ({ type, props }); const react = { jsx: mk, jsxs: mk, Fragment: 'Fragment' }; -const barrel = { XY: 'Section', m: 'Dropdown', Yh: 'Toggle' }; +const barrel = { XY: 'Section', m: 'Dropdown', y4: 'Toggle', Yh: 'Checkbox', + J1: 'FieldProvider' }; // The two shapes take the payload in different forms and bind different names, which // is the whole of the difference between them. @@ -27,10 +28,13 @@ const FORMS = { // `fallback` and `fexTools` stand in for what the hook sets before the transform. function panel(emit, form, globals = { document: { documentElement: { lang: 'en' } } }, { fallback = 'notproton', fexTools = [], names = ['R0', 'B0'], - ready, feats } = {}) { + ready, feats, vars } = {}) { + // Feature rows are handed over rather than read from disk, so a check reads the same + // on any machine and says nothing about which features an install carries. const env = { ...process.env }; if (ready !== undefined) env.NP_TEST_READY = ready; if (feats !== undefined) env.NP_TEST_FEATS = feats; + if (vars !== undefined) env.NP_TEST_VARS = vars; const raw = execFileSync(emit, [form, fallback, ...fexTools], { encoding: 'utf8', env }).trim(); const f = FORMS[form]; diff --git a/dylib/tests/panel-behavior/shell.js b/dylib/tests/panel-behavior/shell.js index 50e32871..019c1ad8 100644 --- a/dylib/tests/panel-behavior/shell.js +++ b/dylib/tests/panel-behavior/shell.js @@ -54,7 +54,7 @@ function view(form, options) { const P = panel(emit, form); const nodes = walk(P.render({ details: details(options) })); const toggles = {}; - nodes.filter(n => n.type === 'Toggle' && n.props.label in TOGGLES) + nodes.filter(n => n.type === 'Checkbox' && n.props.label in TOGGLES) .forEach(n => { toggles[TOGGLES[n.props.label]] = n; }); const backend = nodes.find(n => n.type === 'Dropdown' && n.props.rgOptions.some(o => o.data === 'dxvk')); const upscale = nodes.find(n => n.type === 'Dropdown' && n.props.rgOptions.some(o => o.data === '1.5')); diff --git a/dylib/tests/runnervariantcheck.sh b/dylib/tests/runnervariantcheck.sh new file mode 100755 index 00000000..016ba422 --- /dev/null +++ b/dylib/tests/runnervariantcheck.sh @@ -0,0 +1,254 @@ +#!/bin/sh +# NOTPROTON_RUNNER_VARIANT picks a runner assembled from variant descriptors, builds it +# when the trees behind it have moved on, refuses a tree that would silently fail, and +# never stops a game starting. +set -e +SRC="${1:-$(dirname "$0")/../feats/compat_run.sh}" +[ -f "$SRC" ] || { echo "runnervariantcheck: $SRC not present, skipped"; exit 0; } + +block=$(sed -n '/^np_variant_conf_dir=/,/^export CX_ROOT$/p' "$SRC") +case "$block" in + *np_variant_overlay*np_variant_resolve*) ;; + *) echo "FAIL: the variant block was not found in $SRC"; exit 1 ;; +esac + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +base="$work/runners/crossover-26.3/CrossOver" +mkdir -p "$base/lib/wine" "$base/lib/dxmt/x86_64-windows" "$base/lib/dxmt/x86_64-unix" \ + "$work/variants" +printf '%s\n' "$block" > "$work/block.sh" + +# A PE winebuild has marked as a builtin carries the marker over the DOS stub at 0x40. +dll() { + : > "$1" + dd if=/dev/zero of="$1" bs=1 count=64 2>/dev/null + printf 'Wine builtin DLL' >> "$1" + printf '%s' "${2:-}" >> "$1" +} +native_dll() { + dd if=/dev/zero of="$1" bs=1 count=80 2>/dev/null +} + +desc() { + cat > "$work/variants/$1.conf" +} + +resolve() { + NOTPROTON_RUNNER_VARIANT="$1" NOTPROTON_VARIANT_REBUILD="${2:-}" \ + HOME="$work" np_support="$work" np_build="26.3" np_flavor="${3:-rosetta}" \ + log="$work/log" PATH="$PATH" \ + sh -c '. "$1/block.sh"; printf "%s" "$CX_ROOT"' sh "$work" +} + +fail=0 +ok() { if [ "$2" = "$3" ]; then echo " ok $1"; else + echo " FAIL $1 (want [$3], got [$2])"; fail=1; fi; } +logged() { + if grep -qF "$2" "$work/log"; then echo " ok $1"; else + echo " FAIL $1 (no [$2] in the log)"; fail=1; fi +} + +echo "== without descriptors" +ok "no variant uses the build's own runner" "$(resolve '')" "$base" +ok "a variant that is not installed falls back" "$(resolve 'notinstalled')" "$base" +ok "a name with a slash is refused" "$(resolve '../../etc')" "$base" +ok "a name with a space is refused" "$(resolve 'bad name')" "$base" + +# What install-dxmt.sh produced before descriptors existed still resolves. +prebuilt="$work/runners/crossover-26.3-prebuilt/CrossOver" +mkdir -p "$prebuilt/lib/wine" +ok "a runner built by hand is still used" "$(resolve 'prebuilt')" "$prebuilt" + +echo "== a descriptor builds the runner" +mkdir -p "$work/trees/one/x86_64-windows" "$work/trees/one/x86_64-unix" +dll "$work/trees/one/x86_64-windows/d3d11.dll" one +printf 'unix half\n' > "$work/trees/one/x86_64-unix/winemetal.so" +desc one < "$work/log" +marker="$built/lib/wine/not-from-the-tree" +: > "$marker" +ok "the runner is reused" "$(resolve 'one')" "$built" +ok "nothing was rebuilt" "$([ -f "$marker" ] && echo kept)" "kept" + +echo "== a changed tree is rebuilt" +dll "$work/trees/one/x86_64-windows/d3d11.dll" twotwotwo +ok "the runner is used" "$(resolve 'one')" "$built" +ok "the new tree is copied in" "$(cat "$built/lib/dxmt/x86_64-windows/d3d11.dll" | tr -d '\000')" \ + "Wine builtin DLLtwotwotwo" +ok "the rebuild is a fresh clone" "$([ -f "$marker" ] || echo gone)" "gone" +logged "the build is reported" "building crossover-26.3-one" + +echo "== a rebuild can be forced" +: > "$marker" +ok "the runner is used" "$(resolve 'one' 1)" "$built" +ok "the runner was rebuilt anyway" "$([ -f "$marker" ] || echo gone)" "gone" + +echo "== trees that would fail silently are refused" +: > "$work/log" +mkdir -p "$work/trees/native/x86_64-windows" +native_dll "$work/trees/native/x86_64-windows/d3d11.dll" +desc unmarked < "$work/log" +mkdir -p "$work/trees/arm/aarch64-windows" +dll "$work/trees/arm/aarch64-windows/d3d11.dll" +desc otherarch < "$work/log" +desc typo < "$work/log" +desc escape < "$work/log" +desc gone < "$work/log" +mkdir -p "$work/trees/both/x86_64-windows" "$work/trees/both/aarch64-windows" +dll "$work/trees/both/x86_64-windows/d3d11.dll" bothbothboth +dll "$work/trees/both/aarch64-windows/d3d11.dll" armarmarm +desc bothslots < "$work/log" +mkdir -p "$base/lib/dxmt/aarch64-windows" +ok "an x86_64 tree is still used where x86_64 is what runs" "$(resolve 'one' '' rosetta)" "$built" +ok "and is refused where arm64 Wine runs" "$(resolve 'one' '' fex)" "$base" +logged "which says what the runner runs" "has nothing for aarch64, which is what this runner runs" +ok "a tree carrying both is used either way" "$(resolve 'bothslots' 1 fex)" "$bothruns" +ok "and the slot that runner runs is laid down once the runner has it" \ + "$(tr -d '\000' < "$bothruns/lib/dxmt/aarch64-windows/d3d11.dll")" "Wine builtin DLLarmarmarm" +# The check has to run on every launch, not only when the variant is built: a variant +# directory is named for the build, and two tools can share a build with different +# flavors, so the second one asks for a runner that is already there. +: > "$work/log" +ok "an already built variant is re-checked, not waved through" "$(resolve 'one' '' fex)" "$base" +logged "and says so again" "has nothing for aarch64" +# A tree naming no architecture is not filtered: dst may be a flat directory. +: > "$work/log" +mkdir -p "$work/trees/flat" +dll "$work/trees/flat/plain.dll" flat +desc flat < "$work/log" +mkdir -p "$work/trees/nowhere/sub" +dll "$work/trees/nowhere/sub/plain.dll" nowhere +desc nowhere < "$work/log" +ok "the members that work are still used" "$(resolve 'one+gone')" "$built" +logged "and the rest is reported" "ignoring gone, which cannot be used" +ok "a request of nothing but unusable names falls back" "$(resolve 'gone+typo')" "$base" +# The runner is named for what went into it, not for what was asked for. +ok "the runner is named for the members that worked" \ + "$([ -d "$work/runners/crossover-26.3-one-gone" ] || echo absent)" "absent" +# And a prebuilt runner is still reachable when nothing in the request has a descriptor. +ok "a prebuilt runner still resolves beside descriptors" "$(resolve 'prebuilt')" "$prebuilt" + +echo "== a live runner is left alone" +: > "$work/log" +: > "$marker" +dll "$work/trees/one/x86_64-windows/d3d11.dll" threethreethree +# np_variant_busy reads the executable path of every process, as RunnerInstaller.isRunning +# does. Standing a fake ps in for one keeps the test off whether macOS will run a copied +# binary; the assumption it rests on -- that ps prints absolute paths -- is checked below. +mkdir -p "$work/fakebin" +cat > "$work/fakebin/ps" < runnervariantcheck: variants build on demand, refuse bad trees and fall back" diff --git a/tools/install-dxmt.sh b/tools/install-dxmt.sh new file mode 100755 index 00000000..f37f7705 --- /dev/null +++ b/tools/install-dxmt.sh @@ -0,0 +1,200 @@ +#!/bin/sh +# Point NotProton at a DXMT build. The build itself is not copied anywhere here: the +# descriptor this writes names a tree, and the runner is assembled from it at launch +# whenever the tree has moved on, so a developer's loop is build-and-launch. +# +# ./install-dxmt.sh [name] verify a build tree and write its descriptor +# ./install-dxmt.sh --list descriptors, and whether their trees still verify +# ./install-dxmt.sh --remove drop a descriptor and the runners built from it +# +# A tree is shaped the way DXMT installs: x86_64-windows/*.dll beside x86_64-unix/*.so. +# `meson install` produces exactly that, so configuring DXMT with its prefix set to the +# tree makes `ninja install` the only command in the loop. +# +# The same tree zipped up is all anyone else needs -- the builtin mark travels in the +# file, so no wine tree, no meson, no toolchain on the receiving end. +set -eu + +np_support="$HOME/Library/Application Support/notproton" +runners="$np_support/runners" +conf_dir="$np_support/variants" +dst="lib/dxmt" + +usage() { + echo "usage: $0 [name]" >&2 + echo " $0 --list" >&2 + echo " $0 --remove " >&2 + exit 2 +} + +die() { echo "==> $*" >&2; exit 1; } +say() { echo "==> $*"; } + +# winebuild --builtin writes this over the DOS stub. Wine refuses to bind a PE to its unix +# half unless it is builtin, so an unmarked DLL runs as native and fails DllMain. +is_builtin() { + [ "$(dd if="$1" bs=1 skip=64 count=16 2>/dev/null)" = "Wine builtin DLL" ] +} + +# Checks a tree the way the launch path will, so a developer hears about it now rather +# than from a black screen. Prints what is wrong and returns 1. +verify_src() { + src=$1 + bad=0 + [ -d "$src" ] || { say "nothing at $src"; return 1; } + + files=$(cd "$src" && find . -type f -name '*.dll' -o -type f -name '*.so' | sed 's|^\./||') + [ -n "$files" ] || { say "$src holds no DLLs or unix libraries"; return 1; } + + for rel in $files; do + case "$rel" in + */*) ;; + *) say "$rel sits at the top of the tree; it needs an arch directory such as x86_64-windows" + bad=1; continue ;; + esac + case "$rel" in + *.dll) + if ! is_builtin "$src/$rel"; then + say "$rel is not marked as a Wine builtin -- build with wine_builtin_dll enabled (the default)" + bad=1 + fi ;; + esac + done + + # Both halves of a unixlib have to come from the same build, which is the failure that + # looks like working audio and a black screen. + for rel in $files; do + case "$rel" in + *.so) + stem=$(basename "$rel" .so) + echo "$files" | tr ' ' '\n' | grep -q "/$stem\.dll$" || + { say "$rel has no $stem.dll in the same tree"; bad=1; } ;; + esac + done + + # Every directory in the tree has to exist in a runner, which is the arch check. + for base in "$runners"/crossover-*/; do + base=${base%/} + case "$(basename "$base")" in *-*-*) continue ;; esac + [ -d "$base/CrossOver/$dst" ] || continue + lands=0 + for rel in $files; do + d=$(dirname "$rel") + if [ -d "$base/CrossOver/$dst/$d" ]; then lands=1; else + say "$(basename "$base") has no $dst/$d, so $rel will be skipped there" + fi + done + # A tree may carry a slot this runner has no use for; one that carries nothing it + # can use is a tree built for another machine. + [ "$lands" -eq 1 ] || + { say "$(basename "$base") can use nothing in this tree"; bad=1; } + break + done + + [ "$bad" -eq 0 ] +} + +list_variants() { + found=0 + for conf in "$conf_dir"/*.conf; do + [ -r "$conf" ] || continue + found=1 + name=$(basename "$conf" .conf) + src=$(sed -n 's/^[[:space:]]*src[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' "$conf" | head -1) + case "$src" in /*) ;; *) src="$HOME/$src" ;; esac + if verify_src "$src" >/dev/null 2>&1; then state="ok"; else state="needs attention"; fi + printf ' %-28s %s\n' "$name" "$state" + printf ' %-28s %s\n' "" "$src" + done + [ "$found" -eq 1 ] || echo " (no descriptors in $conf_dir)" + + found=0 + for d in "$runners"/crossover-*-*-*/; do + [ -d "$d" ] || continue + [ "$found" -eq 1 ] || echo " runners built from them:" + found=1 + echo " $(basename "${d%/}")" + done +} + +# A runner is named crossover--..., so a variant owns any directory carrying +# its name as one of the trailing components. +built_runners() { + for d in "$runners"/crossover-*-*/; do + [ -d "$d" ] || continue + case "-$(basename "${d%/}")-" in + *"-$1-"*) printf '%s\n' "${d%/}" ;; + esac + done +} + +remove_variant() { + name=$1 + case "$name" in + *[!A-Za-z0-9.-]*|"") die "$name is not a variant name" ;; + esac + conf="$conf_dir/$name.conf" + gone=0 + if [ -f "$conf" ]; then rm -f "$conf"; say "removed $conf"; gone=1; fi + list=$(mktemp) || die "cannot make a temp file" + built_runners "$name" > "$list" + while IFS= read -r d; do + [ -n "$d" ] || continue + rm -rf "$d" + say "removed the runner $(basename "$d")" + gone=1 + done < "$list" + rm -f "$list" + [ "$gone" -eq 1 ] || die "nothing to remove for $name" +} + +write_descriptor() { + src=$1 + name=${2:-} + + if [ -f "$src/build.ninja" ]; then + say "$src is a meson build directory, not an install tree." + say "DXMT installs the shape this needs, so point meson at the tree instead:" + say " meson configure $src -Dprefix=\$HOME/.local/share/notproton/variants/dxmt-mine" + say " ninja -C $src install" + say "then run this again against that prefix." + exit 1 + fi + + if [ -z "$name" ]; then + name="dxmt-$(basename "$src")" + fi + name=$(printf '%s' "$name" | tr -c 'A-Za-z0-9.-' '-') + + verify_src "$src" || die "$src is not usable yet; nothing was written" + + # Keep the stored path relative to $HOME when it is under it, so the descriptor can be + # shared with the tree and still resolve on another machine. + stored=$src + case "$src" in + "$HOME"/*) stored=${src#"$HOME"/} ;; + esac + + mkdir -p "$conf_dir" + conf="$conf_dir/$name.conf" + cat > "$conf" < Date: Thu, 22 Oct 2026 12:35:06 -0400 Subject: [PATCH 2/4] Let a plugin be a dropdown or a slider, not only a toggle --- dylib/feats/webpatch.c | 233 ++++++++++++++++++++++++- dylib/tests/featcheck.c | 104 +++++++++++ dylib/tests/panel-behavior/behavior.js | 83 +++++++++ dylib/tests/panel-behavior/harness.js | 2 +- 4 files changed, 414 insertions(+), 8 deletions(-) diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index de4653af..04c966eb 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -102,6 +102,8 @@ static int has_suffix(const char *s, const char *suf); #define NP_FEAT_REQ_MAX 4 #define NP_FEAT_STR 192 #define NP_FEAT_DESC 512 +#define NP_FEAT_CH_MAX 16 +#define NP_FEAT_CH_BUF 512 typedef struct { char key[NP_FEAT_STR]; @@ -113,6 +115,13 @@ typedef struct { char depends[NP_FEAT_STR]; char req[NP_FEAT_REQ_MAX][NP_FEAT_STR]; int nreq; + // A row is a toggle unless it says otherwise: "choice" is a dropdown, "slider" is a + // slider over a min/max range or over the values below. + char type[12]; + char ch[NP_FEAT_CH_BUF]; // "value\0label\0" repeated + int nch, chlen; + char min[24], max[24], step[24], dflt[24], suffix[24]; + char don[NP_FEAT_DESC]; // lines shown only while the row is on, newline-separated } np_feat_t; static int feat_exists(const char *home, const char *rel) { @@ -149,6 +158,37 @@ static void feat_set(char *dst, size_t size, const char *v, int *bad) { snprintf(dst, size, "%s", v); } +// Appends to a field that may be given more than once, as "requires" is. +static void feat_add(char *dst, size_t size, const char *v, int *bad) { + size_t used = strlen(dst); + if (used + strlen(v) + 2 > size) { *bad = 1; return; } + snprintf(dst + used, size - used, "%s%s", used ? "\n" : "", v); +} + +// A field that has to reach the page as a JS number. Trailing text is refused rather +// than emitted, so a typo cannot break the panel it renders into. +static int feat_num(const char *v, double *out) { + char *end; + double d; + if (!*v) return 0; + d = strtod(v, &end); + while (feat_space(*end)) end++; + if (*end || d != d || d - d != 0) return 0; + *out = d; + return 1; +} + +static int feat_has_choice(const np_feat_t *f, const char *want) { + const char *p = f->ch; + for (int c = 0; c < f->nch; c++) { + const char *val = p; + p += strlen(p) + 1; + p += strlen(p) + 1; + if (strcmp(val, want) == 0) return 1; + } + return 0; +} + static np_feat_t *feat_find(np_feat_t *list, int n, const char *key) { for (int i = 0; i < n; i++) if (strcmp(list[i].key, key) == 0) return &list[i]; @@ -189,6 +229,37 @@ static void feat_read_file(const char *path, np_feat_t *list, int *count) { else if (strcmp(k, "depends") == 0) feat_set(f.depends, sizeof(f.depends), v, &bad); else if (strcmp(k, "requires") == 0 && f.nreq < NP_FEAT_REQ_MAX) feat_set(f.req[f.nreq++], NP_FEAT_STR, v, &bad); + else if (strcmp(k, "type") == 0) feat_set(f.type, sizeof(f.type), v, &bad); + else if (strcmp(k, "min") == 0) feat_set(f.min, sizeof(f.min), v, &bad); + else if (strcmp(k, "max") == 0) feat_set(f.max, sizeof(f.max), v, &bad); + else if (strcmp(k, "step") == 0) feat_set(f.step, sizeof(f.step), v, &bad); + else if (strcmp(k, "default") == 0) feat_set(f.dflt, sizeof(f.dflt), v, &bad); + else if (strcmp(k, "suffix") == 0) feat_set(f.suffix, sizeof(f.suffix), v, &bad); + else if (strcmp(k, "description_on") == 0) + feat_add(f.don, sizeof(f.don), v, &bad); + else if (strcmp(k, "choice") == 0 && f.nch < NP_FEAT_CH_MAX) { + // "value = label", split on the first = as "env = NAME=VALUE" already is. + char *ceq = strchr(v, '='); + char *lab = v; + if (ceq) { + *ceq = 0; + lab = ceq + 1; + feat_trim(v); + while (feat_space(*lab)) lab++; + } + if (*v) { + size_t need = strlen(v) + strlen(lab) + 2; + if ((size_t)f.chlen + need > sizeof(f.ch)) { + bad = 1; + } else { + memcpy(f.ch + f.chlen, v, strlen(v) + 1); + f.chlen += (int)strlen(v) + 1; + memcpy(f.ch + f.chlen, lab, strlen(lab) + 1); + f.chlen += (int)strlen(lab) + 1; + f.nch++; + } + } + } } fclose(fp); if (bad) { @@ -213,6 +284,43 @@ static void feat_read_file(const char *path, np_feat_t *list, int *count) { NP_WARN("plugins: %s is not in the dev section, ignored", path); return; } + // A slider is either a min/max range or a ladder of declared values; either way it + // needs enough to draw, since a half-declared one renders as a dead control. + if (strcmp(f.type, "slider") == 0 && f.nch) { + if (*f.min || *f.max) { + NP_WARN("plugins: %s is a slider with both values and a range, ignored", path); + return; + } + if (f.nch < 2) { + NP_WARN("plugins: %s is a slider with one value, ignored", path); + return; + } + // The control logs an error of its own and jumps to the first value when handed + // one it cannot find, so a default that is not on the ladder is refused. + if (*f.dflt && !feat_has_choice(&f, f.dflt)) { + NP_WARN("plugins: %s has default %s, which is not one of its values, ignored", + path, f.dflt); + return; + } + } else if (strcmp(f.type, "slider") == 0) { + double mn, mx, ignored; + if (!feat_num(f.min, &mn) || !feat_num(f.max, &mx) || mx <= mn) { + NP_WARN("plugins: %s is a slider with no usable min/max range, ignored", path); + return; + } + if ((*f.step && !feat_num(f.step, &ignored)) || + (*f.dflt && !feat_num(f.dflt, &ignored))) { + NP_WARN("plugins: %s has a step or default that is not a number, ignored", path); + return; + } + } else if (*f.type && strcmp(f.type, "choice") != 0) { + NP_WARN("plugins: %s has type %s, which does not exist, ignored", path, f.type); + return; + } + if (strcmp(f.type, "choice") == 0 && !f.nch) { + NP_WARN("plugins: %s is a choice with no values, ignored", path); + return; + } if (feat_find(list, *count, f.key)) { NP_WARN("plugins: %s repeats key %s, ignored", path, f.key); return; @@ -263,7 +371,8 @@ static int feat_load(const char *home, np_feat_t *list) { } static _Thread_local char g_feat_ready[NP_FEAT_MAX * (NP_FEAT_STR + 8) + 4] = "{}"; -static _Thread_local char g_feat_rows[NP_FEAT_MAX * (2 * (3 * NP_FEAT_STR + NP_FEAT_DESC + 64) + 32) + 4] = "[]"; +static _Thread_local char g_feat_rows[NP_FEAT_MAX * + (2 * (3 * NP_FEAT_STR + 2 * NP_FEAT_DESC + NP_FEAT_CH_BUF + 192) + 32) + 4] = "[]"; static _Thread_local int g_feat_ready_done; static _Thread_local int g_feat_pinned; static _Thread_local char g_var_rows[8192] = "[]"; @@ -412,6 +521,65 @@ static void feat_build(void) { if (!out_put(&rows, ",off:", 5) || !out_jsstr(&rows, list[i].off)) goto done; if (!out_put(&rows, ",dep:", 5) || !out_jsstr(&rows, list[i].depends)) goto done; if (!out_put(&rows, ",d:", 3) || !out_jsstr(&rows, list[i].desc)) goto done; + if (*list[i].don) { + char line[NP_FEAT_DESC]; + const char *p = list[i].don; + int wrotel = 0; + if (!out_put(&rows, ",dn:[", 5)) goto done; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len >= sizeof(line)) len = sizeof(line) - 1; + memcpy(line, p, len); + line[len] = 0; + if (wrotel++ && !out_put(&rows, ",", 1)) goto done; + if (!out_jsstr(&rows, line)) goto done; + p = nl ? nl + 1 : p + strlen(p); + } + if (!out_put(&rows, "]", 1)) goto done; + } + if (*list[i].type) { + if (!out_put(&rows, ",ty:", 4) || !out_jsstr(&rows, list[i].type)) goto done; + } + // Declared values serve a dropdown and a snapping slider alike. + if (list[i].nch) { + const char *p = list[i].ch; + if (!out_put(&rows, ",ch:[", 5)) goto done; + for (int c = 0; c < list[i].nch; c++) { + const char *val = p; p += strlen(p) + 1; + const char *lab = p; p += strlen(p) + 1; + if (c && !out_put(&rows, ",", 1)) goto done; + if (!out_put(&rows, "{v:", 3) || !out_jsstr(&rows, val)) goto done; + if (!out_put(&rows, ",l:", 3) || !out_jsstr(&rows, *lab ? lab : val)) goto done; + if (!out_put(&rows, "}", 1)) goto done; + } + if (!out_put(&rows, "]", 1)) goto done; + // One of the declared values, so it stays a string the slider can match. + if (*list[i].dflt) { + if (!out_put(&rows, ",df:", 4) || !out_jsstr(&rows, list[i].dflt)) goto done; + } + } else if (strcmp(list[i].type, "slider") == 0) { + // Validated at load, so %g here only reformats numbers already parsed. + double mn = 0, mx = 0, step = 0, dflt = 0; + char nums[128]; + int nlen; + feat_num(list[i].min, &mn); + feat_num(list[i].max, &mx); + nlen = snprintf(nums, sizeof(nums), ",mn:%g,mx:%g", mn, mx); + if (nlen < 0 || nlen >= (int)sizeof(nums) || !out_put(&rows, nums, (size_t)nlen)) + goto done; + if (feat_num(list[i].step, &step)) { + nlen = snprintf(nums, sizeof(nums), ",st:%g", step); + if (nlen < 0 || !out_put(&rows, nums, (size_t)nlen)) goto done; + } + if (feat_num(list[i].dflt, &dflt)) { + nlen = snprintf(nums, sizeof(nums), ",df:%g", dflt); + if (nlen < 0 || !out_put(&rows, nums, (size_t)nlen)) goto done; + } + if (*list[i].suffix) { + if (!out_put(&rows, ",sf:", 4) || !out_jsstr(&rows, list[i].suffix)) goto done; + } + } if (!out_put(&rows, "}", 1)) goto done; } if (!out_put(&ready, "}", 1) || !out_put(&rows, "]", 1)) goto done; @@ -545,7 +713,7 @@ static void var_read_file(const char *path, const char *home, np_var_t *list, in else if (strcmp(k, "src") == 0) snprintf(v.src, sizeof(v.src), "%s", val); else if (strcmp(k, "dst") == 0) snprintf(v.dst, sizeof(v.dst), "%s", val); else { - // Refused rather than ignored, as in features.d: a typo that looks like it + // Refused rather than ignored, as a plugin is: a typo that looks like it // worked is worse than a variant that does not appear. NP_WARN("variants: %s declares '%s', which is not a variant field; " "the fields are label, variant, src and dst", path, k); @@ -793,7 +961,8 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { ".MSCXPanel .MSCXRow:hover{box-shadow:0 6px 8px 0 rgba(0,0,0,.16)}" \ ".MSCXPanel .MSCXRowDesc{flex-wrap:wrap;align-items:center}" \ ".MSCXPanel .MSCXRowDesc>:last-child{flex-basis:100%}" \ - ".MSCXNoBottomGap{margin-bottom:0}\"" + ".MSCXNoBottomGap{margin-bottom:0}" \ + ".MSCXKeys{margin-top:5px;opacity:.75}\"" // FEX runs arm64 Wine, which has no D3DMetal and never runs under Rosetta. #define NP_CX_OPTIONS_BODY(ARG, RT, BARREL) \ @@ -803,8 +972,14 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "npZh=" NP_WEB_IS_ZH ",npIt=" NP_WEB_IS_IT ",npL=(en,zh,it)=>npZh?zh:npIt?it:en," \ "L=MSCXLaunch(o,t.unAppID),g=L.g,s=L.s," \ "MSCXReady=" NP_READY ",MSCXFeat=" NP_FEATS ",MSCXVar=" NP_VARS "," \ + /* A toggle parent is satisfied by its on value; one that holds a value, such as a + dropdown or a slider, by holding one at all rather than sitting on Default. */ \ "on1=(k,n=0)=>{const p=MSCXFeat.find(x=>x.k===k);" \ - "return!!p&&n<32&&MSCXReady[k]!==0&&g(k)===p.on&&(!p.dep||on1(p.dep,n+1))}," \ + "return!!p&&n<32&&MSCXReady[k]!==0&&(p.ty?\"\"!==g(k):g(k)===p.on)" \ + "&&(!p.dep||on1(p.dep,n+1))}," \ + /* How deep the chain of parents runs, so a sub-option of a sub-option indents twice. */ \ + "dl=(k,n)=>{const p=MSCXFeat.find(x=>x.k===k);" \ + "return n<32&&p&&p.dep?dl(p.dep,n+1):n}," \ "vav=MSCXVar.filter(v=>v.t.indexOf(t.strCompatToolName||\"" NP_FALLBACK_TOOL "\")>=0)," \ "vcur=g(\"NOTPROTON_RUNNER_VARIANT\").split(\"+\").filter(x=>x)," \ "vids=vav.map(v=>v.v).filter((d,i,a)=>a.indexOf(d)===i)," \ @@ -819,12 +994,47 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "rgOptions:[{data:\"\",label:npL(\"Default\",\"默认\",\"Default\")}]" \ ".concat(vd.map(v=>({data:v.k,label:v.l})))," \ "selectedOption:sel,onChange:x=>vset(id,x.data)},id)}," \ - "TD=(k,l,on,off,d,dep)=>{const rd=MSCXReady[k]!==0;" \ + "TD=(k,l,on,off,d,lv)=>{const rd=MSCXReady[k]!==0;" \ "return(0," RT ".jsx)(" BARREL ".y4," \ "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ "description:d||undefined,checked:rd&&g(k)===on," \ - "indentLevel:dep?1:undefined," \ + "indentLevel:lv||undefined," \ "onChange:v=>s([[k,v?on:(off||\"\")]])},k)}," \ + /* A row that holds one of its declared values, with Default for none of them. */ \ + "TC=(k,l,d,lv,ch)=>{const rd=MSCXReady[k]!==0;" \ + "return(0," RT ".jsx)(" BARREL ".m," \ + "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "description:d||undefined,layout:\"inline\"," \ + "indentLevel:lv||undefined," \ + "rgOptions:[{data:\"\",label:npL(\"Default\",\"默认\",\"Default\")}]" \ + ".concat(ch.map(c=>({data:c.v,label:c.l})))," \ + "selectedOption:g(k),onChange:x=>s([[k,x.data]])},k)}," \ + /* The handle follows the drag on its own; only the finished value is written, and + the declared default is written as no value at all so launch options stay clean. */ \ + "TS=(k,l,d,lv,mn,mx,st,df,sf)=>{const rd=MSCXReady[k]!==0,v=g(k)," \ + "n=\"\"===v||isNaN(+v)?(undefined===df?mn:df):+v;" \ + "return(0," RT ".jsx)(" BARREL ".d3," \ + "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "description:d||undefined," \ + "indentLevel:lv||undefined," \ + "value:n,min:mn,max:mx,step:st||undefined,showValue:true," \ + "valueSuffix:sf||undefined," \ + "onChangeComplete:x=>{const r=+(+x).toFixed(4);" \ + "s([[k,r===df?\"\":String(r)]])}},k)}," \ + /* The same slider snapped to declared values: the control steps by notch and hands + back the value itself, so a key holding none of them starts at the default. Its + own notches are two unlabelled bookends, and ours are spread after them. */ \ + "TL=(k,l,d,lv,ch,df)=>{const rd=MSCXReady[k]!==0,V=ch.map(c=>c.v),v=g(k)," \ + "n=V.indexOf(v)>=0?v:(undefined===df?V[0]:df);" \ + "return(0," RT ".jsx)(" BARREL ".gz," \ + "{label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "description:d||undefined," \ + "indentLevel:lv||undefined," \ + "rValues:V,value:n,showValue:true," \ + "notchCount:V.length,notchTicksVisible:true," \ + "notchLabels:ch.map((c,i)=>({notchIndex:i,label:c.l}))," \ + "renderValue:x=>{const c=ch.find(y=>y.v===x);return c?c.l:String(x)}," \ + "onChangeComplete:x=>s([[k,x===df?\"\":x]])},k)}," \ "T=(ks,l,on,off,d,r)=>{const rd=r!==0;" \ "return(0," RT ".jsx)(" BARREL ".Yh," \ "{className:\"MSCXRow\"+(d?\" MSCXRowDesc\":\"\")," \ @@ -885,7 +1095,16 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "(MSCXFeat.length||vids.length?(0," RT ".jsx)(" BARREL ".J1,{children:(0," RT ".jsx)(" BARREL ".XY," \ "{label:npL(\"Developer\",\"开发者\",\"Developer\")," \ "children:vids.map(V).concat(MSCXFeat.filter(f=>!f.dep||on1(f.dep))" \ - ".map(f=>TD(f.k,f.l,f.on,f.off,f.d,f.dep)))})},\"dev\"):null)" \ + /* Lines a plugin only wants read once the row is on, such as the keys that + change the rest of a feature in game, sit under its description while it is. */ \ + ".map(f=>{const lv=f.dep?dl(f.dep,1):0," \ + "d=f.dn&&on1(f.k)?(0," RT ".jsxs)(\"div\",{children:[f.d]" \ + ".concat(f.dn.map((x,i)=>(0," RT ".jsx)(\"div\"," \ + "{className:\"MSCXKeys\",children:x},i)))}):f.d;" \ + "return\"choice\"===f.ty?TC(f.k,f.l,d,lv,f.ch)" \ + ":\"slider\"===f.ty?(f.ch?TL(f.k,f.l,d,lv,f.ch,f.df)" \ + ":TS(f.k,f.l,d,lv,f.mn,f.mx,f.st,f.df,f.sf))" \ + ":TD(f.k,f.l,f.on,f.off,d,lv)}))})},\"dev\"):null)" \ "]})})}" #define NP_CX_OPTIONS_CAPTURE(RT, BARREL) \ diff --git a/dylib/tests/featcheck.c b/dylib/tests/featcheck.c index fc2edac0..af388e4c 100644 --- a/dylib/tests/featcheck.c +++ b/dylib/tests/featcheck.c @@ -239,6 +239,110 @@ int main(void) { ok(strstr(feats_json(), "d:\"\"") != NULL, "is empty when the plugin omits it"); + puts("== a choice row"); + clear_descs(); + write_desc("10-ch.conf", + "key = NOTPROTON_ENV_HUD_POS\nlabel = HUD position\nsection = dev\n" + "type = choice\n" + "choice = tl = Top left\nchoice = tr = Top right\nchoice = br\n"); + { + const char *j = feats_json(); + // The whole tail, so a mis-counted literal cannot drop a bracket and still pass. + ok(strstr(j, ",ty:\"choice\",ch:[{v:\"tl\",l:\"Top left\"}," + "{v:\"tr\",l:\"Top right\"},{v:\"br\",l:\"br\"}]}") != NULL, + "is marked as a choice, splits value from label, and defaults a missing label"); + } + + puts("== a slider snapped to declared values"); + clear_descs(); + write_desc("10-ld.conf", + "key = NOTPROTON_ENV_HZ\nlabel = Target\nsection = dev\n" + "type = slider\ndefault = off\n" + "choice = off = Off\nchoice = 60 = 60 Hz\n"); + ok(strstr(feats_json(), + ",ty:\"slider\",ch:[{v:\"off\",l:\"Off\"},{v:\"60\",l:\"60 Hz\"}]," + "df:\"off\"") != NULL, + "carries its values and a default that stays one of them"); + clear_descs(); + write_desc("10-e1.conf", "key = NOTPROTON_E1\nlabel = Ee\nsection = dev\n" + "type = slider\nchoice = 30\n"); + write_desc("10-e2.conf", "key = NOTPROTON_E2\nlabel = Ee\nsection = dev\n" + "type = slider\nmin = 0\nmax = 1\nchoice = 30\n" + "choice = 60\n"); + write_desc("10-e3.conf", "key = NOTPROTON_E3\nlabel = Ee\nsection = dev\n" + "type = slider\ndefault = 45\nchoice = 30\nchoice = 60\n"); + { + const char *j = feats_json(); + ok(strstr(j, "NOTPROTON_E1") == NULL, "one value cannot be moved, so it is refused"); + ok(strstr(j, "NOTPROTON_E2") == NULL, "values and a range together are refused"); + ok(strstr(j, "NOTPROTON_E3") == NULL, + "and a default that is not one of the values is refused"); + } + + puts("== a slider that could not be drawn"); + clear_descs(); + write_desc("10-a.conf", "key = NOTPROTON_A\nlabel = Ay\nsection = dev\n" + "type = slider\nmax = 1\n"); + write_desc("10-b.conf", "key = NOTPROTON_B\nlabel = Bee\nsection = dev\n" + "type = slider\nmin = 1\nmax = 1\n"); + write_desc("10-c.conf", "key = NOTPROTON_C\nlabel = Cee\nsection = dev\n" + "type = slider\nmin = 0\nmax = 1\nstep = fast\n"); + write_desc("10-d2.conf", "key = NOTPROTON_D2\nlabel = Dee\nsection = dev\n" + "type = slider\nmin = 0\nmax = 1px\n"); + { + const char *j = feats_json(); + ok(strstr(j, "NOTPROTON_A") == NULL, "no min is refused"); + ok(strstr(j, "NOTPROTON_B") == NULL, "an empty range is refused"); + ok(strstr(j, "NOTPROTON_C") == NULL, "a step that is not a number is refused"); + ok(strstr(j, "NOTPROTON_D2") == NULL, "and so is a bound with trailing text"); + } + + puts("== a slider row"); + clear_descs(); + write_desc("10-sl.conf", + "key = NOTPROTON_ENV_HUD_SCALE\nlabel = HUD size\nsection = dev\n" + "type = slider\nmin = 0.5\nmax = 2\nstep = 0.25\ndefault = 1\n" + "suffix = x\n"); + ok(strstr(feats_json(), + ",ty:\"slider\",mn:0.5,mx:2,st:0.25,df:1,sf:\"x\"") != NULL, + "carries its range, step, default and suffix as numbers"); + clear_descs(); + write_desc("10-sl.conf", + "key = NOTPROTON_ENV_HUD_SCALE\nlabel = HUD size\nsection = dev\n" + "type = slider\nmin = 0\nmax = 1\n"); + ok(strstr(feats_json(), ",ty:\"slider\",mn:0,mx:1}") != NULL, + "and leaves out a step, default and suffix it was not given"); + + puts("== a row with no type, or one that does not exist"); + clear_descs(); + write_desc("10-t.conf", "key = NOTPROTON_T\nlabel = T\nsection = dev\n"); + write_desc("10-t2.conf", "key = NOTPROTON_T2\nlabel = T2\nsection = dev\n" + "type = dial\n"); + write_desc("10-t3.conf", "key = NOTPROTON_T3\nlabel = T3\nsection = dev\n" + "type = choice\n"); + { + const char *j = feats_json(); + ok(strstr(j, "ty:") == NULL, + "no type at all stays the toggle it has always been"); + ok(strstr(j, "NOTPROTON_T2") == NULL, "a type that does not exist is refused"); + ok(strstr(j, "NOTPROTON_T3") == NULL, "and so is a choice with no values"); + } + + puts("== lines for when a row is on"); + clear_descs(); + write_desc("10-dn.conf", + "key = NOTPROTON_DN\nlabel = Dee\nsection = dev\n" + "description = What it does.\n" + "description_on = Ctrl+Opt+H hides it.\n" + "description_on = Ctrl+Opt+G draws the graph.\n"); + ok(strstr(feats_json(), + "d:\"What it does.\",dn:[\"Ctrl+Opt+H hides it.\"," + "\"Ctrl+Opt+G draws the graph.\"]") != NULL, + "each line is carried separately, in the order it was read"); + clear_descs(); + write_desc("10-dn.conf", "key = NOTPROTON_DN\nlabel = Dee\nsection = dev\n"); + ok(strstr(feats_json(), "dn:") == NULL, "a row with none carries none"); + puts("== a label cannot break the page it renders into"); clear_descs(); write_desc("10-q.conf", diff --git a/dylib/tests/panel-behavior/behavior.js b/dylib/tests/panel-behavior/behavior.js index 4c57d91e..f502168f 100644 --- a/dylib/tests/panel-behavior/behavior.js +++ b/dylib/tests/panel-behavior/behavior.js @@ -121,6 +121,89 @@ for (const form of Object.keys(FORMS)) { t.ok(!hasZero(P({ details: details('') })), 'no plugins renders nothing rather than a literal 0'); + // Lines that only appear once the row is on. + const KEYS = JSON.stringify([ + { k: 'NOTPROTON_HUD', l: 'Hud', on: '1', off: '', dep: '', d: 'Draws a HUD.', + dn: ['Ctrl+Opt+H hides it.', 'Ctrl+Opt+G draws the graph.'] }, + ]).replace(/"(\w+)":/g, '$1:'); + const { render: PK } = panel(emit, form, undefined, { feats: KEYS }); + const hud = opts => walk(PK({ details: details(opts) })) + .find(x => x.props && x.props.label === 'Hud'); + const text = n => typeof n === 'string' ? n + : !n || typeof n !== 'object' ? '' + : [].concat(n.props && n.props.children).map(text).join('|'); + + t.ok(hud('').props.description === 'Draws a HUD.', + 'an off row shows its description alone'); + t.ok(text(hud('NOTPROTON_HUD=1 %command%').props.description) + === 'Draws a HUD.|Ctrl+Opt+H hides it.|Ctrl+Opt+G draws the graph.', + 'and an on row shows the extra lines under it, in order'); + + // A slider given values instead of a range snaps to them, and a row can hang off one + // holding a value at all -- which is what makes a sub-option of a dropdown or slider work. + const LADDER = JSON.stringify([ + { k: 'NOTPROTON_ENV_HZ', l: 'Target', on: '1', off: '', dep: '', d: 'Rate.', + ty: 'slider', df: 'off', + ch: [{ v: 'off', l: 'Off' }, { v: '30', l: '30 Hz' }, { v: '60', l: '60 Hz' }] }, + { k: 'NOTPROTON_ENV_MODE', l: 'Mode', on: '1', off: '', dep: 'NOTPROTON_ENV_HZ', d: '', + ty: 'choice', ch: [{ v: 'attime', l: 'Scheduled' }] }, + ]).replace(/"(\w+)":/g, '$1:'); + const { render: PL, written: lwritten } = panel(emit, form, undefined, { feats: LADDER }); + const lnodes = opts => walk(PL({ details: details(opts) })); + const snap = opts => lnodes(opts).find(x => x.type === 'SnapSlider'); + + t.ok(snap('') !== undefined, 'a slider with values renders as a snapping slider'); + t.ok(snap('').props.rValues.join(',') === 'off,30,60', + 'stepping through the declared values'); + t.ok(snap('').props.value === 'off', 'an unset key sits at the declared default'); + t.ok(snap('NOTPROTON_ENV_HZ=60 %command%').props.value === '60', + 'a launch option picks its notch'); + // vr() logs an error of its own and jumps to the first value when it cannot find one. + t.ok(snap('NOTPROTON_ENV_HZ=57 %command%').props.value === 'off', + 'a value that is not on the ladder falls back to the default'); + t.ok(snap('').props.renderValue('30') === '30 Hz', 'the notch reads as its label'); + t.ok(snap('').props.notchCount === 3 && snap('').props.notchTicksVisible === true, + 'a tick is drawn for every value'); + t.ok(snap('').props.notchLabels.map(n => n.notchIndex + ':' + n.label).join(',') + === '0:Off,1:30 Hz,2:60 Hz', + 'each tick carries its own label, matched by notch index'); + lwritten.length = 0; + snap('').props.onChangeComplete('30'); + t.ok(lwritten[0].opts === 'NOTPROTON_ENV_HZ=30 %command%', 'moving it writes the value'); + lwritten.length = 0; + snap('NOTPROTON_ENV_HZ=30 %command%').props.onChangeComplete('off'); + t.ok(lwritten[0].opts === '', 'and sliding back to the default clears the key'); + + const labels = opts => lnodes(opts).filter(x => x.props && x.props.label) + .map(x => x.props.label); + t.ok(!labels('').includes('Mode'), + 'a row under a value-holding parent is hidden while that parent is on Default'); + t.ok(labels('NOTPROTON_ENV_HZ=30 %command%').includes('Mode'), + 'and appears once the parent holds a value'); + t.ok(lnodes('NOTPROTON_ENV_HZ=30 %command%') + .find(x => x.props.label === 'Mode').props.indentLevel === 1, + 'indented under it'); + // The parent is itself hidden with FrameScope off, so the child cannot outlive it. + const DEEP = JSON.stringify([ + { k: 'NOTPROTON_HUD', l: 'Hud', on: '1', off: '', dep: '', d: '' }, + { k: 'NOTPROTON_ENV_HZ', l: 'Target', on: '1', off: '', dep: 'NOTPROTON_HUD', d: '', + ty: 'slider', df: 'off', + ch: [{ v: 'off', l: 'Off' }, { v: '30', l: '30 Hz' }] }, + { k: 'NOTPROTON_ENV_MODE', l: 'Mode', on: '1', off: '', dep: 'NOTPROTON_ENV_HZ', d: '', + ty: 'choice', ch: [{ v: 'attime', l: 'Scheduled' }] }, + ]).replace(/"(\w+)":/g, '$1:'); + const { render: PD } = panel(emit, form, undefined, { feats: DEEP }); + const dlabels = opts => walk(PD({ details: details(opts) })) + .filter(x => x.props && x.props.label).map(x => x.props.label); + t.ok(!dlabels('NOTPROTON_ENV_HZ=30 %command%').includes('Target') + && !dlabels('NOTPROTON_ENV_HZ=30 %command%').includes('Mode'), + 'a whole chain stays hidden while the row at the top of it is off'); + t.ok(dlabels('NOTPROTON_HUD=1 NOTPROTON_ENV_HZ=30 %command%').includes('Mode'), + 'and the chain appears once the top is on'); + t.ok(walk(PD({ details: details('NOTPROTON_HUD=1 NOTPROTON_ENV_HZ=30 %command%') })) + .find(x => x.props.label === 'Mode').props.indentLevel === 2, + 'a sub-option of a sub-option indents twice'); + const { render: PF } = panel(emit, form, undefined, { ready: '{"NOTPROTON_ALPHA":0}', feats: FEATS }); const fnodes = opts => walk(PF({ details: details(opts) })); diff --git a/dylib/tests/panel-behavior/harness.js b/dylib/tests/panel-behavior/harness.js index 2f8ac860..f2cb326e 100644 --- a/dylib/tests/panel-behavior/harness.js +++ b/dylib/tests/panel-behavior/harness.js @@ -16,7 +16,7 @@ const expand = (s, caps) => s.replace(/[\u0001-\u0008\u0013-\u0016]/g, c => { const mk = (type, props) => ({ type, props }); const react = { jsx: mk, jsxs: mk, Fragment: 'Fragment' }; const barrel = { XY: 'Section', m: 'Dropdown', y4: 'Toggle', Yh: 'Checkbox', - J1: 'FieldProvider' }; + d3: 'Slider', gz: 'SnapSlider', J1: 'FieldProvider' }; // The two shapes take the payload in different forms and bind different names, which // is the whole of the difference between them. From a74aae4ee12f2e0d2f5d1321afb9379672006aba Mon Sep 17 00:00:00 2001 From: Nathaniel Brown Date: Thu, 22 Oct 2026 12:35:06 -0400 Subject: [PATCH 3/4] Add example plugins and a variant to drop in --- examples/README.md | 94 +++++++++++++++++++ examples/plugins/10-example-toggle.conf | 16 ++++ examples/plugins/20-example-sub-option.conf | 14 +++ examples/plugins/30-example-dropdown.conf | 14 +++ examples/plugins/40-example-slider.conf | 20 ++++ .../plugins/50-example-snapping-slider.conf | 17 ++++ examples/plugins/60-example-requires.conf | 16 ++++ examples/variants/dxmt-example.conf | 23 +++++ 8 files changed, 214 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/plugins/10-example-toggle.conf create mode 100644 examples/plugins/20-example-sub-option.conf create mode 100644 examples/plugins/30-example-dropdown.conf create mode 100644 examples/plugins/40-example-slider.conf create mode 100644 examples/plugins/50-example-snapping-slider.conf create mode 100644 examples/plugins/60-example-requires.conf create mode 100644 examples/variants/dxmt-example.conf diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..7ad7ad92 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,94 @@ +# Example plugins and variants + +Drop these in and restart Steam to see what a plugin can put in the Developer section +of a game's Compatibility page. They are examples, not features: nothing here is +installed or enabled by default. + + cp examples/plugins/*.conf ~/Library/Application\ Support/notproton/plugins/ + cp examples/variants/*.conf ~/Library/Application\ Support/notproton/variants/ + +Each file is one row. Delete a file and its row goes away; the folders are yours, and +NotProton never writes to them. + +| file | shows | +| --- | --- | +| `plugins/10-example-toggle.conf` | a toggle, an environment variable, and lines that appear only while it is on | +| `plugins/20-example-sub-option.conf` | a row that hides and indents under another | +| `plugins/30-example-dropdown.conf` | a dropdown of declared values | +| `plugins/40-example-slider.conf` | a slider over a range | +| `plugins/50-example-snapping-slider.conf` | a slider that snaps to declared values, with a labelled tick for each | +| `plugins/60-example-requires.conf` | a row for a file you have not built yet, greyed out as "(not installed)" | +| `variants/dxmt-example.conf` | a runner built from a CrossOver copy with your own files over it | + +The toggles work as they are. The dropdown and the two sliders write their value into +the game's launch options, and a launch option named `NOTPROTON_ENV_FOO=bar` reaches +the game as `FOO=bar` once [#219](https://github.com/NotProtonNot/NotProton/pull/219) +lands; until then they show the UI without changing the game. + +## Plugin fields + +A plugin is a `key = value` file in `~/Library/Application Support/notproton/plugins`. +Files are read in name order, which is the order the rows appear in. Unknown fields are +ignored so a file written for a later version still loads; a field given twice keeps the +first value, except for the repeatable ones noted below. + +| field | | +| --- | --- | +| `key` | required, and has to start with `NOTPROTON_`. The launch option the row reads and writes. | +| `label` | required. What the row is called. | +| `description` | the line under the label. | +| `description_on` | repeatable. Lines under the description, shown only while the row is on. | +| `section` | `dev`, the only section plugins can add to, and what you get by leaving it out. | +| `on` | the value that counts as on, `1` by default. | +| `off` | what switching off writes; empty removes the launch option, which is the default. | +| `depends` | another plugin's key. The row is hidden until that one is on, and indents under it. | +| `requires` | repeatable, up to 4. A file, absolute or relative to `$HOME`; the first that exists wins. A row whose files are all missing is greyed out as "(not installed)". | +| `inject` | `1` loads the file `requires` found into the game. | +| `env` | repeatable. `NAME=VALUE` exported for the game. | +| `overrides` | added to `WINEDLLOVERRIDES`, e.g. `dxgi=n,b`. | +| `stage` | a folder whose files are copied into the prefix's `system32` before launch. | + +### Row types + +`type` decides what the row is. Leave it out and the row is a toggle. + +| `type` | | +| --- | --- | +| *(none)* | a switch, writing `on` or `off`. | +| `choice` | a dropdown offering Default plus each `choice`. | +| `slider` | with `min`/`max`, a slider over that range; with `choice` lines, a slider that snaps to those values. | + +| field | | +| --- | --- | +| `choice` | repeatable, up to 16. ` =