Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions examples/plugins/40-fpslimit.conf
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions plugins/fpslimit/Makefile
Original file line number Diff line number Diff line change
@@ -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
171 changes: 171 additions & 0 deletions plugins/fpslimit/fpslimit.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#import <QuartzCore/CAMetalLayer.h>
#import <mach/mach_time.h>
#import <pthread.h>
#import <objc/runtime.h>
#import <os/lock.h>
#import <stdlib.h>
#import <stdbool.h>
#import <stdio.h>
#import <string.h>

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<CAMetalDrawable> (*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<CAMetalDrawable> 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<CAMetalDrawable> 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<CAMetalDrawable>(*)(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");
}
Loading