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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ jobs:
- name: Build
run: xmake build -y stfc-community-mod

- name: Test confirmation setting contracts
shell: pwsh
run: ./tests/run-confirmation-settings.ps1

- name: Test x64 trampoline relocation
shell: pwsh
run: |
Expand Down Expand Up @@ -521,6 +525,10 @@ jobs:
shell: bash
run: sccache --show-stats

- name: Test confirmation setting contracts
shell: bash
run: bash tests/run-confirmation-settings.sh

- name: Report Swift module cache
shell: bash
run: |
Expand Down
111 changes: 111 additions & 0 deletions docs/MOD_SETTINGS_FOUNDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Boolean settings foundation and native FC control

The controller and Fleet Commander preference adapter back a Windows x64 native
confirmation-page control. Mod-owned TOML
persistence and the Community Mod category are separate work.

`settings/boolean_settings.h` is independent of Unity and storage. Definitions have
a stable ID, readable label, read callback, and immediate-write callback. Registry
IDs are unique and registration freezes on first lookup. All operations belong to
the constructing thread; there is no polling, background work or disk I/O.

Consumers must keep unknown state separate from a boolean. `ReadResult` carries
availability, an optional positive value, and an adapter generation. A snapshot
also carries controller identity, revision and lifecycle epoch. Passing a stale
or foreign snapshot rejects the request. `RenderScope` suppresses user-write
handling around native binding/refresh callbacks. Reentrant application is Busy.

Writes re-read before applying, skip already-satisfied values and verify readback.
Failed or uncertain writes never trigger an automatic reverse write. The returned
snapshot contains a fresh authoritative read when available; `Unverified` must
not be rendered as successful application. `AppliedVerified` is local verification,
not a claim of cloud durability.

## Fleet Commander adapter

`FleetCommanderConfirmationSetting()` provides the native UI setting. ON means show confirmation. Reads use the existing
PersistentPrefsManager's `GetBool(key, false, false)`: the final false prevents
insertion of a missing preference while preserving the game's default. Writes use
the native FC setter. The adapter never instantiates managers, invokes abilities,
forces cloud saves, or enumerates other preferences.

Metadata is resolved lazily and checked before access. Two weak handles detect
replacement of the preference manager or saved-data object without retaining
account data. Unavailability invalidates the observed generation. One process-wide
root holds the constant, non-sensitive preference key; no work runs while idle.

The UI calls `InvalidateFleetCommanderConfirmationSession()` before the native
preference manager's RegisterEvents (initialization/reload), session-start handler,
and cloud-load entry. It immediately invalidates live view snapshots. These are
substantive functions; neither the tiny OnApplicationReload wrapper nor the
LifecycleUpdatedEventHandler save-timer path is hooked. Exact-client account
transition validation is still required; object identity alone is insufficient.

The prototype recovery shortcut has been removed. Use the native settings row;
legacy `enable_fc_ability_confirmation` entries are no longer consumed.

## Standalone controller tests

Windows (clang++ with the installed C++ toolchain):

```powershell
clang++ -std=c++23 -Wall -Wextra -Werror -I mods/src tests/boolean_settings_test.cc -o boolean_settings_test.exe
./boolean_settings_test.exe
```

On Unix, use the equivalent compiler invocation with `-pthread`. Keep executables
outside tracked source. These tests cover the pure state machine, not native ABI,
account lifecycle wiring, frame timings, or cloud persistence.

Before extending the UI, measure the baseline and candidate with the same scene,
FPS cap and diagnostics: no scheduled closed-menu work or per-frame allocations;
initial target <=1 ms added normal bind/refresh work at p95, <=2 ms per normal
operation. These are proposed UI acceptance budgets, not measured P1 results.

## Native UI adapter (P2 candidate)

The first registered control is `[MOD] Confirm Fleet Commander abilities`, under
the existing confirmation category. ON means show confirmations; OFF means skip.
The adapter is independent of mod hotkeys and does not install a global localization
hook. It overrides TextLocalizer after native binding and clears its own overrides
on release/rebind, using weak ownership records rather than matching visible text.

