From 7f5f8d07c8d71246cbee514b571e61b83be8722c Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 28 Aug 2026 23:24:37 -0500 Subject: [PATCH 01/20] Prevent duplicate Spud detour targets --- .../packages/s/spud/spud-src/CMakeLists.txt | 50 ++--- .../s/spud/spud-src/include/spud/detour.h | 67 ++++--- .../s/spud/spud-src/src/detour/detour.cc | 180 +++++++++++++----- .../spud-src/src/detour/target_registry.cc | 78 ++++++++ .../spud-src/src/detour/target_registry.h | 43 +++++ .../s/spud/spud-src/tests/detour_registry.cc | 180 ++++++++++++++++++ xmake-requires.lock | 14 +- xmake/dependencies/common.lua | 2 +- 8 files changed, 511 insertions(+), 103 deletions(-) create mode 100644 xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc create mode 100644 xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h create mode 100644 xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc diff --git a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt index eba1509e8..234040179 100644 --- a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt +++ b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt @@ -217,6 +217,8 @@ target_sources( "src/detour/detour.cc" "src/detour/detour_impl.h" "src/detour/fwd.h" + "src/detour/target_registry.cc" + "src/detour/target_registry.h" "src/detour/remapper.cc" "src/detour/remapper.h" "src/memory/protection.cc" @@ -238,7 +240,7 @@ if(NOT SPUD_NO_INSTALL) ) endif() if(SPUD_BUILD_TESTS) - set(TEST_SRCS "tests/signature.cc") + set(TEST_SRCS "tests/detour_registry.cc") file( GLOB_RECURSE TEST_DETOUR_SHARED_SRCS tests/detour/shared/*.cc @@ -306,35 +308,35 @@ if(SPUD_BUILD_TESTS) add_executable( "spud.test" ${TEST_SRCS} - "tests/test_util.h" - "tests/signature.cc" ) - target_include_directories("spud.test" PRIVATE "tests") + target_include_directories("spud.test" PRIVATE "tests" "src") target_link_libraries( "spud.test" PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" ) add_dependencies("spud.test" "Catch2" "spud") - add_executable("spud.benchmark" ${BENCHMARK_SRCS}) - target_include_directories("spud.benchmark" PRIVATE "benchmark") - if(SPUD_COMPARE_LIBS) - target_link_libraries( - "spud.benchmark" - PRIVATE - "Catch2" - "Catch2::Catch2WithMain" - "spud" - "lime" - "minhook" - "PolyHook2" - ) - else() - target_link_libraries( - "spud.benchmark" - PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" - ) + if(BENCHMARK_SRCS) + add_executable("spud.benchmark" ${BENCHMARK_SRCS}) + target_include_directories("spud.benchmark" PRIVATE "benchmark") + if(SPUD_COMPARE_LIBS) + target_link_libraries( + "spud.benchmark" + PRIVATE + "Catch2" + "Catch2::Catch2WithMain" + "spud" + "lime" + "minhook" + "PolyHook2" + ) + else() + target_link_libraries( + "spud.benchmark" + PRIVATE "Catch2" "Catch2::Catch2WithMain" "spud" + ) + endif() + add_dependencies("spud.benchmark" "Catch2" "spud") + add_test(NAME spud.benchmark COMMAND spud.benchmark) endif() - add_dependencies("spud.benchmark" "Catch2" "spud") add_test(NAME spud.test COMMAND spud.test) - add_test(NAME spud.benchmark COMMAND spud.benchmark) endif() diff --git a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h index 946de5cc1..859370953 100644 --- a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h +++ b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #if __cpp_lib_source_location && SPUD_DETOUR_TRACING @@ -17,6 +18,14 @@ #include "utils.h" namespace spud { + +enum class detour_install_status : uint8_t { + not_installed, + installed, + already_installed, + duplicate_target, +}; + namespace detail { struct detour { @@ -29,27 +38,23 @@ struct detour { return this->trampoline_; } + detour_install_status last_install_status() const { + return last_install_status_; + } + using Self = detail::detour; public: detour(detour &&other) noexcept { - this->trampoline_ = other.trampoline_; - this->address_ = other.address_; - this->func_ = other.func_; - this->wrapper_ = other.wrapper_; - this->context_container_ = std::move(other.context_container_); - this->original_func_data_ = other.original_func_data_; - other.original_func_data_.clear(); + move_from(std::move(other)); } detour &operator=(detour &&other) noexcept { - this->trampoline_ = other.trampoline_; - this->address_ = other.address_; - this->func_ = other.func_; - this->wrapper_ = other.wrapper_; - this->context_container_ = std::move(other.context_container_); - this->original_func_data_ = other.original_func_data_; - other.original_func_data_.clear(); + if (this == &other) { + return *this; + } + remove(); + move_from(std::move(other)); return *this; } @@ -66,24 +71,19 @@ struct detour { #if __cpp_lib_source_location && SPUD_DETOUR_TRACING detour(uintptr_t address, uintptr_t func, uintptr_t wrapper, const std::source_location location = std::source_location::current()) - : address_(address), func_(func), wrapper_(wrapper), + : requested_address_(address), address_(address), func_(func), + wrapper_(wrapper), context_container_(std::make_unique()), location_(location) {} #else detour(uintptr_t address, uintptr_t func, uintptr_t wrapper) - : address_(address), func_(func), wrapper_(wrapper), + : requested_address_(address), address_(address), func_(func), + wrapper_(wrapper), context_container_(std::make_unique()) {} #endif Self &install(Arch arch = Arch::kHost); void remove(); - Self &detach() { - original_func_data_.clear(); - // We are intentionally ignoring the return value here - // TODO(alex): Move this to a global cleanup thing - (void)context_container_.release(); - - return *this; - } + Self &detach(); static uintptr_t get_context_value(); @@ -91,6 +91,22 @@ struct detour { detour(detour const &) = delete; detour &operator=(detour const &) = delete; + void move_from(detour &&other) noexcept { + requested_address_ = other.requested_address_; + address_ = other.address_; + func_ = other.func_; + wrapper_ = other.wrapper_; + context_container_ = std::move(other.context_container_); + trampoline_ = other.trampoline_; + original_func_data_ = std::move(other.original_func_data_); + installed_ = std::exchange(other.installed_, false); + last_install_status_ = other.last_install_status_; + + other.trampoline_ = 0; + other.last_install_status_ = detour_install_status::not_installed; + } + + uintptr_t requested_address_; uintptr_t address_; uintptr_t func_; uintptr_t wrapper_; @@ -98,6 +114,9 @@ struct detour { std::unique_ptr context_container_ = nullptr; uintptr_t trampoline_ = 0; std::vector original_func_data_ = {}; + bool installed_ = false; + detour_install_status last_install_status_ = + detour_install_status::not_installed; #if __cpp_lib_source_location && SPUD_DETOUR_TRACING const std::source_location location_ = std::source_location::current(); #endif diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc index e5cd87d42..98a5c41d6 100644 --- a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc @@ -5,11 +5,14 @@ #include "detour_impl.h" #include "remapper.h" +#include "target_registry.h" #if SPUD_OS_APPLE #include #endif +#include +#include #include #if SPUD_OS_APPLE || SPUD_OS_LINUX @@ -22,6 +25,24 @@ extern "C" uintptr_t ASM_FUNC(spud_read_context_value, ()); namespace spud { namespace detail { +namespace { + +void report_conflict(const target_owner &candidate, + const target_owner &incumbent) noexcept { + std::fprintf( + stderr, + "spud: duplicate detour rejected (requested=0x%" PRIxPTR + ", canonical=0x%" PRIxPTR ", replacement=0x%" PRIxPTR + "); existing owner requested=0x%" PRIxPTR ", canonical=0x%" PRIxPTR + ", replacement=0x%" PRIxPTR ")\n", + candidate.requested_address, candidate.canonical_address, + candidate.replacement_address, incumbent.requested_address, + incumbent.canonical_address, incumbent.replacement_address); + std::fflush(stderr); +} + +} // namespace + struct DetourImpl { std::vector (*create_absolute_jump)(uintptr_t target, uintptr_t data); @@ -51,84 +72,127 @@ const static std::array kDetourImpls = { }; detail::detour &detour::install(Arch arch) { + if (installed_) { + last_install_status_ = detour_install_status::already_installed; + return *this; + } + if (context_container_ == nullptr) { + last_install_status_ = detour_install_status::not_installed; + return *this; + } + const auto &impl = kDetourImpls[arch]; // We don't want to hook things that point to a direct jump // This will resolve that jump and instead we hook the underlying function // func_ = impl.maybe_resolve_jump(func_); // TODO(alex): This will break hook stacking most likely right now... - address_ = impl.maybe_resolve_jump(address_); + const auto canonical_address = impl.maybe_resolve_jump(requested_address_); + const target_owner candidate = { + .requested_address = requested_address_, + .canonical_address = canonical_address, + .owner_token = reinterpret_cast(context_container_.get()), + .replacement_address = func_, + }; + const auto claim = detour_target_registry().claim(candidate); + if (claim.status == target_claim_status::conflict) { + last_install_status_ = detour_install_status::duplicate_target; + report_conflict(candidate, claim.incumbent); + return *this; + } + if (claim.status == target_claim_status::already_owned) { + last_install_status_ = detour_install_status::already_installed; + return *this; + } + + address_ = canonical_address; wrapper_ = impl.maybe_resolve_jump(wrapper_); - const auto jump = impl.create_absolute_jump( - wrapper_, reinterpret_cast(context_container_.get())); + bool jit_write_protection_disabled = false; + try { + const auto jump = impl.create_absolute_jump( + wrapper_, reinterpret_cast(context_container_.get())); - auto [relocation_infos, required_trampoline_size] = - impl.collect_relocations(address_, jump.size()); + auto [relocation_infos, required_trampoline_size] = + impl.collect_relocations(address_, jump.size()); - // Required trampoline size is how many bytes we have to take up of the - // original function This will then be used to calculate the expanded size, - // since we do have some instruction replacement and expansion going on + // Required trampoline size is how many bytes we have to take up of the + // original function This will then be used to calculate the expanded size, + // since we do have some instruction replacement and expansion going on - auto trampoline = impl.create_trampoline( - address_ + required_trampoline_size, - {reinterpret_cast(address_), required_trampoline_size}, - relocation_infos); + auto trampoline = impl.create_trampoline( + address_ + required_trampoline_size, + {reinterpret_cast(address_), required_trampoline_size}, + relocation_infos); - // TODO(tashcan): This isn't particularly amazing, we might have to ajdust - // permissions - disable_jit_write_protection(); + // TODO(tashcan): This isn't particularly amazing, we might have to adjust + // permissions + disable_jit_write_protection(); + jit_write_protection_disabled = true; - auto trampoline_address = alloc_executable_memory(trampoline.data.size()); - assert(trampoline_address != nullptr); + auto trampoline_address = alloc_executable_memory(trampoline.data.size()); + assert(trampoline_address != nullptr); - std::memcpy(trampoline_address, trampoline.data.data(), - trampoline.data.size()); - trampoline_ = - trampoline.start + reinterpret_cast(trampoline_address); + std::memcpy(trampoline_address, trampoline.data.data(), + trampoline.data.size()); + trampoline_ = + trampoline.start + reinterpret_cast(trampoline_address); - context_container_->func = func_; - context_container_->trampoline = trampoline_; + context_container_->func = func_; + context_container_->trampoline = trampoline_; #if __cpp_lib_source_location && SPUD_DETOUR_TRACING - context_container_->location = location_; + context_container_->location = location_; #endif - { - const auto copy_size = required_trampoline_size; - original_func_data_.resize(copy_size); - std::memcpy(original_func_data_.data(), reinterpret_cast(address_), - copy_size); - - remapper remap(address_, copy_size); { - SPUD_SCOPED_PROTECTION(remap, copy_size, - mem_protection::READ_WRITE_EXECUTE); - - // This will leave some trashed instructions - // Which is okay for now - // TODO(tashcan): Add some kind of NOP function to make the remaining - // stuff "valid" - std::memcpy(reinterpret_cast(uintptr_t(remap)), jump.data(), - jump.size()); + const auto copy_size = required_trampoline_size; + original_func_data_.resize(copy_size); + std::memcpy(original_func_data_.data(), + reinterpret_cast(address_), copy_size); + + remapper remap(address_, copy_size); + { + SPUD_SCOPED_PROTECTION(remap, copy_size, + mem_protection::READ_WRITE_EXECUTE); + + // This will leave some trashed instructions + // Which is okay for now + // TODO(tashcan): Add some kind of NOP function to make the remaining + // stuff "valid" + std::memcpy(reinterpret_cast(uintptr_t(remap)), jump.data(), + jump.size()); #if SPUD_OS_APPLE - sys_dcache_flush((void *)address_, copy_size); + sys_dcache_flush((void *)address_, copy_size); #endif - } + } #if SPUD_OS_APPLE - sys_icache_invalidate((void *)address_, copy_size); + sys_icache_invalidate((void *)address_, copy_size); #endif - } + } - enable_jit_write_protection(); + enable_jit_write_protection(); + jit_write_protection_disabled = false; + } catch (...) { + if (jit_write_protection_disabled) { + enable_jit_write_protection(); + } + detour_target_registry().release(candidate); + original_func_data_.clear(); + trampoline_ = 0; + address_ = requested_address_; + throw; + } + installed_ = true; + last_install_status_ = detour_install_status::installed; return *this; } void detour::remove() { - assert(context_container_.get() != nullptr); - if (original_func_data_.size() == 0) { + if (!installed_ || original_func_data_.empty()) { return; } + assert(context_container_.get() != nullptr); disable_jit_write_protection(); { @@ -143,6 +207,28 @@ void detour::remove() { enable_jit_write_protection(); original_func_data_.clear(); + detour_target_registry().release({ + .requested_address = requested_address_, + .canonical_address = address_, + .owner_token = reinterpret_cast(context_container_.get()), + .replacement_address = func_, + }); + installed_ = false; + trampoline_ = 0; + address_ = requested_address_; + last_install_status_ = detour_install_status::not_installed; +} + +detour::Self &detour::detach() { + if (!installed_ || original_func_data_.empty()) { + return *this; + } + + original_func_data_.clear(); + // The target remains patched, so both the callback context and target claim + // intentionally live until process exit. + (void)context_container_.release(); + return *this; } uintptr_t detour::get_context_value() { diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc new file mode 100644 index 000000000..ac3397e3f --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.cc @@ -0,0 +1,78 @@ +#include "target_registry.h" + +#include + +namespace spud::detail +{ + +namespace +{ + std::array target_keys(const target_owner &owner) + { return {owner.requested_address, owner.canonical_address}; } +} // namespace + +target_claim_result target_registry::claim(const target_owner &candidate) +{ + std::lock_guard lock(mutex_); + bool owner_seen = false; + + for (const auto key : target_keys(candidate)) { + const auto entry = targets_.find(key); + if (entry == targets_.end()) { + continue; + } + if (entry->second.owner_token != candidate.owner_token) { + return {target_claim_status::conflict, entry->second}; + } + owner_seen = true; + } + if (owner_seen) { + return {target_claim_status::already_owned, {}}; + } + + std::array inserted{}; + size_t inserted_count = 0; + try { + for (const auto key : target_keys(candidate)) { + if (targets_.contains(key)) { + continue; + } + targets_.emplace(key, candidate); + inserted[inserted_count++] = key; + } + } catch (...) { + for (size_t index = 0; index < inserted_count; ++index) { + targets_.erase(inserted[index]); + } + throw; + } + + return {target_claim_status::claimed, {}}; +} + +void target_registry::release(const target_owner &owner) +{ + std::lock_guard lock(mutex_); + for (const auto key : target_keys(owner)) { + const auto entry = targets_.find(key); + if (entry != targets_.end() && entry->second.owner_token == owner.owner_token) { + targets_.erase(entry); + } + } +} + +size_t target_registry::size() const +{ + std::lock_guard lock(mutex_); + return targets_.size(); +} + +target_registry &detour_target_registry() +{ + // Detours can be destroyed during static teardown, so the registry must + // intentionally outlive every static detour object. + static auto *registry = new target_registry(); + return *registry; +} + +} // namespace spud::detail diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h new file mode 100644 index 000000000..cf8c67f9c --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/target_registry.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include + +namespace spud::detail +{ + +struct target_owner { + uintptr_t requested_address; + uintptr_t canonical_address; + uintptr_t owner_token; + uintptr_t replacement_address; +}; + +enum class target_claim_status { + claimed, + already_owned, + conflict, +}; + +struct target_claim_result { + target_claim_status status; + target_owner incumbent{}; +}; + +class target_registry +{ +public: + target_claim_result claim(const target_owner &candidate); + void release(const target_owner &owner); + size_t size() const; + +private: + mutable std::mutex mutex_; + std::unordered_map targets_; +}; + +target_registry &detour_target_registry(); + +} // namespace spud::detail diff --git a/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc new file mode 100644 index 000000000..189cea49b --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc @@ -0,0 +1,180 @@ +#include + +#include "detour/target_registry.h" + +#include +#include +#include +#include + +#include + +namespace +{ + +#if defined(_MSC_VER) +#define SPUD_TEST_NOINLINE __declspec(noinline) +#else +#define SPUD_TEST_NOINLINE __attribute__((noinline)) +#endif + +SPUD_TEST_NOINLINE int duplicate_target(int value) +{ return value + 1; } + +SPUD_TEST_NOINLINE int move_target(int value) +{ return value + 2; } + +SPUD_TEST_NOINLINE int reinstall_target(int value) +{ return value + 3; } + +SPUD_TEST_NOINLINE int detached_target(int value) +{ return value + 4; } + +int add_ten(int (*original)(int), int value) +{ return original(value) + 10; } + +int add_twenty(int (*original)(int), int value) +{ return original(value) + 20; } + +} // namespace + +TEST_CASE("target registry rejects exact and canonical aliases") +{ + spud::detail::target_registry registry; + const spud::detail::target_owner first = { + .requested_address = 0x1000, + .canonical_address = 0x2000, + .owner_token = 0x3000, + .replacement_address = 0x4000, + }; + + REQUIRE(registry.claim(first).status == spud::detail::target_claim_status::claimed); + REQUIRE(registry.size() == 2); + REQUIRE(registry.claim(first).status == spud::detail::target_claim_status::already_owned); + + const auto exact = registry.claim({ + .requested_address = first.requested_address, + .canonical_address = 0x5000, + .owner_token = 0x6000, + .replacement_address = 0x7000, + }); + REQUIRE(exact.status == spud::detail::target_claim_status::conflict); + REQUIRE(exact.incumbent.owner_token == first.owner_token); + + const auto alias = registry.claim({ + .requested_address = 0x8000, + .canonical_address = first.canonical_address, + .owner_token = 0x9000, + .replacement_address = 0xA000, + }); + REQUIRE(alias.status == spud::detail::target_claim_status::conflict); + REQUIRE(alias.incumbent.owner_token == first.owner_token); + + registry.release(first); + REQUIRE(registry.size() == 0); +} + +TEST_CASE("target registry admits exactly one concurrent owner") +{ + spud::detail::target_registry registry; + std::barrier start(3); + std::atomic_size_t claimed = 0; + std::atomic_size_t conflicts = 0; + + const auto contender = [&](uintptr_t owner) { + start.arrive_and_wait(); + const auto result = registry.claim({ + .requested_address = 0x1000, + .canonical_address = 0x2000, + .owner_token = owner, + .replacement_address = owner + 1, + }); + if (result.status == spud::detail::target_claim_status::claimed) { + claimed.fetch_add(1, std::memory_order_relaxed); + } else if (result.status == spud::detail::target_claim_status::conflict) { + conflicts.fetch_add(1, std::memory_order_relaxed); + } + }; + + std::thread first(contender, 0x3000); + std::thread second(contender, 0x4000); + start.arrive_and_wait(); + first.join(); + second.join(); + + REQUIRE(claimed.load(std::memory_order_relaxed) == 1); + REQUIRE(conflicts.load(std::memory_order_relaxed) == 1); + REQUIRE(registry.size() == 2); +} + +TEST_CASE("duplicate detour preserves the first installed hook") +{ + int (*volatile call_target)(int) = duplicate_target; + REQUIRE(call_target(1) == 2); + + { + auto first = spud::create_detour(&duplicate_target, &add_ten); + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 12); + + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::already_installed); + REQUIRE(call_target(1) == 12); + + auto second = spud::create_detour(&duplicate_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(second.trampoline() == nullptr); + REQUIRE(call_target(1) == 12); + } + REQUIRE(call_target(1) == 2); +} + +TEST_CASE("moving an installed detour preserves ownership and cleanup") +{ + int (*volatile call_target)(int) = move_target; + REQUIRE(call_target(1) == 3); + { + auto source = spud::create_detour(&move_target, &add_ten); + source.install(); + auto moved = std::move(source); + REQUIRE(moved.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 13); + } + REQUIRE(call_target(1) == 3); +} + +TEST_CASE("normal destruction releases a target for later installation") +{ + int (*volatile call_target)(int) = reinstall_target; + { + auto first = spud::create_detour(&reinstall_target, &add_ten); + first.install(); + REQUIRE(first.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 14); + } + REQUIRE(call_target(1) == 4); + { + auto second = spud::create_detour(&reinstall_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::installed); + REQUIRE(call_target(1) == 24); + } + REQUIRE(call_target(1) == 4); +} + +TEST_CASE("detached hooks retain their target claim") +{ + int (*volatile call_target)(int) = detached_target; + { + auto first = spud::create_detour(&detached_target, &add_ten); + first.install().detach(); + REQUIRE(call_target(1) == 15); + } + + auto second = spud::create_detour(&detached_target, &add_twenty); + second.install(); + REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(call_target(1) == 15); +} diff --git a/xmake-requires.lock b/xmake-requires.lock index cad21781a..c10fec04b 100644 --- a/xmake-requires.lock +++ b/xmake-requires.lock @@ -126,11 +126,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -273,11 +273,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -384,11 +384,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-3#f56260b5"] = { + ["spud v0.2.0-4#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-3" + version = "v0.2.0-4" }, ["toml++#f56260b5"] = { repo = { @@ -407,4 +407,4 @@ version = "v1.3.2" } } -} \ No newline at end of file +} diff --git a/xmake/dependencies/common.lua b/xmake/dependencies/common.lua index 879f03a4d..17ffdc99d 100644 --- a/xmake/dependencies/common.lua +++ b/xmake/dependencies/common.lua @@ -20,7 +20,7 @@ add_requires("toml++") add_requires("nlohmann_json") add_requires("protobuf 35.1") add_requires("cpr", {system = false}) -add_requires("spud v0.2.0-3") +add_requires("spud v0.2.0-4") add_requires("libil2cpp") add_requires("simdutf", {system = false}) From 7972ec4d17e60a3e55eefd9665ba77fdefacad4b Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 08:05:34 -0500 Subject: [PATCH 02/20] Route duplicate detours to the mod log --- mods/src/patches/patches.cc | 1 + .../s/spud/spud-src/include/spud/detour.h | 5 ++++ .../s/spud/spud-src/src/detour/detour.cc | 28 +++++++++++++++++-- .../s/spud/spud-src/tests/detour_registry.cc | 10 +++++++ xmake-requires.lock | 12 ++++---- xmake/dependencies/common.lua | 2 +- 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 9579b9692..2d5974eb2 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -76,6 +76,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) spdlog::set_level(log_level); spdlog::flush_on(log_level); + spud::set_detour_diagnostic_handler([](const char* message) { spdlog::error("[Spud] {}", message); }); #if VERSION_PATCH if constexpr (sizeof(VERSION_COMMIT_HASH) > 1) { diff --git a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h index 859370953..28705b3a7 100644 --- a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h +++ b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h @@ -26,6 +26,11 @@ enum class detour_install_status : uint8_t { duplicate_target, }; +using detour_diagnostic_handler = void (*)(const char *message); + +// Installs a process-local diagnostic sink. The handler must remain valid until replaced. +void set_detour_diagnostic_handler(detour_diagnostic_handler handler) noexcept; + namespace detail { struct detour { diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc index 98a5c41d6..5767fabca 100644 --- a/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/detour.cc @@ -11,6 +11,8 @@ #include #endif +#include +#include #include #include #include @@ -24,20 +26,40 @@ extern "C" uintptr_t ASM_FUNC(spud_read_context_value, ()); namespace spud { +namespace { +std::atomic diagnostic_handler = nullptr; +} + +void set_detour_diagnostic_handler(detour_diagnostic_handler handler) noexcept { + diagnostic_handler.store(handler, std::memory_order_release); +} + namespace detail { namespace { void report_conflict(const target_owner &candidate, const target_owner &incumbent) noexcept { - std::fprintf( - stderr, + std::array message{}; + std::snprintf( + message.data(), message.size(), "spud: duplicate detour rejected (requested=0x%" PRIxPTR ", canonical=0x%" PRIxPTR ", replacement=0x%" PRIxPTR "); existing owner requested=0x%" PRIxPTR ", canonical=0x%" PRIxPTR - ", replacement=0x%" PRIxPTR ")\n", + ", replacement=0x%" PRIxPTR ")", candidate.requested_address, candidate.canonical_address, candidate.replacement_address, incumbent.requested_address, incumbent.canonical_address, incumbent.replacement_address); + + if (const auto handler = diagnostic_handler.load(std::memory_order_acquire); + handler != nullptr) { + try { + handler(message.data()); + return; + } catch (...) { + } + } + + std::fprintf(stderr, "%s\n", message.data()); std::fflush(stderr); } diff --git a/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc index 189cea49b..78465eb29 100644 --- a/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc +++ b/xmake-packages/packages/s/spud/spud-src/tests/detour_registry.cc @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -30,6 +31,11 @@ SPUD_TEST_NOINLINE int reinstall_target(int value) SPUD_TEST_NOINLINE int detached_target(int value) { return value + 4; } +std::string last_diagnostic; + +void capture_diagnostic(const char *message) +{ last_diagnostic = message; } + int add_ten(int (*original)(int), int value) { return original(value) + 10; } @@ -123,8 +129,12 @@ TEST_CASE("duplicate detour preserves the first installed hook") REQUIRE(call_target(1) == 12); auto second = spud::create_detour(&duplicate_target, &add_twenty); + last_diagnostic.clear(); + spud::set_detour_diagnostic_handler(&capture_diagnostic); second.install(); + spud::set_detour_diagnostic_handler(nullptr); REQUIRE(second.last_install_status() == spud::detour_install_status::duplicate_target); + REQUIRE(last_diagnostic.find("duplicate detour rejected") != std::string::npos); REQUIRE(second.trampoline() == nullptr); REQUIRE(call_target(1) == 12); } diff --git a/xmake-requires.lock b/xmake-requires.lock index c10fec04b..477133c97 100644 --- a/xmake-requires.lock +++ b/xmake-requires.lock @@ -126,11 +126,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-4#f56260b5"] = { + ["spud v0.2.0-5#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-4" + version = "v0.2.0-5" }, ["toml++#f56260b5"] = { repo = { @@ -273,11 +273,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-4#f56260b5"] = { + ["spud v0.2.0-5#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-4" + version = "v0.2.0-5" }, ["toml++#f56260b5"] = { repo = { @@ -384,11 +384,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-4#f56260b5"] = { + ["spud v0.2.0-5#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-4" + version = "v0.2.0-5" }, ["toml++#f56260b5"] = { repo = { diff --git a/xmake/dependencies/common.lua b/xmake/dependencies/common.lua index 17ffdc99d..7f24295d1 100644 --- a/xmake/dependencies/common.lua +++ b/xmake/dependencies/common.lua @@ -20,7 +20,7 @@ add_requires("toml++") add_requires("nlohmann_json") add_requires("protobuf 35.1") add_requires("cpr", {system = false}) -add_requires("spud v0.2.0-4") +add_requires("spud v0.2.0-5") add_requires("libil2cpp") add_requires("simdutf", {system = false}) From c973421722f96574a0aa6604f35dc0bc03e34aac Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 08:08:17 -0500 Subject: [PATCH 03/20] Document Spud diagnostic handler lifetime --- xmake-packages/packages/s/spud/spud-src/include/spud/detour.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h index 28705b3a7..72ae62ce6 100644 --- a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h +++ b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h @@ -28,7 +28,8 @@ enum class detour_install_status : uint8_t { using detour_diagnostic_handler = void (*)(const char *message); -// Installs a process-local diagnostic sink. The handler must remain valid until replaced. +// Installs a process-local diagnostic sink. The handler must have process lifetime; replacement does not wait for +// concurrent reports. The message is borrowed for the duration of the callback. void set_detour_diagnostic_handler(detour_diagnostic_handler handler) noexcept; namespace detail { From c3aa4bce47783b439cc6f5711020fe8215fa5980 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 20:13:51 -0500 Subject: [PATCH 04/20] 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 05/20] 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 06/20] 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 07/20] 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 08/20] 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 b18b0a4c3135def85c8be4ccdf907f3de4c7f0ab Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 21:25:23 -0500 Subject: [PATCH 09/20] Preserve user TOML while saving the instant-warp mode asynchronously --- docs/config-save.md | 55 ++++- example_community_patch_settings_da.toml | 2 + example_community_patch_settings_de.toml | 2 + ...munity_patch_settings_en-GB-x-cockney.toml | 2 + ...mmunity_patch_settings_en-x-minionese.toml | 2 + example_community_patch_settings_en.toml | 2 + example_community_patch_settings_es.toml | 2 + example_community_patch_settings_fr.toml | 2 + example_community_patch_settings_nl.toml | 2 + example_community_patch_settings_ru.toml | 2 + example_community_patch_settings_tlh.toml | 2 + mods/src/config.cc | 4 + mods/src/config_save.cc | 32 +++ mods/src/config_save.h | 8 + mods/src/patches/parts/hotkeys.cc | 6 +- mods/src/patches/parts/runtime_config.cc | 208 ++++++++++++++++++ mods/src/patches/runtime_config.h | 13 ++ mods/src/runtime_config_writer.cc | 131 +++++++++++ mods/src/runtime_config_writer.h | 54 +++++ mods/src/toml_editor.cc | 172 +++++++++++++++ mods/src/toml_editor.h | 34 +++ tests/run-config-save.ps1 | 12 + tests/run-config-save.sh | 6 + tests/runtime_config_writer_test.cc | 125 +++++++++++ tests/toml_editor_test.cc | 63 ++++++ 25 files changed, 940 insertions(+), 3 deletions(-) create mode 100644 mods/src/patches/parts/runtime_config.cc create mode 100644 mods/src/patches/runtime_config.h create mode 100644 mods/src/runtime_config_writer.cc create mode 100644 mods/src/runtime_config_writer.h create mode 100644 mods/src/toml_editor.cc create mode 100644 mods/src/toml_editor.h create mode 100644 tests/runtime_config_writer_test.cc create mode 100644 tests/toml_editor_test.cc diff --git a/docs/config-save.md b/docs/config-save.md index 2bc9367ec..811340b42 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -7,8 +7,7 @@ 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: +and replaces the destination. Startup callers remain synchronous. These whole-document saves do not merge concurrent setting changes or preserve comments. Windows uses `ReplaceFileW` to preserve existing permissions and streams, with a @@ -37,3 +36,55 @@ compile time; it does not install test controls in the mod. Native behavior references: - [Windows ReplaceFileW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew) - [POSIX rename](https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html) + +## Runtime edits + +The instant-warp mode shortcut changes the active mode immediately, then asks one +worker to persist `ui.auto_confirm_instant_warp`. It retains one pending value; +new presses replace that pending value while an active save finishes. The worker +starts only on the first request. It does not read game objects or call Unity. + +The worker reads the current file for each attempt. `TomlEditor` caches a parsed +document only while its source bytes match. It uses toml++ source regions to +replace the selected value, preserving unrelated bytes, comments and line endings. +Missing settings are inserted only when reparsing proves the candidate means +exactly the intended document. Values are typed booleans or strings and encoded +by toml++; quotes, backslashes and newlines cannot become new TOML instructions. + +Each request compares the selected value against the last acknowledged disk value, +including its original spelling and whether it was absent. Unrelated external +changes survive. A value already equal to the requested value succeeds without a +write; a different external value reports a conflict. Invalid TOML, unsupported +value types and I/O errors leave the live mode alone and log one message per failed +attempt, without file contents or values. No automatic retry loop is installed. +The acknowledged value advances only after success. To reconcile a conflict, +restore the original disk value, select the externally saved mode, or restart to +load the file. Runtime edits do not rewrite the startup-only generated snapshot. + +The checked replacement re-reads the source after staging and rejects changed +bytes before commit. This is best-effort conflict detection, not an atomic +compare-and-swap with arbitrary external editors: an external write can still +race the final native replacement. File deletion is an I/O error, not permission +to recreate the user's file from cached content. + +Runtime persistence currently requires the verified build261 Windows x64 quit +method (RVA `0x43548c0`, native extent 411 bytes, 24-byte SPUD overwrite, complete +initial instruction fingerprint checked at installation). macOS and unmatched +clients keep the shortcut's existing session-only behavior and log that persistence +is unavailable. The editor/storage fixtures run on all supported build platforms; +they do not establish native game-hook compatibility. + +Normal quit stops admission, drains accepted work, then resumes the game's quit +request after observing native worker termination. Save failures do not prevent +exit. A genuine game veto is respected and is not retried automatically. A stalled +OS write can delay normal quit; F10 remains the escape path. With pending work, +F10 cancels queued requests and allows the active write up to 500 ms on an +independent native thread before terminating. With no pending/active write it +terminates immediately. No disk operation or wait runs in the key handler. +The existing ScreenManager.Update dispatcher supplies one idle callback; there +is no extra frame detour or per-frame logging. Hook controls have process lifetime; +hot unloading the mod is unsupported. + +The fixture runners also cover preserving edits, escaped values, conflicts, +coalescing, failed-save baselines, draining and cancellation. They use isolated +files and compile-time seams; no test switches or artificial delays ship in the mod. diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index c60c5c39f..d3558a1e3 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -662,6 +662,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 6756ed409..b6ad8756a 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index ded80a9ef..7f48e17d0 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 78b129750..b4d587d3e 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 7706e7c95..a80a08751 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 68b2bb4f7..3b245d2f2 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index dd8e462ac..eb3b92ae4 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b0726f8a9..838bc0811 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 1238fb805..8e74f49c4 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 1c45a5157..ccfe14a9b 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -661,6 +661,8 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump +# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" # Comma-separated ship hull names for which the instant-warp popup is always shown, overriding all automatic actions diff --git a/mods/src/config.cc b/mods/src/config.cc index 04f4db9f7..4d1e55a23 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1,5 +1,6 @@ #include "config.h" #include "config_save.h" +#include "patches/runtime_config.h" #include "file.h" #include "patches/mapkey.h" #include "prime/KeyCode.h" @@ -1421,11 +1422,14 @@ void Config::Load() try { Config::Save(parsed, File::Config(), false); + config = parsed; // First runtime comparison must match the file just created. } catch (const std::exception& error) { spdlog::error("Could not save default config: {}", error.what()); } } + runtime_config::Configure(config); + message.str(""); message << "Creating " << File::Vars() << " (final config file)"; spdlog::info(message.str()); diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc index 9e9a82b6d..f722c0ade 100644 --- a/mods/src/config_save.cc +++ b/mods/src/config_save.cc @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -27,6 +28,29 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& output.exceptions(std::ios::badbit | std::ios::failbit); output << header << config; const auto bytes = output.str(); + ReplaceConfigText(path, bytes); +} + +std::string ReadConfigText(const std::filesystem::path& path) +{ + std::ifstream input(path, std::ios::binary); + if (!input.is_open()) { + throw std::runtime_error("could not open config file"); + } + std::string text; + char buffer[8192]; + while (input.read(buffer, sizeof(buffer)) || input.gcount()) { + text.append(buffer, static_cast(input.gcount())); + } + if (!input.eof() || input.bad()) { + throw std::runtime_error("could not read config file"); + } + return text; +} + +bool ReplaceConfigText(const std::filesystem::path& path, std::string_view bytes, + std::optional expected) +{ (void)toml::parse(bytes); // Follow existing symlinks as the former ofstream save did. A sibling stays on @@ -59,6 +83,13 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& if (closed != 0) { throw std::system_error(errno, std::generic_category(), "could not close temporary config file"); } + // Recheck after staging, immediately before commit. Another editor can still + // race the native replacement; arbitrary external editors do not share our lock. + if (expected && ReadConfigText(path) != *expected) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + return false; + } #if _WIN32 // Let Windows retain the existing file's permissions and streams. A backup // protects the old contents in ReplaceFile's documented partial-failure cases. @@ -97,4 +128,5 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& } throw; } + return true; } diff --git a/mods/src/config_save.h b/mods/src/config_save.h index 2150f5225..8e87aa8a3 100644 --- a/mods/src/config_save.h +++ b/mods/src/config_save.h @@ -1,9 +1,17 @@ #pragma once #include +#include +#include #include #include // Synchronous whole-document output for startup, not a runtime setting editor. // Throws on failure; the caller owns reporting. Does not guarantee power-loss durability. void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header = {}); + +std::string ReadConfigText(const std::filesystem::path& path); +// Preserves the supplied text. With expected text, false means an external edit +// was detected before replacement. This is not a filesystem compare-and-swap. +bool ReplaceConfigText(const std::filesystem::path& path, std::string_view text, + std::optional expected = std::nullopt); diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index 8428a9da0..b017be5e5 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1,4 +1,5 @@ #include "config.h" +#include "patches/runtime_config.h" #include @@ -338,6 +339,7 @@ void CycleAutoConfirmInstantWarp(Config& config) } spdlog::info("Auto-confirm instant warp set to {}", state); + runtime_config::SaveWarpMode(state); } bool MoveOfficerCanvas(bool goLeft) @@ -496,7 +498,8 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) #ifdef _WIN32 if (MapKey::IsDown(GameFunction::Quit)) { - TerminateProcess(GetCurrentProcess(), 1); + runtime_config::ForceClose(); + return; } #elif defined(__APPLE__) if (MapKey::IsDown(GameFunction::Quit)) { @@ -1494,6 +1497,7 @@ void InstallHotkeyHooks() InstallShortcutHintHooks(); install_screen_manager_update_hook(); + runtime_config::Install(); #ifdef _MODDBG fleet_watch::InstallRuntimeProbe(); #endif diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc new file mode 100644 index 000000000..4129fd689 --- /dev/null +++ b/mods/src/patches/parts/runtime_config.cc @@ -0,0 +1,208 @@ +#include "patches/runtime_config.h" +#include "file.h" +#include "runtime_config_writer.h" +#include + +#if defined(_WIN32) && defined(_M_X64) +#include "patches/screen_update_hook.h" +#include +#include +#include +#include + +namespace +{ +// Installed hooks live until process exit. Retain this one control object rather +// than joining a worker from DLL teardown. No worker is started during Configure. +config_edit::RuntimeConfigWriter* writer = nullptr; +bool available = false; +std::atomic owner{0}; +std::atomic_bool forcing{false}; +std::mutex lifecycle; +bool draining = false, stopped = false, resume = false; +std::uint64_t vote = 0; +thread_local unsigned quit_depth = 0; +void (*request_quit)(int) = nullptr; + +void Report(config_edit::Outcome result) +{ + const char* reason = "write failed"; + switch (result) { + case config_edit::Outcome::Conflict: + reason = "file changed externally"; + break; + case config_edit::Outcome::InvalidDocument: + reason = "invalid TOML"; + break; + case config_edit::Outcome::Unsupported: + reason = "unsupported setting representation"; + break; + default: + break; + } + spdlog::warn("Could not persist ui.auto_confirm_instant_warp: {}; active mode is unchanged", reason); +} + +bool WantsQuit(auto original) +{ + struct Depth { + Depth() + { ++quit_depth; } + ~Depth() + { --quit_depth; } + } depth; + std::uint64_t this_vote; + { + std::lock_guard lock(lifecycle); + this_vote = ++vote; + } + const bool allows = original(); + std::lock_guard lock(lifecycle); + if (stopped || !writer) + return allows; + // A later/nested game veto must not be overwritten by an older returning vote. + if (this_vote == vote) + resume = allows; + if (allows) { + draining = true; + writer->Stop(false); + } + return false; // Resume only after observing native worker exit, even after failure. +} + +void Update() +{ + DWORD unset = 0; + owner.compare_exchange_strong(unset, GetCurrentThreadId()); + if (forcing || owner != GetCurrentThreadId() || quit_depth) + return; + bool quit = false; + { + std::lock_guard lock(lifecycle); + if (!draining || stopped || !writer || !writer->PollStopped()) + return; + stopped = true; + quit = resume; + resume = false; // Consume before Unity callbacks; never retry a genuine veto. + } + if (quit) + request_quit(0); +} + +bool MatchesQuitMethod(const MethodInfo* method) +{ + // Verified build261 Windows x64: 411-byte native body vs SPUD's 24-byte + // overwrite. Pin complete initial instructions too; other builds stay session-only. + constexpr unsigned char bytes[]{0x48, 0x89, 0x5c, 0x24, 0x08, 0x56, 0x57, 0x41, 0x56, 0x48, + 0x83, 0xec, 0x40, 0x80, 0x3d, 0xad, 0xd2, 0x8d, 0x01, 0x00, + 0x75, 0x29, 0x48, 0x8d, 0x0d, 0x9b, 0xdf, 0x63, 0x01}; + auto base = reinterpret_cast(GetModuleHandleW(L"GameAssembly.dll")); + if (!base || !method || reinterpret_cast(method->methodPointer) != base + 0x43548c0) + return false; + DWORD64 image_base = 0; + const auto* extent = RtlLookupFunctionEntry(base + 0x43548c0, &image_base, nullptr); + return extent && image_base == base && extent->BeginAddress == 0x43548c0 && extent->EndAddress == 0x4354a5b + && std::memcmp(method->methodPointer, bytes, sizeof(bytes)) == 0; +} + +DWORD WINAPI FinishForceClose(void* handle) +{ + WaitForSingleObject(handle, 500); + CloseHandle(handle); + TerminateProcess(GetCurrentProcess(), 1); + return 0; +} +} // namespace +#elif _WIN32 +#include +#endif + +namespace runtime_config +{ +void Configure(const toml::table& loaded) +{ +#if defined(_WIN32) && defined(_M_X64) + if (writer) + return; + std::optional initial; + if (auto value = loaded["ui"]["auto_confirm_instant_warp"].value()) + initial = *value; + try { + writer = new config_edit::RuntimeConfigWriter(File::MakePath(File::Config()), initial, Report); + } catch (...) { + spdlog::warn("Runtime config persistence unavailable"); + } +#else + (void)loaded; +#endif +} + +void Install() +{ +#if defined(_WIN32) && defined(_M_X64) + static bool attempted = false; + if (attempted || !writer) + return; + attempted = true; + try { + auto helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Application"); + const auto* wants = helper.GetMethodInfo("Internal_ApplicationWantsToQuit", 0); + const auto* quit = helper.GetMethodInfo("Quit", 1); + auto base = reinterpret_cast(GetModuleHandleW(L"GameAssembly.dll")); + if (!MatchesQuitMethod(wants) || !quit || reinterpret_cast(quit->methodPointer) != base + 0x4351c00) + return; + request_quit = reinterpret_cast(quit->methodPointer); + available = install_screen_manager_update_hook() && register_screen_manager_update_callback(Update) + && SPUD_STATIC_DETOUR(wants->methodPointer, WantsQuit); + } catch (...) { + available = false; + } +#endif +} + +void SaveWarpMode(const char* mode) noexcept +{ + try { +#if defined(_WIN32) && defined(_M_X64) + if (available && !forcing && owner == GetCurrentThreadId() && !quit_depth) { + std::lock_guard lock(lifecycle); + if (!draining && writer->Submit(mode)) + return; + } +#else + (void)mode; +#endif + static bool reported = false; + if (!reported) { + reported = true; + spdlog::warn("ui.auto_confirm_instant_warp changed for this session; runtime persistence unavailable"); + } + } catch (...) { /* Persistence must not interrupt the shortcut's live effect. */ + } +} + +#if _WIN32 +void ForceClose() noexcept +{ +#if defined(_M_X64) + if (writer && owner == GetCurrentThreadId() && !quit_depth && writer->HasWork()) { + forcing = true; + HANDLE duplicate = nullptr; + if (auto handle = writer->NativeHandle(); + handle + && DuplicateHandle(GetCurrentProcess(), handle, GetCurrentProcess(), &duplicate, SYNCHRONIZE, FALSE, 0)) { + // Arm the independent deadline before taking any writer lock. A stalled + // filesystem operation or Unity callback cannot prolong this best effort. + if (auto closer = CreateThread(nullptr, 0, FinishForceClose, duplicate, 0, nullptr)) { + CloseHandle(closer); + writer->Stop(true); + return; + } + CloseHandle(duplicate); + } + } +#endif + TerminateProcess(GetCurrentProcess(), 1); +} +#endif +} // namespace runtime_config diff --git a/mods/src/patches/runtime_config.h b/mods/src/patches/runtime_config.h new file mode 100644 index 000000000..9c4c0a2f0 --- /dev/null +++ b/mods/src/patches/runtime_config.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace runtime_config +{ +// Startup only: retain the raw disk spelling as the optimistic comparison base. +void Configure(const toml::table& loaded); +void Install(); +void SaveWarpMode(const char* mode) noexcept; +#if _WIN32 +void ForceClose() noexcept; +#endif +} // namespace runtime_config diff --git a/mods/src/runtime_config_writer.cc b/mods/src/runtime_config_writer.cc new file mode 100644 index 000000000..1d8abf4b7 --- /dev/null +++ b/mods/src/runtime_config_writer.cc @@ -0,0 +1,131 @@ +#include "runtime_config_writer.h" + +#if _WIN32 +#include +#endif + +#ifndef CONFIG_EDIT_SAVE +#define CONFIG_EDIT_SAVE(editor, path, request) (editor).Save(path, request) +#endif + +namespace config_edit +{ +RuntimeConfigWriter::RuntimeConfigWriter(std::filesystem::path path, std::optional initial, Reporter report) + : path_(std::move(path)) + , saved_(std::move(initial)) + , report_(report) +{ +} + +RuntimeConfigWriter::~RuntimeConfigWriter() +{ + Stop(false); + if (worker_.joinable()) + worker_.join(); +} + +std::uint64_t RuntimeConfigWriter::Submit(std::string mode) +{ + if (mode != "none" && mode != "warp" && mode != "jump") + return 0; + std::lock_guard lock(mutex_); + if (stopping_) + return 0; + pending_ = Pending{++revision_, {"ui", "auto_confirm_instant_warp", saved_, std::move(mode)}}; + has_work_.store(true); + if (!worker_.joinable()) { + try { + worker_ = std::thread(&RuntimeConfigWriter::Run, this); + } catch (...) { + pending_.reset(); + has_work_.store(false); + completion_ = {revision_, Outcome::IoError}; + return 0; + } + } + wake_.notify_one(); + return revision_; +} + +void RuntimeConfigWriter::Stop(bool cancel_pending) +{ + std::lock_guard lock(mutex_); + stopping_ = true; + if (cancel_pending && pending_) { + completion_ = {pending_->revision, Outcome::Cancelled}; + pending_.reset(); + } + wake_.notify_one(); +} + +RuntimeConfigWriter::Completion RuntimeConfigWriter::LastCompletion() +{ + std::lock_guard lock(mutex_); + return completion_; +} + +void RuntimeConfigWriter::Run() +{ + for (;;) { + Pending work; + { + std::unique_lock lock(mutex_); + wake_.wait(lock, [&] { return stopping_ || pending_.has_value(); }); + if (!pending_) + break; + work = std::move(*pending_); + pending_.reset(); + } + Outcome outcome; + try { + outcome = CONFIG_EDIT_SAVE(editor_, path_, work.edit); + } catch (...) { + outcome = Outcome::IoError; + } + { + std::lock_guard lock(mutex_); + if (outcome == Outcome::Saved || outcome == Outcome::AlreadySaved) { + // Rebase our queued intent over our own successful write, never over a + // conflicting external edit. Failed saves leave the acknowledged value alone. + if (pending_ && pending_->edit.expected == saved_) + pending_->edit.expected = work.edit.desired; + saved_ = work.edit.desired; + } + if (work.revision >= completion_.revision) + completion_ = {work.revision, outcome}; + has_work_.store(pending_.has_value()); + } + if (report_ && outcome != Outcome::Saved && outcome != Outcome::AlreadySaved) { + try { + report_(outcome); + } catch (...) { /* Diagnostics cannot kill the worker. */ + } + } + } + has_work_.store(false); + finished_.store(true); +} + +bool RuntimeConfigWriter::PollStopped() +{ + if (!worker_.joinable()) { + std::lock_guard lock(mutex_); + return stopping_; + } +#if _WIN32 + if (WaitForSingleObject(worker_.native_handle(), 0) != WAIT_OBJECT_0) + return false; +#else + // The game adapter is Windows-only until native macOS quit integration is validated. + if (!finished_.load()) + return false; +#endif + worker_.join(); + return true; +} + +#if _WIN32 +void* RuntimeConfigWriter::NativeHandle() +{ return worker_.joinable() ? worker_.native_handle() : nullptr; } +#endif +} // namespace config_edit diff --git a/mods/src/runtime_config_writer.h b/mods/src/runtime_config_writer.h new file mode 100644 index 000000000..970a37d04 --- /dev/null +++ b/mods/src/runtime_config_writer.h @@ -0,0 +1,54 @@ +#pragma once + +#include "toml_editor.h" +#include +#include +#include +#include +#include + +namespace config_edit +{ +// One owner for the configured file and its first runtime setting. Extend this +// owner when another setting is registered; do not create another writer for it. +class RuntimeConfigWriter +{ +public: + using Reporter = void (*)(Outcome); + struct Completion { + std::uint64_t revision = 0; + Outcome outcome = Outcome::AlreadySaved; + }; + RuntimeConfigWriter(std::filesystem::path path, std::optional initial, Reporter report = nullptr); + ~RuntimeConfigWriter(); // Tests/explicit owners only; game adapter has process lifetime. + std::uint64_t Submit(std::string mode); + void Stop(bool cancel_pending); + Completion LastCompletion(); + bool HasWork() const + { return has_work_.load(); } + // Owner thread only, like Submit. On Windows this observes native thread exit + // before joining; it never joins a still-running worker on a game callback. + bool PollStopped(); +#if _WIN32 + void* NativeHandle(); // Owner thread only; caller must duplicate before retaining. +#endif +private: + struct Pending { + std::uint64_t revision; + Request edit; + }; + void Run(); + std::filesystem::path path_; + std::optional saved_; + Reporter report_; + TomlEditor editor_; + std::mutex mutex_; + std::condition_variable wake_; + std::thread worker_; + std::optional pending_; + Completion completion_; + std::uint64_t revision_ = 0; + bool stopping_ = false; + std::atomic_bool has_work_{false}, finished_{false}; +}; +} // namespace config_edit diff --git a/mods/src/toml_editor.cc b/mods/src/toml_editor.cc new file mode 100644 index 000000000..356dc974a --- /dev/null +++ b/mods/src/toml_editor.cc @@ -0,0 +1,172 @@ +#include "toml_editor.h" +#include "config_save.h" + +#include +#include + +namespace config_edit +{ +namespace +{ + std::string Encode(const Value& value) + { + return std::visit( + [](const auto& item) { + const toml::value node(item); + std::ostringstream output; + output.exceptions(std::ios::badbit | std::ios::failbit); + output << toml::toml_formatter(node, toml::format_flags::none); + return output.str(); + }, + value); + } + + std::optional ReadValue(const toml::node* node) + { + if (!node) + return std::nullopt; + if (node->is_boolean()) + return Value{node->as_boolean()->get()}; + if (node->is_string()) + return Value{node->as_string()->get()}; + throw std::invalid_argument("unsupported setting type"); + } + + std::size_t BomSize(std::string_view text) + { return text.starts_with("\xef\xbb\xbf") ? 3 : 0; } + + // toml++ columns count Unicode codepoints, including CR; only LF starts a line. + std::size_t Offset(std::string_view text, toml::source_position target) + { + toml::source_position current{1, 1}; + for (auto i = BomSize(text);;) { + if (current == target) + return i; + if (i >= text.size()) + throw std::invalid_argument("invalid source region"); + const auto byte = static_cast(text[i]); + if (byte == '\n') { + ++current.line; + current.column = 1; + } else + ++current.column; + ++i; + // Input has already passed TOML/UTF-8 validation. + while (i < text.size() && (static_cast(text[i]) & 0xc0) == 0x80) + ++i; + } + } +} // namespace + +Prepared TomlEditor::Prepare(const std::string& text, const Request& request) +{ + try { + if (!cached_table_ || cached_text_ != text) { + auto parsed = toml::parse(text); + cached_table_.reset(); // Never pair new bytes with stale regions if allocation fails. + cached_text_ = text; + cached_table_ = std::move(parsed); + } + const auto& document = *cached_table_; + const auto* parent = document.get_as(request.section); + if (document.contains(request.section) && !parent) + return {Outcome::Unsupported, {}}; + const auto* node = parent ? parent->get(request.key) : nullptr; + const auto current = ReadValue(node); + if (current && *current == request.desired) + return {Outcome::AlreadySaved, {}}; + if (current != request.expected) + return {Outcome::Conflict, {}}; + + auto desired_document = document; + if (!parent) + desired_document.insert(request.section, toml::table{}); + std::visit( + [&](const auto& value) { + desired_document.get_as(request.section)->insert_or_assign(request.key, value); + }, + request.desired); + + // Accept a candidate only if a fresh parse has exactly the intended meaning. + auto accepts = [&](const std::string& candidate) { + try { + return toml::parse(candidate) == desired_document; + } catch (const toml::parse_error&) { + return false; + } + }; + const auto encoded = Encode(request.desired); + if (node) { + const auto begin = Offset(text, node->source().begin); + const auto end = Offset(text, node->source().end); + if (end < begin || end > text.size()) + return {Outcome::Unsupported, {}}; + auto result = text; + result.replace(begin, end - begin, encoded); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } + + const auto newline = text.find("\r\n") != std::string::npos ? "\r\n" : "\n"; + const auto assignment = Encode(Value{request.key}) + " = " + encoded; + if (parent && parent->is_inline()) { + const auto end = Offset(text, parent->source().end); + if (!end || text[end - 1] != '}') + return {Outcome::Unsupported, {}}; + auto result = text; + result.insert(end - 1, (parent->empty() ? " " : ", ") + assignment + " "); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } + if (parent) { + const auto begin = Offset(text, parent->source().begin); + if (begin < text.size() && text[begin] == '[') { + auto end = text.find('\n', begin); + auto result = text; + if (end == std::string::npos) + result += std::string(newline) + assignment + newline; + else + result.insert(end + 1, assignment + newline); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + } + // Dotted/implicit tables can sometimes be extended at the document root. + auto result = text; + result.insert(BomSize(text), Encode(Value{request.section}) + "." + assignment + newline); + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + } + auto result = text; + if (!result.empty() && result.back() != '\n') + result += newline; + result += "[" + Encode(Value{request.section}) + "]" + newline + assignment + newline; + if (accepts(result)) + return {Outcome::Prepared, std::move(result)}; + return {Outcome::Unsupported, {}}; + } catch (const toml::parse_error&) { + return {Outcome::InvalidDocument, {}}; + } catch (const std::invalid_argument&) { + return {Outcome::Unsupported, {}}; + } +} + +Outcome TomlEditor::Save(const std::filesystem::path& path, const Request& request) +{ + try { + const auto original = ReadConfigText(path); + auto edit = Prepare(original, request); + if (edit.outcome != Outcome::Prepared) + return edit.outcome; + if (!ReplaceConfigText(path, edit.text, original)) + return Outcome::Conflict; + // Source locations belong to the old document; refresh on the next request. + cached_table_.reset(); + cached_text_.clear(); + return Outcome::Saved; + } catch (const std::exception&) { + return Outcome::IoError; + } +} +} // namespace config_edit diff --git a/mods/src/toml_editor.h b/mods/src/toml_editor.h new file mode 100644 index 000000000..eaf0f1531 --- /dev/null +++ b/mods/src/toml_editor.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace config_edit +{ +using Value = std::variant; +struct Request { + std::string section, key; + std::optional expected; // Missing is distinct from a configured default. + Value desired; +}; +enum class Outcome { Prepared, Saved, AlreadySaved, Conflict, InvalidDocument, Unsupported, IoError, Cancelled }; +struct Prepared { + Outcome outcome; + std::string text; // Nonempty only when an edit has been prepared. +}; + +// Own on a single worker. Cache represents disk text, not live game configuration. +class TomlEditor +{ +public: + Prepared Prepare(const std::string& text, const Request& request); + Outcome Save(const std::filesystem::path& path, const Request& request); + +private: + std::string cached_text_; + std::optional cached_table_; +}; +} // namespace config_edit diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 index 6c80cd0e5..7ceb0995c 100644 --- a/tests/run-config-save.ps1 +++ b/tests/run-config-save.ps1 @@ -59,6 +59,18 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Config failure test compilation failed.' } & ./build/config-save-test/failure-test.exe (Join-Path $fixtureRoot 'failures') if ($LASTEXITCODE -ne 0) { throw 'Config failure regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/toml_editor_test.cc mods/src/toml_editor.cc mods/src/config_save.cc ` + /Febuild/config-save-test/editor-test.exe /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'TOML editor test compilation failed.' } + & ./build/config-save-test/editor-test.exe (Join-Path $fixtureRoot 'editor') + if ($LASTEXITCODE -ne 0) { throw 'TOML editor regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src "/I$TomlInclude" ` + tests/runtime_config_writer_test.cc /Febuild/config-save-test/worker-test.exe ` + /Fobuild/config-save-test/ -Wno-deprecated-literal-operator + if ($LASTEXITCODE -ne 0) { throw 'Runtime writer test compilation failed.' } + & ./build/config-save-test/worker-test.exe + if ($LASTEXITCODE -ne 0) { throw 'Runtime writer regression failed.' } } finally { Pop-Location } diff --git a/tests/run-config-save.sh b/tests/run-config-save.sh index c785669f2..bf9493892 100644 --- a/tests/run-config-save.sh +++ b/tests/run-config-save.sh @@ -13,3 +13,9 @@ clang++ -std=c++23 -I mods/src -I "$toml_include" \ clang++ -std=c++23 -I mods/src -I "$toml_include" \ tests/config_save_failure_test.cc -o "$test_root/failure-test" "$test_root/failure-test" "$test_root/failures" +clang++ -std=c++23 -I mods/src -I "$toml_include" \ + tests/toml_editor_test.cc mods/src/toml_editor.cc mods/src/config_save.cc -o "$test_root/editor-test" +"$test_root/editor-test" "$test_root/editor" +clang++ -std=c++23 -pthread -I mods/src -I "$toml_include" \ + tests/runtime_config_writer_test.cc -o "$test_root/worker-test" +"$test_root/worker-test" diff --git a/tests/runtime_config_writer_test.cc b/tests/runtime_config_writer_test.cc new file mode 100644 index 000000000..55eeb04f2 --- /dev/null +++ b/tests/runtime_config_writer_test.cc @@ -0,0 +1,125 @@ +#include "runtime_config_writer.h" +#include +#include +#include +#include + +using namespace config_edit; +using namespace std::chrono_literals; +namespace +{ +std::mutex gate; +std::condition_variable changed; +bool entered = false, released = false; +Outcome first_result = Outcome::Saved; +std::vector requests; +std::thread::id save_thread; +int reports = 0; + +void Check(bool value) +{ + if (!value) + throw std::runtime_error("worker fixture failed"); +} +Outcome Save(TomlEditor&, const std::filesystem::path&, const Request& request) +{ + std::unique_lock lock(gate); + save_thread = std::this_thread::get_id(); + requests.push_back(request); + if (requests.size() == 1) { + entered = true; + changed.notify_all(); + if (!changed.wait_for(lock, 5s, [] { return released; })) + throw std::runtime_error("fixture timed out"); + return first_result; + } + return Outcome::Saved; +} +void Report(Outcome) +{ + std::lock_guard lock(gate); + ++reports; +} +void Begin(Outcome result) +{ + entered = released = false; + requests.clear(); + reports = 0; + first_result = result; +} +void AwaitSave() +{ + std::unique_lock lock(gate); + Check(changed.wait_for(lock, 5s, [] { return entered; })); +} +void Release() +{ + std::lock_guard lock(gate); + released = true; + changed.notify_all(); +} +} // namespace + +// Block at the real worker's save boundary to exercise scheduling deterministically. +#define CONFIG_EDIT_SAVE(editor, path, request) Save(editor, path, request) +#include "../mods/src/runtime_config_writer.cc" + +int main() +{ + try { + for (auto outcome : {Outcome::Saved, Outcome::AlreadySaved, Outcome::Conflict, Outcome::IoError}) { + Begin(outcome); + RuntimeConfigWriter writer("unused", Value{std::string("none")}, Report); + Check(writer.Submit("invalid") == 0); + Check(writer.Submit("warp") == 1); + AwaitSave(); + Check(writer.HasWork()); + Check(writer.Submit("jump") == 2); + Check(writer.Submit("none") == 3); + writer.Stop(false); + Check(!writer.PollStopped()); + Check(writer.Submit("warp") == 0); + Release(); + auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(!writer.HasWork()); + Check(requests.size() == 2); + Check(requests[1].desired == Value{std::string("none")}); + const bool success = outcome == Outcome::Saved || outcome == Outcome::AlreadySaved; + Check(requests[1].expected == std::optional{std::string(success ? "warp" : "none")}); + Check(reports == (success ? 0 : 1)); + Check(save_thread != std::this_thread::get_id()); + Check(writer.LastCompletion().revision == 3); + Check(writer.LastCompletion().outcome == Outcome::Saved); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}); + writer.Submit("warp"); + AwaitSave(); + writer.Submit("jump"); + writer.Stop(true); + Check(writer.LastCompletion().revision == 2); + Check(writer.LastCompletion().outcome == Outcome::Cancelled); + Release(); + auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.size() == 1); + Check(writer.LastCompletion().revision == 2); + Check(writer.LastCompletion().outcome == Outcome::Cancelled); + } + RuntimeConfigWriter idle("unused", std::nullopt); + idle.Stop(false); + Check(idle.PollStopped()); + std::cout << "Runtime writer coalescing/drain/cancel fixtures passed\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/toml_editor_test.cc b/tests/toml_editor_test.cc new file mode 100644 index 000000000..c51711d47 --- /dev/null +++ b/tests/toml_editor_test.cc @@ -0,0 +1,63 @@ +#include "config_save.h" +#include "toml_editor.h" +#include +#include +#include + +using namespace config_edit; +static Request Mode(std::optional expected, std::string desired) +{ return {"ui", "auto_confirm_instant_warp", std::move(expected), std::move(desired)}; } +int main(int argc, char** argv) +{ + assert(argc == 2); + TomlEditor editor; + const auto request = Mode(Value{std::string("none")}, "warp"); + for (const std::string original : + {"# header\n[ui] # section\nauto_confirm_instant_warp = 'none' # comment\nother = 9\n", + "\xef\xbb\xbf# BOM\r\n[ui]\r\nauto_confirm_instant_warp = 'none' # comment\r\n", + "ui = { other = '\xc3\xa9', auto_confirm_instant_warp = 'none' } # tail\n", + "ui.auto_confirm_instant_warp = '''none'''\n[elsewhere]\nx = nan\n", + "[\"ui\"]\n\"auto_confirm_instant_warp\" = \"\"\"none\"\"\""}) { + auto edit = editor.Prepare(original, request); + assert(edit.outcome == Outcome::Prepared); + const auto parsed = toml::parse(edit.text); + assert(parsed["ui"]["auto_confirm_instant_warp"].value() == "warp"); + // Only the value token changes; comments, surrounding bytes and line endings remain. + const auto old = original.find("'''none'''") != std::string::npos ? "'''none'''" + : original.find("\"\"\"none\"\"\"") != std::string::npos ? "\"\"\"none\"\"\"" + : "'none'"; + auto expected = original; + expected.replace(expected.find(old), std::string(old).size(), "\"warp\""); + assert(edit.text == expected); + } + for (const std::string original : + {"", "# comments only", "[ui]", "[ui]\nother = 4\n[other]\nx=3\n", "ui = {} # inline\n", "ui = {other = 4}\n", + "ui.other = 4\n", "[ui.child]\nx = 4\n"}) { + const auto edit = editor.Prepare(original, Mode(std::nullopt, "jump")); + assert(edit.outcome == Outcome::Prepared); + assert(toml::parse(edit.text)["ui"]["auto_confirm_instant_warp"].value() == "jump"); + } + const std::string special = "quotes \"'\\\n[ui]\nauto_confirm_instant_warp = 'jump'\n\xc3\xa9"; + const auto escaped = editor.Prepare("ui = {auto_confirm_instant_warp='none'}", Mode(request.expected, special)); + assert(escaped.outcome == Outcome::Prepared); + assert(toml::parse(escaped.text)["ui"]["auto_confirm_instant_warp"].value() == special); + assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='jump'", request).outcome == Outcome::Conflict); + assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='warp'", request).outcome == Outcome::AlreadySaved); + assert(editor.Prepare("ui = [", request).outcome == Outcome::InvalidDocument); + assert(editor.Prepare("ui = 3", request).outcome == Outcome::Unsupported); + + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string initial = "[ui]\nauto_confirm_instant_warp='none'\nother=true # preserve\n"; + ReplaceConfigText(path, initial); + const auto external = initial + "# external comment\n"; + ReplaceConfigText(path, external); + assert(editor.Save(path, request) == Outcome::Saved); + assert(ReadConfigText(path).ends_with("# external comment\n")); + ReplaceConfigText(path, "[ui]\nauto_confirm_instant_warp='jump'\n"); + assert(editor.Save(path, request) == Outcome::Conflict); + assert(!ReplaceConfigText(path, initial, external)); + assert(toml::parse(ReadConfigText(path))["ui"]["auto_confirm_instant_warp"].value() == "jump"); + std::cout << "TOML editor preservation/conflict fixtures passed\n"; +} From 3a68f8521c4438c5c1bc1305185fbefab9034e76 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 21:43:01 -0500 Subject: [PATCH 10/20] Cover Unicode key coordinates and missing TOML settings --- tests/toml_editor_test.cc | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/toml_editor_test.cc b/tests/toml_editor_test.cc index c51711d47..e23ccee1f 100644 --- a/tests/toml_editor_test.cc +++ b/tests/toml_editor_test.cc @@ -45,6 +45,23 @@ int main(int argc, char** argv) assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='warp'", request).outcome == Outcome::AlreadySaved); assert(editor.Prepare("ui = [", request).outcome == Outcome::InvalidDocument); assert(editor.Prepare("ui = 3", request).outcome == Outcome::Unsupported); + assert(editor.Prepare("[ui]\nother=true", request).outcome == Outcome::Conflict); + // Decoded key identities may contain dots, Unicode and combining codepoints. + // Columns are parser coordinates, not UTF-8 byte counts or display widths. + const std::string unicode_section = "ui.\xc3\xa9"; + const std::string unicode_key = "e\xcc\x81.mode"; + const std::string unicode_document = + "[\"" + unicode_section + "\"]\r\n\"" + unicode_key + "\" = 'none' # untouched\r\n"; + const auto unicode_edit = + editor.Prepare(unicode_document, {unicode_section, unicode_key, Value{std::string("none")}, std::string("jump")}); + assert(unicode_edit.outcome == Outcome::Prepared); + auto unicode_expected = unicode_document; + unicode_expected.replace(unicode_expected.find("'none'"), 6, "\"jump\""); + assert(unicode_edit.text == unicode_expected); + const std::string combining = "ui = { other = 'e\xcc\x81', auto_confirm_instant_warp = 'none' }\n"; + auto combining_expected = combining; + combining_expected.replace(combining_expected.find("'none'"), 6, "\"warp\""); + assert(editor.Prepare(combining, request).text == combining_expected); const std::filesystem::path root(argv[1]); std::filesystem::create_directories(root); @@ -59,5 +76,9 @@ int main(int argc, char** argv) assert(editor.Save(path, request) == Outcome::Conflict); assert(!ReplaceConfigText(path, initial, external)); assert(toml::parse(ReadConfigText(path))["ui"]["auto_confirm_instant_warp"].value() == "jump"); + const auto absent = root / "absent.toml"; + assert(!std::filesystem::exists(absent)); + assert(editor.Save(absent, request) == Outcome::IoError); + assert(!std::filesystem::exists(absent)); std::cout << "TOML editor preservation/conflict fixtures passed\n"; } From bbb0031bbd72f93a2b8a874927393796027c8513 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 21:53:43 -0500 Subject: [PATCH 11/20] Pass idle quits through and exercise native save lifecycle --- docs/config-save.md | 11 ++- mods/src/patches/parts/runtime_config.cc | 21 +++++- mods/src/patches/runtime_config.h | 2 +- tests/run-config-save.ps1 | 24 +++++++ tests/runtime_config_fixture.h | 40 +++++++++++ tests/runtime_config_test.cc | 88 ++++++++++++++++++++++++ tests/toml_editor_test.cc | 10 +++ 7 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 tests/runtime_config_fixture.h create mode 100644 tests/runtime_config_test.cc diff --git a/docs/config-save.md b/docs/config-save.md index 811340b42..f09068f86 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -52,7 +52,8 @@ exactly the intended document. Values are typed booleans or strings and encoded by toml++; quotes, backslashes and newlines cannot become new TOML instructions. Each request compares the selected value against the last acknowledged disk value, -including its original spelling and whether it was absent. Unrelated external +including whether it was absent. String quoting/escape spelling is not part of +that semantic comparison. Unrelated external changes survive. A value already equal to the requested value succeeds without a write; a different external value reports a conflict. Invalid TOML, unsupported value types and I/O errors leave the live mode alone and log one message per failed @@ -74,7 +75,8 @@ clients keep the shortcut's existing session-only behavior and log that persiste is unavailable. The editor/storage fixtures run on all supported build platforms; they do not establish native game-hook compatibility. -Normal quit stops admission, drains accepted work, then resumes the game's quit +An idle normal quit closes admission and passes the original vote through without +replaying quit. When work is active, normal quit stops admission, drains accepted work, then resumes the game's quit request after observing native worker termination. Save failures do not prevent exit. A genuine game veto is respected and is not retried automatically. A stalled OS write can delay normal quit; F10 remains the escape path. With pending work, @@ -88,3 +90,8 @@ hot unloading the mod is unsupported. The fixture runners also cover preserving edits, escaped values, conflicts, coalescing, failed-save baselines, draining and cancellation. They use isolated files and compile-time seams; no test switches or artificial delays ship in the mod. +The Windows adapter fixture executes the production lifecycle functions with +controlled worker/Unity boundaries. Separate child processes exercise real native +force-close calls, including a stalled cancellation caller and the 500 ms wait. +Its 5-second watchdog allows scheduling overhead; this is not a hard real-time +deadline guarantee or evidence that the current game detour fired. diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc index 4129fd689..b09ad5239 100644 --- a/mods/src/patches/parts/runtime_config.cc +++ b/mods/src/patches/parts/runtime_config.cc @@ -1,3 +1,6 @@ +#ifdef CONFIG_RUNTIME_TEST +#include CONFIG_RUNTIME_TEST // Isolated fixture substitutes Unity/worker boundaries only. +#else #include "patches/runtime_config.h" #include "file.h" #include "runtime_config_writer.h" @@ -9,6 +12,10 @@ #include #include #include +#endif +#endif + +#if defined(_WIN32) && defined(_M_X64) namespace { @@ -64,8 +71,16 @@ bool WantsQuit(auto original) if (this_vote == vote) resume = allows; if (allows) { - draining = true; writer->Stop(false); + // Close admission before checking: Submit shares lifecycle, so no later + // request can race an idle exit. An already-deferred quit still observes + // native thread exit through Update before resuming. + if (!draining && !writer->HasWork() && resume) { + stopped = true; + resume = false; + return true; + } + draining = true; } return false; // Resume only after observing native worker exit, even after failure. } @@ -89,6 +104,7 @@ void Update() request_quit(0); } +#ifndef CONFIG_RUNTIME_TEST bool MatchesQuitMethod(const MethodInfo* method) { // Verified build261 Windows x64: 411-byte native body vs SPUD's 24-byte @@ -104,6 +120,7 @@ bool MatchesQuitMethod(const MethodInfo* method) return extent && image_base == base && extent->BeginAddress == 0x43548c0 && extent->EndAddress == 0x4354a5b && std::memcmp(method->methodPointer, bytes, sizeof(bytes)) == 0; } +#endif DWORD WINAPI FinishForceClose(void* handle) { @@ -119,6 +136,7 @@ DWORD WINAPI FinishForceClose(void* handle) namespace runtime_config { +#ifndef CONFIG_RUNTIME_TEST void Configure(const toml::table& loaded) { #if defined(_WIN32) && defined(_M_X64) @@ -159,6 +177,7 @@ void Install() } #endif } +#endif void SaveWarpMode(const char* mode) noexcept { diff --git a/mods/src/patches/runtime_config.h b/mods/src/patches/runtime_config.h index 9c4c0a2f0..9949cf595 100644 --- a/mods/src/patches/runtime_config.h +++ b/mods/src/patches/runtime_config.h @@ -3,7 +3,7 @@ namespace runtime_config { -// Startup only: retain the raw disk spelling as the optimistic comparison base. +// Startup only: retain the semantic disk value as the optimistic comparison base. void Configure(const toml::table& loaded); void Install(); void SaveWarpMode(const char* mode) noexcept; diff --git a/tests/run-config-save.ps1 b/tests/run-config-save.ps1 index 7ceb0995c..a8005a4c2 100644 --- a/tests/run-config-save.ps1 +++ b/tests/run-config-save.ps1 @@ -71,6 +71,30 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Runtime writer test compilation failed.' } & ./build/config-save-test/worker-test.exe if ($LASTEXITCODE -ne 0) { throw 'Runtime writer regression failed.' } + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Itests ` + tests/runtime_config_test.cc /Febuild/config-save-test/adapter-test.exe /Fobuild/config-save-test/ + if ($LASTEXITCODE -ne 0) { throw 'Native adapter test compilation failed.' } + & ./build/config-save-test/adapter-test.exe + if ($LASTEXITCODE -ne 0) { throw 'Native adapter regression failed.' } + foreach ($mode in @('idle', 'deadline', 'finished', 'missing-handle')) { + $outputPath = Join-Path $fixtureRoot ('force-' + $mode + '.txt') + $timer = [Diagnostics.Stopwatch]::StartNew() + $child = Start-Process -FilePath (Join-Path $repoRoot 'build/config-save-test/adapter-test.exe') ` + -ArgumentList $mode -WindowStyle Hidden -PassThru -RedirectStandardOutput $outputPath + if (-not $child.WaitForExit(5000)) { + $child.Kill() + throw "Force-close fixture stalled: $mode" + } + $child.Refresh() + if ($child.ExitCode -ne 1) { throw "Force-close fixture did not terminate: $mode" } + if ($mode -eq 'deadline') { + if ($timer.ElapsedMilliseconds -lt 450 -or + (Get-Content $outputPath -Raw) -notmatch 'pending cancellation requested') { + throw 'Deadline did not allow best effort and request cancellation.' + } + } + } + Write-Output 'Native force-close child-process fixtures passed (500ms requested deadline; 5s harness watchdog).' } finally { Pop-Location } diff --git a/tests/runtime_config_fixture.h b/tests/runtime_config_fixture.h new file mode 100644 index 000000000..5866725fc --- /dev/null +++ b/tests/runtime_config_fixture.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace spdlog +{ +template void warn(const char*, Args&&...) {} +} // namespace spdlog +namespace config_edit +{ +enum class Outcome { Conflict, InvalidDocument, Unsupported }; +// Controllable boundary; the fixture below includes the actual adapter bodies. +struct RuntimeConfigWriter { + bool work = false, stopped = false, finished = false, cancelled = false; + bool block_cancel = false; + unsigned submissions = 0; + HANDLE handle = nullptr; + bool HasWork() const + { return work; } + void Stop(bool cancel) + { + stopped = true; + cancelled = cancel; + if (cancel && block_cancel) { + std::puts("pending cancellation requested"); + std::fflush(stdout); + Sleep(INFINITE); // Deadline must be independent of this stalled caller. + } + } + bool PollStopped() const + { return finished; } + void* NativeHandle() const + { return handle; } + unsigned Submit(const char*) + { return stopped ? 0 : ++submissions; } +}; +} // namespace config_edit diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc new file mode 100644 index 000000000..cb02be716 --- /dev/null +++ b/tests/runtime_config_test.cc @@ -0,0 +1,88 @@ +#define CONFIG_RUNTIME_TEST "runtime_config_fixture.h" +#include "../mods/src/patches/parts/runtime_config.cc" +#include +#include +#include + +namespace +{ +config_edit::RuntimeConfigWriter fixture; +unsigned resumes = 0; +void Reset() +{ + fixture = {}; + writer = &fixture; + available = true; + owner = GetCurrentThreadId(); + forcing = false; + draining = stopped = resume = false; + vote = 0; + resumes = 0; + request_quit = [](int) { ++resumes; }; +} +} // namespace +int main(int argc, char** argv) +{ + Reset(); + if (argc == 2) { + const std::string_view mode(argv[1]); + fixture.work = mode != "idle"; + fixture.block_cancel = mode == "deadline"; + if (mode != "missing-handle") + fixture.handle = CreateEventW(nullptr, TRUE, mode == "finished", nullptr); + runtime_config::ForceClose(); + Sleep(10000); // Parent kills this fixture if the independent deadline fails. + return 9; + } + assert(WantsQuit([] { return true; })); + assert(fixture.stopped && stopped && !draining); + runtime_config::SaveWarpMode("warp"); + assert(fixture.submissions == 0); + Update(); + assert(resumes == 0); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { return false; })); + assert(!fixture.stopped && !draining); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { return true; })); + assert(fixture.stopped && draining); + Update(); + assert(resumes == 0); + fixture.work = false; // Disk work done is not yet native worker termination. + Update(); + assert(resumes == 0); + fixture.finished = true; + std::thread foreign([] { + Update(); + runtime_config::SaveWarpMode("jump"); + }); + foreign.join(); + assert(resumes == 0 && fixture.submissions == 0); + request_quit = [](int) { + ++resumes; + assert(!WantsQuit([] { return false; })); // A genuine resumed veto is final. + }; + Update(); + Update(); + assert(resumes == 1); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { + assert(!WantsQuit([] { return false; })); + return true; // Older outer vote must not replace the later veto. + })); + fixture.finished = true; + Update(); + assert(resumes == 0); + + Reset(); + assert(!WantsQuit([] { return false; })); + runtime_config::SaveWarpMode("warp"); + assert(fixture.submissions == 1); // Veto leaves ordinary save admission open. + std::puts("Native adapter idle/drain/veto/owner fixtures passed"); +} diff --git a/tests/toml_editor_test.cc b/tests/toml_editor_test.cc index e23ccee1f..895b44af7 100644 --- a/tests/toml_editor_test.cc +++ b/tests/toml_editor_test.cc @@ -62,6 +62,16 @@ int main(int argc, char** argv) auto combining_expected = combining; combining_expected.replace(combining_expected.find("'none'"), 6, "\"warp\""); assert(editor.Prepare(combining, request).text == combining_expected); + const Request boolean{"ui", "enabled", Value{false}, true}; + assert(editor.Prepare("[ui]\nenabled = false # keep\n", boolean).text == "[ui]\nenabled = true # keep\n"); + assert(editor.Prepare("[ui]\nenabled = true", boolean).outcome == Outcome::AlreadySaved); + assert(editor.Prepare("[ui]\nenabled = 'false'", boolean).outcome == Outcome::Conflict); + const std::string escaped_key = "a.\"b\\c"; + for (const std::string document : {"[ui]\n", "ui = {}\n"}) { + const auto inserted = editor.Prepare(document, {"ui", escaped_key, std::nullopt, true}); + assert(inserted.outcome == Outcome::Prepared); + assert(toml::parse(inserted.text)["ui"][escaped_key].value() == true); + } const std::filesystem::path root(argv[1]); std::filesystem::create_directories(root); From b3bfe893c5c3dc586847f00a161fac56ccc2d6c1 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 11 Sep 2026 22:01:57 -0500 Subject: [PATCH 12/20] Publish force-close cancellation before deadline setup --- docs/config-save.md | 4 ++- mods/src/patches/parts/runtime_config.cc | 3 +- mods/src/runtime_config_writer.cc | 24 ++++++++++++++-- mods/src/runtime_config_writer.h | 8 ++++-- tests/runtime_config_fixture.h | 2 ++ tests/runtime_config_writer_test.cc | 35 ++++++++++++++++++++---- 6 files changed, 63 insertions(+), 13 deletions(-) diff --git a/docs/config-save.md b/docs/config-save.md index f09068f86..ee363f9ff 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -78,7 +78,9 @@ they do not establish native game-hook compatibility. An idle normal quit closes admission and passes the original vote through without replaying quit. When work is active, normal quit stops admission, drains accepted work, then resumes the game's quit request after observing native worker termination. Save failures do not prevent -exit. A genuine game veto is respected and is not retried automatically. A stalled +exit. A genuine game veto is respected and is not retried automatically. If the +game vetoes after draining, persistence remains stopped for that session; +subsequent mode shortcuts still affect gameplay but are session-only. A stalled OS write can delay normal quit; F10 remains the escape path. With pending work, F10 cancels queued requests and allows the active write up to 500 ms on an independent native thread before terminating. With no pending/active write it diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc index b09ad5239..2bbe7bcd3 100644 --- a/mods/src/patches/parts/runtime_config.cc +++ b/mods/src/patches/parts/runtime_config.cc @@ -205,7 +205,8 @@ void ForceClose() noexcept { #if defined(_M_X64) if (writer && owner == GetCurrentThreadId() && !quit_depth && writer->HasWork()) { - forcing = true; + forcing = true; + writer->RequestCancelPending(); HANDLE duplicate = nullptr; if (auto handle = writer->NativeHandle(); handle diff --git a/mods/src/runtime_config_writer.cc b/mods/src/runtime_config_writer.cc index 1d8abf4b7..15a89fabc 100644 --- a/mods/src/runtime_config_writer.cc +++ b/mods/src/runtime_config_writer.cc @@ -29,7 +29,7 @@ std::uint64_t RuntimeConfigWriter::Submit(std::string mode) if (mode != "none" && mode != "warp" && mode != "jump") return 0; std::lock_guard lock(mutex_); - if (stopping_) + if (stopping_ || cancel_pending_.load()) return 0; pending_ = Pending{++revision_, {"ui", "auto_confirm_instant_warp", saved_, std::move(mode)}}; has_work_.store(true); @@ -49,6 +49,8 @@ std::uint64_t RuntimeConfigWriter::Submit(std::string mode) void RuntimeConfigWriter::Stop(bool cancel_pending) { + if (cancel_pending) + RequestCancelPending(); std::lock_guard lock(mutex_); stopping_ = true; if (cancel_pending && pending_) { @@ -58,6 +60,12 @@ void RuntimeConfigWriter::Stop(bool cancel_pending) wake_.notify_one(); } +void RuntimeConfigWriter::RequestCancelPending() +{ + cancel_pending_.store(true); + wake_.notify_one(); +} + RuntimeConfigWriter::Completion RuntimeConfigWriter::LastCompletion() { std::lock_guard lock(mutex_); @@ -70,7 +78,13 @@ void RuntimeConfigWriter::Run() Pending work; { std::unique_lock lock(mutex_); - wake_.wait(lock, [&] { return stopping_ || pending_.has_value(); }); + wake_.wait(lock, [&] { return stopping_ || cancel_pending_.load() || pending_.has_value(); }); + if (cancel_pending_.load()) { + stopping_ = true; + if (pending_) + completion_ = {pending_->revision, Outcome::Cancelled}; + pending_.reset(); + } if (!pending_) break; work = std::move(*pending_); @@ -93,7 +107,6 @@ void RuntimeConfigWriter::Run() } if (work.revision >= completion_.revision) completion_ = {work.revision, outcome}; - has_work_.store(pending_.has_value()); } if (report_ && outcome != Outcome::Saved && outcome != Outcome::AlreadySaved) { try { @@ -101,6 +114,11 @@ void RuntimeConfigWriter::Run() } catch (...) { /* Diagnostics cannot kill the worker. */ } } + { + std::lock_guard lock(mutex_); + // A diagnostic callback is still active worker work, even after disk I/O. + has_work_.store(pending_.has_value()); + } } has_work_.store(false); finished_.store(true); diff --git a/mods/src/runtime_config_writer.h b/mods/src/runtime_config_writer.h index 970a37d04..030fe2c0f 100644 --- a/mods/src/runtime_config_writer.h +++ b/mods/src/runtime_config_writer.h @@ -23,8 +23,10 @@ class RuntimeConfigWriter ~RuntimeConfigWriter(); // Tests/explicit owners only; game adapter has process lifetime. std::uint64_t Submit(std::string mode); void Stop(bool cancel_pending); - Completion LastCompletion(); - bool HasWork() const + // Publish force-close cancellation before native deadline setup, without a lock. + void RequestCancelPending(); + Completion LastCompletion(); + bool HasWork() const { return has_work_.load(); } // Owner thread only, like Submit. On Windows this observes native thread exit // before joining; it never joins a still-running worker on a game callback. @@ -49,6 +51,6 @@ class RuntimeConfigWriter Completion completion_; std::uint64_t revision_ = 0; bool stopping_ = false; - std::atomic_bool has_work_{false}, finished_{false}; + std::atomic_bool has_work_{false}, finished_{false}, cancel_pending_{false}; }; } // namespace config_edit diff --git a/tests/runtime_config_fixture.h b/tests/runtime_config_fixture.h index 5866725fc..948dec866 100644 --- a/tests/runtime_config_fixture.h +++ b/tests/runtime_config_fixture.h @@ -20,6 +20,8 @@ struct RuntimeConfigWriter { HANDLE handle = nullptr; bool HasWork() const { return work; } + void RequestCancelPending() + { cancelled = true; } void Stop(bool cancel) { stopped = true; diff --git a/tests/runtime_config_writer_test.cc b/tests/runtime_config_writer_test.cc index 55eeb04f2..b79a93df3 100644 --- a/tests/runtime_config_writer_test.cc +++ b/tests/runtime_config_writer_test.cc @@ -14,7 +14,8 @@ bool entered = false, released = false; Outcome first_result = Outcome::Saved; std::vector requests; std::thread::id save_thread; -int reports = 0; +int reports = 0; +bool block_report = false, release_report = false; void Check(bool value) { @@ -37,8 +38,11 @@ Outcome Save(TomlEditor&, const std::filesystem::path&, const Request& request) } void Report(Outcome) { - std::lock_guard lock(gate); + std::unique_lock lock(gate); ++reports; + changed.notify_all(); + if (block_report) + Check(changed.wait_for(lock, 5s, [] { return release_report; })); } void Begin(Outcome result) { @@ -46,6 +50,7 @@ void Begin(Outcome result) requests.clear(); reports = 0; first_result = result; + block_report = release_report = false; } void AwaitSave() { @@ -101,9 +106,11 @@ int main() writer.Submit("warp"); AwaitSave(); writer.Submit("jump"); - writer.Stop(true); - Check(writer.LastCompletion().revision == 2); - Check(writer.LastCompletion().outcome == Outcome::Cancelled); + writer.Stop(false); // Force-close may arrive during an orderly drain. + writer.RequestCancelPending(); + Check(writer.Submit("none") == 0); + // Hold the force-close caller before Stop(true), while the active save + // completes. Publication alone must prevent the queued save from starting. Release(); auto deadline = std::chrono::steady_clock::now() + 5s; while (!writer.PollStopped()) { @@ -113,6 +120,24 @@ int main() Check(requests.size() == 1); Check(writer.LastCompletion().revision == 2); Check(writer.LastCompletion().outcome == Outcome::Cancelled); + writer.Stop(true); + } + Begin(Outcome::IoError); + { + block_report = true; + RuntimeConfigWriter writer("unused", Value{std::string("none")}, Report); + writer.Submit("warp"); + AwaitSave(); + Release(); + { + std::unique_lock lock(gate); + Check(changed.wait_for(lock, 5s, [] { return reports == 1; })); + Check(writer.HasWork()); // Logging still executing must not look idle. + writer.Stop(false); + Check(!writer.PollStopped()); + release_report = true; + changed.notify_all(); + } } RuntimeConfigWriter idle("unused", std::nullopt); idle.Stop(false); From a523ec0ed142193cf3aa499a7b82cc0d4619498e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 13 Sep 2026 03:50:53 -0500 Subject: [PATCH 13/20] Support runtime numeric settings with per-key save recovery --- docs/config-save.md | 40 +++++-- mods/src/patches/parts/runtime_config.cc | 76 ++++++++++++-- mods/src/patches/runtime_config.h | 8 ++ mods/src/runtime_config_writer.cc | 87 ++++++++++++---- mods/src/runtime_config_writer.h | 36 +++++-- mods/src/toml_editor.cc | 7 ++ mods/src/toml_editor.h | 3 +- tests/runtime_config_fixture.h | 25 ++++- tests/runtime_config_test.cc | 40 +++++++ tests/runtime_config_writer_test.cc | 126 +++++++++++++++++++++-- tests/toml_editor_test.cc | 22 ++++ 11 files changed, 413 insertions(+), 57 deletions(-) diff --git a/docs/config-save.md b/docs/config-save.md index ee363f9ff..f2a704044 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -40,28 +40,53 @@ Native behavior references: ## Runtime edits The instant-warp mode shortcut changes the active mode immediately, then asks one -worker to persist `ui.auto_confirm_instant_warp`. It retains one pending value; -new presses replace that pending value while an active save finishes. The worker -starts only on the first request. It does not read game objects or call Unity. +worker to persist `ui.auto_confirm_instant_warp`. The same worker can serve other +explicitly registered keys. Each key retains its own acknowledged value and at +most one pending request; new submissions replace that key's pending value while +an active save finishes. Registration closes when the worker starts on its first +request. Unregistered keys are rejected. The worker does not read game objects or +call Unity. + +A submission can delay its save until a quiet interval has elapsed. Replacing a +pending request restarts that key's interval, without delaying other ready keys. +Normal quit drains accepted requests, including delayed ones; cancellation wakes +the worker and discards pending requests. There is one worker for the file, with +no per-control threads or timers. Live sliders and shortcut editing in the child +settings work use these capabilities; this branch retains instant warp as its +only registered game setting. Each feature owns its key registration, validation, +and choice of delay. The worker reads the current file for each attempt. `TomlEditor` caches a parsed document only while its source bytes match. It uses toml++ source regions to replace the selected value, preserving unrelated bytes, comments and line endings. Missing settings are inserted only when reparsing proves the candidate means -exactly the intended document. Values are typed booleans or strings and encoded -by toml++; quotes, backslashes and newlines cannot become new TOML instructions. +exactly the intended document. Values are typed booleans, strings, signed 64-bit +integers or finite doubles and encoded by toml++; quotes, backslashes and newlines +cannot become new TOML instructions. Newly requested NaN/infinity values are +rejected. Existing unrelated TOML values are preserved. Feature-specific numeric +ranges remain the caller's responsibility. Each request compares the selected value against the last acknowledged disk value, including whether it was absent. String quoting/escape spelling is not part of that semantic comparison. Unrelated external changes survive. A value already equal to the requested value succeeds without a write; a different external value reports a conflict. Invalid TOML, unsupported -value types and I/O errors leave the live mode alone and log one message per failed +value types and I/O errors leave the live setting alone and log one message per failed attempt, without file contents or values. No automatic retry loop is installed. The acknowledged value advances only after success. To reconcile a conflict, restore the original disk value, select the externally saved mode, or restart to load the file. Runtime edits do not rewrite the startup-only generated snapshot. +Failure state is tracked per key: saving B cannot clear a failure for A. A later +successful save of A clears A's failure. Failure to start the worker is tracked +the same way and does not prevent a later submission from trying again. Reporter +callbacks include the section/key, run on the worker, and cannot stop it by +throwing. The game adapter exposes aggregate failure status and one optional +process-lifetime observer, called on the game thread only when that status +changes. It uses the existing update dispatcher even if persistence setup failed. +The adapter conservatively retains failures from submissions it could not track. +Settings UI wording and widgets belong to the consumers, not the writer. + The checked replacement re-reads the source after staging and rejects changed bytes before commit. This is best-effort conflict detection, not an atomic compare-and-swap with arbitrary external editors: an external write can still @@ -90,7 +115,8 @@ is no extra frame detour or per-frame logging. Hook controls have process lifeti hot unloading the mod is unsupported. The fixture runners also cover preserving edits, escaped values, conflicts, -coalescing, failed-save baselines, draining and cancellation. They use isolated +numeric encoding, per-key coalescing, debounce expiry/replacement, failed-save +baselines, failure/recovery status, worker-start retry, draining and cancellation. They use isolated files and compile-time seams; no test switches or artificial delays ship in the mod. The Windows adapter fixture executes the production lifecycle functions with controlled worker/Unity boundaries. Separate child processes exercise real native diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc index 2bbe7bcd3..32908dfd7 100644 --- a/mods/src/patches/parts/runtime_config.cc +++ b/mods/src/patches/parts/runtime_config.cc @@ -30,8 +30,11 @@ bool draining = false, stopped = false, resume = fa std::uint64_t vote = 0; thread_local unsigned quit_depth = 0; void (*request_quit)(int) = nullptr; +void (*save_status_changed)() = nullptr; +std::atomic_bool persistence_unavailable{false}; +bool reported_save_failure = false; -void Report(config_edit::Outcome result) +void Report(std::string_view section, std::string_view key, config_edit::Outcome result) { const char* reason = "write failed"; switch (result) { @@ -47,7 +50,7 @@ void Report(config_edit::Outcome result) default: break; } - spdlog::warn("Could not persist ui.auto_confirm_instant_warp: {}; active mode is unchanged", reason); + spdlog::warn("Could not persist {}.{}: {}; live setting is unchanged", section, key, reason); } bool WantsQuit(auto original) @@ -91,6 +94,16 @@ void Update() owner.compare_exchange_strong(unset, GetCurrentThreadId()); if (forcing || owner != GetCurrentThreadId() || quit_depth) return; + const bool failed = persistence_unavailable.load() || (writer && writer->HasFailures()); + if (failed != reported_save_failure) { + reported_save_failure = failed; + if (save_status_changed) { + try { + save_status_changed(); + } catch (...) { /* Presentation cannot interrupt shutdown. */ + } + } + } bool quit = false; { std::lock_guard lock(lifecycle); @@ -136,6 +149,30 @@ DWORD WINAPI FinishForceClose(void* handle) namespace runtime_config { +bool SetSaveStatusObserver(void (*observer)()) +{ +#if defined(_WIN32) && defined(_M_X64) + if (save_status_changed && save_status_changed != observer) + return false; + // Status must also update if persistence/quit-hook validation failed, or no + // writer was configured. Registration uses the existing idempotent dispatcher. + if (!observer || !install_screen_manager_update_hook() || !register_screen_manager_update_callback(Update)) + return false; + save_status_changed = observer; + return true; +#else + (void)observer; + return false; +#endif +} +bool HasSaveFailures() noexcept +{ +#if defined(_WIN32) && defined(_M_X64) + return persistence_unavailable.load() || (writer && writer->HasFailures()); +#else + return false; +#endif +} #ifndef CONFIG_RUNTIME_TEST void Configure(const toml::table& loaded) { @@ -179,24 +216,49 @@ void Install() } #endif -void SaveWarpMode(const char* mode) noexcept +void SaveSetting(const char* section, const char* key, config_edit::Value value, + std::chrono::milliseconds delay) noexcept { try { #if defined(_WIN32) && defined(_M_X64) if (available && !forcing && owner == GetCurrentThreadId() && !quit_depth) { std::lock_guard lock(lifecycle); - if (!draining && writer->Submit(mode)) - return; + if (!draining) { + if (writer->Submit(section, key, std::move(value), delay)) + return; + if (writer->HasFailure(section, key)) { + spdlog::warn("{}.{} changed for this session; runtime save submission failed", section, key); + return; // The writer owns this failure and its eventual same-key recovery. + } + } } + persistence_unavailable.store(true); #else - (void)mode; + (void)value; #endif static bool reported = false; if (!reported) { reported = true; - spdlog::warn("ui.auto_confirm_instant_warp changed for this session; runtime persistence unavailable"); + spdlog::warn("{}.{} changed for this session; runtime persistence unavailable", section, key); } } catch (...) { /* Persistence must not interrupt the shortcut's live effect. */ +#if defined(_WIN32) && defined(_M_X64) + persistence_unavailable.store(true); +#endif + } +} + +void SaveWarpMode(const char* mode) noexcept +{ + try { + const std::string value(mode); + if (value != "none" && value != "warp" && value != "jump") + return; + SaveSetting("ui", "auto_confirm_instant_warp", value, {}); + } catch (...) { // Keep value construction inside the shortcut's failure boundary. +#if defined(_WIN32) && defined(_M_X64) + persistence_unavailable.store(true); +#endif } } diff --git a/mods/src/patches/runtime_config.h b/mods/src/patches/runtime_config.h index 9949cf595..8af45a8da 100644 --- a/mods/src/patches/runtime_config.h +++ b/mods/src/patches/runtime_config.h @@ -1,4 +1,6 @@ #pragma once +#include "toml_editor.h" +#include #include namespace runtime_config @@ -6,7 +8,13 @@ namespace runtime_config // Startup only: retain the semantic disk value as the optimistic comparison base. void Configure(const toml::table& loaded); void Install(); +// Register one process-lifetime observer during patch installation. Called on +// the game thread when aggregate failure status changes, never by the worker. +bool SetSaveStatusObserver(void (*observer)()); +bool HasSaveFailures() noexcept; void SaveWarpMode(const char* mode) noexcept; +void SaveSetting(const char* section, const char* key, config_edit::Value value, + std::chrono::milliseconds delay = {}) noexcept; #if _WIN32 void ForceClose() noexcept; #endif diff --git a/mods/src/runtime_config_writer.cc b/mods/src/runtime_config_writer.cc index 15a89fabc..06ba0aeb1 100644 --- a/mods/src/runtime_config_writer.cc +++ b/mods/src/runtime_config_writer.cc @@ -1,4 +1,5 @@ #include "runtime_config_writer.h" +#include #if _WIN32 #include @@ -7,15 +8,16 @@ #ifndef CONFIG_EDIT_SAVE #define CONFIG_EDIT_SAVE(editor, path, request) (editor).Save(path, request) #endif +#ifndef CONFIG_EDIT_START_WORKER +#define CONFIG_EDIT_START_WORKER(...) std::thread(__VA_ARGS__) +#endif namespace config_edit { RuntimeConfigWriter::RuntimeConfigWriter(std::filesystem::path path, std::optional initial, Reporter report) : path_(std::move(path)) - , saved_(std::move(initial)) , report_(report) -{ -} +{ saved_.emplace(Key{"ui", "auto_confirm_instant_warp"}, Saved{std::move(initial)}); } RuntimeConfigWriter::~RuntimeConfigWriter() { @@ -28,18 +30,39 @@ std::uint64_t RuntimeConfigWriter::Submit(std::string mode) { if (mode != "none" && mode != "warp" && mode != "jump") return 0; + return Submit("ui", "auto_confirm_instant_warp", std::move(mode)); +} + +bool RuntimeConfigWriter::Register(std::string section, std::string key, std::optional initial) +{ std::lock_guard lock(mutex_); - if (stopping_ || cancel_pending_.load()) + if (worker_.joinable() || stopping_ || section.empty() || key.empty()) + return false; + return saved_.emplace(Key{std::move(section), std::move(key)}, Saved{std::move(initial)}).second; +} + +std::uint64_t RuntimeConfigWriter::Submit(std::string section, std::string key, Value desired, + std::chrono::milliseconds delay) +{ + std::lock_guard lock(mutex_); + const Key identity{section, key}; + const auto saved = saved_.find(identity); + if (stopping_ || cancel_pending_.load() || saved == saved_.end()) return 0; - pending_ = Pending{++revision_, {"ui", "auto_confirm_instant_warp", saved_, std::move(mode)}}; + pending_.insert_or_assign(identity, + Pending{++revision_, + {std::move(section), std::move(key), saved->second.value, std::move(desired)}, + std::chrono::steady_clock::now() + delay}); has_work_.store(true); if (!worker_.joinable()) { try { - worker_ = std::thread(&RuntimeConfigWriter::Run, this); + worker_ = CONFIG_EDIT_START_WORKER(&RuntimeConfigWriter::Run, this); } catch (...) { - pending_.reset(); + pending_.clear(); has_work_.store(false); completion_ = {revision_, Outcome::IoError}; + saved->second.failed = true; + has_failures_.store(true); return 0; } } @@ -53,9 +76,9 @@ void RuntimeConfigWriter::Stop(bool cancel_pending) RequestCancelPending(); std::lock_guard lock(mutex_); stopping_ = true; - if (cancel_pending && pending_) { - completion_ = {pending_->revision, Outcome::Cancelled}; - pending_.reset(); + if (cancel_pending && !pending_.empty()) { + completion_ = {revision_, Outcome::Cancelled}; + pending_.clear(); } wake_.notify_one(); } @@ -71,6 +94,12 @@ RuntimeConfigWriter::Completion RuntimeConfigWriter::LastCompletion() std::lock_guard lock(mutex_); return completion_; } +bool RuntimeConfigWriter::HasFailure(std::string_view section, std::string_view key) +{ + std::lock_guard lock(mutex_); + const auto found = saved_.find(Key{std::string(section), std::string(key)}); + return found != saved_.end() && found->second.failed; +} void RuntimeConfigWriter::Run() { @@ -78,17 +107,24 @@ void RuntimeConfigWriter::Run() Pending work; { std::unique_lock lock(mutex_); - wake_.wait(lock, [&] { return stopping_ || cancel_pending_.load() || pending_.has_value(); }); + wake_.wait(lock, [&] { return stopping_ || cancel_pending_.load() || !pending_.empty(); }); if (cancel_pending_.load()) { stopping_ = true; - if (pending_) - completion_ = {pending_->revision, Outcome::Cancelled}; - pending_.reset(); + if (!pending_.empty()) + completion_ = {revision_, Outcome::Cancelled}; + pending_.clear(); } - if (!pending_) + if (pending_.empty()) break; - work = std::move(*pending_); - pending_.reset(); + auto next = std::min_element(pending_.begin(), pending_.end(), + [](const auto& a, const auto& b) { return a.second.ready < b.second.ready; }); + if (!stopping_ && next->second.ready > std::chrono::steady_clock::now()) { + const auto deadline = next->second.ready; + wake_.wait_until(lock, deadline); + continue; // New submissions may move a deadline; orderly stop flushes it. + } + work = std::move(next->second); + pending_.erase(next); } Outcome outcome; try { @@ -101,23 +137,30 @@ void RuntimeConfigWriter::Run() if (outcome == Outcome::Saved || outcome == Outcome::AlreadySaved) { // Rebase our queued intent over our own successful write, never over a // conflicting external edit. Failed saves leave the acknowledged value alone. - if (pending_ && pending_->edit.expected == saved_) - pending_->edit.expected = work.edit.desired; - saved_ = work.edit.desired; + const Key identity{work.edit.section, work.edit.key}; + auto& saved = saved_.at(identity).value; + if (auto pending = pending_.find(identity); pending != pending_.end() && pending->second.edit.expected == saved) + pending->second.edit.expected = work.edit.desired; + saved = work.edit.desired; } + // A successful B save must not hide an unsaved A change. + saved_.at(Key{work.edit.section, work.edit.key}).failed = + outcome != Outcome::Saved && outcome != Outcome::AlreadySaved; + has_failures_.store( + std::any_of(saved_.begin(), saved_.end(), [](const auto& entry) { return entry.second.failed; })); if (work.revision >= completion_.revision) completion_ = {work.revision, outcome}; } if (report_ && outcome != Outcome::Saved && outcome != Outcome::AlreadySaved) { try { - report_(outcome); + report_(work.edit.section, work.edit.key, outcome); } catch (...) { /* Diagnostics cannot kill the worker. */ } } { std::lock_guard lock(mutex_); // A diagnostic callback is still active worker work, even after disk I/O. - has_work_.store(pending_.has_value()); + has_work_.store(!pending_.empty()); } } has_work_.store(false); diff --git a/mods/src/runtime_config_writer.h b/mods/src/runtime_config_writer.h index 030fe2c0f..09b2fb0c3 100644 --- a/mods/src/runtime_config_writer.h +++ b/mods/src/runtime_config_writer.h @@ -2,19 +2,22 @@ #include "toml_editor.h" #include +#include #include #include +#include #include +#include #include namespace config_edit { -// One owner for the configured file and its first runtime setting. Extend this -// owner when another setting is registered; do not create another writer for it. +// One owner for the configured file and all its registered runtime settings. +// Register additional keys here rather than creating another writer for the file. class RuntimeConfigWriter { public: - using Reporter = void (*)(Outcome); + using Reporter = void (*)(std::string_view, std::string_view, Outcome); struct Completion { std::uint64_t revision = 0; Outcome outcome = Outcome::AlreadySaved; @@ -22,12 +25,20 @@ class RuntimeConfigWriter RuntimeConfigWriter(std::filesystem::path path, std::optional initial, Reporter report = nullptr); ~RuntimeConfigWriter(); // Tests/explicit owners only; game adapter has process lifetime. std::uint64_t Submit(std::string mode); + // Startup registration only. Runtime submissions may update only known keys. + bool Register(std::string section, std::string key, std::optional initial); + std::uint64_t Submit(std::string section, std::string key, Value desired, std::chrono::milliseconds delay = {}); void Stop(bool cancel_pending); // Publish force-close cancellation before native deadline setup, without a lock. void RequestCancelPending(); Completion LastCompletion(); bool HasWork() const { return has_work_.load(); } + bool HasFailures() const + { return has_failures_.load(); } + // Failure-path query: lets a caller distinguish a tracked failed attempt from + // an untracked rejection. A same-key retry can clear the former normally. + bool HasFailure(std::string_view section, std::string_view key); // Owner thread only, like Submit. On Windows this observes native thread exit // before joining; it never joins a still-running worker on a game callback. bool PollStopped(); @@ -36,21 +47,28 @@ class RuntimeConfigWriter #endif private: struct Pending { - std::uint64_t revision; - Request edit; + std::uint64_t revision; + Request edit; + std::chrono::steady_clock::time_point ready; }; - void Run(); - std::filesystem::path path_; - std::optional saved_; + void Run(); + std::filesystem::path path_; + using Key = std::pair; + struct Saved { + std::optional value; + bool failed = false; + }; + std::map saved_; Reporter report_; TomlEditor editor_; std::mutex mutex_; std::condition_variable wake_; std::thread worker_; - std::optional pending_; + std::map pending_; Completion completion_; std::uint64_t revision_ = 0; bool stopping_ = false; std::atomic_bool has_work_{false}, finished_{false}, cancel_pending_{false}; + std::atomic_bool has_failures_{false}; }; } // namespace config_edit diff --git a/mods/src/toml_editor.cc b/mods/src/toml_editor.cc index 356dc974a..b142d773a 100644 --- a/mods/src/toml_editor.cc +++ b/mods/src/toml_editor.cc @@ -1,6 +1,7 @@ #include "toml_editor.h" #include "config_save.h" +#include #include #include @@ -29,6 +30,10 @@ namespace return Value{node->as_boolean()->get()}; if (node->is_string()) return Value{node->as_string()->get()}; + if (node->is_floating_point()) + return Value{node->as_floating_point()->get()}; + if (node->is_integer()) + return Value{node->as_integer()->get()}; throw std::invalid_argument("unsupported setting type"); } @@ -61,6 +66,8 @@ namespace Prepared TomlEditor::Prepare(const std::string& text, const Request& request) { try { + if (const auto* value = std::get_if(&request.desired); value && !std::isfinite(*value)) + return {Outcome::Unsupported, {}}; if (!cached_table_ || cached_text_ != text) { auto parsed = toml::parse(text); cached_table_.reset(); // Never pair new bytes with stale regions if allocation fails. diff --git a/mods/src/toml_editor.h b/mods/src/toml_editor.h index eaf0f1531..2519b7584 100644 --- a/mods/src/toml_editor.h +++ b/mods/src/toml_editor.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -8,7 +9,7 @@ namespace config_edit { -using Value = std::variant; +using Value = std::variant; struct Request { std::string section, key; std::optional expected; // Missing is distinct from a configured default. diff --git a/tests/runtime_config_fixture.h b/tests/runtime_config_fixture.h index 948dec866..8a4e37117 100644 --- a/tests/runtime_config_fixture.h +++ b/tests/runtime_config_fixture.h @@ -1,9 +1,13 @@ #pragma once #include #include +#include #include #include #include +#include +#include +#include namespace spdlog { @@ -11,9 +15,15 @@ template void warn(const char*, Args&&...) {} } // namespace spdlog namespace config_edit { +using Value = std::variant; enum class Outcome { Conflict, InvalidDocument, Unsupported }; // Controllable boundary; the fixture below includes the actual adapter bodies. struct RuntimeConfigWriter { + bool failures = false; + bool HasFailures() const + { return failures; } + bool HasFailure(std::string_view, std::string_view) const + { return failures; } bool work = false, stopped = false, finished = false, cancelled = false; bool block_cancel = false; unsigned submissions = 0; @@ -37,6 +47,19 @@ struct RuntimeConfigWriter { void* NativeHandle() const { return handle; } unsigned Submit(const char*) - { return stopped ? 0 : ++submissions; } + { return stopped || failures ? 0 : ++submissions; } + unsigned Submit(const char*, const char*, Value, std::chrono::milliseconds) + { return Submit(""); } }; } // namespace config_edit + +// Observe actual callback registration; tests must not manually call an Update +// that the adapter failed to arrange for the no-persistence case. +inline void (*fixture_update_callback)() = nullptr; +inline bool install_screen_manager_update_hook() +{ return true; } +inline bool register_screen_manager_update_callback(void (*callback)()) +{ + fixture_update_callback = callback; + return true; +} diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc index cb02be716..2302a86fa 100644 --- a/tests/runtime_config_test.cc +++ b/tests/runtime_config_test.cc @@ -15,6 +15,10 @@ void Reset() available = true; owner = GetCurrentThreadId(); forcing = false; + persistence_unavailable = false; + reported_save_failure = false; + save_status_changed = nullptr; + fixture_update_callback = nullptr; draining = stopped = resume = false; vote = 0; resumes = 0; @@ -34,6 +38,42 @@ int main(int argc, char** argv) Sleep(10000); // Parent kills this fixture if the independent deadline fails. return 9; } + unsigned notices = 0; + static unsigned* noticeCount = ¬ices; + assert(runtime_config::SetSaveStatusObserver([] { ++*noticeCount; })); + fixture.failures = true; + std::thread foreignNotice([] { Update(); }); + foreignNotice.join(); + assert(notices == 0 && runtime_config::HasSaveFailures()); + Update(); + Update(); + assert(notices == 1); // One UI callback on a transition, never on each frame. + fixture.failures = false; + Update(); + assert(notices == 2 && !runtime_config::HasSaveFailures()); + available = false; + writer = nullptr; // Configure/Install never supplied the normal update path. + fixture_update_callback = nullptr; + assert(runtime_config::SetSaveStatusObserver(save_status_changed)); + assert(fixture_update_callback); + runtime_config::SaveWarpMode("warp"); + fixture_update_callback(); + assert(notices == 3 && runtime_config::HasSaveFailures()); + available = true; + writer = &fixture; + runtime_config::SaveWarpMode("jump"); + Update(); + assert(notices == 3 && runtime_config::HasSaveFailures()); // Rejected edits remain session-only. + Reset(); + fixture.failures = true; // Transient thread-start failure is tracked by its key. + runtime_config::SaveWarpMode("warp"); + assert(runtime_config::HasSaveFailures() && !persistence_unavailable); + fixture.failures = false; + runtime_config::SaveWarpMode("jump"); + assert(!runtime_config::HasSaveFailures()); + Reset(); + runtime_config::SaveWarpMode("invalid"); + assert(fixture.submissions == 0); // Generic submission must retain the mode wrapper's domain check. assert(WantsQuit([] { return true; })); assert(fixture.stopped && stopped && !draining); runtime_config::SaveWarpMode("warp"); diff --git a/tests/runtime_config_writer_test.cc b/tests/runtime_config_writer_test.cc index b79a93df3..0efca5862 100644 --- a/tests/runtime_config_writer_test.cc +++ b/tests/runtime_config_writer_test.cc @@ -2,20 +2,23 @@ #include #include #include +#include #include using namespace config_edit; using namespace std::chrono_literals; namespace { -std::mutex gate; -std::condition_variable changed; -bool entered = false, released = false; -Outcome first_result = Outcome::Saved; -std::vector requests; -std::thread::id save_thread; -int reports = 0; -bool block_report = false, release_report = false; +std::mutex gate; +std::condition_variable changed; +bool entered = false, released = false; +Outcome first_result = Outcome::Saved; +std::vector requests; +std::thread::id save_thread; +std::chrono::steady_clock::time_point save_entered_at; +int reports = 0; +bool block_report = false, release_report = false; +bool fail_thread_start = false; void Check(bool value) { @@ -25,7 +28,8 @@ void Check(bool value) Outcome Save(TomlEditor&, const std::filesystem::path&, const Request& request) { std::unique_lock lock(gate); - save_thread = std::this_thread::get_id(); + save_thread = std::this_thread::get_id(); + save_entered_at = std::chrono::steady_clock::now(); requests.push_back(request); if (requests.size() == 1) { entered = true; @@ -36,7 +40,7 @@ Outcome Save(TomlEditor&, const std::filesystem::path&, const Request& request) } return Outcome::Saved; } -void Report(Outcome) +void Report(std::string_view, std::string_view, Outcome) { std::unique_lock lock(gate); ++reports; @@ -63,10 +67,25 @@ void Release() released = true; changed.notify_all(); } +void AwaitCompletion(RuntimeConfigWriter& writer, std::uint64_t revision) +{ + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (writer.LastCompletion().revision != revision || writer.HasWork()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } +} +template std::thread StartWorker(Function function, Owner* owner) +{ + if (std::exchange(fail_thread_start, false)) + throw std::runtime_error("fixture thread-start failure"); + return std::thread(function, owner); +} } // namespace // Block at the real worker's save boundary to exercise scheduling deterministically. #define CONFIG_EDIT_SAVE(editor, path, request) Save(editor, path, request) +#define CONFIG_EDIT_START_WORKER(...) StartWorker(__VA_ARGS__) #include "../mods/src/runtime_config_writer.cc" int main() @@ -123,6 +142,34 @@ int main() writer.Stop(true); } Begin(Outcome::IoError); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}, Report); + Check(writer.Register("graphics", "threshold", Value{0.5})); + const auto failed = writer.Submit("warp"); + AwaitSave(); + Release(); + AwaitCompletion(writer, failed); + Check(writer.HasFailures()); + AwaitCompletion(writer, writer.Submit("graphics", "threshold", 0.7)); + Check(writer.HasFailures()); // Saving B cannot hide A's failed save. + AwaitCompletion(writer, writer.Submit("jump")); + Check(!writer.HasFailures()); // A later successful save of A clears it. + Check(reports == 1); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}); + fail_thread_start = true; + Check(!writer.Submit("warp")); + Check(writer.HasFailure("ui", "auto_confirm_instant_warp") && writer.HasFailures() && !writer.HasWork()); + Check(!writer.HasFailure("other", "unregistered")); + const auto retry = writer.Submit("jump"); + AwaitSave(); + Release(); + AwaitCompletion(writer, retry); + Check(!writer.HasFailures() && !writer.HasFailure("ui", "auto_confirm_instant_warp")); + } + Begin(Outcome::IoError); { block_report = true; RuntimeConfigWriter writer("unused", Value{std::string("none")}, Report); @@ -139,6 +186,65 @@ int main() changed.notify_all(); } } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", Value{std::string("none")}); + Check(writer.Register("graphics", "threshold", Value{0.5})); + Check(!writer.Submit("other", "unregistered", true)); + writer.Submit("warp"); + AwaitSave(); + Check(!writer.Register("graphics", "late", Value{true})); + writer.Submit("graphics", "threshold", 0.6, 150ms); + writer.Submit("jump"); + writer.Submit("graphics", "threshold", 0.7, 150ms); + writer.Stop(false); // Drain also flushes a slider whose delay has not expired. + Release(); + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.size() == 3); + Check(requests[1].key == "auto_confirm_instant_warp" && requests[1].desired == Value{std::string("jump")}); + Check(requests[1].expected == std::optional{std::string("warp")}); + Check(requests[2].key == "threshold" && requests[2].desired == Value{0.7}); + Check(requests[2].expected == std::optional{0.5}); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", std::nullopt); + Check(writer.Register("graphics", "threshold", Value{0.5})); + writer.Submit("graphics", "threshold", 0.6, 300ms); + { + std::unique_lock lock(gate); + Check(!changed.wait_for(lock, 75ms, [] { return entered; })); + } + const auto submitted = std::chrono::steady_clock::now(); + writer.Submit("graphics", "threshold", 0.7, 300ms); + { + std::unique_lock lock(gate); + // Wait past the old deadline but before the replacement's deadline. + Check(!changed.wait_until(lock, submitted + 250ms, [] { return entered; })); + } + AwaitSave(); // Ordinary expiration must start work without Stop/quit. + Check(save_entered_at >= submitted + 300ms); + writer.Stop(false); + Release(); + } + Check(requests.size() == 1 && requests[0].desired == Value{0.7}); + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused", std::nullopt); + Check(writer.Register("graphics", "threshold", Value{0.5})); + writer.Submit("graphics", "threshold", 0.9, 10s); + writer.Stop(true); // Cancellation must wake a delayed writer promptly. + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.empty()); + } RuntimeConfigWriter idle("unused", std::nullopt); idle.Stop(false); Check(idle.PollStopped()); diff --git a/tests/toml_editor_test.cc b/tests/toml_editor_test.cc index 895b44af7..14a6f22ce 100644 --- a/tests/toml_editor_test.cc +++ b/tests/toml_editor_test.cc @@ -3,6 +3,7 @@ #include #include #include +#include using namespace config_edit; static Request Mode(std::optional expected, std::string desired) @@ -63,6 +64,27 @@ int main(int argc, char** argv) combining_expected.replace(combining_expected.find("'none'"), 6, "\"warp\""); assert(editor.Prepare(combining, request).text == combining_expected); const Request boolean{"ui", "enabled", Value{false}, true}; + const auto numeric = + editor.Prepare("[graphics]\nthreshold = 0.5 # keep\n", {"graphics", "threshold", Value{0.5}, 0.75}); + assert(numeric.outcome == Outcome::Prepared && numeric.text == "[graphics]\nthreshold = 0.75 # keep\n"); + assert(editor.Prepare("[graphics]\nthreshold = 0.6\n", {"graphics", "threshold", Value{0.5}, 0.75}).outcome + == Outcome::Conflict); + const auto integer = + editor.Prepare("[graphics]\nthreshold = 0\n", {"graphics", "threshold", Value{std::int64_t{0}}, 0.5}); + assert(integer.outcome == Outcome::Prepared + && toml::parse(integer.text)["graphics"]["threshold"].value() == 0.5); + for (const auto desired : {std::numeric_limits::min(), std::numeric_limits::max()}) { + const auto whole = + editor.Prepare("[graphics]\ncount = 0 # keep\n", {"graphics", "count", Value{std::int64_t{0}}, desired}); + assert(whole.outcome == Outcome::Prepared && whole.text.ends_with(" # keep\n")); + assert(toml::parse(whole.text)["graphics"]["count"].value() == desired); + } + for (const auto desired : {std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + const auto invalid = + editor.Prepare("[graphics]\nthreshold = 0.5 # keep\n", {"graphics", "threshold", Value{0.5}, desired}); + assert(invalid.outcome == Outcome::Unsupported && invalid.text.empty()); + } assert(editor.Prepare("[ui]\nenabled = false # keep\n", boolean).text == "[ui]\nenabled = true # keep\n"); assert(editor.Prepare("[ui]\nenabled = true", boolean).outcome == Outcome::AlreadySaved); assert(editor.Prepare("[ui]\nenabled = 'false'", boolean).outcome == Outcome::Conflict); From aa351673ce39f03ede59df5aee869a0503182854 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 13 Sep 2026 04:30:56 -0500 Subject: [PATCH 14/20] Make debounce fixture tolerate delayed observers --- tests/runtime_config_writer_test.cc | 42 +++++++++++++++++------------ 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/tests/runtime_config_writer_test.cc b/tests/runtime_config_writer_test.cc index 0efca5862..9cb2f5fef 100644 --- a/tests/runtime_config_writer_test.cc +++ b/tests/runtime_config_writer_test.cc @@ -1,6 +1,7 @@ #include "runtime_config_writer.h" #include #include +#include #include #include #include @@ -15,21 +16,21 @@ bool entered = false, released = false; Outcome first_result = Outcome::Saved; std::vector requests; std::thread::id save_thread; -std::chrono::steady_clock::time_point save_entered_at; +std::vector save_times; int reports = 0; bool block_report = false, release_report = false; bool fail_thread_start = false; -void Check(bool value) +void Check(bool value, std::source_location location = std::source_location::current()) { if (!value) - throw std::runtime_error("worker fixture failed"); + throw std::runtime_error("worker fixture failed at line " + std::to_string(location.line())); } Outcome Save(TomlEditor&, const std::filesystem::path&, const Request& request) { std::unique_lock lock(gate); - save_thread = std::this_thread::get_id(); - save_entered_at = std::chrono::steady_clock::now(); + save_thread = std::this_thread::get_id(); + save_times.push_back(std::chrono::steady_clock::now()); requests.push_back(request); if (requests.size() == 1) { entered = true; @@ -52,6 +53,7 @@ void Begin(Outcome result) { entered = released = false; requests.clear(); + save_times.clear(); reports = 0; first_result = result; block_report = release_report = false; @@ -211,27 +213,33 @@ int main() Check(requests[2].expected == std::optional{0.5}); } Begin(Outcome::Saved); + std::chrono::steady_clock::time_point initial_submitted, replacement_submitted; { RuntimeConfigWriter writer("unused", std::nullopt); Check(writer.Register("graphics", "threshold", Value{0.5})); + initial_submitted = std::chrono::steady_clock::now(); writer.Submit("graphics", "threshold", 0.6, 300ms); { std::unique_lock lock(gate); - Check(!changed.wait_for(lock, 75ms, [] { return entered; })); + changed.wait_for(lock, 75ms, [] { return entered; }); } - const auto submitted = std::chrono::steady_clock::now(); - writer.Submit("graphics", "threshold", 0.7, 300ms); - { - std::unique_lock lock(gate); - // Wait past the old deadline but before the replacement's deadline. - Check(!changed.wait_until(lock, submitted + 250ms, [] { return entered; })); - } - AwaitSave(); // Ordinary expiration must start work without Stop/quit. - Check(save_entered_at >= submitted + 300ms); - writer.Stop(false); + replacement_submitted = std::chrono::steady_clock::now(); + const auto revision = writer.Submit("graphics", "threshold", 0.7, 300ms); Release(); + AwaitCompletion(writer, revision); // Ordinary expiration, without Stop/quit flushing the delay. + } + // The test thread may resume after the first deadline on a busy runner. + // Validate actual entry times, not a negative assertion made by a late observer. + // Both an already-active first write and a coalesced replacement are valid; + // the gated test above independently requires queued same-key coalescing. + Check(requests.size() == 1 || requests.size() == 2); + Check(requests.back().desired == Value{0.7}); + if (requests.size() == 2) + Check(requests.front().desired == Value{0.6}); + for (std::size_t i = 0; i < requests.size(); ++i) { + const auto submitted = requests[i].desired == Value{0.6} ? initial_submitted : replacement_submitted; + Check(save_times[i] >= submitted + 300ms); } - Check(requests.size() == 1 && requests[0].desired == Value{0.7}); Begin(Outcome::Saved); { RuntimeConfigWriter writer("unused", std::nullopt); From 105978169bedabbeccb545b639e9b7827afbd10b Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 17 Sep 2026 06:04:44 -0500 Subject: [PATCH 15/20] Add complete managed method signature resolution --- mods/src/il2cpp/method_contract.h | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 mods/src/il2cpp/method_contract.h diff --git a/mods/src/il2cpp/method_contract.h b/mods/src/il2cpp/method_contract.h new file mode 100644 index 000000000..d7d1ccdc5 --- /dev/null +++ b/mods/src/il2cpp/method_contract.h @@ -0,0 +1,44 @@ +#pragma once + +#include "il2cpp-functions.h" +#include +#include +#include + +namespace method_contract +{ +inline bool Type(const Il2CppType* type, const char* name) +{ + if (!type || type->byref) return false; + auto* actual = il2cpp_type_get_name(type); + const bool matches = actual && std::strcmp(actual, name) == 0; + il2cpp_free(actual); + return matches; +} + +// Resolve the entire managed signature, including static/instance dispatch. +// A renamed, ambiguous or generic method is not a compatible callback. +inline const MethodInfo* Resolve(Il2CppClass* cls, const char* name, bool is_static, + const char* result, std::initializer_list parameters) +{ + if (!cls) return nullptr; + const MethodInfo* found = nullptr; + void* iterator = nullptr; + while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { + if (std::strcmp(method->name, name) != 0 || !method->methodPointer || method->is_generic || method->is_inflated + || bool(method->flags & METHOD_ATTRIBUTE_STATIC) != is_static + || method->parameters_count != parameters.size() || !Type(method->return_type, result)) continue; + bool matches = true; + unsigned i = 0; + for (auto* parameter : parameters) + matches = Type(method->parameters[i++], parameter) && matches; + if (!matches) continue; + if (found) return nullptr; + found = method; + } + return found; +} + +inline void* Pointer(const MethodInfo* method) +{ return method ? reinterpret_cast(method->methodPointer) : nullptr; } +} // namespace method_contract From c44530752ebc10c7fd9a7167a0d3cbb40f5bb4c6 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 17 Sep 2026 06:08:11 -0500 Subject: [PATCH 16/20] Resolve runtime persistence quit hooks across client updates --- docs/config-save.md | 10 ++++---- mods/src/patches/parts/runtime_config.cc | 29 ++++++------------------ 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/docs/config-save.md b/docs/config-save.md index f2a704044..dd8634d77 100644 --- a/docs/config-save.md +++ b/docs/config-save.md @@ -93,12 +93,10 @@ compare-and-swap with arbitrary external editors: an external write can still race the final native replacement. File deletion is an I/O error, not permission to recreate the user's file from cached content. -Runtime persistence currently requires the verified build261 Windows x64 quit -method (RVA `0x43548c0`, native extent 411 bytes, 24-byte SPUD overwrite, complete -initial instruction fingerprint checked at installation). macOS and unmatched -clients keep the shortcut's existing session-only behavior and log that persistence -is unavailable. The editor/storage fixtures run on all supported build platforms; -they do not establish native game-hook compatibility. +Runtime persistence supports Windows x64 clients with compatible Unity quit methods. +The adapter resolves `Internal_ApplicationWantsToQuit()` and `Quit(int)` by their +complete managed signatures, without pinning client addresses or instruction bytes. +macOS and incompatible signatures retain session-only changes. An idle normal quit closes admission and passes the original vote through without replaying quit. When work is active, normal quit stops admission, drains accepted work, then resumes the game's quit diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc index 32908dfd7..9a15bb4d8 100644 --- a/mods/src/patches/parts/runtime_config.cc +++ b/mods/src/patches/parts/runtime_config.cc @@ -9,8 +9,8 @@ #if defined(_WIN32) && defined(_M_X64) #include "patches/screen_update_hook.h" #include -#include #include +#include #include #endif #endif @@ -117,23 +117,6 @@ void Update() request_quit(0); } -#ifndef CONFIG_RUNTIME_TEST -bool MatchesQuitMethod(const MethodInfo* method) -{ - // Verified build261 Windows x64: 411-byte native body vs SPUD's 24-byte - // overwrite. Pin complete initial instructions too; other builds stay session-only. - constexpr unsigned char bytes[]{0x48, 0x89, 0x5c, 0x24, 0x08, 0x56, 0x57, 0x41, 0x56, 0x48, - 0x83, 0xec, 0x40, 0x80, 0x3d, 0xad, 0xd2, 0x8d, 0x01, 0x00, - 0x75, 0x29, 0x48, 0x8d, 0x0d, 0x9b, 0xdf, 0x63, 0x01}; - auto base = reinterpret_cast(GetModuleHandleW(L"GameAssembly.dll")); - if (!base || !method || reinterpret_cast(method->methodPointer) != base + 0x43548c0) - return false; - DWORD64 image_base = 0; - const auto* extent = RtlLookupFunctionEntry(base + 0x43548c0, &image_base, nullptr); - return extent && image_base == base && extent->BeginAddress == 0x43548c0 && extent->EndAddress == 0x4354a5b - && std::memcmp(method->methodPointer, bytes, sizeof(bytes)) == 0; -} -#endif DWORD WINAPI FinishForceClose(void* handle) { @@ -201,11 +184,13 @@ void Install() attempted = true; try { auto helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Application"); - const auto* wants = helper.GetMethodInfo("Internal_ApplicationWantsToQuit", 0); - const auto* quit = helper.GetMethodInfo("Quit", 1); - auto base = reinterpret_cast(GetModuleHandleW(L"GameAssembly.dll")); - if (!MatchesQuitMethod(wants) || !quit || reinterpret_cast(quit->methodPointer) != base + 0x4351c00) + const auto* wants = method_contract::Resolve(helper.get_cls(), "Internal_ApplicationWantsToQuit", true, + "System.Boolean", {}); + const auto* quit = method_contract::Resolve(helper.get_cls(), "Quit", true, "System.Void", {"System.Int32"}); + if (!wants || !quit) { + spdlog::warn("Runtime config persistence unavailable: incompatible Unity quit methods"); return; + } request_quit = reinterpret_cast(quit->methodPointer); available = install_screen_manager_update_hook() && register_screen_manager_update_callback(Update) && SPUD_STATIC_DETOUR(wants->methodPointer, WantsQuit); From 62399bc7e8a9697d7c22fd04b54be00b59d77136 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 18 Sep 2026 21:30:44 -0500 Subject: [PATCH 17/20] Expose bounded detour preflight for macOS hook families --- .github/workflows/ci.yaml | 14 +++ mods/src/patches/native_hook_extent.cc | 103 ++++++++++++++++++ mods/src/patches/native_hook_extent.h | 11 ++ tests/macos_hook_extent_tests.cc | 43 ++++++++ tests/xmake.lua | 12 ++ .../packages/s/spud/spud-src/CMakeLists.txt | 1 + .../s/spud/spud-src/include/spud/detour.h | 5 + .../s/spud/spud-src/src/detour/prologue.cc | 63 +++++++++++ xmake-requires.lock | 12 +- xmake.lua | 2 + xmake/dependencies/common.lua | 2 +- 11 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 mods/src/patches/native_hook_extent.cc create mode 100644 mods/src/patches/native_hook_extent.h create mode 100644 tests/macos_hook_extent_tests.cc create mode 100644 tests/xmake.lua create mode 100644 xmake-packages/packages/s/spud/spud-src/src/detour/prologue.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c4db85207..635eceece 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -477,6 +477,20 @@ jobs: shell: bash run: sccache --show-stats + - name: Test loaded Mach-O hook boundaries + shell: bash + run: | + xmake build -y macos-hook-extent-tests + xmake run macos-hook-extent-tests + + - name: Verify ARM64 debug core + if: ${{ matrix.arch == 'arm64' }} + shell: bash + run: | + xmake f -p macosx -a arm64 -m debug --target_minver=14.6 -y + xmake -y mods + xmake f -p macosx -a arm64 -m release --target_minver=14.6 -y + - name: Report Swift module cache shell: bash run: | diff --git a/mods/src/patches/native_hook_extent.cc b/mods/src/patches/native_hook_extent.cc new file mode 100644 index 000000000..8836e0a28 --- /dev/null +++ b/mods/src/patches/native_hook_extent.cc @@ -0,0 +1,103 @@ +#include "native_hook_extent.h" + +#if __APPLE__ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// LC_FUNCTION_STARTS records ULEB128 deltas from __TEXT. Require an exact +// entry and a following entry: the last function has no proven extent here. +size_t FunctionExtent(const void* method) +{ + Dl_info info{}; + if (!method || !dladdr(method, &info) || !info.dli_fbase) + return 0; + const auto* header = static_cast(info.dli_fbase); + if (header->magic != MH_MAGIC_64) + return 0; + const auto* cursor = reinterpret_cast(header + 1); + const auto* end = cursor + header->sizeofcmds; + const segment_command_64* text = nullptr; + const segment_command_64* linkedit = nullptr; + const linkedit_data_command* starts = nullptr; + for (uint32_t i = 0; i < header->ncmds; ++i) { + if (size_t(end - cursor) < sizeof(load_command)) + return 0; + const auto* command = reinterpret_cast(cursor); + if (command->cmdsize < sizeof(load_command) || command->cmdsize > size_t(end - cursor)) + return 0; + if (command->cmd == LC_SEGMENT_64 && command->cmdsize >= sizeof(segment_command_64)) { + const auto* segment = reinterpret_cast(command); + if (std::strncmp(segment->segname, "__TEXT", 16) == 0) + text = segment; + if (std::strncmp(segment->segname, "__LINKEDIT", 16) == 0) + linkedit = segment; + } + if (command->cmd == LC_FUNCTION_STARTS && command->cmdsize >= sizeof(linkedit_data_command)) + starts = reinterpret_cast(command); + cursor += command->cmdsize; + } + if (!text || !linkedit || !starts || starts->dataoff < linkedit->fileoff) + return 0; + const uint64_t offset = starts->dataoff - linkedit->fileoff; + if (offset > linkedit->filesize || starts->datasize > linkedit->filesize - offset || offset > linkedit->vmsize + || starts->datasize > linkedit->vmsize - offset) + return 0; + const uintptr_t base = reinterpret_cast(header); + const uintptr_t target = reinterpret_cast(method); + if (target < base || target - base >= text->vmsize) + return 0; + const auto slide = base - text->vmaddr; + cursor = reinterpret_cast(slide + linkedit->vmaddr + offset); + end = cursor + starts->datasize; + uint64_t address = 0; + bool found = false; + while (cursor < end) { + uint64_t delta = 0; + unsigned shift = 0; + uint8_t byte; + do { + if (cursor == end || shift >= 64) + return 0; + byte = *cursor++; + if (shift == 63 && (byte & 0x7e)) + return 0; + delta |= uint64_t(byte & 0x7f) << shift; + shift += 7; + } while (byte & 0x80); + if (!delta || delta > text->vmsize - address) + return 0; + address += delta; + if (found) + return address - (target - base); + if (address > target - base) + return 0; + found = address == target - base; + } + return 0; +} + +} // namespace +#endif + +bool native_hooks::MacHookFits(const void* method, std::size_t minimum_extent) +{ +#if __APPLE__ + const auto extent = FunctionExtent(method); + const bool fits = extent >= std::max(minimum_extent, std::size_t{32}) + && spud::has_detour_prologue(method, extent); + spdlog::info("[MacHookExtent] entry={} bytes={} accepted={}", method, extent, fits); + return fits; +#else + (void)method; + (void)minimum_extent; + return false; +#endif +} diff --git a/mods/src/patches/native_hook_extent.h b/mods/src/patches/native_hook_extent.h new file mode 100644 index 000000000..5acdea051 --- /dev/null +++ b/mods/src/patches/native_hook_extent.h @@ -0,0 +1,11 @@ +#pragma once +#include + +namespace native_hooks +{ +// Validates the entry in the loaded image, before SPUD changes any bytes. +// Unsupported images/architectures fail closed. +// Small, exact-entry callbacks may request a lower floor; the host decoder +// still requires the complete SPUD overwrite window without an early exit. +bool MacHookFits(const void* method, std::size_t minimum_extent = 64); +} // namespace native_hooks diff --git a/tests/macos_hook_extent_tests.cc b/tests/macos_hook_extent_tests.cc new file mode 100644 index 000000000..4b09ec733 --- /dev/null +++ b/tests/macos_hook_extent_tests.cc @@ -0,0 +1,43 @@ +#include "patches/native_hook_extent.h" +#include + +// Real Mach-O entries exercise the loaded image, not a mocked function table. +// Keep the bodies independent of optimizer/prologue choices on both CPUs. +__attribute__((naked, noinline, used)) void LongEntry() +{ __asm__ volatile(".rept 96\n nop\n .endr\n ret"); } +__attribute__((naked, noinline, used)) void ShortEntry() +{ +#if defined(__aarch64__) + __asm__ volatile(".rept 10\n nop\n .endr\n ret"); +#else + __asm__ volatile(".rept 40\n nop\n .endr\n ret"); +#endif +} +__attribute__((naked, noinline, used)) void PaddedReturn() +{ __asm__ volatile("ret\n .rept 96\n nop\n .endr"); } +__attribute__((naked, noinline, used)) void TailThunk() +{ +#if defined(__aarch64__) + __asm__ volatile("b 1f\n .rept 96\n nop\n .endr\n 1: ret"); +#else + __asm__ volatile("jmp 1f\n .rept 96\n nop\n .endr\n 1: ret"); +#endif +} +__attribute__((naked, noinline, used)) void LastEntry() +{ __asm__ volatile("ret"); } + +int main() +{ + const auto* entry = reinterpret_cast(&LongEntry); + const auto* short_entry = reinterpret_cast(&ShortEntry); + if (native_hooks::MacHookFits(short_entry) || !native_hooks::MacHookFits(short_entry, 32) + || native_hooks::MacHookFits(reinterpret_cast(&PaddedReturn), 32) + || native_hooks::MacHookFits(reinterpret_cast(&TailThunk), 32) + || !native_hooks::MacHookFits(entry) || native_hooks::MacHookFits(entry + 4) + || native_hooks::MacHookFits(reinterpret_cast(&PaddedReturn)) + || native_hooks::MacHookFits(reinterpret_cast(&TailThunk)) || native_hooks::MacHookFits(nullptr)) { + std::fputs("Mac hook extent regression failed\n", stderr); + return 1; + } + std::puts("Mac hook extent regression passed"); +} diff --git a/tests/xmake.lua b/tests/xmake.lua new file mode 100644 index 000000000..cbc8495f2 --- /dev/null +++ b/tests/xmake.lua @@ -0,0 +1,12 @@ +if is_plat("macosx") then + target("macos-hook-extent-tests") + do + set_kind("binary") + set_default(false) + add_files("macos_hook_extent_tests.cc", "../mods/src/patches/native_hook_extent.cc") + add_includedirs("../mods/src") + add_packages("spud", "spdlog") + set_policy("build.optimization.lto", false) + end +end + diff --git a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt index 234040179..6d3e45fda 100644 --- a/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt +++ b/xmake-packages/packages/s/spud/spud-src/CMakeLists.txt @@ -215,6 +215,7 @@ target_sources( "src/detour/x86_64/relocators.cc" "src/detour/x86_64/relocators.h" "src/detour/detour.cc" + "src/detour/prologue.cc" "src/detour/detour_impl.h" "src/detour/fwd.h" "src/detour/target_registry.cc" diff --git a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h index 72ae62ce6..7f72f9c42 100644 --- a/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h +++ b/xmake-packages/packages/s/spud/spud-src/include/spud/detour.h @@ -19,6 +19,11 @@ namespace spud { +// Read-only, bounded check for enough complete host instructions before a +// return/tail jump. The caller must supply a readable, independently verified +// native extent. This does not install a detour or promise relocation success. +bool has_detour_prologue(const void *address, size_t extent); + enum class detour_install_status : uint8_t { not_installed, installed, diff --git a/xmake-packages/packages/s/spud/spud-src/src/detour/prologue.cc b/xmake-packages/packages/s/spud/spud-src/src/detour/prologue.cc new file mode 100644 index 000000000..40179eefd --- /dev/null +++ b/xmake-packages/packages/s/spud/spud-src/src/detour/prologue.cc @@ -0,0 +1,63 @@ +#include +#include +#include +#if SPUD_ARCH_X86_64 +#include +#elif SPUD_ARCH_ARM64 && SPUD_AARCH64_SUPPORT +#include +#endif + +bool spud::has_detour_prologue(const void* address, size_t extent) +{ + if (!address) + return false; +#if SPUD_ARCH_X86_64 + // mov r11, imm64; jmp [rip]; destination address. + constexpr size_t overwrite = 24; + if (extent < overwrite) + return false; + ZydisDecoder decoder; + if (!ZYAN_SUCCESS(ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64))) + return false; + size_t bytes = 0; + const auto limit = std::min(extent, size_t{64}); + while (bytes < overwrite) { + ZydisDecodedInstruction instruction; + ZydisDecodedOperand operands[ZYDIS_MAX_OPERAND_COUNT]; + if (!ZYAN_SUCCESS(ZydisDecoderDecodeFull(&decoder, static_cast(address) + bytes, limit - bytes, + &instruction, operands))) + return false; + if (instruction.meta.category == ZYDIS_CATEGORY_RET || instruction.meta.category == ZYDIS_CATEGORY_UNCOND_BR + || instruction.meta.category == ZYDIS_CATEGORY_INTERRUPT) + return false; + bytes += instruction.length; + } + return true; +#elif SPUD_ARCH_ARM64 && SPUD_AARCH64_SUPPORT + // ldr; up to four mov instructions; br; destination address. + constexpr size_t overwrite = 32; + if (extent < overwrite) + return false; + csh handle{}; + if (cs_open(CS_ARCH_AARCH64, CS_MODE_LITTLE_ENDIAN, &handle) != CS_ERR_OK) + return false; + cs_option(handle, CS_OPT_DETAIL, CS_OPT_ON); + cs_insn* instructions = nullptr; + const auto count = cs_disasm(handle, static_cast(address), std::min(extent, size_t{64}), + reinterpret_cast(address), 0, &instructions); + size_t bytes = 0; + for (size_t i = 0; i < count && bytes < overwrite; ++i) { + const auto& instruction = instructions[i]; + if (cs_insn_group(handle, &instruction, CS_GRP_RET) || cs_insn_group(handle, &instruction, CS_GRP_INT) + || std::strcmp(instruction.mnemonic, "b") == 0 || std::strcmp(instruction.mnemonic, "br") == 0) + break; + bytes += instruction.size; + } + cs_free(instructions, count); + cs_close(&handle); + return bytes >= overwrite; +#else + (void)extent; + return false; +#endif +} diff --git a/xmake-requires.lock b/xmake-requires.lock index 477133c97..c1158c44f 100644 --- a/xmake-requires.lock +++ b/xmake-requires.lock @@ -126,11 +126,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-5#f56260b5"] = { + ["spud v0.2.0-6#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-5" + version = "v0.2.0-6" }, ["toml++#f56260b5"] = { repo = { @@ -273,11 +273,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-5#f56260b5"] = { + ["spud v0.2.0-6#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-5" + version = "v0.2.0-6" }, ["toml++#f56260b5"] = { repo = { @@ -384,11 +384,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-5#f56260b5"] = { + ["spud v0.2.0-6#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-5" + version = "v0.2.0-6" }, ["toml++#f56260b5"] = { repo = { diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..02524c0e8 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,5 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +includes("tests") diff --git a/xmake/dependencies/common.lua b/xmake/dependencies/common.lua index 7f24295d1..24404794b 100644 --- a/xmake/dependencies/common.lua +++ b/xmake/dependencies/common.lua @@ -20,7 +20,7 @@ add_requires("toml++") add_requires("nlohmann_json") add_requires("protobuf 35.1") add_requires("cpr", {system = false}) -add_requires("spud v0.2.0-5") +add_requires("spud v0.2.0-6") add_requires("libil2cpp") add_requires("simdutf", {system = false}) From f2f8e9ad017e19fa774e1f79e9d6b4a4ce28c2fc Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 18 Sep 2026 21:31:45 -0500 Subject: [PATCH 18/20] Isolate combined SPUD validation package from prior cache --- xmake-requires.lock | 12 ++++++------ xmake/dependencies/common.lua | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/xmake-requires.lock b/xmake-requires.lock index c1158c44f..6df26d62e 100644 --- a/xmake-requires.lock +++ b/xmake-requires.lock @@ -126,11 +126,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-6#f56260b5"] = { + ["spud v0.2.0-7#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-6" + version = "v0.2.0-7" }, ["toml++#f56260b5"] = { repo = { @@ -273,11 +273,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-6#f56260b5"] = { + ["spud v0.2.0-7#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-6" + version = "v0.2.0-7" }, ["toml++#f56260b5"] = { repo = { @@ -384,11 +384,11 @@ }, version = "v1.17.0" }, - ["spud v0.2.0-6#f56260b5"] = { + ["spud v0.2.0-7#f56260b5"] = { repo = { url = "xmake-packages" }, - version = "v0.2.0-6" + version = "v0.2.0-7" }, ["toml++#f56260b5"] = { repo = { diff --git a/xmake/dependencies/common.lua b/xmake/dependencies/common.lua index 24404794b..74702f1be 100644 --- a/xmake/dependencies/common.lua +++ b/xmake/dependencies/common.lua @@ -20,7 +20,7 @@ add_requires("toml++") add_requires("nlohmann_json") add_requires("protobuf 35.1") add_requires("cpr", {system = false}) -add_requires("spud v0.2.0-6") +add_requires("spud v0.2.0-7") add_requires("libil2cpp") add_requires("simdutf", {system = false}) From 1c128c0f967253326a4edfe2abff4408f5092c00 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 18 Sep 2026 21:46:49 -0500 Subject: [PATCH 19/20] Trim trailing whitespace in hook fixture build definition --- tests/xmake.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/xmake.lua b/tests/xmake.lua index cbc8495f2..88088ac4d 100644 --- a/tests/xmake.lua +++ b/tests/xmake.lua @@ -9,4 +9,3 @@ if is_plat("macosx") then set_policy("build.optimization.lto", false) end end - From a5b791a993292c3a153a574c2dda4a9c8e457bc9 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 18 Sep 2026 22:05:52 -0500 Subject: [PATCH 20/20] Document validated Mac runtime setting persistence --- example_community_patch_settings_da.toml | 2 +- example_community_patch_settings_de.toml | 2 +- example_community_patch_settings_en-GB-x-cockney.toml | 2 +- example_community_patch_settings_en-x-minionese.toml | 2 +- example_community_patch_settings_en.toml | 2 +- example_community_patch_settings_es.toml | 2 +- example_community_patch_settings_fr.toml | 2 +- example_community_patch_settings_nl.toml | 2 +- example_community_patch_settings_ru.toml | 2 +- example_community_patch_settings_tlh.toml | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index fd6bdd0b0..a70a8ca66 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 59bcfc1ef..52c3d691e 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 3c613f1eb..99b09f96c 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 3afc074fc..b25e7a49b 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 06494ead7..6024cf44e 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 3cbdff7dd..84a47f431 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index f17d4aabc..16855fe9f 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 9eea1eabf..6f8d431f7 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 285e65966..e5bb73b8d 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none" diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 752952171..9000ae496 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -662,7 +662,7 @@ auto_confirm_ft_upgrade = false # "none" - Do not choose an action automatically; show the confirmation dialog # "warp" - Automatically choose regular warp # "jump" - Automatically choose instant jump -# The mode shortcut persists this value on verified Windows clients; otherwise session-only. +# The mode shortcut persists this value on validated Windows x64 and macOS clients; otherwise session-only. # External edits are preserved; conflicts are logged. See docs/config-save.md. auto_confirm_instant_warp = "none"