From bddd690d102344535c1d915802dcf6a905a08109 Mon Sep 17 00:00:00 2001 From: Nathaniel Brown Date: Mon, 19 Oct 2026 14:53:03 -0400 Subject: [PATCH 1/6] Add feature plug-ins --- Makefile | 17 ++ dylib/feats/compat_run.sh | 131 +++++++++++++- dylib/feats/webpatch.c | 235 ++++++++++++++++++++++++- dylib/feats/webpatch.h | 4 + dylib/tests/envcheck.sh | 4 +- dylib/tests/featcheck.c | 173 ++++++++++++++++++ dylib/tests/featstagecheck.sh | 63 +++++++ dylib/tests/panel-behavior/behavior.js | 68 +++++++ dylib/tests/panel-behavior/emit.c | 3 + dylib/tests/panel-behavior/harness.js | 11 +- 10 files changed, 700 insertions(+), 9 deletions(-) create mode 100644 dylib/tests/featcheck.c create mode 100755 dylib/tests/featstagecheck.sh diff --git a/Makefile b/Makefile index 40ac5290..ebcc0355 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,7 @@ DEPS := $(OBJS:.o=.d) .PHONY: all clean rebuild dobby deploy dylib dylib-install sigcheck fonts fonts-install app-payload app app-zip \ anchorcheck callscheck sigdb-fixtures webpatch-fixtures peicon-fixtures panel-behavior app-tests \ + featcheck featstagecheck \ tests-list overlay-shim overlay-shim-install overlay-shim-tests \ overlay-shim-bench iconmaker icon \ appinfo helpers-install ntdll-resolve bridge runcheck compatcheck \ @@ -122,6 +123,11 @@ bridgecheck: @if [ ! -f dylib/tests/bridgecheck.sh ]; then $(call SKIP,bridgecheck,dylib/tests/bridgecheck.sh); exit 0; fi; \ sh dylib/tests/bridgecheck.sh dylib/feats/compat_run.sh +featstagecheck: + @if [ ! -f dylib/tests/featstagecheck.sh ]; then \ + $(call SKIP,featstagecheck,dylib/tests/featstagecheck.sh); exit 0; fi; \ + sh dylib/tests/featstagecheck.sh dylib/feats/compat_run.sh + 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 @@ -258,6 +264,7 @@ peicon-fixtures: exit $$rc GATECHECK := $(OUT_DIR)/gatecheck +FEATCHECK := $(OUT_DIR)/featcheck WEBPATCH_FIXTURES := dylib/tests/webpatch-fixtures $(GATECHECK): dylib/tests/gatecheck.c dylib/feats/webpatch.c dylib/feats/webpatch.h @@ -270,6 +277,16 @@ $(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 + @mkdir -p $(dir $@) + $(TEST_CC) -O1 -o $@ dylib/tests/featcheck.c + +featcheck: + @if [ ! -f dylib/tests/featcheck.c ]; then \ + $(call SKIP,featcheck,dylib/tests/featcheck.c); exit 0; fi; \ + $(MAKE) -s $(FEATCHECK) || exit 1; \ + $(FEATCHECK) + webpatch-fixtures: @if [ ! -d $(WEBPATCH_FIXTURES) ] || [ ! -f dylib/tests/gatecheck.c ]; then \ $(call SKIP,webpatch-fixtures,$(WEBPATCH_FIXTURES)); exit 0; fi; \ diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index 30fe947f..7273e241 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -1598,6 +1598,134 @@ terminate() { } trap terminate TERM INT HUP +np_env_names="" +# Outside the support directory on purpose: a hard reinstall removes everything there +# that the app does not know about, which would take a user's descriptors with it. +np_feat_dir="$HOME/.config/notproton/features.d" +np_feat_requires() { + np_feat_hit="" + for np_feat_file in "$np_feat_dir"/*.conf; do + [ -r "$np_feat_file" ] || continue + sed -n 's/^[[:space:]]*key[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' "$np_feat_file" \ + | grep -qx "$1" || continue + np_feat_hit=$(sed -n \ + 's/^[[:space:]]*requires[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' "$np_feat_file" \ + | while IFS= read -r np_feat_req; do + # Leading parens: a case pattern's ) can otherwise close the $( ) around this. + case "$np_feat_req" in + (/*) [ -f "$np_feat_req" ] && { printf '%s' "$np_feat_req"; break; } ;; + (*) [ -f "$HOME/$np_feat_req" ] \ + && { printf '%s' "$HOME/$np_feat_req"; break; } ;; + esac + done) + [ -n "$np_feat_hit" ] && break + done + printf '%s' "$np_feat_hit" +} +np_dev_add() { + if [ ! -f "$2" ]; then + echo "=== $1 requested but no dylib at $2 ===" >> "$log" 2>&1 || true + return 0 + fi + case ":$STEAM_DYLD_INSERT_LIBRARIES:" in + *":$2:"*) return 0 ;; + esac + if [ -n "$STEAM_DYLD_INSERT_LIBRARIES" ]; then + STEAM_DYLD_INSERT_LIBRARIES="$STEAM_DYLD_INSERT_LIBRARIES:$2" + else + STEAM_DYLD_INSERT_LIBRARIES="$2" + fi + export STEAM_DYLD_INSERT_LIBRARIES + echo "=== $1 will be loaded from $2 ===" >> "$log" 2>&1 || true +} + +# Each descriptor whose key is switched on gets what it asks for: "inject = 1" loads the +# dylib its "requires" resolved to, each "env = NAME=VALUE" is exported and carried into +# the bundle, "stage" clones a directory of DLLs into the prefix, and "overrides" adds to +# WINEDLLOVERRIDES so those DLLs win over the builtins. Nothing here knows which features +# exist. +np_feat_field() { + sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\(.*\)$/\1/p" "$1" +} +for np_feat_file in "$np_feat_dir"/*.conf; do + [ -r "$np_feat_file" ] || continue + np_feat_key=$(np_feat_field "$np_feat_file" key | head -1) + [ -n "$np_feat_key" ] || continue + case "$np_feat_key" in + NOTPROTON_[A-Z0-9_]*) ;; + *) echo "=== features: $np_feat_file key $np_feat_key is not a NOTPROTON_ name ===" \ + >> "$log" 2>&1 || true; continue ;; + esac + eval "np_feat_val=\${$np_feat_key:-}" + np_feat_on=$(np_feat_field "$np_feat_file" on | head -1) + [ -n "$np_feat_on" ] || np_feat_on=1 + # shellcheck disable=SC2154 # eval assigns np_feat_val above + [ "$np_feat_val" = "$np_feat_on" ] || continue + + if [ "$(np_feat_field "$np_feat_file" inject | head -1)" = 1 ]; then + np_dev_add "$np_feat_key" "$(np_feat_requires "$np_feat_key")" + fi + + # Re-staged every launch, so it survives a prefix the user rebuilt and repairs itself + # if a game overwrote one of the files. Cloned where the filesystem allows it. + np_feat_stage=$(np_feat_field "$np_feat_file" stage | head -1) + if [ -n "$np_feat_stage" ]; then + case "$np_feat_stage" in + (/*) ;; + (*) np_feat_stage="$HOME/$np_feat_stage" ;; + esac + np_feat_sys32="$WINEPREFIX/drive_c/windows/system32" + if [ ! -d "$np_feat_stage" ]; then + echo "=== $np_feat_key stages from $np_feat_stage, which is not there ===" \ + >> "$log" 2>&1 || true + elif [ ! -d "$np_feat_sys32" ]; then + echo "=== $np_feat_key cannot stage, no $np_feat_sys32 ===" >> "$log" 2>&1 || true + else + for np_feat_src in "$np_feat_stage"/*; do + [ -f "$np_feat_src" ] || continue + np_feat_dst="$np_feat_sys32/$(basename "$np_feat_src")" + if cp -c -f "$np_feat_src" "$np_feat_dst" 2>/dev/null \ + || cp -f "$np_feat_src" "$np_feat_dst"; then + echo "=== $np_feat_key staged $(basename "$np_feat_src") ===" >> "$log" 2>&1 || true + else + echo "=== $np_feat_key failed to stage $np_feat_src ===" >> "$log" 2>&1 || true + fi + done + fi + fi + + # Appended, never assigned: the bridge's own overrides are already in there. + np_feat_over=$(np_feat_field "$np_feat_file" overrides | head -1) + if [ -n "$np_feat_over" ]; then + export WINEDLLOVERRIDES="${WINEDLLOVERRIDES:+$WINEDLLOVERRIDES;}$np_feat_over" + echo "=== $np_feat_key adds overrides $np_feat_over ===" >> "$log" 2>&1 || true + fi + + # Fed by here-document, not a pipe: a piped loop runs in a subshell and its exports + # would not survive it. + while IFS= read -r np_feat_env; do + # A descriptor with no env still yields one empty line through the here-document. + [ -n "$np_feat_env" ] || continue + np_feat_name=${np_feat_env%%=*} + case "$np_feat_name" in + [A-Z_][A-Z0-9_]*) ;; + *) echo "=== features: $np_feat_file env $np_feat_env is not NAME=VALUE ===" \ + >> "$log" 2>&1 || true; continue ;; + esac + [ "$np_feat_name" = "$np_feat_env" ] && continue + # The name is checked above, so it is safe to build an assignment around it; the + # value goes through a variable so spaces and quotes in it survive. + # shellcheck disable=SC2034 # the eval below reads it + np_feat_value=${np_feat_env#*=} + eval "export $np_feat_name=\"\$np_feat_value\"" + np_env_names="$np_env_names $np_feat_name" + echo "=== $np_feat_key sets $np_feat_env ===" >> "$log" 2>&1 || true + done <> "$log" 2>&1 || true @@ -1605,7 +1733,8 @@ else echo "=== client staged no overlay renderer, overlay disabled ===" >> "$log" 2>&1 || true fi set -- --args "$shim_exe" "$@" -for name in $(env | LC_ALL=C sed -nE 's/^(CX_APPLEGPTK_LIBD3DSHARED_PATH|Steam[A-Za-z0-9]*|(CX_GRAPHICS|D3DM_|DXMT_|DXVK_|MTL_|ROSETTA_)[A-Z0-9_]*)=.*/\1/p'); do +# shellcheck disable=SC2086 # np_env_names is a list of names and has to split +for name in $np_env_names $(env | LC_ALL=C sed -nE 's/^(CX_APPLEGPTK_LIBD3DSHARED_PATH|Steam[A-Za-z0-9]*|(CX_GRAPHICS|D3DM_|DXMT_|DXVK_|MTL_|ROSETTA_)[A-Z0-9_]*)=.*/\1/p'); do eval "value=\$$name" # shellcheck disable=SC2154 # eval assigns value on the line above set -- --env "$name=$value" "$@" diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index da883dd2..3c0f4c16 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -3,8 +3,11 @@ #include "nativeplatform.h" #include "../util/log.h" +#include +#include #include #include +#include #define NP_C1 "\001" #define NP_C2 "\002" @@ -25,6 +28,10 @@ #define NP_FALLBACK_TOOL "\021" // Stands for the JS array set by np_webpatch_set_fex_tools. #define NP_FEX_TOOLS "\022" +// Stand for the readiness object and the Developer rows, both read from features.d. +// Past NP_C12, so they do not land in the capture range. +#define NP_READY "\027" +#define NP_FEATS "\030" #define NP_TOOL_NAME_MAX 128 @@ -84,9 +91,156 @@ void np_webpatch_set_fex_tools(const char *const *names, size_t count) { g_fex_tools[n] = '\0'; } +static int has_suffix(const char *s, const char *suf); + +// "Developer" section rows are declared in ~/.config/notproton/features.d/*.conf, one +// "key = value" per line, and read by compat_run.sh +#define NP_FEAT_DIR ".config/notproton/features.d" +#define NP_FEAT_MAX 32 +#define NP_FEAT_REQ_MAX 4 +#define NP_FEAT_STR 192 +#define NP_FEAT_DESC 512 + +typedef struct { + char key[NP_FEAT_STR]; + char label[NP_FEAT_STR]; + char desc[NP_FEAT_DESC]; + char section[32]; + char on[32]; + char off[32]; + char depends[NP_FEAT_STR]; + char req[NP_FEAT_REQ_MAX][NP_FEAT_STR]; + int nreq; +} np_feat_t; + +static int feat_exists(const char *home, const char *rel) { + char path[2048]; + struct stat st; + if (!rel || !*rel) return 0; + if (*rel == '/') return stat(rel, &st) == 0 && S_ISREG(st.st_mode); + if (snprintf(path, sizeof(path), "%s/%s", home, rel) >= (int)sizeof(path)) return 0; + return stat(path, &st) == 0 && S_ISREG(st.st_mode); +} + +static void feat_trim(char *v) { + size_t n; + v[strcspn(v, "\r\n")] = 0; + n = strlen(v); + while (n && (v[n - 1] == ' ' || v[n - 1] == '\t')) v[--n] = 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]; + return NULL; +} + +// One descriptor file. Unknown fields are ignored so a descriptor written for a later +// version still loads, and a file with no key is skipped rather than failing the patch. +static void feat_read_file(const char *path, np_feat_t *list, int *count) { + char line[2048]; + np_feat_t f; + FILE *fp = fopen(path, "r"); + if (!fp) return; + memset(&f, 0, sizeof(f)); + while (fgets(line, sizeof(line), fp)) { + char *eq, *v, *k = line; + while (*k == ' ' || *k == '\t') k++; + if (*k == '#' || !(eq = strchr(k, '='))) continue; + *eq = 0; + v = eq + 1; + feat_trim(k); + while (*v == ' ' || *v == '\t') v++; + feat_trim(v); + if (!*v) continue; + if (strcmp(k, "key") == 0) snprintf(f.key, sizeof(f.key), "%s", v); + else if (strcmp(k, "label") == 0) snprintf(f.label, sizeof(f.label), "%s", v); + else if (strcmp(k, "description") == 0) + snprintf(f.desc, sizeof(f.desc), "%s", v); + else if (strcmp(k, "section") == 0) snprintf(f.section, sizeof(f.section), "%s", v); + else if (strcmp(k, "on") == 0) snprintf(f.on, sizeof(f.on), "%s", v); + else if (strcmp(k, "off") == 0) snprintf(f.off, sizeof(f.off), "%s", v); + else if (strcmp(k, "depends") == 0) snprintf(f.depends, sizeof(f.depends), "%s", v); + else if (strcmp(k, "requires") == 0 && f.nreq < NP_FEAT_REQ_MAX) + snprintf(f.req[f.nreq++], NP_FEAT_STR, "%s", v); + } + fclose(fp); + // A descriptor with no key cannot be acted on; everything else is optional. + if (!*f.on) snprintf(f.on, sizeof(f.on), "1"); + if (!*f.section) snprintf(f.section, sizeof(f.section), "dev"); + if (!*f.key) { + NP_WARN("features: %s declares no key, ignored", path); + return; + } + // Only the Developer section is descriptor-driven. Dropping an unknown section + // quietly would look exactly like a typo that worked, so say so and skip the file. + if (strcmp(f.section, "dev") != 0) { + NP_WARN("features: %s declares section '%s', which does not exist; " + "the only section descriptors can add to is 'dev'", path, f.section); + return; + } + if (feat_find(list, *count, f.key)) { + NP_WARN("features: %s repeats key %s, ignored", path, f.key); + return; + } + if (*count >= NP_FEAT_MAX) { + NP_WARN("features: more than %d descriptors, %s ignored", NP_FEAT_MAX, path); + return; + } + list[(*count)++] = f; +} + +static int feat_load(const char *home, np_feat_t *list) { + char dir[2048]; + struct dirent *de; + DIR *d; + int count = 0; + char **names = NULL; + int nnames = 0; + + if (snprintf(dir, sizeof(dir), "%s/" NP_FEAT_DIR, home) >= (int)sizeof(dir)) return 0; + if ((d = opendir(dir))) { + 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); + // readdir order is not defined; sort so the panel row order is stable. + 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)) + feat_read_file(path, list, &count); + free(names[i]); + } + free(names); + } + + return count; +} + +static _Thread_local char g_feat_ready[4096] = "{}"; +static _Thread_local char g_feat_rows[16384] = "[]"; +static _Thread_local int g_feat_ready_done; +static void feat_build(void); + static const char *marker_text(char c) { if (c == NP_FALLBACK_TOOL[0]) return g_fallback_tool; if (c == NP_FEX_TOOLS[0]) return g_fex_tools; + if (c == NP_READY[0] || c == NP_FEATS[0]) { + if (!g_feat_ready_done) feat_build(); + return c == NP_READY[0] ? g_feat_ready : g_feat_rows; + } return NULL; } @@ -179,6 +333,68 @@ static int out_put(np_out_t *o, const char *p, size_t n) { return 1; } +// Built once from features.d, or supplied by a test through np_webpatch_set_features. + +static int out_jsstr(np_out_t *o, const char *v) { + if (!out_put(o, "\"", 1)) return 0; + for (const char *p = v; *p; p++) { + if (*p == '"' || *p == '\\') { if (!out_put(o, "\\", 1)) return 0; } + else if ((unsigned char)*p < 0x20) continue; + if (!out_put(o, p, 1)) return 0; + } + return out_put(o, "\"", 1); +} + +static void feat_build(void) { + const char *home = getenv("HOME"); + np_feat_t list[NP_FEAT_MAX]; + np_out_t ready = { 0 }, rows = { 0 }; + int n = 0, wrote = 0; + + g_feat_ready_done = 1; + if (!home) return; + n = feat_load(home, list); + if (!out_put(&ready, "{", 1) || !out_put(&rows, "[", 1)) goto done; + for (int i = 0; i < n; i++) { + // Nothing declared means nothing can be missing: a row that only steers another + // feature, such as a sub-option, is always offerable. + int ok = list[i].nreq == 0; + char item[NP_FEAT_STR + 32]; + int len; + for (int j = 0; j < list[i].nreq; j++) + if (feat_exists(home, list[i].req[j])) { ok = 1; break; } + len = snprintf(item, sizeof(item), "%s\"%s\":%d", i ? "," : "", list[i].key, ok); + if (len < 0 || len >= (int)sizeof(item) || !out_put(&ready, item, (size_t)len)) goto done; + + if (strcmp(list[i].section, "dev") != 0 || !*list[i].label) continue; + if (wrote++ && !out_put(&rows, ",", 1)) goto done; + if (!out_put(&rows, "{k:", 3) || !out_jsstr(&rows, list[i].key)) goto done; + if (!out_put(&rows, ",l:", 3) || !out_jsstr(&rows, list[i].label)) goto done; + if (!out_put(&rows, ",on:", 4) || !out_jsstr(&rows, list[i].on)) goto done; + 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 (!out_put(&rows, "}", 1)) goto done; + } + if (!out_put(&ready, "}", 1) || !out_put(&rows, "]", 1)) goto done; + if (ready.len < sizeof(g_feat_ready) && rows.len < sizeof(g_feat_rows)) { + snprintf(g_feat_ready, sizeof(g_feat_ready), "%.*s", (int)ready.len, ready.buf); + snprintf(g_feat_rows, sizeof(g_feat_rows), "%.*s", (int)rows.len, rows.buf); + } else { + NP_WARN("features: descriptors are too large to render, ignoring them"); + } +done: + free(ready.buf); + free(rows.buf); +} + +// Lets a check supply both rather than read whatever an install happens to carry. +void np_webpatch_set_features(const char *ready, const char *rows) { + snprintf(g_feat_ready, sizeof(g_feat_ready), "%s", ready ? ready : "{}"); + snprintf(g_feat_rows, sizeof(g_feat_rows), "%s", rows ? rows : "[]"); + g_feat_ready_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); @@ -290,6 +506,8 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { ".MSCXPanel .MSCXRow{display:flex;flex-direction:row;padding:9px;margin:0;" \ "color:#dfe3e6;background:rgba(59,63,72,.5);border-radius:3px}" \ ".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}\"" // FEX runs arm64 Wine, which has no D3DMetal and never runs under Rosetta. @@ -299,9 +517,15 @@ 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," \ - "T=(ks,l,on,off)=>(0," RT ".jsx)(" BARREL ".Yh,{className:\"MSCXRow\",label:l," \ - "checked:g(ks[0])===on," \ - "onChange:v=>s(ks.map(k=>[k,v?on:(off||\"\")]))},ks[0])," \ + "MSCXReady=" NP_READY ",MSCXFeat=" NP_FEATS "," \ + "on1=k=>{const p=MSCXFeat.find(x=>x.k===k);return!!p&&g(k)===p.on}," \ + "T=(ks,l,on,off,d)=>{const rd=MSCXReady[ks[0]]!==0;" \ + "return(0," RT ".jsx)(" BARREL ".Yh," \ + "{className:\"MSCXRow\"+(d?\" MSCXRowDesc\":\"\")," \ + "label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ + "description:d||undefined," \ + "checked:rd&&g(ks[0])===on," \ + "onChange:v=>s(ks.map(k=>[k,v?on:(off||\"\")]))},ks[0])}," \ "bv=g(\"CX_GRAPHICS_BACKEND\"),b=fex&&\"d3dmetal\"===bv?\"\":bv," \ "dm=!fex&&(\"\"===b||\"d3dmetal\"===b)," \ "dx=\"\"===b||\"dxmt\"===b," \ @@ -351,7 +575,10 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "\"允许游戏直接读取控制器(可能导致 Steam Input 失效,不推荐)\"," \ "\"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\")" \ + "]},\"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))},\"dev\"):null)" \ "]})})}" #define NP_CX_OPTIONS_CAPTURE(RT, BARREL) \ diff --git a/dylib/feats/webpatch.h b/dylib/feats/webpatch.h index 28c67935..9bcad115 100644 --- a/dylib/feats/webpatch.h +++ b/dylib/feats/webpatch.h @@ -17,6 +17,10 @@ void np_webpatch_set_fallback_tool(const char *name); // 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); + +// Supplies the readiness object and Developer rows, for checks that must not read +// whatever features.d an install happens to carry. +void np_webpatch_set_features(const char *ready, 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/tests/envcheck.sh b/dylib/tests/envcheck.sh index 32cb9488..ced69a7a 100755 --- a/dylib/tests/envcheck.sh +++ b/dylib/tests/envcheck.sh @@ -69,7 +69,7 @@ np_build="test" library="$np_support/runners/crossover-$np_build/CrossOver/lib64/apple_gptk/external/libd3dshared.dylib" init=$(sed -n '/^CX_ROOT=/,/^wine_unix=/p' "$SRC") # shellcheck disable=SC2016 # Match the literal launcher loop. -forward=$(sed -n '/^for name in $(env /,/^done$/p' "$SRC") +forward=$(sed -n '/^for name in $np_env_names $(env /,/^done$/p' "$SRC") [ -n "$init" ] && [ -n "$forward" ] || { echo "FAIL: runner environment blocks not found"; exit 1; } unset CX_APPLEGPTK_LIBD3DSHARED_PATH eval "$init" @@ -98,7 +98,7 @@ drop=$(sed -n '/^np_dropped=""$/,/^fi$/p' "$SRC") [ -n "$drop" ] || { echo "FAIL: the arm64 settings block was not found"; exit 1; } drop_at=$(grep -n '^np_dropped=""$' "$SRC" | cut -d: -f1) # shellcheck disable=SC2016 # Match the literal launcher loop. -forward_at=$(grep -n '^for name in $(env ' "$SRC" | cut -d: -f1) +forward_at=$(grep -n '^for name in $np_env_names $(env ' "$SRC" | cut -d: -f1) [ "$drop_at" -lt "$forward_at" ] || bad "the arm64 settings block runs before the forwarding loop" "< $forward_at" "$drop_at" seen() { sh -c 'env | sed -nE "s/^(CX_GRAPHICS_BACKEND|D3DM_[A-Z0-9_]*|DXMT_[A-Z0-9_]*|ROSETTA_[A-Z0-9_]*)=.*/\1/p" | sort | tr "\n" " "'; } diff --git a/dylib/tests/featcheck.c b/dylib/tests/featcheck.c new file mode 100644 index 00000000..a6266b2a --- /dev/null +++ b/dylib/tests/featcheck.c @@ -0,0 +1,173 @@ +// Feature descriptors drive the Developer rows and whether each one is offerable, so +// both answers are checked against a descriptor directory this test builds itself. The +// emitters read $HOME, which is what makes a temp tree enough to pin them down. +#include "../feats/webpatch.c" + +#include +#include +#include + +// Satisfies the log macros without pulling in log.c. Rejection reasons land on stderr, +// which is where a check that expects one goes looking. +int np_log_level = NP_LVL_WARN; +FILE *np_log_file; + +char *np_nativeplatform_javascript(void) { + return NULL; +} + +static int g_failed; +static char g_home[1024]; +static char g_featdir[1200]; + +static void ok(int cond, const char *what) { + printf(" %s %s\n", cond ? "ok " : "FAIL", what); + if (!cond) g_failed++; +} + +static void shout(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + vprintf(fmt, ap); + va_end(ap); + putchar('\n'); +} + +static void write_desc(const char *name, const char *body) { + char path[2048]; + FILE *f; + snprintf(path, sizeof(path), "%s/%s", g_featdir, name); + if (!(f = fopen(path, "w"))) { shout("cannot write %s", path); g_failed++; return; } + fputs(body, f); + fclose(f); +} + +static void clear_descs(void) { + char cmd[2600]; + snprintf(cmd, sizeof(cmd), "rm -f '%s'/*.conf", g_featdir); + if (system(cmd) != 0) { shout("cannot clear %s", g_featdir); g_failed++; } +} + +// Touch a file under the temp home so a "requires" can be made to resolve. +static void make_asset(const char *rel) { + char path[2048], cmd[2200]; + FILE *f; + snprintf(path, sizeof(path), "%s/%s", g_home, rel); + snprintf(cmd, sizeof(cmd), "mkdir -p \"$(dirname '%s')\"", path); + if (system(cmd) != 0) { shout("cannot make dirs for %s", path); g_failed++; return; } + if (!(f = fopen(path, "w"))) { shout("cannot write %s", path); g_failed++; return; } + fclose(f); +} + +// feat_build caches, so each call re-reads the directory the test just rewrote. +static void rebuild(void) { + g_feat_ready_done = 0; + feat_build(); +} + +static char *ready_json(void) { rebuild(); return g_feat_ready; } +static char *feats_json(void) { rebuild(); return g_feat_rows; } + +int main(void) { + char tmpl[] = "/tmp/np-featcheck.XXXXXX"; + char cmd[2600]; + char *dir = mkdtemp(tmpl); + + if (!dir) { fputs("cannot make a temp directory\n", stderr); return 2; } + 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 descriptor directory\n", stderr); return 2; } + setenv("HOME", g_home, 1); + + puts("== an empty descriptor directory"); + ok(strcmp(feats_json(), "[]") == 0, "renders no rows"); + ok(strcmp(ready_json(), "{}") == 0, "reports no readiness"); + + puts("== a descriptor with an unmet requires"); + write_desc("10-a.conf", + "key = NOTPROTON_A\nlabel = Feature A\nsection = dev\n" + "on = 1\noff = 0\nrequires = assets/a.dylib\n"); + ok(strstr(feats_json(), + "{k:\"NOTPROTON_A\",l:\"Feature A\",on:\"1\",off:\"0\",dep:\"\",d:\"\"}") != NULL, + "renders with its label and values"); + ok(strcmp(ready_json(), "{\"NOTPROTON_A\":0}") == 0, "is not ready"); + + puts("== once the asset exists"); + make_asset("assets/a.dylib"); + ok(strcmp(ready_json(), "{\"NOTPROTON_A\":1}") == 0, "is ready"); + + puts("== repeated requires are any-of"); + clear_descs(); + write_desc("10-b.conf", + "key = NOTPROTON_B\nlabel = Feature B\nsection = dev\n" + "requires = assets/missing-one.dylib\nrequires = assets/a.dylib\n"); + ok(strcmp(ready_json(), "{\"NOTPROTON_B\":1}") == 0, "the second entry can satisfy it"); + + puts("== a row that requires nothing"); + clear_descs(); + write_desc("10-c.conf", "key = NOTPROTON_C\nlabel = Feature C\nsection = dev\n"); + ok(strcmp(ready_json(), "{\"NOTPROTON_C\":1}") == 0, + "is always offerable, having nothing that can be missing"); + ok(strstr(feats_json(), "on:\"1\"") != NULL, "defaults its on value to 1"); + + puts("== file order decides row order"); + clear_descs(); + write_desc("30-z.conf", "key = NOTPROTON_Z\nlabel = Zed\nsection = dev\n"); + write_desc("10-a.conf", "key = NOTPROTON_A\nlabel = Ay\nsection = dev\n"); + write_desc("20-m.conf", "key = NOTPROTON_M\nlabel = Em\nsection = dev\n"); + { + const char *j = feats_json(); + const char *a = strstr(j, "NOTPROTON_A"); + const char *m = strstr(j, "NOTPROTON_M"); + const char *z = strstr(j, "NOTPROTON_Z"); + ok(a && m && z && a < m && m < z, "rows follow the sorted file names"); + } + + puts("== rejections"); + clear_descs(); + write_desc("10-ok.conf", "key = NOTPROTON_OK\nlabel = Fine\nsection = dev\n"); + write_desc("20-badsection.conf", "key = NOTPROTON_BAD\nlabel = Bad\nsection = graphics\n"); + write_desc("30-nokey.conf", "label = No key\nsection = dev\n"); + write_desc("40-dup.conf", "key = NOTPROTON_OK\nlabel = Again\nsection = dev\n"); + { + const char *j = feats_json(); + ok(strstr(j, "NOTPROTON_OK") != NULL, "a good descriptor still loads"); + ok(strstr(j, "NOTPROTON_BAD") == NULL, "an unknown section is refused"); + ok(strstr(j, "No key") == NULL, "a descriptor with no key is refused"); + ok(strstr(j, "Again") == NULL, "a repeated key is refused"); + } + + puts("== an optional description"); + clear_descs(); + write_desc("10-d.conf", + "key = NOTPROTON_D\nlabel = Dee\nsection = dev\n" + "description = What this does, in a sentence.\n"); + ok(strstr(feats_json(), "d:\"What this does, in a sentence.\"") != NULL, + "is carried through to the row"); + clear_descs(); + write_desc("10-e.conf", "key = NOTPROTON_E\nlabel = Ee\nsection = dev\n"); + ok(strstr(feats_json(), "d:\"\"") != NULL, + "is empty when the descriptor omits it"); + + puts("== a label cannot break the page it renders into"); + clear_descs(); + write_desc("10-q.conf", + "key = NOTPROTON_Q\nlabel = He said \"go\" \\ onwards\nsection = dev\n"); + ok(strstr(feats_json(), "l:\"He said \\\"go\\\" \\\\ onwards\"") != NULL, + "quotes and backslashes are escaped"); + + puts("== a missing descriptor directory"); + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", g_featdir); + if (system(cmd) != 0) { shout("cannot remove %s", g_featdir); g_failed++; } + ok(strcmp(feats_json(), "[]") == 0, "renders no rows, since nothing ships by default"); + ok(strcmp(ready_json(), "{}") == 0, "reports no readiness"); + + 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: descriptors load, rank, validate and report readiness"); + return 0; +} diff --git a/dylib/tests/featstagecheck.sh b/dylib/tests/featstagecheck.sh new file mode 100755 index 00000000..157368dd --- /dev/null +++ b/dylib/tests/featstagecheck.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# A descriptor that stages DLLs into the prefix has to put them there and add to +# WINEDLLOVERRIDES without dropping the bridge's own entries. +set -e +SRC="${1:-$(dirname "$0")/../feats/compat_run.sh}" +[ -f "$SRC" ] || { echo "featstagecheck: $SRC not present, skipped"; exit 0; } + +block=$(sed -n '/^np_feat_dir=/,/^done$/p' "$SRC") +case "$block" in + *np_feat_stage*np_feat_over*) ;; + *) echo "FAIL: the descriptor loop was not found in $SRC"; exit 1 ;; +esac + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/stage" "$work/pfx/drive_c/windows/system32" \ + "$work/.config/notproton/features.d" +printf 'shipped\n' > "$work/pfx/drive_c/windows/system32/d3d11.dll" +printf 'ours\n' > "$work/stage/d3d11.dll" +printf 'ours\n' > "$work/stage/dxgi.dll" +printf '%s\n' "$block" > "$work/block.sh" + +cat > "$work/feature.conf" < "$work/pfx/drive_c/windows/system32/d3d11.dll" +overrides=$(run 0) +ok "a feature switched off stages nothing" "$(cat "$work/pfx/drive_c/windows/system32/d3d11.dll")" "shipped" +ok "and adds no overrides" "$overrides" "steamclient=n;lsteamclient=b" + +# A stage directory that is not there is a user error, not a reason to refuse the launch. +sed "s|^stage = .*|stage = $work/missing|" "$work/feature.conf" > "$work/feature.conf.new" +mv -f "$work/feature.conf.new" "$work/feature.conf" +overrides=$(run 1) +ok "a missing stage directory still launches" "$overrides" "steamclient=n;lsteamclient=b;d3d11,dxgi=n" + +[ "$fail" -eq 0 ] || exit 1 +echo "==> featstagecheck: descriptors stage into the prefix and extend the overrides" diff --git a/dylib/tests/panel-behavior/behavior.js b/dylib/tests/panel-behavior/behavior.js index 2ea63504..d405596f 100644 --- a/dylib/tests/panel-behavior/behavior.js +++ b/dylib/tests/panel-behavior/behavior.js @@ -96,6 +96,74 @@ for (const form of Object.keys(FORMS)) { const css = nodes('').filter(x => x.type === 'style'); t.ok(css.length === 1 && css[0].props.children.includes('.MSCXPanel .MSCXRow{'), 'the panel carries one stylesheet defining the row rule'); + t.ok(css[0].props.children.includes('.MSCXPanel .MSCXRowDesc>:last-child{flex-basis:100%}'), + 'and the rule that drops a description onto its own line'); + + // Developer rows come from descriptors, so these speak for the shapes a descriptor can + // take rather than for whichever features an install happens to carry. With none + // declared the panel is what every check above already asserts: no Developer section. + const FEATS = JSON.stringify([ + { k: 'NOTPROTON_ALPHA', l: 'Alpha', on: '1', off: '', dep: '', d: '' }, + { k: 'NOTPROTON_BETA', l: 'Beta', on: '1', off: '0', dep: '', d: 'Why Beta.' }, + { k: 'NOTPROTON_BETA_SUB', l: 'Beta: extra', on: '1', off: '0', + dep: 'NOTPROTON_BETA', d: '' }, + ]).replace(/"(\w+)":/g, '$1:'); + + t.ok(!sections(nodes('')).includes('Developer'), + 'no descriptors means no Developer section'); + // "len && " yields 0 for an empty array and React renders that as a stray "0". + // walk() drops non-objects, so this scans raw children instead; the self-check below + // keeps it from passing because it looks in the wrong place. + const hasZero = n => { + if (n === 0) return true; + if (!n || typeof n !== 'object') return false; + if (Array.isArray(n)) return n.some(hasZero); + if (typeof n.type === 'function') return hasZero(n.type(n.props)); + return !!(n.props && n.props.children) && hasZero(n.props.children); + }; + t.ok(hasZero({ props: { children: [1 && 'x', 0 && 'y'] } }), + 'the stray-zero scanner finds a zero when there is one'); + t.ok(!hasZero(P({ details: details('') })), + 'no descriptors renders nothing rather than a literal 0'); + + const { render: PF } = panel(emit, form, undefined, + { 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 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'), + 'descriptors add a Developer section'); + 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:')), + 'a dependent row appears once its parent is on'); + t.ok(!flabels('NOTPROTON_BETA=0 %command%').some(l => l.startsWith('Beta:')), + 'a parent at its off value does not count as on'); + t.ok(alpha('') && alpha('').props.disabled === true, + 'a feature whose assets are missing renders disabled'); + t.ok(alpha('').props.label.endsWith('(not installed)'), + 'it says why it cannot be used'); + t.ok(alpha('NOTPROTON_ALPHA=1 %command%').props.checked === false, + 'a stale launch option does not show it as on'); + t.ok(frows('').filter(x => x.props.disabled === true).length === 1, + 'only that one is disabled'); + t.ok(frows('').find(x => x.props.label === 'Beta').props.disabled === false, + 'a feature with no readiness entry stays enabled'); + // The row's children are checkbox, label and description as siblings, and the panel's + // own rule lays them out in a row. A description row wraps instead, with the last child + // taking the full width, so the checkbox and label stay on one line and the text drops + // beneath them. + 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'); + t.ok(frows('').find(x => x.props.label === 'Beta').props.description === 'Why Beta.', + "a descriptor's description reaches the row"); + t.ok(frows('').find(x => x.props.label.startsWith('Alpha')).props.description === undefined, + 'a descriptor without one passes no description at all'); const { render: S } = panel(emit, form, undefined, { names: ['o', 't'] }); const shadowed = walk(S({ details: details('CX_GRAPHICS_BACKEND=dxmt %command%') })); diff --git a/dylib/tests/panel-behavior/emit.c b/dylib/tests/panel-behavior/emit.c index aff84c71..88d27770 100644 --- a/dylib/tests/panel-behavior/emit.c +++ b/dylib/tests/panel-behavior/emit.c @@ -4,6 +4,7 @@ #include "../../feats/webpatch.c" #include +#include #include int np_log_level = 0; @@ -26,6 +27,8 @@ int main(int argc, char **argv) { np_webpatch_set_fallback_tool(argc >= 3 ? argv[2] : NULL); np_webpatch_set_fex_tools((const char *const *)argv + 3, argc > 3 ? (size_t)(argc - 3) : 0); + // Both default to empty, which is what an install with no descriptors looks like. + np_webpatch_set_features(getenv("NP_TEST_READY"), getenv("NP_TEST_FEATS")); put_panel(strcmp(argv[1], "component") == 0 ? NP_CX_OPTIONS_COMPONENT : NP_CX_OPTIONS_STATEMENT); return 0; diff --git a/dylib/tests/panel-behavior/harness.js b/dylib/tests/panel-behavior/harness.js index 23b9910a..a2cc2514 100644 --- a/dylib/tests/panel-behavior/harness.js +++ b/dylib/tests/panel-behavior/harness.js @@ -26,8 +26,15 @@ 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'] } = {}) { - const raw = execFileSync(emit, [form, fallback, ...fexTools], { encoding: 'utf8' }).trim(); + { fallback = 'notproton', fexTools = [], names = ['R0', 'B0'], + ready, feats } = {}) { + // 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; + const raw = execFileSync(emit, [form, fallback, ...fexTools], + { encoding: 'utf8', env }).trim(); const f = FORMS[form]; const body = expand(raw, { [f.react]: names[0], [f.barrel]: names[1] }); const src = form === 'component' ? 'var ' + body.replace(/,$/, '') : body.replace(/;$/, ''); From 4735b93b07905917128df516f496c847695cd136 Mon Sep 17 00:00:00 2001 From: NotProtonNot <332734261+NotProtonNot@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:38:32 -0400 Subject: [PATCH 2/6] Plugin rework, bug fixes and small changes --- .github/workflows/app.yml | 2 + dylib/feats/compat_run.sh | 267 ++++++++++++++------- dylib/feats/webpatch.c | 119 +++++---- dylib/feats/webpatch.h | 3 +- dylib/tests/envcheck.sh | 4 +- dylib/tests/featcheck.c | 84 +++++-- dylib/tests/featstagecheck.sh | 266 +++++++++++++++++--- dylib/tests/gatecheck.c | 2 + dylib/tests/panel-behavior/behavior.js | 50 ++-- dylib/tests/panel-behavior/emit.c | 1 - dylib/tests/panel-behavior/harness.js | 2 - dylib/tests/panel-behavior/localization.js | 5 + 12 files changed, 593 insertions(+), 212 deletions(-) diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 0332cd0d..7943bf1d 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -122,6 +122,8 @@ jobs: make -s shortcutcheck make -s launch-shell make -s envcheck + make -s featcheck + make -s featstagecheck make -s buildcheck make -s settingscheck make -s routecheck diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index 7273e241..545a7ac6 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -1504,6 +1504,9 @@ if [ -n "\$STEAM_DYLD_INSERT_LIBRARIES" ]; then export DYLD_INSERT_LIBRARIES="\$STEAM_DYLD_INSERT_LIBRARIES" fi fi +if [ -n "\$NOTPROTON_FEATURE_DYLIBS" ]; then + export DYLD_INSERT_LIBRARIES="\${DYLD_INSERT_LIBRARIES:+\$DYLD_INSERT_LIBRARIES:}\$NOTPROTON_FEATURE_DYLIBS" +fi [ -n "\$NOTPROTON_GAME_CWD" ] && cd "\$NOTPROTON_GAME_CWD" "$WINELOADER" "\$@" exit \$? @@ -1595,80 +1598,144 @@ terminate() { echo "=== termination signal received, killing wine prefix ===" >> "$log" 2>&1 || true kill_wine_prefix [ -n "$open_pid" ] && kill "$open_pid" 2>/dev/null || true + [ -n "$open_pid" ] || exit 143 } +open_pid="" trap terminate TERM INT HUP -np_env_names="" -# Outside the support directory on purpose: a hard reinstall removes everything there -# that the app does not know about, which would take a user's descriptors with it. -np_feat_dir="$HOME/.config/notproton/features.d" +np_feat_dir="$np_support/plugins" +np_feat_log() { + echo "=== $* ===" >> "$log" 2>&1 || true +} +np_feat_field() { + LC_ALL=C sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\(.*[^[:space:]]\)[[:space:]]*$/\1/p" "$1" +} +np_feat_first() { + np_feat_field "$1" "$2" | head -1 +} np_feat_requires() { - np_feat_hit="" - for np_feat_file in "$np_feat_dir"/*.conf; do - [ -r "$np_feat_file" ] || continue - sed -n 's/^[[:space:]]*key[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' "$np_feat_file" \ - | grep -qx "$1" || continue - np_feat_hit=$(sed -n \ - 's/^[[:space:]]*requires[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' "$np_feat_file" \ - | while IFS= read -r np_feat_req; do - # Leading parens: a case pattern's ) can otherwise close the $( ) around this. - case "$np_feat_req" in - (/*) [ -f "$np_feat_req" ] && { printf '%s' "$np_feat_req"; break; } ;; - (*) [ -f "$HOME/$np_feat_req" ] \ - && { printf '%s' "$HOME/$np_feat_req"; break; } ;; - esac - done) - [ -n "$np_feat_hit" ] && break + np_feat_field "$1" requires | head -n 4 | while IFS= read -r np_feat_req; do + case "$np_feat_req" in + (/*) ;; + (*) np_feat_req="$HOME/$np_feat_req" ;; + esac + [ -f "$np_feat_req" ] && { printf '%s' "$np_feat_req"; break; } done - printf '%s' "$np_feat_hit" + return 0 } -np_dev_add() { - if [ ! -f "$2" ]; then - echo "=== $1 requested but no dylib at $2 ===" >> "$log" 2>&1 || true - return 0 - fi - case ":$STEAM_DYLD_INSERT_LIBRARIES:" in - *":$2:"*) return 0 ;; - esac - if [ -n "$STEAM_DYLD_INSERT_LIBRARIES" ]; then - STEAM_DYLD_INSERT_LIBRARIES="$STEAM_DYLD_INSERT_LIBRARIES:$2" - else - STEAM_DYLD_INSERT_LIBRARIES="$2" - fi - export STEAM_DYLD_INSERT_LIBRARIES - echo "=== $1 will be loaded from $2 ===" >> "$log" 2>&1 || true +np_feat_installed() { + [ -z "$(np_feat_first "$1" requires)" ] || [ -n "$(np_feat_requires "$1")" ] } - -# Each descriptor whose key is switched on gets what it asks for: "inject = 1" loads the -# dylib its "requires" resolved to, each "env = NAME=VALUE" is exported and carried into -# the bundle, "stage" clones a directory of DLLs into the prefix, and "overrides" adds to -# WINEDLLOVERRIDES so those DLLs win over the builtins. Nothing here knows which features -# exist. -np_feat_field() { - sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\(.*\)$/\1/p" "$1" +np_feat_file_of() { + printf '%s\n' "$np_feat_list" | while IFS= read -r np_feat_entry; do + [ "${np_feat_entry%% *}" = "$1" ] && { printf '%s' "${np_feat_entry#* }"; break; } + done + return 0 +} +# webpatch.c skips files with overlong lines or values, so they're skipped here too. +np_feat_too_long() { + LC_ALL=C awk 'length($0) >= 2047 { f = 1 } END { exit !f }' "$1" && return 0 + for np_feat_spec in key:192:1 label:192:1 description:512:1 section:32:1 on:32:1 \ + off:32:1 depends:192:1 requires:192:4; do + np_feat_max=${np_feat_spec#*:} + np_feat_field "$1" "${np_feat_spec%%:*}" | head -n "${np_feat_spec##*:}" | + LC_ALL=C awk -v m="${np_feat_max%:*}" 'length($0) >= m { f = 1 } END { exit !f }' && + return 0 + done + return 1 +} +np_feat_switched() { + eval "np_feat_sv=\${$1-}" + np_feat_son=$(np_feat_first "$2" on) + # shellcheck disable=SC2154 # eval assigns np_feat_sv above + [ "$np_feat_sv" = "${np_feat_son:-1}" ] +} +# Same rules as the panel. +np_feat_active() { + [ "$2" -le 32 ] || return 1 + np_feat_af=$(np_feat_file_of "$1") + [ -n "$np_feat_af" ] || return 1 + np_feat_switched "$1" "$np_feat_af" || return 1 + np_feat_installed "$np_feat_af" || return 1 + np_feat_adep=$(np_feat_first "$np_feat_af" depends) + [ -z "$np_feat_adep" ] || np_feat_active "$np_feat_adep" $(($2 + 1)) } -for np_feat_file in "$np_feat_dir"/*.conf; do - [ -r "$np_feat_file" ] || continue - np_feat_key=$(np_feat_field "$np_feat_file" key | head -1) - [ -n "$np_feat_key" ] || continue - case "$np_feat_key" in - NOTPROTON_[A-Z0-9_]*) ;; - *) echo "=== features: $np_feat_file key $np_feat_key is not a NOTPROTON_ name ===" \ - >> "$log" 2>&1 || true; continue ;; - esac - eval "np_feat_val=\${$np_feat_key:-}" - np_feat_on=$(np_feat_field "$np_feat_file" on | head -1) - [ -n "$np_feat_on" ] || np_feat_on=1 - # shellcheck disable=SC2154 # eval assigns np_feat_val above - [ "$np_feat_val" = "$np_feat_on" ] || continue - if [ "$(np_feat_field "$np_feat_file" inject | head -1)" = 1 ]; then - np_dev_add "$np_feat_key" "$(np_feat_requires "$np_feat_key")" +np_feat_list="" +np_feat_count=0 +# Same order as webpatch.c. +np_feat_files=$(printf '%s\n' "$np_feat_dir"/*.conf | LC_ALL=C sort) +while IFS= read -r np_feat_file; do + case "$np_feat_file" in + ("$np_feat_dir"/*.conf) ;; + (*) continue ;; + esac + [ -f "$np_feat_file" ] && [ -r "$np_feat_file" ] || continue + if np_feat_too_long "$np_feat_file"; then + np_feat_log "plugins: $np_feat_file has a line or value that's too long, ignored" + continue + fi + np_feat_key=$(np_feat_first "$np_feat_file" key) + if [ -z "$np_feat_key" ]; then + np_feat_log "plugins: $np_feat_file has no key, ignored" + continue + fi + if ! printf '%s\n' "$np_feat_key" | LC_ALL=C grep -qx 'NOTPROTON_[A-Z0-9_]\{1,\}'; then + np_feat_log "plugins: $np_feat_file has an invalid key $np_feat_key, ignored" + continue + fi + if [ -z "$(np_feat_first "$np_feat_file" label)" ]; then + np_feat_log "plugins: $np_feat_file has no label, ignored" + continue + fi + case "$(np_feat_first "$np_feat_file" section)" in + ("" | dev) ;; + (*) np_feat_log "plugins: $np_feat_file is not in the dev section, ignored"; continue ;; + esac + if [ -n "$(np_feat_file_of "$np_feat_key")" ]; then + np_feat_log "plugins: $np_feat_file repeats key $np_feat_key, ignored" + continue + fi + if [ "$np_feat_count" -ge 32 ]; then + np_feat_log "plugins: $np_feat_file is over the 32 plugin limit, ignored" + continue + fi + np_feat_count=$((np_feat_count + 1)) + np_feat_list="$np_feat_list +$np_feat_key $np_feat_file" +done <> "$log" 2>&1 || true + np_feat_log "$np_feat_key copies no DLLs as $np_feat_stage doesn't exist" elif [ ! -d "$np_feat_sys32" ]; then - echo "=== $np_feat_key cannot stage, no $np_feat_sys32 ===" >> "$log" 2>&1 || true + np_feat_log "$np_feat_key copies no DLLs as the prefix has no system32" else for np_feat_src in "$np_feat_stage"/*; do [ -f "$np_feat_src" ] || continue np_feat_dst="$np_feat_sys32/$(basename "$np_feat_src")" - if cp -c -f "$np_feat_src" "$np_feat_dst" 2>/dev/null \ - || cp -f "$np_feat_src" "$np_feat_dst"; then - echo "=== $np_feat_key staged $(basename "$np_feat_src") ===" >> "$log" 2>&1 || true + if [ -d "$np_feat_dst" ]; then + np_feat_log "$np_feat_key skips $(basename "$np_feat_src") as system32 has a folder by that name" + continue + fi + cmp -s "$np_feat_src" "$np_feat_dst" && continue + np_feat_tmp="$np_feat_dst.np-stage.$$" + rm -f "$np_feat_tmp" + if { cp -c "$np_feat_src" "$np_feat_tmp" 2>/dev/null \ + || cp "$np_feat_src" "$np_feat_tmp"; } && mv -f "$np_feat_tmp" "$np_feat_dst"; then + np_feat_log "$np_feat_key copied $(basename "$np_feat_src") into system32" else - echo "=== $np_feat_key failed to stage $np_feat_src ===" >> "$log" 2>&1 || true + rm -f "$np_feat_tmp" + np_feat_log "$np_feat_key failed to copy $(basename "$np_feat_src") into system32" fi done fi fi - # Appended, never assigned: the bridge's own overrides are already in there. - np_feat_over=$(np_feat_field "$np_feat_file" overrides | head -1) + np_feat_over=$(np_feat_first "$np_feat_file" overrides) if [ -n "$np_feat_over" ]; then export WINEDLLOVERRIDES="${WINEDLLOVERRIDES:+$WINEDLLOVERRIDES;}$np_feat_over" - echo "=== $np_feat_key adds overrides $np_feat_over ===" >> "$log" 2>&1 || true + np_feat_log "$np_feat_key adds overrides $np_feat_over" fi - # Fed by here-document, not a pipe: a piped loop runs in a subshell and its exports - # would not survive it. while IFS= read -r np_feat_env; do - # A descriptor with no env still yields one empty line through the here-document. [ -n "$np_feat_env" ] || continue - np_feat_name=${np_feat_env%%=*} - case "$np_feat_name" in - [A-Z_][A-Z0-9_]*) ;; - *) echo "=== features: $np_feat_file env $np_feat_env is not NAME=VALUE ===" \ - >> "$log" 2>&1 || true; continue ;; + case "$np_feat_env" in + (*=*) ;; + (*) np_feat_log "$np_feat_key skips env line $np_feat_env as it has no ="; continue ;; + esac + if ! printf '%s\n' "${np_feat_env%%=*}" | LC_ALL=C grep -qx '[A-Z_][A-Z0-9_]*'; then + np_feat_log "$np_feat_key skips env ${np_feat_env%%=*} as it isn't a valid variable name" + continue + fi + case "${np_feat_env%%=*}" in + (DYLD_* | PATH | CX_ROOT | CX_HOME | WINE* | STEAM_COMPAT_* | STEAM_DYLD_INSERT_LIBRARIES | \ + NOTPROTON_FEATURE_DYLIBS | NOTPROTON_GAME_CWD | NOTPROTON_OVERLAY_SHIM) + np_feat_log "$np_feat_key skips env ${np_feat_env%%=*} as the compat_run script sets it" + continue ;; esac - [ "$np_feat_name" = "$np_feat_env" ] && continue - # The name is checked above, so it is safe to build an assignment around it; the - # value goes through a variable so spaces and quotes in it survive. - # shellcheck disable=SC2034 # the eval below reads it - np_feat_value=${np_feat_env#*=} - eval "export $np_feat_name=\"\$np_feat_value\"" - np_env_names="$np_env_names $np_feat_name" - echo "=== $np_feat_key sets $np_feat_env ===" >> "$log" 2>&1 || true + # Kept in reverse since they get prepended to open args (which flips the order) + np_feat_envs="$np_feat_env +$np_feat_envs" + np_feat_log "$np_feat_key sets $np_feat_env" done <> "$log" 2>&1 || true fi set -- --args "$shim_exe" "$@" -# shellcheck disable=SC2086 # np_env_names is a list of names and has to split -for name in $np_env_names $(env | LC_ALL=C sed -nE 's/^(CX_APPLEGPTK_LIBD3DSHARED_PATH|Steam[A-Za-z0-9]*|(CX_GRAPHICS|D3DM_|DXMT_|DXVK_|MTL_|ROSETTA_)[A-Z0-9_]*)=.*/\1/p'); do +# Prepended first so they end up last among the --env args since open keeps the last of a repeated name. +while IFS= read -r np_feat_env; do + [ -n "$np_feat_env" ] && set -- --env "$np_feat_env" "$@" +done <= '\t' && c <= '\r'); +} + static void feat_trim(char *v) { - size_t n; - v[strcspn(v, "\r\n")] = 0; - n = strlen(v); - while (n && (v[n - 1] == ' ' || v[n - 1] == '\t')) v[--n] = 0; + size_t n = strlen(v); + while (n && feat_space(v[n - 1])) v[--n] = 0; +} + +// compat_run.sh ignores keys that aren't valid shell variable names, so the compatibility panel does the same thing +static int feat_key_ok(const char *k) { + if (strncmp(k, "NOTPROTON_", 10) != 0 || !k[10]) return 0; + for (k += 10; *k; k++) + if (!((*k >= 'A' && *k <= 'Z') || (*k >= '0' && *k <= '9') || *k == '_')) return 0; + return 1; +} + +// Takes the first value when a setting appears twice so as to match compat_run.sh. +static void feat_set(char *dst, size_t size, const char *v, int *bad) { + if (*dst) return; + if (strlen(v) >= size) *bad = 1; + snprintf(dst, size, "%s", v); } static np_feat_t *feat_find(np_feat_t *list, int n, const char *key) { @@ -135,56 +152,70 @@ static np_feat_t *feat_find(np_feat_t *list, int n, const char *key) { return NULL; } -// One descriptor file. Unknown fields are ignored so a descriptor written for a later -// version still loads, and a file with no key is skipped rather than failing the patch. +// Unknown fields are ignored so a plugin file written for a later version still loads. static void feat_read_file(const char *path, np_feat_t *list, int *count) { char line[2048]; np_feat_t f; + int bad = 0; FILE *fp = fopen(path, "r"); if (!fp) return; memset(&f, 0, sizeof(f)); while (fgets(line, sizeof(line), fp)) { char *eq, *v, *k = line; - while (*k == ' ' || *k == '\t') k++; + size_t len = strcspn(line, "\n"); + if (len >= sizeof(line) - 1) { + NP_WARN("plugins: %s has a line over %zu bytes, ignored", path, sizeof(line) - 2); + fclose(fp); + return; + } + line[len] = 0; + while (feat_space(*k)) k++; if (*k == '#' || !(eq = strchr(k, '='))) continue; *eq = 0; v = eq + 1; feat_trim(k); - while (*v == ' ' || *v == '\t') v++; + while (feat_space(*v)) v++; feat_trim(v); if (!*v) continue; - if (strcmp(k, "key") == 0) snprintf(f.key, sizeof(f.key), "%s", v); - else if (strcmp(k, "label") == 0) snprintf(f.label, sizeof(f.label), "%s", v); - else if (strcmp(k, "description") == 0) - snprintf(f.desc, sizeof(f.desc), "%s", v); - else if (strcmp(k, "section") == 0) snprintf(f.section, sizeof(f.section), "%s", v); - else if (strcmp(k, "on") == 0) snprintf(f.on, sizeof(f.on), "%s", v); - else if (strcmp(k, "off") == 0) snprintf(f.off, sizeof(f.off), "%s", v); - else if (strcmp(k, "depends") == 0) snprintf(f.depends, sizeof(f.depends), "%s", v); + if (strcmp(k, "key") == 0) feat_set(f.key, sizeof(f.key), v, &bad); + else if (strcmp(k, "label") == 0) feat_set(f.label, sizeof(f.label), v, &bad); + else if (strcmp(k, "description") == 0) feat_set(f.desc, sizeof(f.desc), v, &bad); + else if (strcmp(k, "section") == 0) feat_set(f.section, sizeof(f.section), v, &bad); + else if (strcmp(k, "on") == 0) feat_set(f.on, sizeof(f.on), v, &bad); + else if (strcmp(k, "off") == 0) feat_set(f.off, sizeof(f.off), v, &bad); + 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) - snprintf(f.req[f.nreq++], NP_FEAT_STR, "%s", v); + feat_set(f.req[f.nreq++], NP_FEAT_STR, v, &bad); } fclose(fp); - // A descriptor with no key cannot be acted on; everything else is optional. + if (bad) { + NP_WARN("plugins: %s has a value that's too long, ignored", path); + return; + } if (!*f.on) snprintf(f.on, sizeof(f.on), "1"); if (!*f.section) snprintf(f.section, sizeof(f.section), "dev"); if (!*f.key) { - NP_WARN("features: %s declares no key, ignored", path); + NP_WARN("plugins: %s has no key, ignored", path); + return; + } + if (!feat_key_ok(f.key)) { + NP_WARN("plugins: %s has an invalid key %s, ignored", path, f.key); + return; + } + if (!*f.label) { + NP_WARN("plugins: %s has no label, ignored", path); return; } - // Only the Developer section is descriptor-driven. Dropping an unknown section - // quietly would look exactly like a typo that worked, so say so and skip the file. if (strcmp(f.section, "dev") != 0) { - NP_WARN("features: %s declares section '%s', which does not exist; " - "the only section descriptors can add to is 'dev'", path, f.section); + NP_WARN("plugins: %s is not in the dev section, ignored", path); return; } if (feat_find(list, *count, f.key)) { - NP_WARN("features: %s repeats key %s, ignored", path, f.key); + NP_WARN("plugins: %s repeats key %s, ignored", path, f.key); return; } if (*count >= NP_FEAT_MAX) { - NP_WARN("features: more than %d descriptors, %s ignored", NP_FEAT_MAX, path); + NP_WARN("plugins: %s is over the %d plugin limit, ignored", path, NP_FEAT_MAX); return; } list[(*count)++] = f; @@ -202,7 +233,7 @@ static int feat_load(const char *home, np_feat_t *list) { if ((d = opendir(dir))) { while ((de = readdir(d))) { char **grown; - if (de->d_name[0] == '.') continue; + if (de->d_name[0] == '.' || strchr(de->d_name, '\n')) continue; if (!has_suffix(de->d_name, ".conf")) continue; grown = realloc(names, (size_t)(nnames + 1) * sizeof(*names)); if (!grown) break; @@ -212,7 +243,6 @@ static int feat_load(const char *home, np_feat_t *list) { nnames++; } closedir(d); - // readdir order is not defined; sort so the panel row order is stable. 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; @@ -229,9 +259,10 @@ static int feat_load(const char *home, np_feat_t *list) { return count; } -static _Thread_local char g_feat_ready[4096] = "{}"; -static _Thread_local char g_feat_rows[16384] = "[]"; +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 void feat_build(void); static const char *marker_text(char c) { @@ -333,8 +364,6 @@ static int out_put(np_out_t *o, const char *p, size_t n) { return 1; } -// Built once from features.d, or supplied by a test through np_webpatch_set_features. - static int out_jsstr(np_out_t *o, const char *v) { if (!out_put(o, "\"", 1)) return 0; for (const char *p = v; *p; p++) { @@ -352,12 +381,12 @@ static void feat_build(void) { int n = 0, wrote = 0; g_feat_ready_done = 1; + snprintf(g_feat_ready, sizeof(g_feat_ready), "{}"); + snprintf(g_feat_rows, sizeof(g_feat_rows), "[]"); if (!home) return; n = feat_load(home, list); if (!out_put(&ready, "{", 1) || !out_put(&rows, "[", 1)) goto done; for (int i = 0; i < n; i++) { - // Nothing declared means nothing can be missing: a row that only steers another - // feature, such as a sub-option, is always offerable. int ok = list[i].nreq == 0; char item[NP_FEAT_STR + 32]; int len; @@ -366,7 +395,6 @@ static void feat_build(void) { len = snprintf(item, sizeof(item), "%s\"%s\":%d", i ? "," : "", list[i].key, ok); if (len < 0 || len >= (int)sizeof(item) || !out_put(&ready, item, (size_t)len)) goto done; - if (strcmp(list[i].section, "dev") != 0 || !*list[i].label) continue; if (wrote++ && !out_put(&rows, ",", 1)) goto done; if (!out_put(&rows, "{k:", 3) || !out_jsstr(&rows, list[i].key)) goto done; if (!out_put(&rows, ",l:", 3) || !out_jsstr(&rows, list[i].label)) goto done; @@ -381,18 +409,18 @@ static void feat_build(void) { snprintf(g_feat_ready, sizeof(g_feat_ready), "%.*s", (int)ready.len, ready.buf); snprintf(g_feat_rows, sizeof(g_feat_rows), "%.*s", (int)rows.len, rows.buf); } else { - NP_WARN("features: descriptors are too large to render, ignoring them"); + NP_WARN("plugins: too large to show, all ignored"); } done: free(ready.buf); free(rows.buf); } -// Lets a check supply both rather than read whatever an install happens to carry. void np_webpatch_set_features(const char *ready, const char *rows) { snprintf(g_feat_ready, sizeof(g_feat_ready), "%s", ready ? ready : "{}"); snprintf(g_feat_rows, sizeof(g_feat_rows), "%s", rows ? rows : "[]"); g_feat_ready_done = 1; + g_feat_pinned = 1; } static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { @@ -518,8 +546,9 @@ 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 "," \ - "on1=k=>{const p=MSCXFeat.find(x=>x.k===k);return!!p&&g(k)===p.on}," \ - "T=(ks,l,on,off,d)=>{const rd=MSCXReady[ks[0]]!==0;" \ + "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))}," \ + "T=(ks,l,on,off,d,r)=>{const rd=r!==0;" \ "return(0," RT ".jsx)(" BARREL ".Yh," \ "{className:\"MSCXRow\"+(d?\" MSCXRowDesc\":\"\")," \ "label:rd?l:l+npL(\" (not installed)\",\" (未安装)\",\" (not installed)\"),disabled:!rd," \ @@ -578,7 +607,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "]},\"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))},\"dev\"):null)" \ + ".map(f=>T([f.k],f.l,f.on,f.off,f.d,MSCXReady[f.k]))},\"dev\"):null)" \ "]})})}" #define NP_CX_OPTIONS_CAPTURE(RT, BARREL) \ @@ -882,6 +911,8 @@ char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, if (out_shape) *out_shape = NULL; + if (!g_feat_pinned) + g_feat_ready_done = 0; const np_ui_shape_t *shape = NULL; for (size_t i = 0; i < NP_SHAPE_COUNT; i++) { diff --git a/dylib/feats/webpatch.h b/dylib/feats/webpatch.h index 9bcad115..9c87b593 100644 --- a/dylib/feats/webpatch.h +++ b/dylib/feats/webpatch.h @@ -18,8 +18,7 @@ void np_webpatch_set_fallback_tool(const char *name); void np_webpatch_set_fex_tools(const char *const *names, size_t count); -// Supplies the readiness object and Developer rows, for checks that must not read -// whatever features.d an install happens to carry. +// For tests, so they don't depend on the plugins installed on the machine. void np_webpatch_set_features(const char *ready, 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 diff --git a/dylib/tests/envcheck.sh b/dylib/tests/envcheck.sh index ced69a7a..32cb9488 100755 --- a/dylib/tests/envcheck.sh +++ b/dylib/tests/envcheck.sh @@ -69,7 +69,7 @@ np_build="test" library="$np_support/runners/crossover-$np_build/CrossOver/lib64/apple_gptk/external/libd3dshared.dylib" init=$(sed -n '/^CX_ROOT=/,/^wine_unix=/p' "$SRC") # shellcheck disable=SC2016 # Match the literal launcher loop. -forward=$(sed -n '/^for name in $np_env_names $(env /,/^done$/p' "$SRC") +forward=$(sed -n '/^for name in $(env /,/^done$/p' "$SRC") [ -n "$init" ] && [ -n "$forward" ] || { echo "FAIL: runner environment blocks not found"; exit 1; } unset CX_APPLEGPTK_LIBD3DSHARED_PATH eval "$init" @@ -98,7 +98,7 @@ drop=$(sed -n '/^np_dropped=""$/,/^fi$/p' "$SRC") [ -n "$drop" ] || { echo "FAIL: the arm64 settings block was not found"; exit 1; } drop_at=$(grep -n '^np_dropped=""$' "$SRC" | cut -d: -f1) # shellcheck disable=SC2016 # Match the literal launcher loop. -forward_at=$(grep -n '^for name in $np_env_names $(env ' "$SRC" | cut -d: -f1) +forward_at=$(grep -n '^for name in $(env ' "$SRC" | cut -d: -f1) [ "$drop_at" -lt "$forward_at" ] || bad "the arm64 settings block runs before the forwarding loop" "< $forward_at" "$drop_at" seen() { sh -c 'env | sed -nE "s/^(CX_GRAPHICS_BACKEND|D3DM_[A-Z0-9_]*|DXMT_[A-Z0-9_]*|ROSETTA_[A-Z0-9_]*)=.*/\1/p" | sort | tr "\n" " "'; } diff --git a/dylib/tests/featcheck.c b/dylib/tests/featcheck.c index a6266b2a..fd2d8730 100644 --- a/dylib/tests/featcheck.c +++ b/dylib/tests/featcheck.c @@ -1,14 +1,9 @@ -// Feature descriptors drive the Developer rows and whether each one is offerable, so -// both answers are checked against a descriptor directory this test builds itself. The -// emitters read $HOME, which is what makes a temp tree enough to pin them down. #include "../feats/webpatch.c" #include #include #include -// Satisfies the log macros without pulling in log.c. Rejection reasons land on stderr, -// which is where a check that expects one goes looking. int np_log_level = NP_LVL_WARN; FILE *np_log_file; @@ -48,7 +43,6 @@ static void clear_descs(void) { if (system(cmd) != 0) { shout("cannot clear %s", g_featdir); g_failed++; } } -// Touch a file under the temp home so a "requires" can be made to resolve. static void make_asset(const char *rel) { char path[2048], cmd[2200]; FILE *f; @@ -59,7 +53,6 @@ static void make_asset(const char *rel) { fclose(f); } -// feat_build caches, so each call re-reads the directory the test just rewrote. static void rebuild(void) { g_feat_ready_done = 0; feat_build(); @@ -78,14 +71,14 @@ int main(void) { 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 descriptor directory\n", stderr); return 2; } + if (system(cmd) != 0) { fputs("cannot make the plugins folder\n", stderr); return 2; } setenv("HOME", g_home, 1); - puts("== an empty descriptor directory"); + puts("== an empty plugins folder"); ok(strcmp(feats_json(), "[]") == 0, "renders no rows"); ok(strcmp(ready_json(), "{}") == 0, "reports no readiness"); - puts("== a descriptor with an unmet requires"); + puts("== a plugin with an unmet requires"); write_desc("10-a.conf", "key = NOTPROTON_A\nlabel = Feature A\nsection = dev\n" "on = 1\noff = 0\nrequires = assets/a.dylib\n"); @@ -133,11 +126,72 @@ int main(void) { write_desc("40-dup.conf", "key = NOTPROTON_OK\nlabel = Again\nsection = dev\n"); { const char *j = feats_json(); - ok(strstr(j, "NOTPROTON_OK") != NULL, "a good descriptor still loads"); + ok(strstr(j, "NOTPROTON_OK") != NULL, "a good plugin still loads"); ok(strstr(j, "NOTPROTON_BAD") == NULL, "an unknown section is refused"); - ok(strstr(j, "No key") == NULL, "a descriptor with no key is refused"); + ok(strstr(j, "No key") == NULL, "a plugin with no key is refused"); ok(strstr(j, "Again") == NULL, "a repeated key is refused"); } + clear_descs(); + write_desc("10-evil.conf", "key = NOTPROTON_A:-}$(touch x)${X\nlabel = Evil\n"); + write_desc("20-lower.conf", "key = NOTPROTON_low\nlabel = Lower\n"); + write_desc("30-bare.conf", "key = NOTPROTON_\nlabel = Bare\n"); + write_desc("40-nolabel.conf", "key = NOTPROTON_NOLABEL\n"); + ok(strcmp(feats_json(), "[]") == 0, "a key that is not a NOTPROTON_ name is refused"); + ok(strcmp(ready_json(), "{}") == 0, "and so is a plugin with no label"); + + puts("== a repeated field"); + clear_descs(); + write_desc("10-f.conf", + "key = NOTPROTON_F\nkey = NOTPROTON_G\nlabel = First\nlabel = Second\n" + "on = 2\non = 3\n"); + ok(strstr(feats_json(), "{k:\"NOTPROTON_F\",l:\"First\",on:\"2\"") != NULL, + "keeps its first value"); + + puts("== limits shared with compat_run.sh"); + clear_descs(); + { + char body[4096]; + memset(body, 'x', sizeof(body)); + memcpy(body, "key = NOTPROTON_LONG\nlabel = L\n# ", 33); + memcpy(body + 2100, "\n", 2); + write_desc("10-line.conf", body); + } + write_desc("20-on.conf", "key = NOTPROTON_ON\nlabel = On\non = 0123456789012345678901234567890123\n"); + ok(strcmp(feats_json(), "[]") == 0, "a line or value too long loses the file"); + clear_descs(); + make_asset("assets/fifth.dylib"); + write_desc("10-five.conf", + "key = NOTPROTON_FIVE\nlabel = Five\nrequires = m1\nrequires = m2\n" + "requires = m3\nrequires = m4\nrequires = assets/fifth.dylib\n"); + ok(strcmp(ready_json(), "{\"NOTPROTON_FIVE\":0}") == 0, "only the first four requires count"); + clear_descs(); + write_desc("10-vt.conf", "key\v= NOTPROTON_VT\f\nlabel = Vt\n"); + ok(strstr(feats_json(), "k:\"NOTPROTON_VT\"") != NULL, "any blank counts as space"); + clear_descs(); + for (int i = 0; i < NP_FEAT_MAX; i++) { + char name[32], body[2048], fill[600]; + memset(fill, '"', sizeof(fill)); + snprintf(name, sizeof(name), "%02d.conf", i); + snprintf(body, sizeof(body), + "key = NOTPROTON_%0180d\nlabel = %.190s\ndescription = %.510s\n" + "on = %.31s\noff = %.31s\ndepends = %.190s\n", + i, fill, fill, fill, fill, fill); + write_desc(name, body); + } + { + const char *j = feats_json(); + int rows = 0; + for (const char *p = j; (p = strstr(p, "{k:")); p++) rows++; + ok(rows == NP_FEAT_MAX, "the most plugins at their longest all render"); + } + + puts("== a transform"); + rebuild(); + { + size_t len = 0; + free(np_webpatch_transform((const uint8_t *)"x", 1, &len, NULL)); + } + ok(!g_feat_ready_done, "reads the plugins again"); puts("== an optional description"); clear_descs(); @@ -149,7 +203,7 @@ int main(void) { clear_descs(); write_desc("10-e.conf", "key = NOTPROTON_E\nlabel = Ee\nsection = dev\n"); ok(strstr(feats_json(), "d:\"\"") != NULL, - "is empty when the descriptor omits it"); + "is empty when the plugin omits it"); puts("== a label cannot break the page it renders into"); clear_descs(); @@ -158,7 +212,7 @@ int main(void) { ok(strstr(feats_json(), "l:\"He said \\\"go\\\" \\\\ onwards\"") != NULL, "quotes and backslashes are escaped"); - puts("== a missing descriptor directory"); + puts("== a missing plugins folder"); snprintf(cmd, sizeof(cmd), "rm -rf '%s'", g_featdir); if (system(cmd) != 0) { shout("cannot remove %s", g_featdir); g_failed++; } ok(strcmp(feats_json(), "[]") == 0, "renders no rows, since nothing ships by default"); @@ -168,6 +222,6 @@ int main(void) { 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: descriptors load, rank, validate and report readiness"); + puts("\n==> featcheck: plugins load, rank, validate and report readiness"); return 0; } diff --git a/dylib/tests/featstagecheck.sh b/dylib/tests/featstagecheck.sh index 157368dd..cb3a09df 100755 --- a/dylib/tests/featstagecheck.sh +++ b/dylib/tests/featstagecheck.sh @@ -1,26 +1,54 @@ #!/bin/sh -# A descriptor that stages DLLs into the prefix has to put them there and add to -# WINEDLLOVERRIDES without dropping the bridge's own entries. +# Tests the plugin part of compat_run.sh. set -e SRC="${1:-$(dirname "$0")/../feats/compat_run.sh}" [ -f "$SRC" ] || { echo "featstagecheck: $SRC not present, skipped"; exit 0; } -block=$(sed -n '/^np_feat_dir=/,/^done$/p' "$SRC") +block=$(sed -n '/^np_feat_dir=/,/^NP_FEAT_LIST$/p' "$SRC") case "$block" in *np_feat_stage*np_feat_over*) ;; - *) echo "FAIL: the descriptor loop was not found in $SRC"; exit 1 ;; + *) echo "FAIL: the plugin loop was not found in $SRC"; exit 1 ;; esac work=$(mktemp -d) trap 'rm -rf "$work"' EXIT -mkdir -p "$work/stage" "$work/pfx/drive_c/windows/system32" \ - "$work/.config/notproton/features.d" -printf 'shipped\n' > "$work/pfx/drive_c/windows/system32/d3d11.dll" -printf 'ours\n' > "$work/stage/d3d11.dll" -printf 'ours\n' > "$work/stage/dxgi.dll" +feats="$work/Library/Application Support/notproton/plugins" +sys32="$work/pfx/drive_c/windows/system32" +mkdir -p "$work/stage" "$sys32" "$feats" "$work/lib" +printf 'ours\n' > "$work/stage/d3d11.dll" +printf 'ours\n' > "$work/stage/dxgi.dll" +printf 'dylib\n' > "$work/lib/feat.dylib" printf '%s\n' "$block" > "$work/block.sh" -cat > "$work/feature.conf" < "$feats/$1" +} +reset() { + rm -f "$feats"/*.conf "$work"/pwned* + restore +} +restore() { + printf 'shipped\n' > "$sys32/d3d11.dll" + rm -f "$sys32/dxgi.dll" +} +# Output: line 1 WINEDLLOVERRIDES, line 2 the dylibs, then the env lines. +run() { + # shellcheck disable=SC2016 # expanded by the inner shell + env -i PATH="$PATH" HOME="$work" np_support="$work/Library/Application Support/notproton" \ + log="$work/log" WINEPREFIX="$work/pfx" WINEDLLOVERRIDES="steamclient=n;lsteamclient=b" "$@" \ + sh -c 'set -e; . "$1/block.sh"; printf "%s\n%s\n%s" "$WINEDLLOVERRIDES" "$np_feat_dylibs" "$np_feat_envs"' \ + sh "$work" +} +line() { + printf '%s\n' "$1" | sed -n "$2p" +} + +fail=0 +ok() { if [ "$2" = "$3" ]; then echo " ok $1"; else + echo " FAIL $1 (want [$3], got [$2])"; fail=1; fi; } + +reset +conf 50-test.conf < /dev/null +ok "an unchanged DLL is left alone" "$(stat -f %i "$sys32/d3d11.dll")" "$inode" +printf 'clobbered\n' > "$sys32/d3d11.dll" +run NOTPROTON_TESTFEAT=1 > /dev/null +ok "a DLL a game overwrote is put back" "$(cat "$sys32/d3d11.dll")" "ours" -fail=0 -ok() { if [ "$2" = "$3" ]; then echo " ok $1"; else - echo " FAIL $1 (want [$3], got [$2])"; fail=1; fi; } +restore +out=$(run NOTPROTON_TESTFEAT=0) +ok "a plugin switched off stages nothing" "$(cat "$sys32/d3d11.dll")" "shipped" +ok "and adds no overrides" "$(line "$out" 1)" "steamclient=n;lsteamclient=b" + +sed "s|^stage = .*|stage = $work/missing|" "$feats/50-test.conf" > "$work/x.conf" +mv -f "$work/x.conf" "$feats/50-test.conf" +out=$(run NOTPROTON_TESTFEAT=1) +ok "a missing stage directory still launches" "$(line "$out" 1)" "steamclient=n;lsteamclient=b;d3d11,dxgi=n" + +reset +printf 'key = NOTPROTON_CR\r\nlabel = CR\r\non = 1 \r\noverrides = cr=n\r\n' > "$feats/10-cr.conf" +out=$(run NOTPROTON_CR=1) +ok "trailing whitespace and CR are trimmed" "$(line "$out" 1)" "steamclient=n;lsteamclient=b;cr=n" + +reset +conf 10-first.conf < "$work/lib/feat.dylib" + +reset +conf 10-long.conf < "$feats/30-line.conf" +out=$(run NOTPROTON_LONG=0123456789012345678901234567890123 NOTPROTON_FIVE=1 NOTPROTON_LINE=1) +ok "long lines, long values and a fifth requires are refused" "$(line "$out" 1)" "steamclient=n;lsteamclient=b" + +reset +conf B.conf < "$work/co:lon/feat.dylib" +conf 10-colon.conf < /dev/null +ok "staging skips a directory in the way" "$(find "$sys32/dxgi.dll" -type f | wc -l | tr -d ' ')|$(cat "$sys32/d3d11.dll")" "0|ours" +rmdir "$sys32/dxgi.dll" -printf 'shipped\n' > "$work/pfx/drive_c/windows/system32/d3d11.dll" -overrides=$(run 0) -ok "a feature switched off stages nothing" "$(cat "$work/pfx/drive_c/windows/system32/d3d11.dll")" "shipped" -ok "and adds no overrides" "$overrides" "steamclient=n;lsteamclient=b" +feat_loop=$(sed -n '/^while IFS= read -r np_feat_env; do$/,/^NP_FEAT_ENVS$/p' "$SRC") +feat_at=$(grep -n '^while IFS= read -r np_feat_env; do$' "$SRC" | cut -d: -f1) +# shellcheck disable=SC2016 # Match the literal $(env. +forward_at=$(grep -n '^for name in $(env ' "$SRC" | cut -d: -f1) +ok "plugin env is prepended before the forwarded settings" "$([ "$feat_at" -lt "$forward_at" ] && echo yes)" "yes" +# shellcheck disable=SC2016 # expanded by the inner shell +args=$(np_feat_envs='LATER=2 +GOOD=a b +' sh -c 'f=$1; set -- --args game; eval "$f"; printf "[%s]" "$@"' sh "$feat_loop" 2>&1 || true) +ok "plugin env reaches open in order" "$args" "[--env][GOOD=a b][--env][LATER=2][--args][game]" -# A stage directory that is not there is a user error, not a reason to refuse the launch. -sed "s|^stage = .*|stage = $work/missing|" "$work/feature.conf" > "$work/feature.conf.new" -mv -f "$work/feature.conf.new" "$work/feature.conf" -overrides=$(run 1) -ok "a missing stage directory still launches" "$overrides" "steamclient=n;lsteamclient=b;d3d11,dxgi=n" +# shellcheck disable=SC2016 # Match the literal heredoc. +launcher=$(sed -n '/^cat > "\$loader_macos\/launcher" < "$work/Library/Application Support/notproton/overlay-shim.dylib" +# shellcheck disable=SC2016 # expanded by the inner shell +dyld=$(env -i HOME="$work" STEAM_DYLD_INSERT_LIBRARIES=/o.dylib NOTPROTON_FEATURE_DYLIBS=/f.dylib \ + sh -c 'eval "$1"; printf "%s|%s" "$DYLD_INSERT_LIBRARIES" "${NOTPROTON_OVERLAY_SHIM:+shim}"' sh "$inject") +ok "plugin dylibs load after the overlay" "$dyld" \ + "$work/Library/Application Support/notproton/overlay-shim.dylib:/o.dylib:/f.dylib|shim" +# shellcheck disable=SC2016 # expanded by the inner shell +dyld=$(env -i HOME="$work" NOTPROTON_FEATURE_DYLIBS=/f.dylib \ + sh -c 'eval "$1"; printf "%s|%s" "$DYLD_INSERT_LIBRARIES" "${NOTPROTON_OVERLAY_SHIM:+shim}"' sh "$inject") +ok "plugin dylibs load without the overlay shim" "$dyld" "/f.dylib|" [ "$fail" -eq 0 ] || exit 1 -echo "==> featstagecheck: descriptors stage into the prefix and extend the overrides" +echo "==> featstagecheck: plugins stage into the prefix and extend the overrides" diff --git a/dylib/tests/gatecheck.c b/dylib/tests/gatecheck.c index 27dacdc2..b52c84cb 100644 --- a/dylib/tests/gatecheck.c +++ b/dylib/tests/gatecheck.c @@ -517,6 +517,8 @@ static void check_older_filter(const char *out_path) { } int main(int argc, char **argv) { + // Tests should not read a user's plugins + unsetenv("HOME"); if (argc == 4 && strcmp(argv[1], "--extract") == 0) return extract(argv[2], argv[3]); if (argc == 4 && strcmp(argv[1], "--extract-library") == 0) diff --git a/dylib/tests/panel-behavior/behavior.js b/dylib/tests/panel-behavior/behavior.js index d405596f..7070d21f 100644 --- a/dylib/tests/panel-behavior/behavior.js +++ b/dylib/tests/panel-behavior/behavior.js @@ -99,9 +99,6 @@ for (const form of Object.keys(FORMS)) { t.ok(css[0].props.children.includes('.MSCXPanel .MSCXRowDesc>:last-child{flex-basis:100%}'), 'and the rule that drops a description onto its own line'); - // Developer rows come from descriptors, so these speak for the shapes a descriptor can - // take rather than for whichever features an install happens to carry. With none - // declared the panel is what every check above already asserts: no Developer section. const FEATS = JSON.stringify([ { k: 'NOTPROTON_ALPHA', l: 'Alpha', on: '1', off: '', dep: '', d: '' }, { k: 'NOTPROTON_BETA', l: 'Beta', on: '1', off: '0', dep: '', d: 'Why Beta.' }, @@ -110,10 +107,8 @@ for (const form of Object.keys(FORMS)) { ]).replace(/"(\w+)":/g, '$1:'); t.ok(!sections(nodes('')).includes('Developer'), - 'no descriptors means no Developer section'); - // "len && " yields 0 for an empty array and React renders that as a stray "0". - // walk() drops non-objects, so this scans raw children instead; the self-check below - // keeps it from passing because it looks in the wrong place. + 'no plugins means no Developer section'); + // rows.length && puts a stray "0" on the page when there are no rows. const hasZero = n => { if (n === 0) return true; if (!n || typeof n !== 'object') return false; @@ -124,7 +119,7 @@ for (const form of Object.keys(FORMS)) { t.ok(hasZero({ props: { children: [1 && 'x', 0 && 'y'] } }), 'the stray-zero scanner finds a zero when there is one'); t.ok(!hasZero(P({ details: details('') })), - 'no descriptors renders nothing rather than a literal 0'); + 'no plugins renders nothing rather than a literal 0'); const { render: PF } = panel(emit, form, undefined, { ready: '{"NOTPROTON_ALPHA":0}', feats: FEATS }); @@ -135,7 +130,7 @@ for (const form of Object.keys(FORMS)) { t.ok(fnodes('').filter(x => x.type === 'Section') .some(x => x.props.label === 'Developer'), - 'descriptors add a Developer section'); + 'plugins add a Developer section'); 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:')), @@ -143,7 +138,7 @@ for (const form of Object.keys(FORMS)) { t.ok(!flabels('NOTPROTON_BETA=0 %command%').some(l => l.startsWith('Beta:')), 'a parent at its off value does not count as on'); t.ok(alpha('') && alpha('').props.disabled === true, - 'a feature whose assets are missing renders disabled'); + 'a plugin whose assets are missing renders disabled'); t.ok(alpha('').props.label.endsWith('(not installed)'), 'it says why it cannot be used'); t.ok(alpha('NOTPROTON_ALPHA=1 %command%').props.checked === false, @@ -151,19 +146,40 @@ for (const form of Object.keys(FORMS)) { t.ok(frows('').filter(x => x.props.disabled === true).length === 1, 'only that one is disabled'); t.ok(frows('').find(x => x.props.label === 'Beta').props.disabled === false, - 'a feature with no readiness entry stays enabled'); - // The row's children are checkbox, label and description as siblings, and the panel's - // own rule lays them out in a row. A description row wraps instead, with the last child - // taking the full width, so the checkbox and label stay on one line and the text drops - // beneath them. + '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'); t.ok(frows('').find(x => x.props.label === 'Beta').props.description === 'Why Beta.', - "a descriptor's description reaches the row"); + "a plugin's description reaches the row"); t.ok(frows('').find(x => x.props.label.startsWith('Alpha')).props.description === undefined, - 'a descriptor without one passes no description at all'); + 'a plugin without one passes no description at all'); + + const CHAIN = JSON.stringify([ + { k: 'NOTPROTON_BETA', l: 'Beta', on: '1', off: '0', dep: '', d: '' }, + { k: 'NOTPROTON_BETA_SUB', l: 'Beta: extra', on: '1', off: '0', + dep: 'NOTPROTON_BETA', d: '' }, + { k: 'NOTPROTON_BETA_SUB_SUB', l: 'Beta: more', on: '1', off: '0', + dep: 'NOTPROTON_BETA_SUB', d: '' }, + ]).replace(/"(\w+)":/g, '$1:'); + const chain = ready => { + const { render } = panel(emit, form, undefined, { ready, feats: CHAIN }); + return o => walk(render({ details: details(o) })) + .filter(x => x.type === 'Toggle').map(x => x.props.label); + }; + t.ok(chain('{}')('NOTPROTON_BETA_SUB=1 NOTPROTON_BETA_SUB_SUB=1 %command%') + .every(l => !l.startsWith('Beta:')), + 'a row stays hidden while any plugin above it is off'); + t.ok(chain('{}')('NOTPROTON_BETA=1 NOTPROTON_BETA_SUB=1 %command%').includes('Beta: more'), + 'and appears once the whole chain is on'); + t.ok(!chain('{"NOTPROTON_BETA":0}')('NOTPROTON_BETA=1 %command%').includes('Beta: extra'), + 'a parent that is not installed does not count as on'); + const { render: PB } = panel(emit, form, undefined, + { ready: '{"NOTPROTON_RAW_CONTROLLERS":0}', feats: CHAIN }); + t.ok(walk(PB({ details: details('') })).filter(x => x.type === 'Toggle') + .every(x => x.props.disabled !== true), + 'readiness never disables a built-in row'); const { render: S } = panel(emit, form, undefined, { names: ['o', 't'] }); const shadowed = walk(S({ details: details('CX_GRAPHICS_BACKEND=dxmt %command%') })); diff --git a/dylib/tests/panel-behavior/emit.c b/dylib/tests/panel-behavior/emit.c index 88d27770..ddcf2236 100644 --- a/dylib/tests/panel-behavior/emit.c +++ b/dylib/tests/panel-behavior/emit.c @@ -27,7 +27,6 @@ int main(int argc, char **argv) { np_webpatch_set_fallback_tool(argc >= 3 ? argv[2] : NULL); np_webpatch_set_fex_tools((const char *const *)argv + 3, argc > 3 ? (size_t)(argc - 3) : 0); - // Both default to empty, which is what an install with no descriptors looks like. np_webpatch_set_features(getenv("NP_TEST_READY"), getenv("NP_TEST_FEATS")); put_panel(strcmp(argv[1], "component") == 0 ? NP_CX_OPTIONS_COMPONENT : NP_CX_OPTIONS_STATEMENT); diff --git a/dylib/tests/panel-behavior/harness.js b/dylib/tests/panel-behavior/harness.js index a2cc2514..c36808d8 100644 --- a/dylib/tests/panel-behavior/harness.js +++ b/dylib/tests/panel-behavior/harness.js @@ -28,8 +28,6 @@ const FORMS = { function panel(emit, form, globals = { document: { documentElement: { lang: 'en' } } }, { fallback = 'notproton', fexTools = [], names = ['R0', 'B0'], ready, feats } = {}) { - // 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; diff --git a/dylib/tests/panel-behavior/localization.js b/dylib/tests/panel-behavior/localization.js index a9f5ff44..9e9f0832 100644 --- a/dylib/tests/panel-behavior/localization.js +++ b/dylib/tests/panel-behavior/localization.js @@ -33,6 +33,11 @@ for (const form of Object.keys(FORMS)) { '允许游戏直接读取控制器(可能导致 Steam Input 失效,不推荐)', 'Lascia che i giochi leggano direttamente i controller (può interferire con Steam Input, sconsigliato)')); assert(nodes.some(n => n.props.label === t('Disable hidraw', '停用 hidraw', 'Disable hidraw'))); + const feats = panel(emit, form, globals, { + ready: '{"NOTPROTON_X":0}', feats: '[{k:"NOTPROTON_X",l:"X",on:"1",off:"",dep:"",d:""}]' }); + const fnodes = walk(feats.render({ details: details('') })); + assert(fnodes.some(n => n.props.label === t('Developer', '开发者', 'Developer'))); + assert(fnodes.some(n => n.props.label === 'X' + t(' (not installed)', ' (未安装)', ' (not installed)'))); raw.props.onChange(true); assert.equal(written.at(-1).opts, 'NOTPROTON_RAW_CONTROLLERS=1 CX_GRAPHICS_BACKEND=dxmt %command%'); const hud = nodes.find(n => n.props.label === t('Metal HUD', 'Metal 性能监视器', 'Metal HUD')); From 7ce4476bf609d6acba728239a62eda26d6dcd7ed Mon Sep 17 00:00:00 2001 From: Nathaniel Brown Date: Wed, 21 Oct 2026 08:11:38 -0400 Subject: [PATCH 3/6] 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 4/6] 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 5/6] 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. ` =