diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6f1268bc1..c67f7b716 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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: | @@ -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: | diff --git a/docs/config-save.md b/docs/config-save.md new file mode 100644 index 000000000..aae772254 --- /dev/null +++ b/docs/config-save.md @@ -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. diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 2069b388a..b8312fdef 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -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 diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index dc626072b..f475d474b 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -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 diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index e1aed8205..a3a12223b 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -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 diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 562af2608..ea664d10f 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -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 diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 8dcc031a6..bbeae9a03 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -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 @@ -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 diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 795c8f1c8..94fce610b 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -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 diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 8bf856ea3..d6b5ceb40 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -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 diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 719b54a1d..be2351ba4 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -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 diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 6e1d0ca2a..b2e85c1f1 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -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 diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index ca88ac0b1..26ddbe308 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -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 diff --git a/mods/src/config.cc b/mods/src/config.cc index b69377fbb..e112022fe 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -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" @@ -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]; @@ -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() @@ -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 = @@ -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()); @@ -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 " diff --git a/mods/src/config.h b/mods/src/config.h index 2ab320ce8..bba82279d 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -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; diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc new file mode 100644 index 000000000..e7e8685ad --- /dev/null +++ b/mods/src/config_save.cc @@ -0,0 +1,181 @@ +#include "config_save.h" + +#include +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#include +#include +#include +#pragma comment(lib, "advapi32.lib") +#else +#include +#include +#endif + +// Compile-time substitutions are used only by the isolated failure fixture. +#ifndef CONFIG_SAVE_WRITE +#define CONFIG_SAVE_WRITE std::fwrite +#endif +#ifndef CONFIG_SAVE_CLOSE +#define CONFIG_SAVE_CLOSE std::fclose +#endif + +namespace +{ +std::filesystem::path ResolveConfigDestination(const std::filesystem::path& path) +{ + // Follow dangling final links too, preserving the former ofstream behavior. + auto destination = std::filesystem::weakly_canonical(path); + unsigned links = 0; + while (std::filesystem::is_symlink(std::filesystem::symlink_status(destination))) { + if (++links > 40) + throw std::filesystem::filesystem_error("config symlink cycle", path, + std::make_error_code(std::errc::too_many_symbolic_link_levels)); + auto target = std::filesystem::read_symlink(destination); + destination = std::filesystem::weakly_canonical(target.is_absolute() ? target : destination.parent_path() / target); + } + return destination; +} +} // namespace + +void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header) +{ + // Serialize and validate before opening any file. Values are encoded by toml++, + // never interpolated into TOML source. Validate the header too. + std::ostringstream output; + output.exceptions(std::ios::badbit | std::ios::failbit); + output << header << config; + const auto bytes = output.str(); + ReplaceConfigText(path, bytes); +} + +std::string ReadConfigText(const std::filesystem::path& path) +{ + std::ifstream input(path, std::ios::binary); + if (!input.is_open()) { + throw std::runtime_error("could not open config file"); + } + std::string text; + char buffer[8192]; + while (input.read(buffer, sizeof(buffer)) || input.gcount()) { + text.append(buffer, static_cast(input.gcount())); + } + if (!input.eof() || input.bad()) { + throw std::runtime_error("could not read config file"); + } + return text; +} + +bool ReplaceConfigText(const std::filesystem::path& path, std::string_view bytes, + std::optional expected) +{ + (void)toml::parse(bytes); + + const auto destination = ResolveConfigDestination(path); + static std::atomic sequence{0}; + auto temporary = destination; + temporary += ".tmp-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)); + + // Configs may contain tokens. Protect staging at creation, before any bytes, + // even if the destination is private beneath a more permissive directory. + std::FILE* file = nullptr; +#if _WIN32 + PSECURITY_DESCRIPTOR security = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;;FA;;;OW)(A;;FA;;;SY)", SDDL_REVISION_1, &security, nullptr)) + throw std::system_error(GetLastError(), std::system_category(), "could not protect temporary config file"); + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), security, FALSE}; + const auto handle = CreateFileW(temporary.c_str(), GENERIC_WRITE | READ_CONTROL, 0, &attributes, + CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + const auto create_error = GetLastError(); + LocalFree(security); + if (handle == INVALID_HANDLE_VALUE) + throw std::system_error(create_error, std::system_category(), "could not create temporary config file"); + const auto descriptor = _open_osfhandle(reinterpret_cast(handle), _O_WRONLY | _O_BINARY); + if (descriptor == -1) { + CloseHandle(handle); + } else { + file = _fdopen(descriptor, "wb"); + if (!file) + _close(descriptor); + } +#else + const auto descriptor = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600); + if (descriptor == -1) + throw std::system_error(errno, std::generic_category(), "could not create temporary config file"); + file = ::fdopen(descriptor, "wb"); + if (!file) + ::close(descriptor); +#endif + if (!file) { + const auto error = errno; + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + throw std::system_error(error, std::generic_category(), "could not open temporary config stream"); + } + + bool replacing = false; + try { + if (CONFIG_SAVE_WRITE(bytes.data(), 1, bytes.size(), file) != bytes.size()) { + throw std::system_error(errno, std::generic_category(), "could not write temporary config file"); + } + const auto closed = CONFIG_SAVE_CLOSE(file); // Includes flushing; failure prevents replacement. + file = nullptr; + if (closed != 0) { + throw std::system_error(errno, std::generic_category(), "could not close temporary config file"); + } + // Recheck after staging, immediately before commit. Another editor can still + // race the native replacement; arbitrary external editors do not share our lock. + if (expected && (ResolveConfigDestination(path) != destination || ReadConfigText(destination) != *expected)) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + return false; + } +#if _WIN32 + // Let Windows retain the existing file's permissions and streams. A backup + // protects the old contents in ReplaceFile's documented partial-failure cases. + auto backup = temporary; + backup += ".bak"; + if (!ReplaceFileW(destination.c_str(), temporary.c_str(), backup.c_str(), 0, nullptr, nullptr)) { + auto error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND) { + // Missing-destination fallback: do not overwrite a file appearing before + // this move. The caller's earlier existence check is not a create-only transaction. + error = MoveFileExW(temporary.c_str(), destination.c_str(), 0) ? ERROR_SUCCESS : GetLastError(); + } + if (error != ERROR_SUCCESS) { + replacing = error == ERROR_UNABLE_TO_MOVE_REPLACEMENT || error == ERROR_UNABLE_TO_MOVE_REPLACEMENT_2; + throw std::filesystem::filesystem_error( + replacing ? "config replacement failed; retain temporary/backup for recovery" : "config replacement failed", + temporary, destination, std::error_code(error, std::system_category())); + } + } + std::error_code ignored; + std::filesystem::remove(backup, ignored); +#else + // Preserve ordinary permission bits when replacing an existing config. + if (std::filesystem::exists(destination)) { + std::filesystem::permissions(temporary, std::filesystem::status(destination).permissions()); + } + std::filesystem::rename(temporary, destination); +#endif + } catch (...) { + if (file) { + std::fclose(file); + } + std::error_code ignored; + if (!replacing) { + std::filesystem::remove(temporary, ignored); + } + throw; + } + return true; +} diff --git a/mods/src/config_save.h b/mods/src/config_save.h new file mode 100644 index 000000000..8e87aa8a3 --- /dev/null +++ b/mods/src/config_save.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Synchronous whole-document output for startup, not a runtime setting editor. +// Throws on failure; the caller owns reporting. Does not guarantee power-loss durability. +void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header = {}); + +std::string ReadConfigText(const std::filesystem::path& path); +// Preserves the supplied text. With expected text, false means an external edit +// was detected before replacement. This is not a filesystem compare-and-swap. +bool ReplaceConfigText(const std::filesystem::path& path, std::string_view text, + std::optional expected = std::nullopt); diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 10764b99f..61b47f4e9 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -94,6 +94,7 @@ namespace Patches constexpr bool doubleclickassignshiphooks = true; constexpr bool forbiddentechconfirmhooks = true; constexpr bool audioeventhooks = true; + constexpr bool runtimeconfighooks = true; constexpr bool instantcargocounterhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override constexpr bool officersorthooks = true; // restore Below Deck Ability sort option diff --git a/mods/src/il2cpp/method_contract.h b/mods/src/il2cpp/method_contract.h new file mode 100644 index 000000000..d7d1ccdc5 --- /dev/null +++ b/mods/src/il2cpp/method_contract.h @@ -0,0 +1,44 @@ +#pragma once + +#include "il2cpp-functions.h" +#include +#include +#include + +namespace method_contract +{ +inline bool Type(const Il2CppType* type, const char* name) +{ + if (!type || type->byref) return false; + auto* actual = il2cpp_type_get_name(type); + const bool matches = actual && std::strcmp(actual, name) == 0; + il2cpp_free(actual); + return matches; +} + +// Resolve the entire managed signature, including static/instance dispatch. +// A renamed, ambiguous or generic method is not a compatible callback. +inline const MethodInfo* Resolve(Il2CppClass* cls, const char* name, bool is_static, + const char* result, std::initializer_list parameters) +{ + if (!cls) return nullptr; + const MethodInfo* found = nullptr; + void* iterator = nullptr; + while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { + if (std::strcmp(method->name, name) != 0 || !method->methodPointer || method->is_generic || method->is_inflated + || bool(method->flags & METHOD_ATTRIBUTE_STATIC) != is_static + || method->parameters_count != parameters.size() || !Type(method->return_type, result)) continue; + bool matches = true; + unsigned i = 0; + for (auto* parameter : parameters) + matches = Type(method->parameters[i++], parameter) && matches; + if (!matches) continue; + if (found) return nullptr; + found = method; + } + return found; +} + +inline void* Pointer(const MethodInfo* method) +{ return method ? reinterpret_cast(method->methodPointer) : nullptr; } +} // namespace method_contract diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index fb3129d63..b7bfb6456 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1,4 +1,5 @@ #include "config.h" +#include "patches/runtime_config.h" #include @@ -347,6 +348,7 @@ void CycleAutoConfirmInstantWarp(Config& config) } spdlog::info("Auto-confirm instant warp set to {}", state); + runtime_config::SaveWarpMode(state); } bool MoveOfficerCanvas(bool goLeft) @@ -676,7 +678,8 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) #ifdef _WIN32 if (MapKey::IsDown(GameFunction::Quit)) { - TerminateProcess(GetCurrentProcess(), 1); + runtime_config::ForceClose(); + return; } #elif defined(__APPLE__) if (MapKey::IsDown(GameFunction::Quit)) { diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc new file mode 100644 index 000000000..dd0f0a567 --- /dev/null +++ b/mods/src/patches/parts/runtime_config.cc @@ -0,0 +1,287 @@ +#ifdef CONFIG_RUNTIME_TEST +#include CONFIG_RUNTIME_TEST // Isolated fixture substitutes Unity/worker boundaries only. +#else +#include "patches/runtime_config.h" +#include "file.h" +#include "runtime_config_writer.h" +#include + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "patches/screen_update_hook.h" +#if _WIN32 +#include +#endif +#include +#include +#include +#endif +#endif + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + +namespace +{ +// Installed hooks live until process exit. Retain this one control object rather +// than joining a worker from DLL teardown. No worker is started during Configure. +config_edit::RuntimeConfigWriter* writer = nullptr; +bool available = false; +std::atomic owner{0}; +std::atomic_bool forcing{false}; +std::mutex lifecycle; +bool draining = false, stopped = false, resume = false; +std::uint64_t vote = 0; +thread_local unsigned quit_depth = 0; +void (*request_quit)(int) = nullptr; +void (*save_status_changed)() = nullptr; +std::atomic_bool persistence_unavailable{false}; +bool reported_save_failure = false; + +// Stable identity for the lifetime of the Unity owner thread on either platform. +std::uintptr_t CurrentThreadToken() +{ + static thread_local char token; + return reinterpret_cast(&token); +} + +void Report(std::string_view section, std::string_view key, config_edit::Outcome result) +{ + const char* reason = "write failed"; + switch (result) { + case config_edit::Outcome::Conflict: + reason = "file changed externally"; + break; + case config_edit::Outcome::InvalidDocument: + reason = "invalid TOML"; + break; + case config_edit::Outcome::Unsupported: + reason = "unsupported setting representation"; + break; + default: + break; + } + spdlog::warn("Could not persist {}.{}: {}; live setting is unchanged", section, key, reason); +} + +bool WantsQuit(auto original) +{ + struct Depth { + Depth() + { ++quit_depth; } + ~Depth() + { --quit_depth; } + } depth; + std::uint64_t this_vote; + { + std::lock_guard lock(lifecycle); + this_vote = ++vote; + } + const bool allows = original(); + std::lock_guard lock(lifecycle); + if (stopped || !writer) + return allows; + // A later/nested game veto must not be overwritten by an older returning vote. + if (this_vote == vote) + resume = allows; + if (allows) { + writer->Stop(false); + // Close admission before checking: Submit shares lifecycle, so no later + // request can race an idle exit. An already-deferred quit still observes + // native thread exit through Update before resuming. + if (!draining && !writer->HasWork() && resume) { + stopped = true; + resume = false; + return true; + } + draining = true; + } + return false; // Resume only after observing native worker exit, even after failure. +} + +void Update() +{ + std::uintptr_t unset = 0; + owner.compare_exchange_strong(unset, CurrentThreadToken()); + if (forcing || owner != CurrentThreadToken() || quit_depth) + return; + const bool failed = persistence_unavailable.load() || (writer && writer->HasFailures()); + if (failed != reported_save_failure) { + reported_save_failure = failed; + if (save_status_changed) { + try { + save_status_changed(); + } catch (...) { /* Presentation cannot interrupt shutdown. */ + } + } + } + bool quit = false; + { + std::lock_guard lock(lifecycle); + if (!draining || stopped || !writer || !writer->PollStopped()) + return; + stopped = true; + quit = resume; + resume = false; // Consume before Unity callbacks; never retry a genuine veto. + } + if (quit) + request_quit(0); +} + + +#if _WIN32 +DWORD WINAPI FinishForceClose(void* handle) +{ + WaitForSingleObject(handle, 500); + CloseHandle(handle); + TerminateProcess(GetCurrentProcess(), 1); + return 0; +} +#endif +} // namespace +#elif _WIN32 +#include +#endif + +namespace runtime_config +{ +bool SetSaveStatusObserver(void (*observer)()) +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (save_status_changed && save_status_changed != observer) + return false; + // Status must also update if persistence/quit-hook validation failed, or no + // writer was configured. Registration uses the existing idempotent dispatcher. + if (!observer || !install_screen_manager_update_hook() || !register_screen_manager_update_callback(Update)) + return false; + save_status_changed = observer; + return true; +#else + (void)observer; + return false; +#endif +} +bool HasSaveFailures() noexcept +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + return persistence_unavailable.load() || (writer && writer->HasFailures()); +#else + return false; +#endif +} +#ifndef CONFIG_RUNTIME_TEST +void Configure(const toml::table& loaded) +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (writer) + return; + std::optional initial; + if (auto value = loaded["ui"]["auto_confirm_instant_warp"].value()) + initial = *value; + try { + writer = new config_edit::RuntimeConfigWriter(File::MakePath(File::Config()), initial, Report); + } catch (...) { + spdlog::warn("Runtime config persistence unavailable"); + } +#else + (void)loaded; +#endif +} + +void Install() +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + static bool attempted = false; + if (attempted || !writer) + return; + attempted = true; + try { + auto helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Application"); + const auto* wants = method_contract::Resolve(helper.get_cls(), "Internal_ApplicationWantsToQuit", true, + "System.Boolean", {}); + const auto* quit = method_contract::Resolve(helper.get_cls(), "Quit", true, "System.Void", {"System.Int32"}); + if (!wants || !quit) { + spdlog::warn("Runtime config persistence unavailable: incompatible Unity quit methods"); + return; + } + request_quit = reinterpret_cast(quit->methodPointer); + available = install_screen_manager_update_hook() && register_screen_manager_update_callback(Update) + && SPUD_STATIC_DETOUR(wants->methodPointer, WantsQuit); + spdlog::info("Runtime config persistence ready={}", available); + } catch (...) { + available = false; + } +#endif +} +#endif + +void SaveSetting(const char* section, const char* key, config_edit::Value value, + std::chrono::milliseconds delay) noexcept +{ + try { +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (available && !forcing && owner == CurrentThreadToken() && !quit_depth) { + std::lock_guard lock(lifecycle); + if (!draining) { + if (writer->Submit(section, key, std::move(value), delay)) + return; + if (writer->HasFailure(section, key)) { + spdlog::warn("{}.{} changed for this session; runtime save submission failed", section, key); + return; // The writer owns this failure and its eventual same-key recovery. + } + } + } + persistence_unavailable.store(true); +#else + (void)value; +#endif + static bool reported = false; + if (!reported) { + reported = true; + spdlog::warn("{}.{} changed for this session; runtime persistence unavailable", section, key); + } + } catch (...) { /* Persistence must not interrupt the shortcut's live effect. */ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + persistence_unavailable.store(true); +#endif + } +} + +void SaveWarpMode(const char* mode) noexcept +{ + try { + const std::string value(mode); + if (value != "none" && value != "warp" && value != "jump") + return; + SaveSetting("ui", "auto_confirm_instant_warp", value, {}); + } catch (...) { // Keep value construction inside the shortcut's failure boundary. +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + persistence_unavailable.store(true); +#endif + } +} + +#if _WIN32 +void ForceClose() noexcept +{ +#if defined(_M_X64) + if (writer && owner == CurrentThreadToken() && !quit_depth && writer->HasWork()) { + forcing = true; + writer->RequestCancelPending(); + HANDLE duplicate = nullptr; + if (auto handle = writer->NativeHandle(); + handle + && DuplicateHandle(GetCurrentProcess(), handle, GetCurrentProcess(), &duplicate, SYNCHRONIZE, FALSE, 0)) { + // Arm the independent deadline before taking any writer lock. A stalled + // filesystem operation or Unity callback cannot prolong this best effort. + if (auto closer = CreateThread(nullptr, 0, FinishForceClose, duplicate, 0, nullptr)) { + CloseHandle(closer); + writer->Stop(true); + return; + } + CloseHandle(duplicate); + } + } +#endif + TerminateProcess(GetCurrentProcess(), 1); +} +#endif +} // namespace runtime_config diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index d4e50a9d9..d3d492ee0 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -1,4 +1,5 @@ #include "patches.h" +#include "runtime_config.h" #include "file.h" #include "version.h" @@ -152,6 +153,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.installForbiddenTechConfirmationHooks}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"RuntimeConfigHooks", {runtime_config::Install, &cfg.installRuntimeConfigHooks}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); diff --git a/mods/src/patches/runtime_config.h b/mods/src/patches/runtime_config.h new file mode 100644 index 000000000..8af45a8da --- /dev/null +++ b/mods/src/patches/runtime_config.h @@ -0,0 +1,21 @@ +#pragma once +#include "toml_editor.h" +#include +#include + +namespace runtime_config +{ +// Startup only: retain the semantic disk value as the optimistic comparison base. +void Configure(const toml::table& loaded); +void Install(); +// Register one process-lifetime observer during patch installation. Called on +// the game thread when aggregate failure status changes, never by the worker. +bool SetSaveStatusObserver(void (*observer)()); +bool HasSaveFailures() noexcept; +void SaveWarpMode(const char* mode) noexcept; +void SaveSetting(const char* section, const char* key, config_edit::Value value, + std::chrono::milliseconds delay = {}) noexcept; +#if _WIN32 +void ForceClose() noexcept; +#endif +} // namespace runtime_config diff --git a/mods/src/runtime_config_writer.cc b/mods/src/runtime_config_writer.cc new file mode 100644 index 000000000..2b6a4bdbd --- /dev/null +++ b/mods/src/runtime_config_writer.cc @@ -0,0 +1,192 @@ +#include "runtime_config_writer.h" +#include + +#if _WIN32 +#include +#endif + +#ifndef CONFIG_EDIT_SAVE +#define CONFIG_EDIT_SAVE(editor, path, request) (editor).Save(path, request) +#endif +#ifndef CONFIG_EDIT_START_WORKER +#define CONFIG_EDIT_START_WORKER(...) std::thread(__VA_ARGS__) +#endif + +namespace config_edit +{ +RuntimeConfigWriter::RuntimeConfigWriter(std::filesystem::path path, std::optional initial, Reporter report) + : path_(std::move(path)) + , report_(report) +{ saved_.emplace(Key{"ui", "auto_confirm_instant_warp"}, Saved{std::move(initial)}); } + +RuntimeConfigWriter::~RuntimeConfigWriter() +{ + Stop(false); + if (worker_.joinable()) + worker_.join(); +} + +std::uint64_t RuntimeConfigWriter::Submit(std::string mode) +{ + if (mode != "none" && mode != "warp" && mode != "jump") + return 0; + return Submit("ui", "auto_confirm_instant_warp", std::move(mode)); +} + +bool RuntimeConfigWriter::Register(std::string section, std::string key, std::optional initial) +{ + std::lock_guard lock(mutex_); + if (worker_.joinable() || stopping_ || section.empty() || key.empty()) + return false; + return saved_.emplace(Key{std::move(section), std::move(key)}, Saved{std::move(initial)}).second; +} + +std::uint64_t RuntimeConfigWriter::Submit(std::string section, std::string key, Value desired, + std::chrono::milliseconds delay) +{ + std::lock_guard lock(mutex_); + const Key identity{section, key}; + const auto saved = saved_.find(identity); + if (stopping_ || cancel_pending_.load() || saved == saved_.end()) + return 0; + pending_.insert_or_assign(identity, + Pending{++revision_, + {std::move(section), std::move(key), saved->second.value, std::move(desired)}, + std::chrono::steady_clock::now() + delay}); + has_work_.store(true); + if (!worker_.joinable()) { + try { + worker_ = CONFIG_EDIT_START_WORKER(&RuntimeConfigWriter::Run, this); + } catch (...) { + pending_.clear(); + has_work_.store(false); + completion_ = {revision_, Outcome::IoError}; + saved->second.failed = true; + has_failures_.store(true); + return 0; + } + } + wake_.notify_one(); + return revision_; +} + +void RuntimeConfigWriter::Stop(bool cancel_pending) +{ + if (cancel_pending) + RequestCancelPending(); + std::lock_guard lock(mutex_); + stopping_ = true; + if (cancel_pending && !pending_.empty()) { + completion_ = {revision_, Outcome::Cancelled}; + pending_.clear(); + } + wake_.notify_one(); +} + +void RuntimeConfigWriter::RequestCancelPending() +{ + cancel_pending_.store(true); + wake_.notify_one(); +} + +RuntimeConfigWriter::Completion RuntimeConfigWriter::LastCompletion() +{ + std::lock_guard lock(mutex_); + return completion_; +} +bool RuntimeConfigWriter::HasFailure(std::string_view section, std::string_view key) +{ + std::lock_guard lock(mutex_); + const auto found = saved_.find(Key{std::string(section), std::string(key)}); + return found != saved_.end() && found->second.failed; +} + +void RuntimeConfigWriter::Run() +{ + for (;;) { + Pending work; + { + std::unique_lock lock(mutex_); + wake_.wait(lock, [&] { return stopping_ || cancel_pending_.load() || !pending_.empty(); }); + if (cancel_pending_.load()) { + stopping_ = true; + if (!pending_.empty()) + completion_ = {revision_, Outcome::Cancelled}; + pending_.clear(); + } + if (pending_.empty()) + break; + auto next = std::min_element(pending_.begin(), pending_.end(), + [](const auto& a, const auto& b) { return a.second.ready < b.second.ready; }); + if (!stopping_ && next->second.ready > std::chrono::steady_clock::now()) { + const auto deadline = next->second.ready; + wake_.wait_until(lock, deadline); + continue; // New submissions may move a deadline; orderly stop flushes it. + } + work = std::move(next->second); + pending_.erase(next); + } + Outcome outcome; + try { + outcome = CONFIG_EDIT_SAVE(editor_, path_, work.edit); + } catch (...) { + outcome = Outcome::IoError; + } + { + std::lock_guard lock(mutex_); + if (outcome == Outcome::Saved || outcome == Outcome::AlreadySaved) { + // Rebase our queued intent over our own successful write, never over a + // conflicting external edit. Failed saves leave the acknowledged value alone. + const Key identity{work.edit.section, work.edit.key}; + auto& saved = saved_.at(identity).value; + if (auto pending = pending_.find(identity); pending != pending_.end() && pending->second.edit.expected == saved) + pending->second.edit.expected = work.edit.desired; + saved = work.edit.desired; + } + // A successful B save must not hide an unsaved A change. + saved_.at(Key{work.edit.section, work.edit.key}).failed = + outcome != Outcome::Saved && outcome != Outcome::AlreadySaved; + has_failures_.store( + std::any_of(saved_.begin(), saved_.end(), [](const auto& entry) { return entry.second.failed; })); + if (work.revision >= completion_.revision) + completion_ = {work.revision, outcome}; + } + if (report_ && outcome != Outcome::Saved && outcome != Outcome::AlreadySaved) { + try { + report_(work.edit.section, work.edit.key, outcome); + } catch (...) { /* Diagnostics cannot kill the worker. */ + } + } + { + std::lock_guard lock(mutex_); + // A diagnostic callback is still active worker work, even after disk I/O. + has_work_.store(!pending_.empty()); + } + } + has_work_.store(false); + finished_.store(true); +} + +bool RuntimeConfigWriter::PollStopped() +{ + if (!worker_.joinable()) { + std::lock_guard lock(mutex_); + return stopping_; + } +#if _WIN32 + if (WaitForSingleObject(worker_.native_handle(), 0) != WAIT_OBJECT_0) + return false; +#else + // The worker has completed its disk work and published its final state. + if (!finished_.load()) + return false; +#endif + worker_.join(); + return true; +} + +#if _WIN32 +void* RuntimeConfigWriter::NativeHandle() +{ return worker_.joinable() ? worker_.native_handle() : nullptr; } +#endif +} // namespace config_edit diff --git a/mods/src/runtime_config_writer.h b/mods/src/runtime_config_writer.h new file mode 100644 index 000000000..09b2fb0c3 --- /dev/null +++ b/mods/src/runtime_config_writer.h @@ -0,0 +1,74 @@ +#pragma once + +#include "toml_editor.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace config_edit +{ +// One owner for the configured file and all its registered runtime settings. +// Register additional keys here rather than creating another writer for the file. +class RuntimeConfigWriter +{ +public: + using Reporter = void (*)(std::string_view, std::string_view, Outcome); + struct Completion { + std::uint64_t revision = 0; + Outcome outcome = Outcome::AlreadySaved; + }; + RuntimeConfigWriter(std::filesystem::path path, std::optional initial, Reporter report = nullptr); + ~RuntimeConfigWriter(); // Tests/explicit owners only; game adapter has process lifetime. + std::uint64_t Submit(std::string mode); + // Startup registration only. Runtime submissions may update only known keys. + bool Register(std::string section, std::string key, std::optional initial); + std::uint64_t Submit(std::string section, std::string key, Value desired, std::chrono::milliseconds delay = {}); + void Stop(bool cancel_pending); + // Publish force-close cancellation before native deadline setup, without a lock. + void RequestCancelPending(); + Completion LastCompletion(); + bool HasWork() const + { return has_work_.load(); } + bool HasFailures() const + { return has_failures_.load(); } + // Failure-path query: lets a caller distinguish a tracked failed attempt from + // an untracked rejection. A same-key retry can clear the former normally. + bool HasFailure(std::string_view section, std::string_view key); + // Owner thread only, like Submit. On Windows this observes native thread exit + // before joining; it never joins a still-running worker on a game callback. + bool PollStopped(); +#if _WIN32 + void* NativeHandle(); // Owner thread only; caller must duplicate before retaining. +#endif +private: + struct Pending { + std::uint64_t revision; + Request edit; + std::chrono::steady_clock::time_point ready; + }; + void Run(); + std::filesystem::path path_; + using Key = std::pair; + struct Saved { + std::optional value; + bool failed = false; + }; + std::map saved_; + Reporter report_; + TomlEditor editor_; + std::mutex mutex_; + std::condition_variable wake_; + std::thread worker_; + std::map pending_; + Completion completion_; + std::uint64_t revision_ = 0; + bool stopping_ = false; + std::atomic_bool has_work_{false}, finished_{false}, cancel_pending_{false}; + std::atomic_bool has_failures_{false}; +}; +} // namespace config_edit diff --git a/mods/src/toml_editor.cc b/mods/src/toml_editor.cc new file mode 100644 index 000000000..b142d773a --- /dev/null +++ b/mods/src/toml_editor.cc @@ -0,0 +1,179 @@ +#include "toml_editor.h" +#include "config_save.h" + +#include +#include +#include + +namespace config_edit +{ +namespace +{ + std::string Encode(const Value& value) + { + return std::visit( + [](const auto& item) { + const toml::value node(item); + std::ostringstream output; + output.exceptions(std::ios::badbit | std::ios::failbit); + output << toml::toml_formatter(node, toml::format_flags::none); + return output.str(); + }, + value); + } + + std::optional ReadValue(const toml::node* node) + { + if (!node) + return std::nullopt; + if (node->is_boolean()) + return Value{node->as_boolean()->get()}; + if (node->is_string()) + return Value{node->as_string()->get()}; + if (node->is_floating_point()) + return Value{node->as_floating_point()->get()}; + if (node->is_integer()) + return Value{node->as_integer()->get()}; + throw std::invalid_argument("unsupported setting type"); + } + + std::size_t BomSize(std::string_view text) + { return text.starts_with("\xef\xbb\xbf") ? 3 : 0; } + + // toml++ columns count Unicode codepoints, including CR; only LF starts a line. + std::size_t Offset(std::string_view text, toml::source_position target) + { + toml::source_position current{1, 1}; + for (auto i = BomSize(text);;) { + if (current == target) + return i; + if (i >= text.size()) + throw std::invalid_argument("invalid source region"); + const auto byte = static_cast(text[i]); + if (byte == '\n') { + ++current.line; + current.column = 1; + } else + ++current.column; + ++i; + // Input has already passed TOML/UTF-8 validation. + while (i < text.size() && (static_cast(text[i]) & 0xc0) == 0x80) + ++i; + } + } +} // namespace + +Prepared TomlEditor::Prepare(const std::string& text, const Request& request) +{ + try { + if (const auto* value = std::get_if(&request.desired); value && !std::isfinite(*value)) + return {Outcome::Unsupported, {}}; + if (!cached_table_ || cached_text_ != text) { + auto parsed = toml::parse(text); + cached_table_.reset(); // Never pair new bytes with stale regions if allocation fails. + cached_text_ = text; + cached_table_ = std::move(parsed); + } + const auto& document = *cached_table_; + const auto* parent = document.get_as(request.section); + if (document.contains(request.section) && !parent) + return {Outcome::Unsupported, {}}; + const auto* node = parent ? parent->get(request.key) : nullptr; + const auto current = ReadValue(node); + if (current && *current == request.desired) + return {Outcome::AlreadySaved, {}}; + if (current != request.expected) + return {Outcome::Conflict, {}}; + + auto desired_document = document; + if (!parent) + desired_document.insert(request.section, toml::table{}); + std::visit( + [&](const auto& value) { + desired_document.get_as(request.section)->insert_or_assign(request.key, value); + }, + request.desired); + + // Accept a candidate only if a fresh parse has exactly the intended meaning. + auto accepts = [&](const std::string& candidate) { + try { + return toml::parse(candidate) == desired_document; + } catch (const toml::parse_error&) { + return false; + } + }; + const auto encoded = Encode(request.desired); + if (node) { + const auto begin = Offset(text, node->source().begin); + const auto end = Offset(text, node->source().end); + if (end < begin || end > text.size()) + return {Outcome::Unsupported, {}}; + auto result = text; + result.replace(begin, end - begin, encoded); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } + + const auto newline = text.find("\r\n") != std::string::npos ? "\r\n" : "\n"; + const auto assignment = Encode(Value{request.key}) + " = " + encoded; + if (parent && parent->is_inline()) { + const auto end = Offset(text, parent->source().end); + if (!end || text[end - 1] != '}') + return {Outcome::Unsupported, {}}; + auto result = text; + result.insert(end - 1, (parent->empty() ? " " : ", ") + assignment + " "); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } + if (parent) { + const auto begin = Offset(text, parent->source().begin); + if (begin < text.size() && text[begin] == '[') { + auto end = text.find('\n', begin); + auto result = text; + if (end == std::string::npos) + result += std::string(newline) + assignment + newline; + else + result.insert(end + 1, assignment + newline); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + } + // Dotted/implicit tables can sometimes be extended at the document root. + auto result = text; + result.insert(BomSize(text), Encode(Value{request.section}) + "." + assignment + newline); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + } + auto result = text; + if (!result.empty() && result.back() != '\n') + result += newline; + result += "[" + Encode(Value{request.section}) + "]" + newline + assignment + newline; + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } catch (const toml::parse_error&) { + return {Outcome::InvalidDocument, {}}; + } catch (const std::invalid_argument&) { + return {Outcome::Unsupported, {}}; + } +} + +Outcome TomlEditor::Save(const std::filesystem::path& path, const Request& request) +{ + try { + const auto original = ReadConfigText(path); + auto edit = Prepare(original, request); + if (edit.outcome != Outcome::Prepared) + return edit.outcome; + if (!ReplaceConfigText(path, edit.text, original)) + return Outcome::Conflict; + // Source locations belong to the old document; refresh on the next request. + cached_table_.reset(); + cached_text_.clear(); + return Outcome::Saved; + } catch (const std::exception&) { + return Outcome::IoError; + } +} +} // namespace config_edit diff --git a/mods/src/toml_editor.h b/mods/src/toml_editor.h new file mode 100644 index 000000000..2519b7584 --- /dev/null +++ b/mods/src/toml_editor.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace config_edit +{ +using Value = std::variant; +struct Request { + std::string section, key; + std::optional expected; // Missing is distinct from a configured default. + Value desired; +}; +enum class Outcome { Prepared, Saved, AlreadySaved, Conflict, InvalidDocument, Unsupported, IoError, Cancelled }; +struct Prepared { + Outcome outcome; + std::string text; // Nonempty only when an edit has been prepared. +}; + +// Own on a single worker. Cache represents disk text, not live game configuration. +class TomlEditor +{ +public: + Prepared Prepare(const std::string& text, const Request& request); + Outcome Save(const std::filesystem::path& path, const Request& request); + +private: + std::string cached_text_; + std::optional cached_table_; +}; +} // namespace config_edit diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc new file mode 100644 index 000000000..3cf6898b3 --- /dev/null +++ b/tests/config_save_failure_test.cc @@ -0,0 +1,142 @@ +#include +#include +#include +#include +#if _WIN32 +#include +#include +#include +#include +#else +#include +#endif + +static std::filesystem::path staging; +static void CheckPrivateStaging(std::FILE* file) +{ +#if _WIN32 + PACL acl = nullptr; + PSECURITY_DESCRIPTOR security = nullptr; + const auto handle = file ? reinterpret_cast(_get_osfhandle(_fileno(file))) : nullptr; + const auto error = file ? GetSecurityInfo(handle, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + nullptr, nullptr, &acl, nullptr, &security) + : GetNamedSecurityInfoW(const_cast(staging.c_str()), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + nullptr, nullptr, &acl, nullptr, &security); + assert(error == ERROR_SUCCESS && acl && acl->AceCount == 2); + SECURITY_DESCRIPTOR_CONTROL control; + DWORD revision; + assert(GetSecurityDescriptorControl(security, &control, &revision) && (control & SE_DACL_PROTECTED)); + for (DWORD i = 0; i < acl->AceCount; ++i) { + void* entry = nullptr; + assert(GetAce(acl, i, &entry)); + const auto* ace = static_cast(entry); + assert(ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && !(ace->Header.AceFlags & INHERITED_ACE)); + wchar_t* sid = nullptr; + assert(ConvertSidToStringSidW(const_cast(&ace->SidStart), &sid)); + assert(std::wstring_view(sid) == L"S-1-3-4" || std::wstring_view(sid) == L"S-1-5-18"); + LocalFree(sid); + } + LocalFree(security); +#else + struct stat status; + assert((file ? ::fstat(fileno(file), &status) : ::stat(staging.c_str(), &status)) == 0); + assert((status.st_mode & 0777) == 0600); +#endif +} + +static bool failClose = false; +#if !_WIN32 +static bool retarget = false; +static std::filesystem::path retargetLink, retargetDestination; +#endif + +static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t count, std::FILE* file) +{ + for (const auto& entry : std::filesystem::directory_iterator(staging.parent_path())) + if (entry.path().filename().string().find(".tmp-") != std::string::npos) + staging = entry.path(); + CheckPrivateStaging(file); + bool complete = failClose; +#if !_WIN32 + complete |= retarget; +#endif + const auto written = std::fwrite(data, size, complete ? count : count / 2, file); + CheckPrivateStaging(file); + if (complete) + return written; + errno = ENOSPC; + return written; +} + +static int FailedClose(std::FILE* file) +{ + std::fclose(file); + CheckPrivateStaging(nullptr); +#if !_WIN32 + if (retarget) { + std::filesystem::remove(retargetLink); + std::filesystem::create_symlink(retargetDestination, retargetLink); + return 0; + } +#endif + errno = ENOSPC; + return EOF; +} + +// Exercise the production cleanup path without adding runtime injection controls. +#define CONFIG_SAVE_WRITE ShortWrite +#define CONFIG_SAVE_CLOSE FailedClose +#include "../mods/src/config_save.cc" + +#include +#include +#include + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + staging = path; + const std::string original = "# keep this exactly\nenabled = false\n"; + { + std::ofstream out(path, std::ios::binary); + out << original; + } +#if !_WIN32 + ::umask(0022); + std::filesystem::permissions(path, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write); +#endif + for (bool closeFailure : {false, true}) { + failClose = closeFailure; + bool failed = false; + try { + SaveConfigDocument(toml::table{{"enabled", true}}, path); + } catch (const std::system_error&) { + failed = true; + } + assert(failed); + std::ifstream input(path, std::ios::binary); + const std::string actual(std::istreambuf_iterator{input}, {}); + assert(actual == original); + for (const auto& entry : std::filesystem::directory_iterator(root)) { + assert(entry.path() == path); + } + } +#if !_WIN32 + const auto other = root / "other.toml"; + { std::ofstream out(other); out << original; } + retargetLink = root / "selected.toml"; + retargetDestination = other.filename(); + std::filesystem::create_symlink(path.filename(), retargetLink); + staging = path; + retarget = true; + assert(!ReplaceConfigText(retargetLink, "enabled = true\n", original)); + assert(ReadConfigText(path) == original && ReadConfigText(other) == original); + assert(std::filesystem::read_symlink(retargetLink) == retargetDestination); + for (const auto& entry : std::filesystem::directory_iterator(root)) + assert(entry.path().filename().string().find(".tmp-") == std::string::npos); +#endif + std::cout << "Short-write and failed-close fixtures passed\n"; +} diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc new file mode 100644 index 000000000..c4f713548 --- /dev/null +++ b/tests/config_save_test.cc @@ -0,0 +1,82 @@ +#include "config_save.h" + +#include +#include +#include + +#if _WIN32 +#include +#endif + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string value = "quotes: \"'\\\n[unexpected]\nenabled = true\nUnicode: \xc3\xa9"; + toml::table config{{"value", value}, {"enabled", false}}; + SaveConfigDocument(config, path, "# generated\n"); + auto parsed = toml::parse_file(path.string()); + assert(parsed["value"].value() == value); + assert(parsed.size() == 2); + config.insert_or_assign("enabled", true); + SaveConfigDocument(config, path); + assert(toml::parse_file(path.string())["enabled"].value() == true); + + bool failed = false; + try { + SaveConfigDocument(config, path, "invalid = [\n"); + } catch (const std::exception&) { + failed = true; + } + assert(failed); + assert(toml::parse_file(path.string())["value"].value() == value); + + const auto directory = root / "occupied"; + std::filesystem::create_directory(directory); + failed = false; + try { + SaveConfigDocument(config, directory); + } catch (const std::exception&) { + failed = true; + } + assert(failed && std::filesystem::is_directory(directory)); +#if _WIN32 + // A real sharing violation must leave the previous readable document intact. + auto handle = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); + assert(handle != INVALID_HANDLE_VALUE); + failed = false; + config.insert_or_assign("enabled", false); + try { + SaveConfigDocument(config, path); + } catch (const std::exception&) { + failed = true; + } + CloseHandle(handle); + assert(failed); + assert(toml::parse_file(path.string())["enabled"].value() == true); +#else + const auto mode = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write; + std::filesystem::permissions(path, mode); + const auto link = root / "linked.toml"; + std::filesystem::create_symlink(path, link); + config.insert_or_assign("enabled", false); + SaveConfigDocument(config, link); + assert(std::filesystem::is_symlink(link)); + assert(toml::parse_file(path.string())["enabled"].value() == false); + assert(std::filesystem::status(path).permissions() == mode); + for (bool relative : {false, true}) { + const auto target = root / (relative ? "relative-target.toml" : "absolute-target.toml"); + const auto dangling = root / (relative ? "relative-link.toml" : "absolute-link.toml"); + std::filesystem::create_symlink(relative ? target.filename() : std::filesystem::absolute(target), dangling); + SaveConfigDocument(config, dangling); + assert(std::filesystem::is_symlink(dangling)); + assert(toml::parse_file(target.string())["enabled"].value() == false); + } +#endif + for (const auto& entry : std::filesystem::directory_iterator(root)) { + assert(entry.path().filename().string().find(".tmp-") == std::string::npos); + } + std::cout << "Config save fixtures passed\n"; +} diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 new file mode 100644 index 000000000..abf73bd68 --- /dev/null +++ b/tests/run-config-save.ps1 @@ -0,0 +1,107 @@ +[CmdletBinding()] +param([string]$TomlInclude) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +function Get-PermissionState([string]$Path) { + $acl = Get-Acl -LiteralPath $Path + # Windows can normalize descriptor control bits; compare actual rules, + # ownership and inheritance protection rather than serialized SDDL spelling. + [ordered]@{ + Owner = $acl.Owner + Group = $acl.Group + Protected = $acl.AreAccessRulesProtected + Rules = @($acl.Access | Select-Object IdentityReference, FileSystemRights, + AccessControlType, IsInherited, InheritanceFlags, PropagationFlags) + } | ConvertTo-Json -Depth 5 -Compress +} +Push-Location $repoRoot +try { + if (-not $TomlInclude) { + $packageRoot = Join-Path $env:LOCALAPPDATA '.xmake/packages/t/toml++' + $header = Get-ChildItem -LiteralPath $packageRoot -Recurse -Filter toml.h | + Where-Object { $_.Directory.Name -eq 'toml++' } | Select-Object -First 1 + if (-not $header) { throw 'Build with AX first, or supply -TomlInclude.' } + $TomlInclude = $header.Directory.Parent.FullName + } + New-Item -ItemType Directory -Force build/config-save-test | Out-Null + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/config_save_test.cc mods/src/config_save.cc /Febuild/config-save-test/test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Config save test compilation failed.' } + $fixtureRoot = Join-Path $repoRoot ('build/config-save-test/' + [guid]::NewGuid()) + & ./build/config-save-test/test.exe (Join-Path $fixtureRoot 'created') + if ($LASTEXITCODE -ne 0) { throw 'Initial config creation regression failed.' } + $fixtureRoot = Join-Path $fixtureRoot 'permissions' + # Establish the baseline independently, before the first production save. + New-Item -ItemType Directory -Path $fixtureRoot | Out-Null + $testFile = Join-Path $fixtureRoot 'settings.toml' + Set-Content -LiteralPath $testFile -Value 'enabled = false' + if (-not ((Get-Acl -LiteralPath $testFile).Access | Where-Object IsInherited)) { + throw 'Fixture must have inherited permission entries.' + } + $inherited = Get-PermissionState $testFile + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $testFile) -ne $inherited) { + throw 'Inherited ACL regression failed.' + } + $acl = Get-Acl -LiteralPath $testFile + $acl.SetAccessRuleProtection($true, $true) + Set-Acl -LiteralPath $testFile -AclObject $acl + $explicit = Get-PermissionState $testFile + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $testFile) -ne $explicit) { + throw 'Explicit ACL regression failed.' + } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/config_save_failure_test.cc /Febuild/config-save-test/failure-test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Config failure test compilation failed.' } + & ./build/config-save-test/failure-test.exe (Join-Path $fixtureRoot 'failures') + if ($LASTEXITCODE -ne 0) { throw 'Config failure regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/toml_editor_test.cc mods/src/toml_editor.cc mods/src/config_save.cc ` + /Febuild/config-save-test/editor-test.exe /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'TOML editor test compilation failed.' } + & ./build/config-save-test/editor-test.exe (Join-Path $fixtureRoot 'editor') + if ($LASTEXITCODE -ne 0) { throw 'TOML editor regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/runtime_config_writer_test.cc /Febuild/config-save-test/worker-test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Runtime writer test compilation failed.' } + & ./build/config-save-test/worker-test.exe + if ($LASTEXITCODE -ne 0) { throw 'Runtime writer regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Itests ` + tests/runtime_config_test.cc /Febuild/config-save-test/adapter-test.exe /Fobuild/config-save-test/ + if ($LASTEXITCODE -ne 0) { throw 'Native adapter test compilation failed.' } + & ./build/config-save-test/adapter-test.exe + if ($LASTEXITCODE -ne 0) { throw 'Native adapter regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/runtime_config_persistence_test.cc mods/src/runtime_config_writer.cc ` + mods/src/toml_editor.cc mods/src/config_save.cc ` + /Febuild/config-save-test/persistence-test.exe /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Persistence reload test compilation failed.' } + & ./build/config-save-test/persistence-test.exe (Join-Path $fixtureRoot 'reload') + if ($LASTEXITCODE -ne 0) { throw 'Persistence reload regression failed.' } + foreach ($mode in @('idle', 'deadline', 'finished', 'missing-handle')) { + $outputPath = Join-Path $fixtureRoot ('force-' + $mode + '.txt') + $timer = [Diagnostics.Stopwatch]::StartNew() + $child = Start-Process -FilePath (Join-Path $repoRoot 'build/config-save-test/adapter-test.exe') ` + -ArgumentList $mode -WindowStyle Hidden -PassThru -RedirectStandardOutput $outputPath + if (-not $child.WaitForExit(5000)) { + $child.Kill() + throw "Force-close fixture stalled: $mode" + } + $child.Refresh() + if ($child.ExitCode -ne 1) { throw "Force-close fixture did not terminate: $mode" } + if ($mode -eq 'deadline') { + if ($timer.ElapsedMilliseconds -lt 450 -or + (Get-Content $outputPath -Raw) -notmatch 'pending cancellation requested') { + throw 'Deadline did not allow best effort and request cancellation.' + } + } + } + Write-Output 'Native force-close child-process fixtures passed (500ms requested deadline; 5s harness watchdog).' +} finally { + Pop-Location +} diff --git a/tests/run-config-save.sh b/tests/run-config-save.sh new file mode 100644 index 000000000..a370ced9b --- /dev/null +++ b/tests/run-config-save.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pass the include directory of the toml++ package used by the normal build. +toml_include="${1:?usage: run-config-save.sh TOML_INCLUDE_DIR}" +cd "$(dirname "$0")/.." +mkdir -p build/config-save-test +test_root="$(mktemp -d "$PWD/build/config-save-test/run-XXXXXX")" + +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/config_save_test.cc mods/src/config_save.cc -o "$test_root/test" +"$test_root/test" "$test_root/ordinary" +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/config_save_failure_test.cc -o "$test_root/failure-test" +"$test_root/failure-test" "$test_root/failures" +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/toml_editor_test.cc mods/src/toml_editor.cc mods/src/config_save.cc -o "$test_root/editor-test" +"$test_root/editor-test" "$test_root/editor" +clang++ -std=c++23 -pthread -I mods/src -I "$toml_include" \ + tests/runtime_config_writer_test.cc -o "$test_root/worker-test" +"$test_root/worker-test" + +# Compile the actual platform adapter, including Mac save admission and quit draining. +clang++ -std=c++23 -pthread -I mods/src -I tests \ + tests/runtime_config_test.cc -o "$test_root/adapter-test" +"$test_root/adapter-test" + +clang++ -std=c++23 -pthread -I mods/src -I "$toml_include" \ + tests/runtime_config_persistence_test.cc mods/src/runtime_config_writer.cc \ + mods/src/toml_editor.cc mods/src/config_save.cc -o "$test_root/persistence-test" +"$test_root/persistence-test" "$test_root/reload" diff --git a/tests/runtime_config_fixture.h b/tests/runtime_config_fixture.h new file mode 100644 index 000000000..ad7ab1817 --- /dev/null +++ b/tests/runtime_config_fixture.h @@ -0,0 +1,69 @@ +#pragma once +#if _WIN32 +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include + +namespace spdlog +{ +template void warn(const char*, Args&&...) {} +} // namespace spdlog +namespace config_edit +{ +using Value = std::variant; +enum class Outcome { Conflict, InvalidDocument, Unsupported }; +// Controllable boundary; the fixture below includes the actual adapter bodies. +struct RuntimeConfigWriter { + bool failures = false; + bool HasFailures() const + { return failures; } + bool HasFailure(std::string_view, std::string_view) const + { return failures; } + bool work = false, stopped = false, finished = false, cancelled = false; + bool block_cancel = false; + unsigned submissions = 0; + void* handle = nullptr; + bool HasWork() const + { return work; } + void RequestCancelPending() + { cancelled = true; } + void Stop(bool cancel) + { + stopped = true; + cancelled = cancel; + if (cancel && block_cancel) { + std::puts("pending cancellation requested"); + std::fflush(stdout); +#if _WIN32 + Sleep(INFINITE); // Deadline must be independent of this stalled caller. +#endif + } + } + bool PollStopped() const + { return finished; } + void* NativeHandle() const + { return handle; } + unsigned Submit(const char*) + { return stopped || failures ? 0 : ++submissions; } + unsigned Submit(const char*, const char*, Value, std::chrono::milliseconds) + { return Submit(""); } +}; +} // namespace config_edit + +// Observe actual callback registration; tests must not manually call an Update +// that the adapter failed to arrange for the no-persistence case. +inline void (*fixture_update_callback)() = nullptr; +inline bool install_screen_manager_update_hook() +{ return true; } +inline bool register_screen_manager_update_callback(void (*callback)()) +{ + fixture_update_callback = callback; + return true; +} diff --git a/tests/runtime_config_persistence_test.cc b/tests/runtime_config_persistence_test.cc new file mode 100644 index 000000000..e91c010e7 --- /dev/null +++ b/tests/runtime_config_persistence_test.cc @@ -0,0 +1,37 @@ +#include "runtime_config_writer.h" +#include "config_save.h" +#include +#include +#include +#include + +int main(int argc, char** argv) +{ + assert(argc == 2); + const auto path = std::filesystem::path(argv[1]) / "community_patch_settings.toml"; + std::filesystem::create_directories(path.parent_path()); + const std::string original = "# keep this comment\n[graphics]\ngalaxy_multi_select = false\n" + "galaxy_label_major_detail = \"native\"\ngalaxy_label_major_threshold = 0.5\n" + "[unrelated]\nvalue = 42\n"; + ReplaceConfigText(path, original); + { + config_edit::RuntimeConfigWriter writer(path, std::nullopt); + assert(writer.Register("graphics", "galaxy_multi_select", false)); + assert(writer.Register("graphics", "galaxy_label_major_detail", std::string("native"))); + assert(writer.Register("graphics", "galaxy_label_major_threshold", 0.5)); + assert(writer.Submit("graphics", "galaxy_multi_select", true)); + assert(writer.Submit("graphics", "galaxy_label_major_detail", std::string("threshold"))); + // Quit immediately with a slider write still debounced: orderly stop must flush it. + assert(writer.Submit("graphics", "galaxy_label_major_threshold", 0.95, std::chrono::seconds(30))); + writer.Stop(false); + } + // Fresh parse models a new process, with no access to live Config or writer state. + const auto bytes = ReadConfigText(path); + const auto loaded = toml::parse(bytes); + assert(loaded["graphics"]["galaxy_multi_select"].value() == true); + assert(loaded["graphics"]["galaxy_label_major_detail"].value() == "threshold"); + assert(loaded["graphics"]["galaxy_label_major_threshold"].value() == 0.95); + assert(loaded["unrelated"]["value"].value() == 42); + assert(bytes.starts_with("# keep this comment")); + std::cout << "Runtime settings disk/reload regression passed\n"; +} diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc new file mode 100644 index 000000000..77ef4daf4 --- /dev/null +++ b/tests/runtime_config_test.cc @@ -0,0 +1,130 @@ +#define CONFIG_RUNTIME_TEST "runtime_config_fixture.h" +#include "../mods/src/patches/parts/runtime_config.cc" +#include +#include +#include + +namespace +{ +config_edit::RuntimeConfigWriter fixture; +unsigned resumes = 0; +void Reset() +{ + fixture = {}; + writer = &fixture; + available = true; + owner = CurrentThreadToken(); + forcing = false; + persistence_unavailable = false; + reported_save_failure = false; + save_status_changed = nullptr; + fixture_update_callback = nullptr; + draining = stopped = resume = false; + vote = 0; + resumes = 0; + request_quit = [](int) { ++resumes; }; +} +} // namespace +int main(int argc, char** argv) +{ + Reset(); +#if _WIN32 + if (argc == 2) { + const std::string_view mode(argv[1]); + fixture.work = mode != "idle"; + fixture.block_cancel = mode == "deadline"; + if (mode != "missing-handle") + fixture.handle = CreateEventW(nullptr, TRUE, mode == "finished", nullptr); + runtime_config::ForceClose(); + Sleep(10000); // Parent kills this fixture if the independent deadline fails. + return 9; + } +#endif + unsigned notices = 0; + static unsigned* noticeCount = ¬ices; + assert(runtime_config::SetSaveStatusObserver([] { ++*noticeCount; })); + fixture.failures = true; + std::thread foreignNotice([] { Update(); }); + foreignNotice.join(); + assert(notices == 0 && runtime_config::HasSaveFailures()); + Update(); + Update(); + assert(notices == 1); // One UI callback on a transition, never on each frame. + fixture.failures = false; + Update(); + assert(notices == 2 && !runtime_config::HasSaveFailures()); + available = false; + writer = nullptr; // Configure/Install never supplied the normal update path. + fixture_update_callback = nullptr; + assert(runtime_config::SetSaveStatusObserver(save_status_changed)); + assert(fixture_update_callback); + runtime_config::SaveWarpMode("warp"); + fixture_update_callback(); + assert(notices == 3 && runtime_config::HasSaveFailures()); + available = true; + writer = &fixture; + runtime_config::SaveWarpMode("jump"); + Update(); + assert(notices == 3 && runtime_config::HasSaveFailures()); // Rejected edits remain session-only. + Reset(); + fixture.failures = true; // Transient thread-start failure is tracked by its key. + runtime_config::SaveWarpMode("warp"); + assert(runtime_config::HasSaveFailures() && !persistence_unavailable); + fixture.failures = false; + runtime_config::SaveWarpMode("jump"); + assert(!runtime_config::HasSaveFailures()); + Reset(); + runtime_config::SaveWarpMode("invalid"); + assert(fixture.submissions == 0); // Generic submission must retain the mode wrapper's domain check. + assert(WantsQuit([] { return true; })); + assert(fixture.stopped && stopped && !draining); + runtime_config::SaveWarpMode("warp"); + assert(fixture.submissions == 0); + Update(); + assert(resumes == 0); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { return false; })); + assert(!fixture.stopped && !draining); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { return true; })); + assert(fixture.stopped && draining); + Update(); + assert(resumes == 0); + fixture.work = false; // Disk work done is not yet native worker termination. + Update(); + assert(resumes == 0); + fixture.finished = true; + std::thread foreign([] { + Update(); + runtime_config::SaveWarpMode("jump"); + }); + foreign.join(); + assert(resumes == 0 && fixture.submissions == 0); + request_quit = [](int) { + ++resumes; + assert(!WantsQuit([] { return false; })); // A genuine resumed veto is final. + }; + Update(); + Update(); + assert(resumes == 1); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { + assert(!WantsQuit([] { return false; })); + return true; // Older outer vote must not replace the later veto. + })); + fixture.finished = true; + Update(); + assert(resumes == 0); + + Reset(); + assert(!WantsQuit([] { return false; })); + runtime_config::SaveWarpMode("warp"); + assert(fixture.submissions == 1); // Veto leaves ordinary save admission open. + std::puts("Native adapter idle/drain/veto/owner fixtures passed"); +} diff --git a/tests/runtime_config_writer_test.cc b/tests/runtime_config_writer_test.cc new file mode 100644 index 000000000..9cb2f5fef --- /dev/null +++ b/tests/runtime_config_writer_test.cc @@ -0,0 +1,264 @@ +#include "runtime_config_writer.h" +#include +#include +#include +#include +#include +#include + +using namespace config_edit; +using namespace std::chrono_literals; +namespace +{ +std::mutex gate; +std::condition_variable changed; +bool entered = false, released = false; +Outcome first_result = Outcome::Saved; +std::vector requests; +std::thread::id save_thread; +std::vector save_times; +int reports = 0; +bool block_report = false, release_report = false; +bool fail_thread_start = false; + +void Check(bool value, std::source_location location = std::source_location::current()) +{ + if (!value) + throw std::runtime_error("worker fixture failed at line " + std::to_string(location.line())); +} +Outcome Save(TomlEditor&, const std::filesystem::path&, const Request& request) +{ + std::unique_lock lock(gate); + save_thread = std::this_thread::get_id(); + save_times.push_back(std::chrono::steady_clock::now()); + requests.push_back(request); + if (requests.size() == 1) { + entered = true; + changed.notify_all(); + if (!changed.wait_for(lock, 5s, [] { return released; })) + throw std::runtime_error("fixture timed out"); + return first_result; + } + return Outcome::Saved; +} +void Report(std::string_view, std::string_view, Outcome) +{ + std::unique_lock lock(gate); + ++reports; + changed.notify_all(); + if (block_report) + Check(changed.wait_for(lock, 5s, [] { return release_report; })); +} +void Begin(Outcome result) +{ + entered = released = false; + requests.clear(); + save_times.clear(); + reports = 0; + first_result = result; + block_report = release_report = false; +} +void AwaitSave() +{ + std::unique_lock lock(gate); + Check(changed.wait_for(lock, 5s, [] { return entered; })); +} +void Release() +{ + std::lock_guard lock(gate); + released = true; + changed.notify_all(); +} +void AwaitCompletion(RuntimeConfigWriter& writer, std::uint64_t revision) +{ + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (writer.LastCompletion().revision != revision || writer.HasWork()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } +} +template std::thread StartWorker(Function function, Owner* owner) +{ + if (std::exchange(fail_thread_start, false)) + throw std::runtime_error("fixture thread-start failure"); + return std::thread(function, owner); +} +} // namespace + +// Block at the real worker's save boundary to exercise scheduling deterministically. +#define CONFIG_EDIT_SAVE(editor, path, request) Save(editor, path, request) +#define CONFIG_EDIT_START_WORKER(...) StartWorker(__VA_ARGS__) +#include "../mods/src/runtime_config_writer.cc" + +int main() +{ + try { + for (auto outcome : {Outcome::Saved, Outcome::AlreadySaved, Outcome::Conflict, Outcome::IoError}) { + Begin(outcome); + RuntimeConfigWriter writer("unused", Value{std::string("none")}, Report); + Check(writer.Submit("invalid") == 0); + Check(writer.Submit("warp") == 1); + AwaitSave(); + Check(writer.HasWork()); + Check(writer.Submit("jump") == 2); + Check(writer.Submit("none") == 3); + writer.Stop(false); + Check(!writer.PollStopped()); + Check(writer.Submit("warp") == 0); + Release(); + auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(!writer.HasWork()); + Check(requests.size() == 2); + Check(requests[1].desired == Value{std::string("none")}); + const bool success = outcome == Outcome::Saved || outcome == Outcome::AlreadySaved; + Check(requests[1].expected == std::optional{std::string(success ? "warp" : "none")}); + Check(reports == (success ? 0 : 1)); + Check(save_thread != std::this_thread::get_id()); + Check(writer.LastCompletion().revision == 3); + Check(writer.LastCompletion().outcome == Outcome::Saved); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}); + writer.Submit("warp"); + AwaitSave(); + writer.Submit("jump"); + writer.Stop(false); // Force-close may arrive during an orderly drain. + writer.RequestCancelPending(); + Check(writer.Submit("none") == 0); + // Hold the force-close caller before Stop(true), while the active save + // completes. Publication alone must prevent the queued save from starting. + Release(); + auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.size() == 1); + Check(writer.LastCompletion().revision == 2); + Check(writer.LastCompletion().outcome == Outcome::Cancelled); + writer.Stop(true); + } + Begin(Outcome::IoError); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}, Report); + Check(writer.Register("graphics", "threshold", Value{0.5})); + const auto failed = writer.Submit("warp"); + AwaitSave(); + Release(); + AwaitCompletion(writer, failed); + Check(writer.HasFailures()); + AwaitCompletion(writer, writer.Submit("graphics", "threshold", 0.7)); + Check(writer.HasFailures()); // Saving B cannot hide A's failed save. + AwaitCompletion(writer, writer.Submit("jump")); + Check(!writer.HasFailures()); // A later successful save of A clears it. + Check(reports == 1); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}); + fail_thread_start = true; + Check(!writer.Submit("warp")); + Check(writer.HasFailure("ui", "auto_confirm_instant_warp") && writer.HasFailures() && !writer.HasWork()); + Check(!writer.HasFailure("other", "unregistered")); + const auto retry = writer.Submit("jump"); + AwaitSave(); + Release(); + AwaitCompletion(writer, retry); + Check(!writer.HasFailures() && !writer.HasFailure("ui", "auto_confirm_instant_warp")); + } + Begin(Outcome::IoError); + { + block_report = true; + RuntimeConfigWriter writer("unused", Value{std::string("none")}, Report); + writer.Submit("warp"); + AwaitSave(); + Release(); + { + std::unique_lock lock(gate); + Check(changed.wait_for(lock, 5s, [] { return reports == 1; })); + Check(writer.HasWork()); // Logging still executing must not look idle. + writer.Stop(false); + Check(!writer.PollStopped()); + release_report = true; + changed.notify_all(); + } + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}); + Check(writer.Register("graphics", "threshold", Value{0.5})); + Check(!writer.Submit("other", "unregistered", true)); + writer.Submit("warp"); + AwaitSave(); + Check(!writer.Register("graphics", "late", Value{true})); + writer.Submit("graphics", "threshold", 0.6, 150ms); + writer.Submit("jump"); + writer.Submit("graphics", "threshold", 0.7, 150ms); + writer.Stop(false); // Drain also flushes a slider whose delay has not expired. + Release(); + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.size() == 3); + Check(requests[1].key == "auto_confirm_instant_warp" && requests[1].desired == Value{std::string("jump")}); + Check(requests[1].expected == std::optional{std::string("warp")}); + Check(requests[2].key == "threshold" && requests[2].desired == Value{0.7}); + Check(requests[2].expected == std::optional{0.5}); + } + Begin(Outcome::Saved); + std::chrono::steady_clock::time_point initial_submitted, replacement_submitted; + { + RuntimeConfigWriter writer("unused", std::nullopt); + Check(writer.Register("graphics", "threshold", Value{0.5})); + initial_submitted = std::chrono::steady_clock::now(); + writer.Submit("graphics", "threshold", 0.6, 300ms); + { + std::unique_lock lock(gate); + changed.wait_for(lock, 75ms, [] { return entered; }); + } + replacement_submitted = std::chrono::steady_clock::now(); + const auto revision = writer.Submit("graphics", "threshold", 0.7, 300ms); + Release(); + AwaitCompletion(writer, revision); // Ordinary expiration, without Stop/quit flushing the delay. + } + // The test thread may resume after the first deadline on a busy runner. + // Validate actual entry times, not a negative assertion made by a late observer. + // Both an already-active first write and a coalesced replacement are valid; + // the gated test above independently requires queued same-key coalescing. + Check(requests.size() == 1 || requests.size() == 2); + Check(requests.back().desired == Value{0.7}); + if (requests.size() == 2) + Check(requests.front().desired == Value{0.6}); + for (std::size_t i = 0; i < requests.size(); ++i) { + const auto submitted = requests[i].desired == Value{0.6} ? initial_submitted : replacement_submitted; + Check(save_times[i] >= submitted + 300ms); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", std::nullopt); + Check(writer.Register("graphics", "threshold", Value{0.5})); + writer.Submit("graphics", "threshold", 0.9, 10s); + writer.Stop(true); // Cancellation must wake a delayed writer promptly. + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.empty()); + } + RuntimeConfigWriter idle("unused", std::nullopt); + idle.Stop(false); + Check(idle.PollStopped()); + std::cout << "Runtime writer coalescing/drain/cancel fixtures passed\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/toml_editor_test.cc b/tests/toml_editor_test.cc new file mode 100644 index 000000000..14a6f22ce --- /dev/null +++ b/tests/toml_editor_test.cc @@ -0,0 +1,116 @@ +#include "config_save.h" +#include "toml_editor.h" +#include +#include +#include +#include + +using namespace config_edit; +static Request Mode(std::optional expected, std::string desired) +{ return {"ui", "auto_confirm_instant_warp", std::move(expected), std::move(desired)}; } +int main(int argc, char** argv) +{ + assert(argc == 2); + TomlEditor editor; + const auto request = Mode(Value{std::string("none")}, "warp"); + for (const std::string original : + {"# header\n[ui] # section\nauto_confirm_instant_warp = 'none' # comment\nother = 9\n", + "\xef\xbb\xbf# BOM\r\n[ui]\r\nauto_confirm_instant_warp = 'none' # comment\r\n", + "ui = { other = '\xc3\xa9', auto_confirm_instant_warp = 'none' } # tail\n", + "ui.auto_confirm_instant_warp = '''none'''\n[elsewhere]\nx = nan\n", + "[\"ui\"]\n\"auto_confirm_instant_warp\" = \"\"\"none\"\"\""}) { + auto edit = editor.Prepare(original, request); + assert(edit.outcome == Outcome::Prepared); + const auto parsed = toml::parse(edit.text); + assert(parsed["ui"]["auto_confirm_instant_warp"].value() == "warp"); + // Only the value token changes; comments, surrounding bytes and line endings remain. + const auto old = original.find("'''none'''") != std::string::npos ? "'''none'''" + : original.find("\"\"\"none\"\"\"") != std::string::npos ? "\"\"\"none\"\"\"" + : "'none'"; + auto expected = original; + expected.replace(expected.find(old), std::string(old).size(), "\"warp\""); + assert(edit.text == expected); + } + for (const std::string original : + {"", "# comments only", "[ui]", "[ui]\nother = 4\n[other]\nx=3\n", "ui = {} # inline\n", "ui = {other = 4}\n", + "ui.other = 4\n", "[ui.child]\nx = 4\n"}) { + const auto edit = editor.Prepare(original, Mode(std::nullopt, "jump")); + assert(edit.outcome == Outcome::Prepared); + assert(toml::parse(edit.text)["ui"]["auto_confirm_instant_warp"].value() == "jump"); + } + const std::string special = "quotes \"'\\\n[ui]\nauto_confirm_instant_warp = 'jump'\n\xc3\xa9"; + const auto escaped = editor.Prepare("ui = {auto_confirm_instant_warp='none'}", Mode(request.expected, special)); + assert(escaped.outcome == Outcome::Prepared); + assert(toml::parse(escaped.text)["ui"]["auto_confirm_instant_warp"].value() == special); + assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='jump'", request).outcome == Outcome::Conflict); + assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='warp'", request).outcome == Outcome::AlreadySaved); + assert(editor.Prepare("ui = [", request).outcome == Outcome::InvalidDocument); + assert(editor.Prepare("ui = 3", request).outcome == Outcome::Unsupported); + assert(editor.Prepare("[ui]\nother=true", request).outcome == Outcome::Conflict); + // Decoded key identities may contain dots, Unicode and combining codepoints. + // Columns are parser coordinates, not UTF-8 byte counts or display widths. + const std::string unicode_section = "ui.\xc3\xa9"; + const std::string unicode_key = "e\xcc\x81.mode"; + const std::string unicode_document = + "[\"" + unicode_section + "\"]\r\n\"" + unicode_key + "\" = 'none' # untouched\r\n"; + const auto unicode_edit = + editor.Prepare(unicode_document, {unicode_section, unicode_key, Value{std::string("none")}, std::string("jump")}); + assert(unicode_edit.outcome == Outcome::Prepared); + auto unicode_expected = unicode_document; + unicode_expected.replace(unicode_expected.find("'none'"), 6, "\"jump\""); + assert(unicode_edit.text == unicode_expected); + const std::string combining = "ui = { other = 'e\xcc\x81', auto_confirm_instant_warp = 'none' }\n"; + auto combining_expected = combining; + combining_expected.replace(combining_expected.find("'none'"), 6, "\"warp\""); + assert(editor.Prepare(combining, request).text == combining_expected); + const Request boolean{"ui", "enabled", Value{false}, true}; + const auto numeric = + editor.Prepare("[graphics]\nthreshold = 0.5 # keep\n", {"graphics", "threshold", Value{0.5}, 0.75}); + assert(numeric.outcome == Outcome::Prepared && numeric.text == "[graphics]\nthreshold = 0.75 # keep\n"); + assert(editor.Prepare("[graphics]\nthreshold = 0.6\n", {"graphics", "threshold", Value{0.5}, 0.75}).outcome + == Outcome::Conflict); + const auto integer = + editor.Prepare("[graphics]\nthreshold = 0\n", {"graphics", "threshold", Value{std::int64_t{0}}, 0.5}); + assert(integer.outcome == Outcome::Prepared + && toml::parse(integer.text)["graphics"]["threshold"].value() == 0.5); + for (const auto desired : {std::numeric_limits::min(), std::numeric_limits::max()}) { + const auto whole = + editor.Prepare("[graphics]\ncount = 0 # keep\n", {"graphics", "count", Value{std::int64_t{0}}, desired}); + assert(whole.outcome == Outcome::Prepared && whole.text.ends_with(" # keep\n")); + assert(toml::parse(whole.text)["graphics"]["count"].value() == desired); + } + for (const auto desired : {std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + const auto invalid = + editor.Prepare("[graphics]\nthreshold = 0.5 # keep\n", {"graphics", "threshold", Value{0.5}, desired}); + assert(invalid.outcome == Outcome::Unsupported && invalid.text.empty()); + } + assert(editor.Prepare("[ui]\nenabled = false # keep\n", boolean).text == "[ui]\nenabled = true # keep\n"); + assert(editor.Prepare("[ui]\nenabled = true", boolean).outcome == Outcome::AlreadySaved); + assert(editor.Prepare("[ui]\nenabled = 'false'", boolean).outcome == Outcome::Conflict); + const std::string escaped_key = "a.\"b\\c"; + for (const std::string document : {"[ui]\n", "ui = {}\n"}) { + const auto inserted = editor.Prepare(document, {"ui", escaped_key, std::nullopt, true}); + assert(inserted.outcome == Outcome::Prepared); + assert(toml::parse(inserted.text)["ui"][escaped_key].value() == true); + } + + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string initial = "[ui]\nauto_confirm_instant_warp='none'\nother=true # preserve\n"; + ReplaceConfigText(path, initial); + const auto external = initial + "# external comment\n"; + ReplaceConfigText(path, external); + assert(editor.Save(path, request) == Outcome::Saved); + assert(ReadConfigText(path).ends_with("# external comment\n")); + ReplaceConfigText(path, "[ui]\nauto_confirm_instant_warp='jump'\n"); + assert(editor.Save(path, request) == Outcome::Conflict); + assert(!ReplaceConfigText(path, initial, external)); + assert(toml::parse(ReadConfigText(path))["ui"]["auto_confirm_instant_warp"].value() == "jump"); + const auto absent = root / "absent.toml"; + assert(!std::filesystem::exists(absent)); + assert(editor.Save(absent, request) == Outcome::IoError); + assert(!std::filesystem::exists(absent)); + std::cout << "TOML editor preservation/conflict fixtures passed\n"; +}