diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index bf5565da..6d98ed5f 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -120,6 +120,9 @@ jobs: make -s compatcheck make -s compatsvc-check make -s prefixstate-check + make -s defaults-check + make -s compatpage-check + make -s defaults-drift make -s shortcutcheck make -s launch-shell make -s envcheck diff --git a/Makefile b/Makefile index f181576f..ed3d3050 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,8 @@ SRCS := \ dylib/feats/compat.c \ dylib/feats/webui.c \ dylib/feats/compatsvc.c \ + dylib/feats/defaults.c \ + dylib/feats/compatpage.c \ dylib/feats/prefixstate.c \ dylib/feats/usershortcuts.c \ dylib/feats/webpatch.c \ @@ -71,7 +73,7 @@ DEPS := $(OBJS:.o=.d) tests-list overlay-shim overlay-shim-install overlay-shim-tests \ overlay-shim-bench iconmaker icon \ appinfo helpers-install ntdll-resolve bridge runcheck compatcheck \ - compatsvc-check prefixstate-check shortcutcheck scriptcheck envcheck buildcheck settingscheck routecheck bridgecheck gamedrivecheck launchercheck launch-shell activatecheck FORCE + compatsvc-check prefixstate-check defaults-check compatpage-check defaults-drift shortcutcheck scriptcheck envcheck buildcheck settingscheck routecheck bridgecheck gamedrivecheck launchercheck launch-shell activatecheck FORCE APP_PAYLOAD := app/Sources/NotProtonApp/Resources/payload @@ -180,6 +182,27 @@ compatsvc-check: $(TEST_CC) -o $(COMPATSVC_CHECK) dylib/tests/compatsvc-check.c && \ $(COMPATSVC_CHECK) +DEFAULTS_CHECK := $(OUT_DIR)/defaults-check + +$(DEFAULTS_CHECK): dylib/tests/defaults-check.c dylib/feats/defaults.c dylib/feats/defaults.h \ + dylib/util/buildid.h dylib/util/file.c dylib/util/file.h + @mkdir -p $(dir $@) + $(TEST_CC) -o $@ dylib/tests/defaults-check.c dylib/util/file.c + +defaults-check: $(DEFAULTS_CHECK) + @$(DEFAULTS_CHECK) + +COMPATPAGE_CHECK := $(OUT_DIR)/compatpage-check + +compatpage-check: + @if [ ! -f dylib/tests/compatpage-check.c ]; then $(call SKIP,compatpage-check,dylib/tests/compatpage-check.c); exit 0; fi; \ + mkdir -p $(OUT_DIR) && \ + $(TEST_CC) -o $(COMPATPAGE_CHECK) dylib/tests/compatpage-check.c dylib/feats/defaults.c dylib/util/file.c && \ + $(COMPATPAGE_CHECK) + +defaults-drift: $(DEFAULTS_CHECK) + @sh dylib/tests/defaults-drift.sh $(DEFAULTS_CHECK) + SHORTCUT_BUNDLE_CHECK := $(OUT_DIR)/shortcut-bundle-check SHORTCUTCHECK := $(OUT_DIR)/shortcutcheck @@ -387,6 +410,7 @@ panel-behavior: node $(PANEL_TESTS)/localization.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/fex.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/prefix-row.js $(OUT_DIR)/panel-emit && \ + node $(PANEL_TESTS)/defaults.js $(OUT_DIR)/panel-emit && \ echo "==> panel behavior: renders as expected, no stale arguments" CX_ROOT ?= /Applications/CrossOver Preview.app diff --git a/app/Tests/NotProtonAppTests/ToggleAgreementTests.swift b/app/Tests/NotProtonAppTests/ToggleAgreementTests.swift index 1775e690..519c06a1 100644 --- a/app/Tests/NotProtonAppTests/ToggleAgreementTests.swift +++ b/app/Tests/NotProtonAppTests/ToggleAgreementTests.swift @@ -19,8 +19,8 @@ struct ToggleAgreementTests { static func toggleNames(in webpatch: String) throws -> Set { var names: Set = [] for pattern in [ - #"g\(\\"([A-Z0-9_]+)\\"\)"#, - #"T\(\[\\"([A-Z0-9_]+)\\""#, + #"[gG]\(\\"([A-Z0-9_]+)\\"\)"#, + #"[TD]\(\[?\\"([A-Z0-9_]+)\\""#, #"\[\\"([A-Z0-9_]+)\\","#, ] { for match in webpatch.matches(of: try Regex(pattern)) { diff --git a/dylib/feats/compat.c b/dylib/feats/compat.c index 7ac06724..6e9c8b8d 100644 --- a/dylib/feats/compat.c +++ b/dylib/feats/compat.c @@ -338,7 +338,7 @@ void np_compat_export_tools_path(void) { typedef struct { char name[64]; - char build[64]; + char build[NP_BUILD_ID_MAX + 1]; char flavor[16]; char display[96]; char dir[640]; @@ -383,7 +383,7 @@ static int parse_tool_line(char *line, tool_entry_t *out) { field[n++] = tok; if (n != 4) return 0; - if (!is_tool_name(field[0]) || !is_token(field[1], ".-") + if (!is_tool_name(field[0]) || !np_is_build_id(field[1]) || (strcmp(field[2], "fex") != 0 && strcmp(field[2], "rosetta") != 0) || !is_display(field[3])) return 0; @@ -896,6 +896,32 @@ size_t np_compat_fex_tools(const char **names, size_t max) { return n; } +static np_compat_tool_info_t tool_info(int i) { + return (np_compat_tool_info_t){ g_tools[i].name, g_tools[i].build, g_tools[i].display, + strcmp(g_tools[i].flavor, "fex") == 0 }; +} + +size_t np_compat_tools(np_compat_tool_info_t *out, size_t max) { + load_tool_list_once(); + size_t n = 0; + for (int i = 0; i < g_tool_count && n < max; i++, n++) + out[n] = tool_info(i); + if ((size_t)g_tool_count > max) + NP_WARN("np_compat_tools: more than %zu tools, %s and later are left out", max, + g_tools[max].name); + return n; +} + +bool np_compat_tool_by_name(const char *name, size_t n, np_compat_tool_info_t *out) { + load_tool_list_once(); + for (int i = 0; name && i < g_tool_count; i++) { + if (strlen(g_tools[i].name) != n || memcmp(g_tools[i].name, name, n) != 0) continue; + *out = tool_info(i); + return true; + } + return false; +} + void *np_compat_registered_tool(void *compat_mgr) { if (!compat_mgr) return NULL; diff --git a/dylib/feats/compat.h b/dylib/feats/compat.h index 07d9c6e3..8706e80a 100644 --- a/dylib/feats/compat.h +++ b/dylib/feats/compat.h @@ -6,6 +6,8 @@ #include #include +#include "../util/buildid.h" + // Manager tool array: base pointer at TOOL_ARRAY_OFF, count at TOOL_COUNT_OFF, // fixed per-entry stride from np_compat_tool_stride. #define COMPAT_MANAGER_TOOL_ARRAY_OFF 0x320 @@ -130,6 +132,17 @@ 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); +typedef struct { + const char *name; + const char *build; + const char *display; + bool fex; +} np_compat_tool_info_t; + +size_t np_compat_tools(np_compat_tool_info_t *out, size_t max); + +bool np_compat_tool_by_name(const char *name, size_t n, np_compat_tool_info_t *out); + uint32_t np_compat_manager_tools_max(void); // Absolute path to the directory of the first tool in the app's list, or NULL. diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index cdcf82df..898c7035 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -172,6 +172,50 @@ if [ "$np_flavor" = rosetta ] || [ ! -x "$WINELOADER" ] || [ ! -x "$WINESERVER" fi export WINELOADER WINESERVER +np_from_defaults() { + case " $np_defaulted " in *" $1="*) return 0 ;; esac + return 1 +} +np_defaulted="" +case "${np_flavor-}" in + fex) np_defaults_runner=fex ;; + rosetta) np_defaults_runner=rosetta ;; + *) np_defaults_runner=rosetta; [ "${wine_unix##*/}" != aarch64-unix ] || np_defaults_runner=fex ;; +esac +np_defaults_file="$np_support/defaults-$np_defaults_runner" +if [ -n "${np_build-}" ] && [ -f "$np_support/defaults-override-$np_build-$np_defaults_runner" ]; then + np_defaults_file="$np_support/defaults-override-$np_build-$np_defaults_runner" +fi +if [ "$verb" = waitforexitandrun ] && [ -f "$np_defaults_file" ] && [ -r "$np_defaults_file" ]; then + while IFS= read -r line || [ -n "$line" ]; do + key=${line%%=*} + value=${line#*=} + case "$line" in + CX_GRAPHICS_BACKEND=dxmt | CX_GRAPHICS_BACKEND=dxvk | CX_GRAPHICS_BACKEND=wined3d) ;; + CX_GRAPHICS_BACKEND=d3dmetal | D3DM_ENABLE_METALFX=[01] | ROSETTA_ADVERTISE_AVX=[01]) + [ "${wine_unix##*/}" != aarch64-unix ] || continue ;; + FEX_X87REDUCEDPRECISION=[01]) + [ "${wine_unix##*/}" = aarch64-unix ] || continue ;; + MTL_HUD_ENABLED=[01] | DXMT_ENABLE_NVEXT=[01] | DXMT_METALFX_SPATIAL_SWAPCHAIN=[01] | \ + WINEMSYNC=[01] | NOTPROTON_RETINA=[01] | NOTPROTON_RAW_CONTROLLERS=[01] | NOTPROTON_DISABLE_HIDRAW=[01]) ;; + DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=*) + case "${value#*=}" in 1.5 | 1.72 | 2.0 | 3.0) ;; *) continue ;; esac + if printf '%s' "${DXMT_CONFIG-}" | tr ';' '\n' \ + | grep -q '^[[:space:]]*d3d11\.metalSpatialUpscaleFactor[[:space:]]*='; then + continue + fi + export DXMT_CONFIG="${DXMT_CONFIG:+$DXMT_CONFIG;}$value" + np_defaulted="$np_defaulted${np_defaulted:+ }$line" + continue ;; + *) continue ;; + esac + eval "[ -z \"\${$key+x}\" ]" || continue + export "${line?}" + np_defaulted="$np_defaulted${np_defaulted:+ }$line" + done < "$np_defaults_file" +fi +[ "${CX_GRAPHICS_BACKEND-}" != auto ] || unset -v CX_GRAPHICS_BACKEND + np_dropped="" if [ "${wine_unix##*/}" = aarch64-unix ]; then if [ "${CX_GRAPHICS_BACKEND:-}" = d3dmetal ]; then @@ -184,10 +228,12 @@ if [ "${wine_unix##*/}" = aarch64-unix ]; then done fex_from=environment [ -n "${FEX_X87REDUCEDPRECISION+x}" ] || fex_from=default + if np_from_defaults FEX_X87REDUCEDPRECISION; then fex_from=${np_defaults_file##*/}; fi export FEX_X87REDUCEDPRECISION="${FEX_X87REDUCEDPRECISION-1}" else avx_from=environment [ -n "$ROSETTA_ADVERTISE_AVX" ] || avx_from=default + if np_from_defaults ROSETTA_ADVERTISE_AVX; then avx_from=${np_defaults_file##*/}; fi export ROSETTA_ADVERTISE_AVX="${ROSETTA_ADVERTISE_AVX:-1}" fi @@ -217,6 +263,7 @@ fi echo "STEAM_COMPAT_APP_ID=$STEAM_COMPAT_APP_ID" echo "-- steam env passed through --" env | grep -iE '^(Steam|SDL_)' | sort + [ -z "$np_defaulted" ] || echo "from ${np_defaults_file##*/}: $np_defaulted" [ -z "$np_dropped" ] || echo "ignored on arm64 Wine: $np_dropped" } >> "$log" 2>&1 || true @@ -1370,7 +1417,9 @@ if [ -n "$STEAM_COMPAT_DATA_PATH" ]; then stage_step="prefix lock" prepare_prefix_directory msync_from=environment - if [ -z "$WINEMSYNC" ] && [ -r "$STEAM_COMPAT_DATA_PATH/notproton-msync" ]; then + if np_from_defaults WINEMSYNC; then msync_from=${np_defaults_file##*/}; fi + if [ -z "$WINEMSYNC" ] && [ "$verb" != waitforexitandrun ] \ + && [ -r "$STEAM_COMPAT_DATA_PATH/notproton-msync" ]; then WINEMSYNC=$(tr -d ' \t\n' \ < "$STEAM_COMPAT_DATA_PATH/notproton-msync" 2>/dev/null || true) msync_from=carried-over diff --git a/dylib/feats/compatpage.c b/dylib/feats/compatpage.c new file mode 100644 index 00000000..3bb0a66b --- /dev/null +++ b/dylib/feats/compatpage.c @@ -0,0 +1,41 @@ +#include "compatpage.h" +#include "compat.h" +#include "defaults.h" +#include "webpatch.h" +#include "../util/log.h" + +#include + +static void publish_defaults(void) { + char rosetta[NP_DEFAULTS_TEXT_MAX + 1], fex[NP_DEFAULTS_TEXT_MAX + 1]; + int read = np_defaults_read(NP_DEFAULTS_ROSETTA, rosetta, sizeof rosetta); + read &= np_defaults_read(NP_DEFAULTS_FEX, fex, sizeof fex); + + np_compat_tool_info_t tools[NP_WEBPATCH_BUILDS_MAX]; + size_t count = np_compat_tools(tools, NP_WEBPATCH_BUILDS_MAX); + char (*own)[NP_DEFAULTS_TEXT_MAX + 1] = count ? malloc(count * sizeof *own) : NULL; + np_webpatch_build_t builds[NP_WEBPATCH_BUILDS_MAX]; + size_t n = 0; + if (count && !own) read = 0; + for (size_t i = 0; own && i < count; i++) { + uint32_t runner = tools[i].fex ? NP_DEFAULTS_FEX : NP_DEFAULTS_ROSETTA; + int has = np_defaults_read_build(tools[i].build, runner, own[i], sizeof own[i]); + if (has < 0) { + NP_WARN("webpatch: %s's own settings cannot be read, so the Settings page leaves it out", tools[i].name); + continue; + } + builds[n++] = (np_webpatch_build_t){ tools[i].name, tools[i].build, tools[i].display, + tools[i].fex, has > 0 ? own[i] : NULL }; + } + if (read) np_webpatch_set_defaults(rosetta, fex); + else np_webpatch_set_defaults(NULL, NULL); + np_webpatch_set_builds(builds, n); + free(own); +} + +void np_compatpage_publish(const uint8_t *src, size_t len) { + 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)); + if (np_webpatch_has_shape(src, len)) publish_defaults(); +} diff --git a/dylib/feats/compatpage.h b/dylib/feats/compatpage.h new file mode 100644 index 00000000..416db7ba --- /dev/null +++ b/dylib/feats/compatpage.h @@ -0,0 +1,10 @@ +// Compatibility page inputs +#ifndef NOTPROTON_FEATS_COMPATPAGE_H +#define NOTPROTON_FEATS_COMPATPAGE_H + +#include +#include + +void np_compatpage_publish(const uint8_t *src, size_t len); + +#endif // NOTPROTON_FEATS_COMPATPAGE_H diff --git a/dylib/feats/compatsvc.c b/dylib/feats/compatsvc.c index b7229145..6bdeee32 100644 --- a/dylib/feats/compatsvc.c +++ b/dylib/feats/compatsvc.c @@ -14,6 +14,7 @@ #include "compatsvc.h" #include "compat.h" +#include "defaults.h" #include "prefixstate.h" #include "webui.h" #include "../resolver/anchor.h" @@ -30,6 +31,9 @@ #define ROUTE_REBUILD "NotProton.RebuildPrefix#1" #define ROUTE_BACKUP_REBUILD "NotProton.BackupAndRebuildPrefix#1" #define ROUTE_OPEN_LOG "NotProton.OpenPrefixLog#1" +#define ROUTE_SET_DEFAULTS "NotProton.SetDefaults#1" +#define ROUTE_SET_TOOL_DEFAULTS "NotProton.SetToolDefaults#1" +#define ROUTE_APPLY_TOOL_DEFAULTS "NotProton.ApplyToolDefaults#1" // Mangled RTTI names of the message types. A client that builds the CompatManager // page compiles these without registering the service, so the vtables are there to @@ -481,6 +485,46 @@ static int open_prefix_log(uintptr_t request, uintptr_t response) { return answer_json(response, appid, opened ? "{\"opened\":true}" : "{\"error\":\"log\"}"); } +static int set_defaults(uintptr_t request, uintptr_t response) { + uint32_t has_bits = *(const uint32_t *)(request + REQ_HAS_BITS_OFF); + uint32_t runner = (has_bits & SPECIFY_HAS_APPID) + ? *(const uint32_t *)(request + SPECIFY_APPID_OFF) : 0; + (void)response; + return np_defaults_save(runner, string_field(request + SPECIFY_TOOL_NAME_OFF)) + ? RESULT_OK : RESULT_FAIL; +} + +static int tool_build(const char *tool, size_t n, char *build, size_t size, uint32_t *runner) { + np_compat_tool_info_t info; + if (np_compat_tool_by_name(tool, n, &info) && info.build && strlen(info.build) < size) { + snprintf(build, size, "%s", info.build); + *runner = info.fex ? NP_DEFAULTS_FEX : NP_DEFAULTS_ROSETTA; + return 1; + } + NP_ERR("compatsvc: %.*s is not an installed CrossOver tool", (int)(n > 128 ? 128 : n), tool); + return 0; +} + +static int set_tool_defaults(uintptr_t request, uintptr_t response) { + const char *text = string_field(request + SPECIFY_TOOL_NAME_OFF), *nl = strchr(text, '\n'); + char build[NP_BUILD_ID_MAX + 1]; + uint32_t runner; + (void)response; + if (!tool_build(text, nl ? (size_t)(nl - text) : strlen(text), build, sizeof build, &runner)) + return RESULT_FAIL; + return np_defaults_save_build(build, runner, nl ? nl + 1 : "") ? RESULT_OK : RESULT_FAIL; +} + +static int apply_tool_defaults(uintptr_t request, uintptr_t response) { + const char *tool = string_field(request + SPECIFY_TOOL_NAME_OFF); + char build[NP_BUILD_ID_MAX + 1]; + uint32_t runner; + (void)response; + if (!tool_build(tool, strlen(tool), build, sizeof build, &runner)) return RESULT_FAIL; + int done = np_defaults_apply_build(build, runner); + return done > 0 ? RESULT_OK : done < 0 ? NP_COMPATSVC_PARTLY_APPLIED : RESULT_FAIL; +} + static uintptr_t g_state_changed; static int g_routes = NP_COMPATSVC_UNTRIED; @@ -523,6 +567,12 @@ void np_compatsvc_register(const struct mach_header_64 *mh, intptr_t slide, np_webui_register_route(ROUTE_SPECIFY_COMPAT_TOOL, specify_request, specify_response, specify_compat_tool); + if (!np_webui_register_route(ROUTE_SET_DEFAULTS, specify_request, specify_response, set_defaults) + || !np_webui_register_route(ROUTE_SET_TOOL_DEFAULTS, specify_request, specify_response, + set_tool_defaults) + || !np_webui_register_route(ROUTE_APPLY_TOOL_DEFAULTS, specify_request, specify_response, + apply_tool_defaults)) + NP_WARN("compatsvc: the Settings page cannot save default compatibility settings"); g_routes = NP_COMPATSVC_READY; diff --git a/dylib/feats/compatsvc.h b/dylib/feats/compatsvc.h index 0a23c988..b42628cb 100644 --- a/dylib/feats/compatsvc.h +++ b/dylib/feats/compatsvc.h @@ -23,6 +23,8 @@ int np_compatsvc_routes(void); // Sentinel name for "no tool". #define NP_COMPAT_TOOL_NONE "notproton.none" +#define NP_COMPATSVC_PARTLY_APPLIED 129 + // Tells the page the mapping changed. void np_compatsvc_state_changed(void); diff --git a/dylib/feats/defaults.c b/dylib/feats/defaults.c new file mode 100644 index 00000000..bac639d9 --- /dev/null +++ b/dylib/feats/defaults.c @@ -0,0 +1,366 @@ +#include "defaults.h" +#include "../util/buildid.h" +#include "../util/file.h" +#include "../util/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ROSETTA (1u << NP_DEFAULTS_ROSETTA) +#define FEX (1u << NP_DEFAULTS_FEX) + +static const char *const g_bools[] = { "0", "1", NULL }; +static const char *const g_backends[] = { "dxmt", "dxvk", "wined3d", NULL }; +static const char *const g_d3dmetal[] = { "d3dmetal", NULL }; +static const char *const g_factors[] = { "1.5", "1.72", "2.0", "3.0", NULL }; + +static const struct { + const char *key; + unsigned runners; + const char *prefix; + const char *const *values; + const char *const *rosetta_values; // on top of `values`, for Rosetta only +} g_settings[] = { + { "CX_GRAPHICS_BACKEND", ROSETTA | FEX, "", g_backends, g_d3dmetal }, + { "MTL_HUD_ENABLED", ROSETTA | FEX, "", g_bools, NULL }, + { "D3DM_ENABLE_METALFX", ROSETTA, "", g_bools, NULL }, + { "DXMT_ENABLE_NVEXT", ROSETTA | FEX, "", g_bools, NULL }, + { "ROSETTA_ADVERTISE_AVX", ROSETTA, "", g_bools, NULL }, + { "WINEMSYNC", ROSETTA | FEX, "", g_bools, NULL }, + { "NOTPROTON_RETINA", ROSETTA | FEX, "", g_bools, NULL }, + { "DXMT_METALFX_SPATIAL_SWAPCHAIN", ROSETTA | FEX, "", g_bools, NULL }, + { "DXMT_CONFIG", ROSETTA | FEX, "d3d11.metalSpatialUpscaleFactor=", g_factors, NULL }, + { "NOTPROTON_RAW_CONTROLLERS", ROSETTA | FEX, "", g_bools, NULL }, + { "NOTPROTON_DISABLE_HIDRAW", ROSETTA | FEX, "", g_bools, NULL }, + { "FEX_X87REDUCEDPRECISION", FEX, "", g_bools, NULL }, +}; +#define SETTING_COUNT (sizeof(g_settings) / sizeof(g_settings[0])) + +static const char *const g_runner_names[] = { "rosetta", "fex" }; + +static int is(const char *s, size_t len, const char *want) { + return strlen(want) == len && memcmp(s, want, len) == 0; +} + +static int is_one_of(const char *const *list, const char *v, size_t len) { + for (; list && *list; list++) + if (is(v, len, *list)) return 1; + return 0; +} + +static int valid_value(size_t i, uint32_t runner, const char *v, size_t len) { + size_t pre = strlen(g_settings[i].prefix); + if (len <= pre || memcmp(v, g_settings[i].prefix, pre) != 0) return 0; + return is_one_of(g_settings[i].values, v + pre, len - pre) + || (runner == NP_DEFAULTS_ROSETTA && is_one_of(g_settings[i].rosetta_values, v + pre, len - pre)); +} + +static int setting_index(uint32_t runner, const char *key, size_t key_len) { + if (runner > NP_DEFAULTS_FEX) return -1; + for (size_t i = 0; i < SETTING_COUNT; i++) + if (is(key, key_len, g_settings[i].key) && (g_settings[i].runners & (1u << runner))) + return (int)i; + return -1; +} + +typedef void (*line_fn)(void *ctx, const char *key, size_t key_len, + const char *value, size_t value_len); + +static int each_line(uint32_t runner, const char *text, size_t len, line_fn fn, void *ctx) { + int bad = 0; + unsigned seen = 0; + for (size_t at = 0; at < len; ) { + const char *line = text + at; + const char *nl = memchr(line, '\n', len - at); + size_t n = nl ? (size_t)(nl - line) : len - at; + at += n + (nl ? 1 : 0); + if (n == 0) continue; + + const char *eq = memchr(line, '=', n); + size_t key_len = eq ? (size_t)(eq - line) : n; + int i = eq ? setting_index(runner, line, key_len) : -1; + if (i < 0 || (seen & (1u << i)) || + !valid_value((size_t)i, runner, eq + 1, n - key_len - 1)) { + bad++; + continue; + } + seen |= 1u << i; + if (fn) fn(ctx, line, key_len, eq + 1, n - key_len - 1); + } + return bad; +} + +static void defaults_path(char *buf, size_t size, uint32_t runner) { + char rel[32]; + snprintf(rel, sizeof rel, "defaults-%s", g_runner_names[runner]); + const char *home = np_home_dir(); + np_support_path(buf, size, home ? home : "", rel); +} + +typedef struct { char *buf; size_t size, len; int ok; } np_text_t; + +static void put(np_text_t *t, const char *s, size_t n) { + if (!t->ok || t->len + n >= t->size) { t->ok = 0; return; } + memcpy(t->buf + t->len, s, n); + t->len += n; + t->buf[t->len] = '\0'; +} + +static void put_line(void *ctx, const char *key, size_t key_len, + const char *value, size_t value_len) { + np_text_t *t = ctx; + put(t, key, key_len); + put(t, "=", 1); + put(t, value, value_len); + put(t, "\n", 1); +} + +#define OVERRIDE_PREFIX "defaults-override-" + +static void build_path(char *buf, size_t size, const char *build, uint32_t runner) { + char rel[NP_BUILD_ID_MAX + 32]; + snprintf(rel, sizeof rel, OVERRIDE_PREFIX "%s-%s", build, g_runner_names[runner]); + const char *home = np_home_dir(); + np_support_path(buf, size, home ? home : "", rel); +} + +static pthread_mutex_t g_save_lock = PTHREAD_MUTEX_INITIALIZER; + +static void sync_dir_of(const char *path) { + char dir[PATH_MAX]; + snprintf(dir, sizeof dir, "%s", path); + char *slash = strrchr(dir, '/'); + if (!slash) return; + *slash = '\0'; + int fd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) return; + fsync(fd); + close(fd); +} + +static int write_locked(const char *path, const char *body, size_t len) { + char tmp[PATH_MAX]; + snprintf(tmp, sizeof tmp, "%s.new", path); + if (unlink(tmp) != 0 && (errno == EPERM || errno == EISDIR)) rmdir(tmp); + int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0644); + if (fd < 0) { + NP_ERR("defaults: cannot write %s: %s", tmp, strerror(errno)); + return 0; + } + size_t done = 0; + errno = 0; + while (done < len) { + ssize_t n = write(fd, body + done, len - done); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) break; + done += (size_t)n; + } + int ok = done == len && fsync(fd) == 0; + int err = errno; + if (close(fd) != 0 && ok) { ok = 0; err = errno; } + if (ok && rename(tmp, path) != 0) { ok = 0; err = errno; } + if (!ok) { + NP_ERR("defaults: cannot save %s: %s", path, strerror(err ? err : EIO)); + unlink(tmp); + return 0; + } + sync_dir_of(path); + NP_LOG("defaults: saved %s", path); + return 1; +} + +static int remove_locked(const char *path) { + if (unlink(path) != 0 && errno != ENOENT) { + NP_ERR("defaults: cannot remove %s: %s", path, strerror(errno)); + return 0; + } + sync_dir_of(path); + NP_LOG("defaults: removed %s", path); + return 1; +} + +static int checked_body(uint32_t runner, const char *text, char *body, size_t size, size_t *len) { + const char *name = g_runner_names[runner]; + if (!text) { + NP_ERR("defaults: no text for %s, nothing saved", name); + return 0; + } + size_t n = strlen(text); + if (n > NP_DEFAULTS_TEXT_MAX) { + NP_ERR("defaults: %zu bytes for %s, over the %d allowed", n, name, NP_DEFAULTS_TEXT_MAX); + return 0; + } + int bad = each_line(runner, text, n, NULL, NULL); + if (bad) { + NP_ERR("defaults: %d bad line(s) for %s, nothing saved", bad, name); + return 0; + } + body[0] = '\0'; + np_text_t t = { body, size, 0, 1 }; + each_line(runner, text, n, put_line, &t); + if (!t.ok) { + NP_ERR("defaults: the lines for %s do not fit, nothing saved", name); + return 0; + } + *len = t.len; + return 1; +} + +static int save_file(const char *path, uint32_t runner, const char *text) { + char body[NP_DEFAULTS_TEXT_MAX + 1]; + size_t len = 0; + if (!checked_body(runner, text, body, sizeof body, &len)) return 0; + pthread_mutex_lock(&g_save_lock); + int ok = write_locked(path, body, len); + pthread_mutex_unlock(&g_save_lock); + return ok; +} + +int np_defaults_save(uint32_t runner, const char *text) { + if (runner > NP_DEFAULTS_FEX) { + NP_ERR("defaults: runner %u is neither Rosetta nor FEX", runner); + return 0; + } + char path[PATH_MAX]; + defaults_path(path, sizeof path, runner); + return save_file(path, runner, text); +} + +int np_defaults_save_build(const char *build, uint32_t runner, const char *text) { + if (runner > NP_DEFAULTS_FEX) { + NP_ERR("defaults: runner %u is neither Rosetta nor FEX", runner); + return 0; + } + if (!np_is_build_id(build)) { + NP_ERR("defaults: no CrossOver build ID for runner %u, nothing saved", runner); + return 0; + } + char path[PATH_MAX]; + build_path(path, sizeof path, build, runner); + return save_file(path, runner, text); +} + +static int is_regular(const char *path) { + struct stat st; + return stat(path, &st) == 0 && S_ISREG(st.st_mode); +} + +static int read_lines(const char *path, uint32_t runner, np_text_t *t) { + size_t len = 0; + uint8_t *text = np_read_whole_file(path, &len); + if (!text) { + struct stat st; + if (stat(path, &st) != 0 ? errno == ENOENT : S_ISREG(st.st_mode) && st.st_size == 0) + return 1; + NP_ERR("defaults: cannot read %s", path); + return 0; + } + int ok = len <= NP_DEFAULTS_TEXT_MAX; + if (!ok) { + NP_ERR("defaults: %s is over %d bytes, cannot read it", path, NP_DEFAULTS_TEXT_MAX); + } else { + int bad = each_line(runner, (const char *)text, len, put_line, t); + if (bad) NP_WARN("defaults: %d bad line(s) in %s, left out", bad, path); + } + free(text); + return ok && t->ok; +} + +static DIR *open_folder_of(const char *family) { + char dir[PATH_MAX]; + snprintf(dir, sizeof dir, "%s", family); + char *slash = strrchr(dir, '/'); + if (!slash) return NULL; + *slash = '\0'; + DIR *d = opendir(dir); + if (!d) NP_ERR("defaults: cannot list %s: %s", dir, strerror(errno)); + return d; +} + +static int remove_overrides_locked(DIR *d, uint32_t runner) { + char suffix[16]; + snprintf(suffix, sizeof suffix, "-%s", g_runner_names[runner]); + size_t pre = sizeof(OVERRIDE_PREFIX) - 1, suf = strlen(suffix); + char (*builds)[NP_BUILD_ID_MAX + 1] = NULL; + size_t count = 0, cap = 0; + int ok = 1; + for (struct dirent *e; (e = readdir(d)); ) { + size_t n = strlen(e->d_name); + if (n <= pre + suf || strncmp(e->d_name, OVERRIDE_PREFIX, pre) != 0 + || strcmp(e->d_name + n - suf, suffix) != 0) + continue; + char build[NP_BUILD_ID_MAX + 2]; + size_t bn = n - pre - suf; + if (bn > NP_BUILD_ID_MAX) continue; + memcpy(build, e->d_name + pre, bn); + build[bn] = '\0'; + if (!np_is_build_id(build)) continue; + if (count == cap) { + void *more = realloc(builds, (cap = cap ? 2 * cap : 16) * sizeof *builds); + if (!more) { free(builds); return 0; } + builds = more; + } + memcpy(builds[count++], build, bn + 1); + } + for (size_t i = 0; i < count; i++) { + char path[PATH_MAX]; + build_path(path, sizeof path, builds[i], runner); + if (is_regular(path)) ok &= remove_locked(path); + } + free(builds); + return ok; +} + +int np_defaults_apply_build(const char *build, uint32_t runner) { + if (runner > NP_DEFAULTS_FEX || !np_is_build_id(build)) { + NP_ERR("defaults: no CrossOver build ID for runner %u, nothing applied", runner); + return 0; + } + char own[PATH_MAX], family[PATH_MAX], body[NP_DEFAULTS_TEXT_MAX + 1] = ""; + build_path(own, sizeof own, build, runner); + defaults_path(family, sizeof family, runner); + np_text_t t = { body, sizeof body, 0, 1 }; + pthread_mutex_lock(&g_save_lock); + DIR *d = open_folder_of(family); + int ok = d && read_lines(is_regular(own) ? own : family, runner, &t) && write_locked(family, body, t.len); + int done = ok ? (remove_overrides_locked(d, runner) ? 1 : -1) : 0; + if (d) closedir(d); + pthread_mutex_unlock(&g_save_lock); + if (done > 0) NP_LOG("defaults: %s is now every %s version's set", build, g_runner_names[runner]); + if (done < 0) NP_ERR("defaults: %s is every %s version's set, but some versions kept their own", + build, g_runner_names[runner]); + return done; +} + +int np_defaults_read(uint32_t runner, char *out, size_t size) { + if (!out || !size) return 0; + out[0] = '\0'; + if (runner > NP_DEFAULTS_FEX) return 0; + char path[PATH_MAX]; + defaults_path(path, sizeof path, runner); + np_text_t t = { out, size, 0, 1 }; + if (read_lines(path, runner, &t)) return 1; + out[0] = '\0'; + return 0; +} + +int np_defaults_read_build(const char *build, uint32_t runner, char *out, size_t size) { + if (!out || !size) return -1; + out[0] = '\0'; + if (runner > NP_DEFAULTS_FEX || !np_is_build_id(build)) return -1; + char path[PATH_MAX]; + build_path(path, sizeof path, build, runner); + if (!is_regular(path)) return 0; + np_text_t t = { out, size, 0, 1 }; + if (read_lines(path, runner, &t)) return 1; + out[0] = '\0'; + return -1; +} diff --git a/dylib/feats/defaults.h b/dylib/feats/defaults.h new file mode 100644 index 00000000..08afeedc --- /dev/null +++ b/dylib/feats/defaults.h @@ -0,0 +1,26 @@ +// Default compatibility settings +#ifndef NOTPROTON_FEATS_DEFAULTS_H +#define NOTPROTON_FEATS_DEFAULTS_H + +#include +#include + +#define NP_DEFAULTS_ROSETTA 0 +#define NP_DEFAULTS_FEX 1 + +#define NP_DEFAULTS_TEXT_MAX 1024 + +int np_defaults_save(uint32_t runner, const char *text); + +int np_defaults_save_build(const char *build, uint32_t runner, const char *text); + +// Returns -1 if the defaults saved but some CrossOver versions kept their own settings. +int np_defaults_apply_build(const char *build, uint32_t runner); + +// Returns 0 if the file is there but can't be read. +int np_defaults_read(uint32_t runner, char *out, size_t size); + +// Returns 0 if the version has no settings of its own, -1 if they can't be read. +int np_defaults_read_build(const char *build, uint32_t runner, char *out, size_t size); + +#endif // NOTPROTON_FEATS_DEFAULTS_H diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index 82461e83..9b074738 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -1,11 +1,17 @@ // Enables Steam Play in the UI/enables the Compatibility tab in game properties #include "webpatch.h" +#include "compatsvc.h" +#include "../util/buildid.h" #include "nativeplatform.h" #include "../util/log.h" +#include #include #include +#define NP_STR_(x) #x +#define NP_STR(x) NP_STR_(x) + #define NP_C1 "\001" #define NP_C2 "\002" #define NP_C3 "\003" @@ -25,11 +31,47 @@ #define NP_FALLBACK_TOOL "\021" // Stands for the JS array set by np_webpatch_set_fex_tools. #define NP_FEX_TOOLS "\022" +#define NP_DEFAULTS "\027" +#define NP_BUILDS "\030" #define NP_TOOL_NAME_MAX 128 -static _Thread_local char g_fallback_tool[NP_TOOL_NAME_MAX]; -static _Thread_local char g_fex_tools[NP_WEBPATCH_FEX_TOOLS_MAX * (NP_TOOL_NAME_MAX + 3) + 3] = "[]"; +#define NP_UNREAD_DEFAULTS "{rosetta:null,fex:null}" + +enum { IN_FALLBACK_TOOL, IN_FEX_TOOLS, IN_DEFAULTS, IN_BUILDS, IN_COUNT }; +typedef struct { char *text[IN_COUNT]; } np_inputs_t; +static const char *const g_unset[IN_COUNT] = { "", "[]", NP_UNREAD_DEFAULTS, "[]" }; +static pthread_key_t g_inputs_key; +static pthread_once_t g_inputs_once = PTHREAD_ONCE_INIT; + +static void inputs_free(void *p) { + np_inputs_t *in = p; + for (int i = 0; i < IN_COUNT; i++) free(in->text[i]); + free(in); +} + +static int g_inputs_ok; + +static void inputs_key_make(void) { g_inputs_ok = pthread_key_create(&g_inputs_key, inputs_free) == 0; } + +static void set_input(int which, char *text) { + pthread_once(&g_inputs_once, inputs_key_make); + if (!g_inputs_ok) { free(text); return; } + np_inputs_t *in = pthread_getspecific(g_inputs_key); + if (!in && (in = calloc(1, sizeof *in)) && pthread_setspecific(g_inputs_key, in) != 0) { + free(in); + in = NULL; + } + if (!in) { free(text); return; } + free(in->text[which]); + in->text[which] = text; +} + +static const char *input_text(int which) { + pthread_once(&g_inputs_once, inputs_key_make); + const np_inputs_t *in = g_inputs_ok ? pthread_getspecific(g_inputs_key) : NULL; + return in && in->text[which] ? in->text[which] : g_unset[which]; +} typedef struct { const char *find; @@ -38,6 +80,8 @@ typedef struct { int optional; // skipped in the event that it doesn't match the current Steam client } np_gate_t; +#define NP_GATE_PAGE 2 + #define NP_GATE_MAX 32 typedef struct { const char *at; size_t len; } np_cap_t; @@ -63,30 +107,139 @@ static int is_tool_name(const char *name) { } void np_webpatch_set_fallback_tool(const char *name) { - g_fallback_tool[0] = '\0'; - if (name && is_tool_name(name)) - memcpy(g_fallback_tool, name, strlen(name) + 1); + set_input(IN_FALLBACK_TOOL, name && is_tool_name(name) ? strdup(name) : NULL); +} + +typedef struct { char *buf; size_t size, len; int ok; } np_js_t; + +static np_js_t js_alloc(size_t size) { + char *buf = malloc(size); + if (buf) buf[0] = '\0'; + return (np_js_t){ buf, buf ? size : 0, 0, buf != NULL }; +} + +static char *js_done(np_js_t *j) { + if (j->ok) return j->buf; + free(j->buf); + return NULL; +} + +static void js_put(np_js_t *j, const char *str, size_t n) { + if (!j->ok || j->len + n >= j->size) { j->ok = 0; return; } + memcpy(j->buf + j->len, str, n); + j->len += n; + j->buf[j->len] = '\0'; } +static void js_puts(np_js_t *j, const char *str) { js_put(j, str, strlen(str)); } + void np_webpatch_set_fex_tools(const char *const *names, size_t count) { - size_t n = 0; - g_fex_tools[n++] = '['; - for (size_t i = 0; i < count && i < NP_WEBPATCH_FEX_TOOLS_MAX; i++) { + if (count > NP_WEBPATCH_FEX_TOOLS_MAX) count = NP_WEBPATCH_FEX_TOOLS_MAX; + size_t size = 3; + for (size_t i = 0; names && i < count; i++) size += 3 + (names[i] ? strlen(names[i]) : 0); + np_js_t j = js_alloc(size); + js_put(&j, "[", 1); + for (size_t i = 0; names && i < count; i++) { if (!names[i] || !*names[i] || !is_tool_name(names[i])) continue; - size_t len = strlen(names[i]); - if (n > 1) g_fex_tools[n++] = ','; - g_fex_tools[n++] = '"'; - memcpy(g_fex_tools + n, names[i], len); - n += len; - g_fex_tools[n++] = '"'; + if (j.ok && j.buf[j.len - 1] != '[') js_put(&j, ",", 1); + js_put(&j, "\"", 1); + js_puts(&j, names[i]); + js_put(&j, "\"", 1); } - g_fex_tools[n++] = ']'; - g_fex_tools[n] = '\0'; + js_put(&j, "]", 1); + set_input(IN_FEX_TOOLS, js_done(&j)); +} + +static int is_setting_key(const char *str, size_t n) { + if (!n) return 0; + for (size_t i = 0; i < n; i++) + if (!((str[i] >= 'A' && str[i] <= 'Z') || (str[i] >= '0' && str[i] <= '9') || str[i] == '_')) + return 0; + return 1; +} + +static int is_setting_value(const char *str, size_t n) { + for (size_t i = 0; i < n; i++) + if (!is_ident_char((unsigned char)str[i]) && str[i] != '.' && str[i] != '=') return 0; + return 1; +} + +static void js_settings(np_js_t *j, const char *lines) { + if (!lines) { js_puts(j, "null"); return; } + js_put(j, "{", 1); + int first = 1; + for (const char *at = lines; *at; ) { + const char *nl = strchr(at, '\n'); + size_t n = nl ? (size_t)(nl - at) : strlen(at); + const char *eq = memchr(at, '=', n); + if (eq && is_setting_key(at, (size_t)(eq - at)) && is_setting_value(eq + 1, n - (size_t)(eq - at) - 1)) { + if (!first) js_put(j, ",", 1); + first = 0; + js_put(j, "\"", 1); + js_put(j, at, (size_t)(eq - at)); + js_put(j, "\":\"", 3); + js_put(j, eq + 1, n - (size_t)(eq - at) - 1); + js_put(j, "\"", 1); + } + at += n + (nl ? 1 : 0); + } + js_put(j, "}", 1); +} + +void np_webpatch_set_defaults(const char *rosetta, const char *fex) { + np_js_t j = js_alloc(32 + 3 * (strlen(rosetta ? rosetta : "") + strlen(fex ? fex : ""))); + js_puts(&j, "{rosetta:"); + js_settings(&j, rosetta); + js_puts(&j, ",fex:"); + js_settings(&j, fex); + js_puts(&j, "}"); + if (!j.ok) NP_WARN("webpatch: the saved defaults do not fit the page, it hides them"); + set_input(IN_DEFAULTS, js_done(&j)); +} + +static int is_label(const char *str) { + if (!str || !*str) return 0; + for (; *str; str++) + if ((unsigned char)*str < 0x20 || *str == '"' || *str == '\\') return 0; + return 1; +} + +void np_webpatch_set_builds(const np_webpatch_build_t *builds, size_t count) { + if (count > NP_WEBPATCH_BUILDS_MAX) count = NP_WEBPATCH_BUILDS_MAX; + size_t size = 3; + for (size_t i = 0; builds && i < count; i++) + size += 64 + strlen(builds[i].tool ? builds[i].tool : "") + strlen(builds[i].build ? builds[i].build : "") + + strlen(builds[i].label ? builds[i].label : "") + + (builds[i].override ? 3 * strlen(builds[i].override) + 8 : 4); + np_js_t j = js_alloc(size); + js_put(&j, "[", 1); + for (size_t i = 0; builds && i < count; i++) { + const np_webpatch_build_t *b = &builds[i]; + if (!b->tool || !*b->tool || !is_tool_name(b->tool) || !np_is_build_id(b->build) || !is_label(b->label)) { + NP_WARN("webpatch: tool %zu cannot be shown on the Settings page", i); + continue; + } + if (j.ok && j.buf[j.len - 1] != '[') js_put(&j, ",", 1); + js_puts(&j, "{id:\""); + js_puts(&j, b->tool); + js_puts(&j, "\",build:\""); + js_puts(&j, b->build); + js_puts(&j, b->fex ? "\",runner:\"fex\",label:\"" : "\",runner:\"rosetta\",label:\""); + js_puts(&j, b->label); + js_puts(&j, "\",override:"); + js_settings(&j, b->override); + js_puts(&j, "}"); + } + js_put(&j, "]", 1); + if (!j.ok) NP_WARN("webpatch: the CrossOver builds do not fit the page, it shows none"); + set_input(IN_BUILDS, js_done(&j)); } 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_FALLBACK_TOOL[0]) return input_text(IN_FALLBACK_TOOL); + if (c == NP_FEX_TOOLS[0]) return input_text(IN_FEX_TOOLS); + if (c == NP_DEFAULTS[0]) return input_text(IN_DEFAULTS); + if (c == NP_BUILDS[0]) return input_text(IN_BUILDS); return NULL; } @@ -353,12 +506,16 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "term='',refresh())};const stop=e.npStop=()=>{stopped=1;clearTimeout(timer);observer?.disconnect();globalThis.removeEventListener?.('mscxstat" \ "e',on)};const observer=globalThis.MutationObserver?new globalThis.MutationObserver(()=>{if(!e.isConnected)stop()}):null;observer?.observe(e.ownerDocument,{childList:true,subtree:true});globalThis.addEventListener?.('mscxstate',on);refresh()};" + #define NP_CX_OPTIONS_CSS \ "\".MSCXPanel{margin-top:10px}" \ ".MSCXPanel .MSCXRow{display:flex;flex-direction:row;padding:9px;margin:0;" \ - "color:#dfe3e6;background:rgba(59,63,72,.5);border-radius:3px}" \ + "font-size:14px;line-height:18px;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)}" \ ".MSCXNoBottomGap{margin-bottom:0}" \ + ".DesktopUI .MSCXSetBtn,.MSCXSetBtn.DesktopUI{color:#dfe3e6;background-color:rgba(59,63,72,.5);" \ + "padding:8px 14px;font-size:13px;line-height:18px}" \ + "@media(prefers-contrast: more){.DesktopUI .MSCXSetBtn,.MSCXSetBtn.DesktopUI{background:#000;color:#fff}}" \ ".MSCXBtns{display:flex;gap:8px}" \ ".MSCXBtn{background:#3d4450;color:#fff;border:none;border-radius:2px;padding:6px 14px;" \ "font-size:14px;cursor:pointer}.MSCXBtn:hover{background:#4c5361}" \ @@ -371,28 +528,36 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "animation:MSCXSlide 1.2s linear infinite}" \ "@keyframes MSCXSlide{from{left:-30%}to{left:100%}}\"" +#define NP_CX_BARREL(X) X(XY) X(m) X(Yh) X($n) X(Vb) X(D0) +#define NP_CX_BARREL_C(n) #n, +#define NP_CX_BARREL_JS(n) "\"" #n "\"," +#define NP_CX_BARREL_NAMES { NP_CX_BARREL(NP_CX_BARREL_C) } +#define NP_CX_BARREL_READY(B) "[" NP_CX_BARREL(NP_CX_BARREL_JS) "].every(n=>" B "[n])" + +#define NP_CX_FACTOR_ENTRY "e=>/^[ \\t\\r]*d3d11\\.metalSpatialUpscaleFactor[ \\t\\r]*=/.test(e)" + +#define NP_CX_DEFAULTS "(globalThis.MSCXDefaultsData||(globalThis.MSCXDefaultsData=" NP_DEFAULTS "))" +#define NP_CX_BUILDS "(globalThis.MSCXBuilds||(globalThis.MSCXBuilds=" NP_BUILDS "))" + // FEX runs arm64 Wine, which has no D3DMetal and never runs under Rosetta. -#define NP_CX_OPTIONS_BODY(ARG, RT, BARREL) \ - ARG "=>{" \ - "const t=" ARG ".details,o=t.strLaunchOptions||\"\"," \ - "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])," \ +#define NP_CX_PANEL(RT, BARREL) \ + "({fex,get:G,saved:readSaved,set:s,clearUnused,head,dflt})=>{" \ + "const npZh=" NP_WEB_IS_ZH ",npIt=" NP_WEB_IS_IT ",npL=(en,zh,it)=>npZh?zh:npIt?it:en," \ + "w=(k,v,none)=>dflt&&v===(dflt(k)||none)?\"\":v," \ + "T=(k,l)=>(0," RT ".jsx)(" BARREL ".Yh,{className:\"MSCXRow\",label:l," \ + "checked:\"1\"===G(k),onChange:v=>s([[k,w(k,v?\"1\":\"0\",\"0\")]])},k)," \ "D=(k,l)=>(0," RT ".jsx)(" BARREL ".Yh,{className:\"MSCXRow\",label:l," \ - "checked:\"0\"!==g(k),onChange:v=>s([[k,v?\"\":\"0\"]])},k)," \ - "bv=g(\"CX_GRAPHICS_BACKEND\"),b=fex&&\"d3dmetal\"===bv?\"\":bv," \ + "checked:\"0\"!==G(k),onChange:v=>s([[k,w(k,v?\"1\":\"0\",\"1\")]])},k)," \ + "bv=G(\"CX_GRAPHICS_BACKEND\"),b=\"auto\"===bv||fex&&\"d3dmetal\"===bv?\"\":bv," \ "dm=!fex&&(\"\"===b||\"d3dmetal\"===b)," \ "dx=\"\"===b||\"dxmt\"===b," \ - "sw=\"1\"===g(\"DXMT_METALFX_SPATIAL_SWAPCHAIN\")," \ - "F=\"d3d11.metalSpatialUpscaleFactor=\"," \ - "fe=g(\"DXMT_CONFIG\").split(\";\").filter(e=>e.trim())," \ - "fk=e=>/^[ \\t\\r]*d3d11\\.metalSpatialUpscaleFactor[ \\t\\r]*=/.test(e)," \ + "sw=\"1\"===G(\"DXMT_METALFX_SPATIAL_SWAPCHAIN\")," \ + "F=\"d3d11.metalSpatialUpscaleFactor=\",fk=" NP_CX_FACTOR_ENTRY "," \ + "fe=readSaved(\"DXMT_CONFIG\").split(\";\").filter(e=>e.trim())," \ "fx=v=>fe.filter(e=>!fk(e)).concat(v?[F+v]:[]).join(\";\")," \ - "fv=(fe.filter(fk).pop()||\"=2\").replace(/^[^=]*=/,\"\").replace(/\"/g,\"\").trim().split(/\\s/)[0]," \ - "fn=Math.max(isNaN(+fv)?2:+fv,1)," \ + "pf=c=>{const v=(c.split(\";\").filter(fk).pop()||\"=2\").replace(/^[^=]*=/,\"\").replace(/\"/g,\"\").trim().split(/\\s/)[0];" \ + "return Math.max(isNaN(+v)?2:+v,1)}," \ + "fn=pf(G(\"DXMT_CONFIG\"))," \ "U=[{data:\"\",label:npL(\"Off\",\"关闭\",\"Disattivato\")}," \ "{data:\"1.5\",label:\"1.5x\"}," \ "{data:\"1.72\",label:\"1.72x\"}," \ @@ -402,56 +567,128 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "{data:\"d3dmetal\",label:\"D3DMetal\"}," \ "{data:\"dxmt\",label:\"DXMT\"}," \ "{data:\"dxvk\",label:\"DXVK\"}," \ - "{data:\"wined3d\",label:\"WineD3D\"}].filter(x=>!fex||x.data!==\"d3dmetal\");" \ - "if(t.unAppID<2147483648&&(t.vecPlatforms||[]).indexOf(\"osx\")>=0" \ - "&&!t.strCompatToolName)return null;" \ + "{data:\"wined3d\",label:\"WineD3D\"}].filter(x=>!fex||x.data!==\"d3dmetal\")," \ + "pick=f=>\"1\"===f(\"DXMT_METALFX_SPATIAL_SWAPCHAIN\")?(U.find(u=>u.data&&+u.data===pf(f(\"DXMT_CONFIG\")||\"\"))" \ + "||{data:String(pf(f(\"DXMT_CONFIG\")||\"\"))}).data:\"\";" \ "return(0," RT ".jsx)(\"div\",{className:\"MSCXPanel\",children:(0," RT ".jsxs)(" RT ".Fragment,{children:[" \ - "(0," RT ".jsx)(\"style\",{children:" NP_CX_OPTIONS_CSS "})," \ - "typeof MSCXPrefixRow==\"function\"&&(0," RT ".jsx)(\"div\",{className:\"MSCXRow\",ref:e=>e&&MSCXPrefixRow(e,t.unAppID)},\"npstate\")," \ + "(0," RT ".jsx)(\"style\",{children:" NP_CX_OPTIONS_CSS "}),head," \ "(0," RT ".jsxs)(" BARREL ".XY,{label:npL(\"Graphics\",\"图形\",\"Grafica\"),children:[" \ "(0," RT ".jsx)(" BARREL ".m,{rgOptions:B,selectedOption:b," \ - "onChange:v=>s([[\"CX_GRAPHICS_BACKEND\",v.data]]" \ - ".concat(\"\"===v.data||\"d3dmetal\"===v.data?[]:[[\"D3DM_ENABLE_METALFX\",\"\"]])" \ - ".concat(\"\"===v.data||\"dxmt\"===v.data?[]:" \ + "onChange:v=>s([[\"CX_GRAPHICS_BACKEND\",w(\"CX_GRAPHICS_BACKEND\",v.data||\"auto\",\"auto\")]]" \ + ".concat(!clearUnused||\"\"===v.data||\"d3dmetal\"===v.data?[]:[[\"D3DM_ENABLE_METALFX\",\"\"]])" \ + ".concat(!clearUnused||\"\"===v.data||\"dxmt\"===v.data?[]:" \ "[[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",\"\"],[\"DXMT_CONFIG\",fx(\"\")]])" \ - ".concat(\"dxmt\"===v.data?[]:[[\"DXMT_ENABLE_NVEXT\",\"\"]]))})," \ - "T([\"MTL_HUD_ENABLED\"],npL(\"Metal HUD\",\"Metal 性能监视器\",\"Metal HUD\"),\"1\")," \ - "dm&&T([\"D3DM_ENABLE_METALFX\"],\"DLSS\",\"1\")," \ - "\"dxmt\"===b&&T([\"DXMT_ENABLE_NVEXT\"],\"DLSS\",\"1\")," \ + ".concat(!clearUnused||\"dxmt\"===v.data?[]:[[\"DXMT_ENABLE_NVEXT\",\"\"]]))})," \ + "T(\"MTL_HUD_ENABLED\",npL(\"Metal HUD\",\"Metal 性能监视器\",\"Metal HUD\"))," \ + "dm&&T(\"D3DM_ENABLE_METALFX\",\"DLSS\")," \ + "\"dxmt\"===b&&T(\"DXMT_ENABLE_NVEXT\",\"DLSS\")," \ "!fex&&D(\"ROSETTA_ADVERTISE_AVX\",npL(\"Advertise AVX2 to Rosetta\",\"向 Rosetta 声明 AVX2 支持\",\"Segnala AVX2 a Rosetta\"))," \ - "T([\"WINEMSYNC\"],\"MSync\",\"1\",\"0\")," \ - "T([\"NOTPROTON_RETINA\"],npL(\"High Resolution\",\"高分辨率\",\"Alta risoluzione\"),\"1\",\"0\")" \ + "T(\"WINEMSYNC\",\"MSync\")," \ + "T(\"NOTPROTON_RETINA\",npL(\"High Resolution\",\"高分辨率\",\"Alta risoluzione\"))" \ "]},\"gfx\")," \ "dx&&(0," RT ".jsx)(" BARREL ".XY," \ "{label:npL(\"MetalFX Upscaling (Samples from the resolution the game is set to)\"," \ "\"MetalFX 超分辨率(以游戏设置的分辨率为采样来源)\",\"Upscaling MetalFX (ingrandisce l'immagine dalla risoluzione impostata nel gioco)\")," \ "children:(0," RT ".jsx)(" BARREL ".m,{rgOptions:U," \ "selectedOption:sw?(U.find(u=>u.data&&+u.data===fn)||{data:String(fn)}).data:\"\"," \ - "onChange:v=>s([[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",v.data?\"1\":\"\"],[\"DXMT_CONFIG\",fx(v.data)]])})},\"usf\")," \ + "onChange:v=>s(dflt&&v.data===pick(k=>dflt(k)||\"\")" \ + "?[[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",\"\"],[\"DXMT_CONFIG\",fx(\"\")]]" \ + ":[[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",v.data?\"1\":\"0\"],[\"DXMT_CONFIG\",fx(v.data)]])})},\"usf\")," \ "(0," RT ".jsxs)(" BARREL ".XY,{label:npL(\"Controllers\",\"控制器\",\"Controller\"),children:[" \ - "T([\"NOTPROTON_RAW_CONTROLLERS\"],npL(\"Let games read controllers directly (May break Steam Input. Not recommended)\"," \ + "T(\"NOTPROTON_RAW_CONTROLLERS\",npL(\"Let games read controllers directly (May break Steam Input. Not recommended)\"," \ "\"允许游戏直接读取控制器(可能导致 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\",\"Disattiva hidraw\"),\"1\",\"\")" \ + "\"Lascia che i giochi leggano direttamente i controller (pu\\u00f2 interferire con Steam Input, sconsigliato)\"))," \ + "T(\"NOTPROTON_DISABLE_HIDRAW\",npL(\"Disable hidraw\",\"停用 hidraw\",\"Disattiva hidraw\"))" \ "]},\"ctl\")," \ "fex&&(0," RT ".jsx)(" BARREL ".XY,{label:\"FEX\"," \ "children:D(\"FEX_X87REDUCEDPRECISION\"," \ "\"Reduced x87 Precision (Reducing precision improves game performance, is enabled by default in CrossOver Preview)\")},\"fex\")" \ "]})})}" -#define NP_CX_OPTIONS_CAPTURE(RT, BARREL) \ - "((npReact,npUI)=>" NP_CX_OPTIONS_BODY("np", "npReact()", "npUI()") ")(()=>" RT ",()=>" BARREL ")" +#define NP_CX_OPTIONS_BODY(RT, BARREL) \ + "np=>{" \ + "const t=np.details,o=t.strLaunchOptions||\"\",tn=t.strCompatToolName||\"" NP_FALLBACK_TOOL "\"," \ + "fex=" NP_FEX_TOOLS ".indexOf(tn)>=0," \ + "L=MSCXLaunch(o,t.unAppID),g=L.g,has=k=>L.k.includes(k)," \ + "lost=globalThis.MSCXDefaultsLost,DF=lost?{}:(" NP_CX_BUILDS ".find(x=>x.id===tn)||{}).override||" NP_CX_DEFAULTS "[fex?\"fex\":\"rosetta\"]||{}," \ + "fk=" NP_CX_FACTOR_ENTRY "," \ + "own=g(\"DXMT_CONFIG\").split(\";\").filter(e=>e.trim())," \ + "G=k=>\"DXMT_CONFIG\"===k?(own.some(fk)?g(k):DF[k]||\"\"):has(k)?g(k):DF[k]||\"\";" \ + "if(t.unAppID<2147483648&&(t.vecPlatforms||[]).indexOf(\"osx\")>=0" \ + "&&!t.strCompatToolName)return null;" \ + "return MSCXPanel({fex,get:G,saved:g,set:L.s,clearUnused:1,dflt:lost?null:k=>DF[k],head:" \ + "typeof MSCXPrefixRow==\"function\"&&(0," RT ".jsx)(\"div\",{className:\"MSCXRow\",ref:e=>e&&MSCXPrefixRow(e,t.unAppID)},\"npstate\")" \ + "})}" + +// React breaks if the hook count changes between renders, so the hooks wait in their own component +// until Steam's globals exist. +#define NP_CX_DEFAULTS_PAGE_BODY(RT, BARREL) \ + "(()=>{let Q=Promise.resolve(),gen=0,warned=0,lost=0;" \ + "const ser=d=>Object.keys(d).map(k=>k+\"=\"+d[k]).join(\"\\n\"),I=()=>{" \ + "const use=globalThis.MSCXUseState,eff=globalThis.MSCXUseEffect,send=globalThis.MSCXSpecifyRoute;" \ + "const BL=" NP_CX_BUILDS ",only=BL.length&&BL.every(x=>x.runner===BL[0].runner)?BL[0].runner:\"\"," \ + "d0=BL.find(x=>x.id===globalThis.MSCXDefaultTool)||BL[0]," \ + "[r,sr]=use(d0&&\"fex\"===d0.runner?1:0),[id,si]=use(d0?d0.id:\"\"),[,bump]=use(0),[ok,sok]=use(\"\"),all=" NP_CX_DEFAULTS "," \ + "fex=1===r,n=fex?\"fex\":\"rosetta\",b=BL.find(x=>x.id===id)," \ + "set=(nn,c)=>c?c.override||all[nn]||{}:all[nn]||{},DF=set(n,b),G=k=>DF[k]||\"\"," \ + "q=f=>{Q=Q.then(f).catch(e=>console.error(\"NotProton: the defaults were not saved\",e))}," \ + "clr=()=>{gen++;sok(\"\")}," \ + "done=(x,f)=>{if(x.BSuccess()){f();bump(c=>c+1);return 1}" \ + "console.error(\"NotProton: the defaults were not saved, result \"+x.GetEResult());return 0}," \ + "put=(rv,nn,c,d)=>(c?send(\"NotProton.SetToolDefaults#1\",0,c.id+\"\\n\"+ser(d))" \ + ":send(\"NotProton.SetDefaults#1\",rv,ser(d))).then(x=>done(x,()=>{c?c.override=d:all[nn]=d}))," \ + "s=ps=>{const rv=r,nn=n,c=b;clr();q(()=>{const d=Object.assign({},set(nn,c));" \ + "ps.forEach(p=>{p[1]&&\"auto\"!==p[1]?d[p[0]]=p[1]:delete d[p[0]]});return put(rv,nn,c,d)})}," \ + "kind=only===n?\"CrossOver\":fex?\"FEX\":\"Rosetta\"," \ + "apply=()=>{const nn=n,c=b,kk=kind;clr();const my=gen;" \ + "q(()=>{const d=Object.assign({},c.override||all[nn]||{});" \ + "return send(\"NotProton.ApplyToolDefaults#1\",0,c.id).then(x=>{" \ + "if(" NP_STR(NP_COMPATSVC_PARTLY_APPLIED) "===x.GetEResult()){all[nn]=d;lost=1;globalThis.MSCXDefaultsLost=1;bump(c=>c+1);console.error(\"NotProton: Apply left some versions " \ + "with their own settings, so Default Compatibility Settings are hidden until Steam restarts\");return}" \ + "done(x,()=>{all[nn]=d;BL.forEach(y=>{y.runner===nn&&(y.override=null)})})" \ + "&&my===gen&&sok(\"Applied. Every \"+kk+\" Compatibility Tool now uses these settings.\")})})}," \ + "pickTool=t=>{const p=BL.find(x=>x.id===t);if(p){si(p.id);sr(\"fex\"===p.runner?1:0);clr()}}," \ + "btn=(k,l,t,f,dsc)=>(0," RT ".jsx)(" BARREL ".D0,{label:l,description:dsc," \ + "childrenContainerWidth:\"min\",children:(0," RT ".jsx)(" BARREL ".$n,{className:\"MSCXSetBtn\"," \ + "onClick:f,children:t})},k);" \ + "eff(()=>{const on=e=>pickTool(e.detail);globalThis.addEventListener?.(\"mscxdefaulttool\",on);" \ + "return()=>globalThis.removeEventListener?.(\"mscxdefaulttool\",on)},[]);if(lost)return null;" \ + "return MSCXPanel({fex,get:G,saved:G,set:s,head:(0," RT ".jsxs)(" BARREL ".XY,{label:\"Default Compatibility Settings\",children:[" \ + "(0," RT ".jsx)(" BARREL ".Vb,{label:\"Select default settings for\",description:\"Changes apply to the CrossOver version selected below. " \ + "You can override these settings on a per-game basis in the game's Properties > Compatibility page.\",bMatchWidth:!1,rgOptions:[{data:0,label:\"Rosetta\"}," \ + "{data:1,label:\"FEX\"}],selectedOption:r,onChange:v=>{const p=BL.find(x=>x.runner===(v.data?\"fex\":\"rosetta\"));" \ + "sr(v.data);si(p?p.id:\"\");clr()}},\"for\")," \ + "BL.length?(0," RT ".jsx)(" BARREL ".Vb,{label:\"CrossOver version\"," \ + "bMatchWidth:!1,rgOptions:(b?[]:[{data:\"\",label:\"None installed\"}]).concat(BL.map(x=>({data:x.id," \ + "label:x.label})))," \ + "selectedOption:b?b.id:\"\",onChange:v=>{const p=BL.find(x=>x.id===v.data);if(!p)return;" \ + "si(p.id);sr(p.runner===\"fex\"?1:0);clr()}},\"version\"):null," \ + "b?btn(\"apply\",\"Apply as defaults for all \"+kind+\" Compatibility Tools\",\"Apply\",apply,ok||void 0):null" \ + "]},\"defaults\")})};" \ + "return()=>{const up=typeof globalThis.MSCXUseState==\"function\"&&typeof globalThis.MSCXUseEffect==\"function\"" \ + "&&typeof globalThis.MSCXSpecifyRoute==\"function\";" \ +"const dd=" NP_CX_DEFAULTS ";if(null===dd.rosetta||null===dd.fex){" \ + "warned||(warned=1,console.error(\"NotProton: the saved defaults could not be read, so Default Compatibility Settings are hidden\"));" \ + "return null}" \ + "if(up&&" NP_CX_BARREL_READY(BARREL) ")return(0," RT ".jsx)(I,{});" \ + "up&&!warned&&(warned=1,console.error(\"NotProton: Steam renamed its settings components, so Default Compatibility Settings are hidden\"));" \ + "return null}})()" +#define NP_CX_CAPTURE(BODY, RT, BARREL) \ + "((npReact,npUI)=>" BODY("npReact()", "npUI()") ")(()=>" RT ",()=>" BARREL ")" #define NP_CX_OPTIONS_COMPONENT \ "MSCXLaunch=" NP_CX_LAUNCH ",MSCXPick=" NP_CX_PICK "," \ - "MSCXOpts=" NP_CX_OPTIONS_CAPTURE(NP_C4, NP_C5) "," + "MSCXPanel=" NP_CX_CAPTURE(NP_CX_PANEL, NP_C4, NP_C5) "," \ + "MSCXOpts=" NP_CX_CAPTURE(NP_CX_OPTIONS_BODY, NP_C4, NP_C5) "," // Valve is testing a new compatibility page UI, this logic relates to supporting that #define NP_CX_OPTIONS_STATEMENT \ "var MSCXLaunch=" NP_CX_LAUNCH ";" \ "var MSCXPick=" NP_CX_PICK ";" \ "var MSCXToolQuery=" NP_CX_TOOL_QUERY ";" \ - "var MSCXOpts=" NP_CX_OPTIONS_CAPTURE(NP_C3, NP_C4) ";" + "var MSCXPanel=" NP_CX_CAPTURE(NP_CX_PANEL, NP_C3, NP_C4) ";" \ + "var MSCXOpts=" NP_CX_CAPTURE(NP_CX_OPTIONS_BODY, NP_C3, NP_C4) ";" \ + "var MSCXDefaultsPage=" NP_CX_CAPTURE(NP_CX_DEFAULTS_PAGE_BODY, NP_C3, NP_C4) ";" // 1.0.x saved CrossOver settings without %command%, this migrates them. #define NP_LAUNCH_MIGRATION_RULE \ @@ -546,6 +783,42 @@ static const np_gate_t g_gates_selecttool[] = { "(0," NP_C3 ".jsx)(" NP_C7 ".G,{setting:\"compat_show_all_tools\",label:(0," NP_C5 ".we)" "(\"#Settings_SteamPlay_ShowAll\"),feature:" NP_C8 ".OK})]})," "(0," NP_C3 ".jsx)(MSCXOpts,{details:" NP_C2 ".details})]})}", 1, 0 }, + // SteamPlay settings section + { "function " NP_C1 "(" NP_C2 "){return(0," NP_C3 ".jsxs)(" NP_C4 ".XY,{label:(0," NP_C5 ".we)" + "(\"#Settings_SteamPlay_SteamPlay\"),children:[(0," NP_C3 ".jsx)(" NP_C6 ",{})," + "(0," NP_C3 ".jsx)(" NP_C7 ".G,{setting:\"compat_show_all_tools\",label:(0," NP_C5 ".we)" + "(\"#Settings_SteamPlay_ShowAll\"),feature:" NP_C8 ".OK})]})}", + "function " NP_C1 "(" NP_C2 "){return(0," NP_C3 ".jsxs)(" NP_C3 ".Fragment,{children:[" + "(0," NP_C3 ".jsxs)(" NP_C4 ".XY,{label:(0," NP_C5 ".we)" + "(\"#Settings_SteamPlay_SteamPlay\"),children:[(0," NP_C3 ".jsx)(" NP_C6 ",{})," + "(0," NP_C3 ".jsx)(" NP_C7 ".G,{setting:\"compat_show_all_tools\",label:(0," NP_C5 ".we)" + "(\"#Settings_SteamPlay_ShowAll\"),feature:" NP_C8 ".OK})]})," + "(0," NP_C3 ".jsx)(MSCXDefaultsPage,{})]})}", 1, NP_GATE_PAGE }, + // SpecifyCompatTool: hands its transport to MSCXSpecifyRoute for the defaults routes + { "function " NP_C1 "(" NP_C2 "," NP_C3 "){return " NP_C3 "=" NP_C3 "||(0," NP_C4 + ".OI)().GetDefaultTransport()," NP_C3 "==null?new Promise((" NP_C5 "," NP_C6 ")=>{" + "console.error(\"Transport Error: no transport is available for request\")," + NP_C6 "(\"Transport Error: no transport is available for request\")}):" NP_C3 + ".SendMsg(\"CompatManager.SpecifyCompatTool#1\",(0," NP_C7 ".I8)(" NP_C8 "," NP_C2 ")," + NP_C9 ",{ePrivilege:1,eClientExecutionSite:2})}" NP_C10 ".SendMsgSpecifyCompatTool=" NP_C1 ",", + "function " NP_C1 "(" NP_C2 "," NP_C3 "){return " NP_C3 "=" NP_C3 "||(0," NP_C4 + ".OI)().GetDefaultTransport()," NP_C3 "==null?new Promise((" NP_C5 "," NP_C6 ")=>{" + "console.error(\"Transport Error: no transport is available for request\")," + NP_C6 "(\"Transport Error: no transport is available for request\")}):" NP_C3 + ".SendMsg(\"CompatManager.SpecifyCompatTool#1\",(0," NP_C7 ".I8)(" NP_C8 "," NP_C2 ")," + NP_C9 ",{ePrivilege:1,eClientExecutionSite:2})}" + "globalThis.MSCXSpecifyRoute=(npRt,npA,npX)=>{const npT=(0," NP_C4 ".OI)().GetDefaultTransport();" + "return!/^NotProton\\./.test(npRt)?Promise.reject(\"not a NotProton route\"):" + "npT==null?Promise.reject(\"Transport Error: no transport is available for request\"):" + "npT.SendMsg(npRt,(0," NP_C7 ".I8)(" NP_C8 ",{appid:npA,tool_name:npX})," + NP_C9 ",{ePrivilege:1,eClientExecutionSite:2})}," + NP_C10 ".SendMsgSpecifyCompatTool=" NP_C1 ",", 1, NP_GATE_PAGE }, + { "const " NP_C1 "=new " NP_C2 ";function " NP_C3 "(" NP_C4 "){const " NP_C5 "=" NP_C6 + ".useCallback(async()=>{const " NP_C7 "=await " NP_C8 ".GetCompatTools({appid:" NP_C4 "});", + "const " NP_C1 "=new " NP_C2 ";globalThis.MSCXUseState=" NP_C6 ".useState;" + "globalThis.MSCXUseEffect=" NP_C6 ".useEffect;function " NP_C3 + "(" NP_C4 "){const " NP_C5 "=" NP_C6 + ".useCallback(async()=>{const " NP_C7 "=await " NP_C8 ".GetCompatTools({appid:" NP_C4 "});", 1, NP_GATE_PAGE }, // Compatibility Tool selector, wrapped to drop settings a FEX tool cannot use { "[" NP_C1 "," NP_C2 "]=(0," NP_C3 ".cT)(" NP_C4 ");if(" NP_C1 ".isLoading||!" NP_C1 ".data?.tools?.length)return null;", @@ -564,7 +837,7 @@ static const np_gate_t g_gates_selecttool[] = { NP_C7 ".SendMsg(\"CompatManager.GetCompatTools#1\",(0," NP_C1 ".I8)(" NP_C2 "," NP_C3 ")," NP_C4 "," "{ePrivilege:1,eClientExecutionSite:2})}" "globalThis.MSCXRoute=(r,id)=>{const t=(0," NP_C8 ".OI)().GetDefaultTransport();" - "return t?t.SendMsg(r,(0," NP_C1 ".I8)(" NP_C2 ",{appid:id})," NP_C4 ",{ePrivilege:1,eClientExecutionSite:2})" + "return!/^NotProton\\./.test(r)?Promise.reject(\"not a NotProton route\"):t?t.SendMsg(r,(0," NP_C1 ".I8)(" NP_C2 ",{appid:id})," NP_C4 ",{ePrivilege:1,eClientExecutionSite:2})" ".then(x=>x.BSuccess()?x.Body().toObject().default_tool_identifier||\"\":Promise.reject(\"result \"+x.GetEResult()))" ":Promise.reject(\"no transport\")};" NP_CX_PREFIX_ROW NP_C5 ".GetCompatTools=" NP_C6 ";", 1, 1 }, { "get is_invalid_os_type(){return this.most_available_per_client_data.is_invalid_os_type}", @@ -618,7 +891,10 @@ static const np_gate_t g_fixes[] = { { "return " NP_C1 "?(0," NP_C2 ".jsx)(" NP_C3 ".B,{feature:" NP_C4 ".OK,label:(0," NP_C5 ".we)" "(\"#Settings_SteamPlay_DefaultTool\"),rgOptions:" NP_C6 ",disabled:0==" NP_C6 ".length," "selectedOption:" NP_C1 ",", - "return(" NP_C1 "||" NP_C6 ".some(e=>e.data===\"" NP_FALLBACK_TOOL "\"))?(0," NP_C2 ".jsx)(" + "return(globalThis.MSCXDefaultTool!==(" NP_C1 "||\"" NP_FALLBACK_TOOL "\")&&(globalThis.MSCXDefaultTool=" + NP_C1 "||\"" NP_FALLBACK_TOOL "\",queueMicrotask(()=>globalThis.dispatchEvent?.(new CustomEvent(\"mscxdefaulttool\"," + "{detail:globalThis.MSCXDefaultTool}))))," + NP_C1 "||" NP_C6 ".some(e=>e.data===\"" NP_FALLBACK_TOOL "\"))?(0," NP_C2 ".jsx)(" NP_C3 ".B,{feature:" NP_C4 ".OK,label:(0," NP_C5 ".we)" "(\"#Settings_SteamPlay_DefaultTool\"),rgOptions:" NP_C6 ",disabled:0==" NP_C6 ".length," "selectedOption:" NP_C1 "||\"" NP_FALLBACK_TOOL "\",", @@ -749,6 +1025,64 @@ int np_webpatch_should_patch(const char *path) { && strncmp(rest, "libraries/", sizeof("libraries/") - 1) != 0; } +static const char *find_in(const char *s, size_t len, const char *want) { + size_t n = strlen(want); + for (size_t i = 0; n <= len && i <= len - n; i++) + if (s[i] == want[0] && memcmp(s + i, want, n) == 0) return s + i; + return NULL; +} + +static const char *find_barrel(const char *s, size_t len, size_t *span) { + static const char *const names[] = NP_CX_BARREL_NAMES; + char anchor[16]; + snprintf(anchor, sizeof anchor, "%s:()=>", names[0]); + for (const char *at = s; (at = find_in(at, len - (size_t)(at - s), anchor)); at++) { + const char *open = at, *close = at; + while (open > s && open[-1] != '{' && open[-1] != '}' && at - open < 16384) open--; + while ((size_t)(close - s) < len && *close != '}' && close - at < 16384) close++; + if (open == s || open[-1] != '{' || (size_t)(close - s) >= len || *close != '}') continue; + int all = 1; + for (size_t i = 0; all && i < sizeof names / sizeof names[0]; i++) { + char lead[16], mid[16]; + snprintf(lead, sizeof lead, "{%s:()=>", names[i]); + snprintf(mid, sizeof mid, ",%s:()=>", names[i]); + all = find_in(open - 1, (size_t)(close - open) + 1, lead) + || find_in(open - 1, (size_t)(close - open) + 1, mid); + } + if (all) { + if (span) *span = (size_t)(close - open) + 2; + return open - 1; + } + } + return NULL; +} + +int np_webpatch_has_shape(const uint8_t *src, size_t src_len) { + for (size_t i = 0; i < NP_SHAPE_COUNT; i++) + if (count_matches((const char *)src, src_len, g_shapes[i].probe) > 0) return 1; + return 0; +} + +static const char *settle_page(const np_ui_shape_t *shape, const char *s, size_t len, + int *gate_on, size_t *live) { + int page_in = 0, page_missing = 0; + for (size_t g = 0; g < shape->count; g++) + if (shape->gates[g].optional == NP_GATE_PAGE) { + if (gate_on[g]) page_in = 1; + else page_missing = 1; + } + const char *why = !page_in ? NULL + : page_missing ? "one of its gates did not match" + : !find_barrel(s, len, NULL) ? "Steam's component barrel lacks a name it takes" + : NULL; + for (size_t g = 0; why && g < shape->count; g++) + if (shape->gates[g].optional == NP_GATE_PAGE && gate_on[g]) { + gate_on[g] = 0; + if (live) (*live)--; + } + return why; +} + char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, const char **out_shape) { const char *s = (const char *)src; @@ -835,6 +1169,9 @@ char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, free(native_mac_js); return NULL; } + const char *page_out = settle_page(shape, s, src_len, gate_on, &gate_live); + if (page_out) + NP_ERR("webpatch: [%s] Default Compatibility Settings left out, %s", shape->name, page_out); } np_out_t out = {0}; diff --git a/dylib/feats/webpatch.h b/dylib/feats/webpatch.h index 28c67935..96b83a84 100644 --- a/dylib/feats/webpatch.h +++ b/dylib/feats/webpatch.h @@ -17,12 +17,29 @@ 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); +// NULL for a runner whose file couldn't be read, which hides the page. +void np_webpatch_set_defaults(const char *rosetta, const char *fex); + +#define NP_WEBPATCH_BUILDS_MAX 32 + +typedef struct { + const char *tool; // Steam's name for the tool + const char *build; + const char *label; + int fex; + const char *override; // KEY=VALUE lines, NULL when the build has no own set +} np_webpatch_build_t; + +void np_webpatch_set_builds(const np_webpatch_build_t *builds, size_t count); + // 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. #define NP_SHAPE_FORCETOOL "forcetool" #define NP_SHAPE_SELECTTOOL "selecttool" +int np_webpatch_has_shape(const uint8_t *src, size_t src_len); + // `out_shape` is optional and names the compat UI the chunk carried, so a caller can tell // which page it just served without probing the bytes a second time. It is set whenever a // shape is identified, including the drifted case that returns NULL. diff --git a/dylib/hooks/hook_webpatch.c b/dylib/hooks/hook_webpatch.c index 122bbe71..b1ddf14b 100644 --- a/dylib/hooks/hook_webpatch.c +++ b/dylib/hooks/hook_webpatch.c @@ -3,8 +3,8 @@ // not modified, to keep the Steam bootstrap happy. #include "../feats/webpatch.h" -#include "../feats/compat.h" #include "../feats/compatsvc.h" +#include "../feats/compatpage.h" #include "../util/file.h" #include "../util/log.h" @@ -120,9 +120,7 @@ static int open_patched(const char *path) { } close(in); - 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)); + np_compatpage_publish((const uint8_t *)raw, raw_len); 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/compatcheck.c b/dylib/tests/compatcheck.c index 0e2ef4a6..58e223ae 100644 --- a/dylib/tests/compatcheck.c +++ b/dylib/tests/compatcheck.c @@ -451,6 +451,11 @@ static void tool_list_cases(void) { unlink(path); check(kept == 40 && strcmp(g_tools[39].name, "notproton-39") == 0 && strcmp(g_tools[0].dir, "/tools/notproton-0") == 0, "a long list keeps every tool"); + np_compat_tool_info_t info; + check(np_compat_tool_by_name("notproton-39x", 12, &info) && strcmp(info.name, "notproton-39") == 0 + && strcmp(info.display, "Tool 39") == 0 && !info.fex, "the last of a long list is found by name"); + check(!np_compat_tool_by_name("notproton-3", 10, &info) && !np_compat_tool_by_name("notproton-40", 12, &info) + && !np_compat_tool_by_name(NULL, 0, &info), "a name has to match a listed tool whole"); check(strcmp(np_compat_fallback_tool_name(), "notproton-0") == 0, "the fallback is the first configured tool"); check(np_compat_fex_tools(fex, 3) == 0, "a list with no FEX tool has no FEX names"); diff --git a/dylib/tests/compatpage-check.c b/dylib/tests/compatpage-check.c new file mode 100644 index 00000000..67619675 --- /dev/null +++ b/dylib/tests/compatpage-check.c @@ -0,0 +1,157 @@ +#include "../feats/compatpage.c" + +#include +#include +#include +#include +#include +#include "../util/file.h" + +int np_log_level = 0; +FILE *np_log_file = NULL; + +static np_compat_tool_info_t g_tools[4]; +static size_t g_tool_count; + +size_t np_compat_tools(np_compat_tool_info_t *out, size_t max) { + size_t n = g_tool_count < max ? g_tool_count : max; + memcpy(out, g_tools, n * sizeof *out); + return n; +} + +const char *np_compat_fallback_tool_name(void) { return "notproton-26.1"; } + +size_t np_compat_fex_tools(const char **names, size_t max) { + if (max) names[0] = "notproton-26.1-fex"; + return max ? 1 : 0; +} + +static int g_shape; +int np_webpatch_has_shape(const uint8_t *src, size_t len) { return g_shape; } + +static char g_fallback[64], g_fex[64]; +void np_webpatch_set_fallback_tool(const char *name) { snprintf(g_fallback, sizeof g_fallback, "%s", name); } +void np_webpatch_set_fex_tools(const char *const *names, size_t count) { + snprintf(g_fex, sizeof g_fex, "%s", count ? names[0] : ""); +} + +static char g_pub_rosetta[NP_DEFAULTS_TEXT_MAX + 1], g_pub_fex[NP_DEFAULTS_TEXT_MAX + 1]; +static int g_pub_unread, g_pub_calls; + +void np_webpatch_set_defaults(const char *rosetta, const char *fex) { + g_pub_calls++; + g_pub_unread = !rosetta || !fex; + snprintf(g_pub_rosetta, sizeof g_pub_rosetta, "%s", rosetta ? rosetta : ""); + snprintf(g_pub_fex, sizeof g_pub_fex, "%s", fex ? fex : ""); +} + +static np_webpatch_build_t g_pub[4]; +static char g_pub_override[4][NP_DEFAULTS_TEXT_MAX + 1]; +static size_t g_pub_count; + +void np_webpatch_set_builds(const np_webpatch_build_t *builds, size_t count) { + g_pub_count = count; + for (size_t i = 0; i < count && i < 4; i++) { + g_pub[i] = builds[i]; + if (builds[i].override) { + snprintf(g_pub_override[i], sizeof g_pub_override[i], "%s", builds[i].override); + g_pub[i].override = g_pub_override[i]; + } + } +} + +static int failures; + +static void check(int ok, const char *what) { + printf(" %s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) failures++; +} + +static char g_dir[PATH_MAX]; + +static void path_of(char *buf, const char *name) { snprintf(buf, PATH_MAX, "%s/%s", g_dir, name); } + +static void put_file(const char *name, const char *text) { + char path[PATH_MAX]; + path_of(path, name); + FILE *f = fopen(path, "w"); + fputs(text, f); + fclose(f); +} + +static void chmod_file(const char *name, mode_t mode) { + char path[PATH_MAX]; + path_of(path, name); + chmod(path, mode); +} +static void lock_file(const char *name) { chmod_file(name, 0); } +static void unlock_file(const char *name) { chmod_file(name, 0644); } + +static void publish(void) { np_compatpage_publish((const uint8_t *)"", 0); } + +int main(void) { + const char *tmp = getenv("TMPDIR"); + char tmpl[PATH_MAX]; + snprintf(tmpl, sizeof tmpl, "%s/np-compatpage-XXXXXX", tmp && tmp[0] ? tmp : "/tmp"); + const char *home = mkdtemp(tmpl); + if (!home) { perror("mkdtemp"); return 1; } + setenv("HOME", home, 1); + np_support_path(g_dir, sizeof g_dir, home, NULL); + char lib[PATH_MAX]; + snprintf(lib, sizeof lib, "%s/Library", home); + mkdir(lib, 0755); + strcat(lib, "/Application Support"); + mkdir(lib, 0755); + mkdir(g_dir, 0755); + + put_file("defaults-rosetta", "WINEMSYNC=1\n"); + put_file("defaults-fex", "MTL_HUD_ENABLED=0\n"); + put_file("defaults-override-26.1-fex", "WINEMSYNC=0\n"); + put_file("defaults-override-25.0-rosetta", ""); + g_tools[0] = (np_compat_tool_info_t){ "notproton-26.1-fex", "26.1", "CrossOver 26.1 (FEX)", true }; + g_tools[1] = (np_compat_tool_info_t){ "notproton-26.1", "26.1", "CrossOver 26.1", false }; + g_tools[2] = (np_compat_tool_info_t){ "notproton-25.0", "25.0", "CrossOver 25", false }; + g_tool_count = 3; + + publish(); + check(strcmp(g_fallback, "notproton-26.1") == 0 && strcmp(g_fex, "notproton-26.1-fex") == 0, + "every chunk gets the fallback and the FEX tools"); + check(g_pub_calls == 0, "a chunk without a compat UI reads no defaults"); + + g_shape = 1; + publish(); + check(!g_pub_unread && strcmp(g_pub_rosetta, "WINEMSYNC=1\n") == 0 && + strcmp(g_pub_fex, "MTL_HUD_ENABLED=0\n") == 0, "the page gets each runner's set"); + check(g_pub_count == 3 && g_pub[0].fex && strcmp(g_pub[0].tool, "notproton-26.1-fex") == 0 && + strcmp(g_pub[0].build, "26.1") == 0 && strcmp(g_pub[0].label, "CrossOver 26.1 (FEX)") == 0, + "the page gets every tool with its build and label"); + check(g_pub[0].override && strcmp(g_pub[0].override, "WINEMSYNC=0\n") == 0, + "a build's own set goes with it"); + check(!g_pub[1].override, "a build without its own set gets none"); + check(g_pub[2].override && g_pub[2].override[0] == '\0', "an empty own set stays empty, not none"); + + lock_file("defaults-fex"); + publish(); + check(g_pub_unread, "a runner's set that cannot be read gives the page nothing to save over"); + unlock_file("defaults-fex"); + lock_file("defaults-override-26.1-fex"); + publish(); + check(!g_pub_unread && g_pub_count == 2 && strcmp(g_pub[0].tool, "notproton-26.1") == 0, + "a build's own set that cannot be read leaves only that build out"); + unlock_file("defaults-override-26.1-fex"); + + g_tool_count = 0; + publish(); + check(g_pub_count == 0 && !g_pub_unread, "no tools give the page no builds"); + + char cmd[PATH_MAX + 16]; + snprintf(cmd, sizeof cmd, "rm -rf '%s'", home); + system(cmd); + + if (failures) { + printf("==> compatpage: %d failed\n", failures); + return 1; + } + printf("==> compatpage: every chunk gets its tools, a compat UI also gets the defaults it can save over\n"); + return 0; +} diff --git a/dylib/tests/compatsvc-check.c b/dylib/tests/compatsvc-check.c index 7841bcd7..691ff9ea 100644 --- a/dylib/tests/compatsvc-check.c +++ b/dylib/tests/compatsvc-check.c @@ -6,6 +6,7 @@ #include #include #include +#include "../feats/compat.h" // Stubs for the compat layer, set by each test before calling a handler. The check owns // both sides, so what the handler includes and labels is under test, not the compat answers. @@ -108,12 +109,64 @@ int np_webui_parse_into(uintptr_t message, const void *bytes, uint32_t len) { uintptr_t np_webui_registry(void) { return 1; } +static const char *routes_seen[16]; +static uintptr_t route_requests[16]; +static int route_count; + int np_webui_register_route(const char *name, uintptr_t req, uintptr_t resp, int (*handler)(uintptr_t, uintptr_t)) { - (void)name; (void)req; (void)resp; (void)handler; + (void)resp; (void)handler; + if (route_count < 16) { + routes_seen[route_count] = name; + route_requests[route_count++] = req; + } return 1; } +static uint32_t saved_runner = 99; +static const char *saved_text; +static int save_ok = 1; + +static char saved_build[128]; +static int applied; + +static np_compat_tool_info_t stub_infos[] = { + { "notproton-26.1", "26.1", "CrossOver 26.1", false }, + { "notproton-26.1-fex", "26.1", "CrossOver 26.1 (FEX)", true }, +}; + +bool np_compat_tool_by_name(const char *name, size_t n, np_compat_tool_info_t *out) { + for (size_t i = 0; i < sizeof stub_infos / sizeof stub_infos[0]; i++) + if (strlen(stub_infos[i].name) == n && memcmp(stub_infos[i].name, name, n) == 0) { + *out = stub_infos[i]; + return true; + } + return false; +} + +int np_defaults_save(uint32_t runner, const char *text) { + saved_runner = runner; + saved_text = text; + saved_build[0] = '\0'; + return save_ok; +} + +int np_defaults_save_build(const char *build, uint32_t runner, const char *text) { + saved_runner = runner; + saved_text = text; + snprintf(saved_build, sizeof saved_build, "%s", build); + return save_ok; +} + +static int apply_result = 1; + +int np_defaults_apply_build(const char *build, uint32_t runner) { + saved_runner = runner; + snprintf(saved_build, sizeof saved_build, "%s", build); + applied = 1; + return save_ok ? apply_result : 0; +} + int np_webui_notify(const char *name, uintptr_t message) { (void)name; (void)message; return 1; @@ -653,6 +706,75 @@ static void specify_cases(void) { check(last_map_appid == 0, "a request with no appid has-bit reads as appid zero"); } +static void set_defaults_cases(void) { + reset(); + int rc = set_defaults(build_specify_request(SPECIFY_HAS_APPID, 1, "WINEMSYNC=1\nMTL_HUD_ENABLED=0"), + (uintptr_t)response_buf); + check(rc == RESULT_OK, "saved defaults succeed"); + check(saved_runner == 1, "the appid names the runner"); + check(saved_text && strcmp(saved_text, "WINEMSYNC=1\nMTL_HUD_ENABLED=0") == 0, + "the tool name carries the lines"); + + rc = set_defaults(build_specify_request(0, 1, ""), (uintptr_t)response_buf); + check(saved_runner == 0, "a request with no appid has-bit is Rosetta"); + check(saved_text && saved_text[0] == '\0', "an empty request clears the defaults"); + + save_ok = 0; + rc = set_defaults(build_specify_request(SPECIFY_HAS_APPID, 0, "x"), (uintptr_t)response_buf); + check(rc == RESULT_FAIL, "a refused save fails the request"); + save_ok = 1; +} + +static void tool_defaults_cases(void) { + reset(); + saved_runner = 99; + int rc = set_tool_defaults(build_specify_request(0, 0, "notproton-26.1-fex\nWINEMSYNC=1"), + (uintptr_t)response_buf); + check(rc == RESULT_OK && saved_runner == 1 && strcmp(saved_build, "26.1") == 0, + "a tool's lines save to its build and runner"); + check(saved_text && strcmp(saved_text, "WINEMSYNC=1") == 0, "the lines after the tool are saved"); + + rc = set_tool_defaults(build_specify_request(0, 0, "notproton-26.1"), (uintptr_t)response_buf); + check(rc == RESULT_OK && saved_runner == 0 && saved_text && saved_text[0] == '\0', + "a tool with no lines saves an empty set"); + + saved_build[0] = '\0'; + rc = set_tool_defaults(build_specify_request(0, 0, "notproton-27.0\nWINEMSYNC=1"), + (uintptr_t)response_buf); + check(rc == RESULT_FAIL && !saved_build[0], "a tool that is not installed saves nothing"); + rc = set_tool_defaults(build_specify_request(0, 0, "notproton-26\nWINEMSYNC=1"), + (uintptr_t)response_buf); + check(rc == RESULT_FAIL && !saved_build[0], "a tool name has to match whole"); + + char long_tool[200]; + memset(long_tool, 'a', 150); + long_tool[150] = '\0'; + rc = set_tool_defaults(build_specify_request(0, 0, long_tool), (uintptr_t)response_buf); + check(rc == RESULT_FAIL && !saved_build[0], "an overlong tool is refused before saving"); + + save_ok = 0; + rc = set_tool_defaults(build_specify_request(0, 0, "notproton-26.1\nWINEMSYNC=1"), + (uintptr_t)response_buf); + check(rc == RESULT_FAIL, "a refused build save fails the request"); + save_ok = 1; + + applied = 0; + rc = apply_tool_defaults(build_specify_request(0, 0, "notproton-26.1"), (uintptr_t)response_buf); + check(rc == RESULT_OK && applied && saved_runner == 0 && strcmp(saved_build, "26.1") == 0, + "applying hands the tool's build and runner over"); + applied = 0; + rc = apply_tool_defaults(build_specify_request(0, 0, "notproton-27.0"), (uintptr_t)response_buf); + check(rc == RESULT_FAIL && !applied, "applying a tool that is not installed does nothing"); + save_ok = 0; + rc = apply_tool_defaults(build_specify_request(0, 0, "notproton-26.1"), (uintptr_t)response_buf); + check(rc == RESULT_FAIL, "a refused apply fails the request"); + save_ok = 1; + apply_result = -1; + rc = apply_tool_defaults(build_specify_request(0, 0, "notproton-26.1"), (uintptr_t)response_buf); + check(rc == NP_COMPATSVC_PARTLY_APPLIED, "a partial apply says so, apart from one that changed nothing"); + apply_result = 1; +} + static void no_manager_cases(void) { // When np_compat_manager returns NULL the handlers must cope. reset(); @@ -675,9 +797,24 @@ static void routes_state_cases(void) { check(np_compatsvc_routes() == NP_COMPATSVC_UNTRIED, "no route lookup yet reads as untried rather than absent"); + route_count = 0; np_compatsvc_register(NULL, 0, 0, 0); check(np_compatsvc_routes() == NP_COMPATSVC_READY, "a client carrying every message type registers and reads as ready"); + static const char *const defaults_routes[] = { ROUTE_SET_DEFAULTS, ROUTE_SET_TOOL_DEFAULTS, + ROUTE_APPLY_TOOL_DEFAULTS }; + int specify = -1, after = 1, same = 1; + for (int i = 0; i < route_count; i++) + if (strcmp(routes_seen[i], ROUTE_SPECIFY_COMPAT_TOOL) == 0) specify = i; + for (size_t r = 0; r < sizeof defaults_routes / sizeof defaults_routes[0]; r++) { + int at = -1; + for (int i = 0; i < route_count; i++) + if (strcmp(routes_seen[i], defaults_routes[r]) == 0) at = i; + after &= specify >= 0 && at > specify; + same &= at >= 0 && specify >= 0 && route_requests[at] == route_requests[specify]; + } + check(after, "every defaults route registers after SpecifyCompatTool"); + check(same, "every defaults route takes the SpecifyCompatTool request"); // The stable client: the page specifies the tool directly and the build carries no // CompatManager types at all. @@ -895,6 +1032,8 @@ int main(void) { fallback_cases(); active_flag_cases(); specify_cases(); + set_defaults_cases(); + tool_defaults_cases(); no_manager_cases(); routes_state_cases(); string_field_cases(); diff --git a/dylib/tests/defaults-check.c b/dylib/tests/defaults-check.c new file mode 100644 index 00000000..88a4146f --- /dev/null +++ b/dylib/tests/defaults-check.c @@ -0,0 +1,355 @@ +#include "../feats/defaults.c" + +#include +#include +#include + +int np_log_level = 0; +FILE *np_log_file = NULL; + +static int failures; + +static void check(int ok, const char *what) { + printf(" %s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) failures++; +} + +static int valid(uint32_t runner, const char *line) { + const char *eq = strchr(line, '='); + int i = setting_index(runner, line, (size_t)(eq - line)); + return i >= 0 && valid_value((size_t)i, runner, eq + 1, strlen(eq + 1)); +} + +static char g_dir[PATH_MAX]; + +static char *file_text(const char *name) { + static char text[NP_DEFAULTS_TEXT_MAX + 2]; + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", g_dir, name); + FILE *f = fopen(path, "r"); + if (!f) return NULL; + size_t n = fread(text, 1, sizeof text - 1, f); + text[n] = '\0'; + fclose(f); + return text; +} + +static void put_file(const char *name, const char *text) { + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", g_dir, name); + FILE *f = fopen(path, "w"); + fputs(text, f); + fclose(f); +} + +static void drop_file(const char *name) { + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", g_dir, name); + remove(path); +} + +static void chmod_file(const char *name, mode_t mode) { + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", g_dir, name); + chmod(path, mode); +} +static void lock_file(const char *name) { chmod_file(name, 0); } +static void unlock_file(const char *name) { chmod_file(name, 0644); } + +static void put_big(const char *name) { + static char big[NP_DEFAULTS_TEXT_MAX + 64]; + big[0] = '\0'; + while (strlen(big) + 12 < sizeof big) strcat(big, "WINEMSYNC=1\n"); + put_file(name, big); +} + +static int exists(const char *name) { + char path[PATH_MAX]; + struct stat st; + snprintf(path, sizeof path, "%s/%s", g_dir, name); + return stat(path, &st) == 0; +} + +static void valid_cases(void) { + check(valid(NP_DEFAULTS_ROSETTA, "MTL_HUD_ENABLED=1") && valid(NP_DEFAULTS_FEX, "MTL_HUD_ENABLED=0"), + "both runners take a shared row"); + check(valid(NP_DEFAULTS_ROSETTA, "CX_GRAPHICS_BACKEND=d3dmetal") && + !valid(NP_DEFAULTS_FEX, "CX_GRAPHICS_BACKEND=d3dmetal"), + "only Rosetta takes D3DMetal"); + check(valid(NP_DEFAULTS_FEX, "CX_GRAPHICS_BACKEND=dxvk") && + !valid(NP_DEFAULTS_ROSETTA, "CX_GRAPHICS_BACKEND=auto") && + !valid(NP_DEFAULTS_ROSETTA, "CX_GRAPHICS_BACKEND=vulkan"), + "Automatic and unknown backends are not saved"); + check(!valid(NP_DEFAULTS_FEX, "ROSETTA_ADVERTISE_AVX=1") && + !valid(NP_DEFAULTS_FEX, "D3DM_ENABLE_METALFX=1") && + !valid(NP_DEFAULTS_ROSETTA, "FEX_X87REDUCEDPRECISION=1") && + valid(NP_DEFAULTS_FEX, "FEX_X87REDUCEDPRECISION=0"), + "runner-only rows stay with their runner"); + check(!valid(NP_DEFAULTS_ROSETTA, "WINEMSYNC=") && !valid(NP_DEFAULTS_ROSETTA, "WINEMSYNC=2") && + !valid(NP_DEFAULTS_ROSETTA, "WINEMSYNC=10"), + "a row takes only 0 or 1"); + check(valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.72"), + "DXMT_CONFIG takes a factor"); + check(!valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=") && + !valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=.") && + !valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1;x=1") && + !valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1234567") && + !valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.preferredMaxFrameRate=60"), + "DXMT_CONFIG takes nothing else"); + check(!valid(NP_DEFAULTS_ROSETTA, "WINEDEBUG=+all") && !valid(2, "WINEMSYNC=1"), + "other keys and runners are refused"); + check(!valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=0") && + !valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.2.3") && + !valid(NP_DEFAULTS_ROSETTA, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=2") && + valid(NP_DEFAULTS_FEX, "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=3.0"), + "only the factors the page offers are taken"); +} + +static void build_cases(void) { + check(np_defaults_save_build("26.1.0", NP_DEFAULTS_FEX, "WINEMSYNC=1\nFEX_X87REDUCEDPRECISION=0"), + "a build saves its own set"); + const char *t = file_text("defaults-override-26.1.0-fex"); + check(t && strcmp(t, "WINEMSYNC=1\nFEX_X87REDUCEDPRECISION=0\n") == 0, + "the build's file holds its lines"); + check(!exists("defaults-override-26.1.0-rosetta"), "the other runner's file is left alone"); + + check(!np_defaults_save_build("26.1.0", NP_DEFAULTS_FEX, "ROSETTA_ADVERTISE_AVX=1"), + "a build takes only its runner's settings"); + t = file_text("defaults-override-26.1.0-fex"); + check(t && strcmp(t, "WINEMSYNC=1\nFEX_X87REDUCEDPRECISION=0\n") == 0, + "a refused build save keeps the old file"); + + check(np_defaults_save_build("25.0", NP_DEFAULTS_ROSETTA, ""), "a build saves an empty set"); + check(exists("defaults-override-25.0-rosetta"), "an empty set still leaves its file"); + + const char *bad[] = { "", "../defaults-rosetta", "a/b", "a b", "26_1", "x\ny", + "0123456789012345678901234567890123456789012345678901234567890123" }; + int refused = 1; + for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) + refused &= !np_defaults_save_build(bad[i], NP_DEFAULTS_ROSETTA, "WINEMSYNC=1"); + check(refused && !np_defaults_save_build(NULL, NP_DEFAULTS_ROSETTA, ""), + "anything but a build ID is refused"); + check(!np_defaults_save_build("26.1.0", 7, "WINEMSYNC=1"), "a build save needs a runner"); + + drop_file("defaults-override-26.1.0-fex"); +} + +static void planted_cases(void) { + char victim[PATH_MAX], link_path[PATH_MAX]; + snprintf(victim, sizeof victim, "%s/victim", g_dir); + snprintf(link_path, sizeof link_path, "%s/defaults-rosetta.new", g_dir); + put_file("victim", "keep"); + symlink(victim, link_path); + check(np_defaults_save(NP_DEFAULTS_ROSETTA, "WINEMSYNC=1"), "a leftover .new does not stop a save"); + const char *t = file_text("victim"); + check(t && strcmp(t, "keep") == 0, "a planted .new link is not followed"); + drop_file("victim"); +} + +static void save_cases(void) { + check(np_defaults_save(NP_DEFAULTS_ROSETTA, "WINEMSYNC=1\n\nCX_GRAPHICS_BACKEND=dxmt"), + "a valid set saves"); + const char *t = file_text("defaults-rosetta"); + check(t && strcmp(t, "WINEMSYNC=1\nCX_GRAPHICS_BACKEND=dxmt\n") == 0, + "the file holds one line per setting"); + check(!exists("defaults-rosetta.new"), "nothing is left behind"); + + check(!np_defaults_save(NP_DEFAULTS_ROSETTA, "WINEMSYNC=1\nFEX_X87REDUCEDPRECISION=0"), + "a line the runner does not take refuses the save"); + check(!np_defaults_save(NP_DEFAULTS_ROSETTA, "WINEMSYNC=1\nWINEMSYNC=0"), + "a key set twice refuses the save"); + t = file_text("defaults-rosetta"); + check(t && strcmp(t, "WINEMSYNC=1\nCX_GRAPHICS_BACKEND=dxmt\n") == 0, + "a refused save keeps the old file"); + + char big[NP_DEFAULTS_TEXT_MAX + 16]; + memset(big, '\n', sizeof big - 1); + big[sizeof big - 1] = '\0'; + check(!np_defaults_save(NP_DEFAULTS_FEX, big), "an oversized request is refused"); + check(!np_defaults_save(7, "WINEMSYNC=1"), "an unknown runner is refused"); + check(!np_defaults_save(NP_DEFAULTS_ROSETTA, NULL), "no text is refused"); + + check(np_defaults_save(NP_DEFAULTS_ROSETTA, ""), "an empty set saves"); + t = file_text("defaults-rosetta"); + check(t && t[0] == '\0', "an empty set leaves an empty file"); +} + +static void read_cases(void) { + char text[NP_DEFAULTS_TEXT_MAX + 1]; + drop_file("defaults-rosetta"); + np_defaults_read(NP_DEFAULTS_ROSETTA, text, sizeof text); + check(text[0] == '\0', "no file reads as no defaults"); + + put_file("defaults-fex", "WINEMSYNC=1\nROSETTA_ADVERTISE_AVX=0\nFOO=\"});alert(1);({\nWINEMSYNC=0"); + np_defaults_read(NP_DEFAULTS_FEX, text, sizeof text); + check(strcmp(text, "WINEMSYNC=1\n") == 0, "only the lines that would save are read"); + + char small[8]; + check(!np_defaults_read(NP_DEFAULTS_FEX, small, sizeof small) && small[0] == '\0', + "lines that do not fit are a failed read"); + check(!np_defaults_read(7, text, sizeof text) && text[0] == '\0', "an unknown runner is a failed read"); + check(np_defaults_read(NP_DEFAULTS_ROSETTA, text, sizeof text), "a missing file is a read"); + put_file("defaults-rosetta", ""); + check(np_defaults_read(NP_DEFAULTS_ROSETTA, text, sizeof text) && text[0] == '\0', + "an empty file is a read with nothing in it"); + + lock_file("defaults-fex"); + check(!np_defaults_read(NP_DEFAULTS_FEX, text, sizeof text) && text[0] == '\0', + "a file that cannot be opened is a failed read, not an empty one"); + unlock_file("defaults-fex"); + put_big("defaults-fex"); + check(!np_defaults_read(NP_DEFAULTS_FEX, text, sizeof text), "an oversized file is a failed read"); + drop_file("defaults-rosetta"); + + check(np_defaults_read_build("27.0", NP_DEFAULTS_ROSETTA, text, sizeof text) == 0 && text[0] == '\0', + "a build without a file has no own set"); + put_file("defaults-override-27.0-rosetta", ""); + check(np_defaults_read_build("27.0", NP_DEFAULTS_ROSETTA, text, sizeof text) == 1 && text[0] == '\0', + "an empty file is an own set with nothing in it"); + put_file("defaults-override-27.0-rosetta", "MTL_HUD_ENABLED=1\nFEX_X87REDUCEDPRECISION=1\n"); + check(np_defaults_read_build("27.0", NP_DEFAULTS_ROSETTA, text, sizeof text) == 1 && + strcmp(text, "MTL_HUD_ENABLED=1\n") == 0, "a build's own set reads its valid lines"); + lock_file("defaults-override-27.0-rosetta"); + check(np_defaults_read_build("27.0", NP_DEFAULTS_ROSETTA, text, sizeof text) == -1 && text[0] == '\0', + "a build's own set that cannot be opened is a failed read"); + unlock_file("defaults-override-27.0-rosetta"); + drop_file("defaults-override-27.0-rosetta"); + + char dir[PATH_MAX]; + snprintf(dir, sizeof dir, "%s/defaults-override-30.0-rosetta", g_dir); + mkdir(dir, 0755); + check(np_defaults_read_build("30.0", NP_DEFAULTS_ROSETTA, text, sizeof text) == 0, + "a folder is not a build's own set, as in compat_run.sh"); + rmdir(dir); + check(np_defaults_read_build("../defaults-fex", NP_DEFAULTS_FEX, text, sizeof text) == -1, + "reading takes only a build ID"); + drop_file("defaults-fex"); +} + +static void apply_cases(void) { + put_file("defaults-rosetta", "WINEMSYNC=0\n"); + put_file("defaults-override-26.1-rosetta", "MTL_HUD_ENABLED=1\nNOTPROTON_RETINA=1\n"); + put_file("defaults-override-25.0-rosetta", ""); + put_file("defaults-override-old-rosetta", "WINEMSYNC=1\n"); + put_file("defaults-override-26.1-fex", "WINEMSYNC=1\n"); + check(np_defaults_apply_build("26.1", NP_DEFAULTS_ROSETTA), "a build's set applies"); + const char *t = file_text("defaults-rosetta"); + check(t && strcmp(t, "MTL_HUD_ENABLED=1\nNOTPROTON_RETINA=1\n") == 0, + "the runner's file takes the build's set"); + check(!exists("defaults-override-26.1-rosetta") && !exists("defaults-override-25.0-rosetta") && + !exists("defaults-override-old-rosetta"), + "every build's own set for the runner is gone, installed or not"); + check(exists("defaults-override-26.1-fex"), "the other runner's builds keep theirs"); + + put_file("defaults-override-24.0-rosetta", "WINEMSYNC=1\n"); + check(np_defaults_apply_build("26.1", NP_DEFAULTS_ROSETTA), "a build without its own set applies"); + t = file_text("defaults-rosetta"); + check(t && strcmp(t, "MTL_HUD_ENABLED=1\nNOTPROTON_RETINA=1\n") == 0, + "applying a build without its own set keeps the runner's"); + check(!exists("defaults-override-24.0-rosetta"), "and still drops the others"); + + put_file("defaults-override-24.0-fex", "WINEMSYNC=0\n"); + drop_file("defaults-fex"); + char dir[PATH_MAX]; + snprintf(dir, sizeof dir, "%s/defaults-fex", g_dir); + mkdir(dir, 0755); + check(!np_defaults_apply_build("26.1", NP_DEFAULTS_FEX), "a failed save refuses the apply"); + check(exists("defaults-override-26.1-fex") && exists("defaults-override-24.0-fex"), + "a failed save removes no build's own set"); + rmdir(dir); + drop_file("defaults-override-26.1-fex"); + drop_file("defaults-override-24.0-fex"); + + put_file("defaults-rosetta", "WINEMSYNC=1\n"); + put_file("defaults-override-26.1-rosetta", "MTL_HUD_ENABLED=1\n"); + put_file("defaults-override-25.0-rosetta", "WINEMSYNC=0\n"); + lock_file("defaults-override-26.1-rosetta"); + check(!np_defaults_apply_build("26.1", NP_DEFAULTS_ROSETTA), "a set that cannot be read is not applied"); + unlock_file("defaults-override-26.1-rosetta"); + t = file_text("defaults-rosetta"); + check(t && strcmp(t, "WINEMSYNC=1\n") == 0 && exists("defaults-override-26.1-rosetta") && + exists("defaults-override-25.0-rosetta"), "a failed read keeps every file as it was"); + drop_file("defaults-override-26.1-rosetta"); + lock_file("defaults-rosetta"); + check(!np_defaults_apply_build("26.1", NP_DEFAULTS_ROSETTA) && exists("defaults-override-25.0-rosetta"), + "a runner's set that cannot be read is not applied either"); + unlock_file("defaults-rosetta"); + put_big("defaults-rosetta"); + check(!np_defaults_apply_build("26.1", NP_DEFAULTS_ROSETTA) && exists("defaults-override-25.0-rosetta"), + "an oversized set is not applied"); + drop_file("defaults-override-25.0-rosetta"); + drop_file("defaults-rosetta"); + + put_file("defaults-override-26.1-rosetta", "NOTPROTON_RETINA=1\n"); + put_file("defaults-override-24.0-rosetta", "WINEMSYNC=1\n"); + put_file("defaults-override-23.0-rosetta", "WINEMSYNC=0\n"); + char stuck[PATH_MAX], folder[PATH_MAX]; + snprintf(stuck, sizeof stuck, "%s/defaults-override-24.0-rosetta", g_dir); + snprintf(folder, sizeof folder, "%s/defaults-override-31.0-rosetta", g_dir); + mkdir(folder, 0755); + chflags(stuck, UF_IMMUTABLE); + check(np_defaults_apply_build("26.1", NP_DEFAULTS_ROSETTA) == -1, + "an own set that cannot be removed makes the apply partial"); + chflags(stuck, 0); + t = file_text("defaults-rosetta"); + check(t && strcmp(t, "NOTPROTON_RETINA=1\n") == 0 && !exists("defaults-override-23.0-rosetta") && + exists("defaults-override-24.0-rosetta"), "a partial apply saved the set and dropped what it could"); + drop_file("defaults-override-24.0-rosetta"); + check(np_defaults_apply_build("26.1", NP_DEFAULTS_ROSETTA) == 1 && exists("defaults-override-31.0-rosetta"), + "a folder is not an own set, so it does not stop the apply"); + rmdir(folder); + drop_file("defaults-rosetta"); + + check(!np_defaults_apply_build("../x", NP_DEFAULTS_ROSETTA) && !np_defaults_apply_build("26.1", 7), + "applying takes a build ID and a runner"); +} + +static void print_settings(void) { + for (size_t i = 0; i < SETTING_COUNT; i++) { + printf("key %s%s%s\n", g_settings[i].key, g_settings[i].runners & ROSETTA ? " rosetta" : "", + g_settings[i].runners & FEX ? " fex" : ""); + printf("values %s", g_settings[i].key); + for (const char *const *v = g_settings[i].values; *v; v++) printf(" %s", *v); + printf("\nrosetta-values %s", g_settings[i].key); + for (const char *const *v = g_settings[i].rosetta_values; v && *v; v++) printf(" %s", *v); + printf("\n"); + } +} + +int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "--settings") == 0) { + print_settings(); + return 0; + } + const char *tmp = getenv("TMPDIR"); + char tmpl[PATH_MAX]; + snprintf(tmpl, sizeof tmpl, "%s/np-defaults-XXXXXX", tmp && tmp[0] ? tmp : "/tmp"); + const char *home = mkdtemp(tmpl); + if (!home) { perror("mkdtemp"); return 1; } + setenv("HOME", home, 1); + np_support_path(g_dir, sizeof g_dir, home, NULL); + char lib[PATH_MAX]; + snprintf(lib, sizeof lib, "%s/Library", home); + mkdir(lib, 0755); + strcat(lib, "/Application Support"); + mkdir(lib, 0755); + mkdir(g_dir, 0755); + + valid_cases(); + save_cases(); + read_cases(); + build_cases(); + planted_cases(); + apply_cases(); + + char cmd[PATH_MAX + 16]; + snprintf(cmd, sizeof cmd, "rm -rf '%s'", home); + system(cmd); + + if (failures) { + printf("==> defaults: %d failed\n", failures); + return 1; + } + printf("==> defaults: the runners take their own settings, saves are whole, Apply keeps every set until it saved\n"); + return 0; +} diff --git a/dylib/tests/defaults-drift.sh b/dylib/tests/defaults-drift.sh new file mode 100644 index 00000000..83d0c3e4 --- /dev/null +++ b/dylib/tests/defaults-drift.sh @@ -0,0 +1,82 @@ +#!/bin/sh +set -eu + +root=$(cd -- "$(dirname "$0")/.." && pwd) +compat_run="$root/feats/compat_run.sh" +webpatch="$root/feats/webpatch.c" +settings=$("$1" --settings) + +fail=0 +no() { printf 'FAIL: %s\n' "$1"; fail=1; } +words() { tr ' ' '\n' | sed '/^$/d' | sort -u | tr '\n' ' '; } +same() { [ -n "$2" ] || no "$1: nothing found in defaults.c" + [ "$(printf '%s' "$2" | words)" = "$(printf '%s' "$3" | words)" ] \ + || no "$1: [$(printf '%s' "$2" | words)] vs [$(printf '%s' "$3" | words)]"; } +field() { printf '%s\n' "$settings" | awk -v kind="$1" -v key="$2" \ + '$1 == kind && $2 == key { for (i = 3; i <= NF; i++) print $i }'; } + +all=$(printf '%s\n' "$settings" | awk '$1 == "key" { print $2 }') +rosetta_only=$(printf '%s\n' "$settings" | awk '$1 == "key" && $3 == "rosetta" && NF == 3 { print $2 }') +fex_only=$(printf '%s\n' "$settings" | awk '$1 == "key" && $3 == "fex" && NF == 3 { print $2 }') +[ -n "$all" ] || no "defaults-check printed no settings" + +# shellcheck disable=SC2016 # Match the literal case line. +arms=$(sed -n '/^np_from_defaults() {$/,/^np_dropped=""$/p' "$compat_run" \ + | sed -n '/case "\$line" in/,/^[[:space:]]*esac$/p') +[ -n "$arms" ] || no "no case arms found in the defaults block of compat_run.sh" +sh_rosetta=$(printf '%s\n' "$arms" | grep -B1 '" != aarch64-unix ] || continue' | grep ')$' || true) +sh_fex=$(printf '%s\n' "$arms" | grep -B1 '" = aarch64-unix ] || continue' | grep ')$' || true) + +panel_src=$(sed -n '/^#define NP_CX_PANEL(RT, BARREL)/,/^$/p' "$webpatch") +panel=$(printf '%s\n' "$panel_src" | grep -oE '[(\[]\\"[A-Z][A-Z0-9_]*\\"' \ + | sed 's/^.\\"//; s/\\"$//' | sort -u) +[ -n "$panel" ] || no "no setting keys found in NP_CX_PANEL" +for key in $panel; do + printf '%s\n' "$all" | grep -Fxq "$key" || no "$key is on the panel but not in g_settings" +done +for key in $all; do + printf '%s\n' "$panel" | grep -Fxq "$key" || no "$key is in g_settings but not on the panel" + printf '%s' "$arms" | grep -Fq "$key=" || no "$key has no case arm in the defaults block" + if [ "$(field values "$key" | words)" = "0 1 " ]; then + printf '%s' "$arms" | grep -Fq "$key=[01]" || no "$key does not take 0 and 1 in compat_run.sh" + fi +done +for key in $rosetta_only; do + printf '%s' "$sh_rosetta" | grep -Fq "$key=" || no "$key is not Rosetta-only in compat_run.sh" +done +for key in $fex_only; do + printf '%s' "$sh_fex" | grep -Fq "$key=" || no "$key is not FEX-only in compat_run.sh" +done + +shared=$(printf '%s\n' "$settings" | awk '$1 == "key" && NF == 4 { print $2 }') +for key in $shared; do + [ -z "$(field rosetta-values "$key")" ] || continue + if printf '%s%s' "$sh_rosetta" "$sh_fex" | grep -Fq "$key="; then + no "$key is for both runners in defaults.c but compat_run.sh limits it to one" + fi +done + +sh_backends=$(printf '%s\n' "$arms" | grep -oE 'CX_GRAPHICS_BACKEND=[a-z0-9]+' | sed 's/.*=//') +sh_rosetta_backends=$(printf '%s\n' "$sh_rosetta" | grep -oE 'CX_GRAPHICS_BACKEND=[a-z0-9]+' | sed 's/.*=//') +# shellcheck disable=SC2016 # Match the literal case line. +sh_factors=$(printf '%s\n' "$arms" | grep -F 'case "${value#*=}" in' \ + | sed 's/.* in \(.*\)) ;;.*/\1/' | tr -d '|') +panel_list() { printf '%s\n' "$panel_src" | tr -d '\n' | grep -oE "$1=\[[^]]*\]" \ + | grep -oE 'data:\\"[a-z0-9.]+\\"' | sed 's/data:\\"//; s/\\"//'; } +panel_rosetta_backends=$(printf '%s\n' "$panel_src" | grep -oE 'x\.data!==\\"[a-z0-9]+\\"' \ + | sed 's/.*!==\\"//; s/\\"//') + +c_backends="$(field values CX_GRAPHICS_BACKEND) $(field rosetta-values CX_GRAPHICS_BACKEND)" +c_rosetta_backends=$(field rosetta-values CX_GRAPHICS_BACKEND) +c_factors=$(field values DXMT_CONFIG) +same "backends in defaults.c and compat_run.sh" "$c_backends" "$sh_backends" +same "backends in defaults.c and the panel" "$c_backends" "$(panel_list B)" +same "Rosetta-only backends in defaults.c and compat_run.sh" "$c_rosetta_backends" "$sh_rosetta_backends" +same "Rosetta-only backends in defaults.c and the panel" "$c_rosetta_backends" "$panel_rosetta_backends" +same "upscale factors in defaults.c and compat_run.sh" "$c_factors" "$sh_factors" +same "upscale factors in defaults.c and the panel" "$c_factors" "$(panel_list U)" + +if [ "$fail" -ne 0 ]; then + exit 1 +fi +echo "==> defaults-drift: setting lists and their values agree" diff --git a/dylib/tests/envcheck.sh b/dylib/tests/envcheck.sh index 088cba37..043f0a2e 100755 --- a/dylib/tests/envcheck.sh +++ b/dylib/tests/envcheck.sh @@ -96,6 +96,10 @@ is "settings after a non-UTF-8 value are forwarded" "--env DXVK_HUD=1 " "$after_ echo "== Rosetta-only settings on arm64 Wine ==" drop=$(sed -n '/^np_dropped=""$/,/^fi$/p' "$SRC") [ -n "$drop" ] || { echo "FAIL: the arm64 settings block was not found"; exit 1; } +defaults=$(sed -n '/^np_from_defaults() {$/,/^np_dropped=""$/p' "$SRC") +[ -n "$defaults" ] || { echo "FAIL: the defaults block was not found"; exit 1; } +verb=run +eval "$defaults" 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) @@ -169,6 +173,157 @@ done is "FEX settings reach the game" "FEX_MULTIBLOCK=0 FEX_X87REDUCEDPRECISION=0 " "$(printf %s "$fex_args" | sort | tr '\n' ' ')" unset -v FEX_X87REDUCEDPRECISION FEX_MULTIBLOCK +echo "== Default Compatibility Settings ==" +mkdir -p "$np_support" +cat > "$np_support/defaults-rosetta" <<'EOF' +MTL_HUD_ENABLED=1 +WINEMSYNC=1 +ROSETTA_ADVERTISE_AVX=0 +CX_GRAPHICS_BACKEND=dxmt +DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.5 +FEX_X87REDUCEDPRECISION=0 +CX_GRAPHICS_BACKEND=vulkan +NOTPROTON_RETINA=yes +DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1;touch x +WINEDEBUG=+all +FOO=$(touch "$work/ran") + +EOF +printf 'D3DM_ENABLE_METALFX=1\nCX_GRAPHICS_BACKEND=d3dmetal\nWINEMSYNC=1\nFEX_X87REDUCEDPRECISION=0' \ + > "$np_support/defaults-fex" +keys="MTL_HUD_ENABLED WINEMSYNC ROSETTA_ADVERTISE_AVX CX_GRAPHICS_BACKEND DXMT_CONFIG \ +FEX_X87REDUCEDPRECISION NOTPROTON_RETINA D3DM_ENABLE_METALFX WINEDEBUG FOO" +clear() { for k in $keys; do unset -v "$k"; done; } +clear +wine_unix="/R R/lib/wine/x86_64-unix" +verb=waitforexitandrun +eval "$defaults" +is "unset settings take the default" "1 1 0 dxmt" \ + "$MTL_HUD_ENABLED $WINEMSYNC $ROSETTA_ADVERTISE_AVX $CX_GRAPHICS_BACKEND" +is "the default factor is the DXMT_CONFIG" "d3d11.metalSpatialUpscaleFactor=1.5" "${DXMT_CONFIG-}" +is "the game gets them" "1" "$(sh -c 'printf %s "$MTL_HUD_ENABLED"')" +is "lines that are not settings for this runner are skipped" "unset unset unset unset" \ + "${FEX_X87REDUCEDPRECISION-unset} ${NOTPROTON_RETINA-unset} ${WINEDEBUG-unset} ${FOO-unset}" +is "nothing in the file runs" "absent" "$([ -e "$work/ran" ] && echo ran || echo absent)" +is "the run log names what came from the defaults" \ + "MTL_HUD_ENABLED=1 WINEMSYNC=1 ROSETTA_ADVERTISE_AVX=0 CX_GRAPHICS_BACKEND=dxmt DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.5" \ + "$np_defaulted" +is "the run log names the file" "defaults-rosetta" "${np_defaults_file##*/}" +eval "$drop" +is "a defaulted AVX2 is logged as one" "defaults-rosetta" "$avx_from" +is "a defaulted AVX2 keeps its 0" "0" "$ROSETTA_ADVERTISE_AVX" +if np_from_defaults WINEMSYNC; then msync=yes; else msync=no; fi +is "MSync knows it came from the defaults" "yes" "$msync" + +clear +export MTL_HUD_ENABLED=0 CX_GRAPHICS_BACKEND=auto DXMT_CONFIG="d3d11.preferredMaxFrameRate=60" ROSETTA_ADVERTISE_AVX=1 +eval "$defaults" +is "the game's settings win" "0 1" "$MTL_HUD_ENABLED $ROSETTA_ADVERTISE_AVX" +is "the game's Automatic wins and leaves the backend unset" "unset" "${CX_GRAPHICS_BACKEND-unset}" +is "the default factor joins the game's DXMT_CONFIG" \ + "d3d11.preferredMaxFrameRate=60;d3d11.metalSpatialUpscaleFactor=1.5" "$DXMT_CONFIG" +eval "$drop" +is "a game AVX2 is logged as the game's" "environment" "$avx_from" +if np_from_defaults MTL_HUD_ENABLED; then hud=yes; else hud=no; fi +is "a game setting is not from the defaults" "no" "$hud" +clear +export DXMT_CONFIG="a=1; d3d11.metalSpatialUpscaleFactor = 3.0" +eval "$defaults" +is "the game's factor wins" "a=1; d3d11.metalSpatialUpscaleFactor = 3.0" "$DXMT_CONFIG" +clear +cp "$np_support/defaults-rosetta" "$work/rosetta.keep" +printf 'DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=.\nDXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1234567\nDXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=\n' \ + > "$np_support/defaults-rosetta" +eval "$defaults" +is "factors the Settings page would refuse are skipped" "unset" "${DXMT_CONFIG-unset}" +mv "$work/rosetta.keep" "$np_support/defaults-rosetta" + +clear +verb=run +eval "$defaults" +is "helpers get no defaults" "unset" "${MTL_HUD_ENABLED-unset}" + +clear +verb=waitforexitandrun +wine_unix="/R R/lib/wine/aarch64-unix" +eval "$defaults" +is "arm64 Wine reads the FEX file" "defaults-fex" "${np_defaults_file##*/}" +is "a last line without a newline is read" "1 0" "$WINEMSYNC $FEX_X87REDUCEDPRECISION" +is "FEX skips the Rosetta-only settings" "unset unset" "${D3DM_ENABLE_METALFX-unset} ${CX_GRAPHICS_BACKEND-unset}" +eval "$drop" +is "a defaulted x87 is logged as one" "defaults-fex" "$fex_from" +clear +rm -f "$np_support/defaults-fex" +eval "$defaults" +is "no file sets nothing" "unset" "${WINEMSYNC-unset}" +printf 'WINEMSYNC=1\n' > "$np_support/defaults-fex" +chmod 000 "$np_support/defaults-fex" +for sh in /bin/sh /bin/dash; do + [ -x "$sh" ] || continue + # shellcheck disable=SC2016 + is "an unreadable file is skipped by $sh, the launch goes on" "went on unset" \ + "$(np_support="$np_support" wine_unix="$wine_unix" verb="$verb" np_block="$defaults" \ + "$sh" -c 'set -e; eval "$np_block"; echo "went on ${WINEMSYNC-unset}"' 2>/dev/null)" +done +chmod 644 "$np_support/defaults-fex" +rm -f "$np_support/defaults-fex" + +echo "== Per-build defaults ==" +printf 'WINEMSYNC=1\nFEX_X87REDUCEDPRECISION=0\n' > "$np_support/defaults-fex" +printf 'MTL_HUD_ENABLED=1\nFEX_X87REDUCEDPRECISION=1\n' > "$np_support/defaults-override-26.1-fex" +printf 'NOTPROTON_RETINA=1\n' > "$np_support/defaults-override-26.1-rosetta" +clear; unset -v MTL_HUD_ENABLED NOTPROTON_RETINA +np_build=26.1 np_flavor=fex +eval "$defaults" +is "a build's own file replaces the runner's" "defaults-override-26.1-fex" "${np_defaults_file##*/}" +is "the build's settings apply, the runner's do not" "1 1 unset" \ + "${MTL_HUD_ENABLED-unset} $FEX_X87REDUCEDPRECISION ${WINEMSYNC-unset}" +clear; unset -v MTL_HUD_ENABLED +np_build=27.0 +eval "$defaults" +is "a build without its own file, like a new one, uses the runner's" "defaults-fex 1" \ + "${np_defaults_file##*/} ${WINEMSYNC-unset}" +clear +: > "$np_support/defaults-override-27.0-fex" +eval "$defaults" +is "an empty build file means no defaults for that build" "unset" "${WINEMSYNC-unset}" +rm -f "$np_support/defaults-override-27.0-fex" +clear; unset -v NOTPROTON_RETINA +np_build=26.1 np_flavor=rosetta wine_unix="/R R/lib/wine/x86_64-unix" +eval "$defaults" +is "the same build on Rosetta reads its Rosetta file" "defaults-override-26.1-rosetta 1" \ + "${np_defaults_file##*/} ${NOTPROTON_RETINA-unset}" +clear; unset -v NOTPROTON_RETINA +np_build="" np_flavor="" +eval "$defaults" +is "no build reads the runner's file" "defaults-rosetta" "${np_defaults_file##*/}" +clear +np_build=27.0 np_flavor=fex +eval "$defaults" +is "a FEX tool on x86 Wine still reads the FEX file the page shows" "defaults-fex" "${np_defaults_file##*/}" +is "and still skips what x86 Wine cannot take" "unset" "${FEX_X87REDUCEDPRECISION-unset}" +rm -f "$np_support/defaults-fex" "$np_support/defaults-override-26.1-fex" "$np_support/defaults-override-26.1-rosetta" +unset -v np_build np_flavor +clear; unset -v MTL_HUD_ENABLED NOTPROTON_RETINA +verb=run + +echo "== MSync carry-over ==" +msync=$(sed -n '/^ msync_from=environment$/,/notproton-msync" 2>\/dev\/null || true$/p' "$SRC") +[ -n "$msync" ] || { echo "FAIL: the MSync block was not found"; exit 1; } +STEAM_COMPAT_DATA_PATH="$work/compat" +mkdir -p "$STEAM_COMPAT_DATA_PATH" +printf 1 > "$STEAM_COMPAT_DATA_PATH/notproton-msync" +np_defaulted="" WINEMSYNC="" verb=waitforexitandrun +eval "$msync" +is "a game without MSync set does not inherit its last launch" "0 default" "$WINEMSYNC $msync_from" +is "and helpers then follow it" "0" "$(cat "$STEAM_COMPAT_DATA_PATH/notproton-msync")" +printf 1 > "$STEAM_COMPAT_DATA_PATH/notproton-msync" +WINEMSYNC="" verb=run +eval "$msync" +is "a helper follows the game's last launch" "1 carried-over" "$WINEMSYNC $msync_from" +unset -v STEAM_COMPAT_DATA_PATH WINEMSYNC +verb=run + if [ "$fails" -eq 0 ]; then echo "==> envcheck: all assertions hold" else diff --git a/dylib/tests/gatecheck.c b/dylib/tests/gatecheck.c index 27dacdc2..ba9727b6 100644 --- a/dylib/tests/gatecheck.c +++ b/dylib/tests/gatecheck.c @@ -57,7 +57,8 @@ static void check_fallback(void) { &length, NULL); int ok = out && !memchr(out, '\0', length) && strstr(out, "A.some(e=>e.data===\"notproton-fex\")") - && strstr(out, "selectedOption:t||\"notproton-fex\""); + && strstr(out, "selectedOption:t||\"notproton-fex\"") + && strstr(out, "globalThis.MSCXDefaultTool=t||\"notproton-fex\""); free(out); if (!ok) { wrong("concurrent fallback writes changed a chunk's tool name"); @@ -72,11 +73,11 @@ static void check_fallback(void) { static void check_fex_tools(void) { np_webpatch_set_fex_tools((const char *[]){ "notproton-fex", "bad name", NULL, "", "x\"y", "notproton-fex-41069" }, 6); - if (strcmp(g_fex_tools, "[\"notproton-fex\",\"notproton-fex-41069\"]") != 0) - wrong("the FEX tool array is %s", g_fex_tools); + if (strcmp(marker_text(NP_FEX_TOOLS[0]), "[\"notproton-fex\",\"notproton-fex-41069\"]") != 0) + wrong("the FEX tool array is %s", marker_text(NP_FEX_TOOLS[0])); np_webpatch_set_fex_tools(NULL, 0); - if (strcmp(g_fex_tools, "[]") != 0) wrong("no FEX tools gives %s", g_fex_tools); + if (strcmp(marker_text(NP_FEX_TOOLS[0]), "[]") != 0) wrong("no FEX tools gives %s", marker_text(NP_FEX_TOOLS[0])); char names[NP_WEBPATCH_FEX_TOOLS_MAX + 1][NP_TOOL_NAME_MAX]; const char *list[NP_WEBPATCH_FEX_TOOLS_MAX + 1]; @@ -88,11 +89,119 @@ static void check_fex_tools(void) { } np_webpatch_set_fex_tools(list, NP_WEBPATCH_FEX_TOOLS_MAX + 1); size_t want = 2 + NP_WEBPATCH_FEX_TOOLS_MAX * (NP_TOOL_NAME_MAX + 2) - 1; - if (strlen(g_fex_tools) != want || strchr(g_fex_tools, 'A' + NP_WEBPATCH_FEX_TOOLS_MAX)) - wrong("the longest FEX tool array is %zu bytes, not %zu", strlen(g_fex_tools), want); + if (strlen(marker_text(NP_FEX_TOOLS[0])) != want || strchr(marker_text(NP_FEX_TOOLS[0]), 'A' + NP_WEBPATCH_FEX_TOOLS_MAX)) + wrong("the longest FEX tool array is %zu bytes, not %zu", strlen(marker_text(NP_FEX_TOOLS[0])), want); np_webpatch_set_fex_tools(NULL, 0); } +static void *fresh_defaults(void *arg) { + *(int *)arg = strcmp(marker_text(NP_DEFAULTS[0]), NP_UNREAD_DEFAULTS) == 0 + && strcmp(marker_text(NP_BUILDS[0]), "[]") == 0; + return NULL; +} + +static void check_defaults(void) { + int fresh = 0; + pthread_t other; + np_webpatch_set_defaults("WINEMSYNC=1", ""); + if (pthread_create(&other, NULL, fresh_defaults, &fresh) != 0 || pthread_join(other, NULL) != 0) + wrong("the fresh thread did not run"); + else if (!fresh) + wrong("a thread that set nothing sees another thread's defaults, or reads them as empty"); + np_webpatch_set_defaults("WINEMSYNC=1\nDXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.5\n", "MTL_HUD_ENABLED=0"); + if (strcmp(marker_text(NP_DEFAULTS[0]), "{rosetta:{\"WINEMSYNC\":\"1\"," + "\"DXMT_CONFIG\":\"d3d11.metalSpatialUpscaleFactor=1.5\"}," + "fex:{\"MTL_HUD_ENABLED\":\"0\"}}") != 0) + wrong("the defaults are %s", marker_text(NP_DEFAULTS[0])); + np_webpatch_set_defaults("FOO=\"});alert(1);({\nwinemsync=1\nWINEMSYNC=1\nNOEQUALS\n=1", ""); + if (strcmp(marker_text(NP_DEFAULTS[0]), "{rosetta:{\"WINEMSYNC\":\"1\"},fex:{}}") != 0) + wrong("lines the page cannot take give %s", marker_text(NP_DEFAULTS[0])); + np_webpatch_set_defaults("", ""); + if (strcmp(marker_text(NP_DEFAULTS[0]), "{rosetta:{},fex:{}}") != 0) wrong("no defaults gives %s", marker_text(NP_DEFAULTS[0])); + np_webpatch_set_defaults("WINEMSYNC=1", NULL); + if (strcmp(marker_text(NP_DEFAULTS[0]), "{rosetta:{\"WINEMSYNC\":\"1\"},fex:null}") != 0) + wrong("a set that could not be read gives %s", marker_text(NP_DEFAULTS[0])); + static char big[3 * 2000 + 1]; + for (int i = 0; i < 2000; i++) memcpy(big + 3 * i, "A=\n", 3); + np_webpatch_set_defaults(big, big); + const char *dj = marker_text(NP_DEFAULTS[0]); + if (strncmp(dj, "{rosetta:{\"A\":\"\",", 17) != 0 || strcmp(dj + strlen(dj) - 8, "\"A\":\"\"}}") != 0) + wrong("the most the defaults can grow does not fit: %.40s", dj); + np_webpatch_set_defaults("", ""); + + const np_webpatch_build_t builds[] = { + { "notproton-26.1-fex", "26.1", "CrossOver 26.1 (FEX)", 1, "WINEMSYNC=0\nBAD\"=1" }, + { "notproton-26.1", "26.1", "CrossOver 26.1 (Preview)", 0, NULL }, + { "notproton-25.0", "25.0", "CrossOver 25", 0, "" }, + { "notproton-evil", "26.2", "Evil\"});alert(1);({", 0, NULL }, + { "notproton-slash", "26.2", "Back\\slash", 0, NULL }, + { "notproton-nl", "26.2", "Line\nbreak", 0, NULL }, + { "notproton-path", "../x", "Path", 0, NULL }, + { "bad name", "26.2", "Name", 0, NULL }, + { NULL, "26.2", "No name", 0, NULL }, + { "notproton-nolabel", "26.2", "", 0, NULL }, + }; + int saved = np_log_level; + np_log_level = -1; + np_webpatch_set_builds(builds, sizeof builds / sizeof builds[0]); + const char *want = + "[{id:\"notproton-26.1-fex\",build:\"26.1\",runner:\"fex\",label:\"CrossOver 26.1 (FEX)\"," + "override:{\"WINEMSYNC\":\"0\"}}," + "{id:\"notproton-26.1\",build:\"26.1\",runner:\"rosetta\",label:\"CrossOver 26.1 (Preview)\",override:null}," + "{id:\"notproton-25.0\",build:\"25.0\",runner:\"rosetta\",label:\"CrossOver 25\",override:{}}]"; + if (strcmp(marker_text(NP_BUILDS[0]), want) != 0) + wrong("the builds are %s", marker_text(NP_BUILDS[0])); + + np_webpatch_set_builds(builds + 3, 7); + if (strcmp(marker_text(NP_BUILDS[0]), "[]") != 0) wrong("only refused builds give %s", marker_text(NP_BUILDS[0])); + np_webpatch_set_builds(NULL, 4); + if (strcmp(marker_text(NP_BUILDS[0]), "[]") != 0) wrong("no builds give %s", marker_text(NP_BUILDS[0])); + + enum { MOST = NP_WEBPATCH_BUILDS_MAX }; + static char ids[MOST][64], tools[MOST][NP_TOOL_NAME_MAX], labels[MOST][96]; + const char *full = "CX_GRAPHICS_BACKEND=d3dmetal\nMTL_HUD_ENABLED=1\nD3DM_ENABLE_METALFX=1\n" + "DXMT_ENABLE_NVEXT=1\nROSETTA_ADVERTISE_AVX=1\nWINEMSYNC=1\nNOTPROTON_RETINA=1\n" + "DXMT_METALFX_SPATIAL_SWAPCHAIN=1\nDXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.72\n" + "NOTPROTON_RAW_CONTROLLERS=1\nNOTPROTON_DISABLE_HIDRAW=1\n"; + np_webpatch_build_t most[MOST]; + for (int i = 0; i < MOST; i++) { + snprintf(ids[i], sizeof ids[i], "%02d%061d", i, 0); + memset(tools[i], 't', sizeof tools[i] - 1); + tools[i][sizeof tools[i] - 1] = '\0'; + tools[i][0] = (char)('0' + i / 10); + tools[i][1] = (char)('0' + i % 10); + memset(labels[i], 'L', sizeof labels[i] - 1); + labels[i][sizeof labels[i] - 1] = '\0'; + most[i] = (np_webpatch_build_t){ tools[i], ids[i], labels[i], 0, full }; + } + np_webpatch_set_builds(most, MOST); + const char *js = marker_text(NP_BUILDS[0]); + if (strcmp(js, "[]") == 0 || !strstr(js, "\"NOTPROTON_DISABLE_HIDRAW\":\"1\"}}]")) + wrong("every tool with the longest names and a full set does not fit the page"); + np_webpatch_set_builds(NULL, 0); + np_log_level = saved; +} + +static void check_barrel(void) { + static const struct { const char *src; int want; } cases[] = { + { "e.d(t,{XY:()=>a,m:()=>b,Yh:()=>c,$n:()=>d,Vb:()=>f,D0:()=>g})", 1 }, + { "e.d(t,{Vb:()=>f,D0:()=>g,XY:()=>a,m:()=>b,Yh:()=>c,$n:()=>d,zz:()=>h})", 1 }, + { "e.d(t,{XY:()=>a,m:()=>b,Yh:()=>c,$n:()=>d,Vb:()=>f})", 0 }, + { "e.d(t,{XY:()=>a,m:()=>b,Yh:()=>c,$n:()=>d,Vb:()=>f,xD0:()=>g})", 0 }, + { "e.d(t,{XY:()=>a,m:()=>b,Yh:()=>c}),e.d(u,{$n:()=>d,Vb:()=>f,D0:()=>g})", 0 }, + { "e.d(t,{Vb:()=>f})e.d(u,{XY:()=>a,m:()=>b,Yh:()=>c,$n:()=>d,Vb:()=>f,D0:()=>g})", 1 }, + { "Vb:()=>f,D0:()=>g,XY:()=>a,m:()=>b,Yh:()=>c,$n:()=>d}", 0 }, + }; + for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) + if ((find_barrel(cases[i].src, strlen(cases[i].src), NULL) != NULL) != cases[i].want) + wrong("barrel case %zu reads as %d", i, !cases[i].want); + const char *src = cases[5].src; + size_t span = 0; + const char *at = find_barrel(src, strlen(src), &span); + if (at != strrchr(src, '{') || at + span != strrchr(src, '}') + 1) + wrong("the barrel span is %zu bytes at %td", span, at ? at - src : -1); +} + static void check_captures(void) { int seen = 0; for (int c = 1; c < 256; c++) { @@ -324,6 +433,12 @@ static int extract(const char *chunk, const char *out_path) { return 1; } } + size_t span = 0; + const char *barrel = find_barrel((const char *)buf, len, &span); + if (barrel) { + fwrite(barrel, 1, span, o); + fputs("\n", o); + } fclose(o); free(buf); printf("extracted fixture to %s\n", out_path); @@ -374,6 +489,13 @@ static int extract_library(const char *chunk, const char *out_path) { return 0; } +static void page_gates_out(const np_ui_shape_t *S, const char *why, const int *gates, const char *what) { + if (!why) wrong("[%s] %s and the page was not left out", S->name, what); + for (size_t g = 0; g < S->count; g++) + if (S->gates[g].optional == NP_GATE_PAGE && gates[g]) + wrong("[%s] %s and page gate %zu stayed in", S->name, what, g); +} + static void check_optional_drift(const np_ui_shape_t *S, size_t g, const uint8_t *buf, size_t len, const int *applied) { char *copy = malloc(len + 1); @@ -389,6 +511,8 @@ static void check_optional_drift(const np_ui_shape_t *S, size_t g, const uint8_t int without[NP_GATE_MAX]; memcpy(without, applied, sizeof(without)); without[g] = 0; + const char *why = settle_page(S, copy, len, without, NULL); + if (S->gates[g].optional == NP_GATE_PAGE) page_gates_out(S, why, without, "a page gate drifted"); int saved = np_log_level; np_log_level = -1; @@ -404,6 +528,38 @@ static void check_optional_drift(const np_ui_shape_t *S, size_t g, const uint8_t free(copy); } +static void check_barrel_drift(const np_ui_shape_t *S, const uint8_t *buf, size_t len, + const int *applied) { + size_t span = 0; + const char *at = find_barrel((const char *)buf, len, &span); + if (!at) return; + char *copy = malloc(len + 1); + if (!copy) { wrong("out of memory"); return; } + memcpy(copy, buf, len + 1); + static const char *const names[] = NP_CX_BARREL_NAMES; + char export[16]; + snprintf(export, sizeof export, "%s:()=>", names[sizeof names / sizeof names[0] - 1]); + const char *name = find_in(at, span, export); + if (!name) { wrong("[%s] the barrel has no %s", S->name, export); free(copy); return; } + copy[name - (const char *)buf] = '_'; + int without[NP_GATE_MAX]; + memcpy(without, applied, sizeof(without)); + page_gates_out(S, settle_page(S, copy, len, without, NULL), without, "the barrel lost a name"); + + int saved = np_log_level; + np_log_level = -1; + size_t out_len = 0; + char *out = np_webpatch_transform((const uint8_t *)copy, len, &out_len, NULL); + np_log_level = saved; + if (out) { + check_output(out, out_len, without); + free(out); + } else { + wrong("[%s] a missing barrel left the whole shape unpatched", S->name); + } + free(copy); +} + static const char g_test_lists[] = "window.__NOTPROTON_NATIVE_PLATFORM__={mac:new Set([1,4]),windows:new Set([2,4])};\n"; @@ -478,6 +634,7 @@ static void check_library(const char *path, const char *out_path, int literal_ve free(out); g_platform_js = g_test_lists; + if (np_webpatch_has_shape(buf, len)) wrong("%s reads as a compat UI", path); np_cap_t caps[NP_CAP_MAX]; for (size_t g = 0; g < NP_LIBRARY_FILTER_COUNT; g++) { if (!present[g]) continue; @@ -532,7 +689,7 @@ int main(int argc, char **argv) { } if (argc < 2) { - selfcheck(); check_captures(); check_fallback(); check_fex_tools(); + selfcheck(); check_captures(); check_fallback(); check_fex_tools(); check_defaults(); check_barrel(); return g_wrong; } @@ -545,10 +702,12 @@ int main(int argc, char **argv) { if (!buf) { wrong("cannot read %s", argv[i]); continue; } const np_ui_shape_t *S = shape_of((const char *)buf, len); + if (!S != !np_webpatch_has_shape(buf, len)) wrong("%s reads as having a shape: %d", argv[i], !S); int applied[NP_GATE_MAX] = {0}; for (size_t g = 0; S && g < S->count; g++) applied[g] = count_matches((const char *)buf, len, S->gates[g].find) == (size_t)S->gates[g].expect; + if (S) settle_page(S, (const char *)buf, len, applied, NULL); size_t out_len = 0; char *out = np_webpatch_transform(buf, len, &out_len, NULL); @@ -559,6 +718,7 @@ int main(int argc, char **argv) { for (size_t g = 0; S && g < S->count; g++) if (S->gates[g].optional && applied[g]) check_optional_drift(S, g, buf, len, applied); + if (S) check_barrel_drift(S, buf, len, applied); } else { printf("REJECTED %s\n", argv[i]); } diff --git a/dylib/tests/panel-behavior/behavior.js b/dylib/tests/panel-behavior/behavior.js index 2ea63504..a47a5f4b 100644 --- a/dylib/tests/panel-behavior/behavior.js +++ b/dylib/tests/panel-behavior/behavior.js @@ -97,6 +97,15 @@ for (const form of Object.keys(FORMS)) { t.ok(css.length === 1 && css[0].props.children.includes('.MSCXPanel .MSCXRow{'), 'the panel carries one stylesheet defining the row rule'); + const rowless = nodes('').filter(x => x.type === 'div' && typeof x.props.ref === 'function'); + t.ok(rowless.length === 0, 'no prefix row before the page defines one'); + const rowsFor = []; + global.MSCXPrefixRow = (e, id) => rowsFor.push(id); + const prefix = nodes('').filter(x => x.type === 'div' && typeof x.props.ref === 'function'); + if (prefix.length === 1) prefix[0].props.ref({}); + delete global.MSCXPrefixRow; + t.ok(prefix.length === 1 && rowsFor[0] === 287700, 'the panel hosts the prefix row for its game'); + 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/defaults.js b/dylib/tests/panel-behavior/defaults.js new file mode 100644 index 00000000..e4d38e85 --- /dev/null +++ b/dylib/tests/panel-behavior/defaults.js @@ -0,0 +1,479 @@ +'use strict'; +const { panel, walk, details, runner, FORMS } = require('./harness'); + +const emit = process.argv[2]; +if (!emit) { console.error('usage: defaults.js '); process.exit(2); } + +const FEX_TOOLS = ['notproton-fex']; +const AVX = 'Advertise AVX2 to Rosetta'; +const X87 = 'Reduced x87 Precision (Reducing precision improves game performance, is enabled by default in CrossOver Preview)'; +const SAVED = { + rosetta: { CX_GRAPHICS_BACKEND: 'dxmt', MTL_HUD_ENABLED: '1', ROSETTA_ADVERTISE_AVX: '0', + DXMT_METALFX_SPATIAL_SWAPCHAIN: '1', DXMT_CONFIG: 'd3d11.metalSpatialUpscaleFactor=1.5' }, + fex: { WINEMSYNC: '1', FEX_X87REDUCEDPRECISION: '0' }, +}; +const globalsWith = data => ({ document: { documentElement: { lang: 'en' } }, + MSCXDefaultsData: JSON.parse(JSON.stringify(data)) }); +const settle = () => new Promise(res => setImmediate(res)); +const OK = { BSuccess: () => true, GetEResult: () => 1 }; +const REFUSED = { BSuccess: () => false, GetEResult: () => 2 }; +const PARTLY = { BSuccess: () => false, GetEResult: () => 129 }; + +function hooks() { + const state = []; + const cleanups = []; + let at = 0; + return { + use: init => { + const i = at++; + if (!(i in state)) state[i] = init; + return [state[i], v => { state[i] = typeof v === 'function' ? v(state[i]) : v; }]; + }, + effect: fn => { + const i = at++; + if (!(i in state)) { state[i] = true; cleanups.push(fn() || (() => {})); } + }, + render: fn => { at = 0; return walk(fn({})); }, + unmount: () => cleanups.splice(0).forEach(f => f()), + }; +} + +function connect(g, h, reply = () => OK) { + const events = new EventTarget(); + const sent = []; + g.addEventListener = events.addEventListener.bind(events); + g.removeEventListener = events.removeEventListener.bind(events); + g.dispatchEvent = events.dispatchEvent.bind(events); + g.MSCXUseState = h.use; + g.MSCXUseEffect = h.effect; + g.MSCXSpecifyRoute = (route, appid, text) => { + sent.push({ route, appid, text }); + return Promise.resolve(reply(route)); + }; + return sent; +} + +const BUILDS = [ + { id: 'notproton-26.1', build: '26.1', runner: 'rosetta', label: 'CrossOver 26.1', override: null }, + { id: 'notproton-26.1-fex', build: '26.1', runner: 'fex', label: 'CrossOver 26.1 (FEX)', + override: { WINEMSYNC: '0' } }, + { id: 'notproton-25.0', build: '25.0', runner: 'rosetta', label: 'CrossOver 25', override: null }, +]; + +const settingsRow = (ns, re) => ns.find(x => x.type === 'Field' && re.test(x.props.label) && + x.props.childrenContainerWidth === 'min' && x.props.children && x.props.children.type === 'Button' && + x.props.children.props.className === 'MSCXSetBtn'); +const fieldButton = (ns, re) => { const f = settingsRow(ns, re); return f && f.props.children; }; +const field = (ns, label) => ns.find(x => x.type === 'FieldDropdown' && x.props.label === label); +const row = (ns, label) => ns.find(x => x.type === 'Toggle' && x.props.label === label); + +function openPage(form, builds, { tool, reply, data } = {}) { + const g = data ? { document: { documentElement: { lang: 'en' } } } : globalsWith(SAVED); + if (builds) g.MSCXBuilds = JSON.parse(JSON.stringify(builds)); + if (tool !== undefined) g.MSCXDefaultTool = tool; + const h = hooks(); + const sent = connect(g, h, reply); + const page = panel(emit, form, g, { fexTools: FEX_TOOLS, data }).defaultsPage; + return { g, h, sent, r: () => h.render(page) }; +} + +function oneKindCases(form) { + const t = runner(form + ' one kind'); + if (form === 'component') return t.failed; + const label = ns => (ns.find(x => x.type === 'Field' && /^Apply as defaults/.test(x.props.label)) || {}).props; + const pick = ns => field(ns, 'Select default settings for'); + + let { r } = openPage(form, BUILDS.filter(x => x.runner === 'rosetta')); + t.ok(label(r()).label === 'Apply as defaults for all CrossOver Compatibility Tools', + `only Rosetta installed says CrossOver (${label(r()).label})`); + pick(r()).props.onChange({ data: 1 }); + const ver = field(r(), 'CrossOver version'); + t.ok(ver.props.selectedOption === '' && ver.props.rgOptions[0].label === 'None installed', + 'picking FEX with no FEX installed says none is installed'); + t.ok(!label(r()), 'and there is nothing to apply to'); + + ({ r } = openPage(form, BUILDS.filter(x => x.runner === 'fex'))); + t.ok(pick(r()).props.selectedOption === 1, 'only FEX installed opens on FEX'); + t.ok(label(r()).label === 'Apply as defaults for all CrossOver Compatibility Tools', 'and says CrossOver'); + + ({ r } = openPage(form, BUILDS)); + t.ok(label(r()).label === 'Apply as defaults for all Rosetta Compatibility Tools', 'both kinds name the runner'); + return t.failed; +} + +function defaultToolCases(form) { + const t = runner(form + ' default tool'); + if (form === 'component') return t.failed; + const ver = ns => field(ns, 'CrossOver version').props.selectedOption; + const kind = ns => field(ns, 'Select default settings for').props.selectedOption; + const told = (g, tool) => g.dispatchEvent(new CustomEvent('mscxdefaulttool', { detail: tool })); + + let o = openPage(form, BUILDS, { tool: 'notproton-25.0' }); + t.ok(ver(o.r()) === 'notproton-25.0' && kind(o.r()) === 0, 'opens on the default tool'); + o = openPage(form, BUILDS, { tool: 'notproton-26.1-fex' }); + t.ok(ver(o.r()) === 'notproton-26.1-fex' && kind(o.r()) === 1, 'a FEX default tool opens on FEX'); + o = openPage(form, BUILDS, { tool: 'proton_9' }); + t.ok(ver(o.r()) === 'notproton-26.1', 'a default that is not CrossOver opens on the first version'); + o = openPage(form, BUILDS); + t.ok(ver(o.r()) === 'notproton-26.1', 'so does no default at all'); + + o = openPage(form, BUILDS, { tool: 'notproton-26.1' }); + o.r(); + told(o.g, 'notproton-26.1-fex'); + t.ok(ver(o.r()) === 'notproton-26.1-fex' && kind(o.r()) === 1, 'changing the default tool above follows along'); + told(o.g, 'proton_9'); + t.ok(ver(o.r()) === 'notproton-26.1-fex', 'and a tool that is not CrossOver leaves it where it is'); + o.h.unmount(); + told(o.g, 'notproton-25.0'); + t.ok(ver(o.r()) === 'notproton-26.1-fex', 'a page that has gone stops listening'); + return t.failed; +} + +function splicedCases(form) { + const t = runner(form + ' spliced'); + if (form === 'component') return t.failed; + const { g, r } = openPage(form, null, { data: true }); + r(); + t.ok(JSON.stringify(g.MSCXDefaultsData) === '{"rosetta":{"MTL_HUD_ENABLED":"1"},"fex":{"WINEMSYNC":"1"}}', + `the page parses the defaults C wrote, without the lines it left out (${JSON.stringify(g.MSCXDefaultsData)})`); + t.ok(g.MSCXBuilds.map(b => b.id).join(',') === 'notproton-26.1,notproton-26.1-fex,notproton-empty', + `and the builds, without the one whose label could break out (${g.MSCXBuilds.map(b => b.id)})`); + t.ok(g.MSCXBuilds[0].override === null && g.MSCXBuilds[1].override.WINEMSYNC === '0' && + JSON.stringify(g.MSCXBuilds[2].override) === '{}' && g.MSCXBuilds[1].runner === 'fex', + 'a build has null, its own set, or an empty one'); + return t.failed; +} + +async function buildCases(form) { + const t = runner(form + ' builds'); + const g = globalsWith(SAVED); + g.MSCXBuilds = JSON.parse(JSON.stringify(BUILDS)); + const h = hooks(); + const page = panel(emit, form, g, { fexTools: FEX_TOOLS }).defaultsPage; + if (form === 'component') return t.failed; + + t.ok(h.render(page).length === 0, 'the page waits for its gates'); + let reply = () => OK; + const sent = connect(g, h, route => reply(route)); + let p = h.render(page); + t.ok(field(p, 'Select default settings for'), 'the page mounts once its gates show up'); + + const version = () => field(h.render(page), 'CrossOver version'); + const applyRow = () => settingsRow(h.render(page), /^Apply as defaults for all Rosetta Compatibility Tools$/); + const apply = () => fieldButton(h.render(page), /^Apply as defaults for all Rosetta Compatibility Tools$/); + const last = () => sent[sent.length - 1]; + t.ok(version().props.selectedOption === 'notproton-26.1' && field(p, 'Select default settings for').props.selectedOption === 0, + 'the page opens on the first installed version and its runner'); + t.ok(version().props.rgOptions.map(o => o.data).join(',') === 'notproton-26.1,notproton-26.1-fex,notproton-25.0', + `version lists every build and nothing else (${version().props.rgOptions.map(o => o.label)})`); + t.ok(apply() && !apply().props.disabled && !applyRow().props.disabled, 'the apply row is on from the start'); + t.ok(applyRow().props.description === undefined, 'nothing says it was applied before it is'); + + field(h.render(page), 'Select default settings for').props.onChange({ data: 1 }); + t.ok(version().props.selectedOption === 'notproton-26.1-fex', 'picking FEX jumps to the first FEX version'); + field(h.render(page), 'Select default settings for').props.onChange({ data: 0 }); + t.ok(version().props.selectedOption === 'notproton-26.1', 'and Rosetta back to the first Rosetta one'); + + version().props.onChange({ data: 'notproton-26.1-fex' }); + t.ok(field(h.render(page), 'Select default settings for').props.selectedOption === 1, + 'picking a FEX build switches the runner to FEX'); + + version().props.onChange({ data: 'notproton-26.1' }); + p = h.render(page); + t.ok(row(p, 'Metal HUD').props.checked === true, 'a build without its own set shows the runner\'s'); + + row(p, 'MSync').props.onChange(true); + await settle(); + const lines = () => last().text.split('\n'); + t.ok(last().route === 'NotProton.SetToolDefaults#1' && lines()[0] === 'notproton-26.1', + `it saves to that tool (${JSON.stringify(last())})`); + t.ok(lines().slice(1).sort().join(';') === + 'CX_GRAPHICS_BACKEND=dxmt;DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.5;' + + 'DXMT_METALFX_SPATIAL_SWAPCHAIN=1;MTL_HUD_ENABLED=1;ROSETTA_ADVERTISE_AVX=0;WINEMSYNC=1', + 'starting from the runner\'s set'); + t.ok(g.MSCXBuilds[0].override.WINEMSYNC === '1' && !('WINEMSYNC' in g.MSCXDefaultsData.rosetta), + 'the build gets its own set and the runner\'s is left alone'); + t.ok(version().props.rgOptions.every(o => !/own settings/.test(o.label)) && !fieldButton(h.render(page), /^Use /), + 'nothing marks a build as having its own set, and nothing offers to undo it'); + + g.MSCXDefaultsData.rosetta.NOTPROTON_RETINA = '1'; + t.ok(row(h.render(page), 'High Resolution').props.checked === false, + 'a build with its own set no longer follows the runner\'s'); + + const gp = panel(emit, form, g, { fexTools: FEX_TOOLS.concat('notproton-26.1-fex') }), games = gp.render; + const g2written = () => (gp.written.length ? gp.written[gp.written.length - 1].opts : ''); + const game = tool => walk(games({ details: details('%command%', { strCompatToolName: tool }) })); + t.ok(row(game('notproton-26.1'), 'MSync').props.checked === true && + row(game('notproton-25.0'), 'MSync').props.checked === false, + 'a game shows its own build\'s defaults'); + t.ok(row(game('notproton-26.1-fex'), 'MSync').props.checked === false && + row(game('notproton-fex'), 'MSync').props.checked === true, + 'a FEX build\'s set beats the FEX defaults only for that build'); + + version().props.onChange({ data: 'notproton-25.0' }); + t.ok(row(h.render(page), 'High Resolution').props.checked === true, + 'a build nobody changed follows a change to the runner\'s'); + row(h.render(page), 'Metal HUD').props.onChange(false); + await settle(); + t.ok(g.MSCXBuilds[2].override, 'and 25 its own set'); + + version().props.onChange({ data: 'notproton-26.1' }); + reply = () => REFUSED; + const quiet = console.error; console.error = () => {}; + apply().props.onClick(); + await settle(); await settle(); + console.error = quiet; + t.ok(g.MSCXBuilds[0].override && g.MSCXBuilds[2].override && !('WINEMSYNC' in g.MSCXDefaultsData.rosetta), + 'a refused apply leaves every set as it was'); + t.ok(applyRow().props.description === undefined, 'and does not say it was applied'); + + reply = () => OK; + const before = sent.length; + apply().props.onClick(); + await settle(); await settle(); + t.ok(sent.length === before + 1 && last().route === 'NotProton.ApplyToolDefaults#1' && + last().text === 'notproton-26.1', `apply is one request for the tool shown (${JSON.stringify(last())})`); + t.ok(g.MSCXDefaultsData.rosetta.WINEMSYNC === '1', 'the runner defaults now include what the build had'); + t.ok(g.MSCXBuilds[0].override === null && g.MSCXBuilds[2].override === null && g.MSCXBuilds[1].override, + 'every Rosetta build follows them again and the FEX one keeps its own'); + t.ok(applyRow().props.description === 'Applied. Every Rosetta Compatibility Tool now uses these settings.', + `the row says it was applied (${applyRow().props.description})`); + row(h.render(page), 'Metal HUD').props.onChange(false); + await settle(); + t.ok(applyRow().props.description === undefined, 'changing a setting clears that note'); + version().props.onChange({ data: 'notproton-25.0' }); + apply().props.onClick(); + await settle(); await settle(); + version().props.onChange({ data: 'notproton-26.1' }); + t.ok(applyRow().props.description === undefined, 'so does picking another version'); + + let release; + reply = route => route === 'NotProton.ApplyToolDefaults#1' ? new Promise(res => { release = () => res(OK); }) : OK; + apply().props.onClick(); + await settle(); + field(h.render(page), 'Select default settings for').props.onChange({ data: 1 }); + release(); + await settle(); await settle(); + const fexRow = settingsRow(h.render(page), /^Apply as defaults for all FEX Compatibility Tools$/); + t.ok(fexRow && fexRow.props.description === undefined, + `switching runner while it is on its way keeps the note off the other runner (${fexRow && fexRow.props.description})`); + field(h.render(page), 'Select default settings for').props.onChange({ data: 0 }); + apply().props.onClick(); + await settle(); + row(h.render(page), 'MSync').props.onChange(false); + release(); + await settle(); await settle(); await settle(); + t.ok(applyRow().props.description === undefined, 'and so does a change made while it is on its way'); + + reply = () => PARTLY; + const said = []; + console.error = (...a) => said.push(a.join(' ')); + const shown = g.MSCXBuilds[0].override || g.MSCXDefaultsData.rosetta; + apply().props.onClick(); + await settle(); await settle(); + console.error = quiet; + t.ok(h.render(page).length === 0, 'a partial apply hides the page, so nothing stale saves over it'); + t.ok(said.length === 1 && /until Steam restarts/.test(said[0]), `and says so once (${said.join(' | ')})`); + t.ok(JSON.stringify(g.MSCXDefaultsData.rosetta) === JSON.stringify(shown), + 'game panels get the set that did save'); + g.MSCXBuilds[0].override = { WINEMSYNC: '1' }; + const after = tool => walk(games({ details: details('%command%', { strCompatToolName: tool }) })); + t.ok(row(after('notproton-26.1'), 'MSync').props.checked === false, + 'game panels stop showing build sets that may be gone'); + row(after('notproton-26.1'), 'Metal HUD').props.onChange(false); + t.ok(/MTL_HUD_ENABLED=0/.test(g2written()), `and a choice saves to the game itself (${g2written()})`); + delete g.MSCXDefaultsLost; + return t.failed; +} + +async function main() { +let failed = 0; +for (const form of Object.keys(FORMS)) { + const { render: P, written } = panel(emit, form, globalsWith(SAVED), { fexTools: FEX_TOOLS }); + const t = runner(form); + const nodes = (opts, tool = 'notproton') => + walk(P({ details: details(opts, { strCompatToolName: tool }) })); + const dropdowns = ns => ns.filter(x => x.type === 'Dropdown'); + const last = () => (written.length ? written[written.length - 1].opts : null); + + let r = nodes('%command%'); + t.ok(dropdowns(r)[0].props.selectedOption === 'dxmt', 'an unset backend shows the default'); + t.ok(row(r, 'Metal HUD').props.checked === true, 'an unset row shows the default'); + t.ok(row(r, AVX).props.checked === false, 'the AVX2 default of 0 shows off'); + t.ok(dropdowns(r)[1].props.selectedOption === '1.5', 'an unset upscaler shows the default factor'); + t.ok(!r.some(x => x.type === 'Button' || x.type === 'button'), 'a game has no Reset to defaults'); + + r = nodes('MTL_HUD_ENABLED=0 CX_GRAPHICS_BACKEND=auto %command%'); + t.ok(row(r, 'Metal HUD').props.checked === false, "the game's 0 wins over the default"); + t.ok(dropdowns(r)[0].props.selectedOption === '', "the game's Automatic wins over the default"); + + r = nodes("DXMT_CONFIG='d3d11.preferredMaxFrameRate=60' %command%"); + t.ok(dropdowns(r)[1].props.selectedOption === '1.5', + 'a DXMT_CONFIG without a factor shows the default factor'); + r = nodes("DXMT_CONFIG='d3d11.metalSpatialUpscaleFactor=3.0' %command%"); + t.ok(dropdowns(r)[1].props.selectedOption === '3.0', "the game's factor wins over the default"); + + r = nodes('WINEDEBUG=x %command%', 'notproton-fex'); + t.ok(row(r, 'MSync').props.checked === true && row(r, X87).props.checked === false, + 'a FEX game shows the FEX defaults'); + t.ok(row(r, 'Metal HUD').props.checked === false && dropdowns(r)[0].props.selectedOption === '', + 'a FEX game ignores the Rosetta defaults'); + + written.length = 0; + row(nodes('MTL_HUD_ENABLED=1 %command%'), 'MSync').props.onChange(true); + t.ok(last() === 'WINEMSYNC=1 MTL_HUD_ENABLED=1 %command%', + `a value set by hand that matches the default stays (${last()})`); + written.length = 0; + row(nodes('%command%'), 'Metal HUD').props.onChange(false); + t.ok(last() === 'MTL_HUD_ENABLED=0 %command%', `turning a default off writes 0 (${last()})`); + written.length = 0; + dropdowns(nodes('%command%'))[0].props.onChange({ data: '' }); + t.ok(last() === 'CX_GRAPHICS_BACKEND=auto %command%', `Automatic writes auto (${last()})`); + + written.length = 0; + row(nodes('WINEDEBUG=x MTL_HUD_ENABLED=0 %command%'), 'Metal HUD').props.onChange(true); + t.ok(last() === 'WINEDEBUG=x %command%', `turning a row back to its default follows it again (${last()})`); + written.length = 0; + dropdowns(nodes('CX_GRAPHICS_BACKEND=dxvk %command%'))[0].props.onChange({ data: 'dxmt' }); + t.ok(last() === '', `so does picking the default backend (${last()})`); + written.length = 0; + dropdowns(nodes('DXMT_METALFX_SPATIAL_SWAPCHAIN=0 %command%'))[1].props.onChange({ data: '1.5' }); + t.ok(last() === '', `and the default upscaler (${last()})`); + written.length = 0; + row(nodes('FEX_X87REDUCEDPRECISION=1 %command%', 'notproton-fex'), X87).props.onChange(false); + t.ok(last() === '', `and a FEX row turned back to its default (${last()})`); + written.length = 0; + row(nodes('ROSETTA_ADVERTISE_AVX=1 %command%'), AVX).props.onChange(false); + t.ok(last() === '', `and a row whose default is 0 (${last()})`); + + const D = panel(emit, form, globalsWith(SAVED)).defaultsPage; + if (form === 'component') { + t.ok(D === null, 'the old page has no Settings page'); + failed += t.failed; + continue; + } + t.ok(D && D({}) === null, 'the Settings page shows nothing without its gates'); + + { + const h = hooks(); + const g = globalsWith(SAVED); + let reply = OK; + const sent = connect(g, h, () => reply); + const page = panel(emit, form, g).defaultsPage; + const quiet = console.error; + let warned = ''; + { + const bare = hooks(); + const g2 = globalsWith(SAVED); + connect(g2, bare); + const renamed = panel(emit, form, g2, { drop: ['D0'] }).defaultsPage; + const said = []; + console.error = (...a) => { said.push(a.join(' ')); }; + const first = bare.render(renamed), again = bare.render(renamed); + console.error = quiet; + t.ok(first.length === 0 && again.length === 0, 'the page hides when Steam renames a component it takes'); + t.ok(said.length === 1 && /renamed its settings components/.test(said[0]), + `and says so once (${said.join(' | ')})`); + } + { + const bare = hooks(); + const g2 = globalsWith({ rosetta: SAVED.rosetta, fex: null }); + const sent2 = connect(g2, bare); + const unread = panel(emit, form, g2).defaultsPage; + const said = []; + console.error = (...a) => { said.push(a.join(' ')); }; + const first = bare.render(unread), again = bare.render(unread); + console.error = quiet; + t.ok(first.length === 0 && again.length === 0 && sent2.length === 0, + 'the page hides when the saved defaults could not be read, so nothing saves over them'); + t.ok(said.length === 1 && /could not be read/.test(said[0]), `and says so once (${said.join(' | ')})`); + const P2 = panel(emit, form, g2, { fexTools: FEX_TOOLS }).render; + const game = walk(P2({ details: details('%command%', { strCompatToolName: FEX_TOOLS[0] }) })); + t.ok(row(game, 'MSync') && row(game, 'MSync').props.checked === false, + 'game panels still show, with nothing defaulted'); + } + console.error = (...a) => { warned = a.join(' '); }; + + let p = h.render(page); + t.ok(p.some(x => x.type === 'Section' && x.props.label === 'Default Compatibility Settings'), + 'the page is headed Default Compatibility Settings'); + const pick = () => field(h.render(page), 'Select default settings for'); + t.ok(pick().props.rgOptions.map(o => o.label).join(',') === 'Rosetta,FEX', 'it picks Rosetta or FEX'); + t.ok(/^Changes apply to the CrossOver version selected below\./.test(pick().props.description), + 'it says changes go to the version shown'); + t.ok(row(p, 'Metal HUD').props.checked === true && row(p, AVX).props.checked === false, + 'Rosetta shows its saved defaults'); + t.ok(!p.some(x => x.props && x.type === 'Button' && x.props.children === 'Reset to defaults'), + 'the page has no Reset to defaults'); + + const runnerLines = i => sent[i].text.split('\n').sort().join(';'); + row(p, 'MSync').props.onChange(true); + await settle(); + t.ok(sent.length === 1 && sent[0].route === 'NotProton.SetDefaults#1' && sent[0].appid === 0, + 'with no version installed a change sends the Rosetta defaults'); + t.ok(runnerLines(0) === + 'CX_GRAPHICS_BACKEND=dxmt;DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.5;' + + 'DXMT_METALFX_SPATIAL_SWAPCHAIN=1;MTL_HUD_ENABLED=1;ROSETTA_ADVERTISE_AVX=0;WINEMSYNC=1', + `it sends every saved line plus the change (${JSON.stringify(sent[0].text)})`); + t.ok(g.MSCXDefaultsData.rosetta.WINEMSYNC === '1', 'a saved change reaches the game panels'); + t.ok(row(h.render(page), 'MSync').props.checked === true, 'the page shows the saved change'); + + dropdowns(h.render(page))[0].props.onChange({ data: '' }); + await settle(); + t.ok(!/CX_GRAPHICS_BACKEND/.test(sent[1].text), 'Automatic takes the backend out of the defaults'); + t.ok(!('CX_GRAPHICS_BACKEND' in g.MSCXDefaultsData.rosetta), 'and out of the panels'); + + const upscaler = dropdowns(h.render(page)).find(x => x.props.rgOptions.some(o => o.data === '1.5')); + upscaler.props.onChange({ data: '' }); + await settle(); + t.ok(/DXMT_METALFX_SPATIAL_SWAPCHAIN=0/.test(sent[2].text) && !/DXMT_CONFIG/.test(sent[2].text), + `upscaling off saves 0 and drops the factor (${JSON.stringify(sent[2].text)})`); + + reply = REFUSED; + const before = JSON.stringify(g.MSCXDefaultsData); + let logged = ''; + console.error = (...a) => { logged = a.join(' '); warned = /renamed/.test(a.join(' ')) ? a.join(' ') : warned; }; + row(h.render(page), 'Metal HUD').props.onChange(false); + await settle(); + console.error = (...a) => { warned = a.join(' '); }; + t.ok(JSON.stringify(g.MSCXDefaultsData) === before, 'a failed save changes nothing'); + t.ok(/not saved, result 2/.test(logged), `a failed save is logged (${logged})`); + + pick().props.onChange({ data: 1 }); + p = h.render(page); + t.ok(row(p, X87) && !row(p, AVX), 'FEX shows the FEX rows'); + t.ok(row(p, 'MSync').props.checked === true && row(p, X87).props.checked === false, + 'FEX shows its saved defaults'); + t.ok(!dropdowns(p)[0].props.rgOptions.some(o => o.data === 'd3dmetal'), 'FEX offers no D3DMetal'); + reply = OK; + row(p, X87).props.onChange(true); + await settle(); + t.ok(sent[sent.length - 1].appid === 1 && runnerLines(sent.length - 1) === 'FEX_X87REDUCEDPRECISION=1;WINEMSYNC=1', + `FEX sends its own lines (${JSON.stringify(sent[sent.length - 1])})`); + t.ok(g.MSCXDefaultsData.rosetta.MTL_HUD_ENABLED === '1', 'saving FEX leaves Rosetta alone'); + + p = h.render(page); + row(p, 'Metal HUD').props.onChange(true); + row(p, 'High Resolution').props.onChange(true); + await settle(); + await settle(); + t.ok(runnerLines(sent.length - 1) === 'FEX_X87REDUCEDPRECISION=1;MTL_HUD_ENABLED=1;NOTPROTON_RETINA=1;WINEMSYNC=1', + `two quick changes both get saved (${JSON.stringify(sent[sent.length - 1].text)})`); + t.ok(g.MSCXDefaultsData.fex.MTL_HUD_ENABLED === '1' && g.MSCXDefaultsData.fex.NOTPROTON_RETINA === '1', + 'and both reach the game panels'); + console.error = quiet; + t.ok(!/renamed/.test(warned), 'nothing says a component is missing while Steam has them all'); + failed += t.failed; + } + failed += await buildCases(form); + failed += oneKindCases(form); + failed += defaultToolCases(form); + failed += splicedCases(form); +} +return failed; +} + +main().then(failed => { + console.log(failed ? `\n${failed} failure(s)` : '\ndefaults pass in both shapes'); + process.exit(failed ? 1 : 0); +}, e => { console.error(e); process.exit(1); }); diff --git a/dylib/tests/panel-behavior/emit.c b/dylib/tests/panel-behavior/emit.c index a4fa9a8a..392eac6a 100644 --- a/dylib/tests/panel-behavior/emit.c +++ b/dylib/tests/panel-behavior/emit.c @@ -21,13 +21,32 @@ static void put_panel(const char *body) { } } +static void set_sample_data(void) { + np_webpatch_set_defaults("MTL_HUD_ENABLED=1\nNOT A LINE\n", "WINEMSYNC=1\nX=\"});\n"); + const np_webpatch_build_t builds[] = { + { "notproton-26.1", "26.1", "CrossOver 26.1", 0, NULL }, + { "notproton-26.1-fex", "26.1", "CrossOver 26.1 (FEX)", 1, "WINEMSYNC=0\n" }, + { "notproton-evil", "26.2", "Evil\"});alert(1);({", 0, NULL }, + { "notproton-empty", "25.0", "CrossOver 25", 0, "" }, + }; + np_webpatch_set_builds(builds, sizeof builds / sizeof builds[0]); +} + int main(int argc, char **argv) { - if (argc >= 2 && (strcmp(argv[1], "component") == 0 || strcmp(argv[1], "statement") == 0)) { + const char *form = argc >= 2 ? argv[1] : ""; + int data = strcmp(form, "component-data") == 0 || strcmp(form, "statement-data") == 0; + if (data || strcmp(form, "component") == 0 || strcmp(form, "statement") == 0) { 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); - put_panel(strcmp(argv[1], "component") == 0 ? NP_CX_OPTIONS_COMPONENT - : NP_CX_OPTIONS_STATEMENT); + if (data) set_sample_data(); + put_panel(strncmp(form, "component", 9) == 0 ? NP_CX_OPTIONS_COMPONENT + : NP_CX_OPTIONS_STATEMENT); + return 0; + } + if (argc == 2 && strcmp(argv[1], "barrel-names") == 0) { + static const char *const names[] = NP_CX_BARREL_NAMES; + for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) puts(names[i]); return 0; } if (argc == 2 && strcmp(argv[1], "prefix-row") == 0) { @@ -54,7 +73,8 @@ int main(int argc, char **argv) { fputs(NP_LIBRARY_WINDOWS_SUMMARY, stdout); return 0; } - fputs("usage: emit component|statement [fallback [fex-tool...]]|migration|compat-hint|" - "image-files-label|library-windows-hint|library-windows-summary|prefix-row\n", stderr); + fputs("usage: emit component|statement[-data] [fallback [fex-tool...]]|migration|compat-hint|" + "image-files-label|library-windows-hint|library-windows-summary|prefix-row|barrel-names\n", + stderr); return 2; } diff --git a/dylib/tests/panel-behavior/harness.js b/dylib/tests/panel-behavior/harness.js index 23b9910a..37c1495a 100644 --- a/dylib/tests/panel-behavior/harness.js +++ b/dylib/tests/panel-behavior/harness.js @@ -15,7 +15,12 @@ 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 STUB_TYPES = { XY: 'Section', m: 'Dropdown', Yh: 'Toggle', $n: 'Button', Vb: 'FieldDropdown', D0: 'Field' }; +const barrelFor = emit => Object.fromEntries( + execFileSync(emit, ['barrel-names'], { encoding: 'utf8' }).trim().split('\n').map(n => { + if (!(n in STUB_TYPES)) throw new Error('the page takes ' + n + ' from Steam and the harness has no stub'); + return [n, STUB_TYPES[n]]; + })); // The two shapes take the payload in different forms and bind different names, which // is the whole of the difference between them. @@ -26,8 +31,11 @@ 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'], data = false, drop = [] } = {}) { + const raw = execFileSync(emit, [form + (data ? '-data' : ''), fallback, ...fexTools], + { encoding: 'utf8' }).trim(); + const barrel = barrelFor(emit); + for (const n of drop) delete barrel[n]; 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(/;$/, ''); @@ -37,9 +45,12 @@ function panel(emit, form, globals = { document: { documentElement: { lang: 'en' }; const build = new Function(names[0], names[1], 'SteamClient', 'globalThis', src + '\nreturn { MSCXOpts, MSCXPick: typeof MSCXPick === "function" ? MSCXPick : null,' + - ' MSCXToolQuery: typeof MSCXToolQuery === "function" ? MSCXToolQuery : null };'); - const { MSCXOpts, MSCXPick, MSCXToolQuery } = build(react, barrel, SteamClient, globals); - return { render: MSCXOpts, pick: MSCXPick, toolQuery: MSCXToolQuery, written }; + ' MSCXToolQuery: typeof MSCXToolQuery === "function" ? MSCXToolQuery : null,' + + ' MSCXDefaultsPage: typeof MSCXDefaultsPage === "function" ? MSCXDefaultsPage : null };'); + const { MSCXOpts, MSCXPick, MSCXToolQuery, MSCXDefaultsPage } = + build(react, barrel, SteamClient, globals); + return { render: MSCXOpts, pick: MSCXPick, toolQuery: MSCXToolQuery, + defaultsPage: MSCXDefaultsPage, written, globals }; } // Function components are resolved rather than recorded, so a check sees the nodes the diff --git a/dylib/tests/panel-behavior/shell.js b/dylib/tests/panel-behavior/shell.js index bdb2a54a..97d5bd71 100644 --- a/dylib/tests/panel-behavior/shell.js +++ b/dylib/tests/panel-behavior/shell.js @@ -97,7 +97,8 @@ function agrees(t, v, env, what) { const on = key in ON_UNLESS_0 ? env[key] !== '0' : env[key] === '1'; t.ok(node.props.checked === on, `${what}: ${key} shows ${node.props.checked}, game gets ${env[key]}`); } - t.ok(v.backend.props.selectedOption === (env.CX_GRAPHICS_BACKEND || ''), + const backend = env.CX_GRAPHICS_BACKEND === 'auto' ? '' : env.CX_GRAPHICS_BACKEND || ''; + t.ok(v.backend.props.selectedOption === backend, `${what}: backend shows "${v.backend.props.selectedOption}", game gets ${env.CX_GRAPHICS_BACKEND}`); } diff --git a/dylib/tests/webpatch-fixtures/gates.selecttool-rebuilt.js b/dylib/tests/webpatch-fixtures/gates.selecttool-rebuilt.js index ecd04f8a..e7232255 100644 --- a/dylib/tests/webpatch-fixtures/gates.selecttool-rebuilt.js +++ b/dylib/tests/webpatch-fixtures/gates.selecttool-rebuilt.js @@ -7,3 +7,7 @@ ePlayBar,bInPage:!0,details:le,overview:We,stickyheader:Re,onGameInfoToggle:xe,o ,{})}),(0,h.we)("#GameList_Entry_Invalid_OSType")]}),(0,e.jsx)("div",{className:Q().WarningText,children:(0,e.jsxs)("div",{className:Q().WarningText2,children:[(0,h.we)("#GameList_Entry_Invalid_OSType2"),(0,e.jsx)("a",{href:"https://help.steampowered.com/faqs/view/5E0D-522A-4E62-B6EF",className:Q().WarningText3,children:(0,h.we)("#GameList_Entry_Invalid_OSType3 function Ae(Rj){return(0,Zq.jsxs)(Vt.XY,{label:(0,Nb.we)("#Settings_SteamPlay_SteamPlay"),children:[(0,Zq.jsx)(Ug,{}),(0,Zq.jsx)(Kx.G,{setting:"compat_show_all_tools",label:(0,Nb.we)("#Settings_SteamPlay_ShowAll"),feature:Ld.OK})]})}function Yc(Rj){return(0,Zq.jsx)(Bf.sh,{children:(0,Zq.jsx)(Ae,{})})}function Pm(Rj){return(0,Zq.jsxs)(Vt.XY,{label:(0,Nb.we)("#Settings_SteamPlay_SteamPlay"),children:[(0,Zq.jsx)(Hw,{details:Rj.details}),(0,Zq.jsx)(Kx.G,{setting:"compat_show_all_tools",label:(0,Nb.we)("#Settings_SteamPlay_ShowAll"),feature:Ld.OK})]})}}) display_name);return(0,Nb.uH)(Sx,(0,Zq.jsx)("b",{}))}}return(0,Zq.jsx)(Zq.Fragment,{})}function Pk(Rj){const{label:Wc,nAppId:Hb}=Rj,[Dq]=(0,Zh.VI)("compat_show_all_tools"),[Qk,Wm]=(0,Zr.cT)(Hb);if(Qk.isLoading||!Qk.data?.tools?.length)return null;let Jb=[];for(const Xe of Qk.data.aliases)Jb.push({data:Xe.alias,label:Xe.display_name});let Ov=null;for(const Xe of Qk.data.tools)Xe.is_active&&(Ov=Xe),!(!Dq&&!Xe.is_active& +function Qv(k,Tz){return Tz=Tz||(0,Jh.OI)().GetDefaultTransport(),Tz==null?new Promise((Pq,Xk)=>{console.error("Transport Error: no transport is available for request"),Xk("Transport Error: no transport is available for request")}):Tz.SendMsg("CompatManager.GetCompatTools#1",(0,Bd.I8)(Gc,k),Ns,{ePrivilege:1,eClientExecutionSite:2})}Ym.GetCompatTools=Qv; +function Wv(Rq,Tz){return Tz=Tz||(0,Jh.OI)().GetDefaultTransport(),Tz==null?new Promise((Pq,Xk)=>{console.error("Transport Error: no transport is available for request"),Xk("Transport Error: no transport is available for request")}):Tz.SendMsg("CompatManager.SpecifyCompatTool#1",(0,Bd.I8)(Lc,Rq),Ns,{ePrivilege:1,eClientExecutionSite:2})}Ym.SendMsgSpecifyCompatTool=Wv,Ym.NotifyStateChangedHandler={name:"CompatManager.NotifyStateChanged#1"} +const Gq=new Hf;function Kt(Ym){const Cw=Vr.useCallback(async()=>{const Ue=await Zp.GetCompatTools({appid:Ym});if(!Ue.BSuccess()){if(Ue.GetEResult()==Di.EY)return null;throw new Error("Failed to fetch available compat tools")}return Ue.Body().toObject()},[Ym]),Oa=[D,"CompatTools",Ym??0],Mx=(0,Fd.I)({queryKey:Oa,queryFn:Cw}),Wv=Vr.useCallback(Ue=>{Zp.SpecifyCompatTool({appid:Ym,tool_name:Ue})},[Ym]);return[Mx,Wv]}}), +{Xg:()=>tt.Xg,Cl:()=>Xe,pF:()=>e.pF,Nv:()=>tt.Nv,nB:()=>e.nB,f3:()=>e.f3,a3:()=>e.a3,X8:()=>Be,$n:()=>e.$n,xh:()=>tt.xh,lD:()=>tt.lD,Yh:()=>e.Yh,SR:()=>tt.SR,VP:()=>e.VP,ou:()=>Kr,UC:()=>e.UC,U9:()=>e.U9,$S:()=>tt.$S,BC:()=>tt.BC,G5:()=>e.G5,lr:()=>e.lr,Ws:()=>Ve,pU:()=>Se,Mw:()=>we.Mw,rP:()=>v,Ce:()=>f.Ce,t6:()=>e.t6,ZU:()=>_e,m:()=>ke,aZ:()=>He,n4:()=>Ue,Gt:()=>xe,wf:()=>we.wf,cK:()=>e.cK,D0:()=>tt.D0,ry:()=>tt.ry,Ld:()=>pe,wi:()=>e.wi,lV:()=>e.lV,P9:()=>hi,J1:()=>W,gz:()=>mr,OB:()=>lt,Vb:()=>pt,Mm:()=>sr,qq:()=>tt.qq,A0:()=>Kt,d3:()=>ar,J0:()=>Wt,y4:()=>Wr,Dm:()=>tt.Dm,MJ:()=>tt.MJ,wb:()=>tt.wb,_E:()=>e._E,Y9:()=>e.Y9,MQ:()=>qt,xz:()=>e.xz,pd:()=>x,FO:()=>tt.FO,tm:()=>Oe,OV:()=>fe,m1:()=>De,T8:()=>tt.T8,JU:()=>e.JU,eo:()=>g,ck:()=>Ke.ck,CB:()=>e.CB,VQ:()=>e.VQ,QJ:()=>$t,Bv:()=>we.Bv,O7:()=>we.O7,_Z:()=>we._Z,I0:()=>we.I0,Vj:()=>we.Vj,yA:()=>$e,C2:()=>ct,jn:()=>e.jn,a:()=>G,zW:()=>J,bI:()=>K,sG:()=>U,AL:()=>Ke.AL,Od:()=>e.Od,XY:()=>e.XY,tX:()=>e.tX,Kc:()=>E,wl:()=>e.wl,xv:()=>e.xv,Nu:()=>tt.Nu,iK:()=>e.iK,QW:()=>e.QW,Xp:()=>e.Xp,Hk:()=>B._H,RF:()=>e.RF,P8:()=>B.P8,dR:()=>e.dR,Wx:()=>T,wG:()=>m,E_:()=>Q,QZ:()=>L,ez:()=>O,Zy:()=>b,c3:()=>tt.c3,QS:()=>f.xv,a_:()=>f.a_,v$:()=>we.v$,vn:()=>we.vn} diff --git a/dylib/tests/webpatch-fixtures/gates.selecttool.js b/dylib/tests/webpatch-fixtures/gates.selecttool.js index b8643d5a..8ea2e773 100644 --- a/dylib/tests/webpatch-fixtures/gates.selecttool.js +++ b/dylib/tests/webpatch-fixtures/gates.selecttool.js @@ -7,3 +7,7 @@ ePlayBar,bInPage:!0,details:le,overview:We,stickyheader:Re,onGameInfoToggle:xe,o ,{})}),(0,h.we)("#GameList_Entry_Invalid_OSType")]}),(0,e.jsx)("div",{className:Q().WarningText,children:(0,e.jsxs)("div",{className:Q().WarningText2,children:[(0,h.we)("#GameList_Entry_Invalid_OSType2"),(0,e.jsx)("a",{href:"https://help.steampowered.com/faqs/view/5E0D-522A-4E62-B6EF",className:Q().WarningText3,children:(0,h.we)("#GameList_Entry_Invalid_OSType3 function I(f){return(0,e.jsxs)(a.XY,{label:(0,i.we)("#Settings_SteamPlay_SteamPlay"),children:[(0,e.jsx)(P,{}),(0,e.jsx)(c.G,{setting:"compat_show_all_tools",label:(0,i.we)("#Settings_SteamPlay_ShowAll"),feature:o.OK})]})}function W(f){return(0,e.jsx)(u.sh,{children:(0,e.jsx)(I,{})})}function C(f){return(0,e.jsxs)(a.XY,{label:(0,i.we)("#Settings_SteamPlay_SteamPlay"),children:[(0,e.jsx)(y,{details:f.details}),(0,e.jsx)(c.G,{setting:"compat_show_all_tools",label:(0,i.we)("#Settings_SteamPlay_ShowAll"),feature:o.OK})]})}}) display_name);return(0,r.uH)(S,(0,e.jsx)("b",{}))}}return(0,e.jsx)(e.Fragment,{})}function w(g){const{label:C,nAppId:P}=g,[D]=(0,h.VI)("compat_show_all_tools"),[z,S]=(0,l.cT)(P);if(z.isLoading||!z.data?.tools?.length)return null;let b=[];for(const T of z.data.aliases)b.push({data:T.alias,label:T.display_name});let O=null;for(const T of z.data.tools)T.is_active&&(O=T),!(!D&&!T.is_active& +function Z(k,A){return A=A||(0,o.OI)().GetDefaultTransport(),A==null?new Promise((M,H)=>{console.error("Transport Error: no transport is available for request"),H("Transport Error: no transport is available for request")}):A.SendMsg("CompatManager.GetCompatTools#1",(0,n.I8)(u,k),p,{ePrivilege:1,eClientExecutionSite:2})}O.GetCompatTools=Z; +function Q(k,A){return A=A||(0,o.OI)().GetDefaultTransport(),A==null?new Promise((M,H)=>{console.error("Transport Error: no transport is available for request"),H("Transport Error: no transport is available for request")}):A.SendMsg("CompatManager.SpecifyCompatTool#1",(0,n.I8)(f,k),p,{ePrivilege:1,eClientExecutionSite:2})}O.SendMsgSpecifyCompatTool=Q,O.NotifyStateChangedHandler={name:"CompatManager.NotifyStateChanged#1"} +const S=new z;function b(O){const m=I.useCallback(async()=>{const E=await j.GetCompatTools({appid:O});if(!E.BSuccess()){if(E.GetEResult()==e.EY)return null;throw new Error("Failed to fetch available compat tools")}return E.Body().toObject()},[O]),T=[D,"CompatTools",O??0],L=(0,y.I)({queryKey:T,queryFn:m}),Q=I.useCallback(E=>{j.SpecifyCompatTool({appid:O,tool_name:E})},[O]);return[L,Q]}}), +{Xg:()=>tt.Xg,Cl:()=>Xe,pF:()=>e.pF,Nv:()=>tt.Nv,nB:()=>e.nB,f3:()=>e.f3,a3:()=>e.a3,X8:()=>Be,$n:()=>e.$n,xh:()=>tt.xh,lD:()=>tt.lD,Yh:()=>e.Yh,SR:()=>tt.SR,VP:()=>e.VP,ou:()=>Kr,UC:()=>e.UC,U9:()=>e.U9,$S:()=>tt.$S,BC:()=>tt.BC,G5:()=>e.G5,lr:()=>e.lr,Ws:()=>Ve,pU:()=>Se,Mw:()=>we.Mw,rP:()=>v,Ce:()=>f.Ce,t6:()=>e.t6,ZU:()=>_e,m:()=>ke,aZ:()=>He,n4:()=>Ue,Gt:()=>xe,wf:()=>we.wf,cK:()=>e.cK,D0:()=>tt.D0,ry:()=>tt.ry,Ld:()=>pe,wi:()=>e.wi,lV:()=>e.lV,P9:()=>hi,J1:()=>W,gz:()=>mr,OB:()=>lt,Vb:()=>pt,Mm:()=>sr,qq:()=>tt.qq,A0:()=>Kt,d3:()=>ar,J0:()=>Wt,y4:()=>Wr,Dm:()=>tt.Dm,MJ:()=>tt.MJ,wb:()=>tt.wb,_E:()=>e._E,Y9:()=>e.Y9,MQ:()=>qt,xz:()=>e.xz,pd:()=>x,FO:()=>tt.FO,tm:()=>Oe,OV:()=>fe,m1:()=>De,T8:()=>tt.T8,JU:()=>e.JU,eo:()=>g,ck:()=>Ke.ck,CB:()=>e.CB,VQ:()=>e.VQ,QJ:()=>$t,Bv:()=>we.Bv,O7:()=>we.O7,_Z:()=>we._Z,I0:()=>we.I0,Vj:()=>we.Vj,yA:()=>$e,C2:()=>ct,jn:()=>e.jn,a:()=>G,zW:()=>J,bI:()=>K,sG:()=>U,AL:()=>Ke.AL,Od:()=>e.Od,XY:()=>e.XY,tX:()=>e.tX,Kc:()=>E,wl:()=>e.wl,xv:()=>e.xv,Nu:()=>tt.Nu,iK:()=>e.iK,QW:()=>e.QW,Xp:()=>e.Xp,Hk:()=>B._H,RF:()=>e.RF,P8:()=>B.P8,dR:()=>e.dR,Wx:()=>T,wG:()=>m,E_:()=>Q,QZ:()=>L,ez:()=>O,Zy:()=>b,c3:()=>tt.c3,QS:()=>f.xv,a_:()=>f.a_,v$:()=>we.v$,vn:()=>we.vn} diff --git a/dylib/util/buildid.h b/dylib/util/buildid.h new file mode 100644 index 00000000..be59b1a0 --- /dev/null +++ b/dylib/util/buildid.h @@ -0,0 +1,21 @@ +// CrossOver build IDs +#ifndef NOTPROTON_UTIL_BUILDID_H +#define NOTPROTON_UTIL_BUILDID_H + +#include +#include + +#define NP_BUILD_ID_MAX 63 + +static inline bool np_is_build_id(const char *s) { + size_t n = 0; + for (; s && s[n]; n++) { + char c = s[n]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '.' || c == '-')) + return false; + } + return n > 0 && n <= NP_BUILD_ID_MAX; +} + +#endif // NOTPROTON_UTIL_BUILDID_H