Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7f5f8d0
Prevent duplicate Spud detour targets
Guffawaffle Aug 29, 2026
90b99cc
Merge branch 'dev' into feature/spud-duplicate-target-enforcement
Guffawaffle Sep 2, 2026
7972ec4
Route duplicate detours to the mod log
Guffawaffle Sep 4, 2026
c973421
Document Spud diagnostic handler lifetime
Guffawaffle Sep 4, 2026
c3aa4bc
Check startup config output before replacing files
Guffawaffle Sep 12, 2026
3ba601e
Exercise startup save failures and permission retention
Guffawaffle Sep 12, 2026
a8ed7bc
Capture inherited permissions before the first save
Guffawaffle Sep 12, 2026
4d1a476
Retain missing-file creation coverage alongside ACL fixtures
Guffawaffle Sep 12, 2026
eb56fa0
Run config-save fixtures on native Windows and macOS CI
Guffawaffle Sep 12, 2026
b18b0a4
Preserve user TOML while saving the instant-warp mode asynchronously
Guffawaffle Sep 12, 2026
3a68f85
Cover Unicode key coordinates and missing TOML settings
Guffawaffle Sep 12, 2026
bbb0031
Pass idle quits through and exercise native save lifecycle
Guffawaffle Sep 12, 2026
b3bfe89
Publish force-close cancellation before deadline setup
Guffawaffle Sep 12, 2026
a523ec0
Support runtime numeric settings with per-key save recovery
Guffawaffle Sep 13, 2026
aa35167
Make debounce fixture tolerate delayed observers
Guffawaffle Sep 13, 2026
1059781
Add complete managed method signature resolution
Guffawaffle Sep 17, 2026
c445307
Resolve runtime persistence quit hooks across client updates
Guffawaffle Sep 17, 2026
1b95675
Merge remote-tracking branch 'origin/dev' into feature/spud-duplicate…
Guffawaffle Sep 19, 2026
62399bc
Expose bounded detour preflight for macOS hook families
Guffawaffle Sep 19, 2026
f2f8e9a
Isolate combined SPUD validation package from prior cache
Guffawaffle Sep 19, 2026
1f6cea9
Integrate shared hook validation and enable macOS runtime persistence
Guffawaffle Sep 19, 2026
1c128c0
Trim trailing whitespace in hook fixture build definition
Guffawaffle Sep 19, 2026
97fa89e
Merge branch 'feature/spud-duplicate-target-enforcement' into feature…
Guffawaffle Sep 19, 2026
a5b791a
Document validated Mac runtime setting persistence
Guffawaffle Sep 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,16 @@ jobs:
shell: pwsh
run: sccache --show-stats

- name: Test startup config saves
shell: pwsh
env:
PACKAGE_DIR: ${{ steps.xmake_cache_paths.outputs.package_dir }}
run: |
$header = Get-ChildItem -LiteralPath (Join-Path $env:PACKAGE_DIR 't/toml++') -Recurse -Filter toml.h |
Where-Object { $_.Directory.Name -eq 'toml++' } | Select-Object -First 1
if (-not $header) { throw 'Built toml++ package not found.' }
./tests/run-config-save.ps1 -TomlInclude $header.Directory.Parent.FullName

- name: Package
shell: pwsh
run: |
Expand Down Expand Up @@ -477,6 +487,30 @@ jobs:
shell: bash
run: sccache --show-stats

- name: Test startup config saves
shell: bash
env:
PACKAGE_DIR: ${{ steps.xmake_cache_paths.outputs.package_dir }}
run: |
set -euo pipefail
TOML_HEADER=$(find "$PACKAGE_DIR/t/toml++" -path '*/include/toml++/toml.h' -print -quit)
test -n "$TOML_HEADER"
bash tests/run-config-save.sh "$(dirname "$(dirname "$TOML_HEADER")")"

- name: Test loaded Mach-O hook boundaries
shell: bash
run: |
xmake build -y macos-hook-extent-tests
xmake run macos-hook-extent-tests

- name: Verify ARM64 debug core
if: ${{ matrix.arch == 'arm64' }}
shell: bash
run: |
xmake f -p macosx -a arm64 -m debug --target_minver=14.6 -y
xmake -y mods
xmake f -p macosx -a arm64 -m release --target_minver=14.6 -y

- name: Report Swift module cache
shell: bash
run: |
Expand Down
127 changes: 127 additions & 0 deletions docs/config-save.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Startup config saves

`Config::Save` writes complete TOML documents for two startup callers: the initial
default config and the generated runtime snapshot. It keeps `File::MakePath`
routing and the existing generated-file warning. Save errors are logged once by
the caller; startup continues with the in-memory configuration.

