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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,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 @@ -521,6 +531,16 @@ 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: Report Swift module cache
shell: bash
run: |
Expand Down
132 changes: 132 additions & 0 deletions docs/config-save.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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 private 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.

Staging is private before writing and after close until replacement preparation: Windows uses
a protected owner/system DACL, and macOS uses mode `0600`. New documents keep
these private permissions.

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 and dangling final symlinks are
resolved before staging; cyclic links fail without replacing the link. 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 and checks staging permissions before/during writes and after close;
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-resolves the selected path and re-reads its source
after staging, rejecting a changed symlink target or 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.
Incompatible bindings retain session-only changes with a save-failure notice.
The patch registry owns installation through default-enabled `[patches].runtimeconfighooks`.
Persistence installation is independent of hotkeys and native settings. Disabling
the compatibility switch retains live changes for the session only.

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 @@ -669,6 +669,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 @@ -669,6 +669,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 @@ -669,6 +669,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 @@ -669,6 +669,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
4 changes: 4 additions & 0 deletions example_community_patch_settings_en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ loadingtiphooks = true
doubleclickassignshiphooks = true
forbiddentechconfirmhooks = true
audioeventhooks = true
# Installs runtime config persistence independently of keyboard/native settings features.
runtimeconfighooks = true
freeresizehooks = true
game_version = true
giftsbulkclaimhooks = true
Expand Down Expand Up @@ -680,6 +682,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 @@ -669,6 +669,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 @@ -669,6 +669,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 @@ -669,6 +669,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 @@ -669,6 +669,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 @@ -669,6 +669,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
26 changes: 20 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 @@ -923,6 +924,8 @@ void Config::Load()
get_config_or_default(config, parsed, "patches", "doubleclickassignshiphooks", DCP::doubleclickassignshiphooks, write_config);
this->installForbiddenTechConfirmationHooks =
get_config_or_default(config, parsed, "patches", "forbiddentechconfirmhooks", DCP::forbiddentechconfirmhooks, write_config);
this->installRuntimeConfigHooks =
get_config_or_default(config, parsed, "patches", "runtimeconfighooks", DCP::runtimeconfighooks, write_config);
this->installAudioEventHooks =
get_config_or_default(config, parsed, "patches", "audioeventhooks", DCP::audioeventhooks, write_config);
this->installInstantCargoCounterHooks =
Expand Down Expand Up @@ -1403,9 +1406,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 @@ -1418,7 +1428,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
1 change: 1 addition & 0 deletions mods/src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ class Config final
bool installForbiddenTechConfirmationHooks;
bool installInstantWarpConfirmationHooks;
bool installAudioEventHooks;
bool installRuntimeConfigHooks;

std::string config_settings_url;
std::string config_assets_url_override;
Expand Down
Loading
Loading