Compare commits

..

4 Commits

Author SHA1 Message Date
44r0n7 ad46ad6b14 refactor: deduplicate and clean up across all source modules
- Extract canonical date algorithm to src/date.rs; remove duplicates in log.rs and detect.rs
- Extract build_std_command in launch.rs; unify needs_host_library_injection via pub(crate) delegation
- Add missing unknown-setting warning to parse_imported_config in share.rs
- Extract format_with_hint helper in error.rs; set_proton_no_sync helper in env.rs
- Remove dead match in completion.rs shell_path_literal; use parse_fps for FpsCap in keys.rs
- Replace six as_str() impls with impl_as_str! macro in schema.rs
- Collapse ResolvedSettings::apply (~110 lines) with apply_scalar!/apply_opt!/apply_clone! macros
- Replace six color functions with color_fn! macro in color.rs

89/89 tests passing, zero clippy warnings.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 11:08:43 -04:00
44r0n7 76ab5351a9 test: comprehensive coverage sweep — 34 new tests
Groups added:
- Help topics (all valid topics, bad topic, bad settings group filter)
- version/help flags
- status command
- All 16 toggle settings reject invalid values (maybe/yes/1/ON/empty)
- All 6 enum settings reject bad values
- 14 numeric bad-value cases (fps=0, sharpness=21, abc, negatives, floats)
- 5 numeric boundary-valid cases (sharpness 0/20, fps 1, native, pixels)
- Unknown/wrong-case setting names rejected
- Alias round-trips: host-libs → steam-host-libs, laa → large-address-aware
- config reset edge cases (no args, --all conflict, unknown name)
- config export to stdout and to file (sparse)
- config import error cases (missing file, bad TOML, missing-profile binding)
- profile reserved name "default" rejected in create/export/import
- profile duplicate edge cases (nonexistent src, existing dest, reserved dest, valid copy)
- profile import error cases (missing file, duplicate name)
- profile delete nonexistent
- profile reset edge cases (no-op on unset field, nonexistent profile)
- profile env edge cases (unset missing key, list/clear when empty)
- game command error cases (show/unbind/forget/rename nonexistent, bind to missing profile)
- game list empty state
- game note multiword
- game list --full flag
- dry-run with nonexistent binary (exit 4)
- dry-run shows gamescope flags in plan
- launch with option-like target rejected (exit 2)
- launch with no command
- pre-hook fail mode does NOT trigger post-hook
- both hooks run in order on success
- post-hook runs when binary is missing (fixed bug)
- missing dependency errors for mangohud/gamemoderun/gamescope/mangoapp
- last command with no history
- last shows most recently launched game
- config settings / profile settings list all names
- completion edge cases (no args, bash/zsh/fish output)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 10:14:52 -04:00
44r0n7 e7c3b2eee7 fix: post-hook now runs when game binary does not exist
validate_launch() rejects missing executables in build_plan(), causing an
early return via `?` before the post-hook block was ever reached. Fix by
separating the dry-run path (still fails fast with `?`) from the real-launch
path, where build_plan() result is captured without `?` and chained into
execute_wait() via and_then(). The post-hook fires for both plan-validation
failures and execute_wait() spawn failures.

Also skips the pre-launch hook when the plan is invalid (no binary to set up
for), and skips state recording in the same case.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 09:51:57 -04:00
44r0n7 f984acf0e3 feat: hook reliability, hook-errors setting, sparse config, and logging
Hook reliability:
- Add hook-errors = "warn" | "fail" setting (default: warn); in fail
  mode, abort launch when pre-launch hook exits nonzero or can't execute
- Ensure post-launch hook runs unconditionally, even when execute_wait()
  fails to spawn the game
- Propagate game's real exit status via std::process::exit(); report
  post-hook failures clearly to stderr
- Centralize hook execution via run_hook() helper (sh -c)

New features in this batch:
- Sparse config and profile support: only configured fields are written;
  unset fields fall back through profile → global chain
- config show --effective flag: renders the fully-resolved view
- Config migration: upgrades legacy flat config to current schema
- Structured decision logging (src/log.rs) for session-level audit trail
- Gamescope improvements: additional flags and validation
- CHANGELOG.md tracking template releases

Schema / UX:
- HookErrors enum (Warn/Fail) added to Settings and ResolvedSettings
- hook-errors key in keys.rs, mod.rs rendering, completion candidates,
  doctor output, help text, README, and dry-run display