`SaveConfigDocument` serializes with toml++, parses the output before touching
disk, exclusively creates a sibling temporary file, checks writing and closing,
and replaces the destination. Startup callers remain synchronous. These
whole-document saves do not merge concurrent setting changes or preserve comments.

Windows uses `ReplaceFileW` to preserve existing permissions and streams, with a
temporary backup for its documented partial-failure cases. A missing destination
falls back to a non-replacing move. The caller's startup existence check is not an
exclusive create transaction. Ordinary failures clean up the temporary file; partial
replacement failures retain recovery files and report their location. The backup
name is the reported temporary path plus `.bak`. Recovery is not automatic.
macOS uses rename after copying the existing permission bits. Extended metadata
and hard-link identity are not preserved by that path. Existing symlinks are
resolved before staging. Replacement requires directory permissions in addition
to any file access checks; it cannot exactly match an in-place overwrite.

Successful close/replacement is not a guarantee against power loss. A forced exit
can leave a temporary file. No automatic stale-file sweep is installed.

Run the isolated Windows fixtures with `tests/run-config-save.ps1` after the
normal AX build has installed toml++; `-TomlInclude` can select another include
directory. Fixtures never access the installed game's files.

On macOS, run `bash tests/run-config-save.sh TOML_INCLUDE_DIR`. Both native macOS
CI jobs run these fixtures after the normal build, including permission-bit and
symlink checks. The failure fixture injects short writes and failed closes at
compile time; it does not install test controls in the mod.

Native behavior references:
- [Windows ReplaceFileW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew)
- [POSIX rename](https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html)

## Runtime edits

The instant-warp mode shortcut changes the active mode immediately, then asks one
worker to persist `ui.auto_confirm_instant_warp`. The same worker can serve other
explicitly registered keys. Each key retains its own acknowledged value and at
most one pending request; new submissions replace that key's pending value while
an active save finishes. Registration closes when the worker starts on its first
request. Unregistered keys are rejected. The worker does not read game objects or
call Unity.

A submission can delay its save until a quiet interval has elapsed. Replacing a
pending request restarts that key's interval, without delaying other ready keys.
Normal quit drains accepted requests, including delayed ones; cancellation wakes
the worker and discards pending requests. There is one worker for the file, with
no per-control threads or timers. Live sliders and shortcut editing in the child
settings work use these capabilities; this branch retains instant warp as its
only registered game setting. Each feature owns its key registration, validation,
and choice of delay.

The worker reads the current file for each attempt. `TomlEditor` caches a parsed
document only while its source bytes match. It uses toml++ source regions to
replace the selected value, preserving unrelated bytes, comments and line endings.
Missing settings are inserted only when reparsing proves the candidate means
exactly the intended document. Values are typed booleans, strings, signed 64-bit
integers or finite doubles and encoded by toml++; quotes, backslashes and newlines
cannot become new TOML instructions. Newly requested NaN/infinity values are
rejected. Existing unrelated TOML values are preserved. Feature-specific numeric
ranges remain the caller's responsibility.

Each request compares the selected value against the last acknowledged disk value,
including whether it was absent. String quoting/escape spelling is not part of
that semantic comparison. Unrelated external
changes survive. A value already equal to the requested value succeeds without a
write; a different external value reports a conflict. Invalid TOML, unsupported
value types and I/O errors leave the live setting alone and log one message per failed
attempt, without file contents or values. No automatic retry loop is installed.
The acknowledged value advances only after success. To reconcile a conflict,
restore the original disk value, select the externally saved mode, or restart to
load the file. Runtime edits do not rewrite the startup-only generated snapshot.

Failure state is tracked per key: saving B cannot clear a failure for A. A later
successful save of A clears A's failure. Failure to start the worker is tracked
the same way and does not prevent a later submission from trying again. Reporter
callbacks include the section/key, run on the worker, and cannot stop it by
throwing. The game adapter exposes aggregate failure status and one optional
process-lifetime observer, called on the game thread only when that status
changes. It uses the existing update dispatcher even if persistence setup failed.
The adapter conservatively retains failures from submissions it could not track.
Settings UI wording and widgets belong to the consumers, not the writer.

The checked replacement re-reads the source after staging and rejects changed
bytes before commit. This is best-effort conflict detection, not an atomic
compare-and-swap with arbitrary external editors: an external write can still
race the final native replacement. File deletion is an I/O error, not permission
to recreate the user's file from cached content.