`BooleanView` retains the displayed snapshot. Rendering suppresses writes; stale
clicks conflict; an uncertain apply remains unresolved until a subsequent bind.
Rejected writes with known readback retain that value and show a retry message.
Unknown values suppress both native switch/state visual nodes while retaining the
label. The prefab must prove that those nodes are descendants of the row and do
not contain the label; otherwise that UI is unsupported. Exact visual validation
of this behavior remains a release gate.

Eight weak view records bound bookkeeping. Native contexts own rows/delegates;
there are no strong roots retaining historical settings pages. Native release
clears records, with dead-record reclamation on binding as a fallback. A successful
write refreshes other live framework views. No polling or file work is scheduled.

Each callback registration owns a permanent MethodInfo copy with replaced direct,
virtual and runtime-invoker pointers. Matching native schema supplies reflection
metadata only; the donor MethodInfo remains untouched. Closed delegates must point
to that owned descriptor. The native setter delegate is deliberately inert:
only a live widget's explicit change handler can submit its displayed snapshot.
Reflection and refresh callbacks cannot authorize writes. This does not claim a
general managed-method registration API.

All seven hooks resolve managed signatures and require distinct target addresses.
Hooks remain inert until installation completes. The native control supports
Windows x64 and macOS; other platforms omit it.

Additional standalone tests:

```powershell
clang++ -std=c++23 -Wall -Wextra -Werror -I mods/src tests/boolean_view_test.cc -o boolean_view_test.exe
./boolean_view_test.exe
clang++ -std=c++23 -Wall -Wextra -Werror -Wno-unused-parameter -I mods/src -I third_party/libil2cpp tests/native_boolean_callback_test.cc -o native_boolean_callback_test.exe
./native_boolean_callback_test.exe
```

These cover view failure transitions and owned native callback invocation pointers.
They do not establish delegate construction, DynamicInvoke, Unity pooling, unknown
prefab presentation, account transitions, cloud durability or frame-time budgets
on a running game. Those require the exact candidate artifact, not the earlier
play prototype's successful tests.
2 changes: 2 additions & 0 deletions example_community_patch_settings_en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ loadingtiphooks = true
doubleclickassignshiphooks = true
forbiddentechconfirmhooks = true
audioeventhooks = true
# Install native Mod Settings hooks; disable only for compatibility troubleshooting.
nativesettingshooks = true
freeresizehooks = true
game_version = true
giftsbulkclaimhooks = true
Expand Down
3 changes: 3 additions & 0 deletions mods/src/config.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,9 @@ void Config::Load()
this->auto_confirm_instant_warp =
get_auto_confirm_instant_warp(config, parsed, DCU::auto_confirm_instant_warp, write_config);
this->installInstantWarpConfirmationHooks = true;
// Internal installation switch; UI availability is checked by the native adapter.
this->installModConfirmationSettings =
get_config_or_default(config, parsed, "patches", "nativesettingshooks", DCP::nativesettingshooks, write_config);
read_instant_warp_filter(config, parsed, "instant_warp_auto_jump", this->instant_warp_auto_jump,
this->instant_warp_auto_jump_all, DCU::instant_warp_auto_jump, write_config);
read_instant_warp_filter(config, parsed, "instant_warp_auto_warp", this->instant_warp_auto_warp,
Expand Down
1 change: 1 addition & 0 deletions mods/src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ class Config final
bool installForbiddenTechConfirmationHooks;
bool installInstantWarpConfirmationHooks;
bool installAudioEventHooks;
bool installModConfirmationSettings;

std::string config_settings_url;
std::string config_assets_url_override;
Expand Down
1 change: 1 addition & 0 deletions mods/src/defaultconfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ namespace Patches
constexpr bool doubleclickassignshiphooks = true;
constexpr bool forbiddentechconfirmhooks = true;
constexpr bool audioeventhooks = true;
constexpr bool nativesettingshooks = true;
constexpr bool instantcargocounterhooks = true;
constexpr bool cargoformathooks = true; // on by default: cargo number precision override
constexpr bool officersorthooks = true; // restore Below Deck Ability sort option
Expand Down
165 changes: 165 additions & 0 deletions mods/src/patches/parts/fc_confirmation_reset.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#include "fc_confirmation_reset.h"
#include "settings/boolean_settings.h"
#include <il2cpp-tabledefs.h>
#include <il2cpp/il2cpp_helper.h>

namespace
{
using namespace mod_settings;
constexpr const char* PreferenceKey = "options/hide_fcaa_use_confirmation";

bool ReferenceField(const FieldInfo* field)
{
if (!field || field->type->byref)
return false;
const auto type = field->type->type;
return type == IL2CPP_TYPE_CLASS || type == IL2CPP_TYPE_GENERICINST || type == IL2CPP_TYPE_OBJECT;
}
Il2CppObject* ExistingSingleton(IL2CppClassHelper& helper)
{
auto parent = helper.GetParent("MonoSingleton`1");
auto* cls = parent.get_cls();
auto* instanceField = cls ? il2cpp_class_get_field_from_name(cls, "s_instance") : nullptr;
auto* initializedField = cls ? il2cpp_class_get_field_from_name(cls, "s_initialized") : nullptr;
if (!ReferenceField(instanceField) || !(instanceField->type->attrs & FIELD_ATTRIBUTE_STATIC) || !initializedField
|| initializedField->type->type != IL2CPP_TYPE_BOOLEAN
|| !(initializedField->type->attrs & FIELD_ATTRIBUTE_STATIC))
return nullptr;
bool initialized = false;
il2cpp_field_static_get_value(initializedField, &initialized);
if (!initialized)
return nullptr;
Il2CppObject* instance = nullptr;
il2cpp_field_static_get_value(instanceField, &instance);
return instance && il2cpp_class_is_assignable_from(helper.get_cls(), instance->klass) ? instance : nullptr;
}
bool BoolField(const FieldInfo* field)
{
return field && field->type->type == IL2CPP_TYPE_BOOLEAN && !field->type->byref
&& !(field->type->attrs & FIELD_ATTRIBUTE_STATIC);
}
bool InstanceMethod(const MethodInfo* method, int count, int result)
{
return method && method->parameters_count == count && method->return_type->type == result
&& !method->return_type->byref && !(method->flags & METHOD_ATTRIBUTE_STATIC);
}

struct Binding {
IL2CppClassHelper prefs =
il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PersistentPrefs", "PersistentPrefsManager");
IL2CppClassHelper fc =
il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.FleetCommander", "FleetCommanderManager");
const MethodInfo* get = prefs.GetMethodInfo("GetBool", 3);
const MethodInfo* set = fc.GetMethodInfo("set_HideFleetCommanderAbilityUseConfirmationHidden", 1);
FieldInfo* loaded =
prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_isCloudFileLoaded") : nullptr;
FieldInfo* loading = prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_isLoading") : nullptr;
FieldInfo* saved = prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_savedData") : nullptr;
// Two bounded weak roots track storage replacement without retaining account data.
// Cleared on invalidation while IL2CPP is live, not in a process-exit destructor.
Il2CppGCHandle owner = nullptr, data = nullptr, key = nullptr;
std::uint64_t generation = 1;
bool supported() const
{
return BoolField(loaded) && BoolField(loading) && ReferenceField(saved)
&& !(saved->type->attrs & FIELD_ATTRIBUTE_STATIC) && InstanceMethod(get, 3, IL2CPP_TYPE_BOOLEAN)
&& get->parameters[0]->type == IL2CPP_TYPE_STRING && !get->parameters[0]->byref
&& get->parameters[1]->type == IL2CPP_TYPE_BOOLEAN && !get->parameters[1]->byref
&& get->parameters[2]->type == IL2CPP_TYPE_BOOLEAN && !get->parameters[2]->byref
&& InstanceMethod(set, 1, IL2CPP_TYPE_VOID) && set->parameters[0]->type == IL2CPP_TYPE_BOOLEAN
&& !set->parameters[0]->byref;
}
void invalidate()
{
if (owner)
il2cpp_gchandle_free(owner);
if (data)
il2cpp_gchandle_free(data);
owner = data = nullptr;
++generation;
}
Il2CppObject* ready()
{
auto* p = ExistingSingleton(prefs);
bool isLoaded = false, isLoading = true;
Il2CppObject* state = nullptr;
if (p) {
il2cpp_field_get_value(p, loaded, &isLoaded);
il2cpp_field_get_value(p, loading, &isLoading);
il2cpp_field_get_value(p, saved, &state);
}
if (!p || !state || !isLoaded || isLoading) {
if (owner || data)
invalidate();
return nullptr;
}
if (!owner || !data || il2cpp_gchandle_get_target(owner) != p || il2cpp_gchandle_get_target(data) != state) {
invalidate();
owner = il2cpp_gchandle_new_weakref(p, false);
data = il2cpp_gchandle_new_weakref(state, false);
if (!owner || !data) {
invalidate();
return nullptr;
}
}
return p;
}
};
Binding& Backend()
{
static Binding binding;
return binding;
}

ReadResult ReadConfirmation()
{
auto& b = Backend();
if (!b.supported())
return {Availability::Unsupported};
auto* prefs = b.ready();
if (!prefs)
return {};
if (!b.key)
b.key = il2cpp_gchandle_new(reinterpret_cast<Il2CppObject*>(il2cpp_string_new(PreferenceKey)), false);
if (!b.key)
return {};
// FC's native property uses GetBool(key,false,true). Display reads must not insert
// a missing preference: preserve its default but explicitly pass shouldAddKey=false.
bool defaultHidden = false, addKey = false;
void* args[] = {il2cpp_gchandle_get_target(b.key), &defaultHidden, &addKey};
Il2CppException* exception = nullptr;
auto* value = il2cpp_runtime_invoke(b.get, prefs, args, &exception);
if (exception || !value)
return {};
return ReadResult::Known(!*static_cast<bool*>(il2cpp_object_unbox(value)), b.generation);
}
ApplyResult WriteConfirmation(bool enabled, std::uint64_t expectedGeneration)
{
auto& b = Backend();
if (!b.supported() || !b.ready() || b.generation != expectedGeneration)
return ApplyResult::Rejected;
auto* manager = ExistingSingleton(b.fc);
if (!manager)
return ApplyResult::Rejected;
bool hidden = !enabled;
void* args[] = {&hidden};
Il2CppException* exception = nullptr;
il2cpp_runtime_invoke(b.set, manager, args, &exception);
return exception ? ApplyResult::Unverified : ApplyResult::Applied;
}
} // namespace

mod_settings::BooleanSetting& FleetCommanderConfirmationSetting()
{
static mod_settings::BooleanSetting setting({"community_mod.fc_ability_confirmation",
"[MOD] Confirm Fleet Commander abilities", ReadConfirmation,
WriteConfirmation});
return setting;
}

void InvalidateFleetCommanderConfirmationSession()
{
// The native UI calls this before preference-session boundaries.
FleetCommanderConfirmationSetting().InvalidateSession();
Backend().invalidate();
}
9 changes: 9 additions & 0 deletions mods/src/patches/parts/fc_confirmation_reset.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#pragma once

namespace mod_settings
{
class BooleanSetting;
}
// Game-thread preference adapter for the native confirmation settings UI.
mod_settings::BooleanSetting& FleetCommanderConfirmationSetting();
void InvalidateFleetCommanderConfirmationSession();
1 change: 1 addition & 0 deletions mods/src/patches/parts/hotkeys.cc
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this)

if (!is_in_chat) {
if (!Key::IsInputFocused()) {

if (MapKey::IsDown(GameFunction::SelectCurrent)) {
auto fleet_bar = ObjectFinder<FleetBarViewController>::Get();
if (fleet_bar) {
Expand Down
Loading
Loading