From c1a52ccb1c4536933f49bec41152a7832d5fb94c Mon Sep 17 00:00:00 2001 From: Marcus Shaw <189466376+marcshawco@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:39:11 -0800 Subject: [PATCH 1/2] Add Mac compatibility: per-game settings, badges, launch rules, store block Works out how each game should be run on this Mac and how likely it is to run, and shows it where Steam shows the Steam Deck's. - machine.c, tuning.c: read the Mac (chip, GPU cores, memory, macOS, display) and turn it into settings the defaults whitelist accepts. - game.c: read an installed game's files: word size, which Direct3D it loads, anti-cheat, launcher, engine, native macOS build. - verdict.c: a tier from the sessions the run script now records. - advice.c: Mac + game + history into settings, launch arguments, a badge and a reason. D3D12 goes to D3DMetal, D3D11 to DXMT, D3D9 to DXVK. - rules.c: launch rules from ~/Library/Application Support/notproton/rules, on the plugin grammar (new conf kind). - catalog.c: one pass over the library. Uses Valve's Steam Deck results from appinfo.vdf (appinfo --compat) for games that are not installed. - webpatch.c: badge on library tiles, stat on the game page, "Use Recommended" on the Compatibility page, two switches in Preferences. - store.c, store_page.js: the same on store and wishlist pages, through the web helper's debugger on 127.0.0.1:27101. Has its own switch. - compat_run.sh: records sessions, applies advice and rule arguments at launch after anything the user set. Also fixes two things found on the way: a DXMT_CONFIG with two clauses was dropped on its way to the page (is_setting_value refused ';'), and `make dobby` failed in a path with spaces. Co-Authored-By: Claude Fable 5.2 --- .github/workflows/app.yml | 7 + Makefile | 105 ++++- dylib/feats/advice.c | 183 +++++++++ dylib/feats/advice.h | 54 +++ dylib/feats/catalog.c | 473 +++++++++++++++++++++++ dylib/feats/catalog.h | 30 ++ dylib/feats/compat_run.sh | 79 +++- dylib/feats/compatpage.c | 15 + dylib/feats/compatsvc.c | 17 + dylib/feats/conf.c | 67 +++- dylib/feats/conf.h | 2 +- dylib/feats/game.c | 271 +++++++++++++ dylib/feats/game.h | 59 +++ dylib/feats/machine.c | 132 +++++++ dylib/feats/machine.h | 31 ++ dylib/feats/nativeplatform.c | 15 +- dylib/feats/nativeplatform.h | 3 + dylib/feats/prefixstate.c | 21 + dylib/feats/rules.c | 111 ++++++ dylib/feats/rules.h | 22 ++ dylib/feats/store.c | 354 +++++++++++++++++ dylib/feats/store.h | 50 +++ dylib/feats/store_page.js | 202 ++++++++++ dylib/feats/tuning.c | 103 +++++ dylib/feats/tuning.h | 19 + dylib/feats/verdict.c | 101 +++++ dylib/feats/verdict.h | 35 ++ dylib/feats/webpatch.c | 307 ++++++++++++++- dylib/feats/webpatch.h | 5 + dylib/hooks/hook_spawn.c | 20 +- dylib/tests/advice-check.c | 179 +++++++++ dylib/tests/buildcheck.sh | 32 ++ dylib/tests/catalog-check.c | 200 ++++++++++ dylib/tests/compatpage-check.c | 24 ++ dylib/tests/compatsvc-check.c | 6 + dylib/tests/envcheck.sh | 58 +++ dylib/tests/featcheck.c | 9 + dylib/tests/game-check.c | 160 ++++++++ dylib/tests/gatecheck.c | 9 + dylib/tests/panel-behavior/badge.js | 171 ++++++++ dylib/tests/panel-behavior/defaults.js | 38 ++ dylib/tests/panel-behavior/emit.c | 25 +- dylib/tests/panel-behavior/harness.js | 4 +- dylib/tests/panel-behavior/prefix-row.js | 46 +++ dylib/tests/panel-behavior/tuning.js | 80 ++++ dylib/tests/prefixstate-check.c | 3 +- dylib/tests/rules-check.c | 137 +++++++ dylib/tests/spawn-env.c | 4 + dylib/tests/store-check.c | 110 ++++++ dylib/tests/store-page.js | 128 ++++++ dylib/tests/tuning-check.c | 153 ++++++++ dylib/tests/verdict-check.c | 91 +++++ examples/rules/10-launch-rules.conf | 52 +++ examples/rules/README.md | 11 + helpers/appinfo.swift | 41 +- tools/store-badge/agent.js | 73 ++++ 56 files changed, 4705 insertions(+), 32 deletions(-) create mode 100644 dylib/feats/advice.c create mode 100644 dylib/feats/advice.h create mode 100644 dylib/feats/catalog.c create mode 100644 dylib/feats/catalog.h create mode 100644 dylib/feats/game.c create mode 100644 dylib/feats/game.h create mode 100644 dylib/feats/machine.c create mode 100644 dylib/feats/machine.h create mode 100644 dylib/feats/rules.c create mode 100644 dylib/feats/rules.h create mode 100644 dylib/feats/store.c create mode 100644 dylib/feats/store.h create mode 100644 dylib/feats/store_page.js create mode 100644 dylib/feats/tuning.c create mode 100644 dylib/feats/tuning.h create mode 100644 dylib/feats/verdict.c create mode 100644 dylib/feats/verdict.h create mode 100644 dylib/tests/advice-check.c create mode 100644 dylib/tests/catalog-check.c create mode 100644 dylib/tests/game-check.c create mode 100644 dylib/tests/panel-behavior/badge.js create mode 100644 dylib/tests/panel-behavior/tuning.js create mode 100644 dylib/tests/rules-check.c create mode 100644 dylib/tests/store-check.c create mode 100644 dylib/tests/store-page.js create mode 100644 dylib/tests/tuning-check.c create mode 100644 dylib/tests/verdict-check.c create mode 100644 examples/rules/10-launch-rules.conf create mode 100644 examples/rules/README.md create mode 100644 tools/store-badge/agent.js diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index da70a25f..afca3a7f 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -122,6 +122,13 @@ jobs: make -s compatsvc-check make -s prefixstate-check make -s defaults-check + make -s tuning-check + make -s verdict-check + make -s advice-check + make -s game-check + make -s rules-check + make -s catalog-check + make -s store-check make -s compatpage-check make -s defaults-drift make -s shortcutcheck diff --git a/Makefile b/Makefile index 14db835f..e55b19da 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ CFLAGS := -arch $(ARCH) -mmacosx-version-min=$(MIN_VER) $(AVAIL) \ LDFLAGS := -arch $(ARCH) -mmacosx-version-min=$(MIN_VER) \ -dynamiclib -install_name @rpath/notproton.dylib -FRAMEWORKS := -framework CoreFoundation -framework CoreGraphics +FRAMEWORKS := -framework CoreFoundation -framework CoreGraphics -framework IOKit DOBBY_DIR := build/dobby DOBBY_LIBS := $(DOBBY_DIR)/libdobby.a \ @@ -48,8 +48,16 @@ SRCS := \ dylib/feats/webui.c \ dylib/feats/compatsvc.c \ dylib/feats/defaults.c \ + dylib/feats/machine.c \ + dylib/feats/tuning.c \ dylib/feats/compatpage.c \ dylib/feats/prefixstate.c \ + dylib/feats/verdict.c \ + dylib/feats/game.c \ + dylib/feats/advice.c \ + dylib/feats/catalog.c \ + dylib/feats/rules.c \ + dylib/feats/store.c \ dylib/feats/usershortcuts.c \ dylib/feats/conf.c \ dylib/feats/webpatch.c \ @@ -78,7 +86,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 envpasscheck \ - compatsvc-check prefixstate-check defaults-check compatpage-check defaults-drift shortcutcheck scriptcheck envcheck buildcheck settingscheck routecheck bridgecheck gamedrivecheck launchercheck bundlecheck launch-shell activatecheck FORCE + compatsvc-check prefixstate-check defaults-check tuning-check verdict-check advice-check game-check rules-check catalog-check store-check compatpage-check defaults-drift shortcutcheck scriptcheck envcheck buildcheck settingscheck routecheck bridgecheck gamedrivecheck launchercheck bundlecheck launch-shell activatecheck FORCE APP_PAYLOAD := app/Sources/NotProtonApp/Resources/payload @@ -194,7 +202,7 @@ PREFIXSTATE_CHECK := $(OUT_DIR)/prefixstate-check prefixstate-check: @if [ ! -f dylib/tests/prefixstate-check.c ]; then $(call SKIP,prefixstate-check,dylib/tests/prefixstate-check.c); exit 0; fi; \ mkdir -p $(OUT_DIR) && \ - $(TEST_CC) -o $(PREFIXSTATE_CHECK) dylib/tests/prefixstate-check.c && \ + $(TEST_CC) -o $(PREFIXSTATE_CHECK) dylib/tests/prefixstate-check.c dylib/feats/verdict.c && \ $(PREFIXSTATE_CHECK) COMPATSVC_CHECK := $(OUT_DIR)/compatsvc-check @@ -215,6 +223,84 @@ $(DEFAULTS_CHECK): dylib/tests/defaults-check.c dylib/feats/defaults.c dylib/fea defaults-check: $(DEFAULTS_CHECK) @$(DEFAULTS_CHECK) +STORE_CHECK := $(OUT_DIR)/store-check + +$(STORE_CHECK): dylib/tests/store-check.c dylib/feats/store.c dylib/feats/store.h $(STORE_PAGE_H) \ + dylib/util/file.c vendor/cJSON.c + @mkdir -p $(dir $@) + $(TEST_CC) -Ivendor -I$(GENERATED_DIR) -o $@ dylib/tests/store-check.c dylib/util/file.c vendor/cJSON.c + +store-check: $(STORE_CHECK) + @$(STORE_CHECK) + @if command -v node >/dev/null 2>&1; then node dylib/tests/store-page.js; \ + else echo "==> store-page: node not found, skipped"; fi + +CATALOG_CHECK := $(OUT_DIR)/catalog-check + +$(CATALOG_CHECK): dylib/tests/catalog-check.c dylib/feats/catalog.c dylib/feats/catalog.h \ + dylib/feats/advice.c dylib/feats/rules.c dylib/feats/game.c \ + dylib/feats/machine.c dylib/feats/tuning.c dylib/feats/verdict.c \ + dylib/feats/conf.c dylib/util/pe.c dylib/util/file.c + @mkdir -p $(dir $@) + $(TEST_CC) -o $@ dylib/tests/catalog-check.c dylib/feats/advice.c dylib/feats/rules.c \ + dylib/feats/game.c dylib/feats/machine.c dylib/feats/tuning.c dylib/feats/verdict.c \ + dylib/feats/conf.c dylib/util/pe.c dylib/util/file.c dylib/util/log.c \ + -framework CoreGraphics -framework CoreFoundation -framework IOKit + +catalog-check: $(CATALOG_CHECK) + @$(CATALOG_CHECK) + +RULES_CHECK := $(OUT_DIR)/rules-check + +$(RULES_CHECK): dylib/tests/rules-check.c dylib/feats/rules.c dylib/feats/rules.h \ + dylib/feats/conf.c dylib/feats/conf.h dylib/feats/game.h + @mkdir -p $(dir $@) + $(TEST_CC) -o $@ dylib/tests/rules-check.c dylib/feats/conf.c + +rules-check: $(RULES_CHECK) + @$(RULES_CHECK) + +GAME_CHECK := $(OUT_DIR)/game-check + +$(GAME_CHECK): dylib/tests/game-check.c dylib/feats/game.c dylib/feats/game.h \ + dylib/util/pe.c dylib/util/file.c + @mkdir -p $(dir $@) + $(TEST_CC) -o $@ dylib/tests/game-check.c dylib/util/pe.c dylib/util/file.c + +game-check: $(GAME_CHECK) + @$(GAME_CHECK) + +ADVICE_CHECK := $(OUT_DIR)/advice-check + +$(ADVICE_CHECK): dylib/tests/advice-check.c dylib/feats/advice.c dylib/feats/advice.h \ + dylib/feats/tuning.c dylib/feats/verdict.c dylib/feats/game.c dylib/feats/game.h + @mkdir -p $(dir $@) + $(TEST_CC) -o $@ dylib/tests/advice-check.c dylib/feats/tuning.c dylib/feats/verdict.c \ + dylib/feats/game.c dylib/util/pe.c dylib/util/file.c + +advice-check: $(ADVICE_CHECK) + @$(ADVICE_CHECK) + +VERDICT_CHECK := $(OUT_DIR)/verdict-check + +$(VERDICT_CHECK): dylib/tests/verdict-check.c dylib/feats/verdict.c dylib/feats/verdict.h + @mkdir -p $(dir $@) + $(TEST_CC) -o $@ dylib/tests/verdict-check.c + +verdict-check: $(VERDICT_CHECK) + @$(VERDICT_CHECK) + +TUNING_CHECK := $(OUT_DIR)/tuning-check + +$(TUNING_CHECK): dylib/tests/tuning-check.c dylib/feats/tuning.c dylib/feats/tuning.h \ + dylib/feats/machine.h 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/tuning-check.c dylib/util/file.c + +tuning-check: $(TUNING_CHECK) + @$(TUNING_CHECK) + COMPATPAGE_CHECK := $(OUT_DIR)/compatpage-check compatpage-check: @@ -500,6 +586,8 @@ panel-behavior: node $(PANEL_TESTS)/behavior.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/switching.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/launch-options.js $(OUT_DIR)/panel-emit && \ + node $(PANEL_TESTS)/tuning.js $(OUT_DIR)/panel-emit && \ + node $(PANEL_TESTS)/badge.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/shell.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/migration.js $(OUT_DIR)/panel-emit && \ node $(PANEL_TESTS)/localization.js $(OUT_DIR)/panel-emit && \ @@ -544,6 +632,15 @@ $(RUN_SCRIPT_H): dylib/feats/compat_run.sh dylib/embed_script.py $(OUT_DIR)/dylib/feats/compat.o: $(RUN_SCRIPT_H) +# The script the store's pages are given, carried in the dylib the same way. +STORE_PAGE_H := $(GENERATED_DIR)/store_page.h + +$(STORE_PAGE_H): dylib/feats/store_page.js dylib/embed_script.py + @mkdir -p $(dir $@) + python3 dylib/embed_script.py $< $@ np_store_page_js + +$(OUT_DIR)/dylib/feats/store.o: $(STORE_PAGE_H) + SUPPORT_DIR := $(HOME)/Library/Application Support/notproton OVERLAY_SHIM_SRC := overlay-shim/overlay_shim.m OVERLAY_SHIM_ARM := $(OUT_DIR)/overlay-shim.arm64.dylib @@ -643,7 +740,7 @@ helpers-install: $(ICONMAKER) $(APPINFO) dobby: @mkdir -p $(DOBBY_DIR) - cd $(DOBBY_DIR) && cmake $(CURDIR)/vendor/dobby \ + cd $(DOBBY_DIR) && cmake "$(CURDIR)/vendor/dobby" \ -DCMAKE_OSX_ARCHITECTURES=arm64 \ -DCMAKE_OSX_DEPLOYMENT_TARGET=$(MIN_VER) \ -DDOBBY_DEBUG=OFF \ diff --git a/dylib/feats/advice.c b/dylib/feats/advice.c new file mode 100644 index 00000000..3bb4a558 --- /dev/null +++ b/dylib/feats/advice.c @@ -0,0 +1,183 @@ +// Mac, game and history in; settings, launch arguments and a badge out. +// +// The Mac decides how much work the GPU is given (tuning.c). The game decides which +// translation layer can carry it at all, which is what the rules below are about. The +// history decides whether this Mac has actually seen it run, which is the only thing that +// earns a green badge: everything else here is a reason something might not work, never a +// promise that it will. +#include "advice.h" +#include "tuning.h" + +#include +#include + +// DXMT translates Direct3D 11. D3DMetal is what carries Direct3D 12, and CrossOver only +// builds it for the Rosetta runner, so a D3D12 game on FEX has nothing to run on. +#define NP_D3D12_NEEDS "d3dmetal" + +static void set_line(char *text, size_t size, const char *key, const char *value) { + char want[64]; + int n = snprintf(want, sizeof want, "%s=", key); + if (n < 0 || (size_t)n >= sizeof want) return; + // Replace the line the Mac's own set wrote, so the game has the last word on it. + for (char *at = text; *at; ) { + char *nl = strchr(at, '\n'); + size_t len = nl ? (size_t)(nl - at) : strlen(at); + if (len > (size_t)n && strncmp(at, want, (size_t)n) == 0) { + char rest[NP_DEFAULTS_TEXT_MAX + 1]; + snprintf(rest, sizeof rest, "%s", nl ? nl + 1 : ""); + if ((size_t)snprintf(at, size - (size_t)(at - text), "%s%s\n%s", want, value, rest) + >= size - (size_t)(at - text)) + *at = '\0'; + return; + } + at += len + (nl ? 1 : 0); + } + size_t at = strlen(text); + snprintf(text + at, size - at, "%s%s\n", want, value); +} + +static void add_option(np_advice_t *a, const char *option) { + size_t at = strlen(a->options); + snprintf(a->options + at, sizeof a->options - at, "%s%s", at ? " " : "", option); +} + +static void say(np_advice_t *a, const char *text) { + snprintf(a->reason, sizeof a->reason, "%s", text); +} + +// Which translation layer the game's own graphics API needs. Returns NULL to leave the +// Mac's choice standing, which is what a game that asks for nothing in particular gets. +static const char *backend_for(const np_game_t *g) { + if (!g || !g->scanned || !g->gfx) return NULL; + // Vulkan and OpenGL are not Direct3D: nothing here translates them, and CrossOver's + // own handling is better than a backend picked on a guess. + if (!(g->gfx & (NP_GFX_D3D9 | NP_GFX_D3D11 | NP_GFX_D3D12))) return NULL; + if (g->gfx & NP_GFX_D3D12) return NP_D3D12_NEEDS; + if (g->gfx & NP_GFX_D3D11) return "dxmt"; + // Direct3D 9 predates DXMT, and DXVK has carried it for years. + return "dxvk"; +} + +void np_advice(const np_machine_t *machine, const np_game_t *game, const np_verdict_t *history, + uint32_t runner, np_advice_t *out) { + if (!out) return; + memset(out, 0, sizeof *out); + if (!np_tuning_recommend(machine, runner, out->settings, sizeof out->settings)) + out->settings[0] = '\0'; + + const int fex = runner == NP_DEFAULTS_FEX; + const int scanned = game && game->scanned; + const unsigned gfx = scanned ? game->gfx : 0; + const char *backend = backend_for(game); + const int d3d12_on_fex = fex && backend && strcmp(backend, NP_D3D12_NEEDS) == 0; + + if (backend && !d3d12_on_fex) { + set_line(out->settings, sizeof out->settings, "CX_GRAPHICS_BACKEND", backend); + // MetalFX upscaling and the frame cap are DXMT's own settings, so a game on + // another backend has no use for the lines the Mac's set wrote for them. + if (strcmp(backend, "dxmt") != 0) { + set_line(out->settings, sizeof out->settings, "DXMT_METALFX_SPATIAL_SWAPCHAIN", "0"); + char *at = strstr(out->settings, "DXMT_CONFIG="); + if (at && (at == out->settings || at[-1] == '\n')) { + char *nl = strchr(at, '\n'); + memmove(at, nl ? nl + 1 : "", nl ? strlen(nl + 1) + 1 : 1); + } + } + } + + // Unity's Direct3D 11 renderer is the same game, so a Unity title that came up on + // D3D12 is asked for the path that DXMT can actually carry. + if (scanned && game->engine == NP_ENGINE_UNITY && (gfx & NP_GFX_D3D12)) { + add_option(out, "-force-d3d11"); + set_line(out->settings, sizeof out->settings, "CX_GRAPHICS_BACKEND", "dxmt"); + } + + // The badge, most serious first. Anything that cannot work here outranks anything + // this Mac has seen work. + if (scanned && game->anticheat != NP_AC_NONE) { + out->badge = NP_BADGE_BLOCKED; + snprintf(out->reason, sizeof out->reason, + "This game uses %s, which does not run under CrossOver on macOS.", + np_game_anticheat_name(game->anticheat)); + } else if (history && history->tier == NP_VERDICT_BROKEN) { + out->badge = NP_BADGE_BLOCKED; + snprintf(out->reason, sizeof out->reason, + "This game has crashed every time it was started on this Mac (%d of %d).", + history->failed, history->failed); + } else if (d3d12_on_fex) { + out->badge = NP_BADGE_ISSUES; + say(out, "This game needs Direct3D 12, which only the Rosetta runner has. " + "Switch its compatibility tool away from FEX."); + } else if (scanned && game->launcher != NP_LAUNCHER_NONE) { + out->badge = NP_BADGE_ISSUES; + snprintf(out->reason, sizeof out->reason, + "This game starts through %s, which may need setting up inside the prefix " + "before the game itself will run.", np_game_launcher_name(game->launcher)); + } else if (history && history->tier == NP_VERDICT_PLAYABLE && history->failed) { + out->badge = NP_BADGE_ISSUES; + snprintf(out->reason, sizeof out->reason, + "This game runs on this Mac, but %d of its %d sessions here crashed.", + history->failed, history->failed + history->healthy); + } else if (history && history->tier == NP_VERDICT_VERIFIED) { + out->badge = NP_BADGE_GOOD; + snprintf(out->reason, sizeof out->reason, + "This game has run on this Mac %d times without crashing.", history->healthy); + } else if (scanned && game->native) { + out->badge = NP_BADGE_GOOD; + say(out, "This game has a native macOS version, which Steam runs without CrossOver."); + } else { + out->badge = NP_BADGE_UNKNOWN; + if (scanned && gfx) + snprintf(out->reason, sizeof out->reason, + "This game has not run on this Mac yet. It is set up for %s.", + gfx & NP_GFX_D3D12 ? "Direct3D 12, through D3DMetal" + : gfx & NP_GFX_D3D11 ? "Direct3D 11, through DXMT" + : gfx & NP_GFX_D3D9 ? "Direct3D 9, through DXVK" + : gfx & NP_GFX_VULKAN ? "Vulkan, through MoltenVK" : "OpenGL"); + else + say(out, "This game has not run on this Mac yet."); + } +} + +np_badge_t np_advice_from_valve(int mac, int deck, int anticheat, char *reason, size_t size) { + // An anti-cheat that refuses Proton refuses Wine whoever built it, so this is the one + // of Valve's results that carries over to CrossOver unchanged, and it outranks a + // macOS build the page still lists. + if (anticheat) { + snprintf(reason, size, "Valve's testing found this game's anti-cheat does not support " + "Proton, so it will not run under CrossOver either."); + return NP_BADGE_BLOCKED; + } + if (mac) { + snprintf(reason, size, "This game has a native macOS version, which Steam runs without CrossOver."); + return NP_BADGE_GOOD; + } + switch (deck) { + case NP_DECK_VERIFIED: + snprintf(reason, size, "Verified on Steam Deck through Proton, which is a good sign for " + "CrossOver. It has not run on this Mac yet."); + return NP_BADGE_LIKELY; + case NP_DECK_PLAYABLE: + snprintf(reason, size, "Playable on Steam Deck through Proton, which is a good sign for " + "CrossOver. It has not run on this Mac yet."); + return NP_BADGE_LIKELY; + case NP_DECK_UNSUPPORTED: + snprintf(reason, size, "Valve marks this game unsupported on Steam Deck, so it may not " + "run under CrossOver. It has not run on this Mac yet."); + return NP_BADGE_ISSUES; + default: + snprintf(reason, size, "This game has not run on this Mac yet, and Valve has not tested it."); + return NP_BADGE_UNKNOWN; + } +} + +const char *np_badge_name(np_badge_t badge) { + switch (badge) { + case NP_BADGE_LIKELY: return "likely"; + case NP_BADGE_GOOD: return "good"; + case NP_BADGE_ISSUES: return "issues"; + case NP_BADGE_BLOCKED: return "blocked"; + default: return "unknown"; + } +} diff --git a/dylib/feats/advice.h b/dylib/feats/advice.h new file mode 100644 index 00000000..cecfa128 --- /dev/null +++ b/dylib/feats/advice.h @@ -0,0 +1,54 @@ +// One answer for one game on this Mac: what to run it with, and what to say about it. +#ifndef NOTPROTON_FEATS_ADVICE_H +#define NOTPROTON_FEATS_ADVICE_H + +#include "defaults.h" +#include "game.h" +#include "machine.h" +#include "verdict.h" + +#include + +// What the badge on the game's tile says. The colours are the Steam Deck's, read for a +// Mac: green is a game this Mac has actually run well, red is one that cannot work here, +// yellow is one with something in the way, and white is one nothing is known about yet. +typedef enum { + NP_BADGE_UNKNOWN = 0, + NP_BADGE_GOOD, + NP_BADGE_ISSUES, + NP_BADGE_BLOCKED, + // Not run here, but Valve's testing passed it under Proton, which is the best sign + // there is for a game nobody has downloaded yet. Kept apart from GOOD on purpose: + // green is what this Mac has seen, and this is only what a Steam Deck has. + NP_BADGE_LIKELY, +} np_badge_t; + +#define NP_ADVICE_OPTIONS_MAX 192 +#define NP_ADVICE_REASON_MAX 256 + +typedef struct { + char settings[NP_DEFAULTS_TEXT_MAX + 1]; // KEY=VALUE lines, one per line + char options[NP_ADVICE_OPTIONS_MAX]; // arguments to pass the game itself + np_badge_t badge; + char reason[NP_ADVICE_REASON_MAX]; // one sentence, for the tooltip +} np_advice_t; + +// `game` and `history` may be NULL, which is a game that has not been looked at: the +// settings still come back, and the badge says nothing rather than guessing. +void np_advice(const np_machine_t *machine, const np_game_t *game, const np_verdict_t *history, + uint32_t runner, np_advice_t *out); + +const char *np_badge_name(np_badge_t badge); + +// Valve's Steam Deck categories, as appinfo.vdf numbers them. +#define NP_DECK_UNKNOWN 0 +#define NP_DECK_UNSUPPORTED 1 +#define NP_DECK_PLAYABLE 2 +#define NP_DECK_VERIFIED 3 + +// A verdict for a game that is not installed, from what Steam already knows about it: +// whether it lists a macOS build, its Deck category, and whether Valve's tests failed it +// for its anti-cheat. Writes the reason and returns the badge. +np_badge_t np_advice_from_valve(int mac, int deck, int anticheat, char *reason, size_t size); + +#endif // NOTPROTON_FEATS_ADVICE_H diff --git a/dylib/feats/catalog.c b/dylib/feats/catalog.c new file mode 100644 index 00000000..d0da344a --- /dev/null +++ b/dylib/feats/catalog.c @@ -0,0 +1,473 @@ +// A badge for every installed game. +// +// Two halves that never wait on each other: the library asks for what was saved last +// time, and a thread behind it walks the installed games and saves a fresh set. A game +// installed since the last pass wears no badge until the next one, which is the honest +// answer anyway -- nothing has looked at it yet. +#include "catalog.h" +#include "advice.h" +#include "game.h" +#include "machine.h" +#include "nativeplatform.h" +#include "rules.h" +#include "store.h" +#include "verdict.h" +#include "../util/file.h" +#include "../util/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define NP_CATALOG_LIBRARIES 16 +#define NP_CATALOG_APPS 4096 +#define NP_CATALOG_FILE "badges" +// Present when someone has turned the badges off; the library is then told to draw none. +#define NP_CATALOG_OFF_FILE "no-mac-badges" +// How long the library waits for a fresh pass before settling for the last one. +#define NP_CATALOG_WAIT_MS 2500 +// Where anyone can drop a rule for a game, read the same way plugins are. +#define NP_CATALOG_RULES_DIR "Library/Application Support/notproton/rules" +// A reason is one sentence; the file is one line per game. +#define NP_CATALOG_LINE (NP_ADVICE_REASON_MAX + 64) + +static const char *vdf_value(const char *line, const char *key, char *out, size_t size) { + const char *at = strstr(line, key); + if (!at) return NULL; + at = strchr(at + strlen(key), '"'); // opening quote of the value + if (!at) return NULL; + const char *end = strchr(++at, '"'); + if (!end) return NULL; + size_t n = (size_t)(end - at); + if (n >= size) return NULL; + memcpy(out, at, n); + out[n] = '\0'; + return out; +} + +typedef struct { + char paths[NP_CATALOG_LIBRARIES][PATH_MAX]; + int count; +} libraries_t; + +static void read_libraries(const char *steam_root, libraries_t *libs) { + libs->count = 0; + snprintf(libs->paths[libs->count++], PATH_MAX, "%s", steam_root); + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/steamapps/libraryfolders.vdf", steam_root); + size_t len = 0; + uint8_t *text = np_read_file_max(path, 1u << 20, 0, &len); + if (!text) return; + char line[2048]; + size_t at = 0; + while (at < len && libs->count < NP_CATALOG_LIBRARIES) { + size_t n = 0; + while (at + n < len && text[at + n] != '\n' && n < sizeof line - 1) n++; + memcpy(line, text + at, n); + line[n] = '\0'; + at += n + 1; + char value[PATH_MAX]; + if (!vdf_value(line, "\"path\"", value, sizeof value)) continue; + // Steam writes its escapes doubled; a path is the one thing here that carries them. + char *w = value; + for (const char *r = value; *r; r++) { + if (*r == '\\' && r[1]) r++; + *w++ = *r; + } + *w = '\0'; + int seen = 0; + for (int i = 0; i < libs->count; i++) seen |= strcmp(libs->paths[i], value) == 0; + if (!seen && value[0] == '/') snprintf(libs->paths[libs->count++], PATH_MAX, "%s", value); + } + free(text); +} + +typedef struct { + unsigned appid; + char install[PATH_MAX]; // steamapps/common/ + char compatdata[PATH_MAX]; + char stamp[64]; // the manifest's build, so a patched game is scanned again +} app_t; + +static int read_manifest(const char *steamapps, const char *name, app_t *app) { + unsigned appid = 0; + if (sscanf(name, "appmanifest_%u.acf", &appid) != 1 || !appid) return 0; + char path[PATH_MAX]; + if ((size_t)snprintf(path, sizeof path, "%s/%s", steamapps, name) >= sizeof path) return 0; + size_t len = 0; + uint8_t *text = np_read_file_max(path, 1u << 20, 0, &len); + if (!text) return 0; + + char dir[PATH_MAX] = "", build[64] = "", updated[64] = ""; + char line[2048]; + size_t at = 0; + while (at < len) { + size_t n = 0; + while (at + n < len && text[at + n] != '\n' && n < sizeof line - 1) n++; + memcpy(line, text + at, n); + line[n] = '\0'; + at += n + 1; + if (!dir[0]) vdf_value(line, "\"installdir\"", dir, sizeof dir); + if (!build[0]) vdf_value(line, "\"buildid\"", build, sizeof build); + if (!updated[0]) vdf_value(line, "\"LastUpdated\"", updated, sizeof updated); + } + free(text); + if (!dir[0]) return 0; + + app->appid = appid; + snprintf(app->stamp, sizeof app->stamp, "%s-%s", build[0] ? build : "0", + updated[0] ? updated : "0"); + if ((size_t)snprintf(app->install, sizeof app->install, "%s/common/%s", steamapps, dir) + >= sizeof app->install) return 0; + snprintf(app->compatdata, sizeof app->compatdata, "%s/compatdata/%u", steamapps, appid); + return 1; +} + +static void catalog_path(char *buf, size_t size) { + const char *home = np_home_dir(); + np_support_path(buf, size, home ? home : "", NP_CATALOG_FILE); +} + +// The scan of one game, kept beside its prefix so a game that has not changed is never +// walked twice. The stamp is the manifest's build, so an update costs one fresh scan. +static int cached_scan(const app_t *app, np_game_t *game) { + char path[PATH_MAX], want[64]; + snprintf(path, sizeof path, "%s/notproton-scan", app->compatdata); + size_t len = 0; + uint8_t *text = np_read_file_max(path, 4096, 0, &len); + if (!text) return 0; + int ok = 0; + unsigned gfx = 0; + int bits = 0, native = 0, ac = 0, launcher = 0, engine = 0; + char stamp[64] = ""; + if (sscanf((const char *)text, "1\n%63s\n%d\n%u\n%d\n%d\n%d\n%d", + stamp, &bits, &gfx, &native, &ac, &launcher, &engine) == 7) { + snprintf(want, sizeof want, "%s", app->stamp); + if (strcmp(stamp, want) == 0) { + memset(game, 0, sizeof *game); + game->scanned = 1; + game->bits = bits; + game->gfx = gfx; + game->native = native; + game->anticheat = (np_anticheat_t)ac; + game->launcher = (np_launcher_t)launcher; + game->engine = (np_engine_t)engine; + ok = 1; + } + } + free(text); + return ok; +} + +static void save_scan(const app_t *app, const np_game_t *game) { + char path[PATH_MAX], tmp[PATH_MAX]; + struct stat sb; + if (stat(app->compatdata, &sb) != 0 || !S_ISDIR(sb.st_mode)) return; + snprintf(path, sizeof path, "%s/notproton-scan", app->compatdata); + snprintf(tmp, sizeof tmp, "%s.new", path); + FILE *f = fopen(tmp, "w"); + if (!f) return; + fprintf(f, "1\n%s\n%d\n%u\n%d\n%d\n%d\n%d\n", app->stamp, game->bits, game->gfx, + game->native, (int)game->anticheat, (int)game->launcher, (int)game->engine); + int ok = fflush(f) == 0; + fclose(f); + if (!ok || rename(tmp, path) != 0) unlink(tmp); +} + +// What the run script reads at launch to set up a game it has been told nothing about: +// the settings in one file, the words to pass the game in another. +static void save_beside(const app_t *app, const char *name, const char *body) { + char path[PATH_MAX], tmp[PATH_MAX]; + struct stat sb; + if (stat(app->compatdata, &sb) != 0 || !S_ISDIR(sb.st_mode)) return; + snprintf(path, sizeof path, "%s/%s", app->compatdata, name); + if (!body || !*body) { unlink(path); return; } + snprintf(tmp, sizeof tmp, "%s.new", path); + FILE *f = fopen(tmp, "w"); + if (!f) return; + fputs(body, f); + int ok = fflush(f) == 0; + fclose(f); + if (!ok || rename(tmp, path) != 0) unlink(tmp); +} + +static int js_safe(const char *s) { + for (; *s; s++) + if ((unsigned char)*s < 0x20 || *s == '"' || *s == '\\') return 0; + return 1; +} + +// What Steam already knows about each game from Valve's own testing, which is all there +// is to go on for one that is not installed. +typedef struct { unsigned appid; unsigned char mac, deck, anticheat, seen; } valve_t; + +static size_t read_valve(valve_t **out) { + *out = NULL; + char *text = np_nativeplatform_compat(); + if (!text) return 0; + size_t cap = 256, n = 0; + valve_t *rows = malloc(cap * sizeof *rows); + for (const char *at = text; rows && *at; ) { + unsigned appid = 0, mac = 0, deck = 0, anticheat = 0; + int used = 0; + if (sscanf(at, "%u %u %u %u%n", &appid, &mac, &deck, &anticheat, &used) == 4 + && appid && mac <= 1 && deck <= 3 && anticheat <= 1) { + if (n == cap) { + valve_t *more = realloc(rows, (cap *= 2) * sizeof *rows); + if (!more) break; + rows = more; + } + rows[n++] = (valve_t){ appid, (unsigned char)mac, (unsigned char)deck, + (unsigned char)anticheat, 0 }; + } + const char *nl = strchr(at, '\n'); + if (!nl) break; + at = nl + 1; + } + free(text); + *out = rows; + return rows ? n : 0; +} + +static valve_t *find_valve(valve_t *rows, size_t n, unsigned appid) { + for (size_t i = 0; i < n; i++) + if (rows[i].appid == appid) return &rows[i]; + return NULL; +} + +// One pass at a time. A refresh asked for while one is running is the same pass twice +// over the same files, writing the same scratch file, so the background one stands down +// and a caller that asked outright waits its turn. +static pthread_mutex_t g_pass = PTHREAD_MUTEX_INITIALIZER; + +static int catalog_pass(const char *steam_root); + +int np_catalog_refresh(const char *steam_root) { + if (!steam_root || !*steam_root) return 0; + pthread_mutex_lock(&g_pass); + int seen = catalog_pass(steam_root); + pthread_mutex_unlock(&g_pass); + return seen; +} + +static int catalog_pass(const char *steam_root) { + libraries_t libs; + read_libraries(steam_root, &libs); + + char path[PATH_MAX], tmp[PATH_MAX]; + catalog_path(path, sizeof path); + snprintf(tmp, sizeof tmp, "%s.new", path); + FILE *out = fopen(tmp, "w"); + if (!out) { + NP_WARN("catalog: cannot write the badges, the library keeps the last set"); + return 0; + } + const np_machine_t *machine = np_machine(); + valve_t *valve = NULL; + size_t valve_count = read_valve(&valve); + char rules_dir[PATH_MAX]; + const char *home = np_home_dir(); + snprintf(rules_dir, sizeof rules_dir, "%s/%s", home ? home : "", NP_CATALOG_RULES_DIR); + int seen = 0; + for (int i = 0; i < libs.count && seen < NP_CATALOG_APPS; i++) { + char steamapps[PATH_MAX]; + snprintf(steamapps, sizeof steamapps, "%s/steamapps", libs.paths[i]); + DIR *d = opendir(steamapps); + if (!d) continue; + for (struct dirent *e; (e = readdir(d)) && seen < NP_CATALOG_APPS; ) { + app_t app; + if (strncmp(e->d_name, "appmanifest_", 12) != 0) continue; + if (!read_manifest(steamapps, e->d_name, &app)) continue; + seen++; + + np_game_t game; + if (!cached_scan(&app, &game)) { + np_game_scan(app.install, &game); + save_scan(&app, &game); + } + np_verdict_t history; + np_verdict_read(app.compatdata, &history); + np_advice_t advice; + // The badge is about the game, not about a runner, so it is worked out for + // the one every Mac has. + np_advice(machine, &game, &history, NP_DEFAULTS_ROSETTA, &advice); + + // Rules from the support folder come after, since a rule is someone saying + // something about one game that nothing here could have worked out. + np_rules_t rules; + np_rules_apply(rules_dir, NULL, app.appid, &game, &rules); + for (const char *at = rules.options; *at; ) { + const char *sp = strchr(at, ' '); + size_t len = sp ? (size_t)(sp - at) : strlen(at); + size_t have = strlen(advice.options); + if (have + len + 2 < sizeof advice.options) + snprintf(advice.options + have, sizeof advice.options - have, "%s%.*s", + have ? " " : "", (int)len, at); + at += len + (sp ? 1 : 0); + } + if (rules.note[0]) { + size_t have = strlen(advice.reason); + snprintf(advice.reason + have, sizeof advice.reason - have, "%s%s", + have ? " " : "", rules.note); + } + save_beside(&app, "notproton-advice", advice.settings); + save_beside(&app, "notproton-advice-args", advice.options); + + // Valve's testing fills in what the files could not say. An anti-cheat that + // failed Proton blocks the game whatever its folder looked like, and a game + // nothing here has an opinion on takes Valve's. + valve_t *known = find_valve(valve, valve_count, app.appid); + if (known) { + known->seen = 1; + char reason[NP_ADVICE_REASON_MAX]; + np_badge_t theirs = np_advice_from_valve(known->mac && game.native, known->deck, + known->anticheat, reason, sizeof reason); + if ((known->anticheat && advice.badge != NP_BADGE_BLOCKED) + || (advice.badge == NP_BADGE_UNKNOWN && theirs != NP_BADGE_UNKNOWN)) { + advice.badge = theirs; + snprintf(advice.reason, sizeof advice.reason, "%s", reason); + } + } + // Every installed game gets a line, "nothing is known yet" included: that + // is a badge of its own, the way the Deck's unknown is. + if (!game.scanned) continue; + if (!js_safe(advice.reason)) continue; + fprintf(out, "%u %s %s\n", app.appid, np_badge_name(advice.badge), advice.reason); + } + closedir(d); + } + // Every other game the client knows about: owned, wished for or merely looked at, + // and none of them on disk. Finding out whether a game will run should not take + // downloading it first. + for (size_t i = 0; i < valve_count; i++) { + if (valve[i].seen) continue; + char reason[NP_ADVICE_REASON_MAX]; + np_badge_t badge = np_advice_from_valve(valve[i].mac, valve[i].deck, valve[i].anticheat, + reason, sizeof reason); + if (js_safe(reason)) fprintf(out, "%u %s %s\n", valve[i].appid, np_badge_name(badge), reason); + } + free(valve); + int ok = fflush(out) == 0; + fclose(out); + if (!ok || rename(tmp, path) != 0) { + unlink(tmp); + NP_WARN("catalog: the badges could not be saved"); + return seen; + } + NP_LOG("catalog: %d installed game(s) looked at", seen); + return seen; +} + +typedef struct { char root[PATH_MAX]; } refresh_arg_t; + +// Counts the passes that have finished, so a caller can tell a fresh set from last time's. +static volatile int g_passes; + +static void *refresh_thread(void *p) { + refresh_arg_t *arg = p; + if (pthread_mutex_trylock(&g_pass) == 0) { + catalog_pass(arg->root); + pthread_mutex_unlock(&g_pass); + g_passes++; + } + free(arg); + return NULL; +} + +void np_catalog_refresh_async(const char *steam_root) { + if (!steam_root || !*steam_root) return; + refresh_arg_t *arg = calloc(1, sizeof *arg); + if (!arg) return; + snprintf(arg->root, sizeof arg->root, "%s", steam_root); + pthread_t t; + pthread_attr_t attr; + // Nobody waits for it: the library already has last pass's badges. + if (pthread_attr_init(&attr) != 0) { free(arg); return; } + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + if (pthread_create(&t, &attr, refresh_thread, arg) != 0) free(arg); + pthread_attr_destroy(&attr); +} + +// The saved badges as one JavaScript object, {appid:["tier","reason"],...}, or NULL when +// there are none. Both the library and the store's pages are handed this. +char *np_catalog_map(void) { + char path[PATH_MAX]; + catalog_path(path, sizeof path); + size_t len = 0; + uint8_t *text = np_read_file_max(path, 1u << 20, 0, &len); + if (!text) return NULL; + size_t cap = len * 2 + 16; + char *js = malloc(cap); + if (!js) { free(text); return NULL; } + size_t at = (size_t)snprintf(js, cap, "{"); + int first = 1; + for (size_t i = 0; i < len; ) { + char line[NP_CATALOG_LINE]; + size_t n = 0; + while (i + n < len && text[i + n] != '\n' && n < sizeof line - 1) n++; + memcpy(line, text + i, n); + line[n] = '\0'; + i += n + 1; + unsigned appid = 0; + char tier[16] = ""; + int reason_at = 0; + if (sscanf(line, "%u %15s %n", &appid, tier, &reason_at) < 2 || !appid || !reason_at) continue; + const char *reason = line + reason_at; + if (!js_safe(reason) || !js_safe(tier)) continue; + int wrote = snprintf(js + at, cap - at, "%s%u:[\"%s\",\"%s\"]", first ? "" : ",", + appid, tier, reason); + if (wrote < 0 || (size_t)wrote >= cap - at - 2) break; + at += (size_t)wrote; + first = 0; + } + free(text); + if (first) { free(js); return NULL; } // nothing to say, so nothing is injected + snprintf(js + at, cap - at, "}"); + return js; +} + +char *np_catalog_javascript(const char *steam_root) { + // A pass with every scan already kept is a helper run and a few small files, so the + // library is given a moment for a fresh set before it is handed last time's. Without + // this the badges are always one start of Steam behind, and a new install has none. + int before = g_passes; + np_catalog_refresh_async(steam_root); + for (int waited = 0; g_passes == before && waited < NP_CATALOG_WAIT_MS; waited += 20) + usleep(20 * 1000); + // The store's pages are fed from the same set, by a thread of their own. + np_store_start(); + char *map = np_catalog_map(); + if (!map) return NULL; + + char off_path[PATH_MAX]; + const char *home = np_home_dir(); + np_support_path(off_path, sizeof off_path, home ? home : "", NP_CATALOG_OFF_FILE); + struct stat off; + size_t cap = strlen(map) + 192; + char *js = malloc(cap); + // The switches go with the map rather than instead of it, so turning the badges back + // on in Preferences shows them at once instead of after Steam next starts. + if (js) + snprintf(js, cap, "window.__NOTPROTON_BADGES_OFF__=%s;window.__NOTPROTON_STORE_OFF__=%s;" + "window.__NOTPROTON_COMPAT__=%s;\n", + stat(off_path, &off) == 0 ? "true" : "false", + np_store_switch() ? "false" : "true", map); + free(map); + return js; +} + +int np_catalog_set_enabled(int on) { + char path[PATH_MAX]; + const char *home = np_home_dir(); + np_support_path(path, sizeof path, home ? home : "", NP_CATALOG_OFF_FILE); + if (on) return unlink(path) == 0 || errno == ENOENT; + FILE *f = fopen(path, "w"); + if (!f) return 0; + return fclose(f) == 0; +} diff --git a/dylib/feats/catalog.h b/dylib/feats/catalog.h new file mode 100644 index 00000000..e9c5590e --- /dev/null +++ b/dylib/feats/catalog.h @@ -0,0 +1,30 @@ +// A badge for every installed game, for the library's tiles. +#ifndef NOTPROTON_FEATS_CATALOG_H +#define NOTPROTON_FEATS_CATALOG_H + +#include + +// "window.__NOTPROTON_COMPAT__={...};\n", from the badges worked out on the last pass. +// NULL when there are none to show, which leaves every tile as Steam drew it. The caller +// owns the string. +// +// This reads what was saved, never the games themselves: the library opens at the speed +// of one small file, and np_catalog_refresh brings the file up to date behind it. +char *np_catalog_javascript(const char *steam_root); + +// Walks the installed games, scans the ones whose install changed, and saves the badges +// for the next time the library opens. Also leaves each game's advice beside its prefix, +// where the run script picks it up. Returns how many games were looked at. +int np_catalog_refresh(const char *steam_root); + +// Runs np_catalog_refresh once on a thread of its own. +void np_catalog_refresh_async(const char *steam_root); + +// The saved badges as a JavaScript object literal, or NULL. The caller frees it. +char *np_catalog_map(void); + +// Turns the badges on or off for good, by way of a file in the support folder that the +// library and the store agent both read. Returns 0 when the choice could not be kept. +int np_catalog_set_enabled(int on); + +#endif // NOTPROTON_FEATS_CATALOG_H diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index ca2ab0ed..78f7b57c 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -1162,7 +1162,16 @@ 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 +# What the dylib worked out for this game on this Mac, from the chip and the GPU and from +# what the game's own files ask for. It is read after the settings someone chose, so it +# only ever fills in what nothing else has said, and a file in the support folder turns it +# off for people who would rather set everything themselves. +np_advice_file="$STEAM_COMPAT_DATA_PATH/notproton-advice" +if [ -f "$np_support/no-auto-settings" ] || [ -z "${STEAM_COMPAT_DATA_PATH-}" ]; then + np_advice_file="" +fi +for np_src in "$np_defaults_file" "$np_advice_file"; do + if [ "$verb" = waitforexitandrun ] && [ -n "$np_src" ] && [ -f "$np_src" ] && [ -r "$np_src" ]; then while IFS= read -r line || [ -n "$line" ]; do key=${line%%=*} value=${line#*=} @@ -1222,10 +1231,36 @@ if [ "$verb" = waitforexitandrun ] && [ -f "$np_defaults_file" ] && [ -r "$np_de eval "[ -z \"\${$key+x}\" ]" || continue export "${line?}" np_defaulted="$np_defaulted${np_defaulted:+ }$line" - done < "$np_defaults_file" -fi + done < "$np_src" + fi +done [ "${CX_GRAPHICS_BACKEND-}" != auto ] || unset -v CX_GRAPHICS_BACKEND +# Words a launch rule says this game needs, from ~/Library/Application Support/notproton/ +# rules/*.conf by way of the catalogue. Printed rather than applied, so what would be +# added can be read on its own, and so the caller keeps its own arguments. +np_rule_args() { + [ "$verb" = waitforexitandrun ] || return 0 + [ -n "${np_advice_file-}" ] || return 0 + np_args_file="$STEAM_COMPAT_DATA_PATH/notproton-advice-args" + [ -f "$np_args_file" ] && [ -r "$np_args_file" ] || return 0 + # Only flags, and only ones the game was not already given: a rule adds to what someone + # set, it never says it twice and it never passes a word that could be a file name. + np_have=" $* " + for np_arg in $(tr -d '\n' < "$np_args_file"); do + case "$np_arg" in -*) ;; *) continue ;; esac + case "$np_arg" in *[!A-Za-z0-9._=:+-]*) continue ;; esac + case "$np_have" in *" $np_arg "*) continue ;; esac + np_have="$np_have$np_arg " + printf '%s\n' "$np_arg" + done +} + +for np_arg in $(np_rule_args "$@"); do + set -- "$@" "$np_arg" + echo "=== launch rule adds $np_arg ===" >> "$log" 2>&1 || true +done + np_dropped="" if [ "${wine_unix##*/}" = aarch64-unix ]; then if [ "${CX_GRAPHICS_BACKEND:-}" = d3dmetal ]; then @@ -1584,12 +1619,50 @@ set_switch_state() { fi } +# How this game has run on this Mac, so the Compatibility page can say so: a format line, +# then healthy sessions, failed sessions, the seconds they lasted, and the health, build +# and graphics backend of the last one. A session that says nothing about the build still +# counts its seconds, since it is time the game ran. +record_history() { + record="$STEAM_COMPAT_DATA_PATH/notproton-sessions" + np_h=0 np_f=0 np_secs=0 + if [ "$(head -1 "$record" 2>/dev/null)" = 1 ]; then + { + read -r _ || true + read -r np_h || true + read -r np_f || true + read -r np_secs || true + } < "$record" + fi + # A file someone edited, or one a half-written save left behind, starts the count over + # rather than carrying a number the page would then show. + case "$np_h$np_f$np_secs" in + *[!0-9]* | "") np_h=0 np_f=0 np_secs=0 ;; + esac + case "$1" in + healthy) np_h=$((np_h + 1)) ;; + failed) np_f=$((np_f + 1)) ;; + esac + # Capped where no play session reaches, so a clock that jumped cannot make the total + # say years. + case "$2" in + "" | *[!0-9]*) ;; + *) if [ "$2" -le 86400 ]; then np_secs=$((np_secs + $2)); fi ;; + esac + if ! printf '1\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + "$np_h" "$np_f" "$np_secs" "$1" "$np_build" "${CX_GRAPHICS_BACKEND:-auto}" > "$record.$$" \ + || ! mv -f "$record.$$" "$record"; then + rm -f "$record.$$" + fi +} + # The switch record is old build, its name, then the healthy sessions since or "failed". # Two healthy sessions clear it. record_session() { switch="$STEAM_COMPAT_DATA_PATH/notproton-switch" health=$(session_health "$1" "$2" "$3") echo "=== session health: $health after $2s ===" >> "$log" 2>&1 || true + record_history "$health" "$2" case "$health" in healthy) printf '%s\n' "$np_build" > "$STEAM_COMPAT_DATA_PATH/notproton-healthy" 2>/dev/null || true diff --git a/dylib/feats/compatpage.c b/dylib/feats/compatpage.c index 4fced112..01ca1020 100644 --- a/dylib/feats/compatpage.c +++ b/dylib/feats/compatpage.c @@ -1,11 +1,25 @@ #include "compatpage.h" #include "compat.h" #include "defaults.h" +#include "machine.h" +#include "tuning.h" #include "webpatch.h" #include "../util/log.h" #include +// What this Mac asks for, which the page offers as one button beside the settings it +// writes. A runner whose set could not be worked out is passed as NULL, and the row for +// it stays off the page rather than offering settings nothing stands behind. +static void publish_tuning(void) { + const np_machine_t *m = np_machine(); + char rosetta[NP_DEFAULTS_TEXT_MAX + 1], fex[NP_DEFAULTS_TEXT_MAX + 1], mac[256]; + int has_rosetta = np_tuning_recommend(m, NP_DEFAULTS_ROSETTA, rosetta, sizeof rosetta); + int has_fex = np_tuning_recommend(m, NP_DEFAULTS_FEX, fex, sizeof fex); + np_machine_summary(m, mac, sizeof mac); + np_webpatch_set_tuning(has_rosetta ? rosetta : NULL, has_fex ? fex : NULL, mac); +} + 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); @@ -29,6 +43,7 @@ static void publish_defaults(void) { } if (read) np_webpatch_set_defaults(rosetta, fex); else np_webpatch_set_defaults(NULL, NULL); + publish_tuning(); np_webpatch_set_builds(builds, n); free(own); } diff --git a/dylib/feats/compatsvc.c b/dylib/feats/compatsvc.c index 872be125..30b686bf 100644 --- a/dylib/feats/compatsvc.c +++ b/dylib/feats/compatsvc.c @@ -14,8 +14,10 @@ #include "compatsvc.h" #include "compat.h" +#include "catalog.h" #include "defaults.h" #include "prefixstate.h" +#include "store.h" #include "webui.h" #include "../resolver/anchor.h" #include "../util/file.h" @@ -35,6 +37,7 @@ #define ROUTE_SET_DEFAULTS "NotProton.SetDefaults#1" #define ROUTE_SET_TOOL_DEFAULTS "NotProton.SetToolDefaults#1" #define ROUTE_APPLY_TOOL_DEFAULTS "NotProton.ApplyToolDefaults#1" +#define ROUTE_SET_BADGES "NotProton.SetBadges#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 @@ -487,6 +490,18 @@ static int set_defaults(uintptr_t request, uintptr_t response) { ? RESULT_OK : RESULT_FAIL; } +// The Mac badges, on or off. The number that carries an appid for the other routes +// carries the choice here: anything but zero is on. +static int set_badges(uintptr_t request, uintptr_t response) { + uint32_t has_bits = *(const uint32_t *)(request + REQ_HAS_BITS_OFF); + uint32_t on = (has_bits & SPECIFY_HAS_APPID) + ? *(const uint32_t *)(request + SPECIFY_APPID_OFF) : 0; + (void)response; + // Two switches on one route: 0 and 1 are the badges off and on, 2 and 3 the store's. + if (on >= 2) return np_store_set_enabled(on == 3) ? RESULT_OK : RESULT_FAIL; + return np_catalog_set_enabled(on != 0) ? 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) { @@ -566,6 +581,8 @@ void np_compatsvc_register(const struct mach_header_64 *mh, intptr_t slide, || !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"); + if (!np_webui_register_route(ROUTE_SET_BADGES, specify_request, specify_response, set_badges)) + NP_WARN("compatsvc: the Settings page cannot turn the Mac badges off"); g_routes = NP_COMPATSVC_READY; diff --git a/dylib/feats/conf.c b/dylib/feats/conf.c index 64ccee00..703a2bb2 100644 --- a/dylib/feats/conf.c +++ b/dylib/feats/conf.c @@ -31,6 +31,14 @@ static const np_conf_spec_t kVariant[] = { { "build", 8, 0 }, { "remove", 32, 0 }, { NULL, 0, 0 } }; +// A launch rule: what has to be true of the game, and what to pass it when it is. Every +// condition given has to match, so a rule with none would match everything and is refused. +static const np_conf_spec_t kRule[] = { + { "label", 1, 0 }, { "appid", 32, 0 }, { "engine", 1, 0 }, + { "launcher", 1, 0 }, { "exe", 4, 0 }, { "option", 16, 0 }, + { "note", 1, 0 }, { NULL, 0, 0 } +}; + static int conf_space(char c) { return c == ' ' || (c >= '\t' && c <= '\r'); } static void conf_trim(char *v) { @@ -39,7 +47,8 @@ static void conf_trim(char *v) { } static const np_conf_spec_t *conf_spec(np_conf_kind kind, const char *name) { - const np_conf_spec_t *s = kind == NP_CONF_PLUGIN ? kPlugin : kVariant; + const np_conf_spec_t *s = kind == NP_CONF_PLUGIN ? kPlugin + : kind == NP_CONF_RULE ? kRule : kVariant; for (; s->name; s++) if (strcmp(s->name, name) == 0) return s; return NULL; @@ -180,9 +189,37 @@ static int conf_build_ok(const char *v) { // What a record needs beyond the grammar: the fields the tables mark required, and the // rules a value has to keep. Returns a code, with *detail set when it names a field. +// An appid is Steam's, so it is digits and fits in 32 bits. +static int conf_appid_ok(const char *v) { + if (!v || !*v || strlen(v) > 10) return 0; + for (const char *p = v; *p; p++) + if (*p < '0' || *p > '9') return 0; + return 1; +} + +// What a rule may say about the game it is for. The names are the ones game.c reads. +static int conf_engine_ok(const char *v) { + return strcmp(v, "unreal") == 0 || strcmp(v, "unity") == 0; +} + +static int conf_launcher_ok(const char *v) { + return strcmp(v, "ea") == 0 || strcmp(v, "ubisoft") == 0 + || strcmp(v, "epic") == 0 || strcmp(v, "rockstar") == 0; +} + +// An option is passed to the game as one word, so it carries no quoting of its own and +// nothing a shell would take for its own. +static int conf_option_ok(const char *v) { + if (!v || !*v || strlen(v) > 64) return 0; + for (const char *p = v; *p; p++) + if ((unsigned char)*p <= ' ' || strchr("\"'`$\\;&|<>()*?[]{}!~#", *p)) return 0; + return 1; +} + static const char *conf_schema(const np_conf_rec_t *rec, np_conf_kind kind, const char **detail) { - const np_conf_spec_t *s = kind == NP_CONF_PLUGIN ? kPlugin : kVariant; + const np_conf_spec_t *s = kind == NP_CONF_PLUGIN ? kPlugin + : kind == NP_CONF_RULE ? kRule : kVariant; const char *type, *mn, *mx, *step, *dflt, *n, *v; int it = 0, values; @@ -192,6 +229,32 @@ static const char *conf_schema(const np_conf_rec_t *rec, np_conf_kind kind, return NP_CONF_REQUIRED; } + if (kind == NP_CONF_RULE) { + // A rule that matches on nothing would be a rule for every game ever installed. + if (!np_conf_get(rec, "appid") && !np_conf_get(rec, "engine") + && !np_conf_get(rec, "launcher") && !np_conf_get(rec, "exe")) { + *detail = "appid"; + return NP_CONF_REQUIRED; + } + // And one that matches but says nothing has nothing to do. + if (!np_conf_get(rec, "option") && !np_conf_get(rec, "note")) { + *detail = "option"; + return NP_CONF_REQUIRED; + } + while (np_conf_next(rec, &it, &n, &v)) { + const char *bad = NULL; + if (strcmp(n, "appid") == 0 && !conf_appid_ok(v)) bad = "appid"; + else if (strcmp(n, "engine") == 0 && !conf_engine_ok(v)) bad = "engine"; + else if (strcmp(n, "launcher") == 0 && !conf_launcher_ok(v)) bad = "launcher"; + else if (strcmp(n, "option") == 0 && !conf_option_ok(v)) bad = "option"; + if (bad) { + *detail = bad; + return NP_CONF_BAD_VALUE; + } + } + return NULL; + } + if (kind == NP_CONF_VARIANT) { const char *dst = np_conf_get(rec, "dst"); if (!conf_rel_ok(dst, 1)) { diff --git a/dylib/feats/conf.h b/dylib/feats/conf.h index 62cf2f7b..08bdea5c 100644 --- a/dylib/feats/conf.h +++ b/dylib/feats/conf.h @@ -8,7 +8,7 @@ #define NP_CONF_REC_BUF 4096 // a record's fields, names and values together #define NP_CONF_RECORDS_MAX 32 // per directory, counted across its files -typedef enum { NP_CONF_PLUGIN, NP_CONF_VARIANT } np_conf_kind; +typedef enum { NP_CONF_PLUGIN, NP_CONF_VARIANT, NP_CONF_RULE } np_conf_kind; // One record's fields in the order they were read, as "name\0value\0" pairs. typedef struct { diff --git a/dylib/feats/game.c b/dylib/feats/game.c new file mode 100644 index 00000000..6ffd2c80 --- /dev/null +++ b/dylib/feats/game.c @@ -0,0 +1,271 @@ +// What a game is, read from the files Steam installed: the executable's word size, the +// graphics API it asks for, and the anti-cheat, launcher or engine its folder gives away. +// Nothing here is a judgement; it is what is on disk. +#include "game.h" +#include "../util/file.h" +#include "../util/log.h" +#include "../util/pe.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// A game's folder is walked, not read whole: enough to reach where the engines keep +// their binaries -- Unreal's are four deep, under /Binaries/Win64 -- without +// turning Steam's startup into a disk crawl. +#define NP_SCAN_DEPTH 5 +#define NP_SCAN_ENTRIES 20000 +// The executable is mapped rather than read, so a 2 GB binary costs the pages that are +// actually touched. The plain-text sweep for dynamically loaded names stops here. +#define NP_PE_TEXT_MAX (64u << 20) + +typedef struct { + np_game_t *game; + long long best; // the largest candidate seen, by size + int shipping; // an engine's own binary was found, which outranks any size + int entries; +} scan_t; + +static int ends_with_ci(const char *s, const char *suffix) { + size_t n = strlen(s), m = strlen(suffix); + return n >= m && strcasecmp(s + n - m, suffix) == 0; +} + +static int has_ci(const char *s, const char *needle) { + size_t n = strlen(s), m = strlen(needle); + for (size_t i = 0; m && n >= m && i <= n - m; i++) + if (strncasecmp(s + i, needle, m) == 0) return 1; + return 0; +} + +// Names that are in the folder for everyone's sake rather than the game's: an installer, +// a crash reporter, a prerequisite. Picking one of these would read the wrong binary. +static int is_side_binary(const char *name) { + static const char *const side[] = { + "unitycrashhandler", "crashreport", "crashhandler", "vcredist", "dxsetup", + "directx", "dotnetfx", "oalinst", "uninstall", "setup", "installscript", + "easyanticheat", "beservice", "be_service", "touchup", "prereq", "redist", + "benchmark", "editor", "server", "install", "activation", "cleanup", + "ubisoftconnect", "uplay", "eadesktop", "ealauncher", "epicgameslauncher", + "dotnet", "openal", "physx", "quicksfv", "unitysetup", NULL, + }; + for (size_t i = 0; side[i]; i++) + if (has_ci(name, side[i])) return 1; + return 0; +} + +static void note_markers(np_game_t *g, const char *name) { + if (has_ci(name, "easyanticheat") || has_ci(name, "eac_launcher") || has_ci(name, "_eac")) + g->anticheat = NP_AC_EASYANTICHEAT; + else if (has_ci(name, "battleye") || strcasecmp(name, "beservice.exe") == 0 + || strcasecmp(name, "beclient.dll") == 0) + g->anticheat = NP_AC_BATTLEYE; + else if (has_ci(name, "vanguard") || strcasecmp(name, "vgc.exe") == 0) + g->anticheat = NP_AC_VANGUARD; + + if (has_ci(name, "eadesktop") || has_ci(name, "ealauncher") || has_ci(name, "eacore") + || strcasecmp(name, "activation.exe") == 0) + g->launcher = NP_LAUNCHER_EA; + else if (has_ci(name, "ubisoft") || has_ci(name, "uplay")) + g->launcher = NP_LAUNCHER_UBISOFT; + // Not the EOS SDK: half the catalogue ships it for crossplay and friends lists, and + // none of those games start through the Epic launcher. + else if (has_ci(name, "epicgameslauncher")) + g->launcher = NP_LAUNCHER_EPIC; + else if (has_ci(name, "rockstar") || has_ci(name, "socialclub")) + g->launcher = NP_LAUNCHER_ROCKSTAR; + + if (strcasecmp(name, "unityplayer.dll") == 0 || strcasecmp(name, "mono.dll") == 0) + g->engine = NP_ENGINE_UNITY; + else if (ends_with_ci(name, "-shipping.exe") || strcasecmp(name, "engine") == 0) + g->engine = NP_ENGINE_UNREAL; +} + +// A shipped DLL names the API as plainly as an import does, and a game that carries its +// own d3d12 layer is a game that reaches for D3D12. +static void note_library(np_game_t *g, const char *name) { + if (strcasecmp(name, "d3d12.dll") == 0 || strcasecmp(name, "d3d12core.dll") == 0) + g->gfx |= NP_GFX_D3D12; + else if (strcasecmp(name, "d3d11.dll") == 0) g->gfx |= NP_GFX_D3D11; + else if (strcasecmp(name, "d3d9.dll") == 0) g->gfx |= NP_GFX_D3D9; + else if (strcasecmp(name, "vulkan-1.dll") == 0) g->gfx |= NP_GFX_VULKAN; +} + +static void walk(const char *dir, int depth, scan_t *st, char *best_path, size_t best_size) { + if (depth > NP_SCAN_DEPTH || st->entries >= NP_SCAN_ENTRIES) return; + DIR *d = opendir(dir); + if (!d) return; + for (struct dirent *e; (e = readdir(d)) && st->entries < NP_SCAN_ENTRIES; ) { + if (e->d_name[0] == '.') continue; + st->entries++; + char path[NP_GAME_PATH_MAX]; + if ((size_t)snprintf(path, sizeof path, "%s/%s", dir, e->d_name) >= sizeof path) continue; + note_markers(st->game, e->d_name); + + struct stat sb; + if (stat(path, &sb) != 0) continue; + // A macOS bundle beside the Windows files is the game's own native build, which + // Steam runs without any of this. + if (ends_with_ci(e->d_name, ".app")) { st->game->native = 1; continue; } + if (S_ISDIR(sb.st_mode)) { + walk(path, depth + 1, st, best_path, best_size); + continue; + } + if (!S_ISREG(sb.st_mode)) continue; + if (ends_with_ci(e->d_name, ".dll")) note_library(st->game, e->d_name); + // The game's own executable is the biggest one that is not there for the + // installer, the crash reporter or the anti-cheat. + if (!ends_with_ci(e->d_name, ".exe") || is_side_binary(e->d_name)) continue; + // Unreal names the binary it actually ships, and that one outranks whatever + // shim or trampoline sits at the top of the folder being bigger. + int shipping = ends_with_ci(e->d_name, "-shipping.exe"); + if (shipping) st->game->engine = NP_ENGINE_UNREAL; + if (st->shipping && !shipping) continue; + if (shipping && !st->shipping) st->best = -1; + if ((long long)sb.st_size > st->best) { + st->shipping = shipping; + st->best = (long long)sb.st_size; + snprintf(best_path, best_size, "%s", path); + } + } + closedir(d); +} + +// The DLL names an import descriptor points at, and the names the binary carries for the +// libraries it loads itself. A game that calls LoadLibrary("d3d12.dll") imports nothing, +// so the plain text is read too. +static void note_api_name(np_game_t *g, const char *name) { + if (strcasecmp(name, "d3d9.dll") == 0) g->gfx |= NP_GFX_D3D9; + else if (strcasecmp(name, "d3d11.dll") == 0) g->gfx |= NP_GFX_D3D11; + else if (strcasecmp(name, "d3d12.dll") == 0) g->gfx |= NP_GFX_D3D12; + else if (strncasecmp(name, "dxgi.dll", 8) == 0) g->gfx |= NP_GFX_DXGI; + else if (strcasecmp(name, "vulkan-1.dll") == 0) g->gfx |= NP_GFX_VULKAN; + else if (strcasecmp(name, "opengl32.dll") == 0) g->gfx |= NP_GFX_OPENGL; +} + +static void read_imports(np_game_t *g, const np_pe_view_t *pe, size_t peo, uint16_t magic) { + size_t dir_base = peo + 24 + (magic == 0x20B ? 112 : 96); + uint32_t count = 0, table = 0; + if (!np_pe_rd32(pe, dir_base - 4, &count) || count < 2) return; + if (!np_pe_rd32(pe, dir_base + 8, &table) || !table) return; + size_t at; + if (!np_pe_rva_off(pe, table, &at)) return; + // Each descriptor is 20 bytes and the list ends on an all-zero one. + for (int i = 0; i < 512; i++, at += 20) { + uint32_t name_rva = 0; + if (!np_pe_rd32(pe, at + 12, &name_rva) || !name_rva) return; + size_t name_at; + if (!np_pe_rva_off(pe, name_rva, &name_at) || name_at >= pe->n) return; + char name[64]; + size_t k = 0; + while (k < sizeof name - 1 && name_at + k < pe->n && pe->p[name_at + k]) { + name[k] = (char)pe->p[name_at + k]; + k++; + } + name[k] = '\0'; + note_api_name(g, name); + } +} + +// The names a binary loads by hand, looked for as plain bytes. Bounded to what was read. +static void read_loaded_names(np_game_t *g, const uint8_t *p, size_t n) { + static const char *const names[] = { "d3d9.dll", "d3d11.dll", "d3d12.dll", "dxgi.dll", + "vulkan-1.dll", "opengl32.dll", NULL }; + for (size_t i = 0; names[i]; i++) { + size_t m = strlen(names[i]); + for (size_t at = 0; n >= m && at <= n - m; at++) { + // Case folded by hand: the table is lowercase and a binary may carry either. + size_t k = 0; + while (k < m && (p[at + k] | 0x20) == (unsigned char)names[i][k]) k++; + if (k == m) { note_api_name(g, names[i]); break; } + } + } +} + +static void read_executable(np_game_t *g) { + int fd = open(g->exe, O_RDONLY | O_CLOEXEC); + if (fd < 0) return; + struct stat sb; + if (fstat(fd, &sb) != 0 || !S_ISREG(sb.st_mode) || sb.st_size <= 0) { close(fd); return; } + size_t len = (size_t)sb.st_size; + // Mapped, not read: only the pages the header and the import table fall on are paid + // for, which is what makes reading a multi-gigabyte binary cheap. + uint8_t *buf = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); + close(fd); + if (buf == MAP_FAILED) return; + + np_pe_view_t pe = { buf, len, 0, 0, 0, 0 }; + uint16_t mz = 0, nsects = 0, opt_size = 0, magic = 0, machine = 0; + uint32_t peo32 = 0, sig = 0; + if (np_pe_rd16(&pe, 0, &mz) && mz == 0x5A4D && np_pe_rd32(&pe, 0x3C, &peo32)) { + size_t peo = peo32; + if (np_pe_rd32(&pe, peo, &sig) && sig == 0x00004550 + && np_pe_rd16(&pe, peo + 4, &machine) && np_pe_rd16(&pe, peo + 6, &nsects) + && np_pe_rd16(&pe, peo + 20, &opt_size) && np_pe_rd16(&pe, peo + 24, &magic) + && nsects && nsects <= NP_PE_MAX_SECTIONS) { + g->bits = machine == 0x8664 || machine == 0xAA64 || magic == 0x20B ? 64 + : machine == 0x014C ? 32 : 0; + size_t sects = peo + 24 + opt_size; + if (sects <= pe.n && (pe.n - sects) / 40 >= nsects) { + pe.sects = sects; + pe.nsects = nsects; + read_imports(g, &pe, peo, magic); + } + } + } + read_loaded_names(g, buf, len < NP_PE_TEXT_MAX ? len : NP_PE_TEXT_MAX); + munmap(buf, len); +} + +int np_game_scan(const char *install_dir, np_game_t *out) { + if (!out) return 0; + memset(out, 0, sizeof *out); + if (!install_dir || !*install_dir) return 0; + struct stat sb; + if (stat(install_dir, &sb) != 0 || !S_ISDIR(sb.st_mode)) return 0; + + scan_t st = { out, -1, 0, 0 }; + char best[NP_GAME_PATH_MAX] = ""; + walk(install_dir, 1, &st, best, sizeof best); + snprintf(out->exe, sizeof out->exe, "%s", best); + if (out->exe[0]) read_executable(out); + out->scanned = 1; + NP_LOG("game: %s is %d-bit, gfx 0x%x, anti-cheat %s, launcher %s, engine %s", + out->exe[0] ? out->exe : install_dir, out->bits, out->gfx, + np_game_anticheat_name(out->anticheat), np_game_launcher_name(out->launcher), + np_game_engine_name(out->engine)); + return 1; +} + +const char *np_game_anticheat_name(np_anticheat_t a) { + switch (a) { + case NP_AC_EASYANTICHEAT: return "Easy Anti-Cheat"; + case NP_AC_BATTLEYE: return "BattlEye"; + case NP_AC_VANGUARD: return "Vanguard"; + default: return ""; + } +} + +const char *np_game_launcher_name(np_launcher_t l) { + switch (l) { + case NP_LAUNCHER_EA: return "the EA app"; + case NP_LAUNCHER_UBISOFT: return "Ubisoft Connect"; + case NP_LAUNCHER_EPIC: return "the Epic Games launcher"; + case NP_LAUNCHER_ROCKSTAR: return "the Rockstar launcher"; + default: return ""; + } +} + +const char *np_game_engine_name(np_engine_t e) { + switch (e) { + case NP_ENGINE_UNREAL: return "Unreal Engine"; + case NP_ENGINE_UNITY: return "Unity"; + default: return ""; + } +} diff --git a/dylib/feats/game.h b/dylib/feats/game.h new file mode 100644 index 00000000..5ad1c82e --- /dev/null +++ b/dylib/feats/game.h @@ -0,0 +1,59 @@ +// What a game is, read from the files Steam installed. +#ifndef NOTPROTON_FEATS_GAME_H +#define NOTPROTON_FEATS_GAME_H + +#include + +#define NP_GAME_NAME_MAX 48 +#define NP_GAME_PATH_MAX 1024 + +// Graphics APIs the executable asks for, by import or by the name it loads at runtime. +#define NP_GFX_D3D9 (1u << 0) +#define NP_GFX_D3D11 (1u << 1) +#define NP_GFX_D3D12 (1u << 2) +#define NP_GFX_DXGI (1u << 3) +#define NP_GFX_VULKAN (1u << 4) +#define NP_GFX_OPENGL (1u << 5) + +typedef enum { + NP_AC_NONE = 0, + NP_AC_EASYANTICHEAT, + NP_AC_BATTLEYE, + NP_AC_VANGUARD, +} np_anticheat_t; + +typedef enum { + NP_LAUNCHER_NONE = 0, + NP_LAUNCHER_EA, + NP_LAUNCHER_UBISOFT, + NP_LAUNCHER_EPIC, + NP_LAUNCHER_ROCKSTAR, +} np_launcher_t; + +typedef enum { + NP_ENGINE_NONE = 0, + NP_ENGINE_UNREAL, + NP_ENGINE_UNITY, +} np_engine_t; + +typedef struct { + int scanned; // the install was there and could be read + int native; // it ships a macOS app bundle of its own + char exe[NP_GAME_PATH_MAX]; // the executable the rest of this was read from + int bits; // 32 or 64, 0 when no PE header was read + unsigned gfx; // NP_GFX_* + np_anticheat_t anticheat; + np_launcher_t launcher; + np_engine_t engine; +} np_game_t; + +// Reads the install folder. Returns 0 and leaves `out` empty when there is nothing to +// read, which is a game that is not installed rather than a game that will not run. +int np_game_scan(const char *install_dir, np_game_t *out); + +// For the page and the log. Empty strings for none. +const char *np_game_anticheat_name(np_anticheat_t a); +const char *np_game_launcher_name(np_launcher_t l); +const char *np_game_engine_name(np_engine_t e); + +#endif // NOTPROTON_FEATS_GAME_H diff --git a/dylib/feats/machine.c b/dylib/feats/machine.c new file mode 100644 index 00000000..1a3c6883 --- /dev/null +++ b/dylib/feats/machine.c @@ -0,0 +1,132 @@ +#include "machine.h" +#include "../util/log.h" + +#include +#include +#include +#include +#include +#include +#include + +static int sysctl_num(const char *name, int64_t *out) { + int64_t v = 0; + size_t len = sizeof v; + if (sysctlbyname(name, &v, &len, NULL, 0) != 0) return 0; + // hw.* answers in whichever width the kernel keeps, so a 4-byte reply has to be read + // as one rather than as the low half of an 8-byte value. + if (len == sizeof(int32_t)) v = (int64_t)*(const int32_t *)&v; + else if (len != sizeof v) return 0; + *out = v; + return 1; +} + +static int sysctl_text(const char *name, char *buf, size_t size) { + size_t len = size; + if (sysctlbyname(name, buf, &len, NULL, 0) != 0 || !len) return 0; + buf[len < size ? len : size - 1] = '\0'; + return buf[0] != '\0'; +} + +// The Steam client is x86_64 and runs under Rosetta on Apple silicon, where the CPU brand +// string reads "VirtualApple" and the AVX flags are masked. The I/O registry is not +// translated, so the GPU node is what both the chip name and the core count come from. +static void gpu_from_registry(char *chip, size_t chip_size, int *cores) { + io_iterator_t it = IO_OBJECT_NULL; + if (IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOAccelerator"), &it) + != KERN_SUCCESS) + return; + for (io_object_t svc; (svc = IOIteratorNext(it)); IOObjectRelease(svc)) { + CFTypeRef n = IORegistryEntrySearchCFProperty( + svc, kIOServicePlane, CFSTR("gpu-core-count"), NULL, + kIORegistryIterateRecursively | kIORegistryIterateParents); + if (n) { + int32_t v = 0; + if (CFGetTypeID(n) == CFNumberGetTypeID() && CFNumberGetValue(n, kCFNumberSInt32Type, &v) + && v > *cores) + *cores = v; + CFRelease(n); + } + if (chip[0]) continue; + CFTypeRef m = IORegistryEntrySearchCFProperty( + svc, kIOServicePlane, CFSTR("model"), NULL, + kIORegistryIterateRecursively | kIORegistryIterateParents); + if (!m) continue; + // The property is a string on some releases and a NUL-terminated blob on others. + if (CFGetTypeID(m) == CFStringGetTypeID()) { + CFStringGetCString(m, chip, (CFIndex)chip_size, kCFStringEncodingUTF8); + } else if (CFGetTypeID(m) == CFDataGetTypeID()) { + CFIndex len = CFDataGetLength(m); + if (len > 0) { + if ((size_t)len > chip_size - 1) len = (CFIndex)chip_size - 1; + CFDataGetBytes(m, CFRangeMake(0, len), (UInt8 *)chip); + chip[len] = '\0'; + } + } + for (size_t i = 0; i < chip_size && chip[i]; i++) + if ((unsigned char)chip[i] < 0x20) { chip[i] = '\0'; break; } + CFRelease(m); + } + IOObjectRelease(it); +} + +static void main_display(np_machine_t *m) { + CGDirectDisplayID id = CGMainDisplayID(); + CGDisplayModeRef mode = CGDisplayCopyDisplayMode(id); + if (!mode) return; + m->display_w = (int)CGDisplayModeGetPixelWidth(mode); + m->display_h = (int)CGDisplayModeGetPixelHeight(mode); + double hz = CGDisplayModeGetRefreshRate(mode); + // Built-in panels reported 0 before ProMotion, and a mode that says nothing is left + // unknown rather than guessed at here. + m->refresh_hz = hz > 0 ? (int)(hz + 0.5) : 0; + CGDisplayModeRelease(mode); +} + +static np_machine_t g_machine; +static pthread_once_t g_once = PTHREAD_ONCE_INIT; + +static void probe(void) { + np_machine_t m = { 0 }; + int64_t v = 0; + // A translated process is on Apple silicon whatever the CPU it is shown. + if ((sysctl_num("hw.optional.arm64", &v) && v == 1) + || (sysctl_num("sysctl.proc_translated", &v) && v == 1)) + m.apple_silicon = 1; + gpu_from_registry(m.chip, sizeof m.chip, &m.gpu_cores); + if (!m.chip[0]) sysctl_text("machdep.cpu.brand_string", m.chip, sizeof m.chip); + if (sysctl_num("hw.physicalcpu", &v) && v > 0) m.cpu_cores = (int)v; + if (sysctl_num("hw.memsize", &v) && v > 0) m.ram_gb = (unsigned)(v / (1024 * 1024 * 1024)); + char release[32]; + if (sysctl_text("kern.osproductversion", release, sizeof release)) { + int major = 0; + if (sscanf(release, "%d", &major) == 1 && major > 0) m.macos_major = major; + } + main_display(&m); + g_machine = m; + NP_LOG("machine: %s%s, %d GPU cores, %u GB, macOS %d, %dx%d at %d Hz", + m.chip[0] ? m.chip : "unknown", + m.apple_silicon ? "" : " (Intel)", m.gpu_cores, m.ram_gb, m.macos_major, + m.display_w, m.display_h, m.refresh_hz); +} + +const np_machine_t *np_machine(void) { + pthread_once(&g_once, probe); + return &g_machine; +} + +void np_machine_summary(const np_machine_t *m, char *out, size_t size) { + if (!out || !size) return; + out[0] = '\0'; + if (!m) return; + char cores[32] = "", ram[32] = "", panel[64] = ""; + if (m->gpu_cores > 0) snprintf(cores, sizeof cores, ", %d GPU cores", m->gpu_cores); + if (m->ram_gb > 0) snprintf(ram, sizeof ram, ", %u GB", m->ram_gb); + if (m->display_w > 0 && m->display_h > 0) { + if (m->refresh_hz > 0) + snprintf(panel, sizeof panel, ", %dx%d at %d Hz", m->display_w, m->display_h, m->refresh_hz); + else + snprintf(panel, sizeof panel, ", %dx%d", m->display_w, m->display_h); + } + snprintf(out, size, "%s%s%s%s", m->chip[0] ? m->chip : "This Mac", cores, ram, panel); +} diff --git a/dylib/feats/machine.h b/dylib/feats/machine.h new file mode 100644 index 00000000..44e61977 --- /dev/null +++ b/dylib/feats/machine.h @@ -0,0 +1,31 @@ +// What this Mac is, for the settings that depend on the hardware. +#ifndef NOTPROTON_FEATS_MACHINE_H +#define NOTPROTON_FEATS_MACHINE_H + +#include + +#define NP_MACHINE_CHIP_MAX 64 + +// Zero in a field means the probe could not read it, so a caller treats it as unknown +// rather than as a small number: a Mac with no window server has no display to size, and +// a GPU whose core count the registry does not carry still runs games. +typedef struct { + int apple_silicon; + char chip[NP_MACHINE_CHIP_MAX]; // "Apple M4 Max", or the CPU brand on Intel + int gpu_cores; + int cpu_cores; + unsigned ram_gb; + int macos_major; + int display_w, display_h; // backing pixels of the main display's mode + int refresh_hz; +} np_machine_t; + +// Probed once, then the same answer for the life of the process. Never NULL: a probe that +// reads nothing returns a profile of zeroes, which every caller already has to handle. +const np_machine_t *np_machine(void); + +// "Apple M4 Max, 40 GPU cores, 64 GB, 3456x2234 at 120 Hz", for the line under the +// button that applies the settings. Truncates rather than fails. +void np_machine_summary(const np_machine_t *m, char *out, size_t size); + +#endif // NOTPROTON_FEATS_MACHINE_H diff --git a/dylib/feats/nativeplatform.c b/dylib/feats/nativeplatform.c index 0427be8a..2859272b 100644 --- a/dylib/feats/nativeplatform.c +++ b/dylib/feats/nativeplatform.c @@ -58,7 +58,7 @@ static char *read_output(int fd, pid_t pid, long long deadline) { return buf; } -static char *run_helper(void) { +static char *run_helper(const char *mode) { const char *home = np_home_dir(); if (!home) return NULL; char tool[PATH_MAX], vdf[PATH_MAX]; @@ -86,7 +86,9 @@ static char *run_helper(void) { posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); posix_spawn_file_actions_adddup2(&actions, fds[1], STDOUT_FILENO); posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0); - char *argv[] = { tool, vdf, "--platforms", NULL }; + char mode_arg[32]; + snprintf(mode_arg, sizeof mode_arg, "%s", mode); + char *argv[] = { tool, vdf, mode_arg, NULL }; pid_t pid; long long deadline = now_ms() + HELPER_TIMEOUT_MS; int rc = posix_spawn(&pid, tool, &actions, &attr, argv, *_NSGetEnviron()); @@ -144,7 +146,7 @@ static const char *take_list(const char **at, const char *key, size_t *len) { } char *np_nativeplatform_javascript(void) { - char *out = run_helper(); + char *out = run_helper("--platforms"); if (!out) return NULL; const char *at = out; size_t mac_len = 0, windows_len = 0; @@ -164,3 +166,10 @@ char *np_nativeplatform_javascript(void) { free(out); return js; } + +// What Valve's testing says about every game the client knows, one "appid mac deck +// anticheat" line each. NULL from a helper that predates the mode, which is a catalogue +// that knows only the games on disk. +char *np_nativeplatform_compat(void) { + return run_helper("--compat"); +} diff --git a/dylib/feats/nativeplatform.h b/dylib/feats/nativeplatform.h index 60be6d81..7aa7d848 100644 --- a/dylib/feats/nativeplatform.h +++ b/dylib/feats/nativeplatform.h @@ -4,4 +4,7 @@ char *np_nativeplatform_javascript(void); +// "appid mac deck anticheat" lines for every game in appinfo.vdf, or NULL. Caller frees. +char *np_nativeplatform_compat(void); + #endif // NOTPROTON_FEATS_NATIVEPLATFORM_H diff --git a/dylib/feats/prefixstate.c b/dylib/feats/prefixstate.c index 7a7bd252..e441ece5 100644 --- a/dylib/feats/prefixstate.c +++ b/dylib/feats/prefixstate.c @@ -1,6 +1,7 @@ // Prefix state for the Compatibility page. Mirrors the run script's checks // (prefix_machine, notproton-build) so the page can say what a launch would say. #include "prefixstate.h" +#include "verdict.h" #include #include @@ -283,6 +284,15 @@ static void json_string(json_t *j, const char *key, const char *value) { json_raw(j, "\""); } +static void json_number(json_t *j, const char *key, int value) { + char text[16]; + json_raw(j, j->len > 1 ? ",\"" : "\""); + json_raw(j, key); + json_raw(j, "\":"); + snprintf(text, sizeof text, "%d", value); + json_raw(j, text); +} + static void json_bool(json_t *j, const char *key, int value) { json_raw(j, j->len > 1 ? ",\"" : "\""); json_raw(j, key); @@ -341,6 +351,17 @@ int np_prefix_state_json(uint32_t appid, const np_prefix_state_t *st, char *out, json_bool(&j, "running", st->running); json_string(&j, "rebuild", st->rebuild); json_bool(&j, "app", st->app[0] != '\0'); + // What the game's own sessions on this Mac came to. A game that has never run here + // reads "unknown", which is the page's cue to claim nothing about it. + np_verdict_t v; + np_verdict_read(st->compatdata, &v); + json_string(&j, "verdict", np_verdict_name(v.tier)); + json_number(&j, "runs", v.healthy); + json_number(&j, "crashes", v.failed); + json_number(&j, "minutes", v.minutes); + json_string(&j, "lastRun", v.last); + json_string(&j, "runBuild", v.build); + json_string(&j, "runBackend", v.backend); json_raw(&j, "}"); return j.ok ? (int)j.len : -1; } diff --git a/dylib/feats/rules.c b/dylib/feats/rules.c new file mode 100644 index 00000000..aaef70dc --- /dev/null +++ b/dylib/feats/rules.c @@ -0,0 +1,111 @@ +// Launch rules. +// +// The commands that get a particular game past its own launcher are things people find +// out one game at a time, so they live in files rather than in this binary: a rule names +// the game it is for and the words to pass it, and anyone can add one without a build. +#include "rules.h" +#include "conf.h" +#include "../util/log.h" + +#include +#include +#include + +typedef struct { + unsigned appid; + const np_game_t *game; + np_rules_t *out; +} match_t; + +static const char *engine_word(np_engine_t e) { + switch (e) { + case NP_ENGINE_UNREAL: return "unreal"; + case NP_ENGINE_UNITY: return "unity"; + default: return ""; + } +} + +static const char *launcher_word(np_launcher_t l) { + switch (l) { + case NP_LAUNCHER_EA: return "ea"; + case NP_LAUNCHER_UBISOFT: return "ubisoft"; + case NP_LAUNCHER_EPIC: return "epic"; + case NP_LAUNCHER_ROCKSTAR: return "rockstar"; + default: return ""; + } +} + +static const char *base_name(const char *path) { + const char *slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} + +// A field given more than once is a list, and the rule matches when any entry does. +static int any_is(const np_conf_rec_t *rec, const char *field, const char *want, + int fold, int *given) { + const char *n, *v; + int it = 0; + *given = 0; + int hit = 0; + while (np_conf_next(rec, &it, &n, &v)) { + if (strcmp(n, field) != 0) continue; + *given = 1; + if (fold ? strcasecmp(v, want) == 0 : strcmp(v, want) == 0) hit = 1; + } + return hit; +} + +static int matches(const np_conf_rec_t *rec, const match_t *m) { + char appid[16]; + snprintf(appid, sizeof appid, "%u", m->appid); + int given = 0; + + if (!any_is(rec, "appid", appid, 0, &given) && given) return 0; + if (!any_is(rec, "engine", engine_word(m->game->engine), 0, &given) && given) return 0; + if (!any_is(rec, "launcher", launcher_word(m->game->launcher), 0, &given) && given) return 0; + if (!any_is(rec, "exe", base_name(m->game->exe), 1, &given) && given) return 0; + return 1; +} + +static void add_option(np_rules_t *out, const char *option) { + // The same word twice is the same word: two rules may agree without saying it twice. + size_t n = strlen(option); + for (const char *at = out->options; *at; ) { + const char *sp = strchr(at, ' '); + size_t len = sp ? (size_t)(sp - at) : strlen(at); + if (len == n && strncmp(at, option, n) == 0) return; + at += len + (sp ? 1 : 0); + } + size_t at = strlen(out->options); + if (at + n + 2 > sizeof out->options) return; + snprintf(out->options + at, sizeof out->options - at, "%s%s", at ? " " : "", option); +} + +static const char *take_record(const np_conf_rec_t *rec, void *ctx, const char **detail) { + match_t *m = ctx; + if (!matches(rec, m)) return NULL; + m->out->matched++; + const char *n, *v; + int it = 0; + while (np_conf_next(rec, &it, &n, &v)) { + if (strcmp(n, "option") == 0) add_option(m->out, v); + else if (strcmp(n, "note") == 0 && !m->out->note[0]) + snprintf(m->out->note, sizeof m->out->note, "%s", v); + } + NP_LOG("rules: %s matches app %u", rec->id, m->appid); + return NULL; +} + +static void refused(const char *scope, const char *code, const char *detail, void *ctx) { + NP_WARN("rules: %s refused (%s%s%s)", scope, code, detail ? ": " : "", detail ? detail : ""); +} + +void np_rules_apply(const char *dir, const char *shown_dir, unsigned appid, + const np_game_t *game, np_rules_t *out) { + if (!out) return; + memset(out, 0, sizeof *out); + if (!dir || !game) return; + match_t m = { appid, game, out }; + const np_conf_sink_t sink = { take_record, refused, NULL }; + np_conf_read_dir(dir, shown_dir ? shown_dir : dir, NP_CONF_RULE, &sink, &m); +} diff --git a/dylib/feats/rules.h b/dylib/feats/rules.h new file mode 100644 index 00000000..af61ae16 --- /dev/null +++ b/dylib/feats/rules.h @@ -0,0 +1,22 @@ +// Launch rules: what to pass a particular game, read from files rather than built in. +#ifndef NOTPROTON_FEATS_RULES_H +#define NOTPROTON_FEATS_RULES_H + +#include "game.h" + +#define NP_RULES_OPTIONS_MAX 192 +#define NP_RULES_NOTE_MAX 192 + +typedef struct { + char options[NP_RULES_OPTIONS_MAX]; // space separated, in the order the rules gave them + char note[NP_RULES_NOTE_MAX]; // the first note a matching rule carried + int matched; +} np_rules_t; + +// Reads *.conf in `dir` and collects what the rules matching this game say. A rule has to +// match on everything it names -- appid, engine, launcher, executable -- so a rule for one +// game never reaches another. +void np_rules_apply(const char *dir, const char *shown_dir, unsigned appid, + const np_game_t *game, np_rules_t *out); + +#endif // NOTPROTON_FEATS_RULES_H diff --git a/dylib/feats/store.c b/dylib/feats/store.c new file mode 100644 index 00000000..845d93f8 --- /dev/null +++ b/dylib/feats/store.c @@ -0,0 +1,354 @@ +// Mac compatibility on Steam's store and wishlist pages, by way of the web helper's own +// debugger. See store.h for why it has to be this way and what it costs. +#include "store.h" +#include "catalog.h" +#include "store_page.h" +#include "../util/file.h" +#include "../util/log.h" + +#include "cJSON.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define NP_STORE_OFF_FILE "no-store-badges" +#define NP_BADGES_OFF_FILE "no-mac-badges" +#define NP_STORE_URL "https://store.steampowered.com/" +#define NP_STORE_PLACE "__NP_MAP__" +#define NP_STORE_VIEWS 8 +#define NP_STORE_REPLY_MAX (512u << 10) +#define NP_STORE_POLL_MS 3000 +#define NP_STORE_IO_MS 2000 + +static int support_has(const char *name) { + char path[PATH_MAX]; + const char *home = np_home_dir(); + np_support_path(path, sizeof path, home ? home : "", name); + struct stat sb; + return stat(path, &sb) == 0; +} + +int np_store_switch(void) { + return !support_has(NP_STORE_OFF_FILE); +} + +int np_store_enabled(void) { + return np_store_switch() && !support_has(NP_BADGES_OFF_FILE); +} + +int np_store_set_enabled(int on) { + char path[PATH_MAX]; + const char *home = np_home_dir(); + np_support_path(path, sizeof path, home ? home : "", NP_STORE_OFF_FILE); + if (on) return unlink(path) == 0 || errno == ENOENT; + FILE *f = fopen(path, "w"); + return f && fclose(f) == 0; +} + +char **np_store_helper_args(const char *path, char *const argv[]) { + if (!path || !argv || !np_store_enabled()) return NULL; + const char *base = strrchr(path, '/'); + if (strcmp(base ? base + 1 : path, "Steam Helper") != 0) return NULL; + int count = 0; + for (; argv[count]; count++) { + // A helper started with a --type is one of the first helper's own children, and + // the debugger belongs to the first. One already told a port keeps the one it has. + if (strncmp(argv[count], "--type=", 7) == 0) return NULL; + if (strncmp(argv[count], "--remote-debugging-port", 23) == 0) return NULL; + } + char **full = malloc(sizeof(char *) * (size_t)(count + 3)); + if (!full) return NULL; + for (int i = 0; i < count; i++) full[i] = argv[i]; + // On this machine only: the address is what keeps the port off the network. + static char port[] = "--remote-debugging-port=27101"; + static char addr[] = "--remote-debugging-address=127.0.0.1"; + _Static_assert(NP_STORE_PORT == 27101, "the port argument above spells NP_STORE_PORT out"); + full[count] = port; + full[count + 1] = addr; + full[count + 2] = NULL; + return full; +} + +size_t np_store_frame(const char *text, size_t n, const uint8_t mask[4], uint8_t *out, size_t cap) { + // A client's frames are always masked. The length is 7 bits, or 126 and 16 more, or + // 127 and 64 more, which a page script with a library's worth of badges in it needs. + size_t head = 2 + (n > 65535 ? 8 : n > 125 ? 2 : 0) + 4; + if (!out || head + n > cap) return 0; + size_t at = 0; + out[at++] = 0x81; // final frame, text + if (n > 65535) { + out[at++] = 0x80 | 127; + for (int shift = 56; shift >= 0; shift -= 8) out[at++] = (uint8_t)((uint64_t)n >> shift); + } else if (n > 125) { + out[at++] = 0x80 | 126; + out[at++] = (uint8_t)(n >> 8); + out[at++] = (uint8_t)n; + } else { + out[at++] = (uint8_t)(0x80 | n); + } + memcpy(out + at, mask, 4); + at += 4; + for (size_t i = 0; i < n; i++) out[at + i] = (uint8_t)text[i] ^ mask[i & 3]; + return at + n; +} + +int np_store_wants(const char *type, const char *url) { + return type && url && strcmp(type, "page") == 0 + && strncmp(url, NP_STORE_URL, sizeof(NP_STORE_URL) - 1) == 0; +} + +char *np_store_script(const char *page, const char *map) { + if (!page || !map) return NULL; + const char *at = strstr(page, NP_STORE_PLACE); + // More than one place, or none, is a script this was not written for, and putting it + // in would only throw inside Valve's page. + if (!at || strstr(at + 1, NP_STORE_PLACE)) return NULL; + size_t before = (size_t)(at - page), place = sizeof(NP_STORE_PLACE) - 1; + size_t size = strlen(page) - place + strlen(map) + 1; + char *out = malloc(size); + if (!out) return NULL; + memcpy(out, page, before); + snprintf(out + before, size - before, "%s%s", map, at + place); + return out; +} + +// ---- sockets from here on ---- + +static int dial(void) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + fcntl(fd, F_SETFD, FD_CLOEXEC); + int on = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof on); + struct timeval tv = { NP_STORE_IO_MS / 1000, (NP_STORE_IO_MS % 1000) * 1000 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + struct sockaddr_in to = { 0 }; + to.sin_family = AF_INET; + to.sin_port = htons(NP_STORE_PORT); + to.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (connect(fd, (struct sockaddr *)&to, sizeof to) != 0) { close(fd); return -1; } + return fd; +} + +static int send_all(int fd, const void *data, size_t n) { + const uint8_t *p = data; + while (n) { + ssize_t w = send(fd, p, n, 0); + if (w < 0 && errno == EINTR) continue; + if (w <= 0) return 0; + p += w; + n -= (size_t)w; + } + return 1; +} + +// Reads up to the end of the headers, and returns where the body starts within what was read. +static ssize_t read_head(int fd, char *buf, size_t cap, size_t *got) { + *got = 0; + while (*got < cap - 1) { + ssize_t r = recv(fd, buf + *got, cap - 1 - *got, 0); + if (r < 0 && errno == EINTR) continue; + if (r <= 0) return -1; + *got += (size_t)r; + buf[*got] = '\0'; + const char *end = strstr(buf, "\r\n\r\n"); + if (end) return (ssize_t)(end + 4 - buf); + } + return -1; +} + +// One GET to the debugger. The caller frees the body. +static char *http_get(const char *path) { + int fd = dial(); + if (fd < 0) return NULL; + char req[256]; + int n = snprintf(req, sizeof req, "GET %s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nConnection: close\r\n\r\n", + path, NP_STORE_PORT); + char *buf = malloc(NP_STORE_REPLY_MAX); + char *body = NULL; + size_t got = 0; + ssize_t at = buf && send_all(fd, req, (size_t)n) ? read_head(fd, buf, NP_STORE_REPLY_MAX, &got) : -1; + if (at > 0 && strncmp(buf, "HTTP/1.1 200", 12) == 0) { + // The debugger says how long its answer is and then keeps the connection, so the + // length is what ends the read. + size_t want = 0; + const char *len = strcasestr(buf, "Content-Length:"); + if (len && len < buf + at) want = (size_t)strtoul(len + 15, NULL, 10); + while (want && got - (size_t)at < want && got < NP_STORE_REPLY_MAX - 1) { + ssize_t r = recv(fd, buf + got, NP_STORE_REPLY_MAX - 1 - got, 0); + if (r < 0 && errno == EINTR) continue; + if (r <= 0) break; + got += (size_t)r; + } + buf[got] = '\0'; + if (!want || got - (size_t)at >= want) body = strdup(buf + at); + } + free(buf); + close(fd); + return body; +} + +static int send_text(int fd, const char *text) { + size_t n = strlen(text), cap = n + 16; + uint8_t *frame = malloc(cap); + if (!frame) return 0; + uint8_t mask[4]; + arc4random_buf(mask, sizeof mask); + size_t len = np_store_frame(text, n, mask, frame, cap); + int ok = len && send_all(fd, frame, len); + free(frame); + return ok; +} + +static int send_call(int fd, int id, const char *method, const char *key, const char *value) { + cJSON *msg = cJSON_CreateObject(); + if (!msg) return 0; + cJSON_AddNumberToObject(msg, "id", id); + cJSON_AddStringToObject(msg, "method", method); + if (key) cJSON_AddStringToObject(cJSON_AddObjectToObject(msg, "params"), key, value); + char *text = cJSON_PrintUnformatted(msg); + cJSON_Delete(msg); + int ok = text && send_text(fd, text); + free(text); + return ok; +} + +// Opens the debugger's socket for one page and leaves the script on it: once for every +// page the view goes on to load, and once for the one it is on now. The registration +// lasts as long as the connection does, so the socket is kept. +static int attach(const char *ws_url, const char *script) { + const char *path = strstr(ws_url, "/devtools/"); + if (!path) return -1; + int fd = dial(); + if (fd < 0) return -1; + char req[512]; + // No Origin: the helper refuses a debugger connection that names one it was not told + // to allow, and a program that is not a page has none to name. + int n = snprintf(req, sizeof req, + "GET %s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nUpgrade: websocket\r\n" + "Connection: Upgrade\r\nSec-WebSocket-Key: bm90cHJvdG9uLXN0b3JlIQ==\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n", path, NP_STORE_PORT); + char head[1024]; + size_t got = 0; + if (n <= 0 || (size_t)n >= sizeof req || !send_all(fd, req, (size_t)n) + || read_head(fd, head, sizeof head, &got) < 0 || strncmp(head, "HTTP/1.1 101", 12) != 0 + || !send_call(fd, 1, "Page.enable", NULL, NULL) + || !send_call(fd, 2, "Page.addScriptToEvaluateOnNewDocument", "source", script) + || !send_call(fd, 3, "Runtime.evaluate", "expression", script)) { + close(fd); + return -1; + } + return fd; +} + +typedef struct { int fd; char id[64]; } view_t; + +// Whatever the debugger says back is read and dropped: nothing here acts on it, but a +// socket nobody reads fills up, and a closed one is a view that has gone. +static void drain(view_t *views, int wait_ms) { + struct pollfd fds[NP_STORE_VIEWS]; + int map[NP_STORE_VIEWS], n = 0; + for (int i = 0; i < NP_STORE_VIEWS; i++) + if (views[i].fd >= 0) { fds[n] = (struct pollfd){ views[i].fd, POLLIN, 0 }; map[n++] = i; } + if (!n) { usleep((useconds_t)wait_ms * 1000); return; } + if (poll(fds, (nfds_t)n, wait_ms) <= 0) return; + for (int k = 0; k < n; k++) { + if (!(fds[k].revents & (POLLIN | POLLHUP | POLLERR))) continue; + char sink[4096]; + ssize_t r = recv(fds[k].fd, sink, sizeof sink, MSG_DONTWAIT); + if (r == 0 || (r < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) { + close(fds[k].fd); + views[map[k]].fd = -1; + } + } +} + +static void drop_all(view_t *views) { + for (int i = 0; i < NP_STORE_VIEWS; i++) + if (views[i].fd >= 0) { close(views[i].fd); views[i].fd = -1; } +} + +static void *store_thread(void *unused) { + (void)unused; + view_t views[NP_STORE_VIEWS]; + for (int i = 0; i < NP_STORE_VIEWS; i++) views[i].fd = -1; + int known = 0, said = 0; + + for (;;) { + if (!np_store_enabled()) { drop_all(views); known = 0; sleep(5); continue; } + if (!known) { + // Whatever is listening has to say it is Steam before anything is sent to it: + // the port is a number, and something else could be sitting on it. + char *version = http_get("/json/version"); + known = version && strstr(version, "Valve Steam Client") != NULL; + free(version); + if (!known) { sleep(5); continue; } + if (!said++) NP_LOG("store: Steam's debugger is up on 127.0.0.1:%d", NP_STORE_PORT); + } + char *list = http_get("/json"); + cJSON *targets = list ? cJSON_Parse(list) : NULL; + free(list); + if (!cJSON_IsArray(targets)) { + cJSON_Delete(targets); + drop_all(views); + known = 0; + sleep(3); + continue; + } + cJSON *t; + cJSON_ArrayForEach(t, targets) { + const char *type = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(t, "type")); + const char *url = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(t, "url")); + const char *id = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(t, "id")); + const char *ws = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(t, "webSocketDebuggerUrl")); + if (!id || !ws || !np_store_wants(type, url)) continue; + int have = 0, slot = -1; + for (int i = 0; i < NP_STORE_VIEWS; i++) { + if (views[i].fd >= 0 && strcmp(views[i].id, id) == 0) have = 1; + if (views[i].fd < 0 && slot < 0) slot = i; + } + if (have || slot < 0) continue; + char *map = np_catalog_map(); + char *script = np_store_script(np_store_page_js, map ? map : "{}"); + free(map); + int fd = script ? attach(ws, script) : -1; + free(script); + if (fd < 0) continue; + views[slot].fd = fd; + snprintf(views[slot].id, sizeof views[slot].id, "%s", id); + NP_LOG("store: Mac compatibility added to a store view"); + } + cJSON_Delete(targets); + drain(views, NP_STORE_POLL_MS); + } + return NULL; +} + +void np_store_start(void) { + static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; + static int started; + pthread_mutex_lock(&lock); + int first = !started; + started = 1; + pthread_mutex_unlock(&lock); + if (!first) return; + pthread_t thread; + pthread_attr_t attr; + if (pthread_attr_init(&attr) != 0) return; + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + if (pthread_create(&thread, &attr, store_thread, NULL) != 0) + NP_WARN("store: cannot start the thread, the store shows no Mac compatibility"); + pthread_attr_destroy(&attr); +} diff --git a/dylib/feats/store.h b/dylib/feats/store.h new file mode 100644 index 00000000..27fec983 --- /dev/null +++ b/dylib/feats/store.h @@ -0,0 +1,50 @@ +// Mac compatibility on Steam's store and wishlist pages. +#ifndef NOTPROTON_FEATS_STORE_H +#define NOTPROTON_FEATS_STORE_H + +#include +#include + +// The store is Valve's page, served from the network, so nothing NotProton patches on +// disk reaches it. What does reach it is the debugger built into Steam's web helper: the +// helper is started with it listening on this machine only, and a thread here uses it to +// keep one script registered on the store's pages. +// +// That port is open to anything running on this Mac for as long as Steam is, which is why +// the store has a switch of its own, apart from the one for the badges in the library. +#define NP_STORE_PORT 27101 + +// Whether the store's pages get the block. Off when either switch is: the store's own +// (the "no-store-badges" file) or the one for every badge ("no-mac-badges"). +int np_store_enabled(void); + +// The store's own switch alone, whatever the one for every badge says. For the row in +// Preferences, which shows what was chosen rather than what follows from it. +int np_store_switch(void); + +// Keeps the store's own switch. Takes effect when Steam next starts, since the port is +// decided when the web helper is. +int np_store_set_enabled(int on); + +// The helper's arguments with the debugger's two added, or NULL when they are not +// wanted: the store is off, this is not the helper's own process (its renderers and the +// like carry --type=), or the port is already asked for. The caller frees the array, not +// the strings in it. +char **np_store_helper_args(const char *path, char *const argv[]); + +// Starts the thread that keeps the script on the store's pages. Once per process. +void np_store_start(void); + +// --- the parts with nothing to do with a socket, exposed for the checks --- + +// One masked WebSocket text frame. Returns its length, or 0 when it does not fit. +size_t np_store_frame(const char *text, size_t n, const uint8_t mask[4], uint8_t *out, size_t cap); + +// Whether a debugger target is one of the store's pages. +int np_store_wants(const char *type, const char *url); + +// The page script with the catalogue's map in the one place that takes it. NULL when the +// script does not name that place exactly once. The caller frees it. +char *np_store_script(const char *page, const char *map); + +#endif // NOTPROTON_FEATS_STORE_H diff --git a/dylib/feats/store_page.js b/dylib/feats/store_page.js new file mode 100644 index 00000000..c80346b1 --- /dev/null +++ b/dylib/feats/store_page.js @@ -0,0 +1,202 @@ +// The Mac compatibility block on a store page, beside the Steam Deck one. +// +// This runs inside store.steampowered.com, which is Valve's page and not a file NotProton +// serves, so it is put there through Steam's CEF debugger (see agent.js) rather than by +// the dylib. It reads only what the page already shows: the appid in the address, whether +// the page lists a macOS build, and the anti-cheat notice. The agent fills in the map +// below with the badges this Mac has worked out for its installed games. +(() => { + if (window.__notprotonStore) return; + + const MAP = __NP_MAP__; + const APPLE = 'M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z'; + // The same four as the library's badge and the game page's stat. + const TIERS = { + good: { bg: '#59bf40', fg: '#ffffff', bd: 'none', label: 'Verified' }, + issues: { bg: '#ffc82c', fg: '#1b2838', bd: 'none', label: 'Playable' }, + blocked: { bg: '#d94126', fg: '#ffffff', bd: 'none', label: 'Unsupported' }, + likely: { bg: 'transparent', fg: '#59bf40', bd: '2px solid #59bf40', label: 'Likely' }, + unknown: { bg: 'transparent', fg: '#c6d4df', bd: '1.5px dashed #8b929a', label: 'Unknown' }, + }; + + // What is known about this game, most serious first. What this Mac has actually seen + // of an installed game outranks what the page merely suggests, except that nothing + // outranks an anti-cheat that cannot run here. + function verdict() { + const m = /^\/app\/(\d+)/.exec(location.pathname); + if (!m) return null; + const notice = document.querySelector('.anticheat_section'); + const name = notice && notice.querySelector('.anticheat_name'); + const what = name ? name.firstChild.textContent.trim() : 'an anti-cheat'; + if (notice && /kernel/i.test(notice.textContent)) + return ['blocked', `Uses ${what}, a kernel-level anti-cheat, which does not run under CrossOver on macOS.`]; + if (MAP[m[1]]) return MAP[m[1]]; + if (document.querySelector('.platform_img.mac')) + return ['good', 'This game has a native macOS version, which Steam runs without CrossOver.']; + if (notice) + return ['issues', `Uses ${what}, which may not work under CrossOver on macOS.`]; + return ['unknown', 'Nothing has run this game on this Mac yet. Windows games run through CrossOver.']; + } + + // The same reading of Valve's own Proton testing that advice.c gives a game in the + // library that is not installed, word for word, so a game says one thing everywhere. + function fromValve(mac, deck, anticheat) { + if (anticheat) + return ['blocked', "Valve's testing found this game's anti-cheat does not support Proton, so it will not run under CrossOver either."]; + if (mac) return ['good', 'This game has a native macOS version, which Steam runs without CrossOver.']; + if (deck === 3) return ['likely', 'Verified on Steam Deck through Proton, which is a good sign for CrossOver. It has not run on this Mac yet.']; + if (deck === 2) return ['likely', 'Playable on Steam Deck through Proton, which is a good sign for CrossOver. It has not run on this Mac yet.']; + if (deck === 1) return ['issues', 'Valve marks this game unsupported on Steam Deck, so it may not run under CrossOver. It has not run on this Mac yet.']; + return ['unknown', 'This game has not run on this Mac yet, and Valve has not tested it.']; + } + + // A game this Mac's catalogue has nothing on is asked about where the store itself + // asks: the same two endpoints this page's own Deck block and platform icons are fed + // from. One answer per game per session, two requests at a time, so a long wishlist + // is a trickle and not a burst. + const asked = new Map(); + let running = 0; + const waiting = []; + function pump() { + while (running < 2 && waiting.length) { + running++; + const job = waiting.shift(); + job().finally(() => { running--; pump(); }); + } + } + function lookup(appid) { + if (MAP[appid]) return Promise.resolve(MAP[appid]); + if (asked.has(appid)) return asked.get(appid); + const key = 'notproton-mac-' + appid; + try { + const kept = JSON.parse(sessionStorage.getItem(key)); + if (kept) { const p = Promise.resolve(kept); asked.set(appid, p); return p; } + } catch (e) { /* nothing kept, or nowhere to keep it */ } + const p = new Promise(resolve => { + waiting.push(async () => { + let mac = 0, deck = 0, anticheat = 0; + try { + const r = await (await fetch('/saleaction/ajaxgetdeckappcompatibilityreport?nAppID=' + appid)).json(); + const res = (r && r.results) || {}; + deck = [1, 2, 3].includes(res.resolved_category) ? res.resolved_category : 0; + anticheat = [...(res.resolved_items || []), ...(res.steamos_resolved_items || [])] + .some(i => /UnsupportedAntiCheat/.test(i.loc_token || '')) ? 1 : 0; + } catch (e) { /* no report is an untested game */ } + try { + const d = await (await fetch('/api/appdetails?appids=' + appid + '&filters=platforms')).json(); + mac = d && d[appid] && d[appid].data && d[appid].data.platforms && d[appid].data.platforms.mac ? 1 : 0; + } catch (e) { /* no answer is no macOS build known */ } + const v = fromValve(mac, deck, anticheat); + try { sessionStorage.setItem(key, JSON.stringify(v)); } catch (e) { /* not kept */ } + resolve(v); + }); + pump(); + }); + asked.set(appid, p); + return p; + } + + function el(tag, style, text) { + const n = document.createElement(tag); + Object.assign(n.style, style); + if (text != null) n.textContent = text; + return n; + } + + function disc(tier, px) { + const t = TIERS[tier] || TIERS.unknown; + const d = el('div', { width: px + 'px', height: px + 'px', flex: '0 0 auto', boxSizing: 'border-box', + display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', + background: t.bg, color: t.fg, border: t.bd }); + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); + const w = Math.round(px * 0.46); + svg.setAttribute('viewBox', '0 0 814 1000'); + svg.setAttribute('width', w); + svg.setAttribute('height', Math.round(w * 1000 / 814)); + svg.style.display = 'block'; + svg.style.marginTop = '-1px'; + const path = document.createElementNS(NS, 'path'); + path.setAttribute('d', APPLE); + path.setAttribute('fill', 'currentColor'); + svg.append(path); + d.append(svg); + return d; + } + + // Laid out to the measurements of the Deck block it sits above, so the two read as a + // pair: the same heading, the same row, a disc where the Deck has its icon. + function draw() { + const deck = document.querySelector('[data-featuretarget="deck-verified-results"]'); + if (!deck || document.getElementById('notproton-mac-compat')) return !!deck; + const v = verdict(); + if (!v) return true; + const t = TIERS[v[0]] || TIERS.unknown; + const box = el('div', { padding: '12px 0 4px' }); + box.id = 'notproton-mac-compat'; + box.append(el('div', { color: '#fff', fontSize: '14px', letterSpacing: '0.42px', textTransform: 'uppercase', + fontFamily: '"Motiva Sans", Arial, Helvetica, sans-serif', paddingBottom: '5px' }, 'Mac Compatibility')); + const row = el('div', { display: 'flex', alignItems: 'center', gap: '10px', padding: '14px 24px', + background: 'rgba(0, 0, 0, 0.2)' }); + row.title = v[1]; + row.append(disc(v[0], 22), el('span', { fontSize: '14px', color: '#c6d4df' }, t.label)); + const why = el('div', { fontSize: '12px', lineHeight: '16px', color: '#8f98a0', padding: '8px 24px 0' }, v[1]); + box.append(row, why); + deck.parentNode.insertBefore(box, deck); + // What the page itself shows is drawn at once. A game it says nothing about is then + // asked after, and the row is redrawn with whatever Valve's testing found. + if (v[0] === 'unknown') { + const id = /^\/app\/(\d+)/.exec(location.pathname)[1]; + lookup(id).then(found => { + if (found[0] === 'unknown') return; + row.replaceChildren(disc(found[0], 22), el('span', { fontSize: '14px', color: '#c6d4df' }, (TIERS[found[0]] || TIERS.unknown).label)); + row.title = found[1]; + why.textContent = found[1]; + }); + } + return true; + } + + // The wishlist: one small chip per row, on the right between the date and the price. + // Rows come and go as the list scrolls, so each is marked when it has been done and + // the list is watched for new ones. + function chip(v) { + const t = TIERS[v[0]] || TIERS.unknown; + const c = el('div', { position: 'absolute', right: '20px', top: '50px', display: 'flex', alignItems: 'center', + gap: '7px', padding: '4px 10px 4px 5px', borderRadius: '13px', background: 'rgba(0, 0, 0, 0.28)', + fontSize: '12px', color: '#c6d4df', pointerEvents: 'auto' }); + c.className = 'notproton-mac-chip'; + c.title = v[1]; + c.append(disc(v[0], 18), el('span', {}, 'Mac: ' + t.label)); + return c; + } + function wishlist() { + for (const item of document.querySelectorAll('[data-rfd-draggable-id^="WishlistItem-"]')) { + if (item.dataset.notprotonMac) continue; + const m = /^WishlistItem-(\d+)/.exec(item.dataset.rfdDraggableId); + const panel = item.querySelector('.Panel') || item; + if (!m) continue; + item.dataset.notprotonMac = '1'; + if (getComputedStyle(panel).position === 'static') panel.style.position = 'relative'; + lookup(m[1]).then(v => { if (panel.isConnected && !panel.querySelector('.notproton-mac-chip')) panel.append(chip(v)); }); + } + } + function watchWishlist() { + if (!/^\/wishlist\//.test(location.pathname)) return; + let due = 0; + const soon = () => { if (!due) due = setTimeout(() => { due = 0; wishlist(); }, 150); }; + new MutationObserver(soon).observe(document.documentElement, { childList: true, subtree: true }); + soon(); + } + + // Marked only once everything above has run, so a script that failed part-way is one + // that can be put in again rather than one that claims to be there. + window.__notprotonStore = true; + if (document.documentElement) watchWishlist(); + else document.addEventListener('DOMContentLoaded', watchWishlist); + if (!draw()) { + const again = () => draw(); + document.addEventListener('DOMContentLoaded', again); + window.addEventListener('load', again); + } +})(); diff --git a/dylib/feats/tuning.c b/dylib/feats/tuning.c new file mode 100644 index 00000000..d93e3bfa --- /dev/null +++ b/dylib/feats/tuning.c @@ -0,0 +1,103 @@ +#include "tuning.h" +#include "defaults.h" + +#include +#include + +// An Apple GPU whose core count the registry did not carry is sized as a base part, so an +// unknown GPU is given the help a small one needs rather than the free ride a big one gets. +#define NP_TUNING_ASSUMED_CORES 10 + +// Pixels of the main display per GPU core, which is what decides how much of the frame the +// GPU renders itself and how much MetalFX upscales. The bands come out of what the parts +// are paired with: a 40-core Max driving a 16" panel sits near 190k and needs no help, a +// base chip on the same panel is near 770k and needs the most. +#define NP_TUNING_PPC_HEAVY 600000 +#define NP_TUNING_PPC_MEDIUM 350000 +#define NP_TUNING_PPC_LIGHT 200000 + +// Rosetta only advertises AVX2 from macOS 15, so asking for it on anything older sets a +// flag the translator ignores. +#define NP_TUNING_AVX_MACOS 15 + +// 8 GB is shared with the GPU and with Windows inside the prefix, so a machine with that +// much renders one band lower than its core count alone would ask for. +#define NP_TUNING_TIGHT_RAM_GB 8 + +typedef struct { char *buf; size_t size, len; int ok; } np_out_t; + +static void line(np_out_t *o, const char *key, const char *value) { + if (!o->ok) return; + int n = snprintf(o->buf + o->len, o->size - o->len, "%s=%s\n", key, value); + if (n < 0 || (size_t)n >= o->size - o->len) { o->ok = 0; return; } + o->len += (size_t)n; +} + +// The MetalFX factor for a panel this GPU has to fill, or NULL when it has it covered. +static const char *upscale_factor(const np_machine_t *m) { + long pixels = (long)m->display_w * m->display_h; + if (pixels <= 0) return NULL; // no display to size: nothing to trade away + int cores = m->gpu_cores > 0 ? m->gpu_cores : NP_TUNING_ASSUMED_CORES; + long per = pixels / cores; + if (m->ram_gb > 0 && m->ram_gb <= NP_TUNING_TIGHT_RAM_GB) per = per * 3 / 2; + if (per > NP_TUNING_PPC_HEAVY) return "2.0"; + if (per > NP_TUNING_PPC_MEDIUM) return "1.72"; + if (per > NP_TUNING_PPC_LIGHT) return "1.5"; + return NULL; +} + +// The highest rate the whitelist offers that the panel can still show. Rendering past the +// panel's refresh is heat and fan noise for frames nobody sees, and a rate below it is a +// cap the hardware did not ask for, so the match is exact or it is left unset. +static const char *frame_cap(int refresh_hz) { + static const struct { int hz; const char *value; } caps[] = { + { 144, "144" }, { 120, "120" }, { 60, "60" }, { 40, "40" }, { 30, "30" }, + }; + for (size_t i = 0; i < sizeof caps / sizeof caps[0]; i++) + if (refresh_hz >= caps[i].hz) return caps[i].value; + return NULL; +} + +int np_tuning_recommend(const np_machine_t *m, uint32_t runner, char *out, size_t size) { + if (!out || !size) return 0; + out[0] = '\0'; + if (!m || runner > NP_DEFAULTS_FEX) return 0; + + np_out_t o = { out, size, 0, 1 }; + // DXMT is the D3D11 path both runners share and the only one FEX has. On an Intel Mac + // neither it nor D3DMetal applies, which leaves DXVK over MoltenVK. + const int dxmt = m->apple_silicon != 0; + line(&o, "CX_GRAPHICS_BACKEND", dxmt ? "dxmt" : "dxvk"); + // msync is the fast path for the waits Windows games spend their frame on. + line(&o, "WINEMSYNC", "1"); + // A HUD is a thing to turn on while looking into a game, not a thing to find on. + line(&o, "MTL_HUD_ENABLED", "0"); + // Apple panels are dense, so the game is given their real pixels and MetalFX below + // makes up the difference, which reads sharper than running the whole frame at 1x. + line(&o, "NOTPROTON_RETINA", m->apple_silicon ? "1" : "0"); + + if (runner == NP_DEFAULTS_ROSETTA) { + if (m->macos_major >= NP_TUNING_AVX_MACOS) line(&o, "ROSETTA_ADVERTISE_AVX", "1"); + } else { + // What CrossOver Preview itself runs with: the precision no game has asked for. + line(&o, "FEX_X87REDUCEDPRECISION", "1"); + } + + if (dxmt) { + const char *factor = upscale_factor(m); + const char *fps = frame_cap(m->refresh_hz); + char config[128] = ""; + size_t at = 0; + if (factor) + at += (size_t)snprintf(config + at, sizeof config - at, + "d3d11.metalSpatialUpscaleFactor=%s", factor); + if (fps) + snprintf(config + at, sizeof config - at, "%sd3d11.preferredMaxFrameRate=%s", + at ? ";" : "", fps); + line(&o, "DXMT_METALFX_SPATIAL_SWAPCHAIN", factor ? "1" : "0"); + if (config[0]) line(&o, "DXMT_CONFIG", config); + } + + if (!o.ok) out[0] = '\0'; + return o.ok; +} diff --git a/dylib/feats/tuning.h b/dylib/feats/tuning.h new file mode 100644 index 00000000..b98f5a1d --- /dev/null +++ b/dylib/feats/tuning.h @@ -0,0 +1,19 @@ +// The settings this Mac is best run with, as the lines defaults.c already takes. +#ifndef NOTPROTON_FEATS_TUNING_H +#define NOTPROTON_FEATS_TUNING_H + +#include "machine.h" + +#include +#include + +// Writes "KEY=VALUE\n" lines for `runner` (NP_DEFAULTS_ROSETTA or NP_DEFAULTS_FEX) into +// out, every one of them a key and a value the defaults whitelist accepts. Returns 0 and +// empties out when the runner is neither, or when the lines do not fit. +// +// Only what the hardware decides is set. A toggle whose answer depends on the game, such +// as the DLSS extensions a title may or may not ask for, is left alone so that applying +// this never silently changes how one game behaves on the strength of what the Mac is. +int np_tuning_recommend(const np_machine_t *m, uint32_t runner, char *out, size_t size); + +#endif // NOTPROTON_FEATS_TUNING_H diff --git a/dylib/feats/verdict.c b/dylib/feats/verdict.c new file mode 100644 index 00000000..3486ab50 --- /dev/null +++ b/dylib/feats/verdict.c @@ -0,0 +1,101 @@ +#include "verdict.h" + +#include +#include + +// One clean session is a game that started. Two, with nothing crashed between them, is a +// game that runs, which is as much as a machine can say without being told. +#define NP_VERDICT_SESSIONS 2 +// And it has to have run for long enough to have been played rather than looked at. +#define NP_VERDICT_MINUTES 20 + +// Counts no run could reach, so a record that was edited or half written cannot make the +// page show a number it would have to be wrong about. +#define NP_VERDICT_COUNT_MAX 100000 +#define NP_VERDICT_SECONDS_MAX 100000000 + +#define NP_VERDICT_FORMAT "1" + +static int take_line(FILE *f, char *out, size_t size) { + if (!fgets(out, (int)size, f)) return 0; + size_t n = strlen(out); + if (n && out[n - 1] == '\n') out[--n] = '\0'; + else if (n == size - 1) return 0; // a line longer than the field is not the field + return 1; +} + +static int take_count(FILE *f, long limit, long *out) { + char line[32]; + if (!take_line(f, line, sizeof line) || !line[0]) return 0; + long v = 0; + for (const char *p = line; *p; p++) { + if (*p < '0' || *p > '9') return 0; + v = v * 10 + (*p - '0'); + if (v > limit) return 0; + } + *out = v; + return 1; +} + +// A field the page prints, so it carries no quote, backslash or control character of its +// own: the JSON escapes those, and a value holding them is a record to distrust anyway. +static int take_field(FILE *f, char *out, size_t size) { + if (!take_line(f, out, size)) return 0; + for (const char *p = out; *p; p++) + if ((unsigned char)*p < 0x20 || *p == '"' || *p == '\\') return 0; + return 1; +} + +static np_verdict_tier_t tier_of(const np_verdict_t *v) { + if (!v->healthy && !v->failed) return NP_VERDICT_UNKNOWN; + if (!v->healthy) return NP_VERDICT_BROKEN; + if (v->failed || strcmp(v->last, "failed") == 0) return NP_VERDICT_PLAYABLE; + if (v->healthy >= NP_VERDICT_SESSIONS && v->minutes >= NP_VERDICT_MINUTES) + return NP_VERDICT_VERIFIED; + return NP_VERDICT_PLAYABLE; +} + +int np_verdict_read(const char *compatdata, np_verdict_t *out) { + if (!out) return 0; + memset(out, 0, sizeof *out); + snprintf(out->last, sizeof out->last, "unknown"); + if (!compatdata || !*compatdata) return 0; + + char path[1024]; + if ((size_t)snprintf(path, sizeof path, "%s/notproton-sessions", compatdata) >= sizeof path) + return 0; + FILE *f = fopen(path, "r"); + if (!f) return 0; + + np_verdict_t v; + memset(&v, 0, sizeof v); + char format[16], last[16]; + long healthy = 0, failed = 0, seconds = 0; + int ok = take_line(f, format, sizeof format) && strcmp(format, NP_VERDICT_FORMAT) == 0 + && take_count(f, NP_VERDICT_COUNT_MAX, &healthy) + && take_count(f, NP_VERDICT_COUNT_MAX, &failed) + && take_count(f, NP_VERDICT_SECONDS_MAX, &seconds) + && take_field(f, last, sizeof last) + && take_field(f, v.build, sizeof v.build) + && take_field(f, v.backend, sizeof v.backend); + fclose(f); + if (!ok) return 0; + + v.healthy = (int)healthy; + v.failed = (int)failed; + v.minutes = (int)(seconds / 60); + if (strcmp(last, "healthy") != 0 && strcmp(last, "failed") != 0) snprintf(last, sizeof last, "unknown"); + snprintf(v.last, sizeof v.last, "%s", last); + v.tier = tier_of(&v); + *out = v; + return 1; +} + +const char *np_verdict_name(np_verdict_tier_t tier) { + switch (tier) { + case NP_VERDICT_VERIFIED: return "verified"; + case NP_VERDICT_PLAYABLE: return "playable"; + case NP_VERDICT_BROKEN: return "broken"; + default: return "unknown"; + } +} diff --git a/dylib/feats/verdict.h b/dylib/feats/verdict.h new file mode 100644 index 00000000..e6320301 --- /dev/null +++ b/dylib/feats/verdict.h @@ -0,0 +1,35 @@ +// How a game has actually run on this Mac, from the sessions the run script recorded. +#ifndef NOTPROTON_FEATS_VERDICT_H +#define NOTPROTON_FEATS_VERDICT_H + +#include + +#define NP_VERDICT_FIELD_MAX 64 + +// What the page says about a game. Nothing here is a claim about the game itself: it is +// what this Mac saw, on the builds and settings it was run with. +typedef enum { + NP_VERDICT_UNKNOWN = 0, // never run here, or no session said anything + NP_VERDICT_BROKEN, // it has run and it has only ever crashed + NP_VERDICT_PLAYABLE, // it runs, but not every session came through clean + NP_VERDICT_VERIFIED, // it runs, more than once, and nothing crashed +} np_verdict_tier_t; + +typedef struct { + np_verdict_tier_t tier; + int healthy; // sessions that ended without a Wine crash + int failed; // sessions that crashed inside the first minute + int minutes; // how long the game has run here, in all + char last[16]; // healthy, failed or unknown + char build[NP_VERDICT_FIELD_MAX]; // the CrossOver build of the last session + char backend[NP_VERDICT_FIELD_MAX]; // and the graphics backend it ran with +} np_verdict_t; + +// Reads /notproton-sessions. Returns 0 and leaves `out` an unknown verdict +// when there is no record, or when the one there is not the format this reads. +int np_verdict_read(const char *compatdata, np_verdict_t *out); + +// "unknown", "broken", "playable" or "verified", for the page. +const char *np_verdict_name(np_verdict_tier_t tier); + +#endif // NOTPROTON_FEATS_VERDICT_H diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index a717dabc..da8294a5 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -3,12 +3,14 @@ #include "compatsvc.h" #include "../util/buildid.h" #include "conf.h" +#include "catalog.h" #include "nativeplatform.h" #include "../util/file.h" #include "../util/log.h" #include #include +#include #include #include #include @@ -48,16 +50,20 @@ // Stands for the runner variants read from the support directory. #define NP_VARS "\033" #define NP_OUR_TOOLS "\034" +// Stands for the settings this Mac is best run with, set by np_webpatch_set_tuning. +#define NP_TUNED "\035" #define NP_TOOL_NAME_MAX 128 #define NP_UNREAD_DEFAULTS "{rosetta:null,fex:null}" +// No probe, so no row: the page cannot say what this Mac wants. +#define NP_NO_TUNING "{rosetta:null,fex:null,mac:\"\"}" enum { IN_FALLBACK_TOOL, IN_FEX_TOOLS, IN_DEFAULTS, IN_BUILDS, IN_READY, IN_FEATS, IN_VARS, - IN_OUR_TOOLS, IN_COUNT }; + IN_OUR_TOOLS, IN_TUNED, IN_COUNT }; typedef struct { char *text[IN_COUNT]; } np_inputs_t; static const char *const g_unset[IN_COUNT] = { "", "[]", NP_UNREAD_DEFAULTS, "[]", "{}", "[]", "[]", - "[]" }; + "[]", NP_NO_TUNING }; static pthread_key_t g_inputs_key; static pthread_once_t g_inputs_once = PTHREAD_ONCE_INIT; @@ -176,8 +182,12 @@ static int is_setting_key(const char *str, size_t n) { } static int is_setting_value(const char *str, size_t n) { + // The value is written into a double-quoted JS string, so what it may not carry is a + // quote, a backslash or a control character. DXMT_CONFIG joins its clauses with a + // semicolon, and a value holding more than one of them is still only a string. for (size_t i = 0; i < n; i++) - if (!is_ident_char((unsigned char)str[i]) && str[i] != '.' && str[i] != '=') return 0; + if (!is_ident_char((unsigned char)str[i]) && str[i] != '.' && str[i] != '=' + && str[i] != ';') return 0; return 1; } @@ -221,6 +231,23 @@ static int is_label(const char *str) { return 1; } +void np_webpatch_set_tuning(const char *rosetta, const char *fex, const char *mac) { + // A summary with a quote or a control character in it would land inside a JS string, + // so an unprintable name costs the row its line rather than the page its syntax. + if (mac && !is_label(mac)) mac = NULL; + np_js_t j = js_alloc(48 + 3 * (strlen(rosetta ? rosetta : "") + strlen(fex ? fex : "")) + + strlen(mac ? mac : "")); + js_puts(&j, "{rosetta:"); + js_settings(&j, rosetta); + js_puts(&j, ",fex:"); + js_settings(&j, fex); + js_puts(&j, ",mac:\""); + js_puts(&j, mac ? mac : ""); + js_puts(&j, "\"}"); + if (!j.ok) NP_WARN("webpatch: the settings for this Mac do not fit the page, it hides them"); + set_input(IN_TUNED, js_done(&j)); +} + 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; @@ -311,6 +338,7 @@ static const char *marker_text(char c) { if (c == NP_DEFAULTS[0]) return input_text(IN_DEFAULTS); if (c == NP_BUILDS[0]) return input_text(IN_BUILDS); if (c == NP_OUR_TOOLS[0]) return input_text(IN_OUR_TOOLS); + if (c == NP_TUNED[0]) return input_text(IN_TUNED); if (c == NP_READY[0] || c == NP_FEATS[0]) { if (!g_feat_ready_done) feat_build(); return input_text(c == NP_READY[0] ? IN_READY : IN_FEATS); @@ -1140,9 +1168,17 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { #define NP_PREFIX_LOG NP_WEB_TEXT("Open Log", "Open Log", "Apri registro") #define NP_PREFIX_APP NP_WEB_TEXT("Couldn't find the NotProton app. Move NotProton to Applications, open it and click Install again.", "Couldn't find the NotProton app. Move NotProton to Applications, open it and click Install again.", "App NotProton non trovata. Sposta NotProton in Applicazioni, aprila e fai di nuovo clic su Installa.") +// What a game's own sessions on this Mac came to. Never a claim about the game itself, +// which is why every one of these says "this Mac". +#define NP_VERDICT_VERIFIED_TEXT NP_WEB_TEXT("CrossOver Verified on this Mac", "\u672c\u673a CrossOver \u5df2\u9a8c\u8bc1", "Verificato con CrossOver su questo Mac") +#define NP_VERDICT_PLAYABLE_TEXT NP_WEB_TEXT("Runs on this Mac", "\u53ef\u5728\u672c\u673a\u8fd0\u884c", "Funziona su questo Mac") +#define NP_VERDICT_BROKEN_TEXT NP_WEB_TEXT("Has not run on this Mac", "\u672c\u673a\u5c1a\u672a\u6210\u529f\u8fd0\u884c", "Non \u00e8 ancora partito su questo Mac") +#define NP_VERDICT_RUNS_TEXT NP_WEB_TEXT("runs", "\u8fd0\u884c", "avvii") +#define NP_VERDICT_CRASHES_TEXT NP_WEB_TEXT("crashes", "\u5d29\u6e83", "arresti") + #define NP_CX_PREFIX_ROW \ "globalThis.MSCXPrefixRow=(e,id)=>{if(e.npApp===id)return;e.npStop&&e.npStop();e.npApp=id;let ter" \ - "m='',busy=0,timer=0,t0=0,stopped=0,live=0;const T={need:" \ + "m='',busy=0,timer=0,t0=0,stopped=0,live=0,vlast=null;const T={need:" \ NP_PREFIX_NEED \ ",backup:" \ NP_PREFIX_BACKUP \ @@ -1166,9 +1202,30 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { NP_PREFIX_LOG \ ",app:" \ NP_PREFIX_APP \ + ",vV:" \ + NP_VERDICT_VERIFIED_TEXT \ + ",vP:" \ + NP_VERDICT_PLAYABLE_TEXT \ + ",vB:" \ + NP_VERDICT_BROKEN_TEXT \ + ",vR:" \ + NP_VERDICT_RUNS_TEXT \ + ",vC:" \ + NP_VERDICT_CRASHES_TEXT \ "},el=(tag,cls,txt)=>{const n=e.ownerDocument.createElement(tag);if(cls)n.className=cls;if(txt!=n" \ "ull)n.textContent=txt;return n},btn=(txt,main,off,on)=>{const b=el('button','MSCXBtn'+(main?' MS" \ - "CXMain':''),txt);b.disabled=!!off;b.onclick=on;return b},call=r=>MSCXRoute(r,id).then(j=>JSON.pa" \ + "CXMain':''),txt);b.disabled=!!off;b.onclick=on;return b}," /* The badge: what this game's own sessions here came to, and the numbers behind it. + A game that has never run on this Mac has no verdict, so it gets no badge rather + than a claim nothing stands behind. The last state is kept, so a rebuild starting + does not blank it out. */ \ + "vrow=s=>{const v=s&&s.verdict||'unknown';if('unknown'===v)return null;" \ + "const n=el('div','MSCXVerdict MSCXVerdict-'+v),m=s.minutes|0;" \ + "n.append(el('div','MSCXVerdictTitle','verified'===v?T.vV:'playable'===v?T.vP:T.vB)," \ + "el('div','MSCXVerdictInfo',[T.vR+': '+(s.runs|0),T.vC+': '+(s.crashes|0)," \ + "m>=60?((m/60)|0)+'h '+(m%60)+'m':m+'m'," \ + "s.runBuild&&'CrossOver '+s.runBuild," \ + "s.runBackend&&'auto'!==s.runBackend&&s.runBackend].filter(Boolean).join(' \\u00b7 ')));" \ + "return n},call=r=>MSCXRoute(r,id).then(j=>JSON.pa" \ "rse(j||'{}')),refresh=()=>{if(stopped)return;if(!e.isConnected||e.npApp!==id)return stop();return MSCXRoute('NotProton.PrefixState#1',id).t" \ "hen(j=>draw(JSON.parse(j))).catch(()=>{if(stopped||!e.isConnected||e.npApp!==id)return;clearTimeout(timer);if(busy||live)timer=setTimeout(refresh,500);else e.style.display='none'})},start=r=>{term='';busy=1;t0=Date.now();draw(null);cal" \ "l(r).then(j=>{j.error==='spawn'&&(term='failed')},()=>0).finally(()=>{busy=0;refresh()})},pair=off=>{const r=el('div','MSCXBtns');r.app" \ @@ -1181,8 +1238,15 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { ",0,()=>call('NotProton.OpenPrefixLog#1').catch(()=>0)));k.push(r);s&&s.needsRebuild&&s.app&&!s.r" \ "unning&&k.push(pair(0))}else if(term)k.push(el('div','',term==='done-backup'?T.doneBackup:T.done" \ "));else if(s&&s.needsRebuild){if(!s.app)k.push(el('div','',T.app));else{k.push(el('div','',s.run" \ - "ning?T.running:T.need),pair(s.running));s.running&&(timer=setTimeout(refresh,3e3))}}e.replaceChi" \ - "ldren(...k);e.style.display=k.length?'':'none'};e.style.flexDirection='column';e.style.gap='8px'" \ + "ning?T.running:T.need),pair(s.running));s.running&&(timer=setTimeout(refresh,3e3))}}" \ + /* The badge outlives the rest of the row: it is what the game has done here, not what + the prefix is doing now, so it stays while a rebuild runs, and it alone is reason + enough to show the row at all. */ \ + /* A state that carries no verdict at all is one this row cannot read, so it keeps + the last one it could rather than dropping the badge. A verdict of "unknown" is an + answer, and it does drop it. */ \ + "s&&s.verdict&&(vlast=s);const vb=vrow(vlast);" \ + "e.replaceChildren(...(vb?[vb]:[]).concat(k));e.style.display=vb||k.length?'':'none'};e.style.flexDirection='column';e.style.gap='8px'" \ ";e.style.display='none';const on=v=>{if(!e.isConnected)return e.npStop();v.detail===id&&!busy&&(" \ "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()};" @@ -1210,7 +1274,14 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { ".MSCXBar>div{position:absolute;top:0;bottom:0;left:-30%;width:30%;background:#1a9fff;" \ "animation:MSCXSlide 1.2s linear infinite}" \ "@keyframes MSCXSlide{from{left:-30%}to{left:100%}}" \ - ".MSCXKeys{margin-top:5px;opacity:.75}\"" + ".MSCXKeys{margin-top:5px;opacity:.75}" \ + ".MSCXVerdict{display:flex;flex-direction:column;gap:2px;padding:9px 12px;border-radius:3px;" \ + "background:rgba(59,63,72,.5);border-left:3px solid #67707b}" \ + ".MSCXVerdict-verified{border-left-color:#5ba32b}" \ + ".MSCXVerdict-playable{border-left-color:#e1b000}" \ + ".MSCXVerdict-broken{border-left-color:#c23b22}" \ + ".MSCXVerdictTitle{font-size:14px;line-height:18px;color:#dfe3e6}" \ + ".MSCXVerdictInfo{font-size:12px;line-height:16px;color:#8f98a0}\"" #define NP_CX_BARREL(X) X(XY) X(m) X(Yh) X($n) X(Vb) X(D0) X(y4) X(J1) X(d3) X(gz) #define NP_CX_BARREL_C(n) #n, @@ -1223,6 +1294,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { #define NP_CX_DEFAULTS "(globalThis.MSCXDefaultsData||(globalThis.MSCXDefaultsData=" NP_DEFAULTS "))" #define NP_CX_BUILDS "(globalThis.MSCXBuilds||(globalThis.MSCXBuilds=" NP_BUILDS "))" +#define NP_CX_TUNED "(globalThis.MSCXTuned||(globalThis.MSCXTuned=" NP_TUNED "))" // FEX runs arm64 Wine, which has no D3DMetal and never runs under Rosetta. #define NP_CX_PANEL(RT, BARREL) \ @@ -1378,7 +1450,20 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { with nothing to deliver. */ \ "const npRows=ours?MSCXFeat:MSCXFeat.filter(f=>!f.rn&&(f.ty" \ "?/^NOTPROTON_ENV_/.test(f.k):f.rq||(f.ev||[]).length))," \ + /* What this Mac asks for, worked out by tuning.c from the chip, the GPU, the memory + and the panel. Only the current runner's set is offered, and only the keys in it are + written: a row the hardware does not decide keeps whatever it was set to. */ \ + "npTune=" NP_CX_TUNED ",npTuneSet=npTune[fex?\"fex\":\"rosetta\"]," \ + "npTuneKeys=npTuneSet?Object.keys(npTuneSet):[]," \ + "npTuned=npTuneKeys.length&&npTuneKeys.every(k=>G(k)===npTuneSet[k])," \ "npBody=[npCx&&head," \ + "npCx&&npTuneKeys.length&&(0," RT ".jsx)(" BARREL ".XY," \ + "{label:npL(\"This Mac\",\"本机\",\"Questo Mac\"),children:XF(\"tune\"," \ + "npL(\"Recommended Graphics Settings\",\"推荐的图形设置\",\"Impostazioni grafiche consigliate\")," \ + "npTune.mac||undefined," \ + "[bt(npTuned?npL(\"In Use\",\"已应用\",\"In uso\")" \ + ":npL(\"Use Recommended\",\"使用推荐设置\",\"Usa le impostazioni consigliate\")," \ + "()=>s(npTuneKeys.map(k=>[k,npTuneSet[k]])),\"tune\")])},\"mac\")," \ "npCx&&(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\",w(\"CX_GRAPHICS_BACKEND\",v.data||\"auto\",\"auto\")]]" \ @@ -1501,6 +1586,22 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "bMatchWidth:!1,rgOptions: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," \ + /* The badges on the library, the game page and the store, as one switch. It takes + effect as soon as the tiles next draw, and is kept for the next time Steam starts. */ \ + "(0," RT ".jsx)(" BARREL ".y4,{label:" NP_WEB_TEXT("Show Mac compatibility badges", "\u663e\u793a Mac \u517c\u5bb9\u6027\u5fbd\u7ae0", "Mostra i badge di compatibilit\u00e0 Mac") "," \ + "description:" NP_WEB_TEXT("An Apple badge on each game in your library and on its page, coloured by how it is expected to run on this Mac.", "An Apple badge on each game in your library and on its page, coloured by how it is expected to run on this Mac.", "Un badge Apple su ogni gioco della libreria e sulla sua pagina, colorato in base a come dovrebbe funzionare su questo Mac.") "," \ + "checked:!globalThis.__NOTPROTON_BADGES_OFF__," \ + "onChange:v=>{globalThis.__NOTPROTON_BADGES_OFF__=!v;bump(c=>c+1);" \ + "send(\"NotProton.SetBadges#1\",v?1:0,\"\").catch(e=>console.error(\"NotProton: the badge setting was not saved\",e))}},\"badges\")," \ + /* The store is Valve's page, reached through the web helper's debugger, which is a + port on this Mac that other programs here could use too. That is a different thing + to agree to than a badge, so it has its own switch, and says what it costs. */ \ + "(0," RT ".jsx)(" BARREL ".y4,{label:" NP_WEB_TEXT("Show Mac compatibility in the Store and Wishlist", "\u5728\u5546\u5e97\u548c\u613f\u671b\u5355\u4e2d\u663e\u793a Mac \u517c\u5bb9\u6027", "Mostra la compatibilit\u00e0 Mac nel Negozio e nella Lista dei desideri") "," \ + "description:" NP_WEB_TEXT("Uses Steam's built-in web debugger, which opens a port on this Mac that other programs running here can reach while Steam is open. Changes when Steam restarts.", "Uses Steam's built-in web debugger, which opens a port on this Mac that other programs running here can reach while Steam is open. Changes when Steam restarts.", "Usa il debugger web integrato di Steam, che apre una porta su questo Mac raggiungibile dagli altri programmi in esecuzione mentre Steam \u00e8 aperto. Cambia al riavvio di Steam.") "," \ + "disabled:!!globalThis.__NOTPROTON_BADGES_OFF__," \ + "checked:!globalThis.__NOTPROTON_STORE_OFF__&&!globalThis.__NOTPROTON_BADGES_OFF__," \ + "onChange:v=>{globalThis.__NOTPROTON_STORE_OFF__=!v;bump(c=>c+1);" \ + "send(\"NotProton.SetBadges#1\",v?3:2,\"\").catch(e=>console.error(\"NotProton: the store setting was not saved\",e))}},\"store\")," \ "b?btn(\"apply\",(" NP_WEB_IS_IT "?\"Applica come predefinite a tutti gli strumenti di compatibilit\\u00e0 \"+kind:\"Apply as defaults for all \"+kind+\" Compatibility Tools\")," NP_WEB_TEXT("Apply", "Apply", "Applica") ",apply,ok||void 0):null" \ "]},\"defaults\")})};" \ "return()=>{const up=typeof globalThis.MSCXUseState==\"function\"&&typeof globalThis.MSCXUseEffect==\"function\"" \ @@ -1776,6 +1877,132 @@ static const np_gate_t g_fixes[] = { "\" .SVGIcon_WindowsLogo{width:13px;height:13px}.\"+" NP_C6 "().CheckboxWithImage+" \ "\" .SVGIcon_WindowsLogo rect{fill:inherit}\"}),(0," NP_C2 ".jsx)(" NP_C11 ".Xz0,{})]})" +// The badge on a game's tile: an Apple mark coloured by what this Mac knows about the +// game. Apple's mark is Apple's trademark; it is used here as the Mac indicator the +// project asked for, the way Valve's own badge marks the Deck. +#define NP_APPLE_PATH \ + "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 " \ + "71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 " \ + "40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 " \ + "43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 " \ + "53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 " \ + "83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z" + +// The badge, drawn the way the Deck's are: a filled disc in the tier's colour with the +// mark inside it, since a tinted glyph on its own says very little at this size. Unknown +// is the one that is not filled, the way the Deck's dashed circle is not. +// +// Everything is set on the elements rather than through a stylesheet. A sheet added to +// this page is dropped without a word, and an unstyled badge is one nobody can see. +#define NP_BADGE_TIERS \ + "{good:{bg:\"#59bf40\",fg:\"#ffffff\",bd:\"none\"}," \ + "issues:{bg:\"#ffc82c\",fg:\"#1b2838\",bd:\"none\"}," \ + "blocked:{bg:\"#d94126\",fg:\"#ffffff\",bd:\"none\"}," \ + /* Passed Valve's Proton testing but not yet run here: the green of a good sign, as a + ring rather than a fill, because the fill is for what this Mac has seen. */ \ + "likely:{bg:\"rgba(23,29,37,.82)\",fg:\"#59bf40\",bd:\"2px solid #59bf40\"}," \ + "unknown:{bg:\"rgba(23,29,37,.82)\",fg:\"#c6d4df\",bd:\"1.5px dashed #8b929a\"}}" + +#define NP_BADGE_JS \ + "window.__NOTPROTON_TIERS__=" NP_BADGE_TIERS ";" \ + /* The mark carries its own size, so it never falls back to whatever a renderer + picks for an SVG that has none. It sits a hair high, because the leaf makes the + shape top-heavy and a true centre looks low. */ \ + "window.__NOTPROTON_MARK__=(npR,npPx)=>(0,npR.jsx)(\"svg\"," \ + "{viewBox:\"0 0 814 1000\",width:npPx,height:Math.round(npPx*1000/814)," \ + "focusable:\"false\",style:{display:\"block\",flex:\"0 0 auto\",marginTop:\"-1px\"}," \ + "children:(0,npR.jsx)(\"path\",{d:\"" NP_APPLE_PATH "\",fill:\"currentColor\"})});" \ + "window.__NOTPROTON_DISC__=(npR,npTier,npPx)=>{" \ + "const npT=window.__NOTPROTON_TIERS__[npTier]||window.__NOTPROTON_TIERS__.unknown;" \ + "return(0,npR.jsx)(\"div\",{className:\"NPDisc NPDisc-\"+npTier," \ + "style:{width:npPx+\"px\",height:npPx+\"px\",flex:\"0 0 auto\",boxSizing:\"border-box\"," \ + "display:\"flex\",alignItems:\"center\",justifyContent:\"center\",borderRadius:\"50%\"," \ + "background:npT.bg,color:npT.fg,border:npT.bd}," \ + "children:window.__NOTPROTON_MARK__(npR,Math.round(npPx*.46))})};" \ + /* The tile draws its own icons in a row that only appears under the pointer, so the + badge sits in the corner on its own, above the art and out of the way of clicks. */ \ + /* A tile that has something of its own in the bottom corner gets the badge at the + top instead. The element is copied rather than changed, since the one handed in + may be drawn elsewhere as it is. */ \ + "window.__NOTPROTON_PLACE__=(npEl,npTop)=>{try{" \ + "return!npEl||!npTop?npEl:{...npEl,props:{...npEl.props," \ + "style:{...npEl.props.style,bottom:\"auto\",top:\"8px\"}}}" \ + "}catch(npX){return npEl}};" \ + "window.__NOTPROTON_BADGE__=(npApp,npR,npS)=>{try{" \ + "const npM=window.__NOTPROTON_COMPAT__,npId=npApp&&npApp.appid,npE=npM&&npId&&npM[npId];" \ + "if(!npE||!npR||window.__NOTPROTON_BADGES_OFF__)return null;" \ + "return(0,npR.jsx)(\"div\",{className:\"NPBadge NPBadge-\"+npE[0],title:npE[1]," \ + "style:{position:\"absolute\",left:\"8px\",bottom:\"8px\",zIndex:10,display:\"flex\"," \ + "borderRadius:\"50%\",boxShadow:\"0 1px 4px rgba(0,0,0,.65)\",pointerEvents:\"none\"}," \ + "children:window.__NOTPROTON_DISC__(npR,npE[0],26)})" \ + "}catch(npX){return null}};\n" + +// The same verdict as a stat on the game's page, built to match the ones Steam puts +// beside it: its own icon, its own label, its own detail line. +#define NP_STAT_LABEL NP_WEB_TEXT("MAC", "\u672c\u673a", "MAC") +#define NP_STAT_GOOD NP_WEB_TEXT("Verified", "\u5df2\u9a8c\u8bc1", "Verificato") +#define NP_STAT_ISSUES NP_WEB_TEXT("Playable", "\u53ef\u8fd0\u884c", "Giocabile") +#define NP_STAT_BLOCKED NP_WEB_TEXT("Unsupported", "\u4e0d\u652f\u6301", "Non supportato") +#define NP_STAT_LIKELY NP_WEB_TEXT("Likely", "\u53ef\u80fd\u53ef\u7528", "Probabile") +#define NP_STAT_UNKNOWN NP_WEB_TEXT("Unknown", "\u672a\u77e5", "Sconosciuto") + +#define NP_STAT_JS \ + "window.__NOTPROTON_STAT__=(npP,npR,npS)=>{try{" \ + "const npM=window.__NOTPROTON_COMPAT__," \ + "npId=npP&&(npP.details&&npP.details.unAppID||npP.overview&&npP.overview.appid)," \ + "npE=npM&&npId&&npM[npId];" \ + "if(!npE||!npR||window.__NOTPROTON_BADGES_OFF__)return null;" \ + "const npW={good:" NP_STAT_GOOD ",likely:" NP_STAT_LIKELY ",issues:" NP_STAT_ISSUES ",blocked:" NP_STAT_BLOCKED \ + ",unknown:" NP_STAT_UNKNOWN "}[npE[0]]||" NP_STAT_UNKNOWN ";" \ + "let npC={};try{npC=npS&&npS()||{}}catch(x){}" \ + "return(0,npR.jsxs)(\"div\",{className:(npC.GameStat||\"\")+\" NPStat NPBadge-\"+npE[0]," \ + "title:npE[1],children:[" \ + "(0,npR.jsx)(\"div\",{className:(npC.GameStatIcon||\"\")+\" NPStatIcon\"," \ + "style:{display:\"flex\",alignItems:\"center\",justifyContent:\"center\"}," \ + "children:window.__NOTPROTON_DISC__(npR,npE[0],30)})," \ + "(0,npR.jsxs)(\"div\",{className:npC.GameStatRight||\"\",children:[" \ + "(0,npR.jsx)(\"div\",{className:npC.PlayBarLabel||\"\",children:" NP_STAT_LABEL "})," \ + "(0,npR.jsx)(\"div\",{className:npC.PlayBarDetailLabel||\"\",children:npW})]})]})" \ + "}catch(npX){return null}};\n" + +// The game's own page, where the stat sits in the row with Play Time and Achievements. +// Separate from the tile's gates: one of the two drifting leaves the other working. +static const np_gate_t g_detail_badge[] = { + { "(0," NP_C1 ".jsxs)(\"div\",{className:" NP_C2 "().GameStatsSection,children:[", + "(0," NP_C1 ".jsxs)(\"div\",{className:" NP_C2 "().GameStatsSection,children:[" + "window.__NOTPROTON_STAT__(this.props," NP_C1 "," NP_C2 "),", 1, 0 }, +}; +#define NP_DETAIL_BADGE_COUNT (sizeof(g_detail_badge) / sizeof(g_detail_badge[0])) + +static const np_gate_t g_library_badges[] = { + // Where the tile chooses its compatibility icon. Ours is worked out first, into a + // name the row below can also see, and takes the slot when there is one to show. + { "let " NP_C1 "=null;!" NP_C2 "&&" NP_C3 "?" NP_C1 "=(0," NP_C4 ".jsx)(" NP_C5 + ".g,{display:" NP_C6 ",overview:" NP_C7 ",className:" NP_C8 "().SteamDeckCompatIcon}):!" + NP_C3 "&&" NP_C9 "&&!" NP_C7 ".BIsMusicAlbum()&&(" NP_C1 "=(0," NP_C4 ".jsx)(" NP_C10 + ",{overview:" NP_C7 "}));", + "let npNPB=window.__NOTPROTON_BADGE__(" NP_C7 "," NP_C4 "," NP_C8 ");" + "let " NP_C1 "=null;!" NP_C2 "&&" NP_C3 "?" NP_C1 "=(0," NP_C4 ".jsx)(" NP_C5 + ".g,{display:" NP_C6 ",overview:" NP_C7 ",className:" NP_C8 "().SteamDeckCompatIcon}):!" + NP_C3 "&&" NP_C9 "&&!" NP_C7 ".BIsMusicAlbum()&&(" NP_C1 "=(0," NP_C4 ".jsx)(" NP_C10 + ",{overview:" NP_C7 "}));", 1, 0 }, + /* The row Steam keeps its own icons in only appears while the tile is hovered, so the + badge is put beside that row rather than inside it, as a child of the tile itself. + It is placed by the stylesheet, in the corner the Deck's badge uses. This anchor and + the one above stand or fall together: the whole set is checked before any of it is + written, so npNPB is never read unless it was declared. */ + { "!" NP_C3 "&&!" NP_C4 "&&!" NP_C5 "&&(0," NP_C1 ".jsx)(\"div\",{className:(0," NP_C6 + ".A)(" NP_C2 "().LibraryItemBoxShine," NP_C7 ")}),(0," NP_C1 ".jsx)(\"div\",{className:" + NP_C2 "().LibraryItemOverlayOuterArea,", + /* The first of those three flags is the wide featured tile, which keeps its Play + button in the corner the badge would otherwise take. */ + "!" NP_C3 "&&!" NP_C4 "&&!" NP_C5 "&&(0," NP_C1 ".jsx)(\"div\",{className:(0," NP_C6 + ".A)(" NP_C2 "().LibraryItemBoxShine," NP_C7 ")}),window.__NOTPROTON_PLACE__(npNPB," + NP_C3 "),(0," NP_C1 ".jsx)(\"div\",{className:" NP_C2 "().LibraryItemOverlayOuterArea,", + 1, 0 }, +}; +#define NP_LIBRARY_BADGE_COUNT (sizeof(g_library_badges) / sizeof(g_library_badges[0])) + static const np_gate_t g_library_filters[] = { // Library list and search { "if(!" NP_C1 "(" NP_C2 "[" NP_C3 ".W6]," NP_C4 "(" NP_C5 "))||!" NP_C1 "(" NP_C2 "[" NP_C3 ".sc],", @@ -1963,6 +2190,39 @@ char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, fix_live -= fix_on[NP_NATIVE_MAC_FILTER_FIX] + fix_on[NP_UPDATED_NATIVE_MAC_FILTER_FIX]; fix_on[NP_NATIVE_MAC_FILTER_FIX] = fix_on[NP_UPDATED_NATIVE_MAC_FILTER_FIX] = 0; } + // The tile badges stand on their own: an anchor that drifts costs the badges and + // leaves the Windows filter alone, and a library with nothing known about it shows + // no badge rather than a row of empty ones. + char *badges_js = NULL; + int badges_on[NP_LIBRARY_BADGE_COUNT] = {0}; + int library_badges = 1; + for (size_t g = 0; g < NP_LIBRARY_BADGE_COUNT; g++) { + size_t n = count_matches(s, src_len, g_library_badges[g].find); + badges_on[g] = n == (size_t)g_library_badges[g].expect; + if (badges_on[g]) continue; + if (n) NP_WARN("webpatch: tile badge anchor %zu expected %d occurrence(s), found %zu; " + "no Mac badges", g, g_library_badges[g].expect, n); + library_badges = 0; + } + // The game's own page carries the same verdict as a stat of its own. + int detail_on[NP_DETAIL_BADGE_COUNT] = {0}; + int detail_badge = 1; + for (size_t g = 0; g < NP_DETAIL_BADGE_COUNT; g++) { + size_t n = count_matches(s, src_len, g_detail_badge[g].find); + detail_on[g] = n == (size_t)g_detail_badge[g].expect; + if (detail_on[g]) continue; + if (n) NP_WARN("webpatch: game page stat anchor %zu expected %d occurrence(s), found %zu; " + "no Mac stat", g, g_detail_badge[g].expect, n); + detail_badge = 0; + } + if (library_badges || detail_badge) { + char root[PATH_MAX]; + const char *home = np_home_dir(); + snprintf(root, sizeof root, "%s/Library/Application Support/Steam", home ? home : ""); + badges_js = np_catalog_javascript(root); + if (!badges_js) library_badges = detail_badge = 0; + } + int library_filter = fix_on[NP_NATIVE_MAC_FILTER_FIX] || fix_on[NP_UPDATED_NATIVE_MAC_FILTER_FIX]; int library_on[NP_LIBRARY_FILTER_COUNT] = {0}; for (size_t g = 0; library_filter && g < NP_LIBRARY_FILTER_COUNT; g++) { @@ -1980,9 +2240,10 @@ char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, library_filter = 0; } - if (!shape && !fix_live) { + if (!shape && !fix_live && !library_badges && !detail_badge) { free(native_mac_js); - return NULL; // Not the part carrying the compat UI. + free(badges_js); + return NULL; // Not the part carrying the compat UI or the library tiles. } int gate_on[NP_GATE_MAX] = {0}; @@ -2022,9 +2283,19 @@ char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, !out_put(&out, NP_RUNS_EXE_JS, sizeof(NP_RUNS_EXE_JS) - 1))) { free(out.buf); free(native_mac_js); + free(badges_js); return NULL; } free(native_mac_js); + if ((library_badges || detail_badge) + && (!out_put(&out, badges_js, strlen(badges_js)) + || !out_put(&out, NP_BADGE_JS, sizeof(NP_BADGE_JS) - 1) + || !out_put(&out, NP_STAT_JS, sizeof(NP_STAT_JS) - 1))) { + free(out.buf); + free(badges_js); + return NULL; + } + free(badges_js); unsigned char can_start[256] = {0}; for (size_t g = 0; shape && g < shape->count; g++) if (gate_on[g]) mark_start(can_start, shape->gates[g].find); @@ -2032,6 +2303,10 @@ char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, if (fix_on[f]) mark_start(can_start, g_fixes[f].find); for (size_t g = 0; library_filter && g < NP_LIBRARY_FILTER_COUNT; g++) if (library_on[g]) mark_start(can_start, g_library_filters[g].find); + for (size_t g = 0; library_badges && g < NP_LIBRARY_BADGE_COUNT; g++) + if (badges_on[g]) mark_start(can_start, g_library_badges[g].find); + for (size_t g = 0; detail_badge && g < NP_DETAIL_BADGE_COUNT; g++) + if (detail_on[g]) mark_start(can_start, g_detail_badge[g].find); for (size_t i = 0; i < src_len; ) { size_t plain = i; while (plain < src_len && !can_start[(unsigned char)s[plain]]) plain++; @@ -2057,6 +2332,16 @@ char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, used = match_at(s, src_len, i, g_library_filters[g].find, caps); if (used) { hit = &g_library_filters[g]; break; } } + for (size_t g = 0; !hit && library_badges && g < NP_LIBRARY_BADGE_COUNT; g++) { + if (!badges_on[g]) continue; + used = match_at(s, src_len, i, g_library_badges[g].find, caps); + if (used) { hit = &g_library_badges[g]; break; } + } + for (size_t g = 0; !hit && detail_badge && g < NP_DETAIL_BADGE_COUNT; g++) { + if (!detail_on[g]) continue; + used = match_at(s, src_len, i, g_detail_badge[g].find, caps); + if (used) { hit = &g_detail_badge[g]; break; } + } int ok = hit ? out_expand(&out, hit->replace, caps) : out_put(&out, s + i, 1); if (!ok) { free(out.buf); return NULL; } @@ -2067,6 +2352,8 @@ char *np_webpatch_transform(const uint8_t *src, size_t src_len, size_t *out_len, out.buf[out.len] = '\0'; *out_len = out.len; if (library_filter) NP_LOG("webpatch: Windows library filter button added"); + if (library_badges) NP_LOG("webpatch: Mac compatibility badges added to the library tiles"); + if (detail_badge) NP_LOG("webpatch: Mac compatibility stat added to the game page"); NP_LOG("webpatch: [%s] %zu gates, %zu fixes applied", shape ? shape->name : "no compat UI", gate_live, fix_live); return out.buf; diff --git a/dylib/feats/webpatch.h b/dylib/feats/webpatch.h index 766f0448..5a8c4fc9 100644 --- a/dylib/feats/webpatch.h +++ b/dylib/feats/webpatch.h @@ -24,6 +24,11 @@ void np_webpatch_set_tool_builds(const char *const *names, const char *const *bu // 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); +// The settings this Mac is best run with, per runner, as KEY=VALUE lines, and one line +// naming the Mac they were worked out from. NULL lines, or a summary with a quote or a +// control character in it, hide the row that applies them. +void np_webpatch_set_tuning(const char *rosetta, const char *fex, const char *mac); + #define NP_WEBPATCH_BUILDS_MAX 32 typedef struct { diff --git a/dylib/hooks/hook_spawn.c b/dylib/hooks/hook_spawn.c index 372369cd..31ddea0c 100644 --- a/dylib/hooks/hook_spawn.c +++ b/dylib/hooks/hook_spawn.c @@ -1,5 +1,6 @@ // Strips our own insert and SDL block list from child processes #include "hooks.h" +#include "../feats/store.h" #include "../util/log.h" #include @@ -198,13 +199,18 @@ static char **np_without_insert(char *const envp[]) { static int np_hook_execve(const char *path, char *const argv[], char *const envp[]) { int keep = np_target_keeps_insert(path); char **clean = keep ? np_with_insert(envp) : np_without_insert(envp); - if (!clean) + // Steam starts its web helper this way, after a fork, so this is where the helper is + // given its debugger. See np_spawn_filtered for the other way in. + char **args = np_store_helper_args(path, argv); + if (args) NP_LOG("[spawn] web helper started with its debugger on 127.0.0.1:%d", NP_STORE_PORT); + if (!clean && !args) return orig_execve(path, argv, envp); NP_DBG("[spawn] execve '%s' %s the insert", path, keep ? "with" : "without"); - int rc = orig_execve(path, argv, (char *const *)clean); + int rc = orig_execve(path, args ? (char *const *)args : argv, clean ? (char *const *)clean : envp); // Only reached when the exec failed, since a successful one replaced this image. free(clean); + free(args); return rc; } @@ -215,12 +221,18 @@ static int np_spawn_filtered(fn_posix_spawn orig, const char *api, char *const argv[], char *const envp[]) { int keep = np_target_keeps_insert(path); char **clean = keep ? np_with_insert(envp) : np_without_insert(envp); - if (!clean) + // The web helper is started with its debugger listening on this machine, which is how + // the store's pages are reached. Its own children and every other program are not. + char **args = np_store_helper_args(path, argv); + if (args) NP_LOG("[spawn] web helper started with its debugger on 127.0.0.1:%d", NP_STORE_PORT); + if (!clean && !args) return orig(pid, path, fa, attr, argv, envp); NP_DBG("[spawn] %s '%s' %s the insert", api, path, keep ? "with" : "without"); - int rc = orig(pid, path, fa, attr, argv, (char *const *)clean); + int rc = orig(pid, path, fa, attr, args ? (char *const *)args : argv, + clean ? (char *const *)clean : envp); free(clean); + free(args); return rc; } diff --git a/dylib/tests/advice-check.c b/dylib/tests/advice-check.c new file mode 100644 index 00000000..200f715d --- /dev/null +++ b/dylib/tests/advice-check.c @@ -0,0 +1,179 @@ +// The one answer per game: which backend the game's own API needs, what gets said about +// it, and that the hardware's set is still underneath. +#include "../feats/advice.c" + +#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 const char *value_of(const char *text, const char *key) { + static char value[256]; + size_t klen = strlen(key); + for (const char *at = text; *at; ) { + const char *nl = strchr(at, '\n'); + size_t n = nl ? (size_t)(nl - at) : strlen(at); + if (n > klen && at[klen] == '=' && strncmp(at, key, klen) == 0) { + size_t vlen = n - klen - 1; + if (vlen >= sizeof value) vlen = sizeof value - 1; + memcpy(value, at + klen + 1, vlen); + value[vlen] = '\0'; + return value; + } + at += n + (nl ? 1 : 0); + } + return NULL; +} + +static int says(const char *text, const char *key, const char *want) { + const char *got = value_of(text, key); + return got && strcmp(got, want) == 0; +} + +static np_machine_t this_mac(void) { + np_machine_t m = { 0 }; + m.apple_silicon = 1; + snprintf(m.chip, sizeof m.chip, "Apple M4 Max"); + m.gpu_cores = 40; + m.ram_gb = 64; + m.macos_major = 27; + m.display_w = 3456; + m.display_h = 2234; + m.refresh_hz = 120; + return m; +} + +static np_game_t game(unsigned gfx) { + np_game_t g = { 0 }; + g.scanned = 1; + g.bits = 64; + g.gfx = gfx; + return g; +} + +int main(void) { + const np_machine_t m = this_mac(); + np_advice_t a; + + printf("== the backend the game needs ==\n"); + np_game_t d3d11 = game(NP_GFX_D3D11 | NP_GFX_DXGI); + np_advice(&m, &d3d11, NULL, NP_DEFAULTS_ROSETTA, &a); + check(says(a.settings, "CX_GRAPHICS_BACKEND", "dxmt"), "Direct3D 11 goes to DXMT"); + check(says(a.settings, "WINEMSYNC", "1"), "and the Mac's own set is still underneath"); + + np_game_t d3d12 = game(NP_GFX_D3D12 | NP_GFX_DXGI); + np_advice(&m, &d3d12, NULL, NP_DEFAULTS_ROSETTA, &a); + check(says(a.settings, "CX_GRAPHICS_BACKEND", "d3dmetal"), "Direct3D 12 goes to D3DMetal"); + // Those two are DXMT's own, and DXMT is not what is running the game. + check(says(a.settings, "DXMT_METALFX_SPATIAL_SWAPCHAIN", "0"), "with DXMT's upscaling off"); + check(!value_of(a.settings, "DXMT_CONFIG"), "and DXMT's own config dropped"); + + np_game_t d3d9 = game(NP_GFX_D3D9); + np_advice(&m, &d3d9, NULL, NP_DEFAULTS_ROSETTA, &a); + check(says(a.settings, "CX_GRAPHICS_BACKEND", "dxvk"), "Direct3D 9 goes to DXVK"); + + // A game that asks for neither keeps whatever the Mac was going to run. + np_game_t vulkan = game(NP_GFX_VULKAN); + np_advice(&m, &vulkan, NULL, NP_DEFAULTS_ROSETTA, &a); + check(says(a.settings, "CX_GRAPHICS_BACKEND", "dxmt"), "Vulkan leaves the backend alone"); + np_game_t unread = { 0 }; + np_advice(&m, &unread, NULL, NP_DEFAULTS_ROSETTA, &a); + check(says(a.settings, "CX_GRAPHICS_BACKEND", "dxmt"), "and so does a game never scanned"); + check(a.badge == NP_BADGE_UNKNOWN, "which the badge says nothing about"); + + printf("== what the game's own files change ==\n"); + np_game_t unity = game(NP_GFX_D3D12 | NP_GFX_DXGI); + unity.engine = NP_ENGINE_UNITY; + np_advice(&m, &unity, NULL, NP_DEFAULTS_ROSETTA, &a); + check(strcmp(a.options, "-force-d3d11") == 0, "a Unity game on D3D12 is asked for D3D11"); + check(says(a.settings, "CX_GRAPHICS_BACKEND", "dxmt"), "and then DXMT can carry it"); + + // Unreal's D3D11 path is not the same game, so it is left on D3DMetal. + np_game_t unreal = game(NP_GFX_D3D12 | NP_GFX_DXGI); + unreal.engine = NP_ENGINE_UNREAL; + np_advice(&m, &unreal, NULL, NP_DEFAULTS_ROSETTA, &a); + check(!a.options[0], "an Unreal game on D3D12 is not forced anywhere"); + check(says(a.settings, "CX_GRAPHICS_BACKEND", "d3dmetal"), "it stays on D3DMetal"); + + printf("== the badge ==\n"); + np_game_t eac = game(NP_GFX_D3D11); + eac.anticheat = NP_AC_EASYANTICHEAT; + np_verdict_t ran = { NP_VERDICT_VERIFIED, 9, 0, 300, "healthy", "26.3", "dxmt" }; + np_advice(&m, &eac, &ran, NP_DEFAULTS_ROSETTA, &a); + check(a.badge == NP_BADGE_BLOCKED, "anti-cheat blocks the game"); + check(strstr(a.reason, "Easy Anti-Cheat") != NULL, "and the reason names it"); + check(strstr(a.reason, "does not run") != NULL, "in a sentence that says what that means"); + + // D3D12 has nothing to run on under FEX, which is the game's problem on that runner only. + np_advice(&m, &d3d12, NULL, NP_DEFAULTS_FEX, &a); + check(a.badge == NP_BADGE_ISSUES, "Direct3D 12 under FEX is a problem"); + check(strstr(a.reason, "Rosetta") != NULL, "and the reason says where to go"); + check(!says(a.settings, "CX_GRAPHICS_BACKEND", "d3dmetal"), + "FEX is not told to use a backend it does not have"); + + np_game_t ubi = game(NP_GFX_D3D11); + ubi.launcher = NP_LAUNCHER_UBISOFT; + np_advice(&m, &ubi, NULL, NP_DEFAULTS_ROSETTA, &a); + check(a.badge == NP_BADGE_ISSUES, "a game behind a launcher has something in the way"); + check(strstr(a.reason, "Ubisoft Connect") != NULL, "and the reason names the launcher"); + + np_advice(&m, &d3d11, &ran, NP_DEFAULTS_ROSETTA, &a); + check(a.badge == NP_BADGE_GOOD, "a game this Mac has run clean is good"); + check(strstr(a.reason, "9 times") != NULL, "and the reason counts the runs"); + + np_verdict_t rough = { NP_VERDICT_PLAYABLE, 3, 2, 100, "healthy", "26.3", "dxmt" }; + np_advice(&m, &d3d11, &rough, NP_DEFAULTS_ROSETTA, &a); + check(a.badge == NP_BADGE_ISSUES, "a game that has crashed here has issues"); + check(strstr(a.reason, "2 of its 5") != NULL, "and the reason counts both"); + + np_verdict_t dead = { NP_VERDICT_BROKEN, 0, 4, 2, "failed", "26.3", "dxmt" }; + np_advice(&m, &d3d11, &dead, NP_DEFAULTS_ROSETTA, &a); + check(a.badge == NP_BADGE_BLOCKED, "a game that has only crashed here is blocked"); + + // A clean history never outranks something that cannot work at all. + np_advice(&m, &eac, &ran, NP_DEFAULTS_ROSETTA, &a); + check(a.badge == NP_BADGE_BLOCKED, "anti-cheat outranks a clean history"); + + np_game_t native = game(0); + native.native = 1; + np_advice(&m, &native, NULL, NP_DEFAULTS_ROSETTA, &a); + check(a.badge == NP_BADGE_GOOD, "a game with a macOS build of its own is good"); + check(strstr(a.reason, "native macOS") != NULL, "and the reason says why"); + + printf("== a game that is not installed ==\n"); + char why[NP_ADVICE_REASON_MAX]; + check(np_advice_from_valve(0, NP_DECK_VERIFIED, 0, why, sizeof why) == NP_BADGE_LIKELY, + "verified on Proton is likely, not good"); + check(strstr(why, "has not run on this Mac") != NULL, "and the reason says it has not run here"); + check(np_advice_from_valve(0, NP_DECK_PLAYABLE, 0, why, sizeof why) == NP_BADGE_LIKELY, + "playable on Proton is likely too"); + check(np_advice_from_valve(0, NP_DECK_UNSUPPORTED, 0, why, sizeof why) == NP_BADGE_ISSUES, + "unsupported on Proton has issues"); + check(np_advice_from_valve(0, NP_DECK_UNKNOWN, 0, why, sizeof why) == NP_BADGE_UNKNOWN, + "untested is unknown"); + check(np_advice_from_valve(1, NP_DECK_UNKNOWN, 0, why, sizeof why) == NP_BADGE_GOOD, + "a macOS build is good"); + // An anti-cheat that refuses Proton refuses Wine, so it outranks everything else. + check(np_advice_from_valve(1, NP_DECK_VERIFIED, 1, why, sizeof why) == NP_BADGE_BLOCKED, + "a failed anti-cheat blocks it whatever else is true"); + check(strstr(why, "anti-cheat") != NULL, "and the reason names the anti-cheat"); + + printf("== what a badge is called ==\n"); + check(strcmp(np_badge_name(NP_BADGE_LIKELY), "likely") == 0, "likely"); + check(strcmp(np_badge_name(NP_BADGE_GOOD), "good") == 0, "good"); + check(strcmp(np_badge_name(NP_BADGE_ISSUES), "issues") == 0, "issues"); + check(strcmp(np_badge_name(NP_BADGE_BLOCKED), "blocked") == 0, "blocked"); + check(strcmp(np_badge_name(NP_BADGE_UNKNOWN), "unknown") == 0, "unknown"); + + printf(failures ? "==> advice-check: %d failure(s)\n" : "==> advice-check: every game gets one answer\n", + failures); + return failures ? 1 : 0; +} diff --git a/dylib/tests/buildcheck.sh b/dylib/tests/buildcheck.sh index e6966879..bf6f73fc 100644 --- a/dylib/tests/buildcheck.sh +++ b/dylib/tests/buildcheck.sh @@ -266,6 +266,7 @@ $(extract tool_name) $(extract last_wine_build) $(extract session_health) $(extract set_switch_state) +$(extract record_history) $(extract record_session) $(extract offer_switch_rebuild)" crash_log="$work/crash.log" @@ -333,6 +334,37 @@ switch_world other-build session new-build "$crash_log" 5 is "a game seen working on another build is not blamed on this switch" none "$([ -f "$data/notproton-switch" ] && state || echo none)" +echo "== what the sessions add up to ==" +# The record the Compatibility page reads: healthy, failed, seconds, then the last +# session's health, build and backend. +history() { sed -n "$1p" "$data/notproton-sessions" 2>/dev/null || echo none; } +switch_world old-build +session new-build "$clean_log" 300 +is "a healthy session is counted" "1 0 300 healthy" \ + "$(history 2) $(history 3) $(history 4) $(history 5)" +session new-build "$clean_log" 600 +is "and the next one adds to it" "2 0 900 healthy" \ + "$(history 2) $(history 3) $(history 4) $(history 5)" +is "the build that ran it is recorded" new-build "$(history 6)" +session new-build "$crash_log" 5 +is "a crash is counted apart from the runs" "2 1 905 failed" \ + "$(history 2) $(history 3) $(history 4) $(history 5)" +session new-build "$crash_log" 300 +is "a session that says nothing still counts its time" "2 1 1205 unknown" \ + "$(history 2) $(history 3) $(history 4) $(history 5)" + +# A record that is not this format, or that holds something no run wrote, starts over +# rather than carrying a number the page would then show. +switch_world old-build +printf '9\n7\n7\n7\nhealthy\nb\nd\n' > "$data/notproton-sessions" +session new-build "$clean_log" 300 +is "a record in another format starts over" "1 0 300" "$(history 2) $(history 3) $(history 4)" +printf '1\nmany\n0\n0\nhealthy\nb\nd\n' > "$data/notproton-sessions" +session new-build "$clean_log" 300 +is "a count that is not a number starts over" "1 0 300" "$(history 2) $(history 3) $(history 4)" +session new-build "$clean_log" -5 +is "a session of impossible length adds no time" "2 0 300" "$(history 2) $(history 3) $(history 4)" + if [ "$fails" -eq 0 ]; then echo "==> buildcheck: all assertions hold" else diff --git a/dylib/tests/catalog-check.c b/dylib/tests/catalog-check.c new file mode 100644 index 00000000..b430db9b --- /dev/null +++ b/dylib/tests/catalog-check.c @@ -0,0 +1,200 @@ +// The whole chain on one scratch library: a manifest, the game's files, a rule in the +// support folder, and what comes out -- a badge line, the settings the run script reads, +// and the words the rule says to pass the game. +#include "../feats/catalog.c" + +#include +#include +#include +#include +#include + +// What the appinfo helper would say. A check hands it over, so the games Valve has an +// opinion on are the same on every machine this runs on. +static const char *g_valve; +char *np_nativeplatform_compat(void) { return g_valve ? strdup(g_valve) : NULL; } + +// The store's thread and switch are the store's to check; here they are only counted. +static int g_store_started; +void np_store_start(void) { g_store_started++; } +int np_store_switch(void) { return 1; } + +static int failures; +static char root[PATH_MAX], home[PATH_MAX], steam[PATH_MAX]; + +static void check(const char *what, const char *want, const char *got) { + int ok = strcmp(want, got) == 0; + printf(" %s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) { printf(" want [%s]\n got [%s]\n", want, got); failures++; } +} + +static void has(const char *what, const char *haystack, const char *needle) { + int ok = strstr(haystack, needle) != NULL; + printf(" %s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) { printf(" no [%s] in [%.160s]\n", needle, haystack); failures++; } +} + +static void mkdirs(const char *path) { + char buf[PATH_MAX]; + snprintf(buf, sizeof buf, "%s", path); + for (char *at = buf + 1; *at; at++) { + if (*at != '/') continue; + *at = '\0'; + mkdir(buf, 0755); + *at = '/'; + } + mkdir(buf, 0755); +} + +static void write_at(const char *path, const void *data, size_t len) { + FILE *f = fopen(path, "wb"); + if (!f) { perror(path); exit(1); } + if (len) fwrite(data, 1, len, f); + fclose(f); +} + +static void put_text(const char *path, const char *text) { write_at(path, text, strlen(text)); } + +static char *slurp(const char *path) { + static char buf[4096]; + buf[0] = '\0'; + FILE *f = fopen(path, "r"); + if (!f) return buf; + size_t n = fread(buf, 1, sizeof buf - 1, f); + buf[n] = '\0'; + fclose(f); + return buf; +} + +static void put16(uint8_t *p, uint16_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); } +static void put32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); +} + +// A 64-bit PE that imports one DLL, which is all the scan reads. +static void put_pe(const char *path, const char *dll) { + uint8_t pe[0x400] = { 0 }; + const uint32_t peo = 0x80; + memcpy(pe, "MZ", 2); + put32(pe + 0x3C, peo); + memcpy(pe + peo, "PE\0\0", 4); + put16(pe + peo + 4, 0x8664); + put16(pe + peo + 6, 1); + put16(pe + peo + 20, 240); + put16(pe + peo + 24, 0x20B); + size_t dir_base = peo + 24 + 112; + put32(pe + dir_base - 4, 16); + put32(pe + dir_base + 8, 0x1000); + size_t sect = peo + 24 + 240; + memcpy(pe + sect, ".text", 5); + put32(pe + sect + 8, 0x1000); + put32(pe + sect + 12, 0x1000); + put32(pe + sect + 16, 0x200); + put32(pe + sect + 20, 0x200); + put32(pe + 0x200 + 12, 0x1050); + memcpy(pe + 0x250, dll, strlen(dll)); + write_at(path, pe, sizeof pe); +} + +int main(void) { + snprintf(root, sizeof root, "%s/catalog.XXXXXX", getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + if (!mkdtemp(root)) { perror("mkdtemp"); return 1; } + snprintf(home, sizeof home, "%s/home", root); + snprintf(steam, sizeof steam, "%s/Steam", root); + setenv("HOME", home, 1); + + char path[PATH_MAX], game_dir[PATH_MAX]; + snprintf(path, sizeof path, "%s/Library/Application Support/notproton/rules", home); + mkdirs(path); + // A rule anyone could have dropped in, for every Unreal game. + snprintf(path, sizeof path, "%s/Library/Application Support/notproton/rules/10-ue.conf", home); + put_text(path, "[unreal-movies]\nengine = unreal\noption = -nostartupmovies\n" + "note = Startup movies skipped.\n"); + + // One installed Unreal game, with a prefix, on the library Steam itself is in. + snprintf(game_dir, sizeof game_dir, "%s/steamapps/common/Thing/Thing/Binaries/Win64", steam); + mkdirs(game_dir); + snprintf(path, sizeof path, "%s/steamapps/compatdata/480", steam); + mkdirs(path); + snprintf(path, sizeof path, "%s/Thing-Win64-Shipping.exe", game_dir); + put_pe(path, "d3d12.dll"); + snprintf(path, sizeof path, "%s/steamapps/appmanifest_480.acf", steam); + put_text(path, "\"AppState\"\n{\n\t\"appid\"\t\t\"480\"\n\t\"buildid\"\t\t\"99\"\n" + "\t\"installdir\"\t\t\"Thing\"\n\t\"LastUpdated\"\t\t\"1700000000\"\n}\n"); + + printf("== one pass over the library ==\n"); + int seen = np_catalog_refresh(steam); + check("the installed game is looked at", "1", (char[4]){ (char)('0' + seen), 0 }); + + snprintf(path, sizeof path, "%s/steamapps/compatdata/480/notproton-advice", steam); + const char *advice = slurp(path); + has("the settings name the backend D3D12 needs", advice, "CX_GRAPHICS_BACKEND=d3dmetal"); + has("and the Mac's own set is in them", advice, "WINEMSYNC=1"); + + snprintf(path, sizeof path, "%s/steamapps/compatdata/480/notproton-advice-args", steam); + check("the rule's words are left for the run script", "-nostartupmovies", slurp(path)); + + snprintf(path, sizeof path, "%s/Library/Application Support/notproton/badges", home); + const char *badges = slurp(path); + has("the game gets a badge line", badges, "480 unknown "); + has("whose reason says what it is set up for", badges, "Direct3D 12"); + has("and carries the rule's note", badges, "Startup movies skipped."); + + printf("== what the library is handed ==\n"); + char *js = np_catalog_javascript(steam); + check("there is something to inject", "yes", js ? "yes" : "no"); + if (js) { + has("it defines the map", js, "window.__NOTPROTON_COMPAT__={"); + has("with the game in it", js, "480:[\"unknown\",\""); + free(js); + } + + printf("== the second pass ==\n"); + // The scan is kept beside the prefix, so an unchanged game is not walked again. + snprintf(path, sizeof path, "%s/steamapps/compatdata/480/notproton-scan", steam); + has("the scan was saved", slurp(path), "99-1700000000"); + unlink(path); + snprintf(path, sizeof path, "%s/Thing-Win64-Shipping.exe", game_dir); + unlink(path); + np_catalog_refresh(steam); + snprintf(path, sizeof path, "%s/steamapps/compatdata/480/notproton-advice-args", steam); + check("a game whose files are gone keeps no words", "", slurp(path)); + + printf("== games that are not installed ==\n"); + // Four games nobody downloaded, and the installed one again: Valve failed its + // anti-cheat, which blocks it whatever its folder looked like. + snprintf(path, sizeof path, "%s/Thing-Win64-Shipping.exe", game_dir); + put_pe(path, "d3d12.dll"); + g_valve = "480 0 3 1\n501 1 0 0\n502 0 3 0\n503 0 1 0\n504 0 0 0\n505 0 1 1\nnot a line\n506 9 9 9\n"; + np_catalog_refresh(steam); + snprintf(path, sizeof path, "%s/Library/Application Support/notproton/badges", home); + badges = slurp(path); + has("an installed game Valve failed for anti-cheat is blocked", badges, "480 blocked Valve's testing"); + has("a game with a macOS build is good", badges, "501 good This game has a native macOS"); + has("one Valve verified on Proton is likely", badges, "502 likely Verified on Steam Deck"); + has("one Valve marks unsupported has issues", badges, "503 issues "); + has("one Valve has not tested is unknown", badges, "504 unknown "); + has("one that failed for anti-cheat is blocked without being installed", badges, "505 blocked "); + check("a line that is not one adds no game", "no", strstr(badges, "506 ") ? "yes" : "no"); + + printf("== the switch ==\n"); + check("handing the library its badges starts the store's side too", "yes", g_store_started ? "yes" : "no"); + js = np_catalog_javascript(steam); + has("the badges are on until someone turns them off", js ? js : "", "__NOTPROTON_BADGES_OFF__=false;"); + free(js); + check("turning them off is kept", "1", np_catalog_set_enabled(0) ? "1" : "0"); + js = np_catalog_javascript(steam); + has("and the library is told", js ? js : "", "__NOTPROTON_BADGES_OFF__=true;"); + has("with the map still there, so turning them on again needs no restart", js ? js : "", "502:[\"likely\""); + free(js); + check("turning them on again is kept too", "1", np_catalog_set_enabled(1) ? "1" : "0"); + check("and doing it twice is not an error", "1", np_catalog_set_enabled(1) ? "1" : "0"); + js = np_catalog_javascript(steam); + has("and the library is told that as well", js ? js : "", "__NOTPROTON_BADGES_OFF__=false;"); + free(js); + + printf(failures ? "==> catalog-check: %d failure(s)\n" + : "==> catalog-check: one pass turns a library into badges and settings\n", + failures); + return failures ? 1 : 0; +} diff --git a/dylib/tests/compatpage-check.c b/dylib/tests/compatpage-check.c index d6999243..f6c97c01 100644 --- a/dylib/tests/compatpage-check.c +++ b/dylib/tests/compatpage-check.c @@ -51,6 +51,30 @@ void np_webpatch_set_defaults(const char *rosetta, const char *fex) { snprintf(g_pub_fex, sizeof g_pub_fex, "%s", fex ? fex : ""); } +// The probe reads the real GPU and display, which a check of what the page publishes has +// no use for: a fixed machine keeps the recommended set the same on every Mac this runs on. +static char g_pub_mac[256]; +static int g_pub_tuning_calls; + +void np_webpatch_set_tuning(const char *rosetta, const char *fex, const char *mac) { + g_pub_tuning_calls++; + snprintf(g_pub_mac, sizeof g_pub_mac, "%s", mac ? mac : ""); +} + +const np_machine_t *np_machine(void) { + static const np_machine_t m = { 1, "Apple M4 Max", 40, 16, 64, 27, 3456, 2234, 120 }; + return &m; +} + +void np_machine_summary(const np_machine_t *m, char *out, size_t size) { + snprintf(out, size, "%s", m && m->chip[0] ? m->chip : ""); +} + +int np_tuning_recommend(const np_machine_t *m, uint32_t runner, char *out, size_t size) { + snprintf(out, size, "WINEMSYNC=1\n"); + return 1; +} + 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; diff --git a/dylib/tests/compatsvc-check.c b/dylib/tests/compatsvc-check.c index 691ff9ea..f3ca9ed9 100644 --- a/dylib/tests/compatsvc-check.c +++ b/dylib/tests/compatsvc-check.c @@ -144,6 +144,12 @@ bool np_compat_tool_by_name(const char *name, size_t n, np_compat_tool_info_t *o return false; } +// The badge switch, recorded rather than kept, so the route can be checked on its own. +static int g_badges = -1; +int np_catalog_set_enabled(int on) { g_badges = on; return 1; } +static int g_store = -1; +int np_store_set_enabled(int on) { g_store = on; return 1; } + int np_defaults_save(uint32_t runner, const char *text) { saved_runner = runner; saved_text = text; diff --git a/dylib/tests/envcheck.sh b/dylib/tests/envcheck.sh index 45b72646..37446c87 100755 --- a/dylib/tests/envcheck.sh +++ b/dylib/tests/envcheck.sh @@ -209,6 +209,64 @@ 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##*/}" + +# What the dylib worked out for this one game, read after the settings someone chose: it +# fills in what nothing else said and never argues with what did. +echo "== Settings worked out for the game ==" +STEAM_COMPAT_DATA_PATH="$work/compatdata/480" +mkdir -p "$STEAM_COMPAT_DATA_PATH" +export STEAM_COMPAT_DATA_PATH +printf 'CX_GRAPHICS_BACKEND=d3dmetal\nNOTPROTON_RETINA=1\nMTL_HUD_ENABLED=0\n' \ + > "$STEAM_COMPAT_DATA_PATH/notproton-advice" +clear +eval "$defaults" +is "the advice fills in what the defaults left out" "1" "${NOTPROTON_RETINA-unset}" +is "and the defaults keep what they set" "dxmt 1" "$CX_GRAPHICS_BACKEND $MTL_HUD_ENABLED" +clear +CX_GRAPHICS_BACKEND=wined3d +export CX_GRAPHICS_BACKEND +eval "$defaults" +is "a setting on the game itself beats both" "wined3d" "$CX_GRAPHICS_BACKEND" +unset -v CX_GRAPHICS_BACKEND +# Someone who would rather set everything themselves turns the whole thing off. +clear +: > "$np_support/no-auto-settings" +eval "$defaults" +is "the opt-out file stops the advice" "unset" "${NOTPROTON_RETINA-unset}" +is "and leaves the defaults alone" "dxmt" "$CX_GRAPHICS_BACKEND" +rm -f "$np_support/no-auto-settings" +clear +rm -f "$STEAM_COMPAT_DATA_PATH/notproton-advice" +eval "$defaults" +is "a game with no advice is just the defaults" "unset dxmt" \ + "${NOTPROTON_RETINA-unset} $CX_GRAPHICS_BACKEND" + +# The words a launch rule says the game needs, which are added to the ones it already has. +rule_args=$(sed -n '/^np_rule_args() {$/,/^}$/p' "$SRC") +eval "$rule_args" +printf -- '-nostartupmovies -dx11\n' > "$STEAM_COMPAT_DATA_PATH/notproton-advice-args" +np_advice_file="$STEAM_COMPAT_DATA_PATH/notproton-advice" +is "a rule's words are added" "-nostartupmovies -dx11" \ + "$(np_rule_args /game/thing.exe | tr '\n' ' ' | sed 's/ $//')" +is "a word the game already has is not added twice" "-nostartupmovies" \ + "$(np_rule_args /game/thing.exe -dx11 | tr '\n' ' ' | sed 's/ $//')" +# Every word is judged on its own, and only a plain flag gets through. Nothing here +# reaches a shell -- the words become the game's own arguments -- so what this keeps out +# is a file someone edited by hand into something the game should not be handed. +printf -- 'thing.txt -ok ;rm / -a=1 -x;rm "-q\n' > "$STEAM_COMPAT_DATA_PATH/notproton-advice-args" +is "only plain flags get through" "-ok -a=1" \ + "$(np_rule_args /game/thing.exe | tr '\n' ' ' | sed 's/ $//')" +np_advice_file="" +is "the opt-out stops the words too" "" "$(np_rule_args /game/thing.exe)" +np_advice_file="$STEAM_COMPAT_DATA_PATH/notproton-advice" +verb=run +is "and nothing is added to a verb that is not a launch" "" "$(np_rule_args /game/thing.exe)" +verb=waitforexitandrun +rm -f "$STEAM_COMPAT_DATA_PATH/notproton-advice-args" +is "a game with no words gets none" "" "$(np_rule_args /game/thing.exe)" +unset -v STEAM_COMPAT_DATA_PATH +clear +eval "$defaults" 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" diff --git a/dylib/tests/featcheck.c b/dylib/tests/featcheck.c index d80801c1..d9dc056d 100644 --- a/dylib/tests/featcheck.c +++ b/dylib/tests/featcheck.c @@ -2,6 +2,8 @@ #include #include +#include +#include #include int np_log_level = NP_LVL_WARN; @@ -11,6 +13,13 @@ char *np_nativeplatform_javascript(void) { return NULL; } +// The badges come from the catalogue the dylib keeps. A check hands them over instead, +// so what the tiles do is checked without a Steam library on the machine running it. +char *np_catalog_javascript(const char *steam_root) { + const char *set = getenv("NP_TEST_BADGES"); + return set && *set ? strdup(set) : NULL; +} + static int g_failed; static char g_home[1024]; static char g_featdir[1200]; diff --git a/dylib/tests/game-check.c b/dylib/tests/game-check.c new file mode 100644 index 00000000..e117a0ea --- /dev/null +++ b/dylib/tests/game-check.c @@ -0,0 +1,160 @@ +// The scan of an installed game, against a folder laid out the way the engines lay one +// out, and a PE built by hand so the header walk is read against known bytes. +#include "../feats/game.c" + +#include +#include +#include +#include +#include +#include + +int np_log_level = 0; +FILE *np_log_file = NULL; + +static int failures; +static char root[PATH_MAX]; + +static void check(int ok, const char *what) { + printf(" %s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) failures++; +} + +static void put32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); +} +static void put16(uint8_t *p, uint16_t v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); } + +static void mkdirs(const char *rel) { + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", root, rel); + for (char *at = path + strlen(root) + 1; *at; at++) { + if (*at != '/') continue; + *at = '\0'; + mkdir(path, 0755); + *at = '/'; + } + mkdir(path, 0755); +} + +static void put_file(const char *rel, const void *data, size_t len) { + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", root, rel); + FILE *f = fopen(path, "wb"); + if (!f) { perror(path); exit(1); } + if (len) fwrite(data, 1, len, f); + fclose(f); +} + +// A PE just complete enough to walk: one section, one import descriptor, one DLL name. +// `pad` makes one binary bigger than another, which is how the scan picks between them. +#define PE_SIZE 0x400 +static void put_pe(const char *rel, uint16_t machine, uint16_t magic, const char *dll, size_t pad) { + uint8_t *pe = calloc(1, PE_SIZE + pad); + const uint32_t peo = 0x80; + memcpy(pe, "MZ", 2); + put32(pe + 0x3C, peo); + memcpy(pe + peo, "PE\0\0", 4); + put16(pe + peo + 4, machine); + put16(pe + peo + 6, 1); // one section + put16(pe + peo + 20, 240); // optional header size + put16(pe + peo + 24, magic); + size_t dir_base = peo + 24 + (magic == 0x20B ? 112 : 96); + put32(pe + dir_base - 4, 16); // number of data directories + put32(pe + dir_base + 8, 0x1000); // import table RVA + size_t sect = peo + 24 + 240; + memcpy(pe + sect, ".text", 5); + put32(pe + sect + 8, 0x1000); // virtual size + put32(pe + sect + 12, 0x1000); // virtual address + put32(pe + sect + 16, 0x200); // raw size + put32(pe + sect + 20, 0x200); // raw pointer + put32(pe + 0x200 + 12, 0x1050); // first descriptor's name RVA + memcpy(pe + 0x250, dll, strlen(dll)); + put_file(rel, pe, PE_SIZE + pad); + free(pe); +} + +static np_game_t scan(const char *rel) { + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", root, rel); + np_game_t g; + np_game_scan(path, &g); + return g; +} + +static const char *base(const char *path) { + const char *slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} + +int main(void) { + snprintf(root, sizeof root, "%s/game.XXXXXX", getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + if (!mkdtemp(root)) { perror("mkdtemp"); return 1; } + + printf("== an Unreal game ==\n"); + // Unreal keeps the binary that matters four folders down, with a smaller shim on top. + mkdirs("ue/Game/Binaries/Win64"); + mkdirs("ue/Engine"); + put_pe("ue/Game/Binaries/Win64/Game-Win64-Shipping.exe", 0x8664, 0x20B, "d3d12.dll", 0); + put_pe("ue/GameLauncher.exe", 0x8664, 0x20B, "d3d11.dll", 0x2000); + np_game_t ue = scan("ue"); + check(ue.scanned, "the folder is read"); + check(strcmp(base(ue.exe), "Game-Win64-Shipping.exe") == 0, + "the shipping binary is picked over the bigger shim"); + check(ue.engine == NP_ENGINE_UNREAL, "the engine is Unreal"); + check(ue.bits == 64, "it is 64-bit"); + check((ue.gfx & NP_GFX_D3D12) != 0, "and it asks for Direct3D 12"); + + printf("== a Unity game ==\n"); + mkdirs("unity"); + put_pe("unity/Game.exe", 0x014C, 0x10B, "d3d11.dll", 0); + put_file("unity/UnityPlayer.dll", "", 0); + np_game_t unity = scan("unity"); + check(unity.engine == NP_ENGINE_UNITY, "the engine is Unity"); + check(unity.bits == 32, "a 32-bit binary is read as one"); + check((unity.gfx & NP_GFX_D3D11) != 0, "and it asks for Direct3D 11"); + check((unity.gfx & NP_GFX_D3D12) == 0, "and for nothing it does not"); + + printf("== what the folder gives away ==\n"); + mkdirs("ac/EasyAntiCheat"); + put_pe("ac/Game.exe", 0x8664, 0x20B, "d3d11.dll", 0); + check(scan("ac").anticheat == NP_AC_EASYANTICHEAT, "Easy Anti-Cheat is seen"); + mkdirs("be"); + put_pe("be/Game.exe", 0x8664, 0x20B, "d3d11.dll", 0); + put_file("be/BEService.exe", "", 0); + check(scan("be").anticheat == NP_AC_BATTLEYE, "BattlEye is seen"); + mkdirs("ubi"); + put_pe("ubi/Game.exe", 0x8664, 0x20B, "d3d11.dll", 0); + put_file("ubi/UbisoftConnectInstaller.exe", "", 0); + np_game_t ubi = scan("ubi"); + check(ubi.launcher == NP_LAUNCHER_UBISOFT, "Ubisoft Connect is seen"); + check(strcmp(base(ubi.exe), "Game.exe") == 0, "and its installer is not taken for the game"); + // The EOS SDK is in half the catalogue and starts nothing. + mkdirs("eos"); + put_pe("eos/Game.exe", 0x8664, 0x20B, "d3d11.dll", 0); + put_file("eos/EOSSDK-Win64-Shipping.dll", "", 0); + check(scan("eos").launcher == NP_LAUNCHER_NONE, "the EOS SDK is not a launcher"); + + printf("== a game with its own macOS build ==\n"); + mkdirs("native/Game.app"); + put_pe("native/Game.exe", 0x8664, 0x20B, "d3d11.dll", 0); + check(scan("native").native, "the app bundle is seen"); + + printf("== a shipped library names the API too ==\n"); + mkdirs("shipped"); + put_pe("shipped/Game.exe", 0x8664, 0x20B, "opengl32.dll", 0); + put_file("shipped/d3d12core.dll", "", 0); + check((scan("shipped").gfx & NP_GFX_D3D12) != 0, "a shipped D3D12 layer counts"); + + printf("== nothing to read ==\n"); + np_game_t none; + check(!np_game_scan("/nope/not/here", &none) && !none.scanned, "a folder that is not there"); + check(!np_game_scan(NULL, &none), "and no folder at all"); + mkdirs("empty"); + np_game_t empty = scan("empty"); + check(empty.scanned && !empty.exe[0] && !empty.gfx, "an empty folder is read and says nothing"); + + printf(failures ? "==> game-check: %d failure(s)\n" : "==> game-check: installs read as expected\n", + failures); + return failures ? 1 : 0; +} diff --git a/dylib/tests/gatecheck.c b/dylib/tests/gatecheck.c index 4d53b1d0..5324c51b 100644 --- a/dylib/tests/gatecheck.c +++ b/dylib/tests/gatecheck.c @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include @@ -18,6 +20,13 @@ char *np_nativeplatform_javascript(void) { return g_platform_js ? strdup(g_platform_js) : NULL; } +// The badges come from the catalogue the dylib keeps. A check hands them over instead, +// so what the tiles do is checked without a Steam library on the machine running it. +char *np_catalog_javascript(const char *steam_root) { + const char *set = getenv("NP_TEST_BADGES"); + return set && *set ? strdup(set) : NULL; +} + static int g_wrong; static void wrong(const char *fmt, ...) { diff --git a/dylib/tests/panel-behavior/badge.js b/dylib/tests/panel-behavior/badge.js new file mode 100644 index 00000000..c1ff7dd5 --- /dev/null +++ b/dylib/tests/panel-behavior/badge.js @@ -0,0 +1,171 @@ +// The Apple badge the library tiles get: that it only appears for a game the catalogue +// knows, that it carries the tier and the reason, and that it never throws into a render. +'use strict'; +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); + +const emit = process.argv[2]; +if (!emit) throw new Error('usage: badge.js '); +const src = execFileSync(emit, ['badge-js'], { encoding: 'utf8' }) + + execFileSync(emit, ['stat-js'], { encoding: 'utf8' }); + +const mk = (type, props) => ({ type, props }); +const React = { jsx: mk, jsxs: mk }; + +function world(compat) { + const head = { children: [] }; + const byId = {}; + const doc = { + getElementById: id => byId[id] || null, + createElement: () => ({ set id(v) { byId[v] = this; }, get id() { return this._id; }, textContent: '' }), + head: { appendChild: n => head.children.push(n) }, + }; + // The element records its own id the way the document would. + doc.createElement = () => { + const n = { textContent: '' }; + Object.defineProperty(n, 'id', { set(v) { n._id = v; byId[v] = n; }, get() { return n._id; } }); + return n; + }; + const g = { document: doc, __NOTPROTON_COMPAT__: compat }; + g.window = g; // the page's window is the global, as it is in Steam + new Function('globalThis', 'window', 'document', src)(g, g, doc); + return { g, head, styles: () => ({ SteamDeckCompatIcon: 'DeckIcon' }) }; +} + +const MAP = { + 480: ['good', 'This game has run on this Mac 4 times without crashing.'], + 620: ['blocked', 'This game uses Easy Anti-Cheat, which does not run under CrossOver on macOS.'], + 730: ['issues', 'This game starts through Ubisoft Connect.'], + 440: ['unknown', 'This game has not run on this Mac yet.'], + 550: ['likely', 'Verified on Steam Deck through Proton.'], +}; + +let failed = 0; +const ok = (cond, what) => { if (!cond) failed++; console.log(` ${cond ? 'PASS' : 'FAIL'} ${what}`); }; + +{ + const w = world(MAP); + const badge = w.g.window.__NOTPROTON_BADGE__({ appid: 480 }, React, w.styles); + ok(!!badge, 'a game in the catalogue gets a badge'); + ok(/\bNPBadge-good\b/.test(badge.props.className), `the tier is on the class (${badge.props.className})`); + ok(badge.props.title === MAP[480][1], 'the reason is the tooltip'); + + // Everything the badge needs is on the elements. A stylesheet added to this page is + // dropped without a word, and it would take the badge's place, size and colour with it. + const st = badge.props.style; + ok(st.position === 'absolute', 'the badge places itself'); + ok(st.left && st.bottom, 'in the corner the Deck badge uses'); + ok(Number(st.zIndex) > 0, 'above the art'); + ok(st.pointerEvents === 'none', 'and out of the way of clicks'); + + // A filled disc in the tier's colour, the way the Deck's badges are drawn. + const disc = badge.props.children; + ok(disc.props.style.borderRadius === '50%', 'the badge is a disc'); + ok(disc.props.style.width === '26px' && disc.props.style.height === '26px', 'at a size of its own'); + ok(disc.props.style.background === '#59bf40', `filled with the tier's colour (${disc.props.style.background})`); + const svg = disc.props.children; + ok(svg.type === 'svg' && svg.props.viewBox === '0 0 814 1000', 'with the Apple mark inside'); + ok(svg.props.width > 0 && svg.props.height > 0, 'which carries its own size'); + ok(svg.props.children.props.fill === 'currentColor', 'and takes its colour from the disc'); + + const FILL = { good: '#59bf40', issues: '#ffc82c', blocked: '#d94126' }; + for (const [id, [tier]] of Object.entries(MAP)) { + const b = w.g.window.__NOTPROTON_BADGE__({ appid: +id }, React, w.styles); + ok(b.props.className.includes('NPBadge-' + tier), `${tier} reads as ${tier}`); + const d = b.props.children.props.style; + if (FILL[tier]) ok(d.background === FILL[tier], `and ${tier} is filled ${FILL[tier]}`); + // Unknown is the one that is not filled, the way the Deck's dashed circle is not. + else if (tier === 'unknown') ok(/dashed/.test(d.border), 'and unknown is a dashed ring rather than a fill'); + } +} +{ + const w = world(MAP); + ok(w.g.window.__NOTPROTON_BADGE__({ appid: 1 }, React, w.styles) === null, + 'a game the catalogue says nothing about gets no badge'); + ok(w.g.window.__NOTPROTON_BADGE__(null, React, w.styles) === null, 'and neither does no game'); +} +{ + // Nothing here may throw: it runs inside Steam's own render. + const w = world(undefined); + ok(w.g.window.__NOTPROTON_BADGE__({ appid: 480 }, React, w.styles) === null, + 'no catalogue at all is no badge, not a crash'); + const w2 = world(MAP); + const thrower = () => { throw new Error('no styles here'); }; + const b = w2.g.window.__NOTPROTON_BADGE__({ appid: 480 }, React, thrower); + ok(b && !b.props.className.includes('undefined'), + 'a style module that throws still leaves a badge'); + ok(w2.g.window.__NOTPROTON_BADGE__({ appid: 480 }, null, w2.styles) === null, + 'and no React is no badge, not a crash'); +} + +// Passed Valve's Proton testing but not run here: a green ring, since the fill is kept +// for what this Mac has actually seen. +{ + const w = world(MAP); + const d = w.g.window.__NOTPROTON_BADGE__({ appid: 550 }, React, w.styles).props.children.props.style; + ok(/solid #59bf40/.test(d.border) && d.color === '#59bf40', 'likely is a green ring'); + ok(d.background !== '#59bf40', 'and not the fill a game that ran here gets'); + const stat = w.g.window.__NOTPROTON_STAT__({ details: { unAppID: 550 } }, React, + () => ({ GameStat: 'GS', GameStatIcon: 'GSI', GameStatRight: 'GSR', PlayBarLabel: 'PBL', PlayBarDetailLabel: 'PBD' })); + ok(stat.props.children[1].props.children[1].props.children === 'Likely', 'which the game page calls Likely'); +} + +// The switch in Settings: off is no badge and no stat, and on again brings both back +// without anything being loaded again. +{ + const w = world(MAP); + w.g.window.__NOTPROTON_BADGES_OFF__ = true; + ok(w.g.window.__NOTPROTON_BADGE__({ appid: 480 }, React, w.styles) === null, 'turned off, a tile gets no badge'); + ok(w.g.window.__NOTPROTON_STAT__({ details: { unAppID: 480 } }, React, w.styles) === null, 'and a game page no stat'); + w.g.window.__NOTPROTON_BADGES_OFF__ = false; + ok(!!w.g.window.__NOTPROTON_BADGE__({ appid: 480 }, React, w.styles), 'turned on again, the badge is back'); +} + +// The wide featured tile keeps its Play button in the bottom corner, so the badge moves up. +{ + const w = world(MAP); + const b = w.g.window.__NOTPROTON_BADGE__({ appid: 480 }, React, w.styles); + const top = w.g.window.__NOTPROTON_PLACE__(b, true); + ok(top.props.style.top === '8px' && top.props.style.bottom === 'auto', 'a featured tile gets the badge at the top'); + ok(top.props.style.left === b.props.style.left && top.props.children === b.props.children, + 'and nothing else about it changes'); + ok(b.props.style.bottom === '8px' && b.props.style.top === undefined, 'the element handed in is left as it was'); + ok(w.g.window.__NOTPROTON_PLACE__(b, false) === b, 'any other tile keeps it in the bottom corner'); + ok(w.g.window.__NOTPROTON_PLACE__(null, true) === null, 'and no badge is still no badge'); +} + +// The same verdict as a stat on the game's own page, built like the ones beside it. +{ + const w = world(MAP); + const styles = () => ({ GameStat: 'GS', GameStatIcon: 'GSI', GameStatRight: 'GSR', + PlayBarLabel: 'PBL', PlayBarDetailLabel: 'PBD' }); + const stat = w.g.window.__NOTPROTON_STAT__({ details: { unAppID: 480 } }, React, styles); + ok(!!stat, 'a game in the catalogue gets a stat on its page'); + ok(stat.props.className.includes('GS'), "it uses Steam's own stat class"); + ok(stat.props.className.includes('NPBadge-good'), 'and carries the tier'); + ok(stat.props.title === MAP[480][1], 'the reason is the tooltip'); + const [icon, right] = stat.props.children; + const statDisc = icon.props.children; + ok(statDisc.props.children.props.viewBox === '0 0 814 1000', 'the icon is the Apple mark'); + ok(statDisc.props.style.background === '#59bf40', 'on a disc filled with the tier colour'); + ok(right.props.children[0].props.children === 'MAC', 'the label reads MAC'); + ok(!right.props.style, "and leaves the label and value in Steam's own colours"); + ok(right.props.children[1].props.children === 'Verified', 'and the detail reads Verified'); + + // The page knows the app by either name Steam gives it. + const byOverview = w.g.window.__NOTPROTON_STAT__({ overview: { appid: 620 } }, React, styles); + ok(byOverview.props.className.includes('NPBadge-blocked'), 'an overview names the app too'); + ok(byOverview.props.children[1].props.children[1].props.children === 'Unsupported', + 'and a blocked game reads Unsupported'); + + ok(w.g.window.__NOTPROTON_STAT__({ details: { unAppID: 1 } }, React, styles) === null, + 'a game the catalogue says nothing about gets no stat'); + ok(w.g.window.__NOTPROTON_STAT__({}, React, styles) === null, 'and neither does no app'); + const thrower = () => { throw new Error('no styles'); }; + const safe = w.g.window.__NOTPROTON_STAT__({ details: { unAppID: 480 } }, React, thrower); + ok(safe && !safe.props.className.includes('undefined'), + 'a style module that throws still leaves a stat'); +} + +console.log(failed ? `\nbadge: ${failed} failure(s)` : '\nPASS: the tile badge and the game page stat show the tier, the reason and nothing it should not'); +process.exit(failed ? 1 : 0); diff --git a/dylib/tests/panel-behavior/defaults.js b/dylib/tests/panel-behavior/defaults.js index 3fff4fa1..263322df 100644 --- a/dylib/tests/panel-behavior/defaults.js +++ b/dylib/tests/panel-behavior/defaults.js @@ -308,6 +308,44 @@ 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); + + // The switch for the Mac badges: one row, kept by its own route, felt at once. Only the + // statement shape carries the Settings page at all. + if (form === 'statement') { + const page = openPage(form, BUILDS); + const sw = () => page.r().find(x => x.type === 'ToggleField' && x.props.label === 'Show Mac compatibility badges'); + t.ok(!!sw(), 'the Settings page offers the badge switch'); + t.ok(sw().props.checked === true, 'which is on until someone turns it off'); + sw().props.onChange(false); + await settle(); + t.ok(page.g.__NOTPROTON_BADGES_OFF__ === true, 'turning it off is felt at once'); + const off = page.sent[page.sent.length - 1]; + t.ok(off.route === 'NotProton.SetBadges#1' && off.appid === 0, `and is kept (${off.route}, ${off.appid})`); + t.ok(sw().props.checked === false, 'the row then shows it off'); + sw().props.onChange(true); + await settle(); + const on = page.sent[page.sent.length - 1]; + t.ok(page.g.__NOTPROTON_BADGES_OFF__ === false && on.appid === 1, 'and turning it on again is kept too'); + + // The store has a switch of its own, since what it costs is a port and not a badge. + const store = () => page.r().find(x => x.type === 'ToggleField' && /Store and Wishlist/.test(x.props.label)); + t.ok(!!store() && store().props.checked === true, 'the store has its own switch, on to begin with'); + t.ok(/port on this Mac/.test(store().props.description), 'which says what it opens'); + t.ok(/restart/.test(store().props.description), 'and that it changes when Steam restarts'); + store().props.onChange(false); + await settle(); + const soff = page.sent[page.sent.length - 1]; + t.ok(soff.route === 'NotProton.SetBadges#1' && soff.appid === 2 && page.g.__NOTPROTON_STORE_OFF__ === true, + `turning it off is kept apart from the badges (${soff.appid})`); + t.ok(page.g.__NOTPROTON_BADGES_OFF__ === false, 'which stay on'); + store().props.onChange(true); + await settle(); + t.ok(page.sent[page.sent.length - 1].appid === 3, 'and turning it on again is kept'); + sw().props.onChange(false); + await settle(); + t.ok(store().props.disabled === true && store().props.checked === false, + 'with every badge off, the store row is off and cannot be turned on alone'); + } const nodes = (opts, tool = 'notproton') => walk(P({ details: details(opts, { strCompatToolName: tool }) })); const dropdowns = ns => ns.filter(x => x.type === 'Dropdown'); diff --git a/dylib/tests/panel-behavior/emit.c b/dylib/tests/panel-behavior/emit.c index 22c62ef7..3970712a 100644 --- a/dylib/tests/panel-behavior/emit.c +++ b/dylib/tests/panel-behavior/emit.c @@ -14,6 +14,13 @@ char *np_nativeplatform_javascript(void) { return NULL; } +// The badges come from the catalogue the dylib keeps. A check hands them over instead, +// so what the tiles do is checked without a Steam library on the machine running it. +char *np_catalog_javascript(const char *steam_root) { + const char *set = getenv("NP_TEST_BADGES"); + return set && *set ? strdup(set) : NULL; +} + static void put_panel(const char *body) { for (const char *p = body; *p; p++) { const char *text = marker_text(*p); @@ -31,6 +38,14 @@ static void set_sample_data(void) { { "notproton-empty", "25.0", "CrossOver 25", 0, "" }, }; np_webpatch_set_builds(builds, sizeof builds / sizeof builds[0]); + // What tuning.c works out for the Mac this is running on would make the checks read + // differently on every machine, so the page is handed a fixed pair of sets instead. + // NP_TEST_MAC carries the summary, which lands inside a JS string on the page. + const char *mac = getenv("NP_TEST_MAC"); + np_webpatch_set_tuning("CX_GRAPHICS_BACKEND=dxmt\nNOTPROTON_RETINA=1\n" + "DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=1.5;d3d11.preferredMaxFrameRate=120\n", + "CX_GRAPHICS_BACKEND=dxmt\nFEX_X87REDUCEDPRECISION=1\n", + mac ? mac : "Apple M4 Max, 40 GPU cores, 64 GB, 3456x2234 at 120 Hz"); } int main(int argc, char **argv) { @@ -65,6 +80,14 @@ int main(int argc, char **argv) { for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) puts(names[i]); return 0; } + if (argc == 2 && strcmp(argv[1], "badge-js") == 0) { + fputs(NP_BADGE_JS, stdout); + return 0; + } + if (argc == 2 && strcmp(argv[1], "stat-js") == 0) { + fputs(NP_STAT_JS, stdout); + return 0; + } if (argc == 2 && strcmp(argv[1], "prefix-row") == 0) { fputs(NP_CX_PREFIX_ROW, stdout); return 0; @@ -89,7 +112,7 @@ int main(int argc, char **argv) { fputs(NP_LIBRARY_WINDOWS_SUMMARY, stdout); return 0; } - fputs("usage: emit component|statement[-data] [fallback [fex-tool...]]|migration|compat-hint|" + fputs("usage: emit component|statement[-data] [fallback [fex-tool...]]|migration|compat-hint|badge-js|stat-js|" "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 fad0785f..ff3f779e 100644 --- a/dylib/tests/panel-behavior/harness.js +++ b/dylib/tests/panel-behavior/harness.js @@ -34,13 +34,15 @@ const FORMS = { function panel(emit, form, globals = { document: { documentElement: { lang: 'en' } } }, { fallback = 'notproton', fexTools = [], names = ['R0', 'B0'], data = false, drop = [], ready, feats, vars, + // Extra variables for the emitter, for data it reads from the environment. + env: extraEnv = {}, // What an install would carry. The panel renders for these and no others. tools = ['notproton', 'notproton-26.3', 'notproton-fex', 'notproton-fex-rosetta', 'notproton-rosetta'], } = {}) { // Feature rows are handed over rather than read from disk, so a check reads the same // on any machine and says nothing about which features an install carries. - const env = { ...process.env }; + const env = { ...process.env, ...extraEnv }; if (ready !== undefined) env.NP_TEST_READY = ready; if (feats !== undefined) env.NP_TEST_FEATS = feats; if (vars !== undefined) env.NP_TEST_VARS = vars; diff --git a/dylib/tests/panel-behavior/prefix-row.js b/dylib/tests/panel-behavior/prefix-row.js index 2a32783e..c4363741 100644 --- a/dylib/tests/panel-behavior/prefix-row.js +++ b/dylib/tests/panel-behavior/prefix-row.js @@ -157,5 +157,51 @@ const need = { needsRebuild: true, app: true, running: false, rebuild: '' }; w.listeners.mscxstate({ detail: 480 }); assert(!w.listeners.mscxstate, 'a row that left the page stops listening'); } + // The badge: what the game's own sessions on this Mac came to. + { + const badge = () => w.row.children.find(c => (c.className || '').startsWith('MSCXVerdict')); + const ran = { needsRebuild: false, app: true, running: false, rebuild: '', + verdict: 'verified', runs: 4, crashes: 0, minutes: 192, + runBuild: '26.3', runBackend: 'dxmt' }; + var w = world([ran]); + w.g.MSCXPrefixRow(w.row, 480); await w.settle(); await w.settle(); + assert.equal(w.row.style.display, '', 'a verdict alone is reason enough to show the row'); + assert.equal(badge().className, 'MSCXVerdict MSCXVerdict-verified'); + assert.equal(badge().children[0].textContent, 'CrossOver Verified on this Mac'); + assert.equal(badge().children[1].textContent, 'runs: 4 \u00b7 crashes: 0 \u00b7 3h 12m \u00b7 CrossOver 26.3 \u00b7 dxmt'); + } + { + const w = world([{ needsRebuild: false, app: true, rebuild: '', verdict: 'playable', + runs: 1, crashes: 2, minutes: 7, runBuild: '', runBackend: 'auto' }]); + w.g.MSCXPrefixRow(w.row, 480); await w.settle(); await w.settle(); + const b = w.row.children[0]; + assert.equal(b.className, 'MSCXVerdict MSCXVerdict-playable'); + assert.equal(b.children[0].textContent, 'Runs on this Mac'); + // A build nobody recorded and a backend nobody chose are left off rather than guessed. + assert.equal(b.children[1].textContent, 'runs: 1 \u00b7 crashes: 2 \u00b7 7m'); + } + { + // A game that has never run here gets no badge at all. + const w = world([{ needsRebuild: false, app: true, rebuild: '', verdict: 'unknown', + runs: 0, crashes: 0, minutes: 0 }]); + w.g.MSCXPrefixRow(w.row, 480); await w.settle(); await w.settle(); + assert.equal(w.row.children.length, 0); + assert.equal(w.row.style.display, 'none', 'and nothing to show hides the row'); + } + { + // The badge says what the game has done, not what the prefix is doing, so starting a + // rebuild leaves it where it is. + const w = world([{ needsRebuild: true, app: true, running: false, rebuild: '', + verdict: 'broken', runs: 0, crashes: 3, minutes: 2, + runBuild: '26.3', runBackend: 'dxvk' }, + { needsRebuild: true, app: true, rebuild: 'copy' }]); + w.g.MSCXPrefixRow(w.row, 480); await w.settle(); await w.settle(); + assert.equal(w.row.children[0].children[0].textContent, 'Has not run on this Mac'); + w.buttons()[1].onclick(); await w.settle(); await w.settle(); await w.settle(); + assert.equal(w.row.children[0].className, 'MSCXVerdict MSCXVerdict-broken', + 'the badge stays while the rebuild runs'); + assert(w.texts().includes('Copying your files\u2026'), 'and the rebuild shows under it'); + } console.log('PASS: rebuild row hides, offers, progresses, finishes, fails, waits for the game and finds no app'); + console.log('PASS: the badge shows what this Mac has seen, and nothing when it has seen nothing'); })().catch(e => { console.error(e); process.exit(1); }); diff --git a/dylib/tests/panel-behavior/tuning.js b/dylib/tests/panel-behavior/tuning.js new file mode 100644 index 00000000..7d58b0c8 --- /dev/null +++ b/dylib/tests/panel-behavior/tuning.js @@ -0,0 +1,80 @@ +// The row that applies what this Mac asks for: that it offers the current runner's set, +// writes every line of it, says so once the game already has them, and stays off the page +// when the probe had nothing to say. +'use strict'; +const { panel, walk, details, runner, FORMS } = require('./harness'); + +const emit = process.argv[2]; +if (!emit) { console.error('usage: tuning.js '); process.exit(2); } + +const MAC = 'Apple M4 Max, 40 GPU cores, 64 GB, 3456x2234 at 120 Hz'; +// A value holding a semicolon is quoted on its way into the launch options, the same way +// the MetalFX row beneath already writes one. +const ROSETTA = ['CX_GRAPHICS_BACKEND=dxmt', 'NOTPROTON_RETINA=1', + "DXMT_CONFIG='d3d11.metalSpatialUpscaleFactor=1.5;d3d11.preferredMaxFrameRate=120'"]; +const FEX = ['CX_GRAPHICS_BACKEND=dxmt', 'FEX_X87REDUCEDPRECISION=1']; +const FEX_TOOLS = ['notproton-fex']; + +const section = (ns, label) => ns.find(x => x.type === 'Section' && x.props.label === label); +const button = ns => ns.find(x => x.type === 'Button'); + +let failed = 0; +for (const form of Object.keys(FORMS)) { + const t = runner(form); + + for (const fex of [false, true]) { + const want = fex ? FEX : ROSETTA; + const { render: P, written } = panel(emit, form, undefined, { data: true, fexTools: FEX_TOOLS }); + const tool = fex ? 'notproton-fex' : 'notproton-rosetta'; + const nodes = opts => walk(P({ details: details(opts + ' %command%', { strCompatToolName: tool }) })); + const kind = fex ? 'FEX' : 'Rosetta'; + + const r = nodes(''); + const mac = section(r, 'This Mac'); + t.ok(!!mac, `${kind}: the Mac section is on the page`); + const field = walk(mac).find(x => x.type === 'Field'); + t.ok(field && field.props.description === MAC, `${kind}: it names the Mac (${field && field.props.description})`); + t.ok(button(walk(mac)).props.children === 'Use Recommended', `${kind}: the button offers the set`); + + written.length = 0; + button(walk(mac)).props.onClick(); + const opts = written[written.length - 1].opts; + for (const pair of want) t.ok(opts.includes(pair), `${kind}: it writes ${pair}`); + // The other runner's set is not this one's to write. + const other = fex ? 'NOTPROTON_RETINA=1' : 'FEX_X87REDUCEDPRECISION=1'; + t.ok(!opts.includes(other), `${kind}: and nothing from the other runner (${other})`); + t.ok(opts.includes('%command%'), `${kind}: the command itself is kept (${opts})`); + + // A row the hardware does not decide keeps whatever the game had. + written.length = 0; + button(walk(section(nodes('NOTPROTON_RAW_CONTROLLERS=1'), 'This Mac'))).props.onClick(); + t.ok(written[written.length - 1].opts.includes('NOTPROTON_RAW_CONTROLLERS=1'), + `${kind}: a setting it has no opinion on is left alone`); + + // Applying it twice is applying it once, and the button says as much. + const already = nodes(want.join(' ')); + t.ok(button(walk(section(already, 'This Mac'))).props.children === 'In Use', + `${kind}: a game already on the set reads In Use`); + t.ok(button(walk(section(nodes(want[0]), 'This Mac'))).props.children === 'Use Recommended', + `${kind}: part of the set is not the set`); + } + + // No probe, no row: the page cannot say what this Mac wants, so it offers nothing. + const { render: P0 } = panel(emit, form, undefined, { data: false }); + t.ok(!section(walk(P0({ details: details('%command%') })), 'This Mac'), + 'without a recommendation there is no Mac section'); + + // A summary is a label, not an expression: one that could close the string is dropped + // in C, which costs the row its line and not the page its syntax. + const { render: PE } = panel(emit, form, undefined, + { data: true, env: { NP_TEST_MAC: 'Evil"});alert(1);({' } }); + const evil = section(walk(PE({ details: details('%command%') })), 'This Mac'); + t.ok(!!evil, 'a summary that could break out still leaves the row'); + t.ok(!walk(evil).find(x => x.type === 'Field').props.description, + 'and the row names no Mac at all'); + + failed += t.failed; +} + +console.log(failed ? `\ntuning: ${failed} failure(s)` : '\ntuning rows pass in both shapes'); +process.exit(failed ? 1 : 0); diff --git a/dylib/tests/prefixstate-check.c b/dylib/tests/prefixstate-check.c index e56cf745..8e28ff90 100644 --- a/dylib/tests/prefixstate-check.c +++ b/dylib/tests/prefixstate-check.c @@ -123,7 +123,8 @@ int main(void) { "{\"appid\":480,\"tool\":\"notproton-fex-41073\",\"toolDisplay\":\"CrossOver 2026 10 15-ARM64 - FEX\"," "\"toolArch\":\"fex\",\"prefix\":true,\"prefixArch\":\"rosetta\",\"lastBuild\":\"26.3.0.39832\"," "\"lastDisplay\":\"CrossOver \\\"26.3\\\"\",\"needsRebuild\":true,\"running\":false," - "\"rebuild\":\"\",\"app\":false}", + "\"rebuild\":\"\",\"app\":false,\"verdict\":\"unknown\",\"runs\":0,\"crashes\":0," + "\"minutes\":0,\"lastRun\":\"unknown\",\"runBuild\":\"\",\"runBackend\":\"\"}", state(480, "notproton-fex-41073")); check("the tool's build is what the app is asked to rebuild with", "27.0.0.41073-fex", st.tool_build); check("the prefix is found on the library holding the game", diff --git a/dylib/tests/rules-check.c b/dylib/tests/rules-check.c new file mode 100644 index 00000000..f2250dac --- /dev/null +++ b/dylib/tests/rules-check.c @@ -0,0 +1,137 @@ +// Launch rules: that a rule only reaches the game it names, that what it passes is one +// word at a time, and that a rule naming nothing at all is refused. +#include "../feats/rules.c" + +#include +#include +#include +#include +#include +#include + +int np_log_level = 0; +FILE *np_log_file = NULL; + +static int failures; +static char dir[PATH_MAX]; + +static void check(const char *what, const char *want, const char *got) { + int ok = strcmp(want, got) == 0; + printf(" %s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) { printf(" want [%s]\n got [%s]\n", want, got); failures++; } +} + +static void put(const char *name, const char *text) { + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/%s", dir, name); + FILE *f = fopen(path, "w"); + if (!f) { perror(path); exit(1); } + fputs(text, f); + fclose(f); +} + +static np_game_t game(np_engine_t engine, np_launcher_t launcher, const char *exe) { + np_game_t g = { 0 }; + g.scanned = 1; + g.engine = engine; + g.launcher = launcher; + snprintf(g.exe, sizeof g.exe, "/Games/Thing/%s", exe); + return g; +} + +static const char *options(unsigned appid, const np_game_t *g) { + static char out[NP_RULES_OPTIONS_MAX]; + np_rules_t r; + np_rules_apply(dir, NULL, appid, g, &r); + snprintf(out, sizeof out, "%s", r.options); + return out; +} + +int main(void) { + snprintf(dir, sizeof dir, "%s/rules.XXXXXX", getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + if (!mkdtemp(dir)) { perror("mkdtemp"); return 1; } + + printf("== a rule reaches the game it names ==\n"); + put("10-appid.conf", "[one-game]\nappid = 480\noption = -skipLauncher\n"); + np_game_t plain = game(NP_ENGINE_NONE, NP_LAUNCHER_NONE, "Thing.exe"); + check("the game it names", "-skipLauncher", options(480, &plain)); + check("and no other", "", options(620, &plain)); + + put("20-engine.conf", "[unreal-movies]\nengine = unreal\noption = -nostartupmovies\n"); + np_game_t unreal = game(NP_ENGINE_UNREAL, NP_LAUNCHER_NONE, "Thing-Win64-Shipping.exe"); + check("an engine rule reaches that engine", "-nostartupmovies", options(1, &unreal)); + check("and not another", "", options(1, &plain)); + + // Two conditions is both of them, not either. + put("30-both.conf", "[ea-unity]\nengine = unity\nlauncher = ea\noption = -force-d3d11\n"); + np_game_t unity_ea = game(NP_ENGINE_UNITY, NP_LAUNCHER_EA, "Thing.exe"); + np_game_t unity_only = game(NP_ENGINE_UNITY, NP_LAUNCHER_NONE, "Thing.exe"); + check("both conditions match", "-force-d3d11", options(1, &unity_ea)); + check("one of two does not", "", options(1, &unity_only)); + + // A field given twice is a list, and any entry matches. + put("40-list.conf", "[a-few]\nappid = 10\nappid = 20\noption = -x\n"); + check("the first of a list", "-x", options(10, &plain)); + check("the second of a list", "-x", options(20, &plain)); + check("and nothing else in it", "", options(30, &plain)); + + put("50-exe.conf", "[by-exe]\nexe = Blacklist_DX11_game.exe\noption = -dx11\n"); + np_game_t blacklist = game(NP_ENGINE_NONE, NP_LAUNCHER_NONE, "Blacklist_DX11_game.exe"); + check("a rule for one binary", "-dx11", options(1, &blacklist)); + check("and not for another", "", options(1, &plain)); + + printf("== what the rules add up to ==\n"); + { + char path[PATH_MAX]; + const char *gone[] = { "10-appid.conf", "20-engine.conf", "30-both.conf", + "40-list.conf", "50-exe.conf", NULL }; + for (int i = 0; gone[i]; i++) { + snprintf(path, sizeof path, "%s/%s", dir, gone[i]); + unlink(path); + } + } + put("10-two.conf", "[first]\nengine = unreal\noption = -a\noption = -b\n" + "\n[second]\nengine = unreal\noption = -b\noption = -c\nnote = Started oddly.\n"); + np_rules_t r; + np_rules_apply(dir, NULL, 1, &unreal, &r); + check("two rules join, and a word said twice is said once", "-a -b -c", r.options); + check("the note comes along", "Started oddly.", r.note); + check("and both rules counted", "2", (char[8]){ (char)('0' + r.matched), 0 }); + + printf("== a rule that is not one ==\n"); + put("10-two.conf", ""); + // Nothing to match on would be a rule for every game ever installed. + put("60-no-match.conf", "[everything]\noption = -x\n"); + check("a rule that names no game is refused", "", options(480, &plain)); + put("60-no-match.conf", ""); + // And nothing to say is nothing to do. + put("61-nothing.conf", "[silent]\nappid = 480\n"); + check("a rule with nothing to pass is refused", "", options(480, &plain)); + put("61-nothing.conf", ""); + // An option is one word, passed to a game, so a shell's punctuation is not one. + put("62-shell.conf", "[sneaky]\nappid = 480\noption = -x;rm -rf /\n"); + check("an option carrying a shell's punctuation is refused", "", options(480, &plain)); + put("62-shell.conf", ""); + put("63-unknown.conf", "[odd]\nappid = 480\nengine = cryengine\noption = -x\n"); + check("an engine nothing reads is refused", "", options(480, &plain)); + put("63-unknown.conf", ""); + put("64-appid.conf", "[words]\nappid = four-eighty\noption = -x\n"); + check("an appid that is not a number is refused", "", options(480, &plain)); + put("64-appid.conf", ""); + + printf("== nowhere to read ==\n"); + np_rules_apply("/nope/not/here", NULL, 480, &plain, &r); + check("a folder that is not there says nothing", "", r.options); + np_rules_apply(NULL, NULL, 480, &plain, &r); + check("and neither does no folder", "", r.options); + + printf("== the examples that ship ==\n"); + np_rules_apply("examples/rules", NULL, 1, &unreal, &r); + check("the shipped example reaches an Unreal game", "-nostartupmovies", r.options); + np_rules_apply("examples/rules", NULL, 1, &plain, &r); + check("and nothing else", "", r.options); + + printf(failures ? "==> rules-check: %d failure(s)\n" : "==> rules-check: rules reach their own games\n", + failures); + return failures ? 1 : 0; +} diff --git a/dylib/tests/spawn-env.c b/dylib/tests/spawn-env.c index 41dee3e1..e5cbaef3 100644 --- a/dylib/tests/spawn-env.c +++ b/dylib/tests/spawn-env.c @@ -18,6 +18,10 @@ void *DobbySymbolResolver(const char *image_name, const char *symbol_name) { return NULL; } +// What this checks is the environment a child is given. Its arguments are the store's +// business, and nothing here is the web helper, so they are left as they come. +char **np_store_helper_args(const char *path, char *const argv[]) { return NULL; } + int np_hooks_env_lists_label(const char *var, const char *label) { (void)var; (void)label; return 0; diff --git a/dylib/tests/store-check.c b/dylib/tests/store-check.c new file mode 100644 index 00000000..f2976bdf --- /dev/null +++ b/dylib/tests/store-check.c @@ -0,0 +1,110 @@ +// The store's side with no socket in it: which process gets the debugger, what a frame +// looks like on the wire, which pages are the store's, and the two switches. +#include "../feats/store.c" + +#include +#include +#include +#include + +int np_log_level = 0; +FILE *np_log_file = NULL; + +// The catalogue's map is the catalogue's to check. +char *np_catalog_map(void) { return strdup("{480:[\"good\",\"ok\"]}"); } + +static int failures; + +static void check(int ok, const char *what) { + printf(" %s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) failures++; +} + +static int count(char **argv) { int n = 0; while (argv && argv[n]) n++; return n; } + +int main(void) { + char home[PATH_MAX], dir[PATH_MAX]; + snprintf(home, sizeof home, "%s/store.XXXXXX", getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + if (!mkdtemp(home)) { perror("mkdtemp"); return 1; } + setenv("HOME", home, 1); + snprintf(dir, sizeof dir, "%s/Library", home); mkdir(dir, 0755); + snprintf(dir, sizeof dir, "%s/Library/Application Support", home); mkdir(dir, 0755); + snprintf(dir, sizeof dir, "%s/Library/Application Support/notproton", home); mkdir(dir, 0755); + + printf("== which process gets the debugger ==\n"); + char *helper[] = { "Steam Helper", "-lang=en_US", NULL }; + char **args = np_store_helper_args("/x/Steam Helper.app/Contents/MacOS/Steam Helper", helper); + check(args != NULL, "the web helper itself does"); + check(args && count(args) == 4, "with two arguments added to its own"); + check(args && strcmp(args[0], "Steam Helper") == 0 && strcmp(args[1], "-lang=en_US") == 0, + "and its own left as they were"); + check(args && strcmp(args[2], "--remote-debugging-port=27101") == 0, "the port"); + // The address is what keeps the port off the network. + check(args && strcmp(args[3], "--remote-debugging-address=127.0.0.1") == 0, + "and this machine as the only address"); + free(args); + char *renderer[] = { "Steam Helper", "--type=renderer", NULL }; + check(!np_store_helper_args("/x/Steam Helper", renderer), "one of the helper's children does not"); + char *told[] = { "Steam Helper", "--remote-debugging-port=8080", NULL }; + check(!np_store_helper_args("/x/Steam Helper", told), "a helper already told a port keeps it"); + check(!np_store_helper_args("/x/steam_osx", helper), "Steam itself does not"); + check(!np_store_helper_args("/x/Some Game", helper), "and neither does a game"); + check(!np_store_helper_args(NULL, helper) && !np_store_helper_args("/x/Steam Helper", NULL), + "nothing to start is nothing to add to"); + + printf("== the switches ==\n"); + check(np_store_enabled() && np_store_switch(), "the store is on until someone turns it off"); + check(np_store_set_enabled(0) && !np_store_switch() && !np_store_enabled(), "turning it off is kept"); + check(!np_store_helper_args("/x/Steam Helper", helper), "and the helper then starts without the debugger"); + check(np_store_set_enabled(1) && np_store_enabled(), "turning it on again is kept"); + check(np_store_set_enabled(1), "and doing it twice is not an error"); + // The switch for every badge turns the store off with the rest, without changing + // what the store's own switch was left at. + snprintf(dir, sizeof dir, "%s/Library/Application Support/notproton/no-mac-badges", home); + FILE *f = fopen(dir, "w"); if (f) fclose(f); + check(!np_store_enabled() && np_store_switch(), "every badge off is the store off, its own switch untouched"); + check(!np_store_helper_args("/x/Steam Helper", helper), "and no debugger then either"); + unlink(dir); + + printf("== a frame on the wire ==\n"); + const uint8_t mask[4] = { 0x11, 0x22, 0x33, 0x44 }; + uint8_t out[70000]; + size_t n = np_store_frame("hi", 2, mask, out, sizeof out); + check(n == 8 && out[0] == 0x81 && out[1] == (0x80 | 2), "a short one says its length in a byte, masked"); + check(memcmp(out + 2, mask, 4) == 0 && out[6] == ('h' ^ 0x11) && out[7] == ('i' ^ 0x22), + "and carries the mask and the text under it"); + static char big[66000]; + memset(big, 'a', sizeof big); + n = np_store_frame(big, 300, mask, out, sizeof out); + check(n == 300 + 8 && out[1] == (0x80 | 126) && out[2] == 1 && out[3] == 44, "a longer one takes two more bytes"); + n = np_store_frame(big, 66000, mask, out, sizeof out); + check(n == 66000 + 14 && out[1] == (0x80 | 127) && out[8] == 0x01 && out[9] == 0xD0, + "and a page script's worth takes eight"); + check(out[10] == mask[0] && out[14] == ('a' ^ mask[0]) && out[15] == ('a' ^ mask[1]), "masked the same way"); + check(np_store_frame(big, 66000, mask, out, 100) == 0, "one that does not fit is not written"); + + printf("== which pages are the store's ==\n"); + check(np_store_wants("page", "https://store.steampowered.com/app/480/"), "an app page"); + check(np_store_wants("page", "https://store.steampowered.com/wishlist/id/x/"), "the wishlist"); + check(!np_store_wants("page", "https://steamloopback.host/index.html"), "not Steam's own interface"); + check(!np_store_wants("page", "https://store.steampowered.com.evil.example/"), "not a look-alike host"); + check(!np_store_wants("page", "https://steamcommunity.com/"), "not the community"); + check(!np_store_wants("service_worker", "https://store.steampowered.com/"), "not a worker"); + check(!np_store_wants(NULL, NULL), "and not nothing"); + + printf("== the script the pages are given ==\n"); + char *js = np_store_script("a(__NP_MAP__)b", "{1:2}"); + check(js && strcmp(js, "a({1:2})b") == 0, "the map goes in the one place that takes it"); + free(js); + check(!np_store_script("a b", "{}"), "a script with no place for it is refused"); + check(!np_store_script("__NP_MAP__ and __NP_MAP__", "{}"), "and so is one with two"); + js = np_store_script(np_store_page_js, "{480:[\"good\",\"ok\"]}"); + check(js != NULL, "the script that ships has exactly one"); + check(js && strstr(js, "const MAP = {480:[\"good\",\"ok\"]};") != NULL, "and it is where the map is read"); + check(js && !strstr(js, "__NP_MAP__"), "with nothing left to fill in"); + free(js); + + printf(failures ? "==> store-check: %d failure(s)\n" + : "==> store-check: the debugger goes to the helper alone, and the script goes in whole\n", failures); + return failures ? 1 : 0; +} diff --git a/dylib/tests/store-page.js b/dylib/tests/store-page.js new file mode 100644 index 00000000..41f8d9a9 --- /dev/null +++ b/dylib/tests/store-page.js @@ -0,0 +1,128 @@ +// The script the store's pages are given, run against a page made of just enough to +// stand on: what it says about a game, where it puts it, and that it says the same +// thing advice.c says about a game that is not installed. +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.join(__dirname, '..'); +const src = fs.readFileSync(path.join(root, 'feats/store_page.js'), 'utf8'); +const advice = fs.readFileSync(path.join(root, 'feats/advice.c'), 'utf8'); + +let failed = 0; +const ok = (cond, what) => { if (!cond) failed++; console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}`); }; + +function node(tag) { + const n = { tag, style: {}, children: [], dataset: {}, attrs: {}, isConnected: true, textContent: '', + append(...c) { for (const x of c) { if (x && typeof x === 'object') x.parent = n; n.children.push(x); } }, + replaceChildren(...c) { n.children = []; n.append(...c); }, + setAttribute(k, v) { n.attrs[k] = v; }, + querySelector(sel) { return find(n, sel); }, + }; + return n; +} +function find(n, sel) { + for (const c of n.children) { + if (!c || typeof c !== 'object') continue; + if (sel === '.Panel' && c.className === 'Panel') return c; + if (sel === '.notproton-mac-chip' && c.className === 'notproton-mac-chip') return c; + const deep = find(c, sel); + if (deep) return deep; + } + return null; +} +const text = n => !n || typeof n !== 'object' ? '' : (n.textContent || '') + n.children.map(text).join(' '); + +// A page: where it is, what the store marked up on it, and what its own endpoints say. +function page({ pathname, map = {}, mac = false, anticheat = null, deck = true, report = {}, platforms = {}, rows = [] }) { + const inserted = []; + const deckBlock = { parentNode: { insertBefore: b => inserted.push(b) } }; + const notice = anticheat && { textContent: anticheat.text, + querySelector: s => s === '.anticheat_name' ? { firstChild: { textContent: anticheat.name } } : null }; + const fetched = []; + const stored = {}; + const document = { + documentElement: {}, getElementById: id => inserted.find(b => b.id === id) || null, + createElement: node, createElementNS: (ns, tag) => node(tag), addEventListener() {}, + querySelector: s => s.includes('deck-verified-results') ? (deck ? deckBlock : null) + : s === '.anticheat_section' ? notice : s === '.platform_img.mac' ? (mac ? {} : null) : null, + querySelectorAll: s => s.includes('WishlistItem-') ? rows : [], + }; + const window = { addEventListener() {} }; + const ctx = { window, document, location: { pathname }, getComputedStyle: () => ({ position: 'static' }), + sessionStorage: { getItem: k => stored[k] || null, setItem: (k, v) => { stored[k] = v; } }, + MutationObserver: class { observe() {} }, setTimeout: f => { f(); return 1; }, + fetch: async url => { fetched.push(url); const id = /(\d+)/.exec(url)[1]; + return { json: async () => url.includes('deckapp') ? { results: report[id] || {} } + : { [id]: { data: { platforms: { mac: !!platforms[id] } } } } }; }, + JSON, Promise, Math, Map }; + vm.runInNewContext(src.split('__NP_MAP__').join(JSON.stringify(map)), ctx); + return { window, inserted, fetched, settle: () => new Promise(r => setImmediate(() => setImmediate(r))) }; +} + +(async () => { + console.log('== an app page =='); + { + const p = page({ pathname: '/app/480/Thing/', map: { 480: ['good', 'This game has run on this Mac 4 times without crashing.'] } }); + ok(p.window.__notprotonStore === true, 'the script marks itself once it has run'); + ok(p.inserted.length === 1 && p.inserted[0].id === 'notproton-mac-compat', 'one block goes in above the Deck one'); + ok(/Mac Compatibility/.test(text(p.inserted[0])), 'headed Mac Compatibility'); + ok(/Verified/.test(text(p.inserted[0])) && /4 times/.test(text(p.inserted[0])), 'saying what this Mac has seen of the game'); + ok(p.fetched.length === 0, 'and a game the catalogue knows is not asked about'); + } + { + const p = page({ pathname: '/app/7/X/', mac: true, anticheat: { text: 'Uses Kernel Level Anti-Cheat Easy Anti-Cheat', name: 'Easy Anti-Cheat' }, + map: { 7: ['good', 'ran fine'] } }); + ok(/Unsupported/.test(text(p.inserted[0])) && /Easy Anti-Cheat/.test(text(p.inserted[0])), + 'a kernel anti-cheat outranks a macOS build and a clean history'); + } + { + const p = page({ pathname: '/app/8/X/', mac: true }); + ok(/Verified/.test(text(p.inserted[0])) && /native macOS/.test(text(p.inserted[0])), 'a macOS build the page lists is good'); + } + { + const p = page({ pathname: '/app/9/X/', report: { 9: { resolved_category: 3, resolved_items: [] } } }); + ok(/Unknown/.test(text(p.inserted[0])), 'a game the page says nothing about is drawn unknown at once'); + await p.settle(); + ok(/Likely/.test(text(p.inserted[0])) && /Steam Deck/.test(text(p.inserted[0])), "and redrawn with what Valve's testing found"); + ok(p.fetched.some(u => u === '/saleaction/ajaxgetdeckappcompatibilityreport?nAppID=9'), "asked of the store's own endpoint"); + ok(p.fetched.every(u => u.startsWith('/')), 'and of nowhere but the page it is on'); + } + { + const p = page({ pathname: '/app/10/X/', report: { 10: { resolved_category: 3, + steamos_resolved_items: [{ loc_token: '#SteamOS_TestResult_UnsupportedAntiCheat_Other' }] } } }); + await p.settle(); + ok(/Unsupported/.test(text(p.inserted[0])), 'an anti-cheat Valve failed blocks it, whatever its category'); + } + ok(page({ pathname: '/', deck: false }).inserted.length === 0, 'a page with no Deck block gets none of ours'); + ok(page({ pathname: '/search/' }).inserted.length === 0, 'and neither does a page that is not a game'); + + console.log('== the wishlist =='); + { + const row = id => { const r = node('div'); r.dataset.rfdDraggableId = `WishlistItem-${id}-0`; + const panel = node('div'); panel.className = 'Panel'; r.append(panel); return r; }; + const rows = [row(480), row(55), row(56)]; + const p = page({ pathname: '/wishlist/id/someone/', deck: false, rows, + map: { 480: ['blocked', 'no'] }, report: { 55: { resolved_category: 2 } }, platforms: { 56: true } }); + await p.settle(); await p.settle(); + const chips = rows.map(r => text(r.querySelector('.notproton-mac-chip'))); + ok(/Mac: Unsupported/.test(chips[0]), `a game the catalogue knows says so (${chips[0].trim()})`); + ok(/Mac: Likely/.test(chips[1]), `one Valve calls playable is likely (${chips[1].trim()})`); + ok(/Mac: Verified/.test(chips[2]), `one with a macOS build is verified (${chips[2].trim()})`); + ok(rows.every(r => r.dataset.notprotonMac === '1'), 'each row is marked once it is done'); + ok(rows[0].querySelector('.Panel').style.position === 'relative', 'and holds its chip in place'); + } + + console.log('== one answer, said the same way everywhere =='); + // Every sentence advice.c gives a game that is not installed has to be in the page + // script word for word, or a game would say one thing in the library and another here. + const body = advice.slice(advice.indexOf('np_badge_t np_advice_from_valve'), advice.indexOf('const char *np_badge_name')); + const sentences = [...body.matchAll(/snprintf\(reason, size, ((?:\s*"(?:[^"\\]|\\.)*")+)/g)] + .map(m => [...m[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(x => x[1]).join('')); + ok(sentences.length === 6, `advice.c has six such sentences (${sentences.length})`); + for (const s of sentences) ok(src.includes(s), `the page says: ${s.slice(0, 58)}...`); + + console.log(failed ? `==> store-page: ${failed} failure(s)` : '==> store-page: the store says what the library says, where the Deck block is'); + process.exit(failed ? 1 : 0); +})(); diff --git a/dylib/tests/tuning-check.c b/dylib/tests/tuning-check.c new file mode 100644 index 00000000..e8321d7d --- /dev/null +++ b/dylib/tests/tuning-check.c @@ -0,0 +1,153 @@ +// The recommended settings: that they say what the hardware asks for, and that every line +// is one the defaults whitelist would accept, since the page saves them through it. +#include "../feats/defaults.c" +#include "../feats/tuning.c" + +#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++; +} + +// Each line has to pass the same validation the Settings page saves through, or applying +// the recommendation would write a file the dylib then refuses to read back. +static int whitelisted(const char *text, uint32_t runner) { + for (const char *at = text; *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) return 0; + int i = setting_index(runner, at, (size_t)(eq - at)); + if (i < 0 || !valid_value((size_t)i, runner, eq + 1, n - (size_t)(eq - at) - 1)) return 0; + at += n + (nl ? 1 : 0); + } + return 1; +} + +static const char *value_of(const char *text, const char *key) { + static char value[256]; + size_t klen = strlen(key); + for (const char *at = text; *at; ) { + const char *nl = strchr(at, '\n'); + size_t n = nl ? (size_t)(nl - at) : strlen(at); + if (n > klen && at[klen] == '=' && strncmp(at, key, klen) == 0) { + size_t vlen = n - klen - 1; + if (vlen >= sizeof value) vlen = sizeof value - 1; + memcpy(value, at + klen + 1, vlen); + value[vlen] = '\0'; + return value; + } + at += n + (nl ? 1 : 0); + } + return NULL; +} + +static int says(const char *text, const char *key, const char *want) { + const char *got = value_of(text, key); + return got && strcmp(got, want) == 0; +} + +static np_machine_t mac(const char *chip, int gpu, unsigned ram, int macos, int w, int h, int hz) { + np_machine_t m = { 0 }; + m.apple_silicon = 1; + snprintf(m.chip, sizeof m.chip, "%s", chip); + m.gpu_cores = gpu; + m.cpu_cores = 10; + m.ram_gb = ram; + m.macos_major = macos; + m.display_w = w; + m.display_h = h; + m.refresh_hz = hz; + return m; +} + +int main(void) { + char out[NP_DEFAULTS_TEXT_MAX + 1]; + + // A 40-core Max fills a 16" panel on its own, so nothing is traded away for it. + np_machine_t max = mac("Apple M4 Max", 40, 64, 27, 3456, 2234, 120); + check(np_tuning_recommend(&max, NP_DEFAULTS_ROSETTA, out, sizeof out), "M4 Max: recommends"); + check(whitelisted(out, NP_DEFAULTS_ROSETTA), "M4 Max: every line is on the whitelist"); + check(says(out, "CX_GRAPHICS_BACKEND", "dxmt"), "M4 Max: DXMT"); + check(says(out, "NOTPROTON_RETINA", "1"), "M4 Max: Retina on"); + check(says(out, "WINEMSYNC", "1"), "M4 Max: msync on"); + check(says(out, "MTL_HUD_ENABLED", "0"), "M4 Max: HUD off"); + check(says(out, "DXMT_METALFX_SPATIAL_SWAPCHAIN", "0"), "M4 Max: no upscaling"); + check(says(out, "DXMT_CONFIG", "d3d11.preferredMaxFrameRate=120"), "M4 Max: capped at 120 Hz"); + check(says(out, "ROSETTA_ADVERTISE_AVX", "1"), "M4 Max: AVX2 advertised on macOS 27"); + + // A base M1 on a 13" panel is the other end: the most upscaling, capped at 60. + np_machine_t base = mac("Apple M1", 8, 16, 26, 2560, 1600, 60); + check(np_tuning_recommend(&base, NP_DEFAULTS_ROSETTA, out, sizeof out), "M1: recommends"); + check(whitelisted(out, NP_DEFAULTS_ROSETTA), "M1: every line is on the whitelist"); + check(says(out, "DXMT_METALFX_SPATIAL_SWAPCHAIN", "1"), "M1: upscaling on"); + check(says(out, "DXMT_CONFIG", + "d3d11.metalSpatialUpscaleFactor=1.72;d3d11.preferredMaxFrameRate=60"), + "M1: 1.72x at 60 Hz"); + + // 8 GB is shared with the GPU, so the same part on the same panel steps up a band. + np_machine_t tight = mac("Apple M1", 8, 8, 26, 2560, 1600, 60); + check(np_tuning_recommend(&tight, NP_DEFAULTS_ROSETTA, out, sizeof out), "M1 8 GB: recommends"); + check(says(out, "DXMT_CONFIG", + "d3d11.metalSpatialUpscaleFactor=2.0;d3d11.preferredMaxFrameRate=60"), + "M1 8 GB: 2x at 60 Hz"); + + // A base chip on a 5K panel asks for the most help the whitelist offers. + np_machine_t dense = mac("Apple M4", 10, 16, 26, 5120, 2880, 60); + check(np_tuning_recommend(&dense, NP_DEFAULTS_ROSETTA, out, sizeof out), "5K: recommends"); + check(says(out, "DXMT_CONFIG", + "d3d11.metalSpatialUpscaleFactor=2.0;d3d11.preferredMaxFrameRate=60"), + "5K: 2x at 60 Hz"); + + // FEX has no D3DMetal and no Rosetta, and runs the reduced x87 precision CrossOver + // Preview defaults to. + check(np_tuning_recommend(&max, NP_DEFAULTS_FEX, out, sizeof out), "FEX: recommends"); + check(whitelisted(out, NP_DEFAULTS_FEX), "FEX: every line is on the whitelist"); + check(says(out, "FEX_X87REDUCEDPRECISION", "1"), "FEX: reduced x87 precision"); + check(!value_of(out, "ROSETTA_ADVERTISE_AVX"), "FEX: nothing about Rosetta"); + + // Rosetta advertises AVX2 from macOS 15 on, so older releases are left alone. + np_machine_t old = mac("Apple M1", 8, 16, 14, 2560, 1600, 60); + check(np_tuning_recommend(&old, NP_DEFAULTS_ROSETTA, out, sizeof out), "macOS 14: recommends"); + check(!value_of(out, "ROSETTA_ADVERTISE_AVX"), "macOS 14: no AVX2 flag"); + + // An Intel Mac has neither DXMT nor D3DMetal worth taking, and no dense panel to feed. + np_machine_t intel = mac("Intel Core i9", 0, 32, 26, 2880, 1800, 60); + intel.apple_silicon = 0; + check(np_tuning_recommend(&intel, NP_DEFAULTS_ROSETTA, out, sizeof out), "Intel: recommends"); + check(whitelisted(out, NP_DEFAULTS_ROSETTA), "Intel: every line is on the whitelist"); + check(says(out, "CX_GRAPHICS_BACKEND", "dxvk"), "Intel: DXVK"); + check(says(out, "NOTPROTON_RETINA", "0"), "Intel: Retina off"); + check(!value_of(out, "DXMT_CONFIG"), "Intel: nothing for DXMT"); + check(!value_of(out, "DXMT_METALFX_SPATIAL_SWAPCHAIN"), "Intel: no upscaling row"); + + // No window server, so no display to size: the backend stands, the trade does not. + np_machine_t headless = mac("Apple M4 Max", 40, 64, 27, 0, 0, 0); + check(np_tuning_recommend(&headless, NP_DEFAULTS_ROSETTA, out, sizeof out), "No display: recommends"); + check(whitelisted(out, NP_DEFAULTS_ROSETTA), "No display: every line is on the whitelist"); + check(says(out, "DXMT_METALFX_SPATIAL_SWAPCHAIN", "0"), "No display: no upscaling"); + check(!value_of(out, "DXMT_CONFIG"), "No display: no frame cap"); + + // A probe that read nothing at all still has to produce a set that saves. + np_machine_t nothing = { 0 }; + check(np_tuning_recommend(¬hing, NP_DEFAULTS_ROSETTA, out, sizeof out), "Unknown Mac: recommends"); + check(whitelisted(out, NP_DEFAULTS_ROSETTA), "Unknown Mac: every line is on the whitelist"); + + // A refusal leaves nothing half-written behind for a caller to save. + check(!np_tuning_recommend(&max, 7, out, sizeof out) && !out[0], "Bad runner: refused, out empty"); + check(!np_tuning_recommend(NULL, NP_DEFAULTS_ROSETTA, out, sizeof out) && !out[0], + "No machine: refused, out empty"); + char tiny[8]; + check(!np_tuning_recommend(&max, NP_DEFAULTS_ROSETTA, tiny, sizeof tiny) && !tiny[0], + "No room: refused, out empty"); + + printf(failures ? "==> tuning-check: %d failure(s)\n" : "==> tuning-check: all good\n", failures); + return failures ? 1 : 0; +} diff --git a/dylib/tests/verdict-check.c b/dylib/tests/verdict-check.c new file mode 100644 index 00000000..ad8bb903 --- /dev/null +++ b/dylib/tests/verdict-check.c @@ -0,0 +1,91 @@ +// What a game's recorded sessions come to, and what a record that cannot be trusted does. +#include "../feats/verdict.c" + +#include +#include +#include +#include +#include +#include + +static int failures; +static char root[PATH_MAX]; + +static void check(const char *what, const char *want, const char *got) { + int ok = strcmp(want, got) == 0; + printf(" %s %s%s\n", ok ? "ok " : "FAIL", what, ok ? "" : ""); + if (!ok) { printf(" want [%s]\n got [%s]\n", want, got); failures++; } +} + +static void put(const char *text) { + char path[PATH_MAX]; + snprintf(path, sizeof path, "%s/notproton-sessions", root); + if (!text) { unlink(path); return; } + FILE *f = fopen(path, "w"); + if (!f) { perror("fopen"); exit(1); } + fputs(text, f); + fclose(f); +} + +// " / m ", so one line says +// everything a record came to. +static const char *sum(const char *text) { + static char out[256]; + np_verdict_t v; + put(text); + int read = np_verdict_read(root, &v); + snprintf(out, sizeof out, "%s %s %d/%d %dm %s %s %s", read ? "read" : "no", + np_verdict_name(v.tier), v.healthy, v.failed, v.minutes, v.last, + v.build[0] ? v.build : "-", v.backend[0] ? v.backend : "-"); + return out; +} + +int main(void) { + snprintf(root, sizeof root, "%s/verdict.XXXXXX", getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp"); + if (!mkdtemp(root)) { perror("mkdtemp"); return 1; } + + printf("== what the sessions come to ==\n"); + check("a game never run here is unknown", "no unknown 0/0 0m unknown - -", sum(NULL)); + check("and so is one whose record holds no session", + "read unknown 0/0 0m unknown 26.3 dxmt", sum("1\n0\n0\n0\nunknown\n26.3\ndxmt\n")); + check("two clean sessions over twenty minutes are verified", + "read verified 2/0 45m healthy 26.3 dxmt", sum("1\n2\n0\n2700\nhealthy\n26.3\ndxmt\n")); + // One session is a game that started, which is not yet a game that runs. + check("one clean session is playable", + "read playable 1/0 45m healthy 26.3 dxmt", sum("1\n1\n0\n2700\nhealthy\n26.3\ndxmt\n")); + check("and so are two that barely ran", + "read playable 2/0 3m healthy 26.3 dxmt", sum("1\n2\n0\n180\nhealthy\n26.3\ndxmt\n")); + check("a crash anywhere in the record keeps it playable", + "read playable 9/1 300m healthy 26.3 dxmt", sum("1\n9\n1\n18000\nhealthy\n26.3\ndxmt\n")); + check("a game that has only ever crashed is broken", + "read broken 0/3 2m failed 26.3 dxvk", sum("1\n0\n3\n120\nfailed\n26.3\ndxvk\n")); + + printf("== a record to distrust ==\n"); + check("an empty file says nothing", "no unknown 0/0 0m unknown - -", sum("")); + check("a format this does not read is not read", + "no unknown 0/0 0m unknown - -", sum("2\n2\n0\n2700\nhealthy\n26.3\ndxmt\n")); + check("a count that is not a number is not read", + "no unknown 0/0 0m unknown - -", sum("1\n2x\n0\n2700\nhealthy\n26.3\ndxmt\n")); + check("nor is a negative one", + "no unknown 0/0 0m unknown - -", sum("1\n-2\n0\n2700\nhealthy\n26.3\ndxmt\n")); + check("nor one no run could reach", + "no unknown 0/0 0m unknown - -", sum("1\n999999999\n0\n2700\nhealthy\n26.3\ndxmt\n")); + check("a record that stops early is not read", + "no unknown 0/0 0m unknown - -", sum("1\n2\n0\n2700\n")); + // The build and the backend are printed on the page, so a quote in them is a record + // that was not written by the run script. + check("a field that could break out of the JSON is not read", + "no unknown 0/0 0m unknown - -", sum("1\n2\n0\n2700\nhealthy\n26.3\"\ndxmt\n")); + check("and neither is one longer than the field", + "no unknown 0/0 0m unknown - -", + sum("1\n2\n0\n2700\nhealthy\n2666666666666666666666666666666666666666666666666666666666666666666666.3\ndxmt\n")); + // A health word nobody wrote is not one the page should repeat. + check("an unknown health word reads as unknown", + "read verified 2/0 45m unknown 26.3 dxmt", sum("1\n2\n0\n2700\nwedged\n26.3\ndxmt\n")); + + put(NULL); + rmdir(root); + printf(failures ? "==> verdict-check: %d failure(s)\n" : "==> verdict-check: sessions read as expected\n", + failures); + return failures ? 1 : 0; +} diff --git a/examples/rules/10-launch-rules.conf b/examples/rules/10-launch-rules.conf new file mode 100644 index 00000000..b45f9856 --- /dev/null +++ b/examples/rules/10-launch-rules.conf @@ -0,0 +1,52 @@ +# Launch rules: words to pass a particular game, in a file rather than in the binary. +# +# The command that gets a game past its own launcher, or off a renderer that nothing here +# translates, is something people find out one game at a time. NotProton ships almost none +# of them on purpose: a flag that helps one game does nothing for the next one and can +# stop a third from starting. What it ships is this file format, so a flag somebody has +# actually tested can be added without a build, and passed around as a file. +# +# Drop a copy in ~/Library/Application Support/notproton/rules/ and it applies at the next +# launch. The arguments are added to the ones the game is already being given, never +# instead of them: a flag that is already on the game's launch options is not added twice, +# and the no-auto-settings file in the support folder turns the whole thing off. +# +# A record matches only when everything it names matches, so name as little as says which +# game you mean. The fields: +# +# appid Steam's number for the game, as in the store URL. May be given more than +# once, and then any of them matches. +# engine unreal or unity, when the rule is about what the engine does. +# launcher ea, ubisoft, epic or rockstar, when it is about the launcher in the way. +# exe the executable NotProton picked, by name, if the rule is only for one build. +# option a word to pass the game. Repeat it for more than one. One word each, no +# quoting: a value with a space in it is not something this passes on. +# note a sentence added to what the game's badge says when you hover it. +# +# A record that names no game at all is refused rather than applied to everything, and so +# is one that names a game but has nothing to pass it. + +# Unreal's startup movies cannot be skipped from the game's own menu, and the engine has +# taken this switch for years. Harmless where the game has no movies to skip. +[unreal-skip-startup-movies] +label = Unreal: skip the startup movies +engine = unreal +option = -nostartupmovies + +# An example of the shape a bypass takes, not a flag to trust: 271590 is Grand Theft Auto V +# and the words below are the ones you would have found to work. Put your own appid and +# your own flag here. Delete this record if you are not sure of it -- a wrong flag is a +# game that will not start. +# +# [gta-v-skip-launcher] +# label = Grand Theft Auto V: straight into the game +# appid = 271590 +# option = -skipRockstarLauncher +# note = Started without the Rockstar launcher. + +# A rule does not have to pass anything. One that only carries a note is a way to leave +# yourself a sentence on the game's badge about what you had to do to make it work. +# +# [mass-effect-le-prefix-note] +# appid = 1328670 +# note = Install the EA app inside this game's prefix before the first launch. diff --git a/examples/rules/README.md b/examples/rules/README.md new file mode 100644 index 00000000..6ace2041 --- /dev/null +++ b/examples/rules/README.md @@ -0,0 +1,11 @@ +Rules in this folder are examples. Copy the ones you want into + + ~/Library/Application Support/notproton/rules/ + +and they apply at the next launch. The format is documented in +`10-launch-rules.conf`, and it is the same grammar as `examples/plugins`. + +NotProton ships no per-game bypass flags of its own. A flag that helps one game +does nothing for the next and can stop a third from starting, so the ones that +work are collected here by the people who tested them rather than guessed at by +the program. diff --git a/helpers/appinfo.swift b/helpers/appinfo.swift index de807db5..19dbe28c 100644 --- a/helpers/appinfo.swift +++ b/helpers/appinfo.swift @@ -1,6 +1,9 @@ // Reads Steam's appinfo.vdf cache and prints key=value metadata for one app on // stdout. Usage: appinfo . // With --platforms instead of an appid it prints mac= and windows= appid lists. +// With --compat it prints one "appid mac deck anticheat" line per game: whether it lists +// a macOS build, Valve's Steam Deck category (0 unknown, 1 unsupported, 2 playable, +// 3 verified), and whether any of Valve's tests failed it for its anti-cheat. // // common/icon is a 32x32 thumbnail. common/clienticon is the real multi-res // icon, and the two hashes differ for most apps, so the icon cannot be @@ -48,8 +51,9 @@ if args.count == 4, args[1] == "--same-bundle" { } let platforms = args.count == 3 && args[2] == "--platforms" -guard args.count == 3, platforms || UInt32(args[2]) != nil else { - fputs("usage: appinfo |--platforms\n", stderr) +let compat = args.count == 3 && args[2] == "--compat" +guard args.count == 3, platforms || compat || UInt32(args[2]) != nil else { + fputs("usage: appinfo |--platforms|--compat\n", stderr) exit(2) } @@ -261,11 +265,44 @@ func printPlatforms(_ b: [UInt8]) throws { print("windows=" + windows.map(String.init).joined(separator: ",")) } +// What Valve's own testing says about a game under Proton, which is the nearest thing to +// a verdict there is for a game nobody has installed here. The anti-cheat result is the +// part that carries over to CrossOver unchanged: an anti-cheat that refuses Proton +// refuses Wine, whoever built it. +func printCompat(_ b: [UInt8]) throws { + var lines: [String] = [] + let complete = try forEachApp(b) { id, keys in + guard case .section(let common)? = try keys()["common"] else { return true } + guard case .string(let type)? = common["type"], type.lowercased() == "game" else { return true } + var mac = 0 + if case .string(let oslist)? = common["oslist"], oslist.split(separator: ",").contains("macos") { mac = 1 } + var deck: Int64 = 0 + var anticheat = 0 + if case .section(let report)? = common["steam_deck_compatibility"] { + if case .number(let category)? = report["category"], (0...3).contains(category) { deck = category } + for list in ["tests", "steamos_tests"] { + guard case .section(let tests)? = report[list] else { continue } + for case .section(let test) in tests.values { + if case .string(let token)? = test["token"], token.contains("UnsupportedAntiCheat") { anticheat = 1 } + } + } + } + lines.append("\(id) \(mac) \(deck) \(anticheat)") + return true + } + guard complete else { throw VDFError.truncated } + print(lines.joined(separator: "\n")) +} + do { if platforms { try printPlatforms(bytes) exit(0) } + if compat { + try printCompat(bytes) + exit(0) + } let wantAppID = UInt32(args[2])! guard let app = try findApp(bytes, wantAppID) else { fputs("appinfo: no entry for appid \(wantAppID)\n", stderr) diff --git a/tools/store-badge/agent.js b/tools/store-badge/agent.js new file mode 100644 index 00000000..f8dded62 --- /dev/null +++ b/tools/store-badge/agent.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Puts dylib/feats/store_page.js into Steam's store pages through the CEF debugger, for +// as long as this runs. NotProton's dylib does this itself (dylib/feats/store.c); this is +// the same thing as a script, for working on the page script without building the dylib: +// edit store_page.js, restart this, reload the store page. +// +// NP_CEF_PORT=27101 node tools/store-badge/agent.js (Steam running under NotProton) +// open -a Steam --args -cef-enable-debugging && node tools/store-badge/agent.js (stock, port 8080) +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const PORT = process.env.NP_CEF_PORT || 8080; +const BADGES = path.join(os.homedir(), 'Library/Application Support/notproton/badges'); + +// "appid tier reason" per line, as the catalogue writes it. +function readMap() { + const map = {}; + let text = ''; + try { text = fs.readFileSync(BADGES, 'utf8'); } catch { return map; } + for (const line of text.split('\n')) { + const m = /^(\d+) (good|likely|issues|blocked|unknown) (.+)$/.exec(line); + if (m) map[m[1]] = [m[2], m[3]]; + } + return map; +} + +const page = fs.readFileSync(path.join(__dirname, '../../dylib/feats/store_page.js'), 'utf8'); +// Exactly one place takes the map. More than one, or none, is a page.js this was not +// written for, and injecting it would only throw inside Valve's page. +if (page.split('__NP_MAP__').length !== 2) { + console.error('page.js must name __NP_MAP__ exactly once'); + process.exit(1); +} +// The switch in Steam's Settings is a file, and it turns this off with the rest. +const OFF = path.join(os.homedir(), 'Library/Application Support/notproton/no-mac-badges'); +const source = () => fs.existsSync(OFF) ? '/* Mac badges are turned off */' + : page.split('__NP_MAP__').join(JSON.stringify(readMap())); +const attached = new Set(); + +function attach(target) { + attached.add(target.id); + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; + const send = (method, params) => ws.send(JSON.stringify({ id: ++id, method, params })); + ws.onopen = () => { + const js = source(); + send('Page.enable'); + // Once for every page this view goes on to load, and once for the one it is on. + send('Page.addScriptToEvaluateOnNewDocument', { source: js }); + send('Runtime.evaluate', { expression: js }); + console.log(`store view ${target.id.slice(0, 8)}: attached`); + }; + ws.onclose = () => attached.delete(target.id); + ws.onerror = () => attached.delete(target.id); +} + +async function tick() { + let targets; + try { + targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); + } catch { + console.log('Steam\'s debugger is gone, stopping.'); + process.exit(0); + } + for (const t of targets) + if (t.type === 'page' && /^https:\/\/store\.steampowered\.com\//.test(t.url) && !attached.has(t.id)) + attach(t); +} + +tick(); +setInterval(tick, 3000); From 82819611f347dd23c082805df2aede0e290ff7c5 Mon Sep 17 00:00:00 2001 From: Marcus Shaw <189466376+marcshawco@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:39:11 -0800 Subject: [PATCH 2/2] Document Mac compatibility docs/mac-compatibility.md covers what each badge means, where the answers come from, the switches, launch rules, how the store part works and what its debugger port costs, and the files involved. README points to it. Co-Authored-By: Claude Fable 5.2 --- README.md | 15 +++ docs/mac-compatibility.md | 197 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 docs/mac-compatibility.md diff --git a/README.md b/README.md index 4f5ec728..843aadd2 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,21 @@ I would have done things differently had I been aware of that. Thanks to everyone who has positively contributed to macOS gaming. +Mac compatibility: + +NotProton works out how each game should be run on your Mac and how likely it is to run, and shows +it where Steam shows the Steam Deck's: an Apple badge on every game in the library (downloaded or +not), a stat on the game's page, and a block on store and wishlist pages. Green means it has run +well on this Mac, a green ring means Valve's Proton testing passed it, yellow means something is in +the way, red means it cannot work (an anti-cheat, usually), and a dashed ring means nothing is known. + +The graphics settings that suit the Mac and the game are applied at launch unless you set your own. +Everything can be turned off under Steam → Preferences → Compatibility. The store part uses Steam's +built-in web debugger, which opens a port on your Mac while Steam is running, and has its own switch. + +Read ```docs/mac-compatibility.md``` for what each badge means, where the answers come from, the +launch rules in ```~/Library/Application Support/notproton/rules/```, and what the store switch costs. + Developer Notes: As far as the source structure goes, the macOS app itself is located in the ```app``` folder. The core logic is in ```dylib```. diff --git a/docs/mac-compatibility.md b/docs/mac-compatibility.md new file mode 100644 index 00000000..e22dcffc --- /dev/null +++ b/docs/mac-compatibility.md @@ -0,0 +1,197 @@ +# Mac compatibility + +NotProton works out, for each game, how it should be run on this Mac and how likely it is +to run at all, and shows the answer where Steam shows the Steam Deck's. + +This page covers what you see, where the answers come from, how to turn it off, and how +the pieces fit together for anyone changing them. + +## What you see + +**A badge on every game in the library.** An Apple mark in the corner of the tile, in the +place Steam Deck badges use. Hovering it says why. + +| Badge | Label | Meaning | +|---|---|---| +| Green, filled | Verified | It has run on this Mac at least twice, for 20 minutes or more, without crashing. Or it has a native macOS version. | +| Green ring | Likely | Valve verified it, or found it playable, on Steam Deck through Proton. It has not run on this Mac yet. | +| Yellow | Playable | Something is in the way: a third-party launcher, crashes in some sessions here, or Valve marks it unsupported on Steam Deck. | +| Red | Unsupported | It cannot work here. Usually an anti-cheat that refuses Proton, or it has crashed every time it was started. | +| Dashed ring | Unknown | Nothing is known yet. | + +Filled green is kept for what this Mac has actually seen. A game that only a Steam Deck +has run gets the ring. + +**A stat on the game's page**, beside Play Time and Achievements: the same mark, with +`MAC` and the label. + +**A row on the game's Properties → Compatibility page**: "Recommended Graphics Settings", +naming this Mac, with a "Use Recommended" button that writes the settings into the game's +launch options. + +**A block on store pages and a chip on each wishlist row**, above Steam Deck +Compatibility. See [The store and the wishlist](#the-store-and-the-wishlist): this part +works differently from the rest and has its own switch. + +## Settings that apply themselves + +At launch, a game that has been told nothing gets the settings worked out for it. Anything +you set yourself wins: launch options first, then Default Compatibility Settings, then +these. + +From the Mac (`dylib/feats/machine.c`, `tuning.c`): + +- DXMT on Apple silicon, DXVK on Intel. +- MSync on, the Metal HUD off, High Resolution on for Apple silicon. +- AVX2 advertised to Rosetta on macOS 15 and later. Reduced x87 precision on FEX. +- A MetalFX upscale factor from the display's pixels per GPU core: none where the GPU + covers the panel (a 40-core Max on a 16" display), up to 2x where it does not (a base + chip on a 5K display). One band higher with 8 GB of memory. +- A frame cap at the display's refresh rate. + +From the game (`dylib/feats/game.c`, `advice.c`), read from its installed files: + +- Direct3D 12 → D3DMetal. Direct3D 11 → DXMT. Direct3D 9 → DXVK. Vulkan and OpenGL are + left to CrossOver. +- A Unity game that asks for Direct3D 12 is passed `-force-d3d11` and run on DXMT. An + Unreal one is left on D3DMetal, since forcing it to Direct3D 11 costs features. +- A game that needs Direct3D 12 on a FEX tool is flagged: FEX has no D3DMetal. + +Toggles whose right answer depends on the game rather than the hardware (DLSS, for +instance) are never set for you. + +## Where the answers come from + +Nothing is fetched from the network for the library. In order of weight: + +1. **This Mac's own history.** The run script records how each session ended + (`notproton-sessions` beside the game's prefix): healthy, crashed in the first minute, + or neither, and for how long. +2. **The game's files.** The executable's PE header and import table (word size, which + Direct3D it loads), and whether the folder carries Easy Anti-Cheat, BattlEye, a + third-party launcher, a known engine, or a macOS app bundle. +3. **Valve's Steam Deck testing**, from the `appinfo.vdf` Steam already keeps: the + category, and whether a test failed for anti-cheat. This is what gives a game a badge + before it is downloaded. An anti-cheat that refuses Proton refuses Wine as well, so + that result is taken as it stands; a "verified" is only ever shown as Likely. + +The store and wishlist ask the store's own endpoints for the same Valve report, for games +Steam has no local record of. See below. + +## Turning it off + +In Steam, **Steam → Preferences → Compatibility**: + +- **Show Mac compatibility badges** — the library badges, the game page stat, and the + store. Takes effect at once. +- **Show Mac compatibility in the Store and Wishlist** — the store part alone. Takes + effect when Steam restarts. + +Or by hand, with empty files in `~/Library/Application Support/notproton/`: + +| File | Effect | +|---|---| +| `no-mac-badges` | No badges anywhere. | +| `no-store-badges` | No store or wishlist block, and Steam's web debugger is not turned on. | +| `no-auto-settings` | Nothing is applied at launch that you did not set. | + +## Launch rules + +The flags that get a particular game past its launcher are found one game at a time, so +they live in files rather than in NotProton. A rule names a game and the words to pass +it: + +``` +[some-game-skip-launcher] +appid = 123456 +option = -skipLauncher +note = Started without the launcher. +``` + +Put rules in `~/Library/Application Support/notproton/rules/*.conf`. A rule can match on +`appid`, `engine` (`unreal`, `unity`), `launcher` (`ea`, `ubisoft`, `epic`, `rockstar`) or +`exe`; everything it names has to match. `option` may repeat. `note` is added to the +badge's tooltip. A rule that names no game, or carries shell punctuation in an option, is +refused. The format and a worked example are in `examples/rules/`. + +NotProton ships one rule's worth of behaviour itself (`-force-d3d11` for Unity on +Direct3D 12) and one example (`-nostartupmovies` for Unreal). It ships no launcher +bypasses, because a flag that helps one game can stop another from starting. + +## The store and the wishlist + +Store pages are Valve's, served from the network. Nothing NotProton patches on disk +reaches them, and Steam's client has no way to run a script in them. The one way in is +the debugger built into Steam's web helper (CEF), which is how Decky and Millennium reach +the same pages. + +So, when the store switch is on: + +- NotProton starts Steam's web helper with `--remote-debugging-port=27101 + --remote-debugging-address=127.0.0.1` (`dylib/hooks/hook_spawn.c`). +- A thread in the dylib (`dylib/feats/store.c`) connects to that port, checks that what + answers is Steam, and keeps `dylib/feats/store_page.js` registered on pages under + `https://store.steampowered.com/`. +- That script adds the Mac block above the Deck block on app pages, and a chip on each + wishlist row. It reads the page's own anti-cheat notice and macOS icon, the badges this + Mac has worked out, and, for a game it has nothing on, the store's own Deck report and + platform list (same origin, two requests at a time, remembered for the session). + +**What this costs.** The port listens on this Mac only, not the network. But any program +running on this Mac can connect to it while Steam is open, and through it can read and +drive Steam's web views, the logged-in store among them. That is a real thing to agree +to, which is why it is a separate switch and says so. With the switch off, the helper is +started as Steam starts it and no port is opened. + +## How the pieces fit + +| File | Does | +|---|---| +| `dylib/feats/machine.c` | Reads the Mac. The chip name and GPU core count come from the I/O registry, because Steam runs under Rosetta where `sysctl` reports a virtual CPU. | +| `dylib/feats/tuning.c` | Mac → settings, as lines the defaults whitelist accepts. | +| `dylib/feats/game.c` | Installed folder → what the game is. Maps the executable rather than reading it. | +| `dylib/feats/verdict.c` | A game's recorded sessions → a tier. | +| `dylib/feats/advice.c` | Mac + game + history → settings, launch arguments, badge and reason. Also the verdict for a game that is not installed, from Valve's data. | +| `dylib/feats/rules.c` | Launch rules, on the plugin grammar (`conf.c`, kind `NP_CONF_RULE`). | +| `dylib/feats/catalog.c` | One pass over the library: scans what changed, writes the badge file and each game's advice, hands the map to the UI. | +| `helpers/appinfo.swift` | `--compat` prints Valve's category and anti-cheat result per game. | +| `dylib/feats/webpatch.c` | Draws the tile badge, the game page stat, and the Preferences rows. | +| `dylib/feats/store.c`, `store_page.js` | The store and wishlist. | +| `dylib/feats/compat_run.sh` | Records sessions; applies advice and rule arguments at launch. | + +Two things worth knowing before changing the UI code: + +- Steam runs its JavaScript in a hidden `SharedJSContext` and draws into a separate + window. `document` in injected code is the hidden one, so a `