From c3aa4bce47783b439cc6f5711020fe8215fa5980 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:13:51 -0500 Subject: [PATCH 01/16] Check startup config output before replacing files --- docs/config-save.md | 33 ++++++++++++++ mods/src/config.cc | 20 ++++++--- mods/src/config_save.cc | 91 +++++++++++++++++++++++++++++++++++++++ mods/src/config_save.h | 9 ++++ tests/config_save_test.cc | 64 +++++++++++++++++++++++++++ tests/run-config-save.ps1 | 25 +++++++++++ 6 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 docs/config-save.md create mode 100644 mods/src/config_save.cc create mode 100644 mods/src/config_save.h create mode 100644 tests/config_save_test.cc create mode 100644 tests/run-config-save.ps1 diff --git a/docs/config-save.md b/docs/config-save.md new file mode 100644 index 000000000..7b129ca6f --- /dev/null +++ b/docs/config-save.md @@ -0,0 +1,33 @@ +# Startup config saves + +`Config::Save` writes complete TOML documents for two startup callers: the initial +default config and the generated runtime snapshot. It keeps `File::MakePath` +routing and the existing generated-file warning. Save errors are logged once by +the caller; startup continues with the in-memory configuration. + +`SaveConfigDocument` serializes with toml++, parses the output before touching +disk, exclusively creates a sibling temporary file, checks writing and closing, +and replaces the destination. No threads, frame callbacks, runtime controls or +shutdown interception are installed. This is not the preserving TOML editor: +whole-document saves do not merge concurrent setting changes or preserve comments. + +Windows uses `ReplaceFileW` to preserve existing permissions and streams, with a +temporary backup for its documented partial-failure cases. Initial creation uses +a non-replacing move. Ordinary failures clean up the temporary file; partial +replacement failures retain recovery files and report their location. The backup +name is the reported temporary path plus `.bak`. Recovery is not automatic. +macOS uses rename after copying the existing permission bits. Extended metadata +and hard-link identity are not preserved by that path. Existing symlinks are +resolved before staging. Replacement requires directory permissions in addition +to any file access checks; it cannot exactly match an in-place overwrite. + +Successful close/replacement is not a guarantee against power loss. A forced exit +can leave a temporary file. No automatic stale-file sweep is installed. + +Run the isolated Windows fixtures with `tests/run-config-save.ps1` after the +normal AX build has installed toml++; `-TomlInclude` can select another include +directory. Fixtures never access the installed game's files. + +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) diff --git a/mods/src/config.cc b/mods/src/config.cc index 25086345e..04f4db9f7 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1,4 +1,5 @@ #include "config.h" +#include "config_save.h" #include "file.h" #include "patches/mapkey.h" #include "prime/KeyCode.h" @@ -93,10 +94,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 +119,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() @@ -1419,7 +1419,11 @@ 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); + } catch (const std::exception& error) { + spdlog::error("Could not save default config: {}", error.what()); + } } message.str(""); @@ -1434,7 +1438,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_save.cc b/mods/src/config_save.cc new file mode 100644 index 000000000..59240819b --- /dev/null +++ b/mods/src/config_save.cc @@ -0,0 +1,91 @@ +#include "config_save.h" + +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#endif + +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(); + (void)toml::parse(bytes); + + // Follow existing symlinks as the former ofstream save did. A sibling stays on + // the same filesystem. Exclusive creation avoids truncating another save's file. + const auto destination = std::filesystem::weakly_canonical(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)); + + // C11 exclusive creation avoids depending on newer libc++ fstream runtime + // support on our minimum supported macOS version. +#if _WIN32 + std::FILE* file = nullptr; + _wfopen_s(&file, temporary.c_str(), L"wbx"); +#else + auto* file = std::fopen(temporary.c_str(), "wbx"); +#endif + if (!file) { + throw std::system_error(errno, std::generic_category(), "could not create temporary config file"); + } + + bool replacing = false; + try { + if (std::fwrite(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 = std::fclose(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"); + } +#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) { + // Initial creation must not replace a config created in the meantime. + 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; + } +} diff --git a/mods/src/config_save.h b/mods/src/config_save.h new file mode 100644 index 000000000..2150f5225 --- /dev/null +++ b/mods/src/config_save.h @@ -0,0 +1,9 @@ +#pragma once + +#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 = {}); diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc new file mode 100644 index 000000000..b25a3b5d5 --- /dev/null +++ b/tests/config_save_test.cc @@ -0,0 +1,64 @@ +#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); +#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..f41c44ffd --- /dev/null +++ b/tests/run-config-save.ps1 @@ -0,0 +1,25 @@ +[CmdletBinding()] +param([string]$TomlInclude) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +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 $fixtureRoot + if ($LASTEXITCODE -ne 0) { throw 'Config save regression failed.' } +} finally { + Pop-Location +} From 3ba601e19f05dde0d113fdd47210c19606b9c5d2 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:17:20 -0500 Subject: [PATCH 02/16] Exercise startup save failures and permission retention --- docs/config-save.md | 5 +-- mods/src/config_save.cc | 15 ++++++-- tests/config_save_failure_test.cc | 60 +++++++++++++++++++++++++++++++ tests/run-config-save.ps1 | 20 +++++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/config_save_failure_test.cc diff --git a/docs/config-save.md b/docs/config-save.md index 7b129ca6f..357160b0d 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -12,8 +12,9 @@ shutdown interception are installed. This is not the preserving TOML editor: whole-document saves do not merge concurrent setting changes or preserve comments. Windows uses `ReplaceFileW` to preserve existing permissions and streams, with a -temporary backup for its documented partial-failure cases. Initial creation uses -a non-replacing move. Ordinary failures clean up the temporary file; partial +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 diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc index 59240819b..9e9a82b6d 100644 --- a/mods/src/config_save.cc +++ b/mods/src/config_save.cc @@ -11,6 +11,14 @@ #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 + 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++, @@ -43,10 +51,10 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& bool replacing = false; try { - if (std::fwrite(bytes.data(), 1, bytes.size(), file) != bytes.size()) { + 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 = std::fclose(file); // Includes flushing; failure prevents replacement. + 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"); @@ -59,7 +67,8 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& if (!ReplaceFileW(destination.c_str(), temporary.c_str(), backup.c_str(), 0, nullptr, nullptr)) { auto error = GetLastError(); if (error == ERROR_FILE_NOT_FOUND) { - // Initial creation must not replace a config created in the meantime. + // 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) { diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc new file mode 100644 index 000000000..8bc083c3c --- /dev/null +++ b/tests/config_save_failure_test.cc @@ -0,0 +1,60 @@ +#include +#include + +static bool failClose = false; + +static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t count, std::FILE* file) +{ + if (failClose) { + return std::fwrite(data, size, count, file); + } + const auto written = std::fwrite(data, size, count / 2, file); + errno = ENOSPC; + return written; +} + +static int FailedClose(std::FILE* file) +{ + std::fclose(file); + 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"; + const std::string original = "# keep this exactly\nenabled = false\n"; + { + std::ofstream out(path, std::ios::binary); + out << original; + } + 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); + } + } + std::cout << "Short-write and failed-close fixtures passed\n"; +} diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 index f41c44ffd..bdb7aa29b 100644 --- a/tests/run-config-save.ps1 +++ b/tests/run-config-save.ps1 @@ -20,6 +20,26 @@ try { $fixtureRoot = Join-Path $repoRoot ('build/config-save-test/' + [guid]::NewGuid()) & ./build/config-save-test/test.exe $fixtureRoot if ($LASTEXITCODE -ne 0) { throw 'Config save regression failed.' } + $testFile = Join-Path $fixtureRoot 'settings.toml' + $inherited = (Get-Acl -LiteralPath $testFile).Sddl + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-Acl -LiteralPath $testFile).Sddl -ne $inherited) { + throw 'Inherited ACL regression failed.' + } + $acl = Get-Acl -LiteralPath $testFile + $acl.SetAccessRuleProtection($true, $true) + Set-Acl -LiteralPath $testFile -AclObject $acl + $explicit = (Get-Acl -LiteralPath $testFile).Sddl + & ./build/config-save-test/test.exe $fixtureRoot + if ($LASTEXITCODE -ne 0 -or (Get-Acl -LiteralPath $testFile).Sddl -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.' } } finally { Pop-Location } From a8ed7bc77b96dcf77ea90fa3cf6678e9e96c06c8 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:19:38 -0500 Subject: [PATCH 03/16] Capture inherited permissions before the first save --- tests/run-config-save.ps1 | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 index bdb7aa29b..1fbcfada1 100644 --- a/tests/run-config-save.ps1 +++ b/tests/run-config-save.ps1 @@ -3,6 +3,18 @@ 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) { @@ -18,20 +30,24 @@ try { /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 $fixtureRoot - if ($LASTEXITCODE -ne 0) { throw 'Config save regression failed.' } + # Establish the baseline independently, before the first production save. + New-Item -ItemType Directory -Path $fixtureRoot | Out-Null $testFile = Join-Path $fixtureRoot 'settings.toml' - $inherited = (Get-Acl -LiteralPath $testFile).Sddl + 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-Acl -LiteralPath $testFile).Sddl -ne $inherited) { + 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-Acl -LiteralPath $testFile).Sddl + $explicit = Get-PermissionState $testFile & ./build/config-save-test/test.exe $fixtureRoot - if ($LASTEXITCODE -ne 0 -or (Get-Acl -LiteralPath $testFile).Sddl -ne $explicit) { + 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" ` From 4d1a476962fee61a0be2929ba7baef5d7a35adc0 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:20:05 -0500 Subject: [PATCH 04/16] Retain missing-file creation coverage alongside ACL fixtures --- tests/run-config-save.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 index 1fbcfada1..6c80cd0e5 100644 --- a/tests/run-config-save.ps1 +++ b/tests/run-config-save.ps1 @@ -30,6 +30,9 @@ try { /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' From eb56fa0b430b5e010e28975664c7cf9b0398f384 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:28:02 -0500 Subject: [PATCH 05/16] Run config-save fixtures on native Windows and macOS CI --- .github/workflows/ci.yaml | 20 ++++++++++++++++++++ docs/config-save.md | 5 +++++ tests/config_save_test.cc | 10 ++++++++++ tests/run-config-save.sh | 15 +++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 tests/run-config-save.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c4db85207..de14c8611 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -196,6 +196,16 @@ jobs: shell: pwsh run: sccache --show-stats + - name: Test startup config saves + shell: pwsh + env: + PACKAGE_DIR: ${{ steps.xmake_cache_paths.outputs.package_dir }} + run: | + $header = Get-ChildItem -LiteralPath (Join-Path $env:PACKAGE_DIR 't/toml++') -Recurse -Filter toml.h | + Where-Object { $_.Directory.Name -eq 'toml++' } | Select-Object -First 1 + if (-not $header) { throw 'Built toml++ package not found.' } + ./tests/run-config-save.ps1 -TomlInclude $header.Directory.Parent.FullName + - name: Package shell: pwsh run: | @@ -477,6 +487,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 index 357160b0d..2bc9367ec 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -29,6 +29,11 @@ Run the isolated Windows fixtures with `tests/run-config-save.ps1` after the normal AX build has installed toml++; `-TomlInclude` can select another include directory. Fixtures never access the installed game's files. +On macOS, run `bash tests/run-config-save.sh TOML_INCLUDE_DIR`. Both native macOS +CI jobs run these fixtures after the normal build, including permission-bit and +symlink checks. The failure fixture injects short writes and failed closes at +compile time; it does not install test controls in the mod. + Native behavior references: - [Windows ReplaceFileW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew) - [POSIX rename](https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html) diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc index b25a3b5d5..c34c7c409 100644 --- a/tests/config_save_test.cc +++ b/tests/config_save_test.cc @@ -56,6 +56,16 @@ int main(int argc, char** argv) 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); #endif for (const auto& entry : std::filesystem::directory_iterator(root)) { assert(entry.path().filename().string().find(".tmp-") == std::string::npos); diff --git a/tests/run-config-save.sh b/tests/run-config-save.sh new file mode 100644 index 000000000..c785669f2 --- /dev/null +++ b/tests/run-config-save.sh @@ -0,0 +1,15 @@ +#!/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" From fd15fb53245172579e570af433c892074434d1e5 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 20:44:10 -0500 Subject: [PATCH 06/16] Add shared IL2CPP runtime boundary helpers with contract tests --- .github/workflows/ci.yaml | 12 +++ mods/src/il2cpp/il2cpp_helper.h | 6 +- mods/src/il2cpp/runtime.h | 59 +++++++++++++++ tests/il2cpp_runtime.cc | 127 ++++++++++++++++++++++++++++++++ xmake.lua | 11 +++ 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 mods/src/il2cpp/runtime.h create mode 100644 tests/il2cpp_runtime.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 61cf42826..ff6de6ff7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -189,6 +189,13 @@ jobs: shell: pwsh run: sccache --zero-stats + - name: Test IL2CPP runtime helpers + run: | + xmake build -y il2cpp-runtime-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run il2cpp-runtime-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build run: xmake build -y stfc-community-mod @@ -469,6 +476,11 @@ jobs: shell: bash run: sccache --zero-stats + - name: Test IL2CPP runtime helpers + run: | + xmake build -y il2cpp-runtime-tests + xmake run il2cpp-runtime-tests + - name: Build # -D keeps the existing XMake compiler-cache diagnostics too. run: xmake -y -D diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 3d4ff6e3f..19708b2cd 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); - auto image = il2cpp_assembly_get_image(assemblyT); + auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; + auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; - auto cls = il2cpp_class_from_name(image, namespacez, name); + auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; return IL2CppClassHelper{cls}; } diff --git a/mods/src/il2cpp/runtime.h b/mods/src/il2cpp/runtime.h new file mode 100644 index 000000000..8617cedb1 --- /dev/null +++ b/mods/src/il2cpp/runtime.h @@ -0,0 +1,59 @@ +#pragma once + +#include "il2cpp_helper.h" +#include + +// Mechanics shared by optional runtime features. Signature/overload selection, +// argument storage and feature-specific failure policy remain with the caller. +namespace Il2CppRuntime +{ +inline Il2CppClass* Class(const char* assembly, const char* ns, const char* name) +{ return il2cpp_get_class_helper(assembly, ns, name).get_cls(); } + +inline const MethodInfo* Method(Il2CppClass* cls, const char* name, int count) +{ return IL2CppClassHelper(cls).GetMethodInfo(name, count); } + +inline bool Type(const Il2CppType* type, int expected) +{ return type && !type->byref && type->type == expected; } + +inline bool Reference(const Il2CppType* type) +{ + return Type(type, IL2CPP_TYPE_CLASS) || Type(type, IL2CPP_TYPE_GENERICINST) || Type(type, IL2CPP_TYPE_OBJECT) + || Type(type, IL2CPP_TYPE_STRING); +} + +inline bool Instance(const MethodInfo* method, int count, int result) +{ + return method && method->methodPointer && method->invoker_method && !(method->flags & METHOD_ATTRIBUTE_STATIC) + && method->parameters_count == count && Type(method->return_type, result) + && !method->has_full_generic_sharing_signature; +} + +// The method and argument ABI must already be established by the caller. +// IL2CPP takes references directly, but value/byref arguments as addresses. +// A null target is valid for static methods. Success is independent of whether +// the return value is null (including void methods). Outputs change on success only. +inline bool TryInvoke(const MethodInfo* method, void* target, void** args, Il2CppObject** result = nullptr) +{ + if (!method) + return false; + Il2CppException* exception = nullptr; + auto* value = il2cpp_runtime_invoke(method, target, args, &exception); + if (exception) + return false; + if (result) + *result = value; + return true; +} + +inline bool TryBoolean(Il2CppObject* boxed, bool& value) +{ + if (!boxed || !boxed->klass || !Type(il2cpp_class_get_type(boxed->klass), IL2CPP_TYPE_BOOLEAN)) + return false; + auto* data = static_cast(il2cpp_object_unbox(boxed)); + if (!data) + return false; + value = *data; + return true; +} +} // namespace Il2CppRuntime diff --git a/tests/il2cpp_runtime.cc b/tests/il2cpp_runtime.cc new file mode 100644 index 000000000..1b7113340 --- /dev/null +++ b/tests/il2cpp_runtime.cc @@ -0,0 +1,127 @@ +// Production helpers with a controlled IL2CPP boundary, on Windows and macOS. +#include +#if _WIN32 +#undef IL2CPP_IMPORT +#define IL2CPP_IMPORT +#endif +#include "il2cpp/runtime.h" +#include +#include +#include + +namespace +{ +Il2CppClass klass; +Il2CppType type{}; +Il2CppObject object{}; +MethodInfo method{}; +int stage = 3, calls = 0; +bool fail = false, boxed = false, null_result = false; +void* seen_target = nullptr; +void** seen_args = nullptr; +} // namespace +#if _WIN32 +#define API(ret, name, params) extern "C" ret name params +#define END_API +#else +#define API(ret, name, params) name##_t name = +[] params->ret +#define END_API ; +#endif +API(Il2CppDomain*, il2cpp_domain_get, ()) +{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } +END_API +API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) +{ + if (!domain) + std::abort(); + return stage >= 1 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) +{ + if (!assembly) + std::abort(); + return stage >= 2 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) +{ + if (!image) + std::abort(); + return stage >= 3 ? &klass : nullptr; +} +END_API +API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) +{ + if (!cls) + std::abort(); + return &method; +} +END_API +API(const Il2CppType*, il2cpp_class_get_type, (Il2CppClass*)) +{ return &type; } +END_API +API(void*, il2cpp_object_unbox, (Il2CppObject*)) +{ return &boxed; } +END_API +API(Il2CppObject*, il2cpp_runtime_invoke, (const MethodInfo*, void* target, void** args, Il2CppException** error)) +{ + ++calls; + seen_target = target; + seen_args = args; + if (fail) + *error = reinterpret_cast(&object); + return null_result ? nullptr : &object; +} +END_API +void Require(bool condition) +{ + if (!condition) + throw std::runtime_error("runtime helper regression"); +} +int main() +{ + for (stage = -1; stage < 3; ++stage) + Require(!Il2CppRuntime::Class("Assembly", "Namespace", "Class")); + Require(Il2CppRuntime::Class("Assembly", "Namespace", "Class") == &klass); + Require(!Il2CppRuntime::Method(nullptr, "Method", 0)); + Require(Il2CppRuntime::Method(&klass, "Method", 0) == &method); + + Il2CppObject* result = &object; + Require(!Il2CppRuntime::TryInvoke(nullptr, nullptr, nullptr, &result) && calls == 0 && result == &object); + bool value = true; + void* args[] = {&value, &object, nullptr}; + Require(Il2CppRuntime::TryInvoke(&method, &object, args, &result)); + Require(seen_target == &object && seen_args == args && seen_args[0] == &value && seen_args[1] == &object); + // Static calls, null reference/void returns and exceptions remain distinct. + null_result = true; + Require(Il2CppRuntime::TryInvoke(&method, nullptr, args, &result) && !result && !seen_target); + result = &object; + fail = true; + Require(!Il2CppRuntime::TryInvoke(&method, nullptr, args, &result) && result == &object); + + object.klass = &klass; + type.type = IL2CPP_TYPE_BOOLEAN; + Require(Il2CppRuntime::TryBoolean(&object, value) && !value); + boxed = true; + Require(Il2CppRuntime::TryBoolean(&object, value) && value); + type.type = IL2CPP_TYPE_I4; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + Require(!Il2CppRuntime::TryBoolean(nullptr, value)); + type.type = IL2CPP_TYPE_CLASS; + Require(Il2CppRuntime::Reference(&type)); + type.byref = true; + Require(!Il2CppRuntime::Reference(&type)); + type.byref = false; + type.type = IL2CPP_TYPE_VOID; + method.methodPointer = reinterpret_cast(1); + method.invoker_method = reinterpret_cast(1); + method.return_type = &type; + Require(Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + method.flags = METHOD_ATTRIBUTE_STATIC; + Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + method.flags = 0; + method.has_full_generic_sharing_signature = true; + Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + std::cout << "IL2CPP runtime helper regressions passed\n"; +} diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..ac08005a2 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,14 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +target("il2cpp-runtime-tests") + set_kind("binary") + set_default(false) + add_files("tests/il2cpp_runtime.cc") + add_includedirs("mods/src") + add_packages("libil2cpp", "eastl") + set_exceptions("cxx") + if is_plat("windows") then + add_linkdirs("mods/src/il2cpp") + end From 733997cfe2cd34c9203b61cff755046ca9937643 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 22:27:13 -0500 Subject: [PATCH 07/16] Narrow shared invocation helpers around existing loading screens --- mods/src/il2cpp/il2cpp_helper.h | 6 +- mods/src/il2cpp/runtime.h | 27 ++----- .../src/patches/parts/loading_screen_common.h | 16 ++--- tests/il2cpp_runtime.cc | 70 ++++--------------- 4 files changed, 28 insertions(+), 91 deletions(-) diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 19708b2cd..3d4ff6e3f 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; - auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; + auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); + auto image = il2cpp_assembly_get_image(assemblyT); - auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; + auto cls = il2cpp_class_from_name(image, namespacez, name); return IL2CppClassHelper{cls}; } diff --git a/mods/src/il2cpp/runtime.h b/mods/src/il2cpp/runtime.h index 8617cedb1..b3043673e 100644 --- a/mods/src/il2cpp/runtime.h +++ b/mods/src/il2cpp/runtime.h @@ -7,28 +7,6 @@ // argument storage and feature-specific failure policy remain with the caller. namespace Il2CppRuntime { -inline Il2CppClass* Class(const char* assembly, const char* ns, const char* name) -{ return il2cpp_get_class_helper(assembly, ns, name).get_cls(); } - -inline const MethodInfo* Method(Il2CppClass* cls, const char* name, int count) -{ return IL2CppClassHelper(cls).GetMethodInfo(name, count); } - -inline bool Type(const Il2CppType* type, int expected) -{ return type && !type->byref && type->type == expected; } - -inline bool Reference(const Il2CppType* type) -{ - return Type(type, IL2CPP_TYPE_CLASS) || Type(type, IL2CPP_TYPE_GENERICINST) || Type(type, IL2CPP_TYPE_OBJECT) - || Type(type, IL2CPP_TYPE_STRING); -} - -inline bool Instance(const MethodInfo* method, int count, int result) -{ - return method && method->methodPointer && method->invoker_method && !(method->flags & METHOD_ATTRIBUTE_STATIC) - && method->parameters_count == count && Type(method->return_type, result) - && !method->has_full_generic_sharing_signature; -} - // The method and argument ABI must already be established by the caller. // IL2CPP takes references directly, but value/byref arguments as addresses. // A null target is valid for static methods. Success is independent of whether @@ -48,7 +26,10 @@ inline bool TryInvoke(const MethodInfo* method, void* target, void** args, Il2Cp inline bool TryBoolean(Il2CppObject* boxed, bool& value) { - if (!boxed || !boxed->klass || !Type(il2cpp_class_get_type(boxed->klass), IL2CPP_TYPE_BOOLEAN)) + if (!boxed || !boxed->klass) + return false; + const auto* type = il2cpp_class_get_type(boxed->klass); + if (!type || type->byref || type->type != IL2CPP_TYPE_BOOLEAN) return false; auto* data = static_cast(il2cpp_object_unbox(boxed)); if (!data) diff --git a/mods/src/patches/parts/loading_screen_common.h b/mods/src/patches/parts/loading_screen_common.h index 071b63976..4f78890c7 100644 --- a/mods/src/patches/parts/loading_screen_common.h +++ b/mods/src/patches/parts/loading_screen_common.h @@ -3,7 +3,7 @@ #include "config.h" #include "errormsg.h" -#include +#include #include #include @@ -28,9 +28,8 @@ struct FakeColor { float r, g, b, a; }; inline Il2CppObject* InvokeRuntime(const MethodInfo* method, void* target, void** args, const char* name) { if (!method) return nullptr; - Il2CppException* exception = nullptr; - Il2CppObject* result = il2cpp_runtime_invoke(method, target, args, &exception); - if (exception) { + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, target, args, &result)) { spdlog::warn("[LS] {} invocation failed", name); return nullptr; } @@ -40,9 +39,7 @@ inline Il2CppObject* InvokeRuntime(const MethodInfo* method, void* target, void* inline bool InvokeVoid(const MethodInfo* method, void* target, void** args, const char* name) { if (!method) return false; - Il2CppException* exception = nullptr; - il2cpp_runtime_invoke(method, target, args, &exception); - if (exception) { + if (!Il2CppRuntime::TryInvoke(method, target, args)) { spdlog::warn("[LS] {} invocation failed", name); return false; } @@ -52,9 +49,8 @@ inline bool InvokeVoid(const MethodInfo* method, void* target, void** args, cons inline bool InvokeBool(const MethodInfo* method, void* target, void** args, const char* name) { Il2CppObject* result = InvokeRuntime(method, target, args, name); - if (!result) return false; - void* value = il2cpp_object_unbox(result); - return value ? *reinterpret_cast(value) : false; + bool value = false; + return Il2CppRuntime::TryBoolean(result, value) && value; } inline int32_t InvokeInt32(const MethodInfo* method, void* target, int32_t fallback, const char* name) diff --git a/tests/il2cpp_runtime.cc b/tests/il2cpp_runtime.cc index 1b7113340..b098a2bbc 100644 --- a/tests/il2cpp_runtime.cc +++ b/tests/il2cpp_runtime.cc @@ -15,8 +15,8 @@ Il2CppClass klass; Il2CppType type{}; Il2CppObject object{}; MethodInfo method{}; -int stage = 3, calls = 0; -bool fail = false, boxed = false, null_result = false; +int calls = 0; +bool fail = false, boxed = false, null_result = false, null_type = false, null_unbox = false; void* seen_target = nullptr; void** seen_args = nullptr; } // namespace @@ -27,42 +27,11 @@ void** seen_args = nullptr; #define API(ret, name, params) name##_t name = +[] params->ret #define END_API ; #endif -API(Il2CppDomain*, il2cpp_domain_get, ()) -{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } -END_API -API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) -{ - if (!domain) - std::abort(); - return stage >= 1 ? reinterpret_cast(&object) : nullptr; -} -END_API -API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) -{ - if (!assembly) - std::abort(); - return stage >= 2 ? reinterpret_cast(&object) : nullptr; -} -END_API -API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) -{ - if (!image) - std::abort(); - return stage >= 3 ? &klass : nullptr; -} -END_API -API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) -{ - if (!cls) - std::abort(); - return &method; -} -END_API API(const Il2CppType*, il2cpp_class_get_type, (Il2CppClass*)) -{ return &type; } +{ return null_type ? nullptr : &type; } END_API API(void*, il2cpp_object_unbox, (Il2CppObject*)) -{ return &boxed; } +{ return null_unbox ? nullptr : &boxed; } END_API API(Il2CppObject*, il2cpp_runtime_invoke, (const MethodInfo*, void* target, void** args, Il2CppException** error)) { @@ -81,12 +50,6 @@ void Require(bool condition) } int main() { - for (stage = -1; stage < 3; ++stage) - Require(!Il2CppRuntime::Class("Assembly", "Namespace", "Class")); - Require(Il2CppRuntime::Class("Assembly", "Namespace", "Class") == &klass); - Require(!Il2CppRuntime::Method(nullptr, "Method", 0)); - Require(Il2CppRuntime::Method(&klass, "Method", 0) == &method); - Il2CppObject* result = &object; Require(!Il2CppRuntime::TryInvoke(nullptr, nullptr, nullptr, &result) && calls == 0 && result == &object); bool value = true; @@ -108,20 +71,17 @@ int main() type.type = IL2CPP_TYPE_I4; Require(!Il2CppRuntime::TryBoolean(&object, value) && value); Require(!Il2CppRuntime::TryBoolean(nullptr, value)); - type.type = IL2CPP_TYPE_CLASS; - Require(Il2CppRuntime::Reference(&type)); + type.type = IL2CPP_TYPE_BOOLEAN; type.byref = true; - Require(!Il2CppRuntime::Reference(&type)); - type.byref = false; - type.type = IL2CPP_TYPE_VOID; - method.methodPointer = reinterpret_cast(1); - method.invoker_method = reinterpret_cast(1); - method.return_type = &type; - Require(Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); - method.flags = METHOD_ATTRIBUTE_STATIC; - Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); - method.flags = 0; - method.has_full_generic_sharing_signature = true; - Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + type.byref = false; + null_type = true; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + null_type = false; + null_unbox = true; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + null_unbox = false; + object.klass = nullptr; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); std::cout << "IL2CPP runtime helper regressions passed\n"; } From d6c10b7d0d0988d4438c488cfa9354524d820106 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 22:28:55 -0500 Subject: [PATCH 08/16] Guard existing IL2CPP class lookup when metadata is unavailable --- .github/workflows/ci.yaml | 12 ++++++ mods/src/il2cpp/il2cpp_helper.h | 6 +-- tests/il2cpp_class_lookup.cc | 71 +++++++++++++++++++++++++++++++++ xmake.lua | 11 +++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/il2cpp_class_lookup.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 61cf42826..025c4356b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -189,6 +189,13 @@ jobs: shell: pwsh run: sccache --zero-stats + - name: Test IL2CPP class lookup + run: | + xmake build -y il2cpp-class-lookup-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run il2cpp-class-lookup-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build run: xmake build -y stfc-community-mod @@ -469,6 +476,11 @@ jobs: shell: bash run: sccache --zero-stats + - name: Test IL2CPP class lookup + run: | + xmake build -y il2cpp-class-lookup-tests + xmake run il2cpp-class-lookup-tests + - name: Build # -D keeps the existing XMake compiler-cache diagnostics too. run: xmake -y -D diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 3d4ff6e3f..19708b2cd 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); - auto image = il2cpp_assembly_get_image(assemblyT); + auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; + auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; - auto cls = il2cpp_class_from_name(image, namespacez, name); + auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; return IL2CppClassHelper{cls}; } diff --git a/tests/il2cpp_class_lookup.cc b/tests/il2cpp_class_lookup.cc new file mode 100644 index 000000000..0d471ea00 --- /dev/null +++ b/tests/il2cpp_class_lookup.cc @@ -0,0 +1,71 @@ +// Production helpers with a controlled IL2CPP boundary, on Windows and macOS. +#include +#if _WIN32 +#undef IL2CPP_IMPORT +#define IL2CPP_IMPORT +#endif +#include "il2cpp/il2cpp_helper.h" +#include +#include +#include + +namespace +{ +Il2CppClass klass; +Il2CppObject object{}; +MethodInfo method{}; +int stage = 3; +} // namespace +#if _WIN32 +#define API(ret, name, params) extern "C" ret name params +#define END_API +#else +#define API(ret, name, params) name##_t name = +[] params->ret +#define END_API ; +#endif +API(Il2CppDomain*, il2cpp_domain_get, ()) +{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } +END_API +API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) +{ + if (!domain) + std::abort(); + return stage >= 1 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) +{ + if (!assembly) + std::abort(); + return stage >= 2 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) +{ + if (!image) + std::abort(); + return stage >= 3 ? &klass : nullptr; +} +END_API +API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) +{ + if (!cls) + std::abort(); + return &method; +} +END_API +void Require(bool condition) +{ + if (!condition) + throw std::runtime_error("class lookup regression"); +} +int main() +{ + for (stage = -1; stage < 3; ++stage) + Require(!il2cpp_get_class_helper("Assembly", "Namespace", "Class").get_cls()); + Require(il2cpp_get_class_helper("Assembly", "Namespace", "Class").get_cls() == &klass); + Require(!IL2CppClassHelper(nullptr).GetMethodInfo("Method", 0)); + Require(IL2CppClassHelper(&klass).GetMethodInfo("Method", 0) == &method); + + std::cout << "IL2CPP class lookup regressions passed\n"; +} diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..cfbefafff 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,14 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +target("il2cpp-class-lookup-tests") + set_kind("binary") + set_default(false) + add_files("tests/il2cpp_class_lookup.cc") + add_includedirs("mods/src") + add_packages("libil2cpp", "eastl") + set_exceptions("cxx") + if is_plat("windows") then + add_linkdirs("mods/src/il2cpp") + end From 7b3c25e8b22a5e18b142acaeeb4f911596d67c14 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 27 Sep 2026 22:42:12 -0500 Subject: [PATCH 09/16] Complete localized galaxy label setting examples --- example_community_patch_settings_da.toml | 16 ++++++++++++++++ ...community_patch_settings_en-GB-x-cockney.toml | 16 ++++++++++++++++ ..._community_patch_settings_en-x-minionese.toml | 16 ++++++++++++++++ example_community_patch_settings_es.toml | 16 ++++++++++++++++ example_community_patch_settings_ru.toml | 16 ++++++++++++++++ example_community_patch_settings_tlh.toml | 16 ++++++++++++++++ 6 files changed, 96 insertions(+) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 95e1e0008..c4ce2b545 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -159,6 +159,22 @@ zoom_label_player_detail = "native" # Normalized zoom crossover used when player fleet label detail is "threshold"; 0 is always compact and 1 is always expanded zoom_label_player_threshold = 0.5 +# Galakseetiketter: native, always eller threshold. Always kan øge indlæsningstiden. +# Store og små systemer følger spillets klassifikation; tærskel: nær=0, fjern=1. +# 0 bruger kompakt visning; 1 udvider altid; lighed udvider. +# Kompakt bevarer spillets Far-visning, også for visse store navne. +# Kombiner Standard, Fjender, Minedrift og Farer. Standardværdierne bevarer spillets visning. +# Windows x64 og macOS på validerede klienter. +galaxy_multi_select = false +galaxy_overlay_default = true +galaxy_overlay_mining = false +galaxy_overlay_hostiles = false +galaxy_overlay_hazards = false +galaxy_label_major_detail = "native" +galaxy_label_major_threshold = 0.5 +galaxy_label_minor_detail = "native" +galaxy_label_minor_threshold = 0.5 + # Subgroup: Keyboard # ------------------ diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 82a8a53c5..3cb8d95d4 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -159,6 +159,22 @@ zoom_label_player_detail = "native" # Normalized zoom crossover used when player fleet label detail is "threshold"; 0 is always compact and 1 is always expanded zoom_label_player_threshold = 0.5 +# Galaxy labels: native, always, threshold. Always may increase loading time. +# Major/minor use the game classification. Threshold: near=0, far=1. +# 0 uses compact detail; 1 always expands; equality expands. +# Compact retains native Far presentation, including some major names. +# Combine Default, Hostiles, Mining and Hazards. Defaults preserve native behavior. +# Windows x64 and macOS on validated clients; available in release and debug builds. +galaxy_multi_select = false +galaxy_overlay_default = true +galaxy_overlay_mining = false +galaxy_overlay_hostiles = false +galaxy_overlay_hazards = false +galaxy_label_major_detail = "native" +galaxy_label_major_threshold = 0.5 +galaxy_label_minor_detail = "native" +galaxy_label_minor_threshold = 0.5 + # Subgroup: Keyboard # ------------------ diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 76b91ec37..636c80ce7 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -159,6 +159,22 @@ zoom_label_player_detail = "native" # Normalized zoom crossover used when player fleet label detail is "threshold"; 0 is always compact and 1 is always expanded zoom_label_player_threshold = 0.5 +# Galaxy labels: native, always, threshold. Always may increase loading time. +# Major/minor use the game classification. Threshold: near=0, far=1. +# 0 uses compact detail; 1 always expands; equality expands. +# Compact retains native Far presentation, including some major names. +# Combine Default, Hostiles, Mining and Hazards. Defaults preserve native behavior. +# Windows x64 and macOS on validated clients; available in release and debug builds. +galaxy_multi_select = false +galaxy_overlay_default = true +galaxy_overlay_mining = false +galaxy_overlay_hostiles = false +galaxy_overlay_hazards = false +galaxy_label_major_detail = "native" +galaxy_label_major_threshold = 0.5 +galaxy_label_minor_detail = "native" +galaxy_label_minor_threshold = 0.5 + # Subgroup: Keyboard # ------------------ diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index bcdf10227..a25c86394 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -159,6 +159,22 @@ zoom_label_player_detail = "native" # Normalized zoom crossover used when player fleet label detail is "threshold"; 0 is always compact and 1 is always expanded zoom_label_player_threshold = 0.5 +# Etiquetas de galaxia: native, always o threshold. Always puede aumentar el tiempo de carga. +# Sistemas mayores y menores siguen la clasificación del juego; umbral: cerca=0, lejos=1. +# 0 usa detalle compacto; 1 siempre expande; la igualdad expande. +# Compacto conserva la presentación Far nativa, incluso para algunos nombres mayores. +# Combina Predeterminado, Hostiles, Minería y Peligros. Los valores iniciales conservan la vista nativa. +# Windows x64 y macOS en clientes validados. +galaxy_multi_select = false +galaxy_overlay_default = true +galaxy_overlay_mining = false +galaxy_overlay_hostiles = false +galaxy_overlay_hazards = false +galaxy_label_major_detail = "native" +galaxy_label_major_threshold = 0.5 +galaxy_label_minor_detail = "native" +galaxy_label_minor_threshold = 0.5 + # Subgroup: Keyboard # ------------------ diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index a5104a1d6..af9894064 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -159,6 +159,22 @@ zoom_label_player_detail = "native" # Normalized zoom crossover used when player fleet label detail is "threshold"; 0 is always compact and 1 is always expanded zoom_label_player_threshold = 0.5 +# Подписи галактики: native, always или threshold. Always может увеличить время загрузки. +# Крупные и малые системы следуют классификации игры; порог: близко=0, далеко=1. +# 0 включает краткие подписи; 1 всегда раскрывает; при равенстве подпись раскрывается. +# Краткий режим сохраняет стандартное отображение Far, в том числе для некоторых крупных названий. +# Можно сочетать обычные системы, врагов, добычу и опасности. Значения по умолчанию сохраняют вид игры. +# Windows x64 и macOS на проверенных версиях клиента. +galaxy_multi_select = false +galaxy_overlay_default = true +galaxy_overlay_mining = false +galaxy_overlay_hostiles = false +galaxy_overlay_hazards = false +galaxy_label_major_detail = "native" +galaxy_label_major_threshold = 0.5 +galaxy_label_minor_detail = "native" +galaxy_label_minor_threshold = 0.5 + # Subgroup: Keyboard # ------------------ diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 0ae3827b9..2c7fb5d79 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -159,6 +159,22 @@ zoom_label_player_detail = "native" # Normalized zoom crossover used when player fleet label detail is "threshold"; 0 is always compact and 1 is always expanded zoom_label_player_threshold = 0.5 +# Galaxy labels: native, always, threshold. Always may increase loading time. +# Major/minor use the game classification. Threshold: near=0, far=1. +# 0 uses compact detail; 1 always expands; equality expands. +# Compact retains native Far presentation, including some major names. +# Combine Default, Hostiles, Mining and Hazards. Defaults preserve native behavior. +# Windows x64 and macOS on validated clients; available in release and debug builds. +galaxy_multi_select = false +galaxy_overlay_default = true +galaxy_overlay_mining = false +galaxy_overlay_hostiles = false +galaxy_overlay_hazards = false +galaxy_label_major_detail = "native" +galaxy_label_major_threshold = 0.5 +galaxy_label_minor_detail = "native" +galaxy_label_minor_threshold = 0.5 + # Subgroup: Keyboard # ------------------ From 25d78b535335ea1a83863d4220c2cb19ec4152db Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:11:52 -0500 Subject: [PATCH 10/16] Protect staged configuration contents and follow dangling links --- docs/config-save.md | 13 +++++-- mods/src/config_save.cc | 59 +++++++++++++++++++++++++----- tests/config_save_failure_test.cc | 61 +++++++++++++++++++++++++++++-- tests/config_save_test.cc | 8 ++++ 4 files changed, 124 insertions(+), 17 deletions(-) diff --git a/docs/config-save.md b/docs/config-save.md index 2bc9367ec..608636154 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -6,11 +6,15 @@ routing and the existing generated-file warning. Save errors are logged once by the caller; startup continues with the in-memory configuration. `SaveConfigDocument` serializes with toml++, parses the output before touching -disk, exclusively creates a sibling temporary file, checks writing and closing, +disk, exclusively creates a private sibling temporary file, checks writing and closing, and replaces the destination. No threads, frame callbacks, runtime controls or shutdown interception are installed. This is not the preserving TOML editor: whole-document saves do not merge concurrent setting changes or preserve comments. +Staging is private before writing and remains private after close: 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 @@ -18,8 +22,8 @@ exclusive create transaction. Ordinary failures clean up the temporary file; par replacement failures retain recovery files and report their location. The backup name is the reported temporary path plus `.bak`. Recovery is not automatic. macOS uses rename after copying the existing permission bits. Extended metadata -and hard-link identity are not preserved by that path. Existing symlinks are -resolved before staging. Replacement requires directory permissions in addition +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 @@ -32,7 +36,8 @@ directory. Fixtures never access the installed game's files. On macOS, run `bash tests/run-config-save.sh TOML_INCLUDE_DIR`. Both native macOS CI jobs run these fixtures after the normal build, including permission-bit and symlink checks. The failure fixture injects short writes and failed closes at -compile time; it does not install test controls in the mod. +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) diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc index 9e9a82b6d..70ef49c87 100644 --- a/mods/src/config_save.cc +++ b/mods/src/config_save.cc @@ -9,6 +9,13 @@ #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. @@ -29,24 +36,58 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& const auto bytes = output.str(); (void)toml::parse(bytes); - // Follow existing symlinks as the former ofstream save did. A sibling stays on - // the same filesystem. Exclusive creation avoids truncating another save's file. - const auto destination = std::filesystem::weakly_canonical(path); + // weakly_canonical alone leaves a dangling final symlink unresolved. + // Follow it before staging, 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); + } 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)); - // C11 exclusive creation avoids depending on newer libc++ fstream runtime - // support on our minimum supported macOS version. -#if _WIN32 + // 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; - _wfopen_s(&file, temporary.c_str(), L"wbx"); +#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 - auto* file = std::fopen(temporary.c_str(), "wbx"); + 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) { - throw std::system_error(errno, std::generic_category(), "could not create temporary config 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; diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc index 8bc083c3c..aadc0ef50 100644 --- a/tests/config_save_failure_test.cc +++ b/tests/config_save_failure_test.cc @@ -1,14 +1,61 @@ #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; static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t count, std::FILE* file) { - if (failClose) { - return std::fwrite(data, size, count, file); - } - const auto written = std::fwrite(data, size, count / 2, 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); + const auto written = std::fwrite(data, size, failClose ? count : count / 2, file); + CheckPrivateStaging(file); + if (failClose) + return written; errno = ENOSPC; return written; } @@ -16,6 +63,7 @@ static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t co static int FailedClose(std::FILE* file) { std::fclose(file); + CheckPrivateStaging(nullptr); errno = ENOSPC; return EOF; } @@ -35,11 +83,16 @@ int main(int argc, char** argv) 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; diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc index c34c7c409..c4f713548 100644 --- a/tests/config_save_test.cc +++ b/tests/config_save_test.cc @@ -66,6 +66,14 @@ int main(int argc, char** argv) 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); From d9fcec1987fe2c0134f0c2a923472921ea320135 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:18:38 -0500 Subject: [PATCH 11/16] Give runtime persistence its own patch registry switch --- example_community_patch_settings_en.toml | 2 ++ mods/src/config.cc | 2 ++ mods/src/config.h | 1 + mods/src/defaultconfig.h | 1 + mods/src/patches/parts/hotkeys.cc | 1 - mods/src/patches/patches.cc | 2 ++ 6 files changed, 8 insertions(+), 1 deletion(-) diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index d1e47142d..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 diff --git a/mods/src/config.cc b/mods/src/config.cc index fe5a6fa08..e112022fe 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -924,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 = 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/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/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index a57740c31..b7bfb6456 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1741,7 +1741,6 @@ void InstallHotkeyHooks() InstallShortcutHintHooks(); install_screen_manager_update_hook(); - runtime_config::Install(); #ifdef _MODDBG fleet_watch::InstallRuntimeProbe(); #endif 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); From 2395ba5f6599fd06942beffe0ad0212b0151754f Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:22:54 -0500 Subject: [PATCH 12/16] Reject settings path retargeting during staged edits --- docs/config-save.md | 4 ++-- mods/src/config_save.cc | 32 +++++++++++++++++++----------- tests/config_save_failure_test.cc | 33 +++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/config-save.md b/docs/config-save.md index c7b7ec72f..aae772254 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -92,8 +92,8 @@ changes. It uses the existing update dispatcher even if persistence setup failed The adapter conservatively retains failures from submissions it could not track. Settings UI wording and widgets belong to the consumers, not the writer. -The checked replacement re-reads the source after staging and rejects changed -bytes before commit. This is best-effort conflict detection, not an atomic +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. diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc index 42c511258..e7e8685ad 100644 --- a/mods/src/config_save.cc +++ b/mods/src/config_save.cc @@ -27,6 +27,24 @@ #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++, @@ -60,17 +78,7 @@ bool ReplaceConfigText(const std::filesystem::path& path, std::string_view bytes { (void)toml::parse(bytes); - // weakly_canonical alone leaves a dangling final symlink unresolved. - // Follow it before staging, 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); - } + 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()) + "-" @@ -126,7 +134,7 @@ bool ReplaceConfigText(const std::filesystem::path& path, std::string_view bytes } // Recheck after staging, immediately before commit. Another editor can still // race the native replacement; arbitrary external editors do not share our lock. - if (expected && ReadConfigText(path) != *expected) { + if (expected && (ResolveConfigDestination(path) != destination || ReadConfigText(destination) != *expected)) { std::error_code ignored; std::filesystem::remove(temporary, ignored); return false; diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc index aadc0ef50..3cf6898b3 100644 --- a/tests/config_save_failure_test.cc +++ b/tests/config_save_failure_test.cc @@ -45,6 +45,10 @@ static void CheckPrivateStaging(std::FILE* file) } 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) { @@ -52,9 +56,13 @@ static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t co if (entry.path().filename().string().find(".tmp-") != std::string::npos) staging = entry.path(); CheckPrivateStaging(file); - const auto written = std::fwrite(data, size, failClose ? count : count / 2, file); + bool complete = failClose; +#if !_WIN32 + complete |= retarget; +#endif + const auto written = std::fwrite(data, size, complete ? count : count / 2, file); CheckPrivateStaging(file); - if (failClose) + if (complete) return written; errno = ENOSPC; return written; @@ -64,6 +72,13 @@ 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; } @@ -109,5 +124,19 @@ int main(int argc, char** argv) 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"; } From 7f9ea43fd69c2299a0d751d9e8c169015196ca4d Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:34:48 -0500 Subject: [PATCH 13/16] Align settings groundwork documentation with current adapters --- docs/MOD_SETTINGS_FOUNDATION.md | 2 +- docs/MOD_SETTINGS_NAVIGATION.md | 43 +++++++++++++++++---------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs/MOD_SETTINGS_FOUNDATION.md b/docs/MOD_SETTINGS_FOUNDATION.md index be0147672..e5a8408cb 100644 --- a/docs/MOD_SETTINGS_FOUNDATION.md +++ b/docs/MOD_SETTINGS_FOUNDATION.md @@ -78,7 +78,7 @@ label. The prefab must prove that those nodes are descendants of the row and do not contain the label; otherwise that UI is unsupported. Exact visual validation of this behavior remains a release gate. -Eight weak view records bound bookkeeping. Native contexts own rows/delegates; +Weak view records sized from the page plan bound bookkeeping. Native contexts own rows/delegates; there are no strong roots retaining historical settings pages. Native release clears records, with dead-record reclamation on binding as a fallback. A successful write refreshes other live framework views. No polling or file work is scheduled. diff --git a/docs/MOD_SETTINGS_NAVIGATION.md b/docs/MOD_SETTINGS_NAVIGATION.md index d1df7ae3f..346cea0eb 100644 --- a/docs/MOD_SETTINGS_NAVIGATION.md +++ b/docs/MOD_SETTINGS_NAVIGATION.md @@ -7,7 +7,7 @@ its stored setting or introduce another copy of its value. Confirmation controls continue to belong on the native confirmation page. `PageCatalog` holds stable page IDs, labels, parent IDs and references to existing -`BooleanSetting` instances. Parents register first; invalid parents, duplicate +`BooleanSetting`, `ChoiceSetting`, `SliderSetting` and `ActionSetting` adapters. Parents register first; invalid parents, duplicate pages and conflicting setting owners are rejected. The same setting can appear on different pages, with the same authoritative read/write adapter. Registration freezes at the first build. Definitions and setting owners outlive their views. @@ -24,35 +24,36 @@ duplicate roots within one context, and release any temporary roots on failure. Pooled widgets must clear owned label/state overrides before reuse. No setting registration may install an additional copy of an existing widget detour. -Current build261 metadata exposes both root and parent-taking `AddCategory` -overloads on `SettingsContext`, plus parent-taking toggle/selection builders. -The Windows bridge calls that native builder and adds boolean rows through the -existing confirmation adapter. It restores owned text overrides on category -unbind/rebind and page destruction; titles use the same scoped human-text override -as existing confirmation labels. No global localization hook is installed. -Four substantive category/page lifecycle hooks are installed only when registered -pages exist. Current x64 bodies are 366, 250, 572 and 608 bytes respectively, each -larger than SPUD's 24-byte overwrite. Other platforms omit the native UI pending -their own hook evidence. Metadata/builds alone do not validate presentation or -callback lifetime; repeated navigation/pooling remains a runtime gate. +The shared native adapter supports Windows x64 and macOS and creates boolean, +selection, slider and action rows through validated managed builders. It restores +owned text overrides on category unbind/rebind and page destruction, using scoped +human-text overrides without a global localization hook. Optional category/page, +heading and value hooks install only when their registered controls need them. + +Historical build261 measurements covered four category/page lifecycle methods. +Current Windows client270 static measurements cover those methods and the heading, +action, selection and slider families; every selected SPUD overwrite window fits +its method extent. Those disk measurements do not establish live relocation, +callback lifetime or native presentation. Exact artifact navigation/pooling smoke +and supported Mac native extent/execution evidence remain qualification gates. Register through `ModPages()` before settings installation. The production catalog is empty: no final group layout, settings placement or new preference is shipped by this infrastructure slice. This supersedes the earlier General > Community Mod placement proposal; native confirmation placement remains unchanged. -The current native bridge shares the `ModConfirmationSettings` patch installation -and its debug installation switch. Disabling that patch disables both native UI -surfaces. Settings retain their own identity and persistence independently of it. -The shared native adapter currently supports eight simultaneously bound mod -boolean rows across pages. Plan populated groups within that existing limit; -catalog registration does not itself guarantee native widget capacity. +The native adapter shares the `ModConfirmationSettings` registry entry, controlled +by default-enabled `[patches].nativesettingshooks` in all builds. Disabling it skips +native UI installation. Value records are sized from the registered page plan; +there is no fixed eight-row limit. Managed contexts own rows and delegates, while +weak records track bound views without retaining historical settings pages. Persistence stays with explicit feature adapters. A live mod change and its asynchronous save result are distinct; page construction never calls the TOML -writer. The current writer supports its one known mode setting. This work does -not add arbitrary TOML browsing, a second save worker, automatic config hot reload, -sliders/selection abstractions without a consumer, or speculative profiler options. +writer. The current writer registers only instant-warp mode. Its installation is owned +separately by `[patches].runtimeconfighooks`. Choice, slider and action adapters are +included as navigation groundwork; a populated consumer owns its registrations, +validation and persistence. Page construction does not register arbitrary TOML keys. Run `tests/run-settings.ps1` on Windows or `bash tests/run-settings.sh` on macOS. The catalog fixture covers repeated builds, empty branches, registration failures, From c8104d67b22ca334b208c4dc9c28f99028c27aab Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:43:15 -0500 Subject: [PATCH 14/16] Document independent installation controls for native settings --- docs/MOD_SETTINGS.md | 7 +++++-- docs/MOD_SETTINGS_CONTROLS.md | 10 ++++++---- docs/MOD_SETTINGS_NATIVE_ADAPTER.md | 5 +++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index 358e656bd..994d78bf9 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -15,8 +15,11 @@ Each game settings context gets a fresh tree from that immutable plan. The [native adapter map](MOD_SETTINGS_NATIVE_ADAPTER.md) identifies each hook owner. Interop, value widgets, action widgets, navigation and styling are separate concerns. Existing XMake source discovery builds them. Each detour has one owner. -The historical `ModConfirmationSettings` debug patch key remains compatible; -its C++ name is `installNativeSettings`. +The `ModConfirmationSettings` registry entry uses default-enabled +`[patches].nativesettingshooks` in every build; its C++ flag is +`installNativeSettings`. Persistence is independently controlled by +`[patches].runtimeconfighooks`. Forbidden Tech and fleet-label installation use +their owning patch switches, independent of native UI and feature values. ## Placement and summaries diff --git a/docs/MOD_SETTINGS_CONTROLS.md b/docs/MOD_SETTINGS_CONTROLS.md index d1aececa8..1f288d0ca 100644 --- a/docs/MOD_SETTINGS_CONTROLS.md +++ b/docs/MOD_SETTINGS_CONTROLS.md @@ -72,10 +72,12 @@ The native slider callbacks use the same typed snapshot/reentry guards as choice Unknown values suppress the slider and numeric label; disabled known values remain visible. Releasing a pooled widget restores its label, active state and interaction. -Windows installs the existing fleet-label and Forbidden Tech hooks when the mod -settings UI is enabled, so changing their values does not require a restart. -Each FT hook consults the current bypass flag; hook availability is separate from -the value. Other platforms retain startup-controlled installation and omit this UI. +The owning Zoom and Forbidden Tech patch switches control installation independently +of native settings and feature values. Supported callbacks install once; current +label profiles and the FT bypass flag are checked inside them, so live changes do +not require a restart. Windows x64 and macOS use shared adapters; native availability +requires validated metadata and completed hook families. Mac qualification remains +separate from the Windows evidence below. Confirmation ON means the bypass flag is false. Toggling must never invoke an upgrade callback by itself. diff --git a/docs/MOD_SETTINGS_NATIVE_ADAPTER.md b/docs/MOD_SETTINGS_NATIVE_ADAPTER.md index 6aecc3c72..6a49f1aae 100644 --- a/docs/MOD_SETTINGS_NATIVE_ADAPTER.md +++ b/docs/MOD_SETTINGS_NATIVE_ADAPTER.md @@ -36,8 +36,9 @@ installed by exactly one module. XMake's existing `src/**.cc` rule builds them. [the current state contract](MOD_SETTINGS.md). The entry point and member are `InstallNativeSettings` and -`Config::installNativeSettings`. The debug patch key `ModConfirmationSettings` -remains unchanged. Setting IDs, TOML keys and defaults remain stable while +`Config::installNativeSettings`. The registry entry name `ModConfirmationSettings` +remains stable, with default-enabled `[patches].nativesettingshooks` controlling +installation in every build. Setting IDs, TOML keys and defaults remain stable while presentation placement evolves. ## Validation From fe0e83d1b127514a359b8fe510e74f48f8260aa7 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:43:56 -0500 Subject: [PATCH 15/16] Clarify shared native settings platform implementation --- docs/MOD_SETTINGS_CONTROLS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/MOD_SETTINGS_CONTROLS.md b/docs/MOD_SETTINGS_CONTROLS.md index 1f288d0ca..4d4dafc55 100644 --- a/docs/MOD_SETTINGS_CONTROLS.md +++ b/docs/MOD_SETTINGS_CONTROLS.md @@ -19,7 +19,8 @@ See [the current architecture contract](MOD_SETTINGS.md) and | Future separate branch: Hotkeys | Rebind existing actions | Existing shortcut parser and `MapKey` registrations | | General > confirmation page | Confirm Forbidden Tech upgrades | Inverse of `ui.auto_confirm_ft_upgrade` | -The controls branch implements these controls on Windows x64. +The controls branch implements shared Windows x64 and macOS adapters; platform +qualification is recorded separately from implementation. Hotkey editing remains a separate branch. Native confirmation controls stay on the native page. FC retains its existing owner. @@ -43,7 +44,7 @@ Selected options use bold text and the native checkmark on a normal background, including instant warp and both Fleet Labels profiles. White fill is transient pressed feedback, not persistent selection or keyboard focus. The scoped adapter uses native sprites already rendered by settings rows and restores each Image's -previous override before pooling. A Windows-only `Selectable.DoStateTransition` +previous override before pooling. The shared `Selectable.DoStateTransition` hook observes input-state changes, calls the original once, then updates only owned selection rows. Other controls take the native path; there is no frame polling, animation replacement, asset loading or setting write in this hook. From 8fde63fc2d521d951d1f5c42082f3e118ed45b75 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 20:45:06 -0500 Subject: [PATCH 16/16] Align navigation notes with registered task pages --- docs/MOD_SETTINGS_NAVIGATION.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/docs/MOD_SETTINGS_NAVIGATION.md b/docs/MOD_SETTINGS_NAVIGATION.md index 3611306c9..798344008 100644 --- a/docs/MOD_SETTINGS_NAVIGATION.md +++ b/docs/MOD_SETTINGS_NAVIGATION.md @@ -5,9 +5,8 @@ control placement, conditional rows, summaries and native adapter ownership are documented in [Mod Settings](MOD_SETTINGS.md). This foundation separates presentation placement from a setting's owner. The -intended native path is Settings > Mod Settings > group > setting. Group names -and final membership are deliberately undecided; moving a control must not rename -its stored setting or introduce another copy of its value. Confirmation controls +intended native path is Settings > Mod Settings > group > setting. Current page membership follows the task layout in `MOD_SETTINGS.md`. Moving a +control must not rename its stored setting or introduce another copy of its value. Confirmation controls continue to belong on the native confirmation page. `PageCatalog` holds stable page IDs, labels, parent IDs and references to existing @@ -43,14 +42,12 @@ its method extent. Those disk measurements do not establish live relocation, callback lifetime or native presentation. Exact artifact navigation/pooling smoke and supported Mac native extent/execution evidence remain qualification gates. -Register through `ModPages()` before settings installation. The first production -groups follow populated TOML sections: User Interface > Instant warp mode shares -Alt+I's owner and persistence; Graphics > Fleet Labels places player/non-player -sections on one page, each with detail -choices and a percentage slider. Headings use native text-only rows with scoped -label overrides and optional row tints cleared on refresh/clear. Two text-widget hooks have Windows x64 -extents of 293 and 271 bytes. Future grouping follows the section-based direction in -[MOD_SETTINGS_CONTROLS.md](MOD_SETTINGS_CONTROLS.md). Native confirmation placement remains unchanged. +Register through `ModPages()` before settings installation. Current pages are +Camera, Fleet Labels, Map & Travel, and Previews & Cargo, as documented in +[Mod Settings](MOD_SETTINGS.md). Instant warp shares Alt+I's owner and persistence. +Fleet Labels places Player and Non-player sections on one page, each with detail +choices and a percentage slider. Native text-only headings use scoped labels and +row tints cleared on refresh/clear. Native confirmation placement stays unchanged. Selection controls share the typed setting/view guards with booleans and retain the whole integer value in each row snapshot. Three selection-widget hooks have verified Windows x64 extents of 146, 355 and 281 bytes. Selection prefabs may put