- 9 focused tests covering warn/fail behavior, exit propagation,
  round-trip (set/show/reset), profile round-trip, export/import

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 09:35:24 -04:00
25 changed files with 5199 additions and 1283 deletions
+10
View File
@@ -0,0 +1,10 @@
## v2.0.0 (unreleased)
### Breaking changes
- Profile-to-profile inheritance removed. Profiles are now flat game-specific overrides applied directly on top of global defaults.
- `profile tree`, `profile inherit`, and `profile clear-inherit` commands removed.
- `profile export` now exports only explicitly configured settings (sparse), not fully resolved settings. Old exports with all settings baked in can still be imported.
- Settings renamed: `overlay``mangohud`, `performance``gamemode` (from prior work).
### Migration
If upgrading from v1: run `gamewrap config migrate` to update renamed setting keys. Profile files exported with v1 can still be imported but will include all resolved settings — re-export after import to get the sparse format.
Generated
+1 -1
View File
@@ -179,7 +179,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "gamewrap"
version = "0.3.0"
version = "1.0.0"
dependencies = [
"clap",
"clap_complete",
+32 -17
View File
@@ -37,17 +37,19 @@ Representative flow:
| `src/config/mod.rs` | Config/state I/O | XDG path discovery, TOML load/save, CLI rendering of config/profile views. |
| `src/config/schema.rs` | Persistent structs | `Settings`, `ResolvedSettings`, `ProfileConfig`, `ConfigFile`, `ObservedGame`, `StateFile`. |
| `src/config/keys.rs` | Setting mutation helpers | Friendly key parsing and value application/reset logic. |
| `src/profile.rs` | Resolution + validation | Resolves inherited profiles and validates parent chains/binding targets. |
| `src/config/migrate.rs` | Config migration logic | Renames retired setting keys in raw config/profile TOML before typed deserialization. |
| `src/profile.rs` | Resolution + validation | Applies global defaults plus one named profile and validates binding targets. |
| `src/bindings.rs` | Binding operations | Matching logic, set/remove binding, resolve profile for executable/observed game. |
| `src/detect.rs` | Executable detection | Pulls real game executable out of Steam/Proton wrapper shapes and records observed launches. |
| `src/launch.rs` | Launch planning/execution | Dependency checks, dry-run rendering, final command execution. |
| `src/env.rs` | Env shaping | Steam-context detection, host-library injection decisions, env var construction. |
| `src/log.rs` | Decision log | Best-effort timestamped launch, hook, exit, and playtime logging. |
| `src/doctor.rs` | Doctor rendering | Formats preflight results. |
| `src/status.rs` | Status rendering | Formats dependency/config/state summary. |
| `src/help.rs` | Long-form help text | Shared topic help and top-level examples. |
| `src/error.rs` | Error model | Exit-code-bearing error categories and constructors. |
| `src/notify.rs` | GUI failure notifier | `zenity` / `kdialog` / `xmessage` / `notify-send` selection and popup logic. |
| `src/share.rs` | Share/import formats | Resolved full-config and single-profile portable TOML formats. |
| `src/share.rs` | Share/import formats | Sparse full-config and single-profile portable TOML formats. |
| `tests/cli_matrix.rs` | Integration CLI coverage | End-to-end command validation in isolated XDG temp envs. |
Skip `target/` and other generated artifacts when extending this map.
@@ -89,12 +91,12 @@ Skip `target/` and other generated artifacts when extending this map.
## 🔑 Key Concepts & Domain Terms
- **Defaults**: global baseline settings from config
- **Profile**: reusable settings bundle; may inherit from another named profile
- **Profile**: reusable game-specific settings bundle applied directly over global defaults
- **Binding**: matcher string mapping a known executable/path to a profile
- **Observed game**: recorded launch entry with executable, path, last launched profile, optional note/display name, launch count, and last launch timestamp
- **Resolved settings**: effective values after applying defaults + inheritance + explicit overrides
- **Resolved settings**: effective values after applying global defaults + explicit profile overrides
- **Steam context**: environment where implicit launch mode and GUI failure notifications are enabled
- **Share format**: portable resolved export file, distinct from the sparse internal config layout
- **Share format**: portable sparse export matching the internal defaults/profile layout
---
@@ -103,18 +105,20 @@ Skip `target/` and other generated artifacts when extending this map.
### Internal local files
- Config path: `~/.config/gamewrap/config.toml`
- State path: `~/.local/state/gamewrap/state.toml`
- Default decision log path: `~/.local/state/gamewrap/gamewrap.log` when `log-file` is enabled
- Config file is sparse/raw:
- stores only explicit overrides
- profiles may be empty and may inherit from parents
- profiles may be empty and always apply directly over global defaults
- bindings are stored separately
- gamescope output width/height accept an integer or `native`; window mode is one of `windowed`, `borderless`, or `fullscreen`
### Portable share files
- Full config export suffix: `.gamewrap.toml`
- Profile export suffix: `.gamewrap-profile.toml`
- Export commands write resolved values, not sparse internal overrides
- Profile import creates a standalone explicit profile with no inheritance
- Per-profile env overrides are stored as an optional `env-vars` map on profile settings and merge through inheritance; child keys override parent keys
- Pre/post launch hook commands are stored as optional string settings; pre-launch runs before exec, while post-launch is persisted/displayed but deferred until wrapped launching exists
- Export commands preserve sparse defaults and profile overrides
- Profile import preserves explicitly configured settings; unset values use the receiving config's global defaults
- Per-profile env overrides are stored as an optional `env-vars` map on profile settings and apply over global env defaults
- Pre/post launch hook commands are stored as optional string settings; setting post-launch switches execution to spawn/wait so the hook can run after the game exits
- Config import accepts:
- current resolved share format
- legacy raw config format as fallback
@@ -137,29 +141,28 @@ Skip `target/` and other generated artifacts when extending this map.
- `completion`
### `config`
- `show`
- `show [--effective]`
- `edit`
- `set <setting> <value>`
- `reset <setting>`
- `reset <setting>` / `reset --all`
- `export [name-or-path]`
- `import <name-or-path>`
- `migrate`
### `profile`
- `list`
- `tree`
- `create <name>`
- `duplicate <src> <dst>`
- `show <name>`
- `export <name> [name-or-path]`
- `import <name-or-path>`
- `migrate <file> [--dry-run]`
- `env set <name> <key> <value>`
- `env unset <name> <key>`
- `env list <name>`
- `env clear <name>`
- `set <name> <setting> <value>`
- `reset <name> <setting>`
- `inherit <name> <parent>`
- `clear-inherit <name>`
- `delete <name>`
### `game`
@@ -190,14 +193,14 @@ If you change:
- command surface -> update `tests/cli_matrix.rs`
- share/import/export formats -> update `tests/cli_matrix.rs` and `src/share.rs` tests
- completion install/candidates -> update `tests/cli_matrix.rs` and `src/completion.rs` tests
- profile inheritance logic -> update `src/profile.rs` tests and related CLI matrix cases
- profile resolution logic -> update `src/profile.rs` tests and related CLI matrix cases
---
## 🧩 Feature Areas & Ownership Map
- **CLI / UX**: `src/cli.rs`, `src/help.rs`, `README.md`
- **Persistence**: `src/config/*`, `src/share.rs`
- **Profiles / inheritance**: `src/profile.rs`
- **Profiles / resolution**: `src/profile.rs`
- **Game observation / matching / bindings**: `src/detect.rs`, `src/bindings.rs`
- **Launch execution**: `src/launch.rs`, `src/env.rs`
- **Diagnostics**: `src/doctor.rs`, `src/status.rs`
@@ -235,6 +238,18 @@ Do not update this file for:
- [2026-05-31] Added TTY/NO_COLOR-aware ANSI output helpers for doctor and status rendering.
- [2026-05-31] Added game forget, config edit, profile list/tree improvements, and notify test commands.
- [2026-05-31] Added gamescope settings, launch wrapping, dependency diagnostics, completion candidates, and docs.
- [2026-06-06] Removed retired setting aliases and added automatic and explicit TOML migration for configs and profile exports.
- [2026-05-31] Added observed-game display names, launch timestamps/counts, `last`, `game rename`, and `fps-cap`.
- [2026-05-31] Added per-profile env overrides, vkBasalt support, and Proton esync/fsync compatibility controls.
- [2026-05-31] Added pre-launch shell hooks plus persisted/dry-run/doctor visibility for deferred post-launch hooks.
- [2026-06-05] Expanded gamescope config surface: nested-width/height, unfocused-fps, scaler (-S), filter (-F), sharpness, fullscreen, borderless, adaptive-sync, hdr, steam, expose-wayland. Added gamescope-mangoapp for native MangoHud overlay integration (--mangoapp flag); doctor warns when mangohud prefix is used inside gamescope without mangoapp.
- [2026-06-06] Added optional vkbasalt-config profile/default setting and gated VKBASALT_CONFIG_FILE launch environment support.
- [2026-06-06] Replaced gamescope fullscreen/borderless toggles with `gamescope-mode` and added native display resolution detection for output width/height.
- [2026-06-06] Clarified unset values in `config show` and added `config reset --all` for restoring built-in defaults.
- [2026-06-06] Added gamewrap decision logging plus MangoHud session logging and vkBasalt log-level settings.
- [2026-06-06] Made `config show` display only explicitly configured defaults, with `--effective` for the full computed view.
- [2026-06-06] Added --effective flag to `profile show` and profile sections of `config show`; default now shows only explicitly configured profile settings.
- [2026-06-06] Renamed the MangoHud/GameMode settings to `mangohud`/`gamemode` with legacy aliases, and grouped settings help with per-tool filters.
- [2026-06-06] Removed profile inheritance, flattened resolution to defaults plus one profile, and made config/profile exports sparse.
- [2026-06-14] Added hook-errors setting (warn/fail) to control pre-launch hook failure behavior; ensured post-hook runs after game spawn failures; propagated game exit codes; centralized hook execution via run_hook().
- [2026-06-14] Consolidated repeated config string mappings, resolved-setting application, and color helpers with local macros.
+39 -22
View File
@@ -19,10 +19,10 @@ gamewrap %command%
## Features
- Short Steam launch options
- Friendly setting names like `overlay` and `performance`
- Friendly setting names like `mangohud` and `gamemode`
- Persistent config outside the Steam UI
- Named profiles for reusable setups
- Profile inheritance for layered setups
- Flat game-specific profiles layered on global defaults
- Game-specific profile binding through `game bind`
- Quick access to the last played game through `gamewrap last`
- Play time and launch count tracking per game
@@ -32,7 +32,6 @@ gamewrap %command%
- Config export/import for backup and sharing
- Direct config editing through `config edit`
- Profile export/import for sharing one setup at a time
- Profile tree view for inheritance checks
- Per-profile environment variable overrides
- gamescope Wayland compositor integration
- FPS cap through MangoHud
@@ -97,13 +96,13 @@ $HOME/.cargo/bin/gamewrap %command%
2. Turn MangoHud on by default:
```bash
gamewrap config set overlay on
gamewrap config set mangohud on
```
3. Turn GameMode on by default:
```bash
gamewrap config set performance on
gamewrap config set gamemode on
```
4. Check your setup before launching a real game:
@@ -133,13 +132,11 @@ gamewrap profile create benchmark
gamewrap game bind "Game.exe" benchmark
```
8. Layer one profile on top of another when you want shared defaults:
8. Add game-specific overrides while keeping shared values global:
```bash
gamewrap profile create base
gamewrap profile set base overlay on
gamewrap config set mangohud on
gamewrap profile create benchmark
gamewrap profile inherit benchmark base
gamewrap profile set benchmark verbose on
```
@@ -181,6 +178,8 @@ gamewrap run -- /path/to/game/executable
```bash
gamewrap --help
gamewrap help settings
gamewrap help settings gamescope
gamewrap help settings mangohud
gamewrap help doctor
gamewrap game list
gamewrap game list "elden"
@@ -196,20 +195,23 @@ gamewrap completion zsh
gamewrap completion install zsh
gamewrap completion path zsh
gamewrap config show
gamewrap config show --effective
gamewrap config edit
gamewrap config reset --all
gamewrap config migrate
gamewrap config export shared
gamewrap config import shared
gamewrap last
gamewrap profile list
gamewrap profile tree
gamewrap profile create benchmark
gamewrap profile show benchmark
gamewrap profile show benchmark --effective
gamewrap profile duplicate benchmark benchmark-copy
gamewrap profile inherit benchmark base
gamewrap profile clear-inherit benchmark
gamewrap profile export benchmark benchmark
gamewrap profile import benchmark
gamewrap profile set benchmark overlay on
gamewrap profile reset benchmark overlay
gamewrap profile migrate old-benchmark
gamewrap profile set benchmark mangohud on
gamewrap profile reset benchmark mangohud
gamewrap profile env set benchmark DXVK_ASYNC 1
gamewrap profile env list benchmark
gamewrap profile env unset benchmark DXVK_ASYNC
@@ -222,15 +224,20 @@ gamewrap game clear-note "eldenring.exe"
## Friendly Settings
- `overlay`: turns MangoHud on or off
- `performance`: turns GameMode on or off
- `mangohud`: turns MangoHud on or off
- `gamemode`: turns GameMode on or off
- `steam-host-libs`: prefers host libraries inside Steam runtime environments and sets `STEAM_RUNTIME_PREFER_HOST_LIBRARIES=1`
- `game-libs`: controls whether `gamewrap` injects auto-detected host library directories into `LD_LIBRARY_PATH`
- `verbose`: shows more detail in diagnostic commands
- `log-file`, `log-path`: append gamewrap launch decisions to the default state log or a custom path
- `gamescope`: wraps the game in the gamescope Wayland compositor
- `gamescope-width`, `gamescope-height`, `gamescope-fps`: pass `-W`, `-H`, and `-r` values to gamescope when `gamescope` is on
- `fps-cap`: caps frame rate through MangoHud when `overlay` is on, for example `gamewrap config set fps-cap 60`
- `gamescope-width`, `gamescope-height`: set output resolution with a pixel count or `native`; unset uses gamescope's 1280x720 default
- `gamescope-mode`: selects `windowed`, `borderless`, or `fullscreen`
- `gamescope-fps`: passes the `-r` target refresh value when gamescope is on
- `fps-cap`: caps frame rate through MangoHud when `mangohud` is on, for example `gamewrap config set fps-cap 60`
- `mangohud-log`, `mangohud-log-path`: enable full-session MangoHud performance logging and optionally choose its output path
- `vkbasalt`: enables vkBasalt post-processing with `ENABLE_VKBASALT=1`
- `vkbasalt-log-level`: sets `VKBASALT_LOG_LEVEL` to `debug`, `info`, `warning`, `error`, or `none` when vkBasalt is on
- `esync`: forces Proton esync on or off with `PROTON_NO_ESYNC`; leave unset to use Steam/Proton defaults
- `fsync`: forces Proton fsync on or off with `PROTON_NO_FSYNC`; leave unset to use Steam/Proton defaults
- `large-address-aware` / `laa`: sets `PROTON_LARGE_ADDRESS_AWARE=1` for older 32-bit Proton games that need more than 2 GB of address space
@@ -238,9 +245,20 @@ gamewrap game clear-note "eldenring.exe"
- `post-launch`: runs a shell command after the game exits; when set, gamewrap spawns the game and waits for it to finish before running this hook
- `env-vars`: per-profile environment overrides managed with `gamewrap profile env set/list/unset/clear`
**hook-errors** (`warn` | `fail`, default: `warn`)
Controls what happens when a pre-launch hook exits nonzero or cannot be executed.
- `warn` — a message is printed to stderr and the game launches anyway.
- `fail` — gamewrap aborts with an error and the game is not launched.
The post-launch hook always runs after the pre-launch hook succeeds, even if the game itself fails to start.
Example:
gamewrap config set hook-errors fail
## How It Works
When `gamewrap` launches a game, it resolves your defaults, any inherited profile chain, and any matching game binding, prepares the needed environment variables, and then prefixes the game command with `gamescope`, `mangohud`, and/or `gamemoderun` when those features are enabled.
When `gamewrap` launches a game, it applies global defaults, then any matching profile overrides, prepares the needed environment variables, and prefixes the game command with `gamescope`, `mangohud`, and/or `gamemoderun` when those features are enabled.
If something important is missing, `gamewrap` is designed to fail clearly instead of silently skipping the requested behavior. The `doctor` and `status` commands help you verify that before launching through Steam.
@@ -253,7 +271,6 @@ For terminal usage, `gamewrap` distinguishes between management commands and exp
- `gamewrap config edit` opens the config file in `$VISUAL`, `$EDITOR`, or `nano`
- use `gamewrap config export` and `gamewrap config import ...` for full-config backup and sharing
- use `gamewrap profile export` and `gamewrap profile import ...` for sharing one profile
- use `gamewrap profile tree` to inspect profile inheritance
- use `gamewrap profile env set <profile> <KEY> <VALUE>` for per-profile environment overrides
- use `gamewrap notify test` to verify graphical failure notifications
- use `gamewrap run <command>` when you want to explicitly launch from the terminal
@@ -302,9 +319,9 @@ gamewrap profile import benchmark
`gamewrap` automatically adds `.gamewrap.toml` or `.gamewrap-profile.toml` when needed.
`gamewrap config export` writes resolved settings, not just the sparse internal overrides. That makes the exported file more predictable when you import it on another machine with different defaults.
`gamewrap config export` writes the sparse global defaults and profile overrides exactly as configured.
`gamewrap profile export` also writes resolved settings. Imported profiles are brought in as standalone profiles with explicit values, so they keep the same behavior even if the importing machine uses different defaults.
`gamewrap profile export` writes only explicitly configured profile settings. Imported profiles use the receiving machine's global defaults for every unset value.
## Files
+95 -3
View File
@@ -33,7 +33,7 @@ Implemented features:
- Steam-first launcher flow with `gamewrap %command%`
- Default launch mode with MangoHud and GameMode
- Friendly setting names for all launch options
- Named profiles with full inheritance chains and cycle detection
- Named game-specific profiles (flat two-tier: global defaults + per-game overrides)
- Per-profile environment variable overrides (`profile env set/unset/list/clear`)
- Manual executable-to-profile bindings
- Observed game history with launch count, timestamps, and display names
@@ -51,14 +51,13 @@ Implemented features:
- `dry-run` launch plan preview
- `config edit` to open config in `$EDITOR`
- Config and profile export/import for sharing
- `profile tree` for visualizing inheritance chains
- `notify test` for graphical notification verification
- ANSI color output (respects NO_COLOR, CLICOLOR, CLICOLOR_FORCE)
- Shell completion with live candidates (profiles, games, settings)
- Clear runtime dependency checks and actionable error messages
Friendly settings supported:
- `overlay`, `performance`, `steam-host-libs`, `game-libs`, `verbose`
- `mangohud`, `gamemode`, `steam-host-libs`, `game-libs`, `verbose`
- `gamescope`, `gamescope-width`, `gamescope-height`, `gamescope-fps`
- `fps-cap`, `vkbasalt`
- `esync`, `fsync`, `large-address-aware`
@@ -132,6 +131,7 @@ Features that were deferred from the initial scope and are now implemented:
Features still deferred:
- Benchmark and recording profile presets (named templates that bundle several settings)
- Additional launcher helpers beyond the current set
- **Profile description field for community sharing** — add `description: Option<String>` to `ProfileConfig` and `SharedProfileFile`, shown at the top of exported `.gamewrap-profile.toml` files so ProtonDB/forum posts are self-documenting. CLI: `gamewrap profile describe <name> <text>` and `profile clear-describe <name>`. Preserved through import and shown in `profile show`/`profile list`. No tags, AppIDs, or author fields — those belong on the posting platform, not in the file.
## Recommended Expansion Order
@@ -245,3 +245,95 @@ Before starting packaging or deferred features, re-check:
- whether the project is going public
- whether package maintenance is worth the overhead
- whether v1 behavior is stable enough to freeze the main CLI/config model
---
## Post-v1 Backlog
Logged 2026-06-06. Priority order reflects dependencies — B1 sets naming used by B5, and JSON output (B6 Phase 1) is prerequisite for any GUI work.
### B1 + B4 — Settings grouping, tool-prefixed names, help system overhaul
**Problem:**
- `gamewrap help settings` outputs 36 settings in a flat, ungrouped list with no visual separation by tool
- `overlay` (MangoHud) and `performance` (GameMode) have no tool prefix while every gamescope/vkbasalt/mangohud-log setting already does
- A gamescope configuration note currently sits orphaned between log options and the gamescope settings block in help output
**Proposed changes:**
- Rename `overlay``mangohud` (the MangoHud enable/disable toggle), consistent with `mangohud-log`
- Rename `performance``gamemode` (the GameMode enable/disable toggle)
- Group `gamewrap help settings` into tool-based sections: **Core**, **MangoHud**, **GameMode**, **Gamescope**, **vkBasalt**, **Proton/Wine**, **Hooks & Logging**
- Move the gamescope configuration note into the Gamescope section header (fixes B4)
- Enhance `topic_text()` in `src/help.rs` to accept an optional tool filter: `gamewrap help settings gamescope`
- Propagate renames everywhere: `Settings` struct fields, CLI parser, `config/keys.rs`, TOML serialization, shell completion, tests, README, docs
- Add a migration path for existing configs using the old names (clear error pointing to new name, or silent rename on load)
**Note:** Breaking change — existing config files and profiles using `overlay` or `performance` will need migration. Sets the naming foundation for B5.
**Key files:** `src/help.rs`, `src/config/schema.rs`, `src/config/keys.rs`, `src/config/mod.rs`, `src/cli.rs`, `src/completion.rs`, `tests/`, `README.md`
---
### B2 — Large game list management
**Problem:**
- After using gamewrap on many games (demos, uninstalled games, one-offs), `game list` grows without bound
- The only removal option is `game forget` (hard deletion); no way to soft-hide entries
**Proposed changes:**
- Add `game archive <name-or-exe>` / `game unarchive <name-or-exe>` — sets an `archived` flag on the state entry, excluded from default `game list`
- `game list --all` shows archived entries alongside active ones (visually marked)
- `game forget` remains for permanent removal
- `game list` shows an entry count and a hint if the list exceeds a threshold (e.g. 20 entries)
**Key files:** `src/cli.rs`, state file handling in `src/config/mod.rs`, `src/detect.rs`, tests
---
### B3 — Profile export semantics
**Question:** When a profile is exported to share:
- **Option A — sparse:** Export only what was explicitly set in the profile. Recipient's globals fill the rest at their end. Simple and portable; behavior depends on recipient's global config.
- **Option B — effective:** Export fully resolved settings (profile + globals baked in). Recipient gets a "known good" snapshot but must manually strip their own preferences.
- **Option C — sparse + informational context:** Export sparse profile, plus a `# context:` comment block showing what the exporter's globals were at export time. Informational only — not interpreted by import.
**Current behavior:** Option A (sparse).
**Resolved:** Sparse export implemented. Profiles are now flat (no inheritance). Export produces only explicitly set settings.
---
### B5 — Grouped, cleaner `config show` / `profile show` display
**Problem:**
- `config show` output (especially `--effective`) has no visual grouping — 30+ settings appear in struct field order
- No section headers separating e.g. gamescope settings from MangoHud settings
**Proposed changes:**
- Refactor `render_resolved_settings()` and `render_profile_settings_with_indent()` to emit settings under dimmed section headers using the same tool groups as B1
- Configured mode (default): only show a section header if that section has at least one configured value
- Effective mode (`--effective`): always show section headers; show `(nothing configured)` per-section if empty
**Dependency:** Best done after B1, which defines the canonical section groupings. Can be scoped independently by hardcoding groups if B1 is deferred.
**Key files:** `src/config/mod.rs`, tests
---
### B6 — JSON output flag + GUI (discussion needed)
**Phase 1 — JSON flag (independent):**
- Add `--json` flag to read commands: `config show`, `profile show`, `game list`, `status`, `dry-run`
- Write/mutating commands return `{"ok": true}` or the updated state on success
- Useful for scripting independently of any GUI
**Phase 2 — GUI (dependent on Phase 1):**
- Build a GUI that invokes `gamewrap` as a subprocess and renders the JSON output
- CLI remains the authoritative source of truth; GUI is a frontend only
**Discussion points before Phase 2:**
- Framework: egui (Rust, self-contained), iced (Rust async), Tauri (web frontend, heavier), GTK4 (gtk-rs)?
- Scope: full settings editor, profile manager, game list browser, or read-only status overlay?
- Packaging: GUI as an optional Cargo feature flag to keep the base CLI install slim?
**Recommended starting point:** Phase 1 JSON flag only.
+500 -196
View File
File diff suppressed because it is too large Load Diff
+16 -45
View File
@@ -12,53 +12,24 @@ pub fn enabled() -> bool {
std::env::var("TERM").as_deref() != Ok("dumb")
}
pub fn ok(text: &str) -> String {
if enabled() {
text.green().to_string()
} else {
text.to_string()
}
macro_rules! color_fn {
($name:ident, $method:ident) => {
pub fn $name(text: &str) -> String {
if enabled() {
text.$method().to_string()
} else {
text.to_string()
}
}
};
}
pub fn warn(text: &str) -> String {
if enabled() {
text.yellow().to_string()
} else {
text.to_string()
}
}
pub fn fail(text: &str) -> String {
if enabled() {
text.red().to_string()
} else {
text.to_string()
}
}
pub fn bold(text: &str) -> String {
if enabled() {
text.bold().to_string()
} else {
text.to_string()
}
}
pub fn accent(text: &str) -> String {
if enabled() {
text.cyan().to_string()
} else {
text.to_string()
}
}
pub fn dim(text: &str) -> String {
if enabled() {
text.dimmed().to_string()
} else {
text.to_string()
}
}
color_fn!(ok, green);
color_fn!(warn, yellow);
color_fn!(fail, red);
color_fn!(bold, bold);
color_fn!(accent, cyan);
color_fn!(dim, dimmed);
pub fn on_off(value: bool) -> String {
if value { ok("on") } else { dim("off") }
+222 -52
View File
@@ -58,7 +58,7 @@ pub fn install(shell: Shell, paths: &AppPaths) -> Result<InstallOutcome, AppErro
let mut message = format!(
"Installed {} completion to `{}`.",
shell.to_string(),
shell,
shell_files.script_path.display()
);
@@ -105,61 +105,234 @@ pub fn path(shell: Shell, paths: &AppPaths) -> Result<String, AppError> {
Ok(output)
}
pub const SETTING_NAME_HINTS: &[(&str, &str)] = &[
("mangohud", "Turn MangoHud on or off"),
("gamemode", "Turn GameMode on or off"),
(
"steam-host-libs",
"Prefer host libraries inside the Steam runtime",
),
("game-libs", "Control host library path injection for games"),
("verbose", "Show extra diagnostic detail"),
("log-file", "Write a launch decision log to a file"),
("log-path", "Path to gamewrap log file"),
("gamescope", "Wrap launches in the gamescope compositor"),
(
"gamescope-width",
"Set the gamescope output width (pixels or native)",
),
(
"gamescope-height",
"Set the gamescope output height (pixels or native)",
),
("gamescope-fps", "Set the gamescope target FPS (-r)"),
(
"gamescope-nested-width",
"Set the game render width (-w, for upscaling)",
),
(
"gamescope-nested-height",
"Set the game render height (-h, for upscaling)",
),
(
"gamescope-unfocused-fps",
"Set FPS limit when gamescope window is unfocused",
),
(
"gamescope-scaler",
"Set the gamescope scaling mode (auto, integer, fit, fill, stretch)",
),
(
"gamescope-filter",
"Set the gamescope upscale filter (linear, nearest, fsr, nis, pixel)",
),
(
"gamescope-sharpness",
"Set upscale sharpness 020 (0=max, 20=min, fsr/nis only)",
),
(
"gamescope-mode",
"Set the gamescope window mode (windowed, borderless, fullscreen)",
),
(
"gamescope-adaptive-sync",
"Enable adaptive sync / VRR in gamescope",
),
("gamescope-hdr", "Enable HDR output in gamescope"),
("gamescope-steam", "Enable Steam integration in gamescope"),
(
"gamescope-expose-wayland",
"Expose Wayland socket for native Wayland clients",
),
(
"gamescope-mangoapp",
"Use gamescope native MangoHud overlay instead of mangohud prefix",
),
("fps-cap", "Cap frame rate when MangoHud overlay is enabled"),
("mangohud-log", "Enable MangoHud session performance log"),
("mangohud-log-path", "Path for MangoHud log output"),
("vkbasalt", "Enable the vkBasalt post-processing layer"),
(
"vkbasalt-config",
"Path to a vkBasalt config file (sets VKBASALT_CONFIG_FILE)",
),
("vkbasalt-log-level", "vkBasalt log verbosity"),
("esync", "Force Proton esync on or off"),
("fsync", "Force Proton fsync on or off"),
(
"large-address-aware",
"Set PROTON_LARGE_ADDRESS_AWARE for Proton",
),
("laa", "Alias for large-address-aware"),
(
"pre-launch",
"Run a shell command before launching the game",
),
(
"post-launch",
"Store a shell command for a future wrapped post-game hook",
),
(
"hook-errors",
"Control pre-launch hook failure behavior (warn/fail)",
),
("host-libs", "Alias for steam-host-libs"),
];
pub fn setting_name_candidates() -> Vec<CompletionCandidate> {
[
("overlay", "Turn MangoHud on or off"),
("performance", "Turn GameMode on or off"),
(
"steam-host-libs",
"Prefer host libraries inside the Steam runtime",
),
("game-libs", "Control host library path injection for games"),
("verbose", "Show extra diagnostic detail"),
("gamescope", "Wrap launches in the gamescope compositor"),
("gamescope-width", "Set the gamescope target width"),
("gamescope-height", "Set the gamescope target height"),
("gamescope-fps", "Set the gamescope target FPS"),
("fps-cap", "Cap frame rate when MangoHud overlay is enabled"),
("vkbasalt", "Enable the vkBasalt post-processing layer"),
("esync", "Force Proton esync on or off"),
("fsync", "Force Proton fsync on or off"),
(
"large-address-aware",
"Set PROTON_LARGE_ADDRESS_AWARE for Proton",
),
("laa", "Alias for large-address-aware"),
(
"pre-launch",
"Run a shell command before launching the game",
),
(
"post-launch",
"Store a shell command for a future wrapped post-game hook",
),
("host-libs", "Alias for steam-host-libs"),
]
.into_iter()
.map(|(value, help)| CompletionCandidate::new(value).help(Some(help.into())))
.collect()
SETTING_NAME_HINTS
.iter()
.map(|(value, help)| CompletionCandidate::new(*value).help(Some((*help).into())))
.collect()
}
pub fn setting_value_candidates() -> Vec<CompletionCandidate> {
[
("on", "Enable the setting"),
("off", "Disable the setting"),
("auto", "Use automatic behavior"),
("keep", "Keep the current value unchanged"),
("gamemode", "Always add GameMode library paths"),
]
.into_iter()
.map(|(value, help)| CompletionCandidate::new(value).help(Some(help.into())))
.collect()
// In completion mode, gamewrap is invoked with the full command line as process args.
// The setting name is always the second-to-last arg (last is the partial value being typed).
let args: Vec<String> = std::env::args().collect();
let setting = args
.len()
.checked_sub(2)
.and_then(|i| args.get(i))
.map(String::as_str);
candidates_for_setting(setting)
}
fn candidates_for_setting(setting: Option<&str>) -> Vec<CompletionCandidate> {
let c = |v: &'static str, h: &'static str| CompletionCandidate::new(v).help(Some(h.into()));
let toggle = || {
vec![
c("on", "Enable the setting"),
c("off", "Disable the setting"),
]
};
match setting {
Some(
"mangohud"
| "gamemode"
| "steam-host-libs"
| "host-libs"
| "verbose"
| "log-file"
| "gamescope"
| "gamescope-adaptive-sync"
| "gamescope-hdr"
| "gamescope-steam"
| "gamescope-expose-wayland"
| "gamescope-mangoapp"
| "mangohud-log"
| "vkbasalt"
| "esync"
| "fsync"
| "large-address-aware"
| "laa",
) => toggle(),
Some("game-libs") => vec![
c("auto", "Automatic library path handling"),
c("keep", "Keep current library path unchanged"),
c("gamemode", "Always add GameMode library paths"),
],
Some("gamescope-scaler") => vec![
c("auto", "Automatic scaling"),
c("integer", "Integer scale mode"),
c("fit", "Fit to output resolution"),
c("fill", "Fill output resolution"),
c("stretch", "Stretch to output resolution"),
],
Some("gamescope-filter") => vec![
c("linear", "Linear filter"),
c("nearest", "Nearest-neighbor filter"),
c("fsr", "AMD FidelityFX Super Resolution upscaling"),
c("nis", "NVIDIA Image Scaling upscaling"),
c("pixel", "Pixel-art filter"),
],
Some("gamescope-mode") => vec![
c("windowed", "Windowed mode"),
c("borderless", "Borderless windowed mode"),
c("fullscreen", "Fullscreen mode"),
],
Some("vkbasalt-log-level") => vec![
c("debug", "Debug logging"),
c("info", "Informational logging"),
c("warning", "Warning logging"),
c("error", "Error logging"),
c("none", "Disable logging"),
],
Some("hook-errors") => vec![
c("warn", "Log hook failure and continue launching (default)"),
c("fail", "Abort launch when pre-launch hook fails"),
],
// Numeric and free-text settings have no useful static candidates
Some(
"gamescope-width"
| "gamescope-height"
| "gamescope-nested-width"
| "gamescope-nested-height"
| "gamescope-fps"
| "gamescope-unfocused-fps"
| "gamescope-sharpness"
| "fps-cap"
| "log-path"
| "mangohud-log-path"
| "vkbasalt-config"
| "pre-launch"
| "post-launch",
) => vec![],
// Unknown setting or no context — show all possible values
_ => [
("on", "Enable the setting"),
("off", "Disable the setting"),
("auto", "Use automatic behavior"),
("keep", "Keep current value unchanged"),
("gamemode", "Always add GameMode library paths"),
("fsr", "AMD FidelityFX Super Resolution upscaling"),
("nis", "NVIDIA Image Scaling upscaling"),
("linear", "Linear filter"),
("nearest", "Nearest-neighbor filter"),
("pixel", "Pixel-art filter"),
("integer", "Integer scale mode"),
("fit", "Fit to output resolution"),
("fill", "Fill output resolution"),
("stretch", "Stretch to output resolution"),
("windowed", "Windowed mode"),
("borderless", "Borderless windowed mode"),
("fullscreen", "Fullscreen mode"),
("debug", "Enable debug logging"),
("info", "Informational logging"),
("warning", "Warning logging"),
("error", "Error logging"),
("none", "Disable tool logging"),
]
.into_iter()
.map(|(v, h)| c(v, h))
.collect(),
}
}
pub fn named_profile_candidates() -> Vec<CompletionCandidate> {
load_named_profiles()
.into_iter()
.map(|name| CompletionCandidate::new(name))
.map(CompletionCandidate::new)
.collect()
}
@@ -301,11 +474,8 @@ fn startup_block(shell: Shell, script_path: &Path) -> String {
}
}
fn shell_path_literal(shell: Shell, path: &Path) -> String {
match shell {
Shell::PowerShell => format!("'{}'", escape_single_quotes(path)),
_ => format!("'{}'", escape_single_quotes(path)),
}
fn shell_path_literal(_shell: Shell, path: &Path) -> String {
format!("'{}'", escape_single_quotes(path))
}
fn escape_single_quotes(path: &Path) -> Cow<'_, str> {
+226 -26
View File
@@ -1,48 +1,89 @@
use crate::config::{GameLibsMode, Settings};
use crate::config::{
GameLibsMode, GamescopeFilter, GamescopeScaler, GamescopeSize, GamescopeWindowMode, Settings,
VkbasaltLogLevel,
};
use crate::error::{AppError, config_error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingKey {
Overlay,
Performance,
Mangohud,
Gamemode,
SteamHostLibs,
GameLibs,
Verbose,
LogFile,
LogPath,
Gamescope,
GamescopeWidth,
GamescopeHeight,
GamescopeFps,
GamescopeNestedWidth,
GamescopeNestedHeight,
GamescopeUnfocusedFps,
GamescopeScaler,
GamescopeFilter,
GamescopeSharpness,
GamescopeMode,
GamescopeAdaptiveSync,
GamescopeHdr,
GamescopeSteam,
GamescopeExposeWayland,
GamescopeMangoapp,
FpsCap,
MangohudLog,
MangohudLogPath,
Vkbasalt,
VkbasaltConfig,
VkbasaltLogLevel,
Esync,
Fsync,
LargeAddressAware,
PreLaunch,
PostLaunch,
HookErrors,
}
impl SettingKey {
pub fn parse(value: &str) -> Result<Self, AppError> {
match value {
"overlay" => Ok(Self::Overlay),
"performance" => Ok(Self::Performance),
"mangohud" => Ok(Self::Mangohud),
"gamemode" => Ok(Self::Gamemode),
"steam-host-libs" | "host-libs" => Ok(Self::SteamHostLibs),
"game-libs" => Ok(Self::GameLibs),
"verbose" => Ok(Self::Verbose),
"log-file" => Ok(Self::LogFile),
"log-path" => Ok(Self::LogPath),
"gamescope" => Ok(Self::Gamescope),
"gamescope-width" => Ok(Self::GamescopeWidth),
"gamescope-height" => Ok(Self::GamescopeHeight),
"gamescope-fps" => Ok(Self::GamescopeFps),
"gamescope-nested-width" => Ok(Self::GamescopeNestedWidth),
"gamescope-nested-height" => Ok(Self::GamescopeNestedHeight),
"gamescope-unfocused-fps" => Ok(Self::GamescopeUnfocusedFps),
"gamescope-scaler" => Ok(Self::GamescopeScaler),
"gamescope-filter" => Ok(Self::GamescopeFilter),
"gamescope-sharpness" => Ok(Self::GamescopeSharpness),
"gamescope-mode" => Ok(Self::GamescopeMode),
"gamescope-adaptive-sync" => Ok(Self::GamescopeAdaptiveSync),
"gamescope-hdr" => Ok(Self::GamescopeHdr),
"gamescope-steam" => Ok(Self::GamescopeSteam),
"gamescope-expose-wayland" => Ok(Self::GamescopeExposeWayland),
"gamescope-mangoapp" => Ok(Self::GamescopeMangoapp),
"fps-cap" => Ok(Self::FpsCap),
"mangohud-log" => Ok(Self::MangohudLog),
"mangohud-log-path" => Ok(Self::MangohudLogPath),
"vkbasalt" => Ok(Self::Vkbasalt),
"vkbasalt-config" => Ok(Self::VkbasaltConfig),
"vkbasalt-log-level" => Ok(Self::VkbasaltLogLevel),
"esync" => Ok(Self::Esync),
"fsync" => Ok(Self::Fsync),
"large-address-aware" | "laa" => Ok(Self::LargeAddressAware),
"pre-launch" => Ok(Self::PreLaunch),
"post-launch" => Ok(Self::PostLaunch),
"hook-errors" => Ok(Self::HookErrors),
_ => Err(config_error(
format!("`{value}` is not a known setting."),
"Valid settings: overlay, performance, steam-host-libs, game-libs, verbose, gamescope, gamescope-width, gamescope-height, gamescope-fps, fps-cap, vkbasalt, esync, fsync, large-address-aware, pre-launch, post-launch.",
"Run `gamewrap help settings` to see all available settings.",
)),
}
}
@@ -50,58 +91,114 @@ impl SettingKey {
pub fn set_value(settings: &mut Settings, key: SettingKey, value: &str) -> Result<(), AppError> {
match key {
SettingKey::Overlay => settings.overlay = Some(parse_toggle(value)?),
SettingKey::Performance => settings.performance = Some(parse_toggle(value)?),
SettingKey::Mangohud => settings.mangohud = Some(parse_toggle(value)?),
SettingKey::Gamemode => settings.gamemode = Some(parse_toggle(value)?),
SettingKey::SteamHostLibs => settings.steam_host_libs = Some(parse_toggle(value)?),
SettingKey::GameLibs => settings.game_libs = Some(parse_game_libs(value)?),
SettingKey::Verbose => settings.verbose = Some(parse_toggle(value)?),
SettingKey::LogFile => settings.log_file = Some(parse_toggle(value)?),
SettingKey::LogPath => settings.log_path = Some(value.to_string()),
SettingKey::Gamescope => settings.gamescope = Some(parse_toggle(value)?),
SettingKey::GamescopeWidth => {
settings.gamescope_width = Some(parse_pixel_count(value)?);
settings.gamescope_width = Some(parse_gamescope_size(value)?);
}
SettingKey::GamescopeHeight => {
settings.gamescope_height = Some(parse_pixel_count(value)?);
settings.gamescope_height = Some(parse_gamescope_size(value)?);
}
SettingKey::GamescopeFps => {
settings.gamescope_fps = Some(parse_gamescope_fps(value)?);
settings.gamescope_fps = Some(parse_fps(value, "gamescope-fps")?);
}
SettingKey::FpsCap => {
let n: u32 = value.parse().map_err(|_| {
config_error(
format!("`{value}` is not a valid FPS cap. Use a number like `60` or `120`."),
"Use `gamewrap config set fps-cap 60` for a 60 FPS cap.",
)
})?;
settings.fps_cap = Some(n);
SettingKey::GamescopeNestedWidth => {
settings.gamescope_nested_width = Some(parse_pixel_count(value)?);
}
SettingKey::GamescopeNestedHeight => {
settings.gamescope_nested_height = Some(parse_pixel_count(value)?);
}
SettingKey::GamescopeUnfocusedFps => {
settings.gamescope_unfocused_fps = Some(parse_fps(value, "gamescope-unfocused-fps")?);
}
SettingKey::GamescopeScaler => {
settings.gamescope_scaler = Some(parse_gamescope_scaler(value)?);
}
SettingKey::GamescopeFilter => {
settings.gamescope_filter = Some(parse_gamescope_filter(value)?);
}
SettingKey::GamescopeSharpness => {
settings.gamescope_sharpness = Some(parse_sharpness(value)?);
}
SettingKey::GamescopeMode => {
settings.gamescope_window_mode = Some(parse_gamescope_window_mode(value)?);
}
SettingKey::GamescopeAdaptiveSync => {
settings.gamescope_adaptive_sync = Some(parse_toggle(value)?);
}
SettingKey::GamescopeHdr => {
settings.gamescope_hdr = Some(parse_toggle(value)?);
}
SettingKey::GamescopeSteam => {
settings.gamescope_steam = Some(parse_toggle(value)?);
}
SettingKey::GamescopeExposeWayland => {
settings.gamescope_expose_wayland = Some(parse_toggle(value)?);
}
SettingKey::GamescopeMangoapp => {
settings.gamescope_mangoapp = Some(parse_toggle(value)?);
}
SettingKey::FpsCap => settings.fps_cap = Some(parse_fps(value, "fps-cap")?),
SettingKey::MangohudLog => settings.mangohud_log = Some(parse_toggle(value)?),
SettingKey::MangohudLogPath => settings.mangohud_log_path = Some(value.to_string()),
SettingKey::Vkbasalt => settings.vkbasalt = Some(parse_toggle(value)?),
SettingKey::VkbasaltConfig => settings.vkbasalt_config = Some(value.to_string()),
SettingKey::VkbasaltLogLevel => {
settings.vkbasalt_log_level = Some(parse_vkbasalt_log_level(value)?);
}
SettingKey::Esync => settings.esync = Some(parse_toggle(value)?),
SettingKey::Fsync => settings.fsync = Some(parse_toggle(value)?),
SettingKey::LargeAddressAware => settings.large_address_aware = Some(parse_toggle(value)?),
SettingKey::PreLaunch => settings.pre_launch = Some(value.to_string()),
SettingKey::PostLaunch => settings.post_launch = Some(value.to_string()),
SettingKey::HookErrors => settings.hook_errors = Some(parse_hook_errors(value)?),
}
Ok(())
}
pub fn reset_value(settings: &mut Settings, key: SettingKey) {
match key {
SettingKey::Overlay => settings.overlay = None,
SettingKey::Performance => settings.performance = None,
SettingKey::Mangohud => settings.mangohud = None,
SettingKey::Gamemode => settings.gamemode = None,
SettingKey::SteamHostLibs => settings.steam_host_libs = None,
SettingKey::GameLibs => settings.game_libs = None,
SettingKey::Verbose => settings.verbose = None,
SettingKey::LogFile => settings.log_file = None,
SettingKey::LogPath => settings.log_path = None,
SettingKey::Gamescope => settings.gamescope = None,
SettingKey::GamescopeWidth => settings.gamescope_width = None,
SettingKey::GamescopeHeight => settings.gamescope_height = None,
SettingKey::GamescopeFps => settings.gamescope_fps = None,
SettingKey::GamescopeNestedWidth => settings.gamescope_nested_width = None,
SettingKey::GamescopeNestedHeight => settings.gamescope_nested_height = None,
SettingKey::GamescopeUnfocusedFps => settings.gamescope_unfocused_fps = None,
SettingKey::GamescopeScaler => settings.gamescope_scaler = None,
SettingKey::GamescopeFilter => settings.gamescope_filter = None,
SettingKey::GamescopeSharpness => settings.gamescope_sharpness = None,
SettingKey::GamescopeMode => settings.gamescope_window_mode = None,
SettingKey::GamescopeAdaptiveSync => settings.gamescope_adaptive_sync = None,
SettingKey::GamescopeHdr => settings.gamescope_hdr = None,
SettingKey::GamescopeSteam => settings.gamescope_steam = None,
SettingKey::GamescopeExposeWayland => settings.gamescope_expose_wayland = None,
SettingKey::GamescopeMangoapp => settings.gamescope_mangoapp = None,
SettingKey::FpsCap => settings.fps_cap = None,
SettingKey::MangohudLog => settings.mangohud_log = None,
SettingKey::MangohudLogPath => settings.mangohud_log_path = None,
SettingKey::Vkbasalt => settings.vkbasalt = None,
SettingKey::VkbasaltConfig => settings.vkbasalt_config = None,
SettingKey::VkbasaltLogLevel => settings.vkbasalt_log_level = None,
SettingKey::Esync => settings.esync = None,
SettingKey::Fsync => settings.fsync = None,
SettingKey::LargeAddressAware => settings.large_address_aware = None,
SettingKey::PreLaunch => settings.pre_launch = None,
SettingKey::PostLaunch => settings.post_launch = None,
SettingKey::HookErrors => settings.hook_errors = None,
}
}
@@ -128,6 +225,91 @@ fn parse_game_libs(value: &str) -> Result<GameLibsMode, AppError> {
}
}
fn parse_vkbasalt_log_level(value: &str) -> Result<VkbasaltLogLevel, AppError> {
match value {
"debug" => Ok(VkbasaltLogLevel::Debug),
"info" => Ok(VkbasaltLogLevel::Info),
"warning" => Ok(VkbasaltLogLevel::Warning),
"error" => Ok(VkbasaltLogLevel::Error),
"none" => Ok(VkbasaltLogLevel::None),
_ => Err(config_error(
format!("`{value}` is not a valid vkBasalt log level."),
"Use one of: debug, info, warning, error, none.",
)),
}
}
fn parse_gamescope_scaler(value: &str) -> Result<GamescopeScaler, AppError> {
match value {
"auto" => Ok(GamescopeScaler::Auto),
"integer" => Ok(GamescopeScaler::Integer),
"fit" => Ok(GamescopeScaler::Fit),
"fill" => Ok(GamescopeScaler::Fill),
"stretch" => Ok(GamescopeScaler::Stretch),
_ => Err(config_error(
format!("`{value}` is not a valid gamescope scaler."),
"Use one of: auto, integer, fit, fill, stretch.",
)),
}
}
fn parse_gamescope_filter(value: &str) -> Result<GamescopeFilter, AppError> {
match value {
"linear" => Ok(GamescopeFilter::Linear),
"nearest" => Ok(GamescopeFilter::Nearest),
"fsr" => Ok(GamescopeFilter::Fsr),
"nis" => Ok(GamescopeFilter::Nis),
"pixel" => Ok(GamescopeFilter::Pixel),
_ => Err(config_error(
format!("`{value}` is not a valid gamescope filter."),
"Use one of: linear, nearest, fsr, nis, pixel.",
)),
}
}
fn parse_gamescope_size(value: &str) -> Result<GamescopeSize, AppError> {
if value == "native" {
return Ok(GamescopeSize::Native);
}
let n: u32 = value.parse().map_err(|_| {
config_error(
format!(
"`{value}` is not a valid resolution. Use a pixel count like `1920` or `native`."
),
"Use a whole-number pixel count or `native` to auto-detect the display resolution.",
)
})?;
Ok(GamescopeSize::Pixels(n))
}
fn parse_gamescope_window_mode(value: &str) -> Result<GamescopeWindowMode, AppError> {
match value {
"windowed" => Ok(GamescopeWindowMode::Windowed),
"borderless" => Ok(GamescopeWindowMode::Borderless),
"fullscreen" => Ok(GamescopeWindowMode::Fullscreen),
_ => Err(config_error(
format!("`{value}` is not a valid window mode."),
"Use one of: windowed, borderless, fullscreen.",
)),
}
}
fn parse_sharpness(value: &str) -> Result<u8, AppError> {
let n: u8 = value.parse().map_err(|_| {
config_error(
format!("`{value}` is not a valid sharpness value. Use a number from 0 to 20."),
"0 is maximum sharpness, 20 is minimum sharpness.",
)
})?;
if n > 20 {
return Err(config_error(
format!("`{value}` is out of range. Sharpness must be 020."),
"0 is maximum sharpness, 20 is minimum sharpness.",
));
}
Ok(n)
}
fn parse_pixel_count(value: &str) -> Result<u32, AppError> {
value.parse::<u32>().map_err(|_| {
config_error(
@@ -137,11 +319,29 @@ fn parse_pixel_count(value: &str) -> Result<u32, AppError> {
})
}
fn parse_gamescope_fps(value: &str) -> Result<u32, AppError> {
fn parse_fps(value: &str, setting: &str) -> Result<u32, AppError> {
value.parse::<u32>().map_err(|_| {
config_error(
format!("`{value}` is not a valid FPS. Use a number like `60`."),
"Use `gamewrap config set gamescope-fps 60` for a 60 FPS gamescope target.",
)
if setting == "fps-cap" {
config_error(
format!("`{value}` is not a valid FPS cap. Use a number like `60` or `120`."),
"Use `gamewrap config set fps-cap 60` for a 60 FPS cap.",
)
} else {
config_error(
format!("`{value}` is not a valid FPS. Use a number like `60`."),
format!("Use `gamewrap config set {setting} 60` for a 60 FPS target."),
)
}
})
}
fn parse_hook_errors(value: &str) -> Result<crate::config::HookErrors, AppError> {
match value {
"warn" => Ok(crate::config::HookErrors::Warn),
"fail" => Ok(crate::config::HookErrors::Fail),
_ => Err(config_error(
format!("`{value}` is not a valid hook-errors value."),
"Use `warn` (log and continue) or `fail` (abort launch on hook failure).",
)),
}
}
+123
View File
@@ -0,0 +1,123 @@
use std::collections::HashSet;
/// Rename table: (old_key, new_key). Grows over time as field names change.
pub static SETTING_RENAMES: &[(&str, &str)] =
&[("overlay", "mangohud"), ("performance", "gamemode")];
#[derive(Debug, PartialEq, Eq)]
pub enum MigrateAction {
/// Old key renamed to new key, value transferred.
Renamed { from: String, to: String },
/// Old key removed because new key already had a value.
Removed { old: String, kept: String },
}
/// Apply SETTING_RENAMES to a Settings-shaped TOML table in place.
/// Returns the actions taken for retired keys.
fn migrate_settings_table(table: &mut toml::map::Map<String, toml::Value>) -> Vec<MigrateAction> {
let mut actions = Vec::new();
for (old, new) in SETTING_RENAMES {
if let Some(value) = table.remove(*old) {
if !table.contains_key(*new) {
table.insert(new.to_string(), value);
actions.push(MigrateAction::Renamed {
from: old.to_string(),
to: new.to_string(),
});
} else {
actions.push(MigrateAction::Removed {
old: old.to_string(),
kept: new.to_string(),
});
}
}
}
actions
}
/// Apply SETTING_RENAMES to a ConfigFile-shaped TOML value.
/// Migrates [defaults] and each [profiles.<name>] section.
/// Returns the actions taken for retired keys.
pub fn migrate_config(value: &mut toml::Value) -> Vec<MigrateAction> {
let mut changes = Vec::new();
if let Some(table) = value.as_table_mut() {
if let Some(toml::Value::Table(defaults)) = table.get_mut("defaults") {
changes.extend(migrate_settings_table(defaults));
}
if let Some(toml::Value::Table(profiles)) = table.get_mut("profiles") {
let names: Vec<String> = profiles.keys().cloned().collect();
for name in names {
if let Some(toml::Value::Table(profile)) = profiles.get_mut(&name) {
changes.extend(migrate_settings_table(profile));
}
}
}
}
changes
}
/// Apply SETTING_RENAMES to a SharedProfileFile-shaped TOML value.
/// Migrates the [settings] section.
/// Returns the actions taken for retired keys.
pub fn migrate_profile_export(value: &mut toml::Value) -> Vec<MigrateAction> {
if let Some(toml::Value::Table(settings)) =
value.as_table_mut().and_then(|t| t.get_mut("settings"))
{
migrate_settings_table(settings)
} else {
Vec::new()
}
}
/// Check a settings-shaped TOML table for renamed-away setting keys.
/// Returns unrecognized key names so profile imports can warn about skipped fields.
pub fn unknown_settings_in_table(table: &toml::map::Map<String, toml::Value>) -> Vec<String> {
let old_names: HashSet<&str> = SETTING_RENAMES.iter().map(|(old, _)| *old).collect();
table
.keys()
.filter(|key| old_names.contains(key.as_str()))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_migration_preserves_current_values_on_conflict() {
let mut value: toml::Value = toml::from_str(
r#"
[defaults]
overlay = false
mangohud = true
[profiles.benchmark]
performance = false
"#,
)
.expect("valid TOML");
let changes = migrate_config(&mut value);
assert_eq!(
changes,
vec![
MigrateAction::Removed {
old: "overlay".to_string(),
kept: "mangohud".to_string(),
},
MigrateAction::Renamed {
from: "performance".to_string(),
to: "gamemode".to_string(),
},
]
);
assert_eq!(value["defaults"]["mangohud"].as_bool(), Some(true));
assert!(value["defaults"].get("overlay").is_none());
assert_eq!(
value["profiles"]["benchmark"]["gamemode"].as_bool(),
Some(false)
);
}
}
+578 -164
View File
@@ -1,4 +1,5 @@
pub mod keys;
pub mod migrate;
pub mod schema;
use std::collections::BTreeMap;
@@ -11,8 +12,9 @@ use crate::detect;
use crate::error::{AppError, config_error, internal_error, io_to_internal};
pub use schema::{
Binding, ConfigFile, GameLibsMode, ObservedGame, ProfileConfig, ResolvedSettings, Settings,
StateFile,
Binding, ConfigFile, GameLibsMode, GamescopeFilter, GamescopeScaler, GamescopeSize,
GamescopeWindowMode, HookErrors, ObservedGame, ProfileConfig, ResolvedSettings, Settings,
StateFile, VkbasaltLogLevel,
};
#[derive(Debug, Clone)]
@@ -59,6 +61,7 @@ pub fn load(paths: &AppPaths) -> Result<ConfigFile, AppError> {
error,
)
})?;
let content = migrate_content_if_needed(content, &paths.config_file);
let config = toml::from_str(&content).map_err(|error| {
config_error(
@@ -70,6 +73,27 @@ pub fn load(paths: &AppPaths) -> Result<ConfigFile, AppError> {
Ok(config)
}
fn migrate_content_if_needed(content: String, path: &Path) -> String {
let mut value: toml::Value = match toml::from_str(&content) {
Ok(value) => value,
Err(_) => return content,
};
let changes = migrate::migrate_config(&mut value);
if changes.is_empty() {
return content;
}
if let Ok(migrated) = toml::to_string_pretty(&value) {
if let Err(e) = fs::write(path, &migrated) {
eprintln!(
"warning: could not update config file after migration ({}): {e}",
path.display()
);
}
return migrated;
}
content
}
pub fn save(paths: &AppPaths, config: &ConfigFile) -> Result<(), AppError> {
crate::profile::validate_config(config)?;
ensure_dir(&paths.config_dir)?;
@@ -121,31 +145,33 @@ fn ensure_dir(path: &Path) -> Result<(), AppError> {
.map_err(|error| internal_error(format!("failed creating {}: {error}", path.display())))
}
pub fn render_config(config: &ConfigFile) -> String {
pub fn render_config(config: &ConfigFile, effective: bool) -> String {
let mut output = String::new();
let mut resolved_defaults = ResolvedSettings::default();
resolved_defaults.apply(&config.defaults);
output.push_str(&format!("{}\n", crate::color::bold("[defaults]")));
output.push_str(&render_resolved_settings(&resolved_defaults));
let sparse = if effective {
None
} else {
Some(&config.defaults)
};
output.push_str(&render_resolved_settings(&resolved_defaults, sparse));
output.push_str(&format!("\n{}\n", crate::color::bold("[profiles]")));
if config.profiles.is_empty() {
output.push_str(&format!(" {}\n", crate::color::dim("(none)")));
} else {
for (name, profile) in &config.profiles {
output.push_str(&format!(
" {}{}\n",
crate::color::accent(name),
crate::color::dim(&inherits_suffix(&profile.inherits))
));
let inherited = crate::profile::resolve_named(config, name)
output.push_str(&format!(" {}\n", crate::color::accent(name)));
let resolved = crate::profile::resolve_named(config, name)
.map(|resolved| resolved.settings)
.unwrap_or_else(|_| resolved_defaults.clone());
output.push_str(&render_profile_settings_with_indent(
&profile.settings,
&inherited,
&resolved,
" ",
effective,
));
}
}
@@ -167,41 +193,41 @@ pub fn render_config(config: &ConfigFile) -> String {
output
}
pub fn render_profile(name: &str, profile: &ProfileConfig, inherited: &ResolvedSettings) -> String {
pub fn render_profile(
name: &str,
profile: &ProfileConfig,
effective_settings: &ResolvedSettings,
effective: bool,
) -> String {
let mut output = String::new();
output.push_str(&crate::color::accent(name));
output.push('\n');
if let Some(parent) = &profile.inherits {
output.push_str(&format!(
"{} {}\n",
crate::color::dim("inherits ="),
crate::color::accent(parent)
));
}
output.push_str(&render_profile_settings_with_indent(
&profile.settings,
inherited,
effective_settings,
"",
effective,
));
output
}
fn render_profile_settings_with_indent(
settings: &Settings,
inherited: &ResolvedSettings,
effective_settings: &ResolvedSettings,
indent: &str,
effective: bool,
) -> String {
let mut fields = BTreeMap::new();
fields.insert(
"overlay",
"mangohud",
settings
.overlay
.mangohud
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
"performance",
"gamemode",
settings
.performance
.gamemode
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
@@ -220,67 +246,148 @@ fn render_profile_settings_with_indent(
.verbose
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
"log-file",
settings
.log_file
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert("log-path", settings.log_path.clone());
fields.insert(
"gamescope",
settings
.gamescope
.map(|value| if value { "on" } else { "off" }.to_string()),
);
if settings.gamescope_width.is_some() || inherited.gamescope_width.is_some() {
fields.insert(
"gamescope-width",
settings.gamescope_width.map(|value| value.to_string()),
);
}
if settings.gamescope_height.is_some() || inherited.gamescope_height.is_some() {
fields.insert(
"gamescope-height",
settings.gamescope_height.map(|value| value.to_string()),
);
}
if settings.gamescope_fps.is_some() || inherited.gamescope_fps.is_some() {
fields.insert(
"gamescope-fps",
settings.gamescope_fps.map(|value| value.to_string()),
);
}
if settings.fps_cap.is_some() || inherited.fps_cap.is_some() {
fields.insert("fps-cap", settings.fps_cap.map(|value| value.to_string()));
}
fields.insert(
"gamescope-width",
settings.gamescope_width.map(|value| value.as_display_str()),
);
fields.insert(
"gamescope-height",
settings
.gamescope_height
.map(|value| value.as_display_str()),
);
fields.insert(
"gamescope-fps",
settings.gamescope_fps.map(|value| value.to_string()),
);
fields.insert(
"gamescope-nested-width",
settings
.gamescope_nested_width
.map(|value| value.to_string()),
);
fields.insert(
"gamescope-nested-height",
settings
.gamescope_nested_height
.map(|value| value.to_string()),
);
fields.insert(
"gamescope-unfocused-fps",
settings
.gamescope_unfocused_fps
.map(|value| value.to_string()),
);
fields.insert(
"gamescope-scaler",
settings
.gamescope_scaler
.map(|value| value.as_str().to_string()),
);
fields.insert(
"gamescope-filter",
settings
.gamescope_filter
.map(|value| value.as_str().to_string()),
);
fields.insert(
"gamescope-sharpness",
settings.gamescope_sharpness.map(|value| value.to_string()),
);
fields.insert(
"gamescope-mode",
settings
.gamescope_window_mode
.map(|value| value.as_str().to_string()),
);
fields.insert(
"gamescope-adaptive-sync",
settings
.gamescope_adaptive_sync
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
"gamescope-hdr",
settings
.gamescope_hdr
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
"gamescope-steam",
settings
.gamescope_steam
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
"gamescope-expose-wayland",
settings
.gamescope_expose_wayland
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
"gamescope-mangoapp",
settings
.gamescope_mangoapp
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert("fps-cap", settings.fps_cap.map(|value| value.to_string()));
fields.insert(
"mangohud-log",
settings
.mangohud_log
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert("mangohud-log-path", settings.mangohud_log_path.clone());
fields.insert(
"vkbasalt",
settings
.vkbasalt
.map(|value| if value { "on" } else { "off" }.to_string()),
);
if settings.esync.is_some() || inherited.esync.is_some() {
fields.insert(
"esync",
settings
.esync
.map(|value| if value { "on" } else { "off" }.to_string()),
);
}
if settings.fsync.is_some() || inherited.fsync.is_some() {
fields.insert(
"fsync",
settings
.fsync
.map(|value| if value { "on" } else { "off" }.to_string()),
);
}
fields.insert("vkbasalt-config", settings.vkbasalt_config.clone());
fields.insert(
"vkbasalt-log-level",
settings
.vkbasalt_log_level
.map(|value| value.as_str().to_string()),
);
fields.insert(
"esync",
settings
.esync
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
"fsync",
settings
.fsync
.map(|value| if value { "on" } else { "off" }.to_string()),
);
fields.insert(
"large-address-aware",
settings
.large_address_aware
.map(|value| if value { "on" } else { "off" }.to_string()),
);
if settings.pre_launch.is_some() || inherited.pre_launch.is_some() {
fields.insert("pre-launch", settings.pre_launch.clone());
}
if settings.post_launch.is_some() || inherited.post_launch.is_some() {
fields.insert("post-launch", settings.post_launch.clone());
}
fields.insert("pre-launch", settings.pre_launch.clone());
fields.insert("post-launch", settings.post_launch.clone());
fields.insert(
"hook-errors",
settings.hook_errors.map(|value| value.as_str().to_string()),
);
let mut output = String::new();
for (name, value) in fields {
@@ -295,15 +402,32 @@ fn render_profile_settings_with_indent(
_ => crate::color::accent(value),
}
)),
None => output.push_str(&format!(
"{}{} = {}\n",
indent,
crate::color::dim(name),
crate::color::dim(&format!("(inherits: {})", inherited_value(name, inherited)))
)),
None if effective => {
let default_value = effective_value(name, effective_settings);
let display = if default_value == "none" {
crate::color::dim("(not set)")
} else {
crate::color::dim(&format!("(default: {})", default_value))
};
output.push_str(&format!(
"{}{} = {}\n",
indent,
crate::color::dim(name),
display
));
}
None => {}
}
}
if settings.env_vars.is_some() || !inherited.env_vars.is_empty() {
let show_env = if effective {
settings.env_vars.is_some() || !effective_settings.env_vars.is_empty()
} else {
settings
.env_vars
.as_ref()
.is_some_and(|vars| !vars.is_empty())
};
if show_env {
output.push_str(&format!("{indent}{}\n", crate::color::dim("env-vars:")));
match &settings.env_vars {
Some(vars) if !vars.is_empty() => {
@@ -316,179 +440,469 @@ fn render_profile_settings_with_indent(
));
}
}
_ if inherited.env_vars.is_empty() => {
_ if effective_settings.env_vars.is_empty() => {
output.push_str(&format!("{} {}\n", indent, crate::color::dim("(none)")));
}
_ => {
for (key, value) in &inherited.env_vars {
for (key, value) in &effective_settings.env_vars {
output.push_str(&format!(
"{} {} = {} {}\n",
indent,
crate::color::accent(key),
value,
crate::color::dim("(inherited)")
crate::color::dim("(default)")
));
}
}
}
}
if !effective && output.is_empty() {
output.push_str(&format!(
"{} {}\n",
indent,
crate::color::dim("(no settings configured for this profile)")
));
}
output
}
fn render_resolved_settings(settings: &ResolvedSettings) -> String {
fn render_resolved_settings(settings: &ResolvedSettings, sparse: Option<&Settings>) -> String {
macro_rules! show_if {
($field:ident) => {
sparse.map_or(true, |s| s.$field.is_some())
};
}
let mut output = String::new();
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("overlay"),
crate::color::on_off(settings.overlay)
));
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("performance"),
crate::color::on_off(settings.performance)
));
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("steam-host-libs"),
crate::color::on_off(settings.steam_host_libs)
));
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("game-libs"),
crate::color::accent(settings.game_libs.as_str())
));
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("verbose"),
crate::color::on_off(settings.verbose)
));
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope"),
crate::color::on_off(settings.gamescope)
));
if let Some(width) = settings.gamescope_width {
if show_if!(mangohud) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("mangohud"),
crate::color::on_off(settings.mangohud)
));
}
if show_if!(gamemode) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamemode"),
crate::color::on_off(settings.gamemode)
));
}
if show_if!(steam_host_libs) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("steam-host-libs"),
crate::color::on_off(settings.steam_host_libs)
));
}
if show_if!(game_libs) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("game-libs"),
crate::color::accent(settings.game_libs.as_str())
));
}
if show_if!(verbose) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("verbose"),
crate::color::on_off(settings.verbose)
));
}
if show_if!(log_file) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("log-file"),
if settings.log_file {
crate::color::ok("on")
} else {
crate::color::dim("off (default)")
}
));
}
if show_if!(log_path) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("log-path"),
match &settings.log_path {
Some(v) => crate::color::accent(v),
None => crate::color::dim("(not set — ~/.local/state/gamewrap/gamewrap.log)"),
}
));
}
if show_if!(gamescope) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope"),
crate::color::on_off(settings.gamescope)
));
}
if show_if!(gamescope_width) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-width"),
crate::color::accent(&width.to_string())
match settings.gamescope_width {
Some(crate::config::GamescopeSize::Pixels(v)) => {
crate::color::accent(&v.to_string())
}
Some(crate::config::GamescopeSize::Native) => crate::color::accent("native"),
None => crate::color::dim("(not set — gamescope default: 1280)"),
}
));
}
if let Some(height) = settings.gamescope_height {
if show_if!(gamescope_height) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-height"),
crate::color::accent(&height.to_string())
match settings.gamescope_height {
Some(crate::config::GamescopeSize::Pixels(v)) => {
crate::color::accent(&v.to_string())
}
Some(crate::config::GamescopeSize::Native) => crate::color::accent("native"),
None => crate::color::dim("(not set — gamescope default: 720)"),
}
));
}
if let Some(fps) = settings.gamescope_fps {
if show_if!(gamescope_fps) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-fps"),
crate::color::accent(&fps.to_string())
match settings.gamescope_fps {
Some(v) => crate::color::accent(&v.to_string()),
None => crate::color::dim("(not set — unlimited)"),
}
));
}
if let Some(fps_cap) = settings.fps_cap {
if show_if!(gamescope_nested_width) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-nested-width"),
match settings.gamescope_nested_width {
Some(v) => crate::color::accent(&v.to_string()),
None => crate::color::dim("(not set — matches output width)"),
}
));
}
if show_if!(gamescope_nested_height) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-nested-height"),
match settings.gamescope_nested_height {
Some(v) => crate::color::accent(&v.to_string()),
None => crate::color::dim("(not set — matches output height)"),
}
));
}
if show_if!(gamescope_unfocused_fps) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-unfocused-fps"),
match settings.gamescope_unfocused_fps {
Some(v) => crate::color::accent(&v.to_string()),
None => crate::color::dim("(not set — no limit)"),
}
));
}
if show_if!(gamescope_scaler) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-scaler"),
match settings.gamescope_scaler {
Some(v) => crate::color::accent(v.as_str()),
None => crate::color::dim("(not set — auto)"),
}
));
}
if show_if!(gamescope_filter) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-filter"),
match settings.gamescope_filter {
Some(v) => crate::color::accent(v.as_str()),
None => crate::color::dim("(not set — linear)"),
}
));
}
if show_if!(gamescope_sharpness) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-sharpness"),
match settings.gamescope_sharpness {
Some(v) => crate::color::accent(&v.to_string()),
None => crate::color::dim("(not set — no sharpening)"),
}
));
}
if show_if!(gamescope_window_mode) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-mode"),
match settings.gamescope_window_mode {
Some(v) => crate::color::accent(v.as_str()),
None => crate::color::dim("windowed (default)"),
}
));
}
if show_if!(gamescope_adaptive_sync) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-adaptive-sync"),
crate::color::on_off(settings.gamescope_adaptive_sync)
));
}
if show_if!(gamescope_hdr) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-hdr"),
crate::color::on_off(settings.gamescope_hdr)
));
}
if show_if!(gamescope_steam) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-steam"),
crate::color::on_off(settings.gamescope_steam)
));
}
if show_if!(gamescope_expose_wayland) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-expose-wayland"),
crate::color::on_off(settings.gamescope_expose_wayland)
));
}
if show_if!(gamescope_mangoapp) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("gamescope-mangoapp"),
crate::color::on_off(settings.gamescope_mangoapp)
));
}
if show_if!(fps_cap) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("fps-cap"),
crate::color::accent(&fps_cap.to_string())
match settings.fps_cap {
Some(v) => crate::color::accent(&v.to_string()),
None => crate::color::dim("(not set — no cap)"),
}
));
}
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("vkbasalt"),
crate::color::on_off(settings.vkbasalt)
));
if let Some(esync) = settings.esync {
if show_if!(mangohud_log) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("mangohud-log"),
crate::color::on_off(settings.mangohud_log)
));
}
if show_if!(mangohud_log_path) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("mangohud-log-path"),
match &settings.mangohud_log_path {
Some(v) => crate::color::accent(v),
None => crate::color::dim("(not set — MangoHud default location)"),
}
));
}
if show_if!(vkbasalt) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("vkbasalt"),
crate::color::on_off(settings.vkbasalt)
));
}
if show_if!(vkbasalt_config) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("vkbasalt-config"),
match &settings.vkbasalt_config {
Some(v) => crate::color::accent(v),
None => crate::color::dim("(not set — ~/.config/vkBasalt/vkBasalt.conf)"),
}
));
}
if show_if!(vkbasalt_log_level) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("vkbasalt-log-level"),
match settings.vkbasalt_log_level {
Some(v) => crate::color::accent(v.as_str()),
None => crate::color::dim("(not set — vkBasalt default)"),
}
));
}
if show_if!(esync) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("esync"),
crate::color::on_off(esync)
match settings.esync {
Some(value) => crate::color::on_off(value),
None => crate::color::dim("(not set — Proton default)"),
}
));
}
if let Some(fsync) = settings.fsync {
if show_if!(fsync) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("fsync"),
crate::color::on_off(fsync)
match settings.fsync {
Some(value) => crate::color::on_off(value),
None => crate::color::dim("(not set — Proton default)"),
}
));
}
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("large-address-aware"),
crate::color::on_off(settings.large_address_aware)
));
if let Some(pre_launch) = &settings.pre_launch {
if show_if!(large_address_aware) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("large-address-aware"),
crate::color::on_off(settings.large_address_aware)
));
}
if show_if!(pre_launch) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("pre-launch"),
crate::color::accent(pre_launch)
match &settings.pre_launch {
Some(v) => crate::color::accent(v),
None => crate::color::dim("(not set — none)"),
}
));
}
if let Some(post_launch) = &settings.post_launch {
if show_if!(post_launch) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("post-launch"),
crate::color::accent(post_launch)
match &settings.post_launch {
Some(v) => crate::color::accent(v),
None => crate::color::dim("(not set — none)"),
}
));
}
if !settings.env_vars.is_empty() {
if show_if!(hook_errors) {
output.push_str(&format!(
"{} = {}\n",
crate::color::dim("hook-errors"),
match settings.hook_errors {
HookErrors::Fail => crate::color::warn("fail"),
HookErrors::Warn => crate::color::dim("warn (default)"),
}
));
}
if sparse.is_none_or(|s| s.env_vars.is_some()) && !settings.env_vars.is_empty() {
output.push_str(&format!("{}\n", crate::color::dim("env-vars:")));
for (key, value) in &settings.env_vars {
output.push_str(&format!(" {} = {}\n", crate::color::accent(key), value));
}
}
if sparse.is_some() && output.is_empty() {
output.push_str(&format!(
" {}\n",
crate::color::dim(
"(no global settings configured — run 'gamewrap config set' to customize)"
)
));
}
output
}
fn inherited_value(name: &str, inherited: &ResolvedSettings) -> String {
fn effective_value(name: &str, settings: &ResolvedSettings) -> String {
match name {
"overlay" => crate::color::on_off(inherited.overlay),
"performance" => crate::color::on_off(inherited.performance),
"steam-host-libs" => crate::color::on_off(inherited.steam_host_libs),
"game-libs" => inherited.game_libs.as_str().to_string(),
"verbose" => crate::color::on_off(inherited.verbose),
"gamescope" => crate::color::on_off(inherited.gamescope),
"gamescope-width" => inherited
"mangohud" => crate::color::on_off(settings.mangohud),
"gamemode" => crate::color::on_off(settings.gamemode),
"steam-host-libs" => crate::color::on_off(settings.steam_host_libs),
"game-libs" => settings.game_libs.as_str().to_string(),
"verbose" => crate::color::on_off(settings.verbose),
"log-file" => crate::color::on_off(settings.log_file),
"log-path" => settings.log_path.as_deref().unwrap_or("none").to_string(),
"gamescope" => crate::color::on_off(settings.gamescope),
"gamescope-width" => settings
.gamescope_width
.map(|value| value.to_string())
.map(|value| value.as_display_str())
.unwrap_or_else(|| "none".to_string()),
"gamescope-height" => inherited
"gamescope-height" => settings
.gamescope_height
.map(|value| value.to_string())
.map(|value| value.as_display_str())
.unwrap_or_else(|| "none".to_string()),
"gamescope-fps" => inherited
"gamescope-fps" => settings
.gamescope_fps
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
"fps-cap" => inherited
"gamescope-nested-width" => settings
.gamescope_nested_width
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
"gamescope-nested-height" => settings
.gamescope_nested_height
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
"gamescope-unfocused-fps" => settings
.gamescope_unfocused_fps
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
"gamescope-scaler" => settings
.gamescope_scaler
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| "none".to_string()),
"gamescope-filter" => settings
.gamescope_filter
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| "none".to_string()),
"gamescope-sharpness" => settings
.gamescope_sharpness
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
"gamescope-mode" => settings
.gamescope_window_mode
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| "none".to_string()),
"gamescope-adaptive-sync" => crate::color::on_off(settings.gamescope_adaptive_sync),
"gamescope-hdr" => crate::color::on_off(settings.gamescope_hdr),
"gamescope-steam" => crate::color::on_off(settings.gamescope_steam),
"gamescope-expose-wayland" => crate::color::on_off(settings.gamescope_expose_wayland),
"gamescope-mangoapp" => crate::color::on_off(settings.gamescope_mangoapp),
"fps-cap" => settings
.fps_cap
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
"vkbasalt" => crate::color::on_off(inherited.vkbasalt),
"esync" => crate::color::option_on_off(inherited.esync),
"fsync" => crate::color::option_on_off(inherited.fsync),
"large-address-aware" => crate::color::on_off(inherited.large_address_aware),
"pre-launch" => inherited
"mangohud-log" => crate::color::on_off(settings.mangohud_log),
"mangohud-log-path" => settings
.mangohud_log_path
.as_deref()
.unwrap_or("none")
.to_string(),
"vkbasalt" => crate::color::on_off(settings.vkbasalt),
"vkbasalt-config" => settings
.vkbasalt_config
.as_deref()
.unwrap_or("none")
.to_string(),
"vkbasalt-log-level" => settings
.vkbasalt_log_level
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| "none".to_string()),
"esync" => settings
.esync
.map(crate::color::on_off)
.unwrap_or_else(|| "none".to_string()),
"fsync" => settings
.fsync
.map(crate::color::on_off)
.unwrap_or_else(|| "none".to_string()),
"large-address-aware" => crate::color::on_off(settings.large_address_aware),
"pre-launch" => settings
.pre_launch
.clone()
.unwrap_or_else(|| "none".to_string()),
"post-launch" => inherited
"post-launch" => settings
.post_launch
.clone()
.unwrap_or_else(|| "none".to_string()),
"hook-errors" => settings.hook_errors.as_str().to_string(),
_ => "unknown".to_string(),
}
}
fn inherits_suffix(parent: &Option<String>) -> String {
parent
.as_ref()
.map(|parent| format!(" (inherits: {parent})"))
.unwrap_or_default()
}
#[cfg(test)]
pub fn test_paths(root: &Path) -> AppPaths {
AppPaths {
@@ -510,7 +924,7 @@ mod tests {
let temp = tempdir().expect("temp dir");
let paths = test_paths(temp.path());
let mut config = ConfigFile::default();
config.defaults.overlay = Some(true);
config.defaults.mangohud = Some(true);
config
.profiles
.insert("benchmark".to_string(), ProfileConfig::default());
@@ -518,7 +932,7 @@ mod tests {
save(&paths, &config).expect("save");
let loaded = load(&paths).expect("load");
assert_eq!(loaded.defaults.overlay, Some(true));
assert_eq!(loaded.defaults.mangohud, Some(true));
assert!(loaded.profiles.contains_key("benchmark"));
}
}
+354 -65
View File
@@ -2,6 +2,45 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
macro_rules! impl_as_str {
($type:ty { $($variant:ident => $s:literal),+ $(,)? }) => {
impl $type {
pub fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $s),+
}
}
}
};
}
// src-field is Option<T (Copy)>, dst-field is T
macro_rules! apply_scalar {
($dst:expr, $src:expr, $field:ident) => {
if let Some(v) = $src.$field {
$dst.$field = v;
}
};
}
// src-field is Option<T (Copy)>, dst-field is Option<T>
macro_rules! apply_opt {
($dst:expr, $src:expr, $field:ident) => {
if let Some(v) = $src.$field {
$dst.$field = Some(v);
}
};
}
// src-field is Option<String>, dst-field is Option<String>
macro_rules! apply_clone {
($dst:expr, $src:expr, $field:ident) => {
if let Some(ref v) = $src.$field {
$dst.$field = Some(v.clone());
}
};
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum GameLibsMode {
@@ -11,22 +50,163 @@ pub enum GameLibsMode {
Gamemode,
}
impl GameLibsMode {
pub fn as_str(self) -> &'static str {
impl_as_str!(GameLibsMode {
Auto => "auto",
Keep => "keep",
Gamemode => "gamemode",
});
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum GamescopeScaler {
Auto,
Integer,
Fit,
Fill,
Stretch,
}
impl_as_str!(GamescopeScaler {
Auto => "auto",
Integer => "integer",
Fit => "fit",
Fill => "fill",
Stretch => "stretch",
});
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum GamescopeFilter {
Linear,
Nearest,
Fsr,
Nis,
Pixel,
}
impl_as_str!(GamescopeFilter {
Linear => "linear",
Nearest => "nearest",
Fsr => "fsr",
Nis => "nis",
Pixel => "pixel",
});
/// Output resolution value for gamescope -W/-H.
/// Native = detect display resolution at launch; Pixels = explicit pixel count.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GamescopeSize {
Native,
Pixels(u32),
}
impl GamescopeSize {
pub fn as_display_str(self) -> String {
match self {
Self::Auto => "auto",
Self::Keep => "keep",
Self::Gamemode => "gamemode",
Self::Native => "native".to_string(),
Self::Pixels(n) => n.to_string(),
}
}
}
impl serde::Serialize for GamescopeSize {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Self::Native => s.serialize_str("native"),
Self::Pixels(n) => s.serialize_u32(*n),
}
}
}
impl<'de> serde::Deserialize<'de> for GamescopeSize {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = GamescopeSize;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "a pixel count integer or the string \"native\"")
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(GamescopeSize::Pixels(v as u32))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
if v < 0 {
Err(E::custom("pixel count cannot be negative"))
} else {
Ok(GamescopeSize::Pixels(v as u32))
}
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
if v == "native" {
Ok(GamescopeSize::Native)
} else {
v.parse::<u32>().map(GamescopeSize::Pixels).map_err(|_| {
E::custom(format!("expected a pixel count or \"native\", got {v:?}"))
})
}
}
}
d.deserialize_any(Visitor)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum GamescopeWindowMode {
Windowed,
Borderless,
Fullscreen,
}
impl_as_str!(GamescopeWindowMode {
Windowed => "windowed",
Borderless => "borderless",
Fullscreen => "fullscreen",
});
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum VkbasaltLogLevel {
Debug,
Info,
Warning,
Error,
None,
}
impl_as_str!(VkbasaltLogLevel {
Debug => "debug",
Info => "info",
Warning => "warning",
Error => "error",
None => "none",
});
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum HookErrors {
#[default]
Warn,
Fail,
}
impl_as_str!(HookErrors {
Warn => "warn",
Fail => "fail",
});
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overlay: Option<bool>,
pub mangohud: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub performance: Option<bool>,
pub gamemode: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steam_host_libs: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -34,18 +214,54 @@ pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbose: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_file: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_width: Option<u32>,
pub gamescope_width: Option<GamescopeSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_height: Option<u32>,
pub gamescope_height: Option<GamescopeSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_fps: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_nested_width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_nested_height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_unfocused_fps: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_scaler: Option<GamescopeScaler>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_filter: Option<GamescopeFilter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_sharpness: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_window_mode: Option<GamescopeWindowMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_adaptive_sync: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_hdr: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_steam: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_expose_wayland: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gamescope_mangoapp: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps_cap: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mangohud_log: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mangohud_log_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vkbasalt: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vkbasalt_config: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vkbasalt_log_level: Option<VkbasaltLogLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub esync: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fsync: Option<bool>,
@@ -55,50 +271,138 @@ pub struct Settings {
pub pre_launch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_launch: Option<String>,
#[serde(
default,
rename = "hook-errors",
skip_serializing_if = "Option::is_none"
)]
pub hook_errors: Option<HookErrors>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_vars: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ResolvedSettings {
pub overlay: bool,
pub performance: bool,
pub mangohud: bool,
pub gamemode: bool,
pub steam_host_libs: bool,
pub game_libs: GameLibsMode,
pub verbose: bool,
pub log_file: bool,
pub log_path: Option<String>,
pub gamescope: bool,
pub gamescope_width: Option<u32>,
pub gamescope_height: Option<u32>,
pub gamescope_width: Option<GamescopeSize>,
pub gamescope_height: Option<GamescopeSize>,
pub gamescope_fps: Option<u32>,
pub gamescope_nested_width: Option<u32>,
pub gamescope_nested_height: Option<u32>,
pub gamescope_unfocused_fps: Option<u32>,
pub gamescope_scaler: Option<GamescopeScaler>,
pub gamescope_filter: Option<GamescopeFilter>,
pub gamescope_sharpness: Option<u8>,
pub gamescope_window_mode: Option<GamescopeWindowMode>,
pub gamescope_adaptive_sync: bool,
pub gamescope_hdr: bool,
pub gamescope_steam: bool,
pub gamescope_expose_wayland: bool,
pub gamescope_mangoapp: bool,
pub fps_cap: Option<u32>,
pub mangohud_log: bool,
pub mangohud_log_path: Option<String>,
pub vkbasalt: bool,
pub vkbasalt_config: Option<String>,
pub vkbasalt_log_level: Option<VkbasaltLogLevel>,
pub esync: Option<bool>,
pub fsync: Option<bool>,
pub large_address_aware: bool,
pub pre_launch: Option<String>,
pub post_launch: Option<String>,
pub hook_errors: HookErrors,
pub env_vars: BTreeMap<String, String>,
}
impl From<ResolvedSettings> for Settings {
fn from(r: ResolvedSettings) -> Self {
Settings {
mangohud: Some(r.mangohud),
gamemode: Some(r.gamemode),
steam_host_libs: Some(r.steam_host_libs),
game_libs: Some(r.game_libs),
verbose: Some(r.verbose),
log_file: Some(r.log_file),
log_path: r.log_path,
gamescope: Some(r.gamescope),
gamescope_width: r.gamescope_width,
gamescope_height: r.gamescope_height,
gamescope_fps: r.gamescope_fps,
gamescope_nested_width: r.gamescope_nested_width,
gamescope_nested_height: r.gamescope_nested_height,
gamescope_unfocused_fps: r.gamescope_unfocused_fps,
gamescope_scaler: r.gamescope_scaler,
gamescope_filter: r.gamescope_filter,
gamescope_sharpness: r.gamescope_sharpness,
gamescope_window_mode: r.gamescope_window_mode,
gamescope_adaptive_sync: Some(r.gamescope_adaptive_sync),
gamescope_hdr: Some(r.gamescope_hdr),
gamescope_steam: Some(r.gamescope_steam),
gamescope_expose_wayland: Some(r.gamescope_expose_wayland),
gamescope_mangoapp: Some(r.gamescope_mangoapp),
fps_cap: r.fps_cap,
mangohud_log: Some(r.mangohud_log),
mangohud_log_path: r.mangohud_log_path,
vkbasalt: Some(r.vkbasalt),
vkbasalt_config: r.vkbasalt_config,
vkbasalt_log_level: r.vkbasalt_log_level,
esync: r.esync,
fsync: r.fsync,
large_address_aware: Some(r.large_address_aware),
pre_launch: r.pre_launch,
post_launch: r.post_launch,
hook_errors: Some(r.hook_errors),
env_vars: (!r.env_vars.is_empty()).then_some(r.env_vars),
}
}
}
impl Default for ResolvedSettings {
fn default() -> Self {
Self {
overlay: true,
performance: true,
mangohud: true,
gamemode: true,
steam_host_libs: true,
game_libs: GameLibsMode::Auto,
verbose: false,
log_file: false,
log_path: None,
gamescope: false,
gamescope_width: None,
gamescope_height: None,
gamescope_fps: None,
gamescope_nested_width: None,
gamescope_nested_height: None,
gamescope_unfocused_fps: None,
gamescope_scaler: None,
gamescope_filter: None,
gamescope_sharpness: None,
gamescope_window_mode: None,
gamescope_adaptive_sync: false,
gamescope_hdr: false,
gamescope_steam: false,
gamescope_expose_wayland: false,
gamescope_mangoapp: false,
fps_cap: None,
mangohud_log: false,
mangohud_log_path: None,
vkbasalt: false,
vkbasalt_config: None,
vkbasalt_log_level: None,
esync: None,
fsync: None,
large_address_aware: false,
pre_launch: None,
post_launch: None,
hook_errors: HookErrors::Warn,
env_vars: BTreeMap::new(),
}
}
@@ -106,54 +410,41 @@ impl Default for ResolvedSettings {
impl ResolvedSettings {
pub fn apply(&mut self, settings: &Settings) {
if let Some(value) = settings.overlay {
self.overlay = value;
}
if let Some(value) = settings.performance {
self.performance = value;
}
if let Some(value) = settings.steam_host_libs {
self.steam_host_libs = value;
}
if let Some(value) = settings.game_libs {
self.game_libs = value;
}
if let Some(value) = settings.verbose {
self.verbose = value;
}
if let Some(value) = settings.gamescope {
self.gamescope = value;
}
if let Some(value) = settings.gamescope_width {
self.gamescope_width = Some(value);
}
if let Some(value) = settings.gamescope_height {
self.gamescope_height = Some(value);
}
if let Some(value) = settings.gamescope_fps {
self.gamescope_fps = Some(value);
}
if let Some(value) = settings.fps_cap {
self.fps_cap = Some(value);
}
if let Some(value) = settings.vkbasalt {
self.vkbasalt = value;
}
if let Some(value) = settings.esync {
self.esync = Some(value);
}
if let Some(value) = settings.fsync {
self.fsync = Some(value);
}
if let Some(value) = settings.large_address_aware {
self.large_address_aware = value;
}
if let Some(ref value) = settings.pre_launch {
self.pre_launch = Some(value.clone());
}
if let Some(ref value) = settings.post_launch {
self.post_launch = Some(value.clone());
}
apply_scalar!(self, settings, mangohud);
apply_scalar!(self, settings, gamemode);
apply_scalar!(self, settings, steam_host_libs);
apply_scalar!(self, settings, game_libs);
apply_scalar!(self, settings, verbose);
apply_scalar!(self, settings, log_file);
apply_clone!(self, settings, log_path);
apply_scalar!(self, settings, gamescope);
apply_opt!(self, settings, gamescope_width);
apply_opt!(self, settings, gamescope_height);
apply_opt!(self, settings, gamescope_fps);
apply_opt!(self, settings, gamescope_nested_width);
apply_opt!(self, settings, gamescope_nested_height);
apply_opt!(self, settings, gamescope_unfocused_fps);
apply_opt!(self, settings, gamescope_scaler);
apply_opt!(self, settings, gamescope_filter);
apply_opt!(self, settings, gamescope_sharpness);
apply_opt!(self, settings, gamescope_window_mode);
apply_scalar!(self, settings, gamescope_adaptive_sync);
apply_scalar!(self, settings, gamescope_hdr);
apply_scalar!(self, settings, gamescope_steam);
apply_scalar!(self, settings, gamescope_expose_wayland);
apply_scalar!(self, settings, gamescope_mangoapp);
apply_opt!(self, settings, fps_cap);
apply_scalar!(self, settings, mangohud_log);
apply_clone!(self, settings, mangohud_log_path);
apply_scalar!(self, settings, vkbasalt);
apply_clone!(self, settings, vkbasalt_config);
apply_opt!(self, settings, vkbasalt_log_level);
apply_opt!(self, settings, esync);
apply_opt!(self, settings, fsync);
apply_scalar!(self, settings, large_address_aware);
apply_clone!(self, settings, pre_launch);
apply_clone!(self, settings, post_launch);
apply_scalar!(self, settings, hook_errors);
if let Some(ref vars) = settings.env_vars {
for (key, value) in vars {
self.env_vars.insert(key.clone(), value.clone());
@@ -170,8 +461,6 @@ pub struct Binding {
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ProfileConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inherits: Option<String>,
#[serde(flatten)]
pub settings: Settings,
}
+26
View File
@@ -0,0 +1,26 @@
/// Convert days since Unix epoch (1970-01-01) to (year, month, day).
pub fn epoch_days_to_ymd(days: i64) -> (i32, u32, u32) {
let days = days + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
(year as i32, month as u32, day as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_epoch_days_to_gregorian_dates() {
assert_eq!(epoch_days_to_ymd(0), (1970, 1, 1));
assert_eq!(epoch_days_to_ymd(19_782), (2024, 2, 29));
}
}
+1 -17
View File
@@ -81,27 +81,11 @@ fn now_rfc3339() -> String {
let hour = seconds_in_day / 3_600;
let min = (seconds_in_day % 3_600) / 60;
let sec = seconds_in_day % 60;
let (year, month, day) = civil_from_days(days);
let (year, month, day) = crate::date::epoch_days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
}
fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
let days = days_since_epoch + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
(year, month as u32, day as u32)
}
pub fn sanitize_state(state: &mut StateFile) {
state.games.retain(|game| {
let executable = ExecutableInfo {
+82 -26
View File
@@ -11,60 +11,110 @@ pub fn render(
command: Option<&[OsString]>,
) -> String {
let mut output = String::new();
output.push_str("gamewrap doctor\n");
output.push_str("Assumed launch context: Steam\n");
output.push_str(&format!("{}\n", crate::color::bold("gamewrap doctor")));
output.push_str(&format!(
"Failure notifier: {}\n",
"{} Steam\n",
crate::color::dim("Assumed launch context:")
));
output.push_str(&format!(
"{} {}\n",
crate::color::dim("Failure notifier:"),
notify::available_notifier_name()
));
output.push_str("\nResolved settings:\n");
output.push_str(&format!(" overlay: {}\n", color::on_off(settings.overlay)));
output.push_str(&format!("\n{}\n", crate::color::bold("Resolved settings:")));
output.push_str(&format!(
" performance: {}\n",
color::on_off(settings.performance)
" {} {}\n",
crate::color::dim("mangohud:"),
color::on_off(settings.mangohud)
));
output.push_str(&format!(
" steam-host-libs: {}\n",
" {} {}\n",
crate::color::dim("gamemode:"),
color::on_off(settings.gamemode)
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("steam-host-libs:"),
color::on_off(settings.steam_host_libs)
));
output.push_str(&format!(" game-libs: {}\n", settings.game_libs.as_str()));
output.push_str(&format!(" verbose: {}\n", color::on_off(settings.verbose)));
output.push_str(&format!(
" gamescope: {}\n",
" {} {}\n",
crate::color::dim("game-libs:"),
crate::color::accent(settings.game_libs.as_str())
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("verbose:"),
color::on_off(settings.verbose)
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope:"),
color::on_off(settings.gamescope)
));
if settings.gamescope {
if let Some(width) = settings.gamescope_width {
output.push_str(&format!(" gamescope-width: {width}\n"));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope-width:"),
crate::color::accent(&width.as_display_str())
));
}
if let Some(height) = settings.gamescope_height {
output.push_str(&format!(" gamescope-height: {height}\n"));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope-height:"),
crate::color::accent(&height.as_display_str())
));
}
if let Some(fps) = settings.gamescope_fps {
output.push_str(&format!(" gamescope-fps: {fps}\n"));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope-fps:"),
crate::color::accent(&fps.to_string())
));
}
}
output.push_str(&format!(
" vkbasalt: {}\n",
" {} {}\n",
crate::color::dim("vkbasalt:"),
color::on_off(settings.vkbasalt)
));
output.push_str(&format!(
" esync: {}\n",
" {} {}\n",
crate::color::dim("esync:"),
color::option_on_off(settings.esync)
));
output.push_str(&format!(
" fsync: {}\n",
" {} {}\n",
crate::color::dim("fsync:"),
color::option_on_off(settings.fsync)
));
output.push_str(&format!(
" large-address-aware: {}\n",
" {} {}\n",
crate::color::dim("large-address-aware:"),
color::on_off(settings.large_address_aware)
));
if let Some(pre_cmd) = &settings.pre_launch {
output.push_str(&format!(" pre-launch: {pre_cmd}\n"));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("pre-launch:"),
crate::color::accent(pre_cmd)
));
}
if let Some(post_cmd) = &settings.post_launch {
output.push_str(&format!(" post-launch: {post_cmd}\n"));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("post-launch:"),
crate::color::accent(post_cmd)
));
}
if settings.pre_launch.is_some() || settings.post_launch.is_some() {
output.push_str(&format!(
" {} {}\n",
crate::color::dim("hook-errors:"),
crate::color::accent(settings.hook_errors.as_str())
));
}
if let Some(command) = command {
@@ -73,22 +123,27 @@ pub fn render(
.map(|part| part.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ");
output.push_str(&format!("\nTarget command:\n {rendered}\n"));
output.push_str(&format!(
"\n{}\n {}\n",
crate::color::bold("Target command:"),
crate::color::accent(&rendered)
));
}
output.push_str("\nChecks:\n");
output.push_str(&format!("\n{}\n", crate::color::bold("Checks:")));
for check in &report.checks {
output.push_str(&format!(
" [{}] {}: {}\n",
status_label(check.status),
check.name,
crate::color::dim(&check.name),
check.detail
));
}
output.push_str("\nSummary:\n");
output.push_str(&format!("\n{}\n", crate::color::bold("Summary:")));
output.push_str(&format!(
" overall: {}\n",
" {} {}\n",
crate::color::dim("overall:"),
if report.has_failures() {
color::fail("fail")
} else if report.has_warnings() {
@@ -98,7 +153,8 @@ pub fn render(
}
));
output.push_str(&format!(
" launchable: {}\n",
" {} {}\n",
crate::color::dim("launchable:"),
if report.launchable() {
color::ok("yes")
} else {
+111 -27
View File
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::Path;
use crate::config::{GameLibsMode, ResolvedSettings};
use crate::config::ResolvedSettings;
pub fn is_steam_context() -> bool {
std::env::var_os("SteamAppId").is_some()
@@ -11,6 +11,15 @@ pub fn is_steam_context() -> bool {
|| std::env::var_os("SteamGameId").is_some()
}
fn set_proton_no_sync(map: &mut BTreeMap<OsString, OsString>, key: &str, enabled: Option<bool>) {
if let Some(v) = enabled {
map.insert(
OsString::from(key),
OsString::from(if v { "0" } else { "1" }),
);
}
}
pub fn build_env(settings: ResolvedSettings) -> BTreeMap<OsString, OsString> {
let mut env = BTreeMap::new();
@@ -36,27 +45,40 @@ pub fn build_env(settings: ResolvedSettings) -> BTreeMap<OsString, OsString> {
if settings.vkbasalt {
env.insert(OsString::from("ENABLE_VKBASALT"), OsString::from("1"));
if let Some(ref cfg) = settings.vkbasalt_config {
env.insert(
OsString::from("VKBASALT_CONFIG_FILE"),
OsString::from(cfg.as_str()),
);
}
if let Some(level) = settings.vkbasalt_log_level {
env.insert(
OsString::from("VKBASALT_LOG_LEVEL"),
OsString::from(level.as_str()),
);
}
}
if let Some(fps_cap) = settings.fps_cap.filter(|_| settings.overlay) {
if settings.mangohud && settings.mangohud_log {
env.insert(OsString::from("MANGOHUD_LOG"), OsString::from("1"));
env.insert(OsString::from("MANGOHUD_LOG_DURATION"), OsString::from("0"));
if let Some(ref path) = settings.mangohud_log_path {
env.insert(
OsString::from("MANGOHUD_OUTPUT"),
OsString::from(path.as_str()),
);
}
}
if let Some(fps_cap) = settings.fps_cap.filter(|_| settings.mangohud) {
env.insert(
OsString::from("MANGOHUD_PARAMS"),
OsString::from(format!("fps_limit={fps_cap}")),
);
}
if let Some(esync) = settings.esync {
env.insert(
OsString::from("PROTON_NO_ESYNC"),
OsString::from(if esync { "0" } else { "1" }),
);
}
if let Some(fsync) = settings.fsync {
env.insert(
OsString::from("PROTON_NO_FSYNC"),
OsString::from(if fsync { "0" } else { "1" }),
);
}
set_proton_no_sync(&mut env, "PROTON_NO_ESYNC", settings.esync);
set_proton_no_sync(&mut env, "PROTON_NO_FSYNC", settings.fsync);
if settings.large_address_aware {
env.insert(
OsString::from("PROTON_LARGE_ADDRESS_AWARE"),
@@ -72,15 +94,7 @@ pub fn build_env(settings: ResolvedSettings) -> BTreeMap<OsString, OsString> {
}
pub fn needs_host_library_injection(settings: &ResolvedSettings) -> bool {
if !settings.performance {
return false;
}
match settings.game_libs {
GameLibsMode::Keep => false,
GameLibsMode::Gamemode => true,
GameLibsMode::Auto => is_steam_context(),
}
crate::launch::needs_host_libs_for_context(settings, is_steam_context())
}
pub fn detected_host_library_dirs() -> Vec<String> {
@@ -116,7 +130,7 @@ mod tests {
#[test]
fn gamemode_mode_always_sets_library_path() {
let env = build_env(ResolvedSettings {
performance: true,
gamemode: true,
game_libs: GameLibsMode::Gamemode,
..ResolvedSettings::default()
});
@@ -131,7 +145,7 @@ mod tests {
#[test]
fn keep_mode_preserves_library_path() {
let env = build_env(ResolvedSettings {
performance: true,
gamemode: true,
game_libs: GameLibsMode::Keep,
..ResolvedSettings::default()
});
@@ -142,7 +156,7 @@ mod tests {
#[test]
fn fps_cap_uses_mangohud_params_when_overlay_is_enabled() {
let env = build_env(ResolvedSettings {
overlay: true,
mangohud: true,
fps_cap: Some(60),
..ResolvedSettings::default()
});
@@ -156,7 +170,7 @@ mod tests {
#[test]
fn fps_cap_is_ignored_when_overlay_is_disabled() {
let env = build_env(ResolvedSettings {
overlay: false,
mangohud: false,
fps_cap: Some(60),
..ResolvedSettings::default()
});
@@ -164,10 +178,43 @@ mod tests {
assert!(!env.contains_key(&OsString::from("MANGOHUD_PARAMS")));
}
#[test]
fn mangohud_logging_requires_overlay_and_sets_optional_output() {
let env = build_env(ResolvedSettings {
mangohud: true,
mangohud_log: true,
mangohud_log_path: Some("/tmp/mangohud.csv".to_string()),
..ResolvedSettings::default()
});
assert_eq!(
env.get(&OsString::from("MANGOHUD_LOG")),
Some(&OsString::from("1"))
);
assert_eq!(
env.get(&OsString::from("MANGOHUD_LOG_DURATION")),
Some(&OsString::from("0"))
);
assert_eq!(
env.get(&OsString::from("MANGOHUD_OUTPUT")),
Some(&OsString::from("/tmp/mangohud.csv"))
);
let disabled = build_env(ResolvedSettings {
mangohud: false,
mangohud_log: true,
mangohud_log_path: Some("/tmp/mangohud.csv".to_string()),
..ResolvedSettings::default()
});
assert!(!disabled.contains_key(&OsString::from("MANGOHUD_LOG")));
assert!(!disabled.contains_key(&OsString::from("MANGOHUD_OUTPUT")));
}
#[test]
fn proton_and_vkbasalt_settings_set_expected_env_vars() {
let env = build_env(ResolvedSettings {
vkbasalt: true,
vkbasalt_config: Some("/tmp/vkbasalt.conf".to_string()),
esync: Some(true),
fsync: Some(false),
..ResolvedSettings::default()
@@ -177,6 +224,10 @@ mod tests {
env.get(&OsString::from("ENABLE_VKBASALT")),
Some(&OsString::from("1"))
);
assert_eq!(
env.get(&OsString::from("VKBASALT_CONFIG_FILE")),
Some(&OsString::from("/tmp/vkbasalt.conf"))
);
assert_eq!(
env.get(&OsString::from("PROTON_NO_ESYNC")),
Some(&OsString::from("0"))
@@ -187,6 +238,39 @@ mod tests {
);
}
#[test]
fn vkbasalt_config_is_ignored_when_vkbasalt_is_disabled() {
let env = build_env(ResolvedSettings {
vkbasalt: false,
vkbasalt_config: Some("/tmp/vkbasalt.conf".to_string()),
..ResolvedSettings::default()
});
assert!(!env.contains_key(&OsString::from("VKBASALT_CONFIG_FILE")));
}
#[test]
fn vkbasalt_log_level_requires_vkbasalt() {
use crate::config::VkbasaltLogLevel;
let env = build_env(ResolvedSettings {
vkbasalt: true,
vkbasalt_log_level: Some(VkbasaltLogLevel::Warning),
..ResolvedSettings::default()
});
assert_eq!(
env.get(&OsString::from("VKBASALT_LOG_LEVEL")),
Some(&OsString::from("warning"))
);
let disabled = build_env(ResolvedSettings {
vkbasalt: false,
vkbasalt_log_level: Some(VkbasaltLogLevel::Debug),
..ResolvedSettings::default()
});
assert!(!disabled.contains_key(&OsString::from("VKBASALT_LOG_LEVEL")));
}
#[test]
fn large_address_aware_sets_env_var() {
let env = build_env(ResolvedSettings {
+7 -3
View File
@@ -26,12 +26,16 @@ impl AppError {
}
}
fn format_with_hint(message: impl Into<String>, hint: impl Into<String>) -> String {
format!("Error: {}\nHint: {}", message.into(), hint.into())
}
pub fn usage_error(message: impl Into<String>, hint: impl Into<String>) -> AppError {
AppError::Usage(format!("Error: {}\nHint: {}", message.into(), hint.into()))
AppError::Usage(format_with_hint(message, hint))
}
pub fn config_error(message: impl Into<String>, hint: impl Into<String>) -> AppError {
AppError::Config(format!("Error: {}\nHint: {}", message.into(), hint.into()))
AppError::Config(format_with_hint(message, hint))
}
pub fn profile_not_found_error(name: &str) -> AppError {
@@ -52,7 +56,7 @@ pub fn game_not_found_error(matcher: &str) -> AppError {
}
pub fn dependency_error(message: impl Into<String>, hint: impl Into<String>) -> AppError {
AppError::Dependency(format!("Error: {}\nHint: {}", message.into(), hint.into()))
AppError::Dependency(format_with_hint(message, hint))
}
pub fn internal_error(message: impl Into<String>) -> AppError {
+271 -44
View File
@@ -18,21 +18,27 @@ pub const TOP_LEVEL_AFTER_HELP: &str = r#"Common tasks:
gamewrap game rename "eldenring.exe" "Elden Ring"
Give a game a readable display name.
gamewrap config set overlay on
gamewrap config settings
List all available settings with one-line descriptions.
gamewrap config set mangohud on
Enable MangoHud overlay by default.
gamewrap config set fps-cap 60
Cap frame rate through MangoHud (requires overlay to be on).
Cap frame rate through MangoHud (requires mangohud to be on).
gamewrap config set gamescope on
Wrap all launches in the gamescope Wayland compositor.
gamewrap config set gamescope-width 1920
Set the gamescope target resolution width.
gamewrap config set gamescope-width native
Match the gamescope output width to the connected display.
gamewrap config set pre-launch "notify-send Starting"
Run a shell hook before launching the game.
gamewrap config reset --all
Reset every global setting to its built-in default.
gamewrap profile create benchmark
Create a named profile with its own settings.
@@ -42,9 +48,6 @@ pub const TOP_LEVEL_AFTER_HELP: &str = r#"Common tasks:
gamewrap profile set benchmark fps-cap 120
Override a setting for one specific profile.
gamewrap profile inherit benchmark base
Make a profile build on top of another profile.
gamewrap doctor
Check that your default setup is ready for a Steam launch.
@@ -56,15 +59,25 @@ pub const TOP_LEVEL_AFTER_HELP: &str = r#"Common tasks:
Help topics (run for detailed docs):
gamewrap help settings all settings and their effects
gamewrap help profiles profiles, inheritance, and env overrides
gamewrap help profiles profiles, defaults, and env overrides
gamewrap help bindings binding games to profiles
gamewrap help doctor understanding preflight checks
gamewrap help completion shell completion setup
gamewrap help troubleshooting"#;
pub fn topic_text(topic: &str) -> Option<&'static str> {
pub fn topic_text(topic: &str, filter: Option<&str>) -> Option<&'static str> {
match topic {
"settings" => Some(SETTINGS_HELP),
"settings" => match filter {
Some("core") => Some(SECTION_CORE),
Some("mangohud") => Some(SECTION_MANGOHUD),
Some("gamemode") => Some(SECTION_GAMEMODE),
Some("gamescope") => Some(SECTION_GAMESCOPE),
Some("vkbasalt") => Some(SECTION_VKBASALT),
Some("proton" | "wine" | "proton-wine") => Some(SECTION_PROTON),
Some("logging" | "hooks") => Some(SECTION_LOGGING),
Some(_) => None,
None => Some(SETTINGS_HELP),
},
"doctor" => Some(DOCTOR_HELP),
"profiles" => Some(PROFILES_HELP),
"bindings" => Some(BINDINGS_HELP),
@@ -74,17 +87,9 @@ pub fn topic_text(topic: &str) -> Option<&'static str> {
}
}
const SETTINGS_HELP: &str = r#"Settings
overlay
What it does: turn MangoHud on or off.
Technical effect: prefixes the launch command with mangohud.
Values: on, off
performance
What it does: turn GameMode on or off.
Technical effect: prefixes the launch command with gamemoderun.
Values: on, off
macro_rules! section_core {
() => {
r#"── Core ────────────────────────────────────────────────────────────
steam-host-libs
What it does: prefer host libraries when Steam is using its runtime.
@@ -95,7 +100,7 @@ game-libs
What it does: control how game-related library paths are handled.
Technical effect: controls whether auto-detected host library directories are appended to LD_LIBRARY_PATH.
Values:
auto append auto-detected host library paths only in Steam-like contexts when performance is on
auto append auto-detected host library paths only in Steam-like contexts when gamemode is on
keep leave LD_LIBRARY_PATH alone
gamemode always append auto-detected host library paths
@@ -103,33 +108,180 @@ verbose
What it does: show more detail in diagnostic commands.
Technical effect: enables extra explanatory output from gamewrap itself.
Values: on, off
"#
};
}
macro_rules! section_mangohud {
() => {
r#"── MangoHud ─────────────────────────────────────────────────────────
mangohud (was: overlay)
What it does: turn MangoHud on or off.
Technical effect: prefixes the launch command with mangohud.
Values: on, off
Default: on
fps-cap
What it does: cap frame rate.
Technical effect: sets MangoHud fps_limit through MANGOHUD_PARAMS when mangohud is on.
Values: number, or reset to use the default/clear
Example: gamewrap config set fps-cap 60
mangohud-log
What it does: log full-session FPS and performance data to a file.
Technical effect: sets MANGOHUD_LOG=1 and MANGOHUD_LOG_DURATION=0.
Values: on, off
Default: off
Requires: mangohud = on
mangohud-log-path
What it does: override where MangoHud writes the performance log.
Technical effect: sets MANGOHUD_OUTPUT when mangohud-log and mangohud are on.
Values: file path
Default: MangoHud's default location.
"#
};
}
macro_rules! section_gamemode {
() => {
r#"── GameMode ─────────────────────────────────────────────────────────
gamemode (was: performance)
What it does: turn GameMode on or off.
Technical effect: prefixes the launch command with gamemoderun.
Values: on, off
Default: on
"#
};
}
macro_rules! section_gamescope {
() => {
r#"── Gamescope ────────────────────────────────────────────────────────
Gamescope is a Wayland micro-compositor that provides resolution scaling,
refresh rate control, and HDR support. Enable it with 'gamescope = on',
then set width/height/fps to your preferences. gamescope-nested-* controls
the inner game resolution when scaling is active.
Recommended setup:
gamewrap config set gamescope on
gamewrap config set gamescope-mode fullscreen
gamewrap config set gamescope-width native
gamewrap config set gamescope-height native
Output vs. render resolution:
gamescope-width / gamescope-height = what the monitor displays (-W / -H flags)
gamescope-nested-width / gamescope-nested-height = what the game renders (-w / -h flags)
For native-res gaming, set gamescope-width/height to native and leave nested unset.
For upscaling: set nested to a lower render resolution and width/height to your monitor res.
Example: render at 1080p, output at 4K:
gamewrap config set gamescope-nested-width 1920
gamewrap config set gamescope-nested-height 1080
gamewrap config set gamescope-width native
gamewrap config set gamescope-height native
gamescope
What it does: wrap the game in the gamescope Wayland compositor.
Technical effect: prefixes the launch command with gamescope [-- args] --.
Technical effect: prefixes the launch command with gamescope [args] --.
Values: on, off
Requires: gamescope installed (https://github.com/ValveSoftware/gamescope)
gamescope-width
What it does: set the target game resolution width.
What it does: set the gamescope output width (what the monitor displays).
Technical effect: passes -W <value> to gamescope.
Values: number (e.g. 1920). Only effective when gamescope is on.
Values: pixel count (e.g. 1920) or native (auto-detects connected display)
Default: not set (gamescope uses 1280)
Example: gamewrap config set gamescope-width native
gamescope-height
What it does: set the target game resolution height.
What it does: set the gamescope output height (what the monitor displays).
Technical effect: passes -H <value> to gamescope.
Values: number (e.g. 1080). Only effective when gamescope is on.
Values: pixel count (e.g. 1080) or native (auto-detects connected display)
Default: not set (gamescope uses 720)
Example: gamewrap config set gamescope-height native
gamescope-fps
What it does: set the target FPS for the gamescope compositor.
Technical effect: passes -r <value> to gamescope.
Values: number (e.g. 60). Only effective when gamescope is on.
fps-cap
What it does: cap frame rate.
Technical effect: sets MangoHud fps_limit through MANGOHUD_PARAMS when overlay is on.
Values: number, or reset to inherit/clear
Example: gamewrap config set fps-cap 60
gamescope-nested-width
What it does: set the game render width (inner resolution).
Technical effect: passes -w <value> to gamescope.
Values: number (e.g. 1280). Only effective when gamescope is on.
Note: combine with gamescope-width and gamescope-filter fsr/nis to upscale from a lower res.
gamescope-nested-height
What it does: set the game render height (inner resolution).
Technical effect: passes -h <value> to gamescope.
Values: number (e.g. 720). Only effective when gamescope is on.
gamescope-unfocused-fps
What it does: throttle FPS when the gamescope window is unfocused.
Technical effect: passes -o <value> to gamescope.
Values: number (e.g. 30). Only effective when gamescope is on.
gamescope-scaler
What it does: set how content is scaled to the output resolution.
Technical effect: passes -S <value> to gamescope.
Values: auto, integer, fit, fill, stretch. Only effective when gamescope is on.
gamescope-filter
What it does: set the upscaling/filtering algorithm.
Technical effect: passes -F <value> to gamescope.
Values: linear, nearest, fsr, nis, pixel. Only effective when gamescope is on.
Note: fsr (AMD FidelityFX SR) and nis (NVIDIA Image Scaling) are the quality upscalers.
gamescope-sharpness
What it does: set upscaler sharpness for fsr or nis filter.
Technical effect: passes --sharpness <value> to gamescope.
Values: 0 (maximum sharpness) to 20 (minimum sharpness). Only meaningful with fsr or nis filter.
gamescope-mode
What it does: set the gamescope window mode.
Technical effect: fullscreen passes -f; borderless passes -b; windowed passes neither.
Values: windowed, borderless, fullscreen
Default: windowed (no flag passed)
Example: gamewrap config set gamescope-mode fullscreen
gamescope-adaptive-sync
What it does: enable adaptive sync / variable refresh rate (VRR / FreeSync / G-Sync).
Technical effect: passes --adaptive-sync to gamescope.
Values: on, off. Requires a VRR-capable display. Only effective when gamescope is on.
gamescope-hdr
What it does: enable HDR output.
Technical effect: passes --hdr-enabled to gamescope.
Values: on, off. Requires gamescope WSI layer enabled and a compatible HDR display.
Only effective when gamescope is on.
gamescope-steam
What it does: enable Steam integration so the Steam overlay works inside gamescope.
Technical effect: passes -e to gamescope.
Values: on, off. Only effective when gamescope is on.
gamescope-expose-wayland
What it does: expose a Wayland socket for native Wayland game clients.
Technical effect: passes --expose-wayland to gamescope.
Values: on, off. Only effective when gamescope is on.
gamescope-mangoapp
What it does: use gamescope's native mangohud overlay (mangoapp) instead of running mangohud as a prefix.
Technical effect: passes --mangoapp to gamescope; skips the mangohud prefix in the command chain.
Values: on, off. Default: off.
Requires: mangoapp binary (included with the mangohud package on most distros).
Note: when both mangohud and gamescope are on, enabling this gives better reliability — the HUD
renders through gamescope's own compositor layer rather than via LD_PRELOAD injection.
"#
};
}
macro_rules! section_vkbasalt {
() => {
r#"── vkBasalt ─────────────────────────────────────────────────────────
vkbasalt
What it does: enable vkBasalt post-processing.
@@ -138,6 +290,25 @@ vkbasalt
Default: off
Requires: vkBasalt installed.
vkbasalt-config
What it does: point vkBasalt at a specific config file.
Technical effect: sets VKBASALT_CONFIG_FILE to the given path. Only active when vkbasalt is on.
Values: file path
Example: gamewrap profile set screenshot vkbasalt-config ~/.config/vkBasalt/hq.conf
vkbasalt-log-level
What it does: set vkBasalt log verbosity.
Technical effect: sets VKBASALT_LOG_LEVEL when vkbasalt is on.
Values: debug, info, warning, error, none
Requires: vkbasalt = on
"#
};
}
macro_rules! section_proton {
() => {
r#"── Proton / Wine ────────────────────────────────────────────────────
esync
What it does: force Proton esync on or off.
Technical effect: sets PROTON_NO_ESYNC=0 when on, or PROTON_NO_ESYNC=1 when off.
@@ -156,6 +327,13 @@ large-address-aware / laa
Values: on, off
Default: off
Context: only active in Proton context.
"#
};
}
macro_rules! section_logging {
() => {
r#"── Hooks & Logging ──────────────────────────────────────────────────
pre-launch
What it does: run a shell command before launching the game.
@@ -166,40 +344,85 @@ pre-launch
post-launch
What it does: run a shell command after the game exits.
Technical effect: when set, gamewrap spawns the launch plan, waits for it to exit, then runs `sh -c <command>`.
The post-hook is attempted even if the game fails to spawn, as long as the pre-launch hook succeeded.
Values: shell command string
Example: gamewrap profile set benchmark post-launch 'notify-send "Game exited"'
hook-errors
What it does: control what happens when a pre-launch hook fails.
Technical effect: in fail mode, gamewrap aborts before launching when the pre-launch hook
exits nonzero or cannot be executed. In warn mode (the default), the failure is logged to
stderr and the launch continues.
Values: warn (default), fail
Note: the post-launch hook always runs after the pre-launch hook succeeds, even if the game
fails to start.
log-file
What it does: enable or disable gamewrap's own log file.
Technical effect: records what gamewrap decided at launch time — resolved profile, final command, env changes, and hook results.
Values: on, off
Default: off
log-path
What it does: override the default gamewrap log file location.
Values: file path
Default: ~/.local/state/gamewrap/gamewrap.log
env-vars
What it does: apply arbitrary environment variable overrides at launch.
Technical effect: inserts KEY=value pairs after gamewrap's standard environment variables, so they can override gamewrap defaults.
Values: set through `gamewrap profile env set <profile> <KEY> <VALUE>`, not `gamewrap config set`.
Default: none
"#;
"#
};
}
const SECTION_CORE: &str = section_core!();
const SECTION_MANGOHUD: &str = section_mangohud!();
const SECTION_GAMEMODE: &str = section_gamemode!();
const SECTION_GAMESCOPE: &str = section_gamescope!();
const SECTION_VKBASALT: &str = section_vkbasalt!();
const SECTION_PROTON: &str = section_proton!();
const SECTION_LOGGING: &str = section_logging!();
const SETTINGS_HELP: &str = concat!(
section_core!(),
"\n",
section_mangohud!(),
"\n",
section_gamemode!(),
"\n",
section_gamescope!(),
"\n",
section_vkbasalt!(),
"\n",
section_proton!(),
"\n",
section_logging!(),
);
const PROFILES_HELP: &str = r#"Profiles
Profiles are reusable setting bundles.
The default profile is built from your global config.
Named profiles override only the settings you change.
Profiles can also inherit from another named profile.
Examples:
gamewrap profile list
gamewrap profile tree
gamewrap profile create benchmark
gamewrap profile duplicate benchmark benchmark-copy
gamewrap profile inherit benchmark base
gamewrap profile clear-inherit benchmark
gamewrap profile export benchmark benchmark
gamewrap profile import benchmark
gamewrap profile set benchmark overlay on
gamewrap profile reset benchmark overlay
gamewrap profile migrate old-benchmark
gamewrap profile set benchmark mangohud on
gamewrap profile reset benchmark mangohud
gamewrap profile env set benchmark DXVK_ASYNC 1
gamewrap profile env list benchmark
gamewrap profile env unset benchmark DXVK_ASYNC
gamewrap profile env clear benchmark
gamewrap profile set benchmark performance on
gamewrap profile set benchmark gamemode on
gamewrap profile show benchmark
gamewrap profile show benchmark --effective
"#;
const DOCTOR_HELP: &str = r#"Doctor
@@ -215,12 +438,13 @@ Examples:
Check a specific game executable as if Steam were launching it.
What it checks:
- MangoHud availability when overlay is on
- MangoHud availability when mangohud is on (or mangoapp when gamescope-mangoapp is on)
- gamescope availability when gamescope is on
- GameMode availability when performance is on
- GameMode availability when gamemode is on
- vkBasalt layer availability when vkbasalt is on
- auto-detected host library path injection for Steam/Proton-style launches
- whether a provided target command looks runnable
- warns when MangoHud is used as a prefix inside gamescope (suggest gamescope-mangoapp instead)
What the summary means:
overall: ok
@@ -257,14 +481,17 @@ Matching is case-insensitive and checks the executable basename first.
const TROUBLESHOOTING_HELP: &str = r#"Troubleshooting
Missing mangohud
Install MangoHud or turn overlay off.
Install MangoHud or turn mangohud off.
Missing gamemoderun
Install GameMode or turn performance off.
Install GameMode or turn gamemode off.
Missing gamescope
Install gamescope or turn gamescope off.
Missing mangoapp
Install mangohud (which includes mangoapp) or turn gamescope-mangoapp off.
Steam runtime library issues
Try:
gamewrap config set steam-host-libs on
+210 -66
View File
@@ -52,33 +52,150 @@ impl PreflightReport {
}
}
/// Detect the primary display's native resolution.
/// Tries xrandr first, falls back to /sys/class/drm.
/// Returns None if detection fails; caller should warn and skip the flag.
fn detect_display_resolution() -> Option<(u32, u32)> {
if let Ok(output) = std::process::Command::new("xrandr")
.arg("--current")
.output()
&& output.status.success()
{
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains('*') {
let trimmed = line.trim();
if let Some(res_part) = trimmed.split_whitespace().next()
&& let Some((w_str, h_str)) = res_part.split_once('x')
&& let (Ok(w), Ok(h)) = (w_str.parse::<u32>(), h_str.parse::<u32>())
{
return Some((w, h));
}
}
}
}
if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect();
paths.sort();
for path in paths {
let modes_path = path.join("modes");
if let Ok(content) = std::fs::read_to_string(&modes_path)
&& let Some(first_line) = content.lines().next()
&& let Some((w_str, h_str)) = first_line.trim().split_once('x')
&& let (Ok(w), Ok(h)) = (w_str.parse::<u32>(), h_str.parse::<u32>())
{
return Some((w, h));
}
}
}
None
}
pub fn build_plan(target: &[OsString], settings: ResolvedSettings) -> Result<LaunchPlan, AppError> {
validate_launch(target, &settings, env::is_steam_context())?;
let mut command = Vec::with_capacity(target.len() + 10);
let use_mangoapp = settings.gamescope && settings.gamescope_mangoapp;
if settings.gamescope {
command.push(OsString::from("gamescope"));
if let Some(width) = settings.gamescope_width {
command.push(OsString::from("-W"));
match settings.gamescope_width {
Some(crate::config::GamescopeSize::Pixels(width)) => {
command.push(OsString::from("-W"));
command.push(OsString::from(width.to_string()));
}
Some(crate::config::GamescopeSize::Native) => {
if let Some((width, _)) = detect_display_resolution() {
command.push(OsString::from("-W"));
command.push(OsString::from(width.to_string()));
} else {
eprintln!(
"gamewrap: could not detect native display width; gamescope will use its default (1280)"
);
}
}
None => {}
}
match settings.gamescope_height {
Some(crate::config::GamescopeSize::Pixels(height)) => {
command.push(OsString::from("-H"));
command.push(OsString::from(height.to_string()));
}
Some(crate::config::GamescopeSize::Native) => {
if let Some((_, height)) = detect_display_resolution() {
command.push(OsString::from("-H"));
command.push(OsString::from(height.to_string()));
} else {
eprintln!(
"gamewrap: could not detect native display height; gamescope will use its default (720)"
);
}
}
None => {}
}
if let Some(width) = settings.gamescope_nested_width {
command.push(OsString::from("-w"));
command.push(OsString::from(width.to_string()));
}
if let Some(height) = settings.gamescope_height {
command.push(OsString::from("-H"));
if let Some(height) = settings.gamescope_nested_height {
command.push(OsString::from("-h"));
command.push(OsString::from(height.to_string()));
}
if let Some(fps) = settings.gamescope_fps {
command.push(OsString::from("-r"));
command.push(OsString::from(fps.to_string()));
}
if let Some(fps) = settings.gamescope_unfocused_fps {
command.push(OsString::from("-o"));
command.push(OsString::from(fps.to_string()));
}
if let Some(scaler) = settings.gamescope_scaler {
command.push(OsString::from("-S"));
command.push(OsString::from(scaler.as_str()));
}
if let Some(filter) = settings.gamescope_filter {
command.push(OsString::from("-F"));
command.push(OsString::from(filter.as_str()));
}
if let Some(sharpness) = settings.gamescope_sharpness {
command.push(OsString::from("--sharpness"));
command.push(OsString::from(sharpness.to_string()));
}
match settings.gamescope_window_mode {
Some(crate::config::GamescopeWindowMode::Fullscreen) => {
command.push(OsString::from("-f"));
}
Some(crate::config::GamescopeWindowMode::Borderless) => {
command.push(OsString::from("-b"));
}
Some(crate::config::GamescopeWindowMode::Windowed) | None => {}
}
if settings.gamescope_adaptive_sync {
command.push(OsString::from("--adaptive-sync"));
}
if settings.gamescope_hdr {
command.push(OsString::from("--hdr-enabled"));
}
if settings.gamescope_steam {
command.push(OsString::from("-e"));
}
if settings.gamescope_expose_wayland {
command.push(OsString::from("--expose-wayland"));
}
if use_mangoapp {
command.push(OsString::from("--mangoapp"));
}
command.push(OsString::from("--"));
}
if settings.overlay {
if settings.mangohud && !use_mangoapp {
command.push(OsString::from("mangohud"));
}
if settings.performance {
if settings.gamemode {
command.push(OsString::from("gamemoderun"));
}
@@ -95,13 +212,31 @@ pub fn preflight(
) -> PreflightReport {
let mut checks = Vec::new();
if settings.overlay {
checks.push(check_dependency("overlay wrapper", "mangohud"));
let use_mangoapp = settings.gamescope && settings.gamescope_mangoapp;
if use_mangoapp {
checks.push(Check {
name: "mangohud wrapper".to_string(),
status: CheckStatus::Ok,
detail:
"gamescope-mangoapp is on — gamescope provides the MangoHud overlay via mangoapp."
.to_string(),
});
checks.push(check_dependency("gamescope mangoapp", "mangoapp"));
} else if settings.mangohud {
checks.push(check_dependency("mangohud wrapper", "mangohud"));
if settings.gamescope {
checks.push(Check {
name: "gamescope overlay".to_string(),
status: CheckStatus::Warn,
detail: "MangoHud is running as a prefix inside gamescope. For better reliability, enable `gamescope-mangoapp` to use gamescope's native overlay instead.".to_string(),
});
}
} else {
checks.push(Check {
name: "overlay wrapper".to_string(),
name: "mangohud wrapper".to_string(),
status: CheckStatus::Ok,
detail: "overlay is off, so MangoHud is not required.".to_string(),
detail: "mangohud is off, so MangoHud is not required.".to_string(),
});
}
@@ -115,13 +250,13 @@ pub fn preflight(
});
}
if settings.performance {
checks.push(check_dependency("performance wrapper", "gamemoderun"));
if settings.gamemode {
checks.push(check_dependency("gamemode wrapper", "gamemoderun"));
} else {
checks.push(Check {
name: "performance wrapper".to_string(),
name: "gamemode wrapper".to_string(),
status: CheckStatus::Ok,
detail: "performance is off, so GameMode is not required.".to_string(),
detail: "gamemode is off, so GameMode is not required.".to_string(),
});
}
@@ -165,80 +300,74 @@ pub fn preflight(
PreflightReport { checks }
}
pub fn execute(plan: LaunchPlan) -> Result<(), AppError> {
let executable = plan
.command
fn build_std_command(plan: LaunchPlan) -> Result<Command, AppError> {
let LaunchPlan { command, env } = plan;
let executable = command
.first()
.ok_or_else(|| internal_error("Launch plan did not include a command."))?;
let mut command = Command::new(executable);
if plan.command.len() > 1 {
command.args(&plan.command[1..]);
let mut cmd = Command::new(executable);
if command.len() > 1 {
cmd.args(&command[1..]);
}
for (key, value) in plan.env {
command.env(key, value);
for (key, value) in env {
cmd.env(key, value);
}
Ok(cmd)
}
pub fn execute(plan: LaunchPlan) -> Result<(), AppError> {
let mut command = build_std_command(plan)?;
let error = command.exec();
Err(internal_error(format!(
"Failed to exec launch command: {error}"
)))
}
pub fn execute_wait(plan: LaunchPlan) -> Result<std::time::Duration, AppError> {
let executable = plan
.command
.first()
.ok_or_else(|| internal_error("Launch plan did not include a command."))?;
let mut command = Command::new(executable);
if plan.command.len() > 1 {
command.args(&plan.command[1..]);
}
for (key, value) in plan.env {
command.env(key, value);
}
pub fn execute_wait(
plan: LaunchPlan,
) -> Result<(std::process::ExitStatus, std::time::Duration), AppError> {
let mut command = build_std_command(plan)?;
let start = std::time::Instant::now();
let mut child = command
.spawn()
.map_err(|error| internal_error(format!("Failed to spawn launch command: {error}")))?;
child
let status = child
.wait()
.map_err(|error| internal_error(format!("Failed to wait for game process: {error}")))?;
Ok(start.elapsed())
Ok((status, start.elapsed()))
}
pub fn render_plan(plan: &LaunchPlan, profile: &str, verbose: bool) -> String {
let mut output = String::new();
output.push_str(&format!("Resolved profile: {profile}\n"));
output.push_str(&format!(
"{} {}\n",
crate::color::dim("Resolved profile:"),
crate::color::accent(profile)
));
if verbose {
output.push_str("Environment changes:\n");
output.push_str(&format!("{}\n", crate::color::bold("Environment changes:")));
if plan.env.is_empty() {
output.push_str(" (none)\n");
output.push_str(&format!(" {}\n", crate::color::dim("(none)")));
} else {
for (key, value) in &plan.env {
output.push_str(&format!(
" {}={}\n",
key.to_string_lossy(),
value.to_string_lossy()
crate::color::accent(&key.to_string_lossy()),
crate::color::dim(&value.to_string_lossy())
));
}
}
}
output.push_str("Final command:\n ");
output.push_str(
&plan
.command
.iter()
.map(|part| shell_escape(part))
.collect::<Vec<_>>()
.join(" "),
);
output.push_str(&format!("{}\n ", crate::color::bold("Final command:")));
let cmd_str = plan
.command
.iter()
.map(shell_escape)
.collect::<Vec<_>>()
.join(" ");
output.push_str(&crate::color::accent(&cmd_str));
output
}
@@ -303,12 +432,23 @@ fn validate_launch(
.ok_or_else(|| internal_error("Missing target command during launch planning."))?;
ensure_target_command(target_program)?;
if settings.overlay {
ensure_dependency("mangohud", "overlay")?;
let use_mangoapp = settings.gamescope && settings.gamescope_mangoapp;
if use_mangoapp {
which("mangoapp").map(|_| ()).map_err(|_| {
dependency_error(
"`mangoapp` is required because `gamescope-mangoapp` is enabled.",
"Install `mangohud` (which includes `mangoapp`), or turn `gamescope-mangoapp` off with `gamewrap config set gamescope-mangoapp off`.",
)
})?;
}
if settings.performance {
ensure_dependency("gamemoderun", "performance")?;
if settings.mangohud && !use_mangoapp {
ensure_dependency("mangohud", "mangohud")?;
}
if settings.gamemode {
ensure_dependency("gamemoderun", "gamemode")?;
}
if settings.gamescope {
@@ -337,8 +477,11 @@ fn ensure_library_paths_for_context(
Ok(())
}
fn needs_host_libs_for_context(settings: &ResolvedSettings, steam_context: bool) -> bool {
if !settings.performance {
pub(crate) fn needs_host_libs_for_context(
settings: &ResolvedSettings,
steam_context: bool,
) -> bool {
if !settings.gamemode {
return false;
}
@@ -451,7 +594,8 @@ mod tests {
};
let rendered = render_plan(&plan, "default", false);
assert!(rendered.contains("Resolved profile: default"));
assert!(rendered.contains("Resolved profile:"));
assert!(rendered.contains("default"));
assert!(rendered.contains("mangohud game.exe"));
}
@@ -460,8 +604,8 @@ mod tests {
let error = build_plan(
&[],
ResolvedSettings {
overlay: false,
performance: false,
mangohud: false,
gamemode: false,
steam_host_libs: false,
game_libs: GameLibsMode::Keep,
verbose: false,
@@ -483,8 +627,8 @@ mod tests {
let error = build_plan(
&[OsString::from("--help")],
ResolvedSettings {
overlay: false,
performance: false,
mangohud: false,
gamemode: false,
steam_host_libs: false,
game_libs: GameLibsMode::Keep,
verbose: false,
+2
View File
@@ -3,12 +3,14 @@ mod cli;
mod color;
mod completion;
mod config;
mod date;
mod detect;
mod doctor;
mod env;
mod error;
mod help;
mod launch;
mod log;
mod notify;
mod profile;
mod share;
+54
View File
@@ -0,0 +1,54 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub fn default_log_path(state_dir: &Path) -> PathBuf {
state_dir.join("gamewrap.log")
}
/// Append timestamped lines to the log file. Logging must never crash gamewrap.
pub fn append(path: &Path, lines: &[String]) {
let _ = try_append(path, lines);
}
fn try_append(path: &Path, lines: &[String]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let now = iso_timestamp();
for line in lines {
writeln!(file, "[{now}] {line}")?;
}
Ok(())
}
fn iso_timestamp() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let s = secs % 60;
let m = (secs / 60) % 60;
let h = (secs / 3600) % 24;
let days = secs / 86400;
let (y, mo, d) = crate::date::epoch_days_to_ymd(days as i64);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}:{s:02} UTC")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appends_timestamped_lines() {
let temp = tempfile::tempdir().expect("temp dir");
let path = temp.path().join("nested/gamewrap.log");
append(&path, &["--- launch ---".to_string()]);
let content = fs::read_to_string(path).expect("log contents");
assert!(content.contains(" UTC] --- launch ---"));
}
}
+2 -207
View File
@@ -1,7 +1,5 @@
use std::collections::BTreeSet;
use crate::bindings;
use crate::config::{ConfigFile, ProfileConfig, ResolvedSettings};
use crate::config::{ConfigFile, ResolvedSettings};
use crate::detect::ExecutableInfo;
use crate::error::{AppError, config_error};
@@ -46,7 +44,7 @@ pub fn resolve_named(config: &ConfigFile, profile_name: &str) -> Result<Resolved
let mut settings = ResolvedSettings::default();
settings.apply(&config.defaults);
apply_profile_chain(config, profile, &mut settings)?;
settings.apply(&profile.settings);
Ok(ResolvedProfile {
profile_name: profile_name.to_string(),
@@ -64,208 +62,5 @@ pub fn validate_config(config: &ConfigFile) -> Result<(), AppError> {
}
}
let mut visiting = BTreeSet::new();
let mut visited = BTreeSet::new();
for name in config.profiles.keys() {
validate_profile_chain(config, name, &mut visiting, &mut visited)?;
}
Ok(())
}
fn apply_profile_chain(
config: &ConfigFile,
profile: &ProfileConfig,
settings: &mut ResolvedSettings,
) -> Result<(), AppError> {
if let Some(parent) = &profile.inherits {
let parent_profile = config.profiles.get(parent).ok_or_else(|| {
config_error(
format!("Profile inherits from missing profile `{parent}`."),
"Fix the parent profile name or clear the inheritance chain.",
)
})?;
apply_profile_chain(config, parent_profile, settings)?;
}
settings.apply(&profile.settings);
Ok(())
}
fn validate_profile_chain(
config: &ConfigFile,
name: &str,
visiting: &mut BTreeSet<String>,
visited: &mut BTreeSet<String>,
) -> Result<(), AppError> {
if visited.contains(name) {
return Ok(());
}
if !visiting.insert(name.to_string()) {
return Err(config_error(
format!("Profile inheritance cycle detected at `{name}`."),
"Break the cycle with `gamewrap profile clear-inherit <name>` or choose a different parent profile.",
));
}
if let Some(parent) = config
.profiles
.get(name)
.ok_or_else(|| {
config_error(
format!("Profile `{name}` does not exist."),
"Run `gamewrap profile list` to see the available profiles.",
)
})?
.inherits
.as_ref()
{
if parent == name {
return Err(config_error(
format!("Profile `{name}` cannot inherit from itself."),
"Choose another parent profile or clear the inheritance chain.",
));
}
if !config.profiles.contains_key(parent) {
return Err(config_error(
format!("Profile `{name}` inherits from missing profile `{parent}`."),
"Create the parent profile first or clear the inheritance chain.",
));
}
validate_profile_chain(config, parent, visiting, visited)?;
}
visiting.remove(name);
visited.insert(name.to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::config::{ConfigFile, GameLibsMode, Settings};
#[test]
fn resolve_named_applies_inheritance_chain() {
let config = ConfigFile {
defaults: Settings {
overlay: Some(false),
performance: Some(false),
steam_host_libs: None,
game_libs: None,
verbose: None,
gamescope: None,
gamescope_width: None,
gamescope_height: None,
gamescope_fps: None,
fps_cap: None,
env_vars: Some(BTreeMap::from([(
"GW_DEFAULT".to_string(),
"default".to_string(),
)])),
..Settings::default()
},
profiles: BTreeMap::from([
(
"base".to_string(),
ProfileConfig {
inherits: None,
settings: Settings {
overlay: Some(true),
performance: Some(true),
steam_host_libs: None,
game_libs: Some(GameLibsMode::Keep),
verbose: None,
gamescope: None,
gamescope_width: None,
gamescope_height: None,
gamescope_fps: None,
fps_cap: None,
env_vars: Some(BTreeMap::from([
("GW_BASE".to_string(), "base".to_string()),
("GW_OVERRIDE".to_string(), "base".to_string()),
])),
..Settings::default()
},
},
),
(
"benchmark".to_string(),
ProfileConfig {
inherits: Some("base".to_string()),
settings: Settings {
overlay: None,
performance: None,
steam_host_libs: Some(false),
game_libs: None,
verbose: Some(true),
gamescope: None,
gamescope_width: None,
gamescope_height: None,
gamescope_fps: None,
fps_cap: Some(60),
env_vars: Some(BTreeMap::from([(
"GW_OVERRIDE".to_string(),
"benchmark".to_string(),
)])),
..Settings::default()
},
},
),
]),
bindings: Vec::new(),
};
let resolved = resolve_named(&config, "benchmark").expect("resolve benchmark");
assert_eq!(resolved.profile_name, "benchmark");
assert!(resolved.settings.overlay);
assert!(resolved.settings.performance);
assert!(!resolved.settings.steam_host_libs);
assert_eq!(resolved.settings.game_libs, GameLibsMode::Keep);
assert!(resolved.settings.verbose);
assert_eq!(resolved.settings.fps_cap, Some(60));
assert_eq!(
resolved.settings.env_vars.get("GW_DEFAULT"),
Some(&"default".to_string())
);
assert_eq!(
resolved.settings.env_vars.get("GW_BASE"),
Some(&"base".to_string())
);
assert_eq!(
resolved.settings.env_vars.get("GW_OVERRIDE"),
Some(&"benchmark".to_string())
);
}
#[test]
fn validate_config_rejects_inheritance_cycles() {
let config = ConfigFile {
defaults: Settings::default(),
profiles: BTreeMap::from([
(
"alpha".to_string(),
ProfileConfig {
inherits: Some("beta".to_string()),
settings: Settings::default(),
},
),
(
"beta".to_string(),
ProfileConfig {
inherits: Some("alpha".to_string()),
settings: Settings::default(),
},
),
]),
bindings: Vec::new(),
};
let error = validate_config(&config).expect_err("expected cycle validation to fail");
assert!(error.to_string().contains("inheritance cycle"));
}
}
+70 -95
View File
@@ -2,9 +2,9 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::config::{Binding, ConfigFile, ProfileConfig, ResolvedSettings, Settings};
use crate::config::migrate;
use crate::config::{Binding, ConfigFile, ProfileConfig, Settings};
use crate::error::{AppError, config_error};
use crate::profile;
const CONFIG_KIND: &str = "gamewrap-config";
const PROFILE_KIND: &str = "gamewrap-profile";
@@ -16,9 +16,9 @@ pub const PROFILE_EXPORT_SUFFIX: &str = ".gamewrap-profile.toml";
pub struct SharedConfigFile {
pub kind: String,
pub version: u32,
pub defaults: ResolvedSettings,
pub defaults: Settings,
#[serde(default)]
pub profiles: BTreeMap<String, ResolvedSettings>,
pub profiles: BTreeMap<String, Settings>,
#[serde(default)]
pub bindings: Vec<Binding>,
}
@@ -28,22 +28,19 @@ pub struct SharedProfileFile {
pub kind: String,
pub version: u32,
pub name: String,
pub settings: ResolvedSettings,
pub settings: Settings,
}
pub fn export_config(config: &ConfigFile) -> Result<SharedConfigFile, AppError> {
let defaults = resolved_defaults(&config.defaults);
let mut profiles = BTreeMap::new();
for name in config.profiles.keys() {
let resolved = profile::resolve_named(config, name)?;
profiles.insert(name.clone(), resolved.settings);
}
Ok(SharedConfigFile {
kind: CONFIG_KIND.to_string(),
version: FORMAT_VERSION,
defaults,
profiles,
defaults: config.defaults.clone(),
profiles: config
.profiles
.iter()
.map(|(name, profile)| (name.clone(), profile.settings.clone()))
.collect(),
bindings: config.bindings.clone(),
})
}
@@ -53,31 +50,28 @@ pub fn import_config(shared: SharedConfigFile) -> Result<ConfigFile, AppError> {
validate_version(shared.version, "config")?;
Ok(ConfigFile {
defaults: explicit_settings(shared.defaults),
defaults: shared.defaults,
profiles: shared
.profiles
.into_iter()
.map(|(name, settings)| {
(
name,
ProfileConfig {
inherits: None,
settings: explicit_settings(settings),
},
)
})
.map(|(name, settings)| (name, ProfileConfig { settings }))
.collect(),
bindings: shared.bindings,
})
}
pub fn export_profile(config: &ConfigFile, name: &str) -> Result<SharedProfileFile, AppError> {
let resolved = profile::resolve_named(config, name)?;
let profile = config.profiles.get(name).ok_or_else(|| {
config_error(
format!("Profile `{name}` does not exist."),
"Run `gamewrap profile list` to see available profiles.",
)
})?;
Ok(SharedProfileFile {
kind: PROFILE_KIND.to_string(),
version: FORMAT_VERSION,
name: name.to_string(),
settings: resolved.settings,
settings: profile.settings.clone(),
})
}
@@ -88,13 +82,48 @@ pub fn import_profile(shared: SharedProfileFile) -> Result<(String, ProfileConfi
Ok((
shared.name,
ProfileConfig {
inherits: None,
settings: explicit_settings(shared.settings),
settings: shared.settings,
},
))
}
/// Parse a profile export TOML string, warn about renamed setting keys,
/// then return the parsed SharedProfileFile.
pub fn parse_imported_profile(content: &str) -> Result<SharedProfileFile, AppError> {
if let Ok(raw) = toml::from_str::<toml::Value>(content)
&& let Some(toml::Value::Table(settings)) = raw.get("settings")
{
let unknown = migrate::unknown_settings_in_table(settings);
if !unknown.is_empty() {
let keys = unknown.join(", ");
eprintln!("warning: This profile uses renamed settings that were skipped: {keys}.");
eprintln!(
" Run `gamewrap profile migrate <file>` to update the file before importing."
);
}
}
toml::from_str::<SharedProfileFile>(content).map_err(|error| {
config_error(
format!("Profile export file is invalid: {error}"),
"Use a `.gamewrap-profile.toml` export from `gamewrap profile export`.",
)
})
}
pub fn parse_imported_config(content: &str) -> Result<ConfigFile, AppError> {
// Warn about any renamed settings in the defaults section before parsing.
if let Ok(raw) = toml::from_str::<toml::Value>(content)
&& let Some(toml::Value::Table(defaults)) = raw.get("defaults")
{
let unknown = migrate::unknown_settings_in_table(defaults);
if !unknown.is_empty() {
let keys = unknown.join(", ");
eprintln!("warning: This config uses renamed settings that were skipped: {keys}.");
eprintln!(
" Run `gamewrap config migrate <file>` to update the file before importing."
);
}
}
if let Ok(shared) = toml::from_str::<SharedConfigFile>(content) {
return import_config(shared);
}
@@ -130,7 +159,7 @@ fn validate_kind(kind: &str, expected: &str, label: &str) -> Result<(), AppError
} else {
Err(config_error(
format!("This file is not a {label} export."),
&format!("Expected kind `{expected}`, but found `{kind}`."),
format!("Expected kind `{expected}`, but found `{kind}`."),
))
}
}
@@ -141,92 +170,38 @@ fn validate_version(version: u32, label: &str) -> Result<(), AppError> {
} else {
Err(config_error(
format!("This {label} export uses unsupported version `{version}`."),
&format!("Expected version `{FORMAT_VERSION}`."),
format!("Expected version `{FORMAT_VERSION}`."),
))
}
}
fn resolved_defaults(settings: &Settings) -> ResolvedSettings {
let mut resolved = ResolvedSettings::default();
resolved.apply(settings);
resolved
}
fn explicit_settings(settings: ResolvedSettings) -> Settings {
Settings {
overlay: Some(settings.overlay),
performance: Some(settings.performance),
steam_host_libs: Some(settings.steam_host_libs),
game_libs: Some(settings.game_libs),
verbose: Some(settings.verbose),
gamescope: Some(settings.gamescope),
gamescope_width: settings.gamescope_width,
gamescope_height: settings.gamescope_height,
gamescope_fps: settings.gamescope_fps,
fps_cap: settings.fps_cap,
vkbasalt: Some(settings.vkbasalt),
esync: settings.esync,
fsync: settings.fsync,
large_address_aware: Some(settings.large_address_aware),
pre_launch: settings.pre_launch,
post_launch: settings.post_launch,
env_vars: (!settings.env_vars.is_empty()).then_some(settings.env_vars),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GameLibsMode;
use crate::config::GamescopeSize;
#[test]
fn import_profile_clears_inheritance_and_makes_values_explicit() {
fn import_profile_preserves_sparse_settings() {
let shared = SharedProfileFile {
kind: PROFILE_KIND.to_string(),
version: FORMAT_VERSION,
name: "benchmark".to_string(),
settings: ResolvedSettings {
overlay: true,
performance: false,
steam_host_libs: true,
game_libs: GameLibsMode::Gamemode,
verbose: false,
gamescope: true,
gamescope_width: Some(1920),
gamescope_height: Some(1080),
gamescope_fps: Some(60),
fps_cap: Some(60),
vkbasalt: true,
esync: Some(true),
fsync: Some(false),
large_address_aware: true,
env_vars: BTreeMap::from([("GW_FLAG".to_string(), "1".to_string())]),
..ResolvedSettings::default()
settings: Settings {
mangohud: Some(true),
gamescope: Some(true),
gamescope_width: Some(GamescopeSize::Pixels(1920)),
..Settings::default()
},
};
let (name, imported) = import_profile(shared).expect("import profile");
assert_eq!(name, "benchmark");
assert_eq!(imported.inherits, None);
assert_eq!(imported.settings.overlay, Some(true));
assert_eq!(imported.settings.performance, Some(false));
assert_eq!(imported.settings.game_libs, Some(GameLibsMode::Gamemode));
assert_eq!(imported.settings.mangohud, Some(true));
assert_eq!(imported.settings.gamescope, Some(true));
assert_eq!(imported.settings.gamescope_width, Some(1920));
assert_eq!(imported.settings.gamescope_height, Some(1080));
assert_eq!(imported.settings.gamescope_fps, Some(60));
assert_eq!(imported.settings.fps_cap, Some(60));
assert_eq!(imported.settings.vkbasalt, Some(true));
assert_eq!(imported.settings.esync, Some(true));
assert_eq!(imported.settings.fsync, Some(false));
assert_eq!(imported.settings.large_address_aware, Some(true));
assert_eq!(
imported
.settings
.env_vars
.as_ref()
.and_then(|vars| vars.get("GW_FLAG")),
Some(&"1".to_string())
imported.settings.gamescope_width,
Some(GamescopeSize::Pixels(1920))
);
assert_eq!(imported.settings.gamemode, None);
}
}
+147 -44
View File
@@ -13,104 +13,207 @@ pub fn render(paths: &AppPaths, config: &ConfigFile, state: &StateFile) -> Strin
};
let mut output = String::new();
output.push_str("Paths:\n");
output.push_str(&format!(" config: {}\n", paths.config_file.display()));
output.push_str(&format!(" state: {}\n", paths.state_file.display()));
output.push_str("\nDependencies:\n");
output.push_str(&format!(" mangohud: {}\n", dependency_state("mangohud")));
output.push_str(&format!("{}\n", crate::color::bold("Paths:")));
output.push_str(&format!(
" gamemoderun: {}\n",
" {} {}\n",
crate::color::dim("config:"),
crate::color::dim(&paths.config_file.display().to_string())
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("state:"),
crate::color::dim(&paths.state_file.display().to_string())
));
output.push_str(&format!("\n{}\n", crate::color::bold("Dependencies:")));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("mangohud:"),
dependency_state("mangohud")
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamemoderun:"),
dependency_state("gamemoderun")
));
output.push_str(&format!(" zenity: {}\n", dependency_state("zenity")));
output.push_str(&format!(
" notify-send: {}\n",
" {} {}\n",
crate::color::dim("zenity:"),
dependency_state("zenity")
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("notify-send:"),
dependency_state("notify-send")
));
output.push_str(&format!(" vkbasalt: {}\n", vkbasalt_state()));
output.push_str(&format!(" gamescope: {}\n", dependency_state("gamescope")));
output.push_str(&format!(
" failure notifier: {}\n",
" {} {}\n",
crate::color::dim("vkbasalt:"),
vkbasalt_state()
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope:"),
dependency_state("gamescope")
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("failure notifier:"),
notify::available_notifier_name()
));
output.push_str(&format!(" host library dirs: {}\n", host_dirs_summary()));
output.push_str("\nResolved defaults:\n");
output.push_str(&format!(" overlay: {}\n", color::on_off(defaults.overlay)));
output.push_str(&format!(
" performance: {}\n",
color::on_off(defaults.performance)
" {} {}\n",
crate::color::dim("host library dirs:"),
host_dirs_summary()
));
output.push_str(&format!("\n{}\n", crate::color::bold("Resolved defaults:")));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("mangohud:"),
color::on_off(defaults.mangohud)
));
output.push_str(&format!(
" steam-host-libs: {}\n",
" {} {}\n",
crate::color::dim("gamemode:"),
color::on_off(defaults.gamemode)
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("steam-host-libs:"),
color::on_off(defaults.steam_host_libs)
));
output.push_str(&format!(" game-libs: {}\n", defaults.game_libs.as_str()));
output.push_str(&format!(" verbose: {}\n", color::on_off(defaults.verbose)));
output.push_str(&format!(
" gamescope: {}\n",
" {} {}\n",
crate::color::dim("game-libs:"),
crate::color::accent(defaults.game_libs.as_str())
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("verbose:"),
color::on_off(defaults.verbose)
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope:"),
color::on_off(defaults.gamescope)
));
if let Some(width) = defaults.gamescope_width {
output.push_str(&format!(" gamescope-width: {width}\n"));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope-width:"),
crate::color::accent(&width.as_display_str())
));
}
if let Some(height) = defaults.gamescope_height {
output.push_str(&format!(" gamescope-height: {height}\n"));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope-height:"),
crate::color::accent(&height.as_display_str())
));
}
if let Some(fps) = defaults.gamescope_fps {
output.push_str(&format!(" gamescope-fps: {fps}\n"));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("gamescope-fps:"),
crate::color::accent(&fps.to_string())
));
}
output.push_str(&format!(
" vkbasalt: {}\n",
" {} {}\n",
crate::color::dim("vkbasalt:"),
color::on_off(defaults.vkbasalt)
));
output.push_str(&format!(
" esync: {}\n",
" {} {}\n",
crate::color::dim("esync:"),
color::option_on_off(defaults.esync)
));
output.push_str(&format!(
" fsync: {}\n",
" {} {}\n",
crate::color::dim("fsync:"),
color::option_on_off(defaults.fsync)
));
output.push_str(&format!(
" large-address-aware: {}\n",
" {} {}\n",
crate::color::dim("large-address-aware:"),
color::on_off(defaults.large_address_aware)
));
output.push_str("\nProfiles:\n");
output.push_str(&format!(" count: {}\n", config.profiles.len()));
output.push_str(&format!("\n{}\n", crate::color::bold("Profiles:")));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("count:"),
config.profiles.len()
));
if config.profiles.is_empty() {
output.push_str(" names: (none)\n");
output.push_str(&format!(
" {} {}\n",
crate::color::dim("names:"),
crate::color::dim("(none)")
));
} else {
let names = config
.profiles
.keys()
.map(String::as_str)
.map(|name| crate::color::accent(name))
.collect::<Vec<_>>()
.join(", ");
output.push_str(&format!(" names: {names}\n"));
output.push_str(&format!(" {} {names}\n", crate::color::dim("names:")));
}
output.push_str("Bindings:\n");
output.push_str(&format!(" count: {}\n", config.bindings.len()));
output.push_str(&format!("{}\n", crate::color::bold("Bindings:")));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("count:"),
config.bindings.len()
));
if config.bindings.is_empty() {
output.push_str(" items: (none)\n");
output.push_str(&format!(
" {} {}\n",
crate::color::dim("items:"),
crate::color::dim("(none)")
));
} else {
for binding in &config.bindings {
output.push_str(&format!(" {} -> {}\n", binding.matcher, binding.profile));
output.push_str(&format!(
" {} {} {}\n",
crate::color::accent(&binding.matcher),
crate::color::dim("->"),
crate::color::accent(&binding.profile)
));
}
}
output.push_str("Observed games:\n");
output.push_str(&format!(" count: {}\n", state.games.len()));
output.push_str(&format!("{}\n", crate::color::bold("Observed games:")));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("count:"),
state.games.len()
));
if state.games.is_empty() {
output.push_str(" items: (none)\n");
output.push_str(&format!(
" {} {}\n",
crate::color::dim("items:"),
crate::color::dim("(none)")
));
} else {
for game in &state.games {
let resolved_profile =
bindings::resolve_profile_for_observed(config, game).unwrap_or("default");
output.push_str(&format!(" {}\n", game.executable));
output.push_str(&format!(" resolved profile: {resolved_profile}\n"));
output.push_str(&format!(" last launched: {}\n", game.last_profile));
output.push_str(&format!(" path: {}\n", game.command_path));
output.push_str(&format!(" {}\n", crate::color::bold(&game.executable)));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("resolved profile:"),
crate::color::accent(resolved_profile)
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("last launched:"),
game.last_profile
));
output.push_str(&format!(
" {} {}\n",
crate::color::dim("path:"),
crate::color::dim(&game.command_path)
));
if let Some(note) = &game.note {
output.push_str(&format!(" note: {note}\n"));
output.push_str(&format!(" {} {note}\n", crate::color::dim("note:")));
}
}
}
+2020 -163
View File
File diff suppressed because it is too large Load Diff