Runtime persistence supports Windows x64 and macOS clients with compatible Unity quit methods.
The adapter resolves `Internal_ApplicationWantsToQuit()` and `Quit(int)` by their
complete managed signatures, without pinning client addresses or instruction bytes.
On macOS the loaded quit method must also pass native extent/prologue validation.
Incompatible bindings retain session-only changes with a save-failure notice.
The adapter is idempotent, allowing native settings and keyboard consumers to
request the same persistence lifecycle.

An idle normal quit closes admission and passes the original vote through without
replaying quit. When work is active, normal quit stops admission, drains accepted work, then resumes the game's quit
request after observing native worker termination. Save failures do not prevent
exit. A genuine game veto is respected and is not retried automatically. If the
game vetoes after draining, persistence remains stopped for that session;
subsequent mode shortcuts still affect gameplay but are session-only. A stalled
OS write can delay normal quit. On Windows, F10 remains the escape path. With pending work,
F10 cancels queued requests and allows the active write up to 500 ms on an
independent native thread before terminating. With no pending/active write it
terminates immediately. No disk operation or wait runs in the key handler.
The existing ScreenManager.Update dispatcher supplies one idle callback; there
is no extra frame detour or per-frame logging. Hook controls have process lifetime;
hot unloading the mod is unsupported.

The fixture runners also cover preserving edits, escaped values, conflicts,
per-key coalescing, quiet-period expiry/replacement, failed-save baselines,
draining and cancellation. They use isolated files and compile-time seams;
no test switches or injected test delays ship in the mod.
The Windows and macOS adapter fixtures execute the production lifecycle functions with
controlled worker/Unity boundaries. A disk/reload test also checks a pending 95%
galaxy threshold survives orderly shutdown. Separate Windows child processes exercise real native
force-close calls, including a stalled cancellation caller and the 500 ms wait.
Its 5-second watchdog allows scheduling overhead; this is not a hard real-time
deadline guarantee or evidence that the current game detour fired.
2 changes: 2 additions & 0 deletions example_community_patch_settings_da.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_de.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_en-GB-x-cockney.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_en-x-minionese.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_es.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_fr.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_nl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_ru.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
2 changes: 2 additions & 0 deletions example_community_patch_settings_tlh.toml
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false
# "none" - Do not choose an action automatically; show the confirmation dialog
# "warp" - Automatically choose regular warp
# "jump" - Automatically choose instant jump
# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only.
# External edits are preserved; conflicts are logged. See docs/config-save.md.
auto_confirm_instant_warp = "none"

# Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions
Expand Down
24 changes: 18 additions & 6 deletions mods/src/config.cc
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#include "config.h"
#include "config_save.h"
#include "patches/runtime_config.h"
#include "file.h"
#include "patches/mapkey.h"
#include "prime/KeyCode.h"
Expand Down Expand Up @@ -93,10 +95,10 @@ Config::Config()

void Config::Save(const toml::table& config, const std::string_view filename, bool apply_warning)
{
std::ofstream config_file;
std::ostringstream config_file;

auto config_path = File::MakePath(filename, true);
config_file.open(config_path);
config_file.exceptions(std::ios::badbit | std::ios::failbit);

if (apply_warning) {
char defaultFile[255], configFile[255];
Expand All @@ -118,8 +120,7 @@ void Config::Save(const toml::table& config, const std::string_view filename, bo
config_file << "#######################################################################\n\n";
}

config_file << config;
config_file.close();
SaveConfigDocument(config, std::filesystem::path(config_path), config_file.str());
}

Config& Config::Get()
Expand Down Expand Up @@ -1420,9 +1421,16 @@ void Config::Load()
message << "Creating " << File::Config() << " (default config file)";
spdlog::warn(message.str());

Config::Save(parsed, File::Config(), false);
try {
Config::Save(parsed, File::Config(), false);
config = parsed; // First runtime comparison must match the file just created.
} catch (const std::exception& error) {
spdlog::error("Could not save default config: {}", error.what());
}
}

runtime_config::Configure(config);

message.str("");
message << "Creating " << File::Vars() << " (final config file)";
spdlog::info(message.str());
Expand All @@ -1435,7 +1443,11 @@ void Config::Load()
std::filesystem::remove(FILE_DEF_PARSED);
}

Config::Save(parsed, File::Vars());
try {
Config::Save(parsed, File::Vars());
} catch (const std::exception& error) {
spdlog::error("Could not save runtime config: {}", error.what());
}

std::cout << "\n\n-----------------------------\n\n"
<< parsed << "\n\n-----------------------------\nVersion "
Expand Down
Loading
Loading