diff --git a/README.md b/README.md index 3dbfe044..63e96634 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,29 @@ Configuration files are loaded and merged from the following directories, from h 3. `/etc/` 4. `/usr/share/gamemode/` +### Per-game configuration + +In addition to the global configuration, individual clients can opt into per-game override files. Setting `GAMEMODE_CONFIG=` in a game's environment causes the daemon to load `gamemode.d/.ini` for that client only. Per-game config files are checked in the same locations as the default: `/usr/share/gamemode/gamemode.d/`, `/etc/gamemode.d/`, then `$XDG_CONFIG_HOME/gamemode.d/` (falling back to `~/.config/gamemode.d/`). Values in later files override earlier ones. + +For example, set the following Steam launch options: + +``` +GAMEMODE_CONFIG=eldenring gamemoderun %command% +``` + +And create `~/.config/gamemode.d/eldenring.ini` containing: + +```ini +[cpu] +pin_cores=no +``` + +This will disable core pinning for that game while all other clients and all global optimisations behave as configured normally. + +The `` may only contain alphanumeric characters, `-` and `_`. Per-game files can only override per-client optimisations; global machine state (governor, split lock mitigation, GPU settings, custom scripts) remains controlled by the main configuration. Currently the only recognised key is `pin_cores` under `[cpu]`, where a value of `no`, `false` or `0` disables core pinning for that client. + +Per-game files are read once, when the client registers; unlike the main configuration files they are not watched for changes, so edits apply to the next game launch. + --- ## Note for Hybrid GPU users diff --git a/daemon/gamemode-context.c b/daemon/gamemode-context.c index f0524936..6857cca8 100644 --- a/daemon/gamemode-context.c +++ b/daemon/gamemode-context.c @@ -45,7 +45,9 @@ POSSIBILITY OF SUCH DAMAGE. #include "build-config.h" #include +#include #include +#include #include #include #include @@ -54,17 +56,28 @@ POSSIBILITY OF SUCH DAMAGE. #include /* TODO: Move usage to gamemode-dbus.c */ #include +/** + * Per-game override values parsed from gamemode.d/.ini files, + * selected by a GAMEMODE_CONFIG= variable in the client's environment. + * Only per-client optimisation keys are recognised here; global machine state + * (governor, splitlock, GPU, scripts) remains owned by the main config. + */ +struct GameModeClientOverrides { + char pin_cores[64]; /**/environ for KEY= and copy its value into out. + * environ is a sequence of NUL-terminated "KEY=VALUE" strings. + * Returns true if found. + */ +static bool game_mode_client_env_value(pid_t pid, const char *key, char *out, size_t outlen) +{ + char path[64]; + snprintf(path, sizeof(path), "/proc/%d/environ", pid); + + FILE *f = fopen(path, "re"); + if (!f) + return false; + + bool found = false; + size_t keylen = strlen(key); + char *entry = NULL; + size_t cap = 0; + + while (getdelim(&entry, &cap, '\0', f) != -1) { + if (strncmp(entry, key, keylen) == 0 && entry[keylen] == '=') { + strncpy(out, entry + keylen + 1, outlen - 1); + out[outlen - 1] = '\0'; + found = true; + break; + } + } + + free(entry); + fclose(f); + return found; +} + +/** + * A per-game config name may only contain [A-Za-z0-9_-]. Anything else + * (slashes, dots, etc.) is rejected so the name can never traverse paths. + */ +static bool game_mode_config_name_valid(const char *name) +{ + if (!name || !name[0] || strlen(name) > 64) + return false; + for (const char *p = name; *p; p++) { + if (!isalnum((unsigned char)*p) && *p != '_' && *p != '-') + return false; + } + return true; +} + +/** + * inih callback: record only [cpu] pin_cores, ignore everything else. + * Returning 1 continues parsing. + */ +static int overrides_ini_handler(void *user, const char *section, const char *name, + const char *value) +{ + struct GameModeClientOverrides *ov = user; + + if (strcmp(section, "cpu") == 0 && strcmp(name, "pin_cores") == 0) { + strncpy(ov->pin_cores, value, sizeof(ov->pin_cores) - 1); + ov->pin_cores[sizeof(ov->pin_cores) - 1] = '\0'; + } + + return 1; +} + +/** + * Load per-game override files gamemode.d/.ini into ov. The same + * locations are searched as for the main config, in ascending priority: + * SYSCONFDIR, /etc, then $XDG_CONFIG_HOME (or ~/.config). Each file found + * is parsed into the same struct, so values in later files overwrite + * earlier ones, matching the main config's merge behaviour. + * Returns true if at least one file was loaded. + */ +static bool game_mode_load_client_overrides(const char *name, struct GameModeClientOverrides *ov) +{ + memset(ov, 0, sizeof(*ov)); + + /* Resolve the user's config dir: $XDG_CONFIG_HOME or ~/.config */ + char user_path[PATH_MAX] = { 0 }; + const char *xdg = getenv("XDG_CONFIG_HOME"); + const char *home = getenv("HOME"); + if (xdg) + snprintf(user_path, sizeof(user_path), "%s/gamemode.d/%s.ini", xdg, name); + else if (home) + snprintf(user_path, sizeof(user_path), "%s/.config/gamemode.d/%s.ini", home, name); + + char sys_path[PATH_MAX]; + char etc_path[PATH_MAX]; + snprintf(sys_path, sizeof(sys_path), SYSCONFDIR "/gamemode.d/%s.ini", name); + snprintf(etc_path, sizeof(etc_path), "/etc/gamemode.d/%s.ini", name); + + /* Ascending priority, matching load_config_files() */ + const char *paths[] = { sys_path, etc_path, user_path[0] ? user_path : NULL }; + + bool loaded = false; + for (size_t i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) { + if (!paths[i]) + continue; + + FILE *f = fopen(paths[i], "re"); + if (!f) + continue; + + int err = ini_parse_file(f, overrides_ini_handler, ov); + fclose(f); + + if (err) { + LOG_ERROR("Per-game config [%s]: parse error on line %d of %s\n", name, err, paths[i]); + continue; + } + + LOG_MSG("Per-game config [%s]: loaded %s\n", name, paths[i]); + loaded = true; + } + + if (!loaded) + LOG_MSG("Per-game config [%s]: no files found\n", name); + + return loaded; +} + +/** + * Returns true if a per-game config disables core pinning for this client. + * Accepts the same false spellings as the main config. + */ +static bool client_pinning_disabled(const struct GameModeClientOverrides *ov) +{ + return ov && ov->pin_cores[0] && + (strcmp(ov->pin_cores, "no") == 0 || strcmp(ov->pin_cores, "false") == 0 || + strcmp(ov->pin_cores, "0") == 0); +} + static GameModeContext instance = { 0 }; /** @@ -761,7 +906,8 @@ GameModeClient *game_mode_context_lookup_client(GameModeContext *self, pid_t cli return found; } -static int game_mode_apply_client_optimisations(GameModeContext *self, pid_t client) +static int game_mode_apply_client_optimisations(GameModeContext *self, pid_t client, + const struct GameModeClientOverrides *ov) { /* Store current renice and apply */ game_mode_apply_renice(self, client, 0 /* expect zero value to start with */); @@ -772,8 +918,11 @@ static int game_mode_apply_client_optimisations(GameModeContext *self, pid_t cli /* Apply scheduler policies */ game_mode_apply_scheduling(self, client); - /* Apply core pinning */ - game_mode_apply_core_pinning(self->cpu, client, false); + /* Apply core pinning, unless a per-game config disables it for this client */ + if (client_pinning_disabled(ov)) + LOG_MSG("Per-game config: core pinning disabled for client %d\n", client); + else + game_mode_apply_core_pinning(self->cpu, client, false); return 0; } @@ -834,6 +983,24 @@ int game_mode_context_register(GameModeContext *self, pid_t client, pid_t reques if (!executable) goto error_cleanup; + /* Per-game config discovery: an explicit GAMEMODE_CONFIG= in the + * client's environment selects gamemode.d/.ini override files */ + struct GameModeClientOverrides ov; + memset(&ov, 0, sizeof(ov)); + { + char cfgname[80]; + if (game_mode_client_env_value(client, "GAMEMODE_CONFIG", cfgname, sizeof(cfgname))) { + if (!game_mode_config_name_valid(cfgname)) { + LOG_ERROR("Per-game config: invalid name in GAMEMODE_CONFIG, ignored\n"); + } else if (game_mode_load_client_overrides(cfgname, &ov)) { + if (ov.pin_cores[0]) + LOG_MSG("Per-game config loaded [%s]: pin_cores=%s\n", cfgname, ov.pin_cores); + else + LOG_MSG("Per-game config loaded [%s]: no overridable keys\n", cfgname); + } + } + } + /* Check our blacklist and whitelist */ if (!config_get_client_whitelisted(self->config, executable)) { LOG_MSG("Client [%s] was rejected (not in whitelist)\n", executable); @@ -847,7 +1014,8 @@ int game_mode_context_register(GameModeContext *self, pid_t client, pid_t reques GameModeClient *cl = game_mode_client_new(client, executable, requester); if (!cl) goto error_cleanup; - free(executable); /* we're now done with memory */ + cl->overrides = ov; /* attach per-game overrides to the client */ + free(executable); /* we're now done with memory */ /* Begin a write lock now to insert our new client at list start */ pthread_rwlock_wrlock(&self->rwlock); @@ -863,7 +1031,7 @@ int game_mode_context_register(GameModeContext *self, pid_t client, pid_t reques game_mode_context_enter(self); } - game_mode_apply_client_optimisations(self, client); + game_mode_apply_client_optimisations(self, client, &cl->overrides); /* Unlock now we're done applying optimisations */ pthread_rwlock_unlock(&self->rwlock); @@ -1173,8 +1341,12 @@ static void game_mode_reapply_core_pinning_internal(GameModeContext *self) { pthread_rwlock_wrlock(&self->rwlock); if (game_mode_context_num_clients(self)) { - for (GameModeClient *cl = self->client; cl; cl = cl->next) + for (GameModeClient *cl = self->client; cl; cl = cl->next) { + /* Respect per-game configs which disable pinning */ + if (client_pinning_disabled(&cl->overrides)) + continue; game_mode_apply_core_pinning(self->cpu, cl->pid, true); + } } pthread_rwlock_unlock(&self->rwlock); } @@ -1205,7 +1377,7 @@ static void game_mode_reload_config_internal(GameModeContext *self) game_mode_context_enter(self); for (GameModeClient *cl = self->client; cl; cl = cl->next) - game_mode_apply_client_optimisations(self, cl->pid); + game_mode_apply_client_optimisations(self, cl->pid, &cl->overrides); } pthread_rwlock_unlock(&self->rwlock); diff --git a/example/gamemode.ini b/example/gamemode.ini index 67cf0887..2fe5ba77 100644 --- a/example/gamemode.ini +++ b/example/gamemode.ini @@ -113,6 +113,13 @@ disable_splitlock=1 ;amd_x3d_mode_desired=frequency ;amd_x3d_mode_default=cache +; Per-game configuration: +; A client may set GAMEMODE_CONFIG= in its environment (e.g. via Steam launch options) to load +; gamemode.d/.ini override files for that client only, searched in the same locations as this config +; (/usr/share/gamemode, /etc, then the user config dir). Currently only [cpu] pin_cores is recognised there; a +; value of "no" disables core pinning for that client while leaving other clients and all global optimisations +; unaffected. + [supervisor] ; This section controls the new gamemode functions gamemode_request_start_for and gamemode_request_end_for ; The whilelist and blacklist control which supervisor programs are allowed to make the above requests