diff --git a/Makefile b/Makefile index 128769d1..14c9b817 100644 --- a/Makefile +++ b/Makefile @@ -76,7 +76,7 @@ DEPS := $(OBJS:.o=.d) anchorcheck callscheck sigdb-fixtures webpatch-fixtures peicon-fixtures ceg-check panel-behavior app-tests \ featcheck featstagecheck runnervariantcheck conf-fixtures confcheck \ tests-list overlay-shim overlay-shim-install overlay-shim-tests \ - overlay-shim-bench iconmaker icon \ + overlay-shim-bench fpslimit fpslimit-install 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 @@ -594,6 +594,29 @@ overlay-shim-bench: printf ' %-8s ' "$$a"; $(OUT_DIR)/passbench.$$a || exit 1; \ done +FPSLIMIT_SRC := plugins/fpslimit/fpslimit.m +FPSLIMIT_ARM := $(OUT_DIR)/libfpslimit.arm64.dylib +FPSLIMIT_X86 := $(OUT_DIR)/libfpslimit.x86_64.dylib +FPSLIMIT := $(OUT_DIR)/libfpslimit.dylib +FPSLIMIT_FLAGS := -mmacosx-version-min=$(MIN_VER) $(AVAIL) -dynamiclib -O2 -Wall -Wextra \ + -install_name @rpath/libfpslimit.dylib \ + -framework Metal -framework QuartzCore \ + -framework CoreFoundation -framework Foundation + +fpslimit: $(FPSLIMIT) + +$(FPSLIMIT): $(FPSLIMIT_SRC) + @mkdir -p $(OUT_DIR) + $(CC) -arch arm64 $(FPSLIMIT_FLAGS) -o $(FPSLIMIT_ARM) $< + $(CC) -arch x86_64 $(FPSLIMIT_FLAGS) -o $(FPSLIMIT_X86) $< + lipo -create $(FPSLIMIT_ARM) $(FPSLIMIT_X86) -output $@ + codesign -fs - $@ + @lipo -info $@ + +fpslimit-install: $(FPSLIMIT) + $(call install_atomically,$(FPSLIMIT),$(SUPPORT_DIR)/libfpslimit.dylib) + @echo "==> Installed: $(SUPPORT_DIR)/libfpslimit.dylib" + ICONMAKER := $(OUT_DIR)/iconmaker iconmaker: $(ICONMAKER) diff --git a/examples/README.md b/examples/README.md index 46b70288..5b112513 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,7 @@ and NotProton never writes to them. | `plugins/10-framescope.conf` | a feature and its seven settings in one file: a toggle that injects a dylib, a dropdown, a slider over a range, a slider that snaps to values with labelled ticks, a sub-option of a sub-option, and lines that appear only while the row is on | | `plugins/20-renderdoc.conf` | a toggle with one sub-option, writing `0` when it is switched off rather than clearing the launch option | | `plugins/30-kosmickrisp.conf` | one toggle that both injects a dylib and sets two environment variables | +| `plugins/40-fpslimit.conf` | a snapping slider that meters frame submissions to display refresh intervals with an optional VSync toggle | | `variants/dxmt-example.conf` | a runner built from a CrossOver copy with your own files over it | These are the real descriptors behind three tools, not invented ones, so `requires` points diff --git a/examples/plugins/40-fpslimit.conf b/examples/plugins/40-fpslimit.conf new file mode 100644 index 00000000..95ebe6c9 --- /dev/null +++ b/examples/plugins/40-fpslimit.conf @@ -0,0 +1,26 @@ +# Frame pacing for Metal backends (D3DMetal, DXMT, DXVK, WineD3D). +# Injects universal libfpslimit to meter frame submissions and pace presentation to display vblanks. +[NOTPROTON_FPS_LIMIT] +label = FPS Limiter +description = Hardware frame pacing for Metal (flat frametimes). +section = dev +type = slider +inject = 1 +requires = Library/Application Support/notproton/libfpslimit.dylib +requires = out/libfpslimit.dylib +default = off +choice = off = Off +choice = 30 = 30 FPS +choice = 40 = 40 FPS +choice = 60 = 60 FPS +choice = 120 = 120 FPS +choice = 144 = 144 FPS +description_on = Frames are paced with sub-microsecond precision and synchronized to display refresh intervals. + +# Optional sub-option: disable VSync quantization +[NOTPROTON_ENV_FPS_NO_VSYNC] +label = FPS Limiter: disable VSync +description = Presents frames immediately on GPU finish rather than synchronizing to display refresh ticks. +depends = NOTPROTON_FPS_LIMIT +on = 1 +off = 0 diff --git a/plugins/fpslimit/Makefile b/plugins/fpslimit/Makefile new file mode 100644 index 00000000..ab3a9482 --- /dev/null +++ b/plugins/fpslimit/Makefile @@ -0,0 +1,21 @@ +CC ?= clang +MIN_VER ?= 15.0 +OUT ?= ../../out/libfpslimit.dylib + +all: $(OUT) + +$(OUT): fpslimit.m + @mkdir -p $(dir $(OUT)) + $(CC) -arch arm64 -arch x86_64 \ + -mmacosx-version-min=$(MIN_VER) \ + -dynamiclib -O2 -Wall -Wextra \ + -framework Metal -framework QuartzCore -framework Foundation \ + -install_name @rpath/libfpslimit.dylib \ + -o $(OUT) fpslimit.m + codesign -fs - $(OUT) + @echo "==> Built universal $(OUT)" + +clean: + rm -f $(OUT) + +.PHONY: all clean diff --git a/plugins/fpslimit/fpslimit.m b/plugins/fpslimit/fpslimit.m new file mode 100644 index 00000000..7ad610c9 --- /dev/null +++ b/plugins/fpslimit/fpslimit.m @@ -0,0 +1,171 @@ +#import +#import +#import +#import +#import +#import +#import +#import +#import + +typedef struct { + uint64_t last_frame_time; + uint64_t interval_ticks; + uint64_t tick_rem; + uint64_t rem_accum; + uint64_t fps; + os_unfair_lock lock; + bool initialized; +} layer_pace_state_t; + +@interface NPLayerPaceWrapper : NSObject { +@public + layer_pace_state_t state; +} +@end + +@implementation NPLayerPaceWrapper +@end + +static const char kPaceKey = 0; +static os_unfair_lock g_wrap_lock = OS_UNFAIR_LOCK_INIT; +static id (*orig_nextDrawable)(id, SEL) = NULL; + +static uint64_t g_fps = 0; +static uint64_t g_interval_ticks = 0; +static uint64_t g_tick_rem = 0; +static bool g_disable_vsync = false; +static mach_timebase_info_data_t g_timebase; + +static inline NPLayerPaceWrapper *get_or_create_wrap(id layer) { + NPLayerPaceWrapper *wrap = (NPLayerPaceWrapper *)objc_getAssociatedObject(layer, &kPaceKey); + if (__builtin_expect(wrap != nil, 1)) { + return wrap; + } + + os_unfair_lock_lock(&g_wrap_lock); + wrap = (NPLayerPaceWrapper *)objc_getAssociatedObject(layer, &kPaceKey); + if (!wrap) { + wrap = [[NPLayerPaceWrapper alloc] init]; + wrap->state.fps = g_fps; + wrap->state.interval_ticks = g_interval_ticks; + wrap->state.tick_rem = g_tick_rem; + wrap->state.rem_accum = 0; + wrap->state.lock = OS_UNFAIR_LOCK_INIT; + wrap->state.initialized = false; + objc_setAssociatedObject(layer, &kPaceKey, wrap, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [wrap release]; + } + os_unfair_lock_unlock(&g_wrap_lock); + return wrap; +} + +static id hook_nextDrawable(id self, SEL _cmd) { + if (g_disable_vsync) { + CAMetalLayer *layer = (CAMetalLayer *)self; + if (layer.displaySyncEnabled) { + layer.displaySyncEnabled = NO; + } + } + + if (g_fps == 0) { + return orig_nextDrawable(self, _cmd); + } + + // 1. Acquire the drawable from Metal FIRST. + // Any swapchain or GPU wait time happens here and is absorbed as work. + id drawable = orig_nextDrawable(self, _cmd); + if (!drawable) { + return nil; + } + + NPLayerPaceWrapper *wrap = get_or_create_wrap(self); + layer_pace_state_t *st = &wrap->state; + + os_unfair_lock_lock(&st->lock); + + uint64_t now = mach_absolute_time(); + if (!st->initialized) { + st->last_frame_time = now; + st->initialized = true; + os_unfair_lock_unlock(&st->lock); + return drawable; + } + + // Fractional Bresenham remainder carryover + st->rem_accum += st->tick_rem; + uint64_t extra = st->rem_accum / st->fps; + st->rem_accum %= st->fps; + + uint64_t target = st->last_frame_time + st->interval_ticks + extra; + + // Work-Compensated Check: + // If the GPU/driver already took longer than the frame interval (now >= target), + // DO NOT SLEEP. Return immediately so the pipeline never cascades into hitches. + if (now >= target) { + st->last_frame_time = now; + os_unfair_lock_unlock(&st->lock); + return drawable; + } + + st->last_frame_time = target; + os_unfair_lock_unlock(&st->lock); + + // Sleep only the remaining headroom without burning CPU cores in a spin loop + mach_wait_until(target); + + return drawable; +} + +__attribute__((constructor)) +static void np_fpslimit_init(void) { + const char *fps_env = getenv("NOTPROTON_FPS_LIMIT"); + if (!fps_env || !*fps_env || atof(fps_env) <= 1.0) { + fps_env = getenv("NOTPROTON_ENV_FPS_LIMIT"); + } + if (!fps_env || !*fps_env || atof(fps_env) <= 1.0) { + fps_env = getenv("FPS_LIMIT"); + } + if (!fps_env || !*fps_env) { + return; + } + + double fps_val = atof(fps_env); + if (fps_val <= 0.0) { + return; + } + + g_fps = (uint64_t)(fps_val + 0.5); + if (g_fps == 0) return; + + const char *no_vsync_env = getenv("NOTPROTON_FPS_NO_VSYNC"); + if (!no_vsync_env) no_vsync_env = getenv("NOTPROTON_NO_VSYNC"); + if (!no_vsync_env) no_vsync_env = getenv("NOTPROTON_ENV_FPS_NO_VSYNC"); + if (!no_vsync_env) no_vsync_env = getenv("FPS_NO_VSYNC"); + if (no_vsync_env && (strcmp(no_vsync_env, "1") == 0 || strcmp(no_vsync_env, "true") == 0)) { + g_disable_vsync = true; + } + const char *vsync_off = getenv("NOTPROTON_FPS_VSYNC"); + if (!vsync_off) vsync_off = getenv("FPS_VSYNC"); + if (vsync_off && strcmp(vsync_off, "0") == 0) { + g_disable_vsync = true; + } + + mach_timebase_info(&g_timebase); + + uint64_t ticks_per_sec = (1000000000ULL * (uint64_t)g_timebase.denom) / (uint64_t)g_timebase.numer; + g_interval_ticks = ticks_per_sec / g_fps; + g_tick_rem = ticks_per_sec % g_fps; + + Class layer_cls = objc_getClass("CAMetalLayer"); + if (layer_cls) { + Method m = class_getInstanceMethod(layer_cls, sel_registerName("nextDrawable")); + if (m) { + orig_nextDrawable = (id(*)(id, SEL))method_getImplementation(m); + method_setImplementation(m, (IMP)hook_nextDrawable); + } + } + + fprintf(stderr, "[notproton-fpslimit] Active: target %llu FPS (vsync: %s)\n", + g_fps, g_disable_vsync ? "disabled" : "enabled"); +}