From fb94a600b7f4b9166c48ff7da122af1210f69f7a Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 14 Jul 2026 23:00:47 -0500 Subject: [PATCH 1/8] docs: simplify board membership verification gate --- ...ard-membership-live-verification-design.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-board-membership-live-verification-design.md diff --git a/docs/superpowers/specs/2026-07-14-board-membership-live-verification-design.md b/docs/superpowers/specs/2026-07-14-board-membership-live-verification-design.md new file mode 100644 index 0000000..13c503a --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-board-membership-live-verification-design.md @@ -0,0 +1,102 @@ +# Board Membership Live Verification Design + +## Goal + +Expose live board add/remove only after the current game build demonstrates that +the hook can reproduce the game's own board-membership path. Keep the gate small +enough to reach a functional result quickly. + +## Selected approach + +Use one disposable dynasty or a dynasty with a verified backup and two off-board +recruits. Capture vanilla UI behavior first, identify the real add/remove path +from those events, then invoke that path and compare the result with vanilla. +Use another dynasty load only when the first load produces conflicting evidence. + +Two alternatives are rejected: + +- An exhaustive matrix across many recruits, saves, weeks, and dynasty modes is + too expensive for the initial feature gate. +- Synthetic table writes are not a prerequisite for the live API. They may be + tested later as an explicitly announced fallback, never as an unannounced live + mutation. + +## Safety boundary + +- The user performs every vanilla UI add/remove action after capture is armed. +- Before any mutation, use a disposable dynasty or confirm a restorable backup. +- Capture and inspection may be automated; no synthetic board mutation may run + without a separate announcement. +- Existing pitch, visit, NIL, and contact-action work may continue through + automated tests because it does not change board membership or freelists. +- `addBoard` and `removeBoard` remain absent until all three gates below pass. + +## Gate 1: establish the vanilla delta + +Within one live dynasty load: + +1. Capture state before and after adding recruit A through the game UI. +2. Repeat the UI add with recruit B. +3. Capture state before and after removing recruit A through the game UI. + +Each capture records the relevant board row, membership array, allocated target +rows, freelist headers, packed references, hours, `ProspectInteraction` rows and +list membership, and recruit-keyed runtime heap objects. The comparison derives +the observed object count and table changes; it does not assume Brooks's +six-object conclusion is correct. + +Row numbers and heap addresses may vary. The invariant is the structure and +meaning of the delta: allocation or release, references, membership order, +hours, interaction state, and runtime objects. If the two adds disagree in a +material way, repeat on a second dynasty load before proceeding. + +## Gate 2: identify and reproduce the real path + +Validate breakpoint evidence only against the vanilla events from Gate 1. A +candidate handler must be attributable to one add or remove event, carry the +selected recruit or slot identity, and produce the observed vanilla delta. +Discard generic hot functions, redraw floods, and hits seen only after synthetic +writes. + +After the add and remove signatures are captured, invoke the game's own handlers +on the correct game thread for an off-board/on-board recruit pair. Do not expose +an SDK method yet. The proof passes only when the hook-driven operation matches +the structural vanilla delta from Gate 1 and the game remains responsive. + +If no attributable handler or safe call site can be established, stop with the +boundary documented as unverified. Do not substitute Brooks's call-chain or +runtime-object conclusions for this capture. + +## Gate 3: render and durability check + +For both hook-driven add and remove: + +1. Confirm the recruiting UI reflects the change immediately or after the same + ordinary screen transition required by vanilla. +2. Save the dynasty through the game UI and reload it. +3. Confirm board membership and the relevant table/runtime invariants still + match the intended state. + +Screen-change rendering and reload durability are recorded separately. A state +that appears only after reload is not a successful live board mutation. + +## Pass and fail outcomes + +| Result | Outcome | +|---|---| +| All three gates pass for add and remove | Design and implement guarded `addBoard` and `removeBoard` APIs using the verified game pathway. | +| Add passes but remove fails, or vice versa | Expose neither operation; document the asymmetric evidence and continue focused investigation. | +| Handler invocation changes tables but does not render live | Keep the API closed; classify it as table-only behavior. | +| Live rendering works but save/reload loses the state | Keep the API closed; classify it as non-durable runtime behavior. | +| Evidence conflicts between recruits | Repeat the conflicting action on a second dynasty load before deciding. | +| Capture cannot attribute a real UI handler | Record the exact unverified boundary and stop. | + +## Deliverables + +- A compact capture report for recruit A add, recruit B add, and recruit A + remove, with before/after structural deltas. +- Attributable add/remove handler captures with arguments and safe-thread call + evidence, or an explicit unverified-boundary report. +- Hook-driven render and save/reload results kept separate. +- Only after a full pass, an implementation plan for guarded public board APIs. + From cae98917d07ea968103243bb5be6e67c98d83302 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 14 Jul 2026 23:34:10 -0500 Subject: [PATCH 2/8] feat: add in-process native call primitive --- .github/workflows/windows-ci.yml | 1 + docs/development/building.md | 1 + docs/lua-api.md | 14 ++ docs/protocol.md | 35 ++++- ...7-14-board-membership-live-verification.md | 107 +++++++++++++++ native/CMakeLists.txt | 9 ++ native/host/lua_host.cpp | 111 +++++++++++++++ native/host/native_call.cpp | 87 ++++++++++++ native/host/native_call.h | 27 ++++ native/smoke/native_call_smoke.cpp | 61 +++++++++ native/smoke/protocol_smoke.cpp | 35 +++++ packages/sdk/src/client.cjs | 40 ++++++ packages/sdk/src/errors.cjs | 2 + packages/sdk/test/native-call.test.cjs | 127 ++++++++++++++++++ tests/closed-gate-policy.test.cjs | 1 + 15 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-07-14-board-membership-live-verification.md create mode 100644 native/host/native_call.cpp create mode 100644 native/host/native_call.h create mode 100644 native/smoke/native_call_smoke.cpp create mode 100644 packages/sdk/test/native-call.test.cjs diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index e45a176..412d1ec 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -22,6 +22,7 @@ jobs: - run: native/build-release/Release/cfb27_memory_reader_smoke.exe - run: native/build-release/Release/cfb27_telemetry_smoke.exe - run: native/build-release/Release/cfb27_memory_transaction_smoke.exe + - run: native/build-release/Release/cfb27_native_call_smoke.exe - run: native/build-release/Release/cfb27_frtk_profile_smoke.exe - run: native/build-release/Release/cfb27_frtk_field_schema_smoke.exe - run: native/build-release/Release/cfb27_frtk_discovery_smoke.exe diff --git a/docs/development/building.md b/docs/development/building.md index 41ddde2..c15b72b 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -19,6 +19,7 @@ native/build-release/Release/cfb27_startup_smoke.exe native/build-release/Releas native/build-release/Release/cfb27_memory_reader_smoke.exe native/build-release/Release/cfb27_telemetry_smoke.exe native/build-release/Release/cfb27_memory_transaction_smoke.exe +native/build-release/Release/cfb27_native_call_smoke.exe native/build-release/Release/cfb27_frtk_profile_smoke.exe native/build-release/Release/cfb27_frtk_field_schema_smoke.exe native/build-release/Release/cfb27_frtk_discovery_smoke.exe diff --git a/docs/lua-api.md b/docs/lua-api.md index d387559..1b77b0a 100644 --- a/docs/lua-api.md +++ b/docs/lua-api.md @@ -59,6 +59,10 @@ local matches = cfb.aob_scan("4D 5A ?? ??", 8) -- byte, writable committed memory, and successful readback. local changed = cfb.write_u8(address, expected, replacement) +-- Synchronously call executable code in the current process using the Win64 +-- integer/pointer ABI. The target is followed by zero to eight 64-bit values. +local result = cfb.call(target, arg0, arg1) + cfb.log("script loaded") -- The trusted main-process client must register this type first with @@ -78,6 +82,16 @@ The lowercase `cfb` functions above are the legacy host scripting surface and are separate from `CFB27.db`; no raw-memory wrapper is added to the database API. +`cfb.call` accepts any committed executable address in the current process, +zero to eight integer or pointer arguments, and returns the function's 64-bit +integer result. It is enabled only for the supported offline game build. Calls +are serialized and execute synchronously on the host worker that evaluates the +Lua buffer; the primitive does not move work onto a game-owned UI thread. +Floating-point/vector arguments, structure returns, and alternate ABI shapes +are not supported. A Windows structured exception becomes a Lua error, but +that guard cannot make an invalid native call safe or undo side effects that +occurred before the exception. + Supported callback names are `game_ready` and `tick`. The host runs `tick` callbacks approximately every 100 ms. The event protocol coalesces observable tick events to at most one per second. diff --git a/docs/protocol.md b/docs/protocol.md index 14d8912..489c2dc 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -47,6 +47,8 @@ Error response: readable private-memory ranges. - `writeTransaction { transactionId, operations }` — apply a bounded guarded batch with complete preflight comparison, readback, and rollback. +- `nativeCall { address, arguments }` — synchronously invoke an executable + in-process address with zero to eight Win64 integer/pointer arguments. - `loadFrtkProfile { profile, layout }` — atomically validate and load a matching version-1 bundle. - `discoverFrtkCatalog {}` — resolve every required table into a new catalog. @@ -61,7 +63,8 @@ Error response: `hello.capabilities` advertises the memory commands as `memoryScan` and `memoryRead`, allocation-aware scans as `memoryScanAllocationMetadata`, guarded writes as `memoryWriteTransaction`, and structured event registration as -`telemetry`. `status.sessionWritesDisabled` reports whether an +`telemetry`. Direct native invocation is advertised as `nativeCall`. +`status.sessionWritesDisabled` reports whether an unverifiable rollback has permanently disabled writes for the current host session. @@ -254,6 +257,34 @@ mutate memory while preflight, apply, verification, or rollback is running. Callers must establish a stable window appropriate to the target data before submitting a transaction. +### Native call + +`nativeCall` is the low-level in-process invocation primitive. `address` and +every entry in `arguments` use canonical uppercase hexadecimal strings so all +64 bits survive JSON transport. The argument array may contain zero through +eight values. The host uses the Windows x64 integer/pointer ABI and returns the +64-bit integer result as another canonical hexadecimal string. + +```json +{"protocol":1,"id":"call-1","command":"nativeCall","params":{"address":"0x140001000","arguments":["0x1","0x2"]}} +``` + +```json +{"address":"0x140001000","value":"0x24"} +``` + +The target must be a committed executable address in the current process, and +the host must recognize the supported offline game build. Calls are serialized +and run synchronously on the named-pipe request worker; this command does not +schedule onto a game-owned UI thread. The primitive supports integer and +pointer arguments only—no floating-point/vector arguments, structures, or +alternate calling conventions. `NATIVE_CALL_TARGET_INVALID` rejects a +non-executable target. `NATIVE_CALL_EXCEPTION` reports a structured-exception +code, but cannot roll back native side effects that occurred before the fault. + +The SDK method is `client.nativeCall({ address, arguments })` and negotiates the +`nativeCall` capability before sending the request. + The host retains at most 512 log entries and 1,024 events. Event cursors are monotonic for one host session. Tick events are coalesced to at most one per second; Lua tick callbacks still run at their normal cadence. @@ -293,6 +324,8 @@ transaction shapes, addresses, hex, and overlapping operations return Typed FrTk commands additionally return `FRTK_PROFILE_INVALID`, `FRTK_DISCOVERY_FAILED`, `FRTK_CATALOG_STALE`, `FRTK_FIELD_INVALID`, and `FRTK_AUTHORITY_UNPROVEN`. +Native calls additionally return `NATIVE_CALL_TARGET_INVALID` and +`NATIVE_CALL_EXCEPTION`. The unversioned legacy text pipe remains temporarily available for migration, but it is not the integration contract for new tools. diff --git a/docs/superpowers/plans/2026-07-14-board-membership-live-verification.md b/docs/superpowers/plans/2026-07-14-board-membership-live-verification.md new file mode 100644 index 0000000..d346186 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-board-membership-live-verification.md @@ -0,0 +1,107 @@ +# Board Membership Live Verification Plan + +**Goal:** Get live board add/remove working through the game's own current-build +path with the minimum evidence needed to avoid shipping a table-only mutation. + +**Scope:** Local offline `CollegeFB27.exe` only. Use a disposable dynasty or a +verified backup. The user performs vanilla UI actions when capture is armed; no +unannounced synthetic board mutation runs. + +## Brooks inputs + +These are research leads, not accepted conclusions: + +| Commit | Useful input | +|---|---| +| `9f42cf13d1200da8212342c46d2a6b6fe6055d06` | Candidate table/header layouts. | +| `5d0a5748287cff0e8f9605fc51f6cf6c6f73a903` | Candidate ProspectInteraction footprint. | +| `b585faa5666245bbd8910a88646533b851b5a55a` | Data-breakpoint capture POC. | +| `cdcaafd01f6b47f52e2789070a4ed163e66cb820` | Execute capture and PE `.pdata` lookup POCs. | +| `9a37dba2bd1b53bc4ceb3f3872a192ad5efd79fd` | Candidate clean-chain RVAs. | +| `27dceed2fe9538fc977ae0b1e4ebc4c70c7bd932` | Six-runtime-object hypothesis. | +| `550274bd85dceec7f3c7d712d5d5fe687cfc5060` | Board add/remove dossier. | + +## Gate 1: Capture the real UI path + +Build only the tools needed to capture one clean vanilla sequence: + +1. Snapshot the relevant board row, membership list, freelists, references, + hours, ProspectInteraction state, and recruit-keyed runtime objects. +2. Arm bounded data/execute breakpoints using Brooks's POCs as starting points. +3. With the user, capture UI add A, UI add B, and UI remove A. +4. Accept a handler only when its arguments identify the selected recruit and + its event produces the captured structural delta. Reject hot redraw and + generic-container hits. + +Implementation files: + +- `scripts/board-verification/board-state.cjs` +- `scripts/board-verification/capture-handler.cjs` +- `scripts/board-verification/pe-functions.cjs` +- `native/host/research_watch.h` +- `native/host/research_watch.cpp` +- `native/smoke/research_watch_smoke.cpp` +- `tests/board-verification.test.cjs` + +Private captures stay under `.frtk/board-verification/`. If the two adds +materially disagree, repeat only the conflicting action on a second dynasty +load. The observed runtime-object count wins; six is not the expected answer. + +The reusable call primitive required for replay is already implemented: + +- `native/host/native_call.h/.cpp` +- protocol capability and command `nativeCall` +- SDK `client.nativeCall({ address, arguments })` +- Lua `cfb.call(target, ...)` + +It accepts a committed executable address, zero to eight Win64 integer/pointer +arguments, and a 64-bit return value. Calls are serialized and guarded for +structured exceptions. It does not schedule itself onto a game-owned UI thread. + +Gate 1 passes when add and remove each have an attributable current-build +handler, argument shape, and thread requirement. Otherwise record the exact +unverified boundary and stop. + +## Gate 2: Reproduce add and remove + +After Gate 1 passes, announce the mutation test and use the disposable/backup +dynasty: + +1. Invoke the captured add handler for an off-board recruit using the native + call primitive and the verified thread/call path. +2. Compare the complete structural delta with the vanilla add delta. +3. Invoke the captured remove handler for that recruit and compare it with the + vanilla remove delta. +4. Confirm the recruiting screen reflects both operations immediately or after + the same ordinary screen transition vanilla needs. + +Do not substitute direct table writes if a handler call fails. A table change +that appears only after a screen change or reload is recorded as such and does +not pass live reproduction. + +Gate 2 passes only when both directions match vanilla structurally and render +live without destabilizing the game. + +## Gate 3: Prove durability, then expose the API + +For the hook-driven add and remove: + +1. Save through the game UI, reload, and verify membership plus the captured + table/runtime invariants. +2. Record immediate rendering, reload materialization, and save durability as + separate results in `docs/research/runtime-verification.md`. +3. If both operations pass, implement guarded `addBoard` and `removeBoard` + wrappers that resolve only the verified build handlers and validate board + preconditions before dispatch. +4. If either direction fails, expose neither wrapper and document the boundary. + +Before committing each implementation slice, run: + +```powershell +npm run check +npm test +cmake --build native/build-release --config Release +``` + +No public board API exists until all three gates pass. Portable pitch, visit, +NIL, and contact-action work remains independent of this gate. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 4b003b3..552182e 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -53,6 +53,7 @@ add_library(cfb27_lua_host SHARED host/lua_host.cpp host/memory_reader.cpp host/memory_transaction.cpp + host/native_call.cpp host/protocol.cpp host/telemetry.cpp ) @@ -95,6 +96,14 @@ target_compile_features(cfb27_memory_transaction_smoke PRIVATE cxx_std_20) target_compile_definitions(cfb27_memory_transaction_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) target_link_options(cfb27_memory_transaction_smoke PRIVATE /STACK:1048576) +add_executable(cfb27_native_call_smoke + smoke/native_call_smoke.cpp + host/native_call.cpp +) +target_compile_features(cfb27_native_call_smoke PRIVATE cxx_std_20) +target_compile_definitions(cfb27_native_call_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_options(cfb27_native_call_smoke PRIVATE /STACK:1048576) + add_executable(cfb27_telemetry_smoke smoke/telemetry_smoke.cpp host/telemetry.cpp diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index 45b77da..9d6a3e7 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -4,6 +4,7 @@ #include "memory_reader.h" #include "memory_transaction.h" +#include "native_call.h" #include "frtk_catalog.h" #include "frtk_lua_api.h" #include "frtk_profile.h" @@ -77,6 +78,7 @@ std::mutex g_host_write_mutex; std::mutex g_event_mutex; std::mutex g_file_log_mutex; std::mutex g_frtk_mutex; +std::mutex g_native_call_mutex; lua_State* g_lua{}; std::vector g_callbacks; std::filesystem::path g_host_directory; @@ -261,6 +263,11 @@ bool WriteEnvironmentAllowed() { return (SupportedBuild() || SmokeWritesAllowed()) && !RealAnticheatIsRunning(); } +bool NativeCallsAllowed() { + return !g_session_writes_disabled.load(std::memory_order_acquire) && + WriteEnvironmentAllowed(); +} + class ProcessDiscoveryBackend final : public cfb27::frtk::DiscoveryBackend { public: explicit ProcessDiscoveryBackend(bool smoke_timeout_progress = false) @@ -538,6 +545,42 @@ int LuaWriteU8(lua_State* state) { return 1; } +int LuaNativeCall(lua_State* state) { + const int count = lua_gettop(state); + if (count < 1 || count > static_cast(cfb27::native_call::kMaxArguments + 1)) { + return luaL_error(state, "cfb.call requires a target and zero to eight arguments"); + } + if (!NativeCallsAllowed()) { + return luaL_error(state, "native calls require the supported offline game build"); + } + + const auto target = static_cast(luaL_checkinteger(state, 1)); + std::array arguments{}; + for (int index = 2; index <= count; ++index) { + arguments[static_cast(index - 2)] = + static_cast(luaL_checkinteger(state, index)); + } + cfb27::native_call::Result result; + { + std::lock_guard call_lock(g_native_call_mutex); + result = cfb27::native_call::Invoke( + target, std::span(arguments.data(), + static_cast(count - 1))); + } + if (result.status == cfb27::native_call::Status::kInvalidTarget) { + return luaL_error(state, "native call target is not executable process code"); + } + if (result.status == cfb27::native_call::Status::kTooManyArguments) { + return luaL_error(state, "native call accepts at most eight arguments"); + } + if (result.status == cfb27::native_call::Status::kException) { + return luaL_error(state, "native call raised exception 0x%08X", + result.fault_code); + } + lua_pushinteger(state, static_cast(result.value)); + return 1; +} + struct PatternByte { std::uint8_t value{}; bool wildcard{}; }; std::optional> ParsePattern(std::string_view text) { @@ -856,6 +899,7 @@ void RegisterApi(lua_State* state) { lua_pushcfunction(state, LuaModuleBase); lua_setfield(state, -2, "module_base"); lua_pushcfunction(state, LuaReadU8); lua_setfield(state, -2, "read_u8"); lua_pushcfunction(state, LuaWriteU8); lua_setfield(state, -2, "write_u8"); + lua_pushcfunction(state, LuaNativeCall); lua_setfield(state, -2, "call"); lua_pushcfunction(state, LuaAobScan); lua_setfield(state, -2, "aob_scan"); lua_pushcfunction(state, LuaLog); lua_setfield(state, -2, "log"); lua_pushcfunction(state, LuaEmit); lua_setfield(state, -2, "emit"); @@ -1269,6 +1313,7 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { {"capabilities", {"status", "runScript", "evaluate", "logs", "events", "memoryScan", "memoryScanAllocationMetadata", "memoryRead", "memoryWriteTransaction", + "nativeCall", "telemetry", "frtkProfileV1", "frtkCatalogV1", "frtkRecordReadV1", "frtkFieldTransactionV1"}}, }); @@ -1291,6 +1336,72 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { }); } + if (command == "nativeCall") { + if (!HasOnlyKeys(params, {"address", "arguments"}) || + !params.contains("address") || !params["address"].is_string() || + !params.contains("arguments") || !params["arguments"].is_array() || + params["arguments"].size() > cfb27::native_call::kMaxArguments) { + return ErrorResponse(id, "INVALID_REQUEST", + "nativeCall requires an address and zero to eight arguments"); + } + if (session_writes_disabled) { + return ErrorResponse(id, "SESSION_WRITES_DISABLED", + "Native calls are disabled for this host session"); + } + if (!NativeCallsAllowed()) { + return ErrorResponse(id, "UNSUPPORTED_BUILD", + "Native calls require the supported offline game build"); + } + + const auto canonical_target = CanonicalAddress(params["address"].get()); + if (!canonical_target) { + return ErrorResponse(id, "INVALID_REQUEST", + "nativeCall address must be canonical hexadecimal"); + } + const auto target = cfb27::memory::ParseAddress(*canonical_target); + std::vector arguments; + arguments.reserve(params["arguments"].size()); + for (const auto& argument : params["arguments"]) { + if (!argument.is_string()) { + return ErrorResponse(id, "INVALID_REQUEST", + "nativeCall arguments must be canonical hexadecimal strings"); + } + const auto canonical_argument = CanonicalAddress(argument.get()); + if (!canonical_argument) { + return ErrorResponse(id, "INVALID_REQUEST", + "nativeCall arguments must be canonical hexadecimal strings"); + } + const auto parsed = cfb27::memory::ParseAddress(*canonical_argument); + if (!parsed) { + return ErrorResponse(id, "INVALID_REQUEST", "nativeCall argument is invalid"); + } + arguments.push_back(static_cast(*parsed)); + } + + cfb27::native_call::Result call; + { + std::lock_guard call_lock(g_native_call_mutex); + call = cfb27::native_call::Invoke(*target, arguments); + } + if (call.status == cfb27::native_call::Status::kInvalidTarget) { + return ErrorResponse(id, "NATIVE_CALL_TARGET_INVALID", + "Native call target is not executable process code"); + } + if (call.status == cfb27::native_call::Status::kTooManyArguments) { + return ErrorResponse(id, "INVALID_REQUEST", + "nativeCall accepts at most eight arguments"); + } + if (call.status == cfb27::native_call::Status::kException) { + return ErrorResponse( + id, "NATIVE_CALL_EXCEPTION", "Native call raised a structured exception", + {{"exceptionCode", FormatCanonicalAddress(call.fault_code)}}); + } + return SuccessResponse(id, { + {"address", *canonical_target}, + {"value", FormatCanonicalAddress(static_cast(call.value))}, + }); + } + if (command == "loadFrtkProfile") { if (!HasOnlyKeys(params, {"profile", "layout"}) || !params.contains("profile") || !params.contains("layout")) { diff --git a/native/host/native_call.cpp b/native/host/native_call.cpp new file mode 100644 index 0000000..f80a42f --- /dev/null +++ b/native/host/native_call.cpp @@ -0,0 +1,87 @@ +#include "native_call.h" + +#include + +namespace cfb27::native_call { +namespace { + +using Word = std::uint64_t; + +Word Dispatch(std::uintptr_t address, std::span arguments) { + switch (arguments.size()) { + case 0: + return reinterpret_cast(address)(); + case 1: + return reinterpret_cast(address)(arguments[0]); + case 2: + return reinterpret_cast(address)( + arguments[0], arguments[1]); + case 3: + return reinterpret_cast(address)( + arguments[0], arguments[1], arguments[2]); + case 4: + return reinterpret_cast(address)( + arguments[0], arguments[1], arguments[2], arguments[3]); + case 5: + return reinterpret_cast(address)( + arguments[0], arguments[1], arguments[2], arguments[3], arguments[4]); + case 6: + return reinterpret_cast(address)( + arguments[0], arguments[1], arguments[2], arguments[3], arguments[4], + arguments[5]); + case 7: + return reinterpret_cast( + address)(arguments[0], arguments[1], arguments[2], arguments[3], + arguments[4], arguments[5], arguments[6]); + case 8: + return reinterpret_cast< + Word (*)(Word, Word, Word, Word, Word, Word, Word, Word)>(address)( + arguments[0], arguments[1], arguments[2], arguments[3], arguments[4], + arguments[5], arguments[6], arguments[7]); + default: + return 0; + } +} + +Result InvokeGuarded(std::uintptr_t address, + std::span arguments) { + Result result{.status = Status::kOk}; +#if defined(_MSC_VER) + __try { + result.value = Dispatch(address, arguments); + } __except (EXCEPTION_EXECUTE_HANDLER) { + result.status = Status::kException; + result.fault_code = static_cast(GetExceptionCode()); + } +#else + result.value = Dispatch(address, arguments); +#endif + return result; +} + +} // namespace + +bool IsExecutableAddress(std::uintptr_t address) { + if (!address) return false; + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(address), &info, sizeof(info)) != + sizeof(info) || + info.State != MEM_COMMIT || (info.Protect & (PAGE_GUARD | PAGE_NOACCESS))) { + return false; + } + constexpr DWORD executable = PAGE_EXECUTE | PAGE_EXECUTE_READ | + PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + return (info.Protect & executable) != 0; +} + +Result Invoke(std::uintptr_t address, std::span arguments) { + if (arguments.size() > kMaxArguments) { + return {.status = Status::kTooManyArguments}; + } + if (!IsExecutableAddress(address)) { + return {.status = Status::kInvalidTarget}; + } + return InvokeGuarded(address, arguments); +} + +} // namespace cfb27::native_call diff --git a/native/host/native_call.h b/native/host/native_call.h new file mode 100644 index 0000000..cebe9eb --- /dev/null +++ b/native/host/native_call.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +namespace cfb27::native_call { + +constexpr std::size_t kMaxArguments = 8; + +enum class Status { + kOk, + kInvalidTarget, + kTooManyArguments, + kException, +}; + +struct Result { + Status status{Status::kInvalidTarget}; + std::uint64_t value{}; + std::uint32_t fault_code{}; +}; + +bool IsExecutableAddress(std::uintptr_t address); +Result Invoke(std::uintptr_t address, std::span arguments); + +} // namespace cfb27::native_call diff --git a/native/smoke/native_call_smoke.cpp b/native/smoke/native_call_smoke.cpp new file mode 100644 index 0000000..8e85dc2 --- /dev/null +++ b/native/smoke/native_call_smoke.cpp @@ -0,0 +1,61 @@ +#include "../host/native_call.h" + +#include + +#include +#include +#include + +namespace { + +__declspec(noinline) std::uint64_t SumEight( + std::uint64_t a, std::uint64_t b, std::uint64_t c, std::uint64_t d, + std::uint64_t e, std::uint64_t f, std::uint64_t g, std::uint64_t h) { + return a + b + c + d + e + f + g + h; +} + +__declspec(noinline) std::uint64_t RaiseFault() { + RaiseException(0xE0424242, 0, 0, nullptr); + return 0; +} + +void Require(bool condition, const char* message) { + if (!condition) throw message; +} + +} // namespace + +int main() { + try { + const auto target = reinterpret_cast(&SumEight); + Require(cfb27::native_call::IsExecutableAddress(target), + "test function was not accepted as executable code"); + const std::vector arguments{1, 2, 3, 4, 5, 6, 7, 8}; + const auto result = cfb27::native_call::Invoke(target, arguments); + Require(result.status == cfb27::native_call::Status::kOk, + "eight-argument call failed"); + Require(result.value == 36, "eight-argument return value is wrong"); + + const auto fault = cfb27::native_call::Invoke( + reinterpret_cast(&RaiseFault), {}); + Require(fault.status == cfb27::native_call::Status::kException, + "structured exception was not captured"); + Require(fault.fault_code == 0xE0424242, + "structured exception code was not preserved"); + + const auto invalid = cfb27::native_call::Invoke(1, {}); + Require(invalid.status == cfb27::native_call::Status::kInvalidTarget, + "invalid target was accepted"); + + const std::vector excessive(9, 0); + const auto too_many = cfb27::native_call::Invoke(target, excessive); + Require(too_many.status == cfb27::native_call::Status::kTooManyArguments, + "excessive argument list was accepted"); + + std::cout << "native call smoke passed\n"; + return 0; + } catch (const char* error) { + std::cerr << "native call smoke failed: " << error << '\n'; + return 1; + } +} diff --git a/native/smoke/protocol_smoke.cpp b/native/smoke/protocol_smoke.cpp index 22ffee6..4cd358c 100644 --- a/native/smoke/protocol_smoke.cpp +++ b/native/smoke/protocol_smoke.cpp @@ -27,6 +27,12 @@ constexpr std::array kSentinel{ 0xD4, 0xE5, 0xF6, 0x07, 0x18, 0x29, 0x3A, 0x4B, }; +__declspec(noinline) std::uint64_t NativeCallSumEight( + std::uint64_t a, std::uint64_t b, std::uint64_t c, std::uint64_t d, + std::uint64_t e, std::uint64_t f, std::uint64_t g, std::uint64_t h) { + return a + b + c + d + e + f + g + h; +} + class Allocation { public: explicit Allocation(std::size_t size) @@ -432,6 +438,35 @@ int wmain(int argc, wchar_t** argv) { if (std::find(capabilities.begin(), capabilities.end(), "telemetry") == capabilities.end()) return 51; if (std::find(capabilities.begin(), capabilities.end(), "memoryScanAllocationMetadata") == capabilities.end()) return 106; + if (std::find(capabilities.begin(), capabilities.end(), "nativeCall") == + capabilities.end()) return 139; + Json native_arguments = Json::array(); + for (std::uintptr_t value = 1; value <= 8; ++value) { + native_arguments.push_back(FormatAddress(value)); + } + const auto native_target = + FormatAddress(reinterpret_cast(&NativeCallSumEight)); + if (!Request(pipe, {{"protocol", 1}, {"id", "native-call"}, + {"command", "nativeCall"}, + {"params", {{"address", native_target}, + {"arguments", native_arguments}}}}, + response, false) || !response.value("ok", false) || + response["result"].value("address", "") != native_target || + response["result"].value("value", "") != "0x24") return 140; + const std::string native_lua = + "assert(cfb.call(" + + std::to_string(reinterpret_cast(&NativeCallSumEight)) + + ", 1, 2, 3, 4, 5, 6, 7, 8) == 36)"; + if (!Request(pipe, {{"protocol", 1}, {"id", "native-call-lua"}, + {"command", "evaluate"}, + {"params", {{"source", native_lua}}}}, + response, false) || !response.value("ok", false)) return 142; + if (!Request(pipe, {{"protocol", 1}, {"id", "native-call-invalid"}, + {"command", "nativeCall"}, + {"params", {{"address", "0x1"}, + {"arguments", Json::array()}}}}, + response, false) || + !IsError(response, "NATIVE_CALL_TARGET_INVALID")) return 141; const auto bundle = SyntheticBundle(std::span(frtk_bytes, 48)); if (!Request(pipe, {{"protocol", 1}, {"id", "frtk-missing-profile"}, {"command", "discoverFrtkCatalog"}, {"params", Json::object()}}, diff --git a/packages/sdk/src/client.cjs b/packages/sdk/src/client.cjs index e2f1ad8..10e7cd8 100644 --- a/packages/sdk/src/client.cjs +++ b/packages/sdk/src/client.cjs @@ -34,6 +34,7 @@ const TRANSACTION_ID = /^[A-Za-z0-9._-]{1,64}$/; const RESERVED_TELEMETRY_TYPES = new Set(['game_ready', 'tick', 'log']); const PIPE_CONNECT_RETRY_DELAY_MS = 10; const MAX_UINT64 = 0xFFFFFFFFFFFFFFFFn; +const NATIVE_CALL_CAPABILITY = 'nativeCall'; const WRITE_TRANSACTION_ERROR_MESSAGES = Object.freeze({ INVALID_REQUEST: 'Host rejected the write transaction request', UNSUPPORTED_BUILD: 'Memory writes require the supported game build', @@ -752,6 +753,28 @@ function cloneTelemetryTypes(types) { return clone; } +function cloneNativeCallOptions(options = {}) { + if (!isObject(options) || !hasOnlyKeys(options, ['address', 'arguments']) || + typeof options.address !== 'string' || !CANONICAL_ADDRESS.test(options.address)) { + throw invalidRequest('nativeCall requires a canonical hexadecimal address'); + } + const argumentsValue = options.arguments === undefined ? [] : options.arguments; + if (!Array.isArray(argumentsValue) || argumentsValue.length > 8 || + !argumentsValue.every((value) => + typeof value === 'string' && CANONICAL_ADDRESS.test(value))) { + throw invalidRequest('nativeCall accepts zero to eight canonical hexadecimal arguments'); + } + return { address: options.address, arguments: [...argumentsValue] }; +} + +function validateNativeCallResult(result, params) { + if (!hasExactKeys(result, ['address', 'value']) || result.address !== params.address || + typeof result.value !== 'string' || !CANONICAL_ADDRESS.test(result.value)) { + throw invalidResponse('Host returned an invalid nativeCall result'); + } + return result; +} + function validateTelemetryRegistration(result, types) { if (!hasExactKeys(result, ['types']) || !Array.isArray(result.types) || result.types.length !== types.length || @@ -897,6 +920,18 @@ function createClient({ pid, pipeName, timeoutMs = 20000 } = {}) { } } + async function requireNativeCallCapability() { + const hello = await request('hello'); + if (!hello || hello.protocolVersion !== 1 || + !Array.isArray(hello.capabilities) || + !hello.capabilities.includes(NATIVE_CALL_CAPABILITY)) { + throw new Cfb27HookError( + 'PROTOCOL_MISMATCH', + 'Host does not advertise nativeCall capability', + ); + } + } + async function requireFrtkCapability(capability) { const hello = await request('hello'); if (!hasExactKeys(hello, ['protocolVersion', 'hostVersion', 'supportedBuild', 'writesAllowed', @@ -951,6 +986,11 @@ function createClient({ pid, pipeName, timeoutMs = 20000 } = {}) { evaluateLua(source) { return request('evaluate', { source }); }, + async nativeCall(options = {}) { + const params = cloneNativeCallOptions(options); + await requireNativeCallCapability(); + return validateNativeCallResult(await request('nativeCall', params), params); + }, getLogs({ limit = 100 } = {}) { return request('logs', { limit }); }, diff --git a/packages/sdk/src/errors.cjs b/packages/sdk/src/errors.cjs index a9b1c31..bd41e76 100644 --- a/packages/sdk/src/errors.cjs +++ b/packages/sdk/src/errors.cjs @@ -18,6 +18,8 @@ const ERROR_CODES = Object.freeze([ 'TRANSACTION_APPLY_FAILED', 'ROLLBACK_VERIFICATION_FAILED', 'SESSION_WRITES_DISABLED', + 'NATIVE_CALL_TARGET_INVALID', + 'NATIVE_CALL_EXCEPTION', 'FRTK_PROFILE_INVALID', 'FRTK_DISCOVERY_FAILED', 'FRTK_DISCOVERY_TIMEOUT', diff --git a/packages/sdk/test/native-call.test.cjs b/packages/sdk/test/native-call.test.cjs new file mode 100644 index 0000000..e7ece66 --- /dev/null +++ b/packages/sdk/test/native-call.test.cjs @@ -0,0 +1,127 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const net = require('node:net'); +const { createClient } = require('../src/client.cjs'); +const { ERROR_CODES } = require('../src/errors.cjs'); +const { FrameDecoder, encodeFrame } = require('../src/frame.cjs'); + +function pipeName(label) { + return `\\\\.\\pipe\\cfb27-native-call-${label}-${process.pid}-${Date.now()}-${Math.random()}`; +} + +function listen(server, name) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(name, resolve); + }); +} + +test('native call error codes are public and stable', () => { + assert.equal(ERROR_CODES.includes('NATIVE_CALL_TARGET_INVALID'), true); + assert.equal(ERROR_CODES.includes('NATIVE_CALL_EXCEPTION'), true); +}); + +test('nativeCall negotiates capability and sends an exact cloned request', async (t) => { + const name = pipeName('valid'); + const requests = []; + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + requests.push({ command: request.command, params: request.params }); + const result = request.command === 'hello' + ? { + protocolVersion: 1, + hostVersion: 'test', + supportedBuild: true, + writesAllowed: true, + capabilities: ['nativeCall'], + } + : { address: request.params.address, value: '0x24' }; + socket.end(encodeFrame({ protocol: 1, id: request.id, ok: true, result })); + } + }); + }); + await listen(server, name); + t.after(() => server.close()); + + const client = createClient({ pipeName: name, timeoutMs: 1000 }); + const input = { address: '0x140001000', arguments: ['0x1', '0x2', '0x3'] }; + const pending = client.nativeCall(input); + input.address = '0x140002000'; + input.arguments[0] = '0x9'; + assert.deepEqual(await pending, { address: '0x140001000', value: '0x24' }); + assert.deepEqual(requests, [ + { command: 'hello', params: {} }, + { + command: 'nativeCall', + params: { address: '0x140001000', arguments: ['0x1', '0x2', '0x3'] }, + }, + ]); +}); + +test('nativeCall rejects malformed targets and arguments before I/O', async () => { + const client = createClient({ pipeName: pipeName('unused'), timeoutMs: 25 }); + const invalid = [ + {}, + { address: '140001000' }, + { address: '0x0140001000' }, + { address: '0x140001000', arguments: new Array(9).fill('0x0') }, + { address: '0x140001000', arguments: [1] }, + { address: '0x140001000', arguments: ['0xabc'] }, + { address: '0x140001000', arguments: [], extra: true }, + ]; + for (const input of invalid) { + await assert.rejects(Promise.resolve().then(() => client.nativeCall(input)), { + code: 'INVALID_REQUEST', + }); + } +}); + +test('nativeCall fails closed when the host lacks the capability', async (t) => { + const name = pipeName('capability'); + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + socket.end(encodeFrame({ + protocol: 1, + id: request.id, + ok: true, + result: { protocolVersion: 1, capabilities: [] }, + })); + } + }); + }); + await listen(server, name); + t.after(() => server.close()); + + const client = createClient({ pipeName: name, timeoutMs: 1000 }); + await assert.rejects(client.nativeCall({ address: '0x140001000' }), { + code: 'PROTOCOL_MISMATCH', + }); +}); + +test('nativeCall rejects malformed host results', async (t) => { + const name = pipeName('response'); + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + const result = request.command === 'hello' + ? { protocolVersion: 1, capabilities: ['nativeCall'] } + : { address: request.params.address, value: 36 }; + socket.end(encodeFrame({ protocol: 1, id: request.id, ok: true, result })); + } + }); + }); + await listen(server, name); + t.after(() => server.close()); + + const client = createClient({ pipeName: name, timeoutMs: 1000 }); + await assert.rejects(client.nativeCall({ address: '0x140001000' }), { + code: 'INVALID_RESPONSE', + }); +}); diff --git a/tests/closed-gate-policy.test.cjs b/tests/closed-gate-policy.test.cjs index 4976397..8b75a8a 100644 --- a/tests/closed-gate-policy.test.cjs +++ b/tests/closed-gate-policy.test.cjs @@ -19,6 +19,7 @@ const standaloneSmokes = [ 'cfb27_memory_reader_smoke.exe', 'cfb27_telemetry_smoke.exe', 'cfb27_memory_transaction_smoke.exe', + 'cfb27_native_call_smoke.exe', 'cfb27_frtk_profile_smoke.exe', 'cfb27_frtk_field_schema_smoke.exe', 'cfb27_frtk_discovery_smoke.exe', From f4eb33120dff1eace2da0c6de27a16d0d230d987 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 15 Jul 2026 17:38:51 -0500 Subject: [PATCH 3/8] feat: add bounded live handler capture --- .github/workflows/windows-ci.yml | 1 + docs/development/building.md | 1 + docs/lua-api.md | 16 ++ native/CMakeLists.txt | 9 + native/host/lua_host.cpp | 77 ++++++ native/host/research_watch.cpp | 350 ++++++++++++++++++++++++++ native/host/research_watch.h | 63 +++++ native/smoke/protocol_smoke.cpp | 11 + native/smoke/research_watch_smoke.cpp | 94 +++++++ tests/closed-gate-policy.test.cjs | 1 + 10 files changed, 623 insertions(+) create mode 100644 native/host/research_watch.cpp create mode 100644 native/host/research_watch.h create mode 100644 native/smoke/research_watch_smoke.cpp diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 412d1ec..a6a669b 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -23,6 +23,7 @@ jobs: - run: native/build-release/Release/cfb27_telemetry_smoke.exe - run: native/build-release/Release/cfb27_memory_transaction_smoke.exe - run: native/build-release/Release/cfb27_native_call_smoke.exe + - run: native/build-release/Release/cfb27_research_watch_smoke.exe - run: native/build-release/Release/cfb27_frtk_profile_smoke.exe - run: native/build-release/Release/cfb27_frtk_field_schema_smoke.exe - run: native/build-release/Release/cfb27_frtk_discovery_smoke.exe diff --git a/docs/development/building.md b/docs/development/building.md index c15b72b..ab77268 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -20,6 +20,7 @@ native/build-release/Release/cfb27_memory_reader_smoke.exe native/build-release/Release/cfb27_telemetry_smoke.exe native/build-release/Release/cfb27_memory_transaction_smoke.exe native/build-release/Release/cfb27_native_call_smoke.exe +native/build-release/Release/cfb27_research_watch_smoke.exe native/build-release/Release/cfb27_frtk_profile_smoke.exe native/build-release/Release/cfb27_frtk_field_schema_smoke.exe native/build-release/Release/cfb27_frtk_discovery_smoke.exe diff --git a/docs/lua-api.md b/docs/lua-api.md index 1b77b0a..01d4414 100644 --- a/docs/lua-api.md +++ b/docs/lua-api.md @@ -63,6 +63,12 @@ local changed = cfb.write_u8(address, expected, replacement) -- integer/pointer ABI. The target is followed by zero to eight 64-bit values. local result = cfb.call(target, arg0, arg1) +-- Research capture: at most four process-local hardware breakpoint slots. +local slot, threads = cfb.watch(write_address, 4) +local exec_slot, exec_threads = cfb.watch_exec(function_address) +local hits = cfb.watch_hits(true) +local restored_threads = cfb.unwatch() + cfb.log("script loaded") -- The trusted main-process client must register this type first with @@ -92,6 +98,16 @@ are not supported. A Windows structured exception becomes a Lua error, but that guard cannot make an invalid native call safe or undo side effects that occurred before the exception. +`cfb.watch(address, length)` arms a write breakpoint of length 1, 2, 4, or 8; +the address must be naturally aligned. `cfb.watch_exec(address)` arms an +execute breakpoint. At most four total slots may be active. Existing process +threads that are accessible and do not already own hardware breakpoints are +armed; the returned values are the zero-based slot and armed-thread count. +`cfb.watch_hits(clear)` returns at most 128 fixed register/stack snapshots plus +a `dropped` count. `cfb.unwatch()` restores saved debug-register state. These +functions are current-process research tools; always collect and disarm before +continuing normal play. + Supported callback names are `game_ready` and `tick`. The host runs `tick` callbacks approximately every 100 ms. The event protocol coalesces observable tick events to at most one per second. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 552182e..8f31dab 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -55,6 +55,7 @@ add_library(cfb27_lua_host SHARED host/memory_transaction.cpp host/native_call.cpp host/protocol.cpp + host/research_watch.cpp host/telemetry.cpp ) target_compile_features(cfb27_lua_host PRIVATE cxx_std_20) @@ -104,6 +105,14 @@ target_compile_features(cfb27_native_call_smoke PRIVATE cxx_std_20) target_compile_definitions(cfb27_native_call_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) target_link_options(cfb27_native_call_smoke PRIVATE /STACK:1048576) +add_executable(cfb27_research_watch_smoke + smoke/research_watch_smoke.cpp + host/research_watch.cpp +) +target_compile_features(cfb27_research_watch_smoke PRIVATE cxx_std_20) +target_compile_definitions(cfb27_research_watch_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_options(cfb27_research_watch_smoke PRIVATE /STACK:1048576) + add_executable(cfb27_telemetry_smoke smoke/telemetry_smoke.cpp host/telemetry.cpp diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index 9d6a3e7..408faad 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -10,6 +10,7 @@ #include "frtk_profile.h" #include "frtk_record_access.h" #include "protocol.h" +#include "research_watch.h" #include "telemetry.h" #include @@ -581,6 +582,77 @@ int LuaNativeCall(lua_State* state) { return 1; } +int LuaArmWatch(lua_State* state, cfb27::research_watch::Kind kind) { + if (!NativeCallsAllowed()) { + return luaL_error(state, "research watches require the supported offline game build"); + } + const auto address = static_cast(luaL_checkinteger(state, 1)); + const auto length = kind == cfb27::research_watch::Kind::kExecute + ? 1u + : static_cast(luaL_optinteger(state, 2, 4)); + const auto result = cfb27::research_watch::Arm(kind, address, length); + if (result.status != cfb27::research_watch::ArmStatus::kOk) { + return luaL_error(state, "could not arm research watch: %s", + cfb27::research_watch::ArmStatusCode(result.status)); + } + lua_pushinteger(state, static_cast(result.slot)); + lua_pushinteger(state, static_cast(result.thread_count)); + return 2; +} + +int LuaWatch(lua_State* state) { + return LuaArmWatch(state, cfb27::research_watch::Kind::kWrite); +} + +int LuaWatchExec(lua_State* state) { + return LuaArmWatch(state, cfb27::research_watch::Kind::kExecute); +} + +void PushHitInteger(lua_State* state, const char* name, std::uint64_t value) { + lua_pushinteger(state, static_cast(value)); + lua_setfield(state, -2, name); +} + +int LuaWatchHits(lua_State* state) { + const bool clear = lua_toboolean(state, 1) != 0; + const auto snapshot = cfb27::research_watch::Collect(clear); + lua_createtable(state, static_cast(snapshot.hits.size()), 1); + for (std::size_t index = 0; index < snapshot.hits.size(); ++index) { + const auto& hit = snapshot.hits[index]; + lua_createtable(state, 0, 16); + PushHitInteger(state, "slot", hit.slot); + PushHitInteger(state, "thread_id", hit.thread_id); + PushHitInteger(state, "rip", hit.rip); + PushHitInteger(state, "rsp", hit.rsp); + PushHitInteger(state, "rax", hit.rax); + PushHitInteger(state, "rbx", hit.rbx); + PushHitInteger(state, "rbp", hit.rbp); + PushHitInteger(state, "rsi", hit.rsi); + PushHitInteger(state, "rdi", hit.rdi); + PushHitInteger(state, "rcx", hit.rcx); + PushHitInteger(state, "rdx", hit.rdx); + PushHitInteger(state, "r8", hit.r8); + PushHitInteger(state, "r9", hit.r9); + PushHitInteger(state, "r10", hit.r10); + PushHitInteger(state, "r11", hit.r11); + lua_createtable(state, static_cast(hit.stack_count), 0); + for (std::size_t stack_index = 0; stack_index < hit.stack_count; ++stack_index) { + lua_pushinteger(state, static_cast(hit.stack[stack_index])); + lua_rawseti(state, -2, static_cast(stack_index + 1)); + } + lua_setfield(state, -2, "stack"); + lua_rawseti(state, -2, static_cast(index + 1)); + } + PushHitInteger(state, "dropped", snapshot.dropped); + return 1; +} + +int LuaUnwatch(lua_State* state) { + lua_pushinteger(state, + static_cast(cfb27::research_watch::Disarm())); + return 1; +} + struct PatternByte { std::uint8_t value{}; bool wildcard{}; }; std::optional> ParsePattern(std::string_view text) { @@ -900,6 +972,10 @@ void RegisterApi(lua_State* state) { lua_pushcfunction(state, LuaReadU8); lua_setfield(state, -2, "read_u8"); lua_pushcfunction(state, LuaWriteU8); lua_setfield(state, -2, "write_u8"); lua_pushcfunction(state, LuaNativeCall); lua_setfield(state, -2, "call"); + lua_pushcfunction(state, LuaWatch); lua_setfield(state, -2, "watch"); + lua_pushcfunction(state, LuaWatchExec); lua_setfield(state, -2, "watch_exec"); + lua_pushcfunction(state, LuaWatchHits); lua_setfield(state, -2, "watch_hits"); + lua_pushcfunction(state, LuaUnwatch); lua_setfield(state, -2, "unwatch"); lua_pushcfunction(state, LuaAobScan); lua_setfield(state, -2, "aob_scan"); lua_pushcfunction(state, LuaLog); lua_setfield(state, -2, "log"); lua_pushcfunction(state, LuaEmit); lua_setfield(state, -2, "emit"); @@ -1314,6 +1390,7 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { "memoryScan", "memoryScanAllocationMetadata", "memoryRead", "memoryWriteTransaction", "nativeCall", + "researchWatch", "telemetry", "frtkProfileV1", "frtkCatalogV1", "frtkRecordReadV1", "frtkFieldTransactionV1"}}, }); diff --git a/native/host/research_watch.cpp b/native/host/research_watch.cpp new file mode 100644 index 0000000..511c49e --- /dev/null +++ b/native/host/research_watch.cpp @@ -0,0 +1,350 @@ +#include "research_watch.h" + +#include +#include + +#include +#include +#include + +namespace cfb27::research_watch { +namespace { + +struct SlotState { + std::atomic address{}; + std::atomic kind{Kind::kWrite}; + std::atomic length{1}; +}; + +struct BufferedHit { + Hit hit; + std::atomic ready{}; +}; + +struct SavedThread { + DWORD thread_id{}; + DWORD64 dr0{}; + DWORD64 dr1{}; + DWORD64 dr2{}; + DWORD64 dr3{}; + DWORD64 dr6{}; + DWORD64 dr7{}; +}; + +std::array g_slots; +std::array g_hits; +std::atomic g_hit_count{}; +std::atomic g_dropped{}; +std::mutex g_mutex; +std::vector g_saved_threads; +PVOID g_handler{}; + +bool IsExecutable(std::uintptr_t address) { + MEMORY_BASIC_INFORMATION info{}; + if (!address || VirtualQuery(reinterpret_cast(address), &info, + sizeof(info)) != sizeof(info) || + info.State != MEM_COMMIT || (info.Protect & (PAGE_GUARD | PAGE_NOACCESS))) { + return false; + } + constexpr DWORD executable = PAGE_EXECUTE | PAGE_EXECUTE_READ | + PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + return (info.Protect & executable) != 0; +} + +bool IsWritable(std::uintptr_t address, std::size_t length) { + if (!address || !length || address > UINTPTR_MAX - length) return false; + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(address), &info, sizeof(info)) != + sizeof(info) || + info.State != MEM_COMMIT || (info.Protect & (PAGE_GUARD | PAGE_NOACCESS))) { + return false; + } + constexpr DWORD writable = PAGE_READWRITE | PAGE_WRITECOPY | + PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + const auto region_end = reinterpret_cast(info.BaseAddress) + + static_cast(info.RegionSize); + return address + length <= region_end && (info.Protect & writable) != 0; +} + +DWORD64 LengthEncoding(std::size_t length) { + switch (length) { + case 1: return 0; + case 2: return 1; + case 4: return 3; + case 8: return 2; + default: return 0; + } +} + +void SetDebugAddress(CONTEXT& context, std::size_t slot, DWORD64 address) { + switch (slot) { + case 0: context.Dr0 = address; break; + case 1: context.Dr1 = address; break; + case 2: context.Dr2 = address; break; + case 3: context.Dr3 = address; break; + default: break; + } +} + +void ApplySlots(CONTEXT& context, const SavedThread& original) { + context.Dr0 = original.dr0; + context.Dr1 = original.dr1; + context.Dr2 = original.dr2; + context.Dr3 = original.dr3; + context.Dr6 = 0; + context.Dr7 = original.dr7; + for (std::size_t slot = 0; slot < kMaxSlots; ++slot) { + const auto enable_shift = slot * 2; + const auto control_shift = 16 + slot * 4; + context.Dr7 &= ~(static_cast(3) << enable_shift); + context.Dr7 &= ~(static_cast(0xF) << control_shift); + const auto address = g_slots[slot].address.load(std::memory_order_acquire); + if (!address) continue; + SetDebugAddress(context, slot, static_cast(address)); + context.Dr7 |= static_cast(1) << enable_shift; + if (g_slots[slot].kind.load(std::memory_order_relaxed) == Kind::kWrite) { + const auto control = static_cast(1) | + (LengthEncoding(g_slots[slot].length.load(std::memory_order_relaxed)) << 2); + context.Dr7 |= control << control_shift; + } + } +} + +SavedThread* FindSavedThread(DWORD thread_id) { + const auto found = std::find_if( + g_saved_threads.begin(), g_saved_threads.end(), + [thread_id](const SavedThread& saved) { return saved.thread_id == thread_id; }); + return found == g_saved_threads.end() ? nullptr : &*found; +} + +bool ConfigureThread(DWORD thread_id) { + if (thread_id == GetCurrentThreadId()) return false; + HANDLE thread = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | + THREAD_SET_CONTEXT | THREAD_QUERY_INFORMATION, + FALSE, thread_id); + if (!thread) return false; + if (SuspendThread(thread) == static_cast(-1)) { + CloseHandle(thread); + return false; + } + + CONTEXT context{}; + context.ContextFlags = CONTEXT_DEBUG_REGISTERS; + bool configured = false; + if (GetThreadContext(thread, &context)) { + auto* saved = FindSavedThread(thread_id); + if (!saved && (context.Dr7 & 0xFF) == 0) { + g_saved_threads.push_back({thread_id, context.Dr0, context.Dr1, context.Dr2, + context.Dr3, context.Dr6, context.Dr7}); + saved = &g_saved_threads.back(); + } + if (saved) { + ApplySlots(context, *saved); + configured = SetThreadContext(thread, &context) != FALSE; + } + } + ResumeThread(thread); + CloseHandle(thread); + return configured; +} + +std::size_t ConfigureAllThreads() { + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if (snapshot == INVALID_HANDLE_VALUE) return 0; + THREADENTRY32 entry{sizeof(entry)}; + std::size_t configured = 0; + if (Thread32First(snapshot, &entry)) { + do { + if (entry.th32OwnerProcessID == GetCurrentProcessId() && + ConfigureThread(entry.th32ThreadID)) { + ++configured; + } + } while (Thread32Next(snapshot, &entry)); + } + CloseHandle(snapshot); + return configured; +} + +bool RestoreThread(const SavedThread& saved) { + if (saved.thread_id == GetCurrentThreadId()) return false; + HANDLE thread = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | + THREAD_SET_CONTEXT | THREAD_QUERY_INFORMATION, + FALSE, saved.thread_id); + if (!thread) return false; + if (SuspendThread(thread) == static_cast(-1)) { + CloseHandle(thread); + return false; + } + CONTEXT context{}; + context.ContextFlags = CONTEXT_DEBUG_REGISTERS; + bool restored = false; + if (GetThreadContext(thread, &context)) { + context.Dr0 = saved.dr0; + context.Dr1 = saved.dr1; + context.Dr2 = saved.dr2; + context.Dr3 = saved.dr3; + context.Dr6 = saved.dr6; + context.Dr7 = saved.dr7; + restored = SetThreadContext(thread, &context) != FALSE; + } + ResumeThread(thread); + CloseHandle(thread); + return restored; +} + +void ClearHits() { + g_hit_count.store(0, std::memory_order_release); + g_dropped.store(0, std::memory_order_release); + for (auto& buffered : g_hits) buffered.ready.store(false, std::memory_order_release); +} + +LONG CALLBACK HandleException(EXCEPTION_POINTERS* pointers) { + if (!pointers || !pointers->ExceptionRecord || !pointers->ContextRecord || + pointers->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) { + return EXCEPTION_CONTINUE_SEARCH; + } + auto& context = *pointers->ContextRecord; + const DWORD64 fired = context.Dr6 & 0xF; + bool ours = false; + for (std::size_t slot = 0; slot < kMaxSlots; ++slot) { + if ((fired & (static_cast(1) << slot)) == 0 || + g_slots[slot].address.load(std::memory_order_acquire) == 0) { + continue; + } + ours = true; + const auto ordinal = g_hit_count.fetch_add(1, std::memory_order_acq_rel); + if (ordinal >= kMaxHits) { + g_dropped.fetch_add(1, std::memory_order_relaxed); + continue; + } + auto& buffered = g_hits[static_cast(ordinal)]; + auto& hit = buffered.hit; + hit = {}; + hit.slot = slot; + hit.thread_id = GetCurrentThreadId(); + hit.rip = static_cast(context.Rip); + hit.rsp = static_cast(context.Rsp); + hit.rax = context.Rax; + hit.rbx = context.Rbx; + hit.rbp = context.Rbp; + hit.rsi = context.Rsi; + hit.rdi = context.Rdi; + hit.rcx = context.Rcx; + hit.rdx = context.Rdx; + hit.r8 = context.R8; + hit.r9 = context.R9; + hit.r10 = context.R10; + hit.r11 = context.R11; + for (std::size_t index = 0; index < kStackWords; ++index) { +#if defined(_MSC_VER) + __try { + hit.stack[index] = *(reinterpret_cast(context.Rsp) + index); + hit.stack_count = index + 1; + } __except (EXCEPTION_EXECUTE_HANDLER) { + break; + } +#else + hit.stack[index] = *(reinterpret_cast(context.Rsp) + index); + hit.stack_count = index + 1; +#endif + } + buffered.ready.store(true, std::memory_order_release); + } + if (!ours) return EXCEPTION_CONTINUE_SEARCH; + context.Dr6 = 0; + context.EFlags |= 1u << 16; + return EXCEPTION_CONTINUE_EXECUTION; +} + +bool AnySlotActive() { + return std::any_of(g_slots.begin(), g_slots.end(), [](const SlotState& slot) { + return slot.address.load(std::memory_order_acquire) != 0; + }); +} + +} // namespace + +ArmResult Arm(Kind kind, std::uintptr_t address, std::size_t length) { + if ((kind == Kind::kExecute && !IsExecutable(address)) || + (kind == Kind::kWrite && + ((length != 1 && length != 2 && length != 4 && length != 8) || + address % length != 0 || !IsWritable(address, length)))) { + return {.status = ArmStatus::kInvalidAddress}; + } + + std::lock_guard lock(g_mutex); + std::size_t slot = kMaxSlots; + for (std::size_t index = 0; index < kMaxSlots; ++index) { + if (g_slots[index].address.load(std::memory_order_acquire) == 0) { + slot = index; + break; + } + } + if (slot == kMaxSlots) return {.status = ArmStatus::kNoSlot}; + + if (!g_handler) { + g_handler = AddVectoredExceptionHandler(1, HandleException); + if (!g_handler) return {.status = ArmStatus::kHandlerFailed}; + ClearHits(); + } + g_slots[slot].kind.store(kind, std::memory_order_relaxed); + g_slots[slot].length.store(kind == Kind::kExecute ? 1 : length, + std::memory_order_relaxed); + g_slots[slot].address.store(address, std::memory_order_release); + + const auto thread_count = ConfigureAllThreads(); + if (!thread_count) { + g_slots[slot].address.store(0, std::memory_order_release); + if (!AnySlotActive()) { + RemoveVectoredExceptionHandler(g_handler); + g_handler = nullptr; + g_saved_threads.clear(); + } + return {.status = ArmStatus::kNoThreads}; + } + return {.status = ArmStatus::kOk, .slot = slot, .thread_count = thread_count}; +} + +Snapshot Collect(bool clear) { + Snapshot snapshot; + const auto count = (std::min)(g_hit_count.load(std::memory_order_acquire), + static_cast(kMaxHits)); + snapshot.hits.reserve(static_cast(count)); + for (std::size_t index = 0; index < static_cast(count); ++index) { + if (g_hits[index].ready.load(std::memory_order_acquire)) { + snapshot.hits.push_back(g_hits[index].hit); + } + } + snapshot.dropped = g_dropped.load(std::memory_order_acquire); + if (clear) ClearHits(); + return snapshot; +} + +std::size_t Disarm() { + std::lock_guard lock(g_mutex); + if (!g_handler && !AnySlotActive()) return 0; + std::size_t restored = 0; + for (const auto& saved : g_saved_threads) { + if (RestoreThread(saved)) ++restored; + } + for (auto& slot : g_slots) slot.address.store(0, std::memory_order_release); + if (g_handler) { + RemoveVectoredExceptionHandler(g_handler); + g_handler = nullptr; + } + g_saved_threads.clear(); + return restored; +} + +const char* ArmStatusCode(ArmStatus status) { + switch (status) { + case ArmStatus::kOk: return "ok"; + case ArmStatus::kInvalidAddress: return "invalid_address"; + case ArmStatus::kNoSlot: return "no_slot"; + case ArmStatus::kNoThreads: return "no_threads"; + case ArmStatus::kHandlerFailed: return "handler_failed"; + } + return "unknown"; +} + +} // namespace cfb27::research_watch diff --git a/native/host/research_watch.h b/native/host/research_watch.h new file mode 100644 index 0000000..2c3aa8a --- /dev/null +++ b/native/host/research_watch.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include + +namespace cfb27::research_watch { + +constexpr std::size_t kMaxSlots = 4; +constexpr std::size_t kMaxHits = 128; +constexpr std::size_t kStackWords = 32; + +enum class Kind : std::uint8_t { + kWrite, + kExecute, +}; + +enum class ArmStatus { + kOk, + kInvalidAddress, + kNoSlot, + kNoThreads, + kHandlerFailed, +}; + +struct ArmResult { + ArmStatus status{ArmStatus::kInvalidAddress}; + std::size_t slot{}; + std::size_t thread_count{}; +}; + +struct Hit { + std::size_t slot{}; + std::uint32_t thread_id{}; + std::uintptr_t rip{}; + std::uintptr_t rsp{}; + std::uint64_t rax{}; + std::uint64_t rbx{}; + std::uint64_t rbp{}; + std::uint64_t rsi{}; + std::uint64_t rdi{}; + std::uint64_t rcx{}; + std::uint64_t rdx{}; + std::uint64_t r8{}; + std::uint64_t r9{}; + std::uint64_t r10{}; + std::uint64_t r11{}; + std::array stack{}; + std::size_t stack_count{}; +}; + +struct Snapshot { + std::vector hits; + std::uint64_t dropped{}; +}; + +ArmResult Arm(Kind kind, std::uintptr_t address, std::size_t length = 4); +Snapshot Collect(bool clear); +std::size_t Disarm(); +const char* ArmStatusCode(ArmStatus status); + +} // namespace cfb27::research_watch diff --git a/native/smoke/protocol_smoke.cpp b/native/smoke/protocol_smoke.cpp index 4cd358c..592b5b6 100644 --- a/native/smoke/protocol_smoke.cpp +++ b/native/smoke/protocol_smoke.cpp @@ -440,6 +440,8 @@ int wmain(int argc, wchar_t** argv) { "memoryScanAllocationMetadata") == capabilities.end()) return 106; if (std::find(capabilities.begin(), capabilities.end(), "nativeCall") == capabilities.end()) return 139; + if (std::find(capabilities.begin(), capabilities.end(), "researchWatch") == + capabilities.end()) return 143; Json native_arguments = Json::array(); for (std::uintptr_t value = 1; value <= 8; ++value) { native_arguments.push_back(FormatAddress(value)); @@ -461,6 +463,15 @@ int wmain(int argc, wchar_t** argv) { {"command", "evaluate"}, {"params", {{"source", native_lua}}}}, response, false) || !response.value("ok", false)) return 142; + const std::string watch_lua = + "assert(type(cfb.watch)=='function'); " + "assert(type(cfb.watch_exec)=='function'); " + "assert(type(cfb.watch_hits)=='function'); " + "assert(type(cfb.unwatch)=='function'); cfb.unwatch()"; + if (!Request(pipe, {{"protocol", 1}, {"id", "research-watch-lua"}, + {"command", "evaluate"}, + {"params", {{"source", watch_lua}}}}, + response, false) || !response.value("ok", false)) return 144; if (!Request(pipe, {{"protocol", 1}, {"id", "native-call-invalid"}, {"command", "nativeCall"}, {"params", {{"address", "0x1"}, diff --git a/native/smoke/research_watch_smoke.cpp b/native/smoke/research_watch_smoke.cpp new file mode 100644 index 0000000..faf0971 --- /dev/null +++ b/native/smoke/research_watch_smoke.cpp @@ -0,0 +1,94 @@ +#include "../host/research_watch.h" + +#include + +#include +#include +#include +#include + +namespace { + +alignas(8) volatile std::uint64_t g_watched{}; + +__declspec(noinline) std::uint64_t ExecTarget(std::uint64_t value) { + return value + 1; +} + +void Require(bool condition, const char* message) { + if (!condition) throw message; +} + +template +std::uint32_t RunWorkerAfterArm(cfb27::research_watch::Kind kind, + std::uintptr_t address, std::size_t length, + Action action) { + HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, nullptr); + HANDLE start = CreateEventW(nullptr, TRUE, FALSE, nullptr); + HANDLE done = CreateEventW(nullptr, TRUE, FALSE, nullptr); + Require(ready && start && done, "event allocation failed"); + std::atomic worker_id{}; + std::thread worker([&] { + worker_id.store(GetCurrentThreadId(), std::memory_order_release); + SetEvent(ready); + WaitForSingleObject(start, INFINITE); + action(); + SetEvent(done); + }); + Require(WaitForSingleObject(ready, 5000) == WAIT_OBJECT_0, "worker did not start"); + const auto armed = cfb27::research_watch::Arm(kind, address, length); + Require(armed.status == cfb27::research_watch::ArmStatus::kOk, + "watch did not arm"); + SetEvent(start); + Require(WaitForSingleObject(done, 5000) == WAIT_OBJECT_0, "worker did not finish"); + worker.join(); + CloseHandle(ready); + CloseHandle(start); + CloseHandle(done); + return worker_id.load(std::memory_order_acquire); +} + +} // namespace + +int main() { + try { + const auto write_thread = RunWorkerAfterArm( + cfb27::research_watch::Kind::kWrite, + reinterpret_cast(&g_watched), sizeof(g_watched), + [] { g_watched = 0xCFB27; }); + const auto write_hits = cfb27::research_watch::Collect(false); + Require(!write_hits.hits.empty(), "write watch captured no hits"); + Require(write_hits.hits.front().thread_id == write_thread, + "write watch captured the wrong thread"); + Require(write_hits.hits.front().rip != 0 && + write_hits.hits.front().stack_count != 0, + "write watch omitted instruction or stack state"); + cfb27::research_watch::Disarm(); + cfb27::research_watch::Collect(true); + + volatile std::uint64_t result{}; + const auto exec_thread = RunWorkerAfterArm( + cfb27::research_watch::Kind::kExecute, + reinterpret_cast(&ExecTarget), 1, + [&] { + auto* volatile target = &ExecTarget; + result = target(41); + }); + const auto exec_hits = cfb27::research_watch::Collect(false); + Require(result == 42, "execute target did not complete"); + Require(!exec_hits.hits.empty(), "execute watch captured no hits"); + Require(exec_hits.hits.front().thread_id == exec_thread, + "execute watch captured the wrong thread"); + Require(exec_hits.hits.front().rip == + reinterpret_cast(&ExecTarget), + "execute watch captured the wrong instruction"); + cfb27::research_watch::Disarm(); + + std::cout << "research watch smoke passed\n"; + return 0; + } catch (const char* error) { + cfb27::research_watch::Disarm(); + std::cerr << "research watch smoke failed: " << error << '\n'; + return 1; + } +} diff --git a/tests/closed-gate-policy.test.cjs b/tests/closed-gate-policy.test.cjs index 8b75a8a..ee7388a 100644 --- a/tests/closed-gate-policy.test.cjs +++ b/tests/closed-gate-policy.test.cjs @@ -20,6 +20,7 @@ const standaloneSmokes = [ 'cfb27_telemetry_smoke.exe', 'cfb27_memory_transaction_smoke.exe', 'cfb27_native_call_smoke.exe', + 'cfb27_research_watch_smoke.exe', 'cfb27_frtk_profile_smoke.exe', 'cfb27_frtk_field_schema_smoke.exe', 'cfb27_frtk_discovery_smoke.exe', From 9cf87968f4334b22eb32b13ef9958dfe80357e40 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 15 Jul 2026 19:36:03 -0500 Subject: [PATCH 4/8] feat: deepen live board capture --- docs/lua-api.md | 11 +- native/host/lua_host.cpp | 20 +- native/host/research_watch.cpp | 26 +++ native/host/research_watch.h | 15 +- native/smoke/research_watch_smoke.cpp | 13 +- scripts/board-verification/live-anchor.cjs | 215 ++++++++++++++++++ .../live-table-snapshot.cjs | 55 +++++ 7 files changed, 346 insertions(+), 9 deletions(-) create mode 100644 scripts/board-verification/live-anchor.cjs create mode 100644 scripts/board-verification/live-table-snapshot.cjs diff --git a/docs/lua-api.md b/docs/lua-api.md index 01d4414..3c6cbda 100644 --- a/docs/lua-api.md +++ b/docs/lua-api.md @@ -103,10 +103,13 @@ the address must be naturally aligned. `cfb.watch_exec(address)` arms an execute breakpoint. At most four total slots may be active. Existing process threads that are accessible and do not already own hardware breakpoints are armed; the returned values are the zero-based slot and armed-thread count. -`cfb.watch_hits(clear)` returns at most 128 fixed register/stack snapshots plus -a `dropped` count. `cfb.unwatch()` restores saved debug-register state. These -functions are current-process research tools; always collect and disarm before -continuing normal play. +`cfb.watch_hits(clear)` returns at most 128 fixed snapshots plus a `dropped` +count. Each hit includes the integer registers, up to 256 stack qwords, and up +to eight safely readable qwords at each of `rbx`, `rsi`, `rdi`, `rcx`, `rdx`, +`r8`, and `r9` in fields such as `rcx_memory`. An unreadable pointer produces +an empty or partial array. `cfb.unwatch()` restores saved debug-register state. +These functions are current-process research tools; always collect and disarm +before continuing normal play. Supported callback names are `game_ready` and `tick`. The host runs `tick` callbacks approximately every 100 ms. The event protocol coalesces observable diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index 408faad..abf1117 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -613,13 +613,24 @@ void PushHitInteger(lua_State* state, const char* name, std::uint64_t value) { lua_setfield(state, -2, name); } +void PushPointerSnapshot( + lua_State* state, const char* name, + const cfb27::research_watch::PointerSnapshot& snapshot) { + lua_createtable(state, static_cast(snapshot.count), 0); + for (std::size_t index = 0; index < snapshot.count; ++index) { + lua_pushinteger(state, static_cast(snapshot.words[index])); + lua_rawseti(state, -2, static_cast(index + 1)); + } + lua_setfield(state, -2, name); +} + int LuaWatchHits(lua_State* state) { const bool clear = lua_toboolean(state, 1) != 0; const auto snapshot = cfb27::research_watch::Collect(clear); lua_createtable(state, static_cast(snapshot.hits.size()), 1); for (std::size_t index = 0; index < snapshot.hits.size(); ++index) { const auto& hit = snapshot.hits[index]; - lua_createtable(state, 0, 16); + lua_createtable(state, 0, 23); PushHitInteger(state, "slot", hit.slot); PushHitInteger(state, "thread_id", hit.thread_id); PushHitInteger(state, "rip", hit.rip); @@ -635,6 +646,13 @@ int LuaWatchHits(lua_State* state) { PushHitInteger(state, "r9", hit.r9); PushHitInteger(state, "r10", hit.r10); PushHitInteger(state, "r11", hit.r11); + PushPointerSnapshot(state, "rbx_memory", hit.rbx_memory); + PushPointerSnapshot(state, "rsi_memory", hit.rsi_memory); + PushPointerSnapshot(state, "rdi_memory", hit.rdi_memory); + PushPointerSnapshot(state, "rcx_memory", hit.rcx_memory); + PushPointerSnapshot(state, "rdx_memory", hit.rdx_memory); + PushPointerSnapshot(state, "r8_memory", hit.r8_memory); + PushPointerSnapshot(state, "r9_memory", hit.r9_memory); lua_createtable(state, static_cast(hit.stack_count), 0); for (std::size_t stack_index = 0; stack_index < hit.stack_count; ++stack_index) { lua_pushinteger(state, static_cast(hit.stack[stack_index])); diff --git a/native/host/research_watch.cpp b/native/host/research_watch.cpp index 511c49e..4b0d00d 100644 --- a/native/host/research_watch.cpp +++ b/native/host/research_watch.cpp @@ -198,6 +198,25 @@ void ClearHits() { for (auto& buffered : g_hits) buffered.ready.store(false, std::memory_order_release); } +void CapturePointer(std::uint64_t address, PointerSnapshot& snapshot) { + if (!address) return; + for (std::size_t index = 0; index < kPointerWords; ++index) { +#if defined(_MSC_VER) + __try { + snapshot.words[index] = + *(reinterpret_cast(address) + index); + snapshot.count = index + 1; + } __except (EXCEPTION_EXECUTE_HANDLER) { + break; + } +#else + snapshot.words[index] = + *(reinterpret_cast(address) + index); + snapshot.count = index + 1; +#endif + } +} + LONG CALLBACK HandleException(EXCEPTION_POINTERS* pointers) { if (!pointers || !pointers->ExceptionRecord || !pointers->ContextRecord || pointers->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP) { @@ -235,6 +254,13 @@ LONG CALLBACK HandleException(EXCEPTION_POINTERS* pointers) { hit.r9 = context.R9; hit.r10 = context.R10; hit.r11 = context.R11; + CapturePointer(hit.rbx, hit.rbx_memory); + CapturePointer(hit.rsi, hit.rsi_memory); + CapturePointer(hit.rdi, hit.rdi_memory); + CapturePointer(hit.rcx, hit.rcx_memory); + CapturePointer(hit.rdx, hit.rdx_memory); + CapturePointer(hit.r8, hit.r8_memory); + CapturePointer(hit.r9, hit.r9_memory); for (std::size_t index = 0; index < kStackWords; ++index) { #if defined(_MSC_VER) __try { diff --git a/native/host/research_watch.h b/native/host/research_watch.h index 2c3aa8a..0c0e6ee 100644 --- a/native/host/research_watch.h +++ b/native/host/research_watch.h @@ -9,7 +9,8 @@ namespace cfb27::research_watch { constexpr std::size_t kMaxSlots = 4; constexpr std::size_t kMaxHits = 128; -constexpr std::size_t kStackWords = 32; +constexpr std::size_t kStackWords = 256; +constexpr std::size_t kPointerWords = 8; enum class Kind : std::uint8_t { kWrite, @@ -30,6 +31,11 @@ struct ArmResult { std::size_t thread_count{}; }; +struct PointerSnapshot { + std::array words{}; + std::size_t count{}; +}; + struct Hit { std::size_t slot{}; std::uint32_t thread_id{}; @@ -46,6 +52,13 @@ struct Hit { std::uint64_t r9{}; std::uint64_t r10{}; std::uint64_t r11{}; + PointerSnapshot rbx_memory{}; + PointerSnapshot rsi_memory{}; + PointerSnapshot rdi_memory{}; + PointerSnapshot rcx_memory{}; + PointerSnapshot rdx_memory{}; + PointerSnapshot r8_memory{}; + PointerSnapshot r9_memory{}; std::array stack{}; std::size_t stack_count{}; }; diff --git a/native/smoke/research_watch_smoke.cpp b/native/smoke/research_watch_smoke.cpp index faf0971..f168d10 100644 --- a/native/smoke/research_watch_smoke.cpp +++ b/native/smoke/research_watch_smoke.cpp @@ -11,8 +11,8 @@ namespace { alignas(8) volatile std::uint64_t g_watched{}; -__declspec(noinline) std::uint64_t ExecTarget(std::uint64_t value) { - return value + 1; +__declspec(noinline) std::uint64_t ExecTarget(const std::uint64_t* value) { + return *value + 1; } void Require(bool condition, const char* message) { @@ -67,12 +67,13 @@ int main() { cfb27::research_watch::Collect(true); volatile std::uint64_t result{}; + const std::uint64_t argument = 41; const auto exec_thread = RunWorkerAfterArm( cfb27::research_watch::Kind::kExecute, reinterpret_cast(&ExecTarget), 1, [&] { auto* volatile target = &ExecTarget; - result = target(41); + result = target(&argument); }); const auto exec_hits = cfb27::research_watch::Collect(false); Require(result == 42, "execute target did not complete"); @@ -82,6 +83,12 @@ int main() { Require(exec_hits.hits.front().rip == reinterpret_cast(&ExecTarget), "execute watch captured the wrong instruction"); + Require(exec_hits.hits.front().rcx == + reinterpret_cast(&argument), + "execute watch captured the wrong first argument"); + Require(exec_hits.hits.front().rcx_memory.count != 0 && + exec_hits.hits.front().rcx_memory.words[0] == argument, + "execute watch omitted first-argument memory"); cfb27::research_watch::Disarm(); std::cout << "research watch smoke passed\n"; diff --git a/scripts/board-verification/live-anchor.cjs b/scripts/board-verification/live-anchor.cjs new file mode 100644 index 0000000..b00a51d --- /dev/null +++ b/scripts/board-verification/live-anchor.cjs @@ -0,0 +1,215 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const sdk = require('../../packages/sdk'); + +const TABLES = [ + { id: 4168, table1Length: 40444, words: 9, capacity: 1120, stride: 36, headerSize: 324, offsetStart: 0 }, + { id: 4176, table1Length: 19364, words: 1, capacity: 4830, stride: 4, headerSize: 244, offsetStart: 0 }, + { id: 4190, table1Length: 37560, words: 1, capacity: 9380, stride: 4, headerSize: 240, offsetStart: 0 }, + { id: 4251, table1Length: 1704, words: 3, capacity: 138, stride: 12, headerSize: 248, offsetStart: 0 }, + { id: 5790, table1Length: 77312, words: 3, capacity: 4830, stride: 12, headerSize: 265, offsetStart: 33, isArray: true }, + { id: 5847, table1Length: 19904, words: 35, capacity: 138, stride: 140, headerSize: 263, offsetStart: 31, isArray: true }, +]; + +const OUTPUT = path.resolve(__dirname, '..', '..', '.frtk', 'board-verification', 'live-mirror-bases.json'); + +function canonical(value) { + return `0x${value.toString(16).toUpperCase()}`; +} + +function signature(table) { + const bytes = Buffer.alloc(16); + bytes.writeUInt32LE(table.table1Length, 0); + bytes.writeUInt32LE(table.table1Length, 4); + bytes.writeUInt32LE(table.words, 8); + bytes.writeUInt32LE(table.capacity, 12); + return bytes.toString('hex').toUpperCase(); +} + +function decodeRef(value) { + return { tableId: value >>> 17, row: value & 0x1FFFF }; +} + +function expectedRef(value, tableId, capacity = Number.MAX_SAFE_INTEGER) { + const ref = decodeRef(value); + return ref.tableId === tableId && ref.row < capacity; +} + +function scoreCandidate(table, data) { + let freeRows = 0; + let contentRows = 0; + const sampleRows = Math.min(table.capacity, 512); + for (let row = 0; row < sampleRows; row += 1) { + const offset = row * table.stride; + const first = data.readUInt32LE(offset); + let restZero = true; + for (let byte = offset + 4; byte < offset + table.stride; byte += 4) { + if (data.readUInt32LE(byte) !== 0) { + restZero = false; + break; + } + } + if (first === row + 1 && restZero) freeRows += 1; + + if (table.id === 4168 && expectedRef(data.readUInt32LE(offset + 12), 4269)) contentRows += 1; + if (table.id === 4251 && expectedRef(first, 5847, 138)) contentRows += 1; + if (table.id === 5790 && expectedRef(first, 4190, 9380)) contentRows += 1; + if (table.id === 5847) { + const ref = decodeRef(first); + if ((ref.tableId === 4168 || ref.tableId === 4288) && ref.row < 0x20000) contentRows += 1; + } + } + return { freeRows, contentRows, score: freeRows + (contentRows * 8) }; +} + +async function readRange(client, address, length) { + const result = await client.readMemory({ ranges: [{ address: canonical(address), length }] }); + return Buffer.from(result.ranges[0].bytesHex, 'hex'); +} + +async function locateTable(client, table) { + process.stderr.write(`Locating table ${table.id}...\n`); + const dataOffset = table.headerSize - 204 - table.offsetStart + + (table.isArray ? table.capacity * 4 : 0); + const candidates = []; + let cursor = process.env.CFB27_SCAN_START || '0x380000000'; + let signatureMatches = 0; + for (let pageNumber = 0; pageNumber < 512; pageNumber += 1) { + const page = await client.scanMemoryPage({ + patternHex: signature(table), + maskHex: 'FF'.repeat(16), + maxMatches: 4, + contextBefore: 0, + contextAfter: 0, + includeAllocationMetadata: true, + cursor, + }); + signatureMatches += page.matches.length; + for (const match of page.matches) { + const header = BigInt(match.address); + const base = header + BigInt(dataOffset); + try { + const data = await readRange(client, base, table.capacity * table.stride); + const score = scoreCandidate(table, data); + if (score.score > 0) { + candidates.push({ header, base, data, score, allocationBase: match.allocationBase, allocationSize: match.allocationSize }); + } + } catch { + // A signature at a page boundary can be valid while its derived region is not. + } + } + if (candidates.length > 0 || page.complete) break; + cursor = page.nextCursor; + if (pageNumber > 0 && pageNumber % 16 === 0) process.stderr.write(` scanned ${pageNumber + 1} pages...\n`); + } + candidates.sort((left, right) => right.score.score - left.score.score); + if (candidates.length === 0 || candidates[0].score.score === 0) { + throw new Error(`Table ${table.id} signatures failed structural validation`); + } + const selected = candidates[0]; + const head = (await readRange(client, selected.header + 24n, 4)).readUInt32LE(0); + process.stderr.write(` ${canonical(selected.base)} score=${selected.score.score} candidates=${candidates.length}\n`); + return { ...table, ...selected, freelistHead: head, signatureMatches }; +} + +function findUserBoard(tables) { + const boardIndex = tables.get(4251); + const membership = tables.get(5847); + const candidates = []; + for (let boardRow = 0; boardRow < boardIndex.capacity; boardRow += 1) { + const boardOffset = boardRow * boardIndex.stride; + const boardRefValue = boardIndex.data.readUInt32LE(boardOffset); + const boardRef = decodeRef(boardRefValue); + if (boardRef.tableId !== 5847 || boardRef.row >= membership.capacity) continue; + + const membershipOffset = boardRef.row * membership.stride; + let userRefs = 0; + let cpuRefs = 0; + let occupied = 0; + let firstFreeSlot = -1; + for (let slot = 0; slot < membership.words; slot += 1) { + const value = membership.data.readUInt32LE(membershipOffset + slot * 4); + if (value === 0) { + if (firstFreeSlot < 0) firstFreeSlot = slot; + continue; + } + occupied += 1; + const ref = decodeRef(value); + if (ref.tableId === 4168) userRefs += 1; + if (ref.tableId === 4288) cpuRefs += 1; + } + candidates.push({ + boardRow, + teamRow: boardRef.row, + boardRefValue, + occupied, + userRefs, + cpuRefs, + firstFreeSlot, + }); + } + + candidates.sort((left, right) => + (right.userRefs - left.userRefs) || + (left.cpuRefs - right.cpuRefs) || + (right.occupied - left.occupied)); + const selected = candidates.find((candidate) => candidate.userRefs > 0 && candidate.cpuRefs === 0); + if (!selected) { + throw new Error('Could not uniquely identify the user board from table 4168 membership references'); + } + if (selected.firstFreeSlot < 0) throw new Error('The active recruiting board has no free membership slot'); + return { selected, candidates: candidates.slice(0, 8) }; +} + +async function main() { + const game = await sdk.discoverGame(); + const client = sdk.createClient({ pid: game.pid, timeoutMs: 60_000 }); + const hello = await client.hello(); + if (!hello.capabilities.includes('researchWatch')) throw new Error('Loaded host does not support researchWatch'); + + const located = []; + for (const table of TABLES) located.push(await locateTable(client, table)); + const tables = new Map(located.map((table) => [table.id, table])); + const board = findUserBoard(tables); + const membership = tables.get(5847); + const boardIndex = tables.get(4251); + const freelist4168 = tables.get(4168).header + 24n; + const membershipSlot = membership.base + BigInt(board.selected.teamRow * membership.stride + board.selected.firstFreeSlot * 4); + + const output = { + capturedAt: new Date().toISOString(), + pid: game.pid, + supportedBuild: hello.supportedBuild, + tables: Object.fromEntries(located.map((table) => [String(table.id), { + headerSignature: canonical(table.header), + dataBase: canonical(table.base), + stride: table.stride, + capacity: table.capacity, + freelistHeadValue: table.freelistHead, + score: table.score, + signatureMatches: table.signatureMatches, + }])), + userBoard: { + ...board.selected, + boardIndexAddress: canonical(boardIndex.base + BigInt(board.selected.boardRow * boardIndex.stride)), + membershipRowAddress: canonical(membership.base + BigInt(board.selected.teamRow * membership.stride)), + firstFreeSlotAddress: canonical(membershipSlot), + }, + captureAddresses: { + table4168FreelistHead: canonical(freelist4168), + firstFreeMembershipSlot: canonical(membershipSlot), + }, + topBoardCandidates: board.candidates, + }; + + fs.mkdirSync(path.dirname(OUTPUT), { recursive: true }); + fs.writeFileSync(OUTPUT, `${JSON.stringify(output, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error.code || 'ERROR'}: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/board-verification/live-table-snapshot.cjs b/scripts/board-verification/live-table-snapshot.cjs new file mode 100644 index 0000000..e0c58b0 --- /dev/null +++ b/scripts/board-verification/live-table-snapshot.cjs @@ -0,0 +1,55 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const sdk = require('../../packages/sdk'); + +const anchorPath = path.resolve(__dirname, '..', '..', '.frtk', 'board-verification', 'live-mirror-bases.json'); +const requestedOutput = process.argv[2]; +if (!requestedOutput) { + process.stderr.write('Usage: node live-table-snapshot.cjs \n'); + process.exit(2); +} +const outputPath = path.resolve(requestedOutput); + +function canonical(value) { + return `0x${value.toString(16).toUpperCase()}`; +} + +async function main() { + const anchor = JSON.parse(fs.readFileSync(anchorPath, 'utf8')); + const game = await sdk.discoverGame(); + if (game.pid !== anchor.pid) throw new Error('Anchor belongs to a different game process; rerun live-anchor.cjs'); + const client = sdk.createClient({ pid: game.pid, timeoutMs: 30_000 }); + const tables = {}; + for (const [id, table] of Object.entries(anchor.tables)) { + const length = table.stride * table.capacity; + const result = await client.readMemory({ ranges: [{ address: table.dataBase, length }] }); + tables[id] = { + dataBase: table.dataBase, + stride: table.stride, + capacity: table.capacity, + freelistHeadValue: table.freelistHeadValue, + bytesHex: result.ranges[0].bytesHex, + }; + } + const capture = { + capturedAt: new Date().toISOString(), + pid: game.pid, + userBoard: anchor.userBoard, + tables, + }; + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(capture, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ + outputPath, + pid: game.pid, + occupied: anchor.userBoard.occupied, + bytes: Object.values(tables).reduce((sum, table) => sum + table.bytesHex.length / 2, 0), + })}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error.code || 'ERROR'}: ${error.message}\n`); + process.exitCode = 1; +}); From fd0f17118f6227714b3b8fa8b3af1c2c7180fecd Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 15 Jul 2026 20:21:43 -0500 Subject: [PATCH 5/8] docs: design screen-agnostic board mutations --- .../2026-07-15-board-mutation-api-design.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-board-mutation-api-design.md diff --git a/docs/superpowers/specs/2026-07-15-board-mutation-api-design.md b/docs/superpowers/specs/2026-07-15-board-mutation-api-design.md new file mode 100644 index 0000000..0053337 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-board-mutation-api-design.md @@ -0,0 +1,78 @@ +# Screen-Agnostic Board Mutation API Design + +**Date:** 2026-07-15 + +## Goal + +Expose guarded `addBoard` and `removeBoard` operations that use the current +game build's own recruiting runtime pathways. The caller identifies a recruit; +the operation must not depend on that recruit being selected or on the Prospect +List being the active screen. Keeping the broader recruiting UI loaded is an +acceptable first-release requirement. + +## Evidence Boundary + +The full UI add routine at module RVA `0x8109060` is independently verified. +It produced the same table allocations and compact membership append as a +vanilla UI add, rendered in the UI, and survived a dynasty reload. + +The low-level remove routine at RVA `0x80116B0` is explicitly rejected. Calling +it alone left stale runtime state and caused a membership hole on a later UI +add. `removeBoard` will not call or wrap that routine. Its full UI entry point +and postconditions must be captured from another real removal before exposure. + +## Architecture + +Add a build-locked native board-mutation service in the injected host. Each +request performs fresh runtime discovery rather than caching session pointers: + +1. Resolve the supported module and current recruiting runtime objects. +2. Resolve the requested recruit by stable recruit row/reference, independent + of UI selection. +3. Require one unambiguous controller, recruit wrapper, and supporting runtime + object set. Fail closed when recruiting is not loaded or discovery is + ambiguous. +4. Invoke the verified full game routine using host-owned pointer cells. +5. Re-read the relevant board membership, allocation rows, references, and + freelists and accept the operation only when its complete postcondition is + present. + +The SDK exposes recruit-row-based methods and translates host failures into +clear errors such as recruiting-not-loaded, already-on-board, not-on-board, +board-full, runtime-discovery-ambiguous, and postcondition-failed. + +## Operations + +### `addBoard({ recruitRow })` + +Use the verified full add routine. Require an off-board recruit, a compact free +membership slot, and available allocation rows before invocation. Success must +show exactly one membership append and the expected linked rows/references. +An already-boarded recruit returns an unchanged result. + +### `removeBoard({ recruitRow })` + +First capture the full remove entry point from a real UI removal and validate it +with a no-op plus one backed-up synthetic invocation. Success must show compact +membership removal, correct freelist returns, cleared recruit/active-pitch +references, consistent runtime state, immediate rendering, and reload +durability. Only then expose it through the same host and SDK surface. + +## Safety and Scope + +- Supported executable hash only. +- Recruiting UI may be required to be loaded, but no specific recruiting + screen or selected row is required. +- No table-only board mutation fallback. +- No cached runtime pointer reuse across requests or dynasty loads. +- Serialize board mutations and reject concurrent requests. +- A postcondition mismatch disables further board mutations for the session and + instructs the caller to reload the dynasty. + +## Verification + +Native smoke tests cover locator uniqueness, argument validation, state guards, +postcondition validation, and failure paths using synthetic memory fixtures. +SDK tests cover request validation and error mapping. Live acceptance covers +add and remove from more than one recruiting screen, immediate UI state, board +compaction, and reload durability using a verified backup. From 440d2bb343164cabe13f35221c96493b150d72de Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 15 Jul 2026 20:22:41 -0500 Subject: [PATCH 6/8] docs: plan board mutation API --- .../plans/2026-07-15-board-mutation-api.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-board-mutation-api.md diff --git a/docs/superpowers/plans/2026-07-15-board-mutation-api.md b/docs/superpowers/plans/2026-07-15-board-mutation-api.md new file mode 100644 index 0000000..61e6186 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-board-mutation-api.md @@ -0,0 +1,127 @@ +# Board Mutation API Implementation Plan + +> Execute in this workspace with the disposable dynasty backup retained until +> both live operations pass reload verification. + +**Goal:** Implement screen-agnostic `addBoard` and `removeBoard` operations that +invoke verified full game routines while any supported recruiting screen is +loaded. + +**Architecture:** A build-locked native service performs fresh runtime object +discovery, invokes the full game routines with host-owned pointer cells, and +validates table/runtime postconditions. The pipe protocol and CommonJS SDK add +typed board commands. No table-only fallback and no use of the unsafe inner +remove routine. + +--- + +## 1. Capture and verify the full remove pathway + +**Use:** +- `scripts/board-verification/live-anchor.cjs` +- `scripts/board-verification/live-table-snapshot.cjs` +- existing bounded research-watch protocol +- private output under `.frtk/board-verification/` + +1. Re-anchor after the current reload and snapshot all six relevant tables. +2. Arm bounded execute watches on the caller region above the rejected inner + remove call. +3. Ask the user to remove one known recruit through the vanilla UI. +4. Snapshot again and retain only hits whose timing and table diff match that + UI event. +5. Disassemble the clean chain upward until finding the full routine that owns + runtime/controller state and accepts a recruit identity or wrapper. +6. Reject generic containers, hot unrelated functions, and any entry observed + only during synthetic activity. +7. Call the candidate with an already-absent recruit and require a no-op with + byte-identical tables. +8. With the verified backup available, invoke it once for an on-board recruit, + then verify compaction, freelists, references, runtime UI state, and reload + durability. + +## 2. Add native board-mutation fixtures and tests + +**Create:** +- `native/host/board_mutation.h` +- `native/host/board_mutation.cpp` +- `native/smoke/board_mutation_smoke.cpp` + +**Modify:** +- `native/CMakeLists.txt` + +Write failing fixture tests for supported-build gating, unique runtime-object +resolution, recruit-row lookup, board-full/already-present/not-present guards, +compact add/remove validation, and postcondition mismatch lockout. Implement +bounded readable-memory scans and pure validation helpers until the smoke test +passes. + +Run: + +```powershell +cmake --build native/build --config Release --target cfb27_board_mutation_smoke +native/build/Release/cfb27_board_mutation_smoke.exe +``` + +## 3. Add host protocol commands + +**Modify:** +- `native/host/lua_host.cpp` +- `native/host/protocol.*` if shared response helpers are required +- `native/smoke/protocol_smoke.cpp` +- `docs/protocol.md` + +Add `boardMutationV1` capability plus `addBoard` and `removeBoard` commands. +Both accept exactly `{ recruitRow }`, serialize on the host write mutex, require +the supported build, and return a normalized result containing operation, +status, recruit row, board slot, and affected rows. Map discovery, state, and +postcondition failures to stable protocol error codes. Test malformed requests, +unsupported builds, capability advertisement, success, unchanged, and lockout. + +## 4. Invoke verified full routines + +**Modify:** +- `native/host/board_mutation.cpp` +- `native/smoke/board_mutation_smoke.cpp` + +Resolve function addresses as supported-module RVAs. Discover the active +recruiting controller and record wrappers fresh per call. Place supporting and +recruit wrapper pointers in local host-owned cells matching the captured ABI. +Invoke add/remove through the existing guarded native-call machinery, then +validate the exact postcondition. Never cache session addresses. Disable board +mutations for the session after an ambiguous partial result. + +## 5. Add the SDK surface + +**Modify:** +- `packages/sdk/src/client.cjs` +- `packages/sdk/src/errors.cjs` +- `packages/sdk/index.cjs` + +**Create:** +- `packages/sdk/test/board-mutation.test.cjs` + +Add strict `client.addBoard({ recruitRow })` and +`client.removeBoard({ recruitRow })` methods with capability negotiation, +integer/range validation, cloned input, response validation, and stable error +translation. Test exact wire requests, malformed input, missing capability, +unchanged results, host errors, and malformed responses. + +Run: + +```powershell +node --test packages/sdk/test/board-mutation.test.cjs +``` + +## 6. Regression and live acceptance + +1. Run the complete native smoke suite and `npm test`. +2. Build and reinstall the hook only while the game is closed. +3. From the Prospect List, add an off-board recruit without selecting them. +4. From the Recruiting Board, remove an on-board recruit by row without relying + on current selection. +5. Confirm both screens agree immediately, membership remains compact, and the + relevant allocation/reference state is correct. +6. Back out to materialize the save, reload the dynasty, and verify both changes + persist. +7. Record the verified RVAs, build hash, behavior, and remaining recruiting-UI + requirement in `docs/research/runtime-verification.md`. From d612e1da14b86a90b61b735aa160e8d8b5a96c60 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 15 Jul 2026 21:00:16 -0500 Subject: [PATCH 7/8] feat: add verified board mutation APIs --- .github/workflows/windows-ci.yml | 1 + docs/development/building.md | 1 + docs/protocol.md | 30 ++ docs/research/runtime-verification.md | 47 ++ native/CMakeLists.txt | 10 + native/host/board_mutation.cpp | 496 ++++++++++++++++++++++ native/host/board_mutation.h | 39 ++ native/host/lua_host.cpp | 70 +++ native/smoke/board_mutation_smoke.cpp | 21 + native/smoke/protocol_smoke.cpp | 6 + packages/sdk/src/client.cjs | 52 +++ packages/sdk/src/errors.cjs | 7 + packages/sdk/test/board-mutation.test.cjs | 148 +++++++ 13 files changed, 928 insertions(+) create mode 100644 native/host/board_mutation.cpp create mode 100644 native/host/board_mutation.h create mode 100644 native/smoke/board_mutation_smoke.cpp create mode 100644 packages/sdk/test/board-mutation.test.cjs diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index a6a669b..debeb36 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -23,6 +23,7 @@ jobs: - run: native/build-release/Release/cfb27_telemetry_smoke.exe - run: native/build-release/Release/cfb27_memory_transaction_smoke.exe - run: native/build-release/Release/cfb27_native_call_smoke.exe + - run: native/build-release/Release/cfb27_board_mutation_smoke.exe - run: native/build-release/Release/cfb27_research_watch_smoke.exe - run: native/build-release/Release/cfb27_frtk_profile_smoke.exe - run: native/build-release/Release/cfb27_frtk_field_schema_smoke.exe diff --git a/docs/development/building.md b/docs/development/building.md index ab77268..d635da9 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -20,6 +20,7 @@ native/build-release/Release/cfb27_memory_reader_smoke.exe native/build-release/Release/cfb27_telemetry_smoke.exe native/build-release/Release/cfb27_memory_transaction_smoke.exe native/build-release/Release/cfb27_native_call_smoke.exe +native/build-release/Release/cfb27_board_mutation_smoke.exe native/build-release/Release/cfb27_research_watch_smoke.exe native/build-release/Release/cfb27_frtk_profile_smoke.exe native/build-release/Release/cfb27_frtk_field_schema_smoke.exe diff --git a/docs/protocol.md b/docs/protocol.md index 489c2dc..1d01703 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -285,6 +285,36 @@ code, but cannot roll back native side effects that occurred before the fault. The SDK method is `client.nativeCall({ address, arguments })` and negotiates the `nativeCall` capability before sending the request. +## Recruiting board mutations + +`addBoard { recruitRow, teamRow }` and `removeBoard { recruitRow, teamRow }` +invoke the current supported build's verified full recruiting handlers. The +rows identify the requested Recruit record and the active Team record; no team +is hardcoded. Both commands require the `boardMutationV1` capability and must +be called while the recruiting runtime is loaded, but they do not depend on a +specific recruiting screen or selected UI row. + +The host freshly resolves the recruiting controller and both record wrappers, +validates compact membership and freelist state before the call, and verifies +the complete table postcondition afterward. A successful result uses status +`applied_verified`; an already-satisfied add or remove uses `unchanged`. + +```json +{"protocol":1,"id":"board-1","command":"addBoard","params":{"recruitRow":3182,"teamRow":92}} +``` + +An already-open Recruiting Board view refreshes on the next recruiting screen +change. The returned `uiRefresh` value is therefore +`next_recruiting_screen_change`. This is a rendering limitation, not delayed +table materialization; save durability follows the normal dynasty autosave +path. + +Typed board failures include `RECRUITING_NOT_LOADED`, +`RUNTIME_DISCOVERY_AMBIGUOUS`, `BOARD_TABLE_DISCOVERY_FAILED`, +`BOARD_STATE_INVALID`, `BOARD_FULL`, `BOARD_NATIVE_CALL_FAILED`, and +`BOARD_POSTCONDITION_FAILED`. A postcondition failure disables further writes +for that host session. + The host retains at most 512 log entries and 1,024 events. Event cursors are monotonic for one host session. Tick events are coalesced to at most one per second; Lua tick callbacks still run at their normal cadence. diff --git a/docs/research/runtime-verification.md b/docs/research/runtime-verification.md index fc08564..041bb09 100644 --- a/docs/research/runtime-verification.md +++ b/docs/research/runtime-verification.md @@ -263,3 +263,50 @@ scholarships, scouting, board membership, pitch-intensity changes, and pitch or visit creation/removal. Verification for this import is automated and offline; no additional installed-host, CFB27, MMC, weekly-advance, or autosave gate was performed. + +## Independent board mutation verification on July 15, 2026 + +Board membership was retested on the supported current build using a disposable +dynasty with a verified backup. Brooks's dossier and earlier synthetic results +were treated only as leads. + +Multiple vanilla UI adds and removes established the authoritative table +behavior. An add allocates one UserRecruitTarget row (4168), one +ActivePitchRecord row (5790), and appends one compact 4168 reference to the +user's 5847 membership row. A remove returns both rows to their freelists, +clears the Recruit and ActivePitches references, and compacts membership. +Tables 4176, 4190, and 4251 did not change in the no-pitch/no-visit cases. + +The verified full current-build routines are: + +- add: module RVA `0x8109060`; +- remove: module RVA `0x8166090`. + +Both receive the active recruiting controller plus pointer cells containing an +active Team record wrapper and the requested Recruit record wrapper. The Team +row is dynamic; no school is hardcoded. The low-level remove routine at RVA +`0x80116B0` is prohibited because calling it alone left stale runtime state and +later produced a membership hole. + +The full add routine first passed an already-targeted no-op with byte-identical +tables. It then added Storm Thompson Jr. (Recruit row 3182), produced the exact +vanilla allocation/membership diff, rendered after normal recruiting +navigation, and survived a dynasty reload. + +The full remove chain was captured twice from real UI removals, including a +function-entry capture with zero dropped hits. An already-absent Julian Holmes +no-op left all six observed tables byte-identical. A synthetic removal of +Derrick Wilder from slot 0 compacted Keith Pearson from slot 1 into slot 0, +returned both allocated rows to their freelists, rendered after leaving and +re-entering the Recruiting Board, and survived a dynasty reload. + +An already-open Recruiting Board does not redraw immediately after a direct +worker-thread call because the surrounding UI caller's refresh code is not +executed. The mutation is visible on the next recruiting screen change; this is +screen-change rendering, not reload materialization. Save durability follows +the normal dynasty autosave path. + +The exact claim that board addition constructs six runtime objects was not +independently enumerated and remains unverified as a count. The public board API +does not synthesize those objects: it invokes the verified full game routines, +which own their required runtime construction and teardown. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 8f31dab..2d1c21e 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -44,6 +44,7 @@ target_include_directories(lua54 PUBLIC ${LUA_DIR}) set_target_properties(lua54 PROPERTIES POSITION_INDEPENDENT_CODE ON) add_library(cfb27_lua_host SHARED + host/board_mutation.cpp host/frtk_catalog.cpp host/frtk_discovery.cpp host/frtk_field_schema.cpp @@ -105,6 +106,15 @@ target_compile_features(cfb27_native_call_smoke PRIVATE cxx_std_20) target_compile_definitions(cfb27_native_call_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) target_link_options(cfb27_native_call_smoke PRIVATE /STACK:1048576) +add_executable(cfb27_board_mutation_smoke + smoke/board_mutation_smoke.cpp + host/board_mutation.cpp + host/native_call.cpp +) +target_compile_features(cfb27_board_mutation_smoke PRIVATE cxx_std_20) +target_compile_definitions(cfb27_board_mutation_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_options(cfb27_board_mutation_smoke PRIVATE /STACK:1048576) + add_executable(cfb27_research_watch_smoke smoke/research_watch_smoke.cpp host/research_watch.cpp diff --git a/native/host/board_mutation.cpp b/native/host/board_mutation.cpp new file mode 100644 index 0000000..df8334c --- /dev/null +++ b/native/host/board_mutation.cpp @@ -0,0 +1,496 @@ +#include "board_mutation.h" + +#include "native_call.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cfb27::board_mutation { +namespace { + +constexpr std::uintptr_t kGenericRecordWrapperVtableRva = 0xB093F68; +constexpr std::uintptr_t kRecruitingControllerVtableRva = 0xB0B5BA8; +constexpr std::uintptr_t kFullAddRva = 0x8109060; +constexpr std::uintptr_t kFullRemoveRva = 0x8166090; +constexpr std::uint32_t kRecruitTableId = 4269; +constexpr std::uint32_t kTeamTableId = 6334; +constexpr std::uint32_t kControllerDescriptorTableId = 5003; +constexpr std::uint32_t kUserTargetTableId = 4168; +constexpr std::uint32_t kActivePitchTableId = 5790; +constexpr std::uint32_t kMembershipTableId = 5847; +constexpr std::uint32_t kMembershipCapacity = 138; +constexpr std::uint32_t kBoardSlots = 35; +constexpr std::uint32_t kReferenceRowMask = 0x1FFFF; + +struct Region { + const std::uint8_t* begin{}; + std::size_t size{}; +}; + +struct TableSpec { + std::uint32_t id{}; + std::uint32_t table1_length{}; + std::uint32_t words{}; + std::uint32_t capacity{}; + std::uint32_t stride{}; + std::uint32_t data_offset{}; +}; + +struct TableView { + const TableSpec* spec{}; + std::uintptr_t header{}; + std::uintptr_t data{}; + std::uint32_t head{}; + std::uint32_t score{}; +}; + +struct BoardItem { + std::uint32_t slot{}; + std::uint32_t target_row{}; + std::uint32_t recruit_row{}; + std::uint32_t active_pitch_row{UINT32_MAX}; +}; + +struct BoardSnapshot { + bool valid{}; + bool compact{}; + std::uint32_t membership_row{}; + std::vector items; +}; + +constexpr TableSpec kUserTarget{ + 4168, 40444, 9, 1120, 36, 120}; +constexpr TableSpec kActivePitch{ + 5790, 77312, 3, 4830, 12, 19348}; +constexpr TableSpec kMembership{ + 5847, 19904, 35, 138, 140, 580}; + +bool ReadableProtection(DWORD protection) { + if (protection & (PAGE_GUARD | PAGE_NOACCESS)) return false; + const DWORD base = protection & 0xFF; + return base == PAGE_READONLY || base == PAGE_READWRITE || + base == PAGE_WRITECOPY || base == PAGE_EXECUTE_READ || + base == PAGE_EXECUTE_READWRITE || base == PAGE_EXECUTE_WRITECOPY; +} + +bool ReadableRange(std::uintptr_t address, std::size_t size) { + if (!address || !size || address > std::numeric_limits::max() - size) + return false; + auto cursor = address; + const auto end = address + size; + while (cursor < end) { + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(cursor), &info, sizeof(info)) != + sizeof(info) || + info.State != MEM_COMMIT || !ReadableProtection(info.Protect)) return false; + const auto region_end = reinterpret_cast(info.BaseAddress) + + info.RegionSize; + if (region_end <= cursor) return false; + cursor = std::min(end, region_end); + } + return true; +} + +template +bool ReadValue(std::uintptr_t address, T& value) { + if (!ReadableRange(address, sizeof(T))) return false; +#if defined(_MSC_VER) + __try { + std::memcpy(&value, reinterpret_cast(address), sizeof(T)); + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +#else + std::memcpy(&value, reinterpret_cast(address), sizeof(T)); +#endif + return true; +} + +std::vector PrivateReadableRegions() { + SYSTEM_INFO system{}; + GetSystemInfo(&system); + auto cursor = reinterpret_cast(system.lpMinimumApplicationAddress); + const auto maximum = reinterpret_cast(system.lpMaximumApplicationAddress); + std::vector regions; + while (cursor < maximum) { + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(cursor), &info, sizeof(info)) != + sizeof(info)) break; + const auto base = reinterpret_cast(info.BaseAddress); + if (info.State == MEM_COMMIT && info.Type == MEM_PRIVATE && + ReadableProtection(info.Protect) && info.RegionSize >= 32) { + regions.push_back({reinterpret_cast(base), info.RegionSize}); + } + const auto next = base + info.RegionSize; + if (next <= cursor) break; + cursor = next; + } + return regions; +} + +template +void FindBytes(const Region& region, std::span needle, + Callback callback) { + const auto* cursor = region.begin; + const auto* end = region.begin + region.size; + while (cursor + needle.size() <= end) { + const auto* found = std::search(cursor, end, needle.begin(), needle.end()); + if (found == end) break; + callback(reinterpret_cast(found)); + cursor = found + 1; + } +} + +std::array QwordBytes(std::uint64_t value) { + std::array bytes{}; + std::memcpy(bytes.data(), &value, sizeof(value)); + return bytes; +} + +std::array TableSignature(const TableSpec& spec) { + std::array words{ + spec.table1_length, spec.table1_length, spec.words, spec.capacity}; + std::array bytes{}; + std::memcpy(bytes.data(), words.data(), bytes.size()); + return bytes; +} + +std::uint32_t ReferenceTable(std::uint32_t value) { return value >> 17; } +std::uint32_t ReferenceRow(std::uint32_t value) { return value & kReferenceRowMask; } + +std::uint32_t ScoreTable(const TableSpec& spec, std::uintptr_t data) { + if (!ReadableRange(data, static_cast(spec.capacity) * spec.stride)) + return 0; + std::uint32_t free_rows = 0; + std::uint32_t content_rows = 0; + const auto sample = std::min(spec.capacity, 512); + for (std::uint32_t row = 0; row < sample; ++row) { + const auto record = data + static_cast(row) * spec.stride; + std::uint32_t first{}; + if (!ReadValue(record, first)) return 0; + if (spec.id == kMembershipTableId) { + bool structural = true; + bool saw_zero = false; + for (std::uint32_t slot = 0; slot < spec.words; ++slot) { + std::uint32_t reference{}; + if (!ReadValue(record + slot * 4, reference)) return 0; + if (!reference) { + saw_zero = true; + continue; + } + const auto table = ReferenceTable(reference); + if (saw_zero || (table != kUserTargetTableId && table != 4288)) { + structural = false; + break; + } + ++content_rows; + } + if (structural) ++free_rows; + continue; + } + bool rest_zero = true; + for (std::uint32_t offset = 4; offset < spec.stride; offset += 4) { + std::uint32_t word{}; + if (!ReadValue(record + offset, word)) return 0; + if (word != 0) rest_zero = false; + } + if (first == row + 1 && rest_zero) ++free_rows; + if (spec.id == kUserTargetTableId) { + std::uint32_t recruit{}; + if (!ReadValue(record + 12, recruit)) return 0; + if (ReferenceTable(recruit) == kRecruitTableId) ++content_rows; + } else if (spec.id == kActivePitchTableId) { + if (ReferenceTable(first) == 4190) ++content_rows; + } + } + return free_rows + content_rows * 8; +} + +bool LocateTable(const std::vector& regions, const TableSpec& spec, + TableView& selected) { + const auto signature = TableSignature(spec); + std::vector candidates; + for (const auto& region : regions) { + FindBytes(region, signature, [&](std::uintptr_t header) { + const auto data = header + spec.data_offset; + const auto score = ScoreTable(spec, data); + std::uint32_t head{}; + if (score && ReadValue(header + 24, head)) + candidates.push_back({&spec, header, data, head, score}); + }); + } + if (candidates.empty()) return false; + std::sort(candidates.begin(), candidates.end(), [](const auto& left, const auto& right) { + return left.score > right.score; + }); + if (candidates.size() > 1 && candidates[0].score == candidates[1].score) return false; + selected = candidates[0]; + return true; +} + +std::uint32_t DescriptorTableId(std::uintptr_t descriptor) { + std::uint64_t encoded{}; + if (!ReadValue(descriptor + 40, encoded)) return 0; + return static_cast(encoded >> 32); +} + +void FindRuntimeObjects(const std::vector& regions, std::uintptr_t module, + std::uint32_t recruit_row, std::uint32_t team_row, + std::vector& controllers, + std::vector& recruit_wrappers, + std::vector& team_wrappers) { + const auto wrapper_vtable = module + kGenericRecordWrapperVtableRva; + const auto controller_vtable = module + kRecruitingControllerVtableRva; + const auto wrapper_bytes = QwordBytes(wrapper_vtable); + const auto controller_bytes = QwordBytes(controller_vtable); + for (const auto& region : regions) { + FindBytes(region, controller_bytes, [&](std::uintptr_t address) { + if ((address & 7) != 0) return; + std::uint64_t membership_row{}; + std::uintptr_t descriptor{}; + std::uintptr_t board_store{}; + if (!ReadValue(address + 8, membership_row) || membership_row >= kMembershipCapacity || + !ReadValue(address + 16, descriptor) || + DescriptorTableId(descriptor) != kControllerDescriptorTableId || + !ReadValue(address + 0x138, board_store) || !ReadableRange(board_store, 8)) return; + controllers.push_back(address); + }); + FindBytes(region, wrapper_bytes, [&](std::uintptr_t address) { + if ((address & 7) != 0 || !ReadableRange(address, 32)) return; + std::uintptr_t descriptor{}; + std::uint64_t row{}; + if (!ReadValue(address + 16, descriptor) || !ReadValue(address + 24, row)) return; + const auto table_id = DescriptorTableId(descriptor); + if (row == recruit_row && table_id == kRecruitTableId) + recruit_wrappers.push_back(address); + if (row == team_row && table_id == kTeamTableId) + team_wrappers.push_back(address); + }); + } +} + +BoardSnapshot ReadBoard(const TableView& targets, const TableView& pitches, + const TableView& membership, std::uint32_t membership_row) { + BoardSnapshot result{.membership_row = membership_row}; + if (membership_row >= membership.spec->capacity) return result; + const auto row_address = membership.data + + static_cast(membership_row) * membership.spec->stride; + bool saw_zero = false; + result.compact = true; + for (std::uint32_t slot = 0; slot < kBoardSlots; ++slot) { + std::uint32_t reference{}; + if (!ReadValue(row_address + slot * 4, reference)) return result; + if (!reference) { + saw_zero = true; + continue; + } + if (saw_zero) result.compact = false; + if (ReferenceTable(reference) != kUserTargetTableId) return result; + const auto target_row = ReferenceRow(reference); + if (target_row >= targets.spec->capacity) return result; + const auto target_address = targets.data + + static_cast(target_row) * targets.spec->stride; + std::uint32_t recruit_reference{}; + std::uint32_t pitch_reference{}; + if (!ReadValue(target_address + 12, recruit_reference) || + !ReadValue(target_address + 16, pitch_reference) || + ReferenceTable(recruit_reference) != kRecruitTableId) return result; + std::uint32_t pitch_row = UINT32_MAX; + if (pitch_reference) { + if (ReferenceTable(pitch_reference) != kActivePitchTableId || + ReferenceRow(pitch_reference) >= pitches.spec->capacity) return result; + pitch_row = ReferenceRow(pitch_reference); + } + result.items.push_back({slot, target_row, ReferenceRow(recruit_reference), pitch_row}); + } + result.valid = result.compact; + return result; +} + +std::vector Matching(const BoardSnapshot& board, std::uint32_t recruit_row) { + std::vector matches; + for (const auto& item : board.items) + if (item.recruit_row == recruit_row) matches.push_back(item); + return matches; +} + +Result BaseResult(Operation operation, std::uint32_t recruit_row, + std::uint32_t team_row) { + return {.operation = operation, .recruit_row = recruit_row, .team_row = team_row}; +} + +} // namespace + +Result Invoke(Operation operation, std::uint32_t recruit_row, + std::uint32_t team_row) { + auto result = BaseResult(operation, recruit_row, team_row); + if (recruit_row > kReferenceRowMask || team_row > kReferenceRowMask) { + result.status = Status::kInvalidArgument; + return result; + } + const auto module = reinterpret_cast(GetModuleHandleW(nullptr)); + if (!module) { + result.status = Status::kRecruitingNotLoaded; + return result; + } + const auto regions = PrivateReadableRegions(); + std::vector controllers; + std::vector recruit_wrappers; + std::vector team_wrappers; + FindRuntimeObjects(regions, module, recruit_row, team_row, controllers, + recruit_wrappers, team_wrappers); + if (controllers.empty() || recruit_wrappers.empty() || team_wrappers.empty()) { + result.status = Status::kRecruitingNotLoaded; + return result; + } + if (controllers.size() != 1 || recruit_wrappers.size() != 1 || + team_wrappers.size() != 1) { + result.status = Status::kRuntimeAmbiguous; + return result; + } + + TableView targets; + TableView pitches; + TableView membership; + if (!LocateTable(regions, kUserTarget, targets) || + !LocateTable(regions, kActivePitch, pitches) || + !LocateTable(regions, kMembership, membership)) { + result.status = Status::kTableDiscoveryFailed; + return result; + } + std::uint64_t membership_row64{}; + if (!ReadValue(controllers[0] + 8, membership_row64) || + membership_row64 >= kMembershipCapacity) { + result.status = Status::kBoardStateInvalid; + return result; + } + result.membership_row = static_cast(membership_row64); + const auto before = ReadBoard(targets, pitches, membership, result.membership_row); + if (!before.valid) { + result.status = Status::kBoardStateInvalid; + return result; + } + const auto before_matches = Matching(before, recruit_row); + if (before_matches.size() > 1) { + result.status = Status::kBoardStateInvalid; + return result; + } + if (operation == Operation::kAdd && !before_matches.empty()) { + const auto item = before_matches[0]; + result.status = Status::kUnchanged; + result.board_slot = item.slot; + result.target_row = item.target_row; + result.active_pitch_row = item.active_pitch_row; + return result; + } + if (operation == Operation::kRemove && before_matches.empty()) { + result.status = Status::kUnchanged; + return result; + } + if (operation == Operation::kAdd && before.items.size() >= kBoardSlots) { + result.status = Status::kBoardFull; + return result; + } + + const auto old_target_head = targets.head; + const auto old_pitch_head = pitches.head; + std::uint32_t next_target_head{}; + std::uint32_t next_pitch_head{}; + if (operation == Operation::kAdd && + (old_target_head >= targets.spec->capacity || old_pitch_head >= pitches.spec->capacity || + !ReadValue(targets.data + static_cast(old_target_head) * + targets.spec->stride, next_target_head) || + !ReadValue(pitches.data + static_cast(old_pitch_head) * + pitches.spec->stride, next_pitch_head))) { + result.status = Status::kBoardStateInvalid; + return result; + } + const auto removed = operation == Operation::kRemove ? before_matches[0] : BoardItem{}; + + std::uint64_t team_cell = team_wrappers[0]; + std::uint64_t recruit_cell = recruit_wrappers[0]; + const std::array arguments{ + controllers[0], reinterpret_cast(&team_cell), + reinterpret_cast(&recruit_cell)}; + const auto target = module + + (operation == Operation::kAdd ? kFullAddRva : kFullRemoveRva); + const auto call = native_call::Invoke(target, arguments); + result.call_value = call.value; + result.fault_code = call.fault_code; + if (call.status != native_call::Status::kOk) { + result.status = Status::kNativeCallFailed; + return result; + } + + std::uint32_t new_target_head{}; + std::uint32_t new_pitch_head{}; + if (!ReadValue(targets.header + 24, new_target_head) || + !ReadValue(pitches.header + 24, new_pitch_head)) { + result.status = Status::kPostconditionFailed; + return result; + } + const auto after = ReadBoard(targets, pitches, membership, result.membership_row); + const auto after_matches = Matching(after, recruit_row); + if (!after.valid) { + result.status = Status::kPostconditionFailed; + return result; + } + + if (operation == Operation::kAdd) { + if (after.items.size() != before.items.size() + 1 || after_matches.size() != 1 || + after_matches[0].slot != before.items.size() || + after_matches[0].target_row != old_target_head || + after_matches[0].active_pitch_row != old_pitch_head || + new_target_head != next_target_head || new_pitch_head != next_pitch_head) { + result.status = Status::kPostconditionFailed; + return result; + } + result.board_slot = after_matches[0].slot; + result.target_row = after_matches[0].target_row; + result.active_pitch_row = after_matches[0].active_pitch_row; + } else { + std::uint32_t cleared_recruit{}; + std::uint32_t cleared_pitch{}; + const auto freed_target = targets.data + + static_cast(removed.target_row) * targets.spec->stride; + if (after.items.size() + 1 != before.items.size() || !after_matches.empty() || + new_target_head != removed.target_row || + removed.active_pitch_row == UINT32_MAX || new_pitch_head != removed.active_pitch_row || + !ReadValue(freed_target + 12, cleared_recruit) || cleared_recruit != 0 || + !ReadValue(freed_target + 16, cleared_pitch) || cleared_pitch != 0) { + result.status = Status::kPostconditionFailed; + return result; + } + result.board_slot = removed.slot; + result.target_row = removed.target_row; + result.active_pitch_row = removed.active_pitch_row; + } + result.status = Status::kApplied; + return result; +} + +const char* StatusCode(Status status) { + switch (status) { + case Status::kApplied: return "APPLIED"; + case Status::kUnchanged: return "UNCHANGED"; + case Status::kInvalidArgument: return "INVALID_ARGUMENT"; + case Status::kRecruitingNotLoaded: return "RECRUITING_NOT_LOADED"; + case Status::kRuntimeAmbiguous: return "RUNTIME_DISCOVERY_AMBIGUOUS"; + case Status::kTableDiscoveryFailed: return "BOARD_TABLE_DISCOVERY_FAILED"; + case Status::kBoardStateInvalid: return "BOARD_STATE_INVALID"; + case Status::kBoardFull: return "BOARD_FULL"; + case Status::kNativeCallFailed: return "BOARD_NATIVE_CALL_FAILED"; + case Status::kPostconditionFailed: return "BOARD_POSTCONDITION_FAILED"; + } + return "BOARD_STATE_INVALID"; +} + +} // namespace cfb27::board_mutation diff --git a/native/host/board_mutation.h b/native/host/board_mutation.h new file mode 100644 index 0000000..83627f0 --- /dev/null +++ b/native/host/board_mutation.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +namespace cfb27::board_mutation { + +enum class Operation { kAdd, kRemove }; + +enum class Status { + kApplied, + kUnchanged, + kInvalidArgument, + kRecruitingNotLoaded, + kRuntimeAmbiguous, + kTableDiscoveryFailed, + kBoardStateInvalid, + kBoardFull, + kNativeCallFailed, + kPostconditionFailed, +}; + +struct Result { + Status status{Status::kInvalidArgument}; + Operation operation{Operation::kAdd}; + std::uint32_t recruit_row{}; + std::uint32_t team_row{}; + std::uint32_t membership_row{}; + std::uint32_t board_slot{UINT32_MAX}; + std::uint32_t target_row{UINT32_MAX}; + std::uint32_t active_pitch_row{UINT32_MAX}; + std::uint64_t call_value{}; + std::uint32_t fault_code{}; +}; + +Result Invoke(Operation operation, std::uint32_t recruit_row, + std::uint32_t team_row); +const char* StatusCode(Status status); + +} // namespace cfb27::board_mutation diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index abf1117..8352b06 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -5,6 +5,7 @@ #include "memory_reader.h" #include "memory_transaction.h" #include "native_call.h" +#include "board_mutation.h" #include "frtk_catalog.h" #include "frtk_lua_api.h" #include "frtk_profile.h" @@ -1408,6 +1409,7 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { "memoryScan", "memoryScanAllocationMetadata", "memoryRead", "memoryWriteTransaction", "nativeCall", + "boardMutationV1", "researchWatch", "telemetry", "frtkProfileV1", "frtkCatalogV1", "frtkRecordReadV1", "frtkFieldTransactionV1"}}, @@ -1497,6 +1499,74 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { }); } + if (command == "addBoard" || command == "removeBoard") { + if (!HasOnlyKeys(params, {"recruitRow", "teamRow"}) || + !params.contains("recruitRow") || !params["recruitRow"].is_number_unsigned() || + !params.contains("teamRow") || !params["teamRow"].is_number_unsigned()) { + return ErrorResponse(id, "INVALID_REQUEST", + "Board mutation requires recruitRow and teamRow"); + } + const auto recruit_row64 = params["recruitRow"].get(); + const auto team_row64 = params["teamRow"].get(); + if (recruit_row64 > 0x1FFFF || team_row64 > 0x1FFFF) { + return ErrorResponse(id, "INVALID_REQUEST", + "Board mutation rows are outside the supported range"); + } + if (session_writes_disabled) { + return ErrorResponse(id, "SESSION_WRITES_DISABLED", + "Board mutations are disabled for this host session"); + } + if (!NativeCallsAllowed()) { + return ErrorResponse(id, "UNSUPPORTED_BUILD", + "Board mutations require the supported offline game build"); + } + const auto operation = command == "addBoard" + ? cfb27::board_mutation::Operation::kAdd + : cfb27::board_mutation::Operation::kRemove; + cfb27::board_mutation::Result mutation; + { + std::scoped_lock call_lock(g_host_write_mutex, g_native_call_mutex); + mutation = cfb27::board_mutation::Invoke( + operation, static_cast(recruit_row64), + static_cast(team_row64)); + } + using BoardStatus = cfb27::board_mutation::Status; + if (mutation.status != BoardStatus::kApplied && + mutation.status != BoardStatus::kUnchanged) { + const std::string code = cfb27::board_mutation::StatusCode(mutation.status); + if (mutation.status == BoardStatus::kPostconditionFailed) { + g_session_writes_disabled.store(true, std::memory_order_release); + } + Json details{ + {"operation", command == "addBoard" ? "add" : "remove"}, + {"recruitRow", recruit_row64}, + {"teamRow", team_row64}, + }; + if (mutation.fault_code) { + details["exceptionCode"] = FormatCanonicalAddress(mutation.fault_code); + } + return ErrorResponse(id, code, "Board mutation could not be verified", + std::move(details)); + } + auto optional_row = [](std::uint32_t value) -> Json { + return value == UINT32_MAX ? Json(nullptr) : Json(value); + }; + return SuccessResponse(id, { + {"operation", command == "addBoard" ? "add" : "remove"}, + {"status", mutation.status == BoardStatus::kApplied + ? "applied_verified" : "unchanged"}, + {"recruitRow", mutation.recruit_row}, + {"teamRow", mutation.team_row}, + {"membershipRow", mutation.membership_row}, + {"boardSlot", optional_row(mutation.board_slot)}, + {"targetRow", optional_row(mutation.target_row)}, + {"activePitchRow", optional_row(mutation.active_pitch_row)}, + {"callValue", FormatCanonicalAddress( + static_cast(mutation.call_value))}, + {"uiRefresh", "next_recruiting_screen_change"}, + }); + } + if (command == "loadFrtkProfile") { if (!HasOnlyKeys(params, {"profile", "layout"}) || !params.contains("profile") || !params.contains("layout")) { diff --git a/native/smoke/board_mutation_smoke.cpp b/native/smoke/board_mutation_smoke.cpp new file mode 100644 index 0000000..949b338 --- /dev/null +++ b/native/smoke/board_mutation_smoke.cpp @@ -0,0 +1,21 @@ +#include "../host/board_mutation.h" + +#include + +int main() { + using cfb27::board_mutation::Invoke; + using cfb27::board_mutation::Operation; + using cfb27::board_mutation::Status; + + const auto invalid = Invoke(Operation::kAdd, 0x20000, 0); + if (invalid.status != Status::kInvalidArgument) return 1; + + const auto unloaded = Invoke(Operation::kRemove, 1, 1); + if (unloaded.status != Status::kRecruitingNotLoaded) return 2; + + if (std::string(cfb27::board_mutation::StatusCode(Status::kBoardFull)) != + "BOARD_FULL") return 3; + + std::cout << "board mutation smoke passed\n"; + return 0; +} diff --git a/native/smoke/protocol_smoke.cpp b/native/smoke/protocol_smoke.cpp index 592b5b6..2acb365 100644 --- a/native/smoke/protocol_smoke.cpp +++ b/native/smoke/protocol_smoke.cpp @@ -442,6 +442,12 @@ int wmain(int argc, wchar_t** argv) { capabilities.end()) return 139; if (std::find(capabilities.begin(), capabilities.end(), "researchWatch") == capabilities.end()) return 143; + if (std::find(capabilities.begin(), capabilities.end(), "boardMutationV1") == + capabilities.end()) return 145; + if (!Request(pipe, {{"protocol", 1}, {"id", "board-invalid"}, + {"command", "addBoard"}, + {"params", {{"recruitRow", 1}}}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 146; Json native_arguments = Json::array(); for (std::uintptr_t value = 1; value <= 8; ++value) { native_arguments.push_back(FormatAddress(value)); diff --git a/packages/sdk/src/client.cjs b/packages/sdk/src/client.cjs index 10e7cd8..076208c 100644 --- a/packages/sdk/src/client.cjs +++ b/packages/sdk/src/client.cjs @@ -35,6 +35,7 @@ const RESERVED_TELEMETRY_TYPES = new Set(['game_ready', 'tick', 'log']); const PIPE_CONNECT_RETRY_DELAY_MS = 10; const MAX_UINT64 = 0xFFFFFFFFFFFFFFFFn; const NATIVE_CALL_CAPABILITY = 'nativeCall'; +const BOARD_MUTATION_CAPABILITY = 'boardMutationV1'; const WRITE_TRANSACTION_ERROR_MESSAGES = Object.freeze({ INVALID_REQUEST: 'Host rejected the write transaction request', UNSUPPORTED_BUILD: 'Memory writes require the supported game build', @@ -775,6 +776,35 @@ function validateNativeCallResult(result, params) { return result; } +function cloneBoardMutationOptions(options = {}) { + if (!isObject(options) || !hasExactKeys(options, ['recruitRow', 'teamRow']) || + !isSafeIntegerBetween(options.recruitRow, 0, 0x1FFFF) || + !isSafeIntegerBetween(options.teamRow, 0, 0x1FFFF)) { + throw invalidRequest('Board mutation requires recruitRow and teamRow'); + } + return { recruitRow: options.recruitRow, teamRow: options.teamRow }; +} + +function validateBoardMutationResult(result, params, operation) { + const keys = ['operation', 'status', 'recruitRow', 'teamRow', 'membershipRow', + 'boardSlot', 'targetRow', 'activePitchRow', 'callValue', 'uiRefresh']; + const optionalRow = (value) => value === null || isSafeIntegerBetween(value, 0, 0x1FFFF); + if (!hasExactKeys(result, keys) || result.operation !== operation || + !['applied_verified', 'unchanged'].includes(result.status) || + result.recruitRow !== params.recruitRow || result.teamRow !== params.teamRow || + !isSafeIntegerBetween(result.membershipRow, 0, 137) || + !optionalRow(result.boardSlot) || !optionalRow(result.targetRow) || + !optionalRow(result.activePitchRow) || typeof result.callValue !== 'string' || + !CANONICAL_ADDRESS.test(result.callValue) || + result.uiRefresh !== 'next_recruiting_screen_change') { + throw invalidResponse('Host returned an invalid board mutation result'); + } + if (result.boardSlot !== null && result.boardSlot > 34) { + throw invalidResponse('Host returned an invalid board slot'); + } + return result; +} + function validateTelemetryRegistration(result, types) { if (!hasExactKeys(result, ['types']) || !Array.isArray(result.types) || result.types.length !== types.length || @@ -932,6 +962,18 @@ function createClient({ pid, pipeName, timeoutMs = 20000 } = {}) { } } + async function requireBoardMutationCapability() { + const hello = await request('hello'); + if (!hello || hello.protocolVersion !== 1 || + !Array.isArray(hello.capabilities) || + !hello.capabilities.includes(BOARD_MUTATION_CAPABILITY)) { + throw new Cfb27HookError( + 'PROTOCOL_MISMATCH', + 'Host does not advertise boardMutationV1 capability', + ); + } + } + async function requireFrtkCapability(capability) { const hello = await request('hello'); if (!hasExactKeys(hello, ['protocolVersion', 'hostVersion', 'supportedBuild', 'writesAllowed', @@ -991,6 +1033,16 @@ function createClient({ pid, pipeName, timeoutMs = 20000 } = {}) { await requireNativeCallCapability(); return validateNativeCallResult(await request('nativeCall', params), params); }, + async addBoard(options = {}) { + const params = cloneBoardMutationOptions(options); + await requireBoardMutationCapability(); + return validateBoardMutationResult(await request('addBoard', params), params, 'add'); + }, + async removeBoard(options = {}) { + const params = cloneBoardMutationOptions(options); + await requireBoardMutationCapability(); + return validateBoardMutationResult(await request('removeBoard', params), params, 'remove'); + }, getLogs({ limit = 100 } = {}) { return request('logs', { limit }); }, diff --git a/packages/sdk/src/errors.cjs b/packages/sdk/src/errors.cjs index bd41e76..5a10396 100644 --- a/packages/sdk/src/errors.cjs +++ b/packages/sdk/src/errors.cjs @@ -20,6 +20,13 @@ const ERROR_CODES = Object.freeze([ 'SESSION_WRITES_DISABLED', 'NATIVE_CALL_TARGET_INVALID', 'NATIVE_CALL_EXCEPTION', + 'RECRUITING_NOT_LOADED', + 'RUNTIME_DISCOVERY_AMBIGUOUS', + 'BOARD_TABLE_DISCOVERY_FAILED', + 'BOARD_STATE_INVALID', + 'BOARD_FULL', + 'BOARD_NATIVE_CALL_FAILED', + 'BOARD_POSTCONDITION_FAILED', 'FRTK_PROFILE_INVALID', 'FRTK_DISCOVERY_FAILED', 'FRTK_DISCOVERY_TIMEOUT', diff --git a/packages/sdk/test/board-mutation.test.cjs b/packages/sdk/test/board-mutation.test.cjs new file mode 100644 index 0000000..c68f7e1 --- /dev/null +++ b/packages/sdk/test/board-mutation.test.cjs @@ -0,0 +1,148 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const net = require('node:net'); +const { createClient } = require('../src/client.cjs'); +const { ERROR_CODES } = require('../src/errors.cjs'); +const { FrameDecoder, encodeFrame } = require('../src/frame.cjs'); + +function pipeName(label) { + return `\\\\.\\pipe\\cfb27-board-${label}-${process.pid}-${Date.now()}-${Math.random()}`; +} + +function listen(server, name) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(name, resolve); + }); +} + +function result(operation, params, overrides = {}) { + return { + operation, + status: 'applied_verified', + recruitRow: params.recruitRow, + teamRow: params.teamRow, + membershipRow: 72, + boardSlot: 3, + targetRow: 3, + activePitchRow: 3, + callValue: '0x0', + uiRefresh: 'next_recruiting_screen_change', + ...overrides, + }; +} + +test('board mutation error codes are public and stable', () => { + for (const code of [ + 'RECRUITING_NOT_LOADED', + 'RUNTIME_DISCOVERY_AMBIGUOUS', + 'BOARD_TABLE_DISCOVERY_FAILED', + 'BOARD_STATE_INVALID', + 'BOARD_FULL', + 'BOARD_NATIVE_CALL_FAILED', + 'BOARD_POSTCONDITION_FAILED', + ]) assert.equal(ERROR_CODES.includes(code), true, code); +}); + +test('addBoard and removeBoard negotiate capability and send exact cloned requests', async (t) => { + const name = pipeName('valid'); + const requests = []; + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + requests.push({ command: request.command, params: request.params }); + const response = request.command === 'hello' + ? { protocolVersion: 1, capabilities: ['boardMutationV1'] } + : result(request.command === 'addBoard' ? 'add' : 'remove', request.params); + socket.end(encodeFrame({ protocol: 1, id: request.id, ok: true, result: response })); + } + }); + }); + await listen(server, name); + t.after(() => server.close()); + + const client = createClient({ pipeName: name, timeoutMs: 1000 }); + const add = { recruitRow: 3182, teamRow: 92 }; + const pendingAdd = client.addBoard(add); + add.recruitRow = 1; + assert.equal((await pendingAdd).operation, 'add'); + assert.equal((await client.removeBoard({ recruitRow: 3182, teamRow: 92 })).operation, 'remove'); + assert.deepEqual(requests, [ + { command: 'hello', params: {} }, + { command: 'addBoard', params: { recruitRow: 3182, teamRow: 92 } }, + { command: 'hello', params: {} }, + { command: 'removeBoard', params: { recruitRow: 3182, teamRow: 92 } }, + ]); +}); + +test('board mutations reject malformed rows before I/O', async () => { + const client = createClient({ pipeName: pipeName('unused'), timeoutMs: 25 }); + for (const input of [ + {}, + { recruitRow: 1 }, + { recruitRow: -1, teamRow: 92 }, + { recruitRow: 0x20000, teamRow: 92 }, + { recruitRow: 1, teamRow: 1.5 }, + { recruitRow: 1, teamRow: 92, extra: true }, + ]) { + await assert.rejects(Promise.resolve().then(() => client.addBoard(input)), { + code: 'INVALID_REQUEST', + }); + } +}); + +test('board mutations fail closed without the capability', async (t) => { + const name = pipeName('capability'); + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + socket.end(encodeFrame({ + protocol: 1, + id: request.id, + ok: true, + result: { protocolVersion: 1, capabilities: [] }, + })); + } + }); + }); + await listen(server, name); + t.after(() => server.close()); + await assert.rejects( + createClient({ pipeName: name, timeoutMs: 1000 }).addBoard({ recruitRow: 1, teamRow: 92 }), + { code: 'PROTOCOL_MISMATCH' }, + ); +}); + +test('board mutations accept unchanged and reject malformed host results', async (t) => { + const name = pipeName('responses'); + let calls = 0; + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + let response; + if (request.command === 'hello') { + response = { protocolVersion: 1, capabilities: ['boardMutationV1'] }; + } else if (calls++ === 0) { + response = result('remove', request.params, { + status: 'unchanged', boardSlot: null, targetRow: null, activePitchRow: null, + }); + } else { + response = result('add', request.params, { boardSlot: 35 }); + } + socket.end(encodeFrame({ protocol: 1, id: request.id, ok: true, result: response })); + } + }); + }); + await listen(server, name); + t.after(() => server.close()); + const client = createClient({ pipeName: name, timeoutMs: 1000 }); + assert.equal((await client.removeBoard({ recruitRow: 3182, teamRow: 92 })).status, 'unchanged'); + await assert.rejects(client.addBoard({ recruitRow: 3182, teamRow: 92 }), { + code: 'INVALID_RESPONSE', + }); +}); From bcd220979a1b38074d804f7f6223fdb926ae6539 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 15 Jul 2026 21:14:53 -0500 Subject: [PATCH 8/8] docs: use release-safe native call address --- docs/protocol.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 1d01703..cb2e447 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -266,11 +266,11 @@ eight values. The host uses the Windows x64 integer/pointer ABI and returns the 64-bit integer result as another canonical hexadecimal string. ```json -{"protocol":1,"id":"call-1","command":"nativeCall","params":{"address":"0x140001000","arguments":["0x1","0x2"]}} +{"protocol":1,"id":"call-1","command":"nativeCall","params":{"address":"0x1234AB80","arguments":["0x1","0x2"]}} ``` ```json -{"address":"0x140001000","value":"0x24"} +{"address":"0x1234AB80","value":"0x24"} ``` The target must be a committed executable address in the current process, and