From 3edc16bb799950864849ed20c8f756bab2ed3b34 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 20:22:50 -0500 Subject: [PATCH 01/16] docs: design patch 1 build re-anchor --- ...2026-07-16-patch1-build-reanchor-design.md | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-patch1-build-reanchor-design.md diff --git a/docs/superpowers/specs/2026-07-16-patch1-build-reanchor-design.md b/docs/superpowers/specs/2026-07-16-patch1-build-reanchor-design.md new file mode 100644 index 0000000..dd56305 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-patch1-build-reanchor-design.md @@ -0,0 +1,289 @@ +# Patch 1 Build Re-Anchor Design + +**Date:** 2026-07-16 + +## Goal + +Restore the CFB27 Lua Hook on the July 16 Patch 1 executable and replace the +current one-off offset update process with a repeatable, fail-closed re-anchor +workflow for later game updates. + +The repair covers the active hook only. It does not revive archived edit-player +hooks or modify Brooks's SPEX data. It must preserve the current offline-only +safety boundary and must not edit an active save during discovery. + +## Confirmed Inputs + +The previously supported executable is: + +- size: `247845776` bytes; +- SHA-256: `9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8`. + +The installed Patch 1 executable is: + +- path: `F:\EA SPORTS College Football 27\CollegeFB27.exe`; +- size: `249801616` bytes; +- SHA-256: `A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD`. + +The active board implementation contains four build-local values that cannot +be carried forward without new evidence: + +- generic record-wrapper vtable RVA `0xB093F68`; +- recruiting-controller vtable RVA `0xB0B5BA8`; +- full board-add routine RVA `0x8109060`; +- full board-remove routine RVA `0x8166090`. + +MMC 1.1.0.1 still supplies the recognized stock `CryptBase.dll`: + +- size: `95744` bytes; +- SHA-256: `3E87682118E593F334BA665826E2A6AB85BA460F2E1FE95B173A7199863AD454`. + +The new MMC folder is clean: its `ThirdParty\CryptBase.dll` is the stock proxy +and it has no `MMCBase.dll`. The old MMC folder was already hook-installed. +The existing reversible installer therefore remains compatible with MMC +1.1.0.1 and should create and verify the new folder's backup normally. + +Brooks's post-patch static sweep found all six dynasty FTC files byte-identical, +including `dynasty-expression-binary.FTC`. His `live-action-layout.json` v1.5.0 +is a consumer-side live table-anchor map, not an executable hook. SPEX is out of +scope for this repair. The unchanged FTC evidence makes the live table layout +likely stable, but it does not replace a live post-patch validation. + +## Design Principles + +1. An unknown executable is never write-enabled. +2. A known diagnostic build is not automatically a supported mutation build. +3. Session addresses are evidence, never configuration. +4. Production RVAs remain compiled into the host; an ignored calibration file + cannot grant write authority. +5. Table anchors are rediscovered and structurally validated every session. +6. Native routines and vtables are accepted only after independent capture and + postcondition evidence. +7. Previous build layouts remain available instead of being overwritten by the + newest layout. + +## Build Registry + +Move executable identity and native layout values into one native build +registry. Each entry contains: + +- executable size and uppercase SHA-256; +- build label; +- support state: `diagnostic` or `certified`; +- optional board layout containing the two vtable RVAs and two full-routine + RVAs. + +The existing July 11 build becomes a `certified` entry with its current values. +Patch 1 begins as `diagnostic`, with no board layout. The registry returns one +of three runtime states: + +- `unknown`: executable identity is absent; +- `diagnostic`: exact identity is recognized for read-only research, but game + writes and native calls remain blocked; +- `certified`: exact identity and board layout passed the complete acceptance + gate. + +The public `supportedBuild` field remains true only for `certified` builds so +existing SDK safety semantics do not weaken. `writesAllowed` also requires a +certified build, offline status, no real anticheat process, and no session +lockout. + +Read-only memory scanning continues to require the caller's existing explicit +unsupported-build opt-in for non-certified builds. Research watches may run on +an exact `diagnostic` build because they install debug-register watches but do +not write game data or call game routines. Arbitrary native calls, memory +transactions, FrTk writes, live-class replacement, and board mutations remain +blocked while the build is diagnostic. + +## Re-Anchor Workflow + +Create one guided developer script under `scripts/board-verification/` that +coordinates the existing SDK, table-anchor logic, and research-watch API. It +writes all raw and derived evidence below `.frtk/board-reanchor//`, +which remains ignored and is never packaged. + +### Phase 1: Preflight + +The script must: + +1. Hash `CollegeFB27.exe` and require the exact diagnostic registry entry. +2. Confirm the process executable matches the on-disk file. +3. Confirm the host is ready but reports `supportedBuild: false` and + `writesAllowed: false`. +4. Confirm the real EA/Javelin anticheat process is absent. +5. Record the PID and reject evidence from another PID or later host session. + +Any mismatch stops the run without arming watches. + +### Phase 2: Table-Anchor Validation + +Locate and structurally validate the six tables used by the live recruiting +layout: + +- UserRecruitTarget `4168`; +- ActiveVisitInfo `4176`; +- ActiveRecruitingPitch `4190`; +- RecruitingBoard `4251`; +- ActiveRecruitingPitch array `5790`; +- RecruitTarget membership array `5847`. + +Use the existing 16-byte header signature +`[table1Length][table1Length][recordWords][capacity]`, derive the data address +from the save-header geometry, and score freelist and content structure. A +table passes only when one candidate wins unambiguously and its record +references, capacity, stride, freelist head, and board relationships are +consistent. Re-read the winning headers and sample rows before accepting them. + +This phase validates Brooks's v1.5.0 anchor constants as a consumer of the hook +and validates the subset embedded in `board_mutation.cpp`. It does not promote +the executable to certified status. + +### Phase 3: Vanilla UI Capture + +Use a disposable dynasty or a dynasty with a verified backup. Discovery never +performs a synthetic mutation. + +For board add: + +1. Select an off-board recruit while the script watches the validated 4168 + freelist head and first free 5847 membership slot. +2. Perform one vanilla UI add. +3. Collect write hits, RIPs, register snapshots, stack return addresses, and + pointed-to qwords. +4. Re-anchor the tables and verify the vanilla allocation and compact + membership postcondition. +5. Repeat with a second recruit to reject incidental call sites. + +For board remove: + +1. Select an on-board recruit with no pitch, visit, or assigned action that + would make the capture ambiguous. +2. Watch the validated membership row and relevant freelist heads. +3. Perform one vanilla UI remove and collect the same evidence. +4. Verify membership compaction, cleared references, and both freelist returns. +5. Repeat once to reject incidental call sites. + +Candidate full-routine entries must appear consistently in both captures of an +operation and must receive arguments shaped as the active controller plus +pointer cells containing Team and Recruit wrappers. Low-level allocation or +table-only routines are rejected even if they occur in every capture. + +### Phase 4: Vtable Derivation + +Derive vtable candidates from the captured, structurally verified objects: + +- the controller must expose the expected membership row, descriptor table + identity, and readable board store; +- the Recruit and Team wrappers must expose their expected descriptor table + identities and row numbers; +- each first qword must lie inside the main module's readable image; +- every sampled vtable function pointer must target executable image memory. + +Convert accepted module addresses to RVAs only after those checks. Repeat the +object discovery after a recruiting-screen transition; the object addresses +may change, but the vtable RVAs must remain identical. + +### Phase 5: Candidate Artifact + +Emit a candidate artifact containing: + +- executable size and SHA-256; +- table-anchor validation summary; +- proposed four RVAs; +- capture counts and consistency checks; +- PE-section checks; +- PID/session identity; +- pass/fail status for every required evidence gate. + +The artifact may retain raw process addresses because it is ignored local +research material. Committed documentation records only build identity, RVAs, +and sanitized verification results. The artifact is never loaded by the +production host and cannot enable writes. + +## Promotion and Live Acceptance + +After reviewing the candidate artifact, add the Patch 1 board layout to the +compiled registry and mark it certified only in the local acceptance build. +The branch is not releasable until all of these gates pass: + +1. Full automated Node and native smoke suites. +2. Clean Windows x64 Release build. +3. Installer/doctor verification against MMC 1.1.0.1. +4. Host status: exact new build, ready, certified, and offline write-eligible. +5. Already-present add returns unchanged without a native call. +6. Already-absent remove returns unchanged without a native call. +7. One real guarded add on the disposable dynasty produces exactly one 4168 + allocation, one 5790 allocation, and one compact membership append. +8. One real guarded remove returns both rows to their freelists, clears the + references, and compacts membership. +9. The board renders after a normal recruiting screen transition. +10. The result survives the game's normal autosave and dynasty reload. +11. Host ticks, status, and write eligibility remain healthy with no session + lockout. +12. The verified backup remains byte-identical and recoverable. + +A native fault, ambiguous discovery result, unexpected table change, or failed +postcondition disables further board mutations for the session. The build +returns to diagnostic state in source until the candidate is corrected and the +complete gate is rerun. + +## MMC 1.1.0.1 Deployment + +The existing installer remains the only supported deployment path. With the +game closed, it must: + +1. recognize the game's preserved `MMCBase.dll` and installed forwarding + `CryptBase.dll`; +2. recognize the new MMC folder's stock `ThirdParty\CryptBase.dll`; +3. create and verify `ThirdParty\MMCBase.dll` in MMC 1.1.0.1; +4. install the forwarding proxy in both locations; +5. install the rebuilt host and autorun script under `CFB27LiveEditor`; +6. verify every resulting hash. + +No manual proxy copy is part of the design. Uninstall must restore both stock +proxies and verify the recognized stock hash. + +## Failure Handling + +- Unknown build: read-only diagnostics only with explicit opt-in; no research + watches, native calls, or writes. +- Diagnostic build: table scans and research watches allowed; all game-data + writes and native calls blocked. +- Table ambiguity: discard the session evidence and do not continue to capture. +- Capture inconsistency: keep the raw local evidence, emit a failed artifact, + and require another vanilla capture. +- Vtable or PE-section mismatch: reject the candidate. +- Native-call fault or postcondition failure: lock board mutations for the + session and instruct the operator to reload the disposable dynasty. +- Installer conflict: leave both locations unchanged or roll back both to + their verified prior state. + +## Future Update Procedure + +For a later executable update, the intended recovery path is: + +1. add the new exact executable identity as diagnostic; +2. build and install the diagnostic host; +3. run the guided re-anchor script; +4. perform the prompted vanilla add and remove actions; +5. review the generated candidate artifact; +6. add the reviewed RVAs to the compiled registry; +7. run the complete acceptance gate; +8. promote and release only after success. + +Table anchors should normally revalidate automatically. Vtable discovery is +expected to be automatic once matching runtime objects exist. Native routine +recovery remains guided because arbitrary game updates may change call graphs, +argument conventions, or behavior. The workflow promises deterministic, +evidence-backed recovery and safe failure, not blind automatic promotion. + +## Out of Scope + +- Patching or decoding SPEX bytecode. +- Updating Brooks's repository or consumer-side hardcoded table IDs. +- Reviving archived `SubmitEditPlayerRequest` or edit-response hooks. +- Supporting online play or bypassing anticheat. +- Shipping raw process addresses, memory dumps, save files, or calibration + artifacts. +- Automatically certifying a build solely because its old signatures still + match. From c1b240fae7fe2b2b9232fa7dd9eed1c063f12fdb Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 20:34:20 -0500 Subject: [PATCH 02/16] docs: plan patch 1 build re-anchor --- .../plans/2026-07-16-patch1-build-reanchor.md | 932 ++++++++++++++++++ 1 file changed, 932 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-patch1-build-reanchor.md diff --git a/docs/superpowers/plans/2026-07-16-patch1-build-reanchor.md b/docs/superpowers/plans/2026-07-16-patch1-build-reanchor.md new file mode 100644 index 0000000..8537462 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-patch1-build-reanchor.md @@ -0,0 +1,932 @@ +# Patch 1 Build Re-Anchor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore the offline CFB27 Lua Hook on the July 16 Patch 1 executable and leave behind a deterministic, fail-closed workflow for re-anchoring later game builds. + +**Architecture:** A tracked JSON manifest is the authoritative list of exact game identities and board layouts. Deterministic code generation compiles that data into the native host, whose runtime gates distinguish unknown, diagnostic, and certified builds. A guided Node CLI reuses tested table discovery, captures vanilla UI actions with research watches, validates PE sections and object shapes, and emits ignored evidence; only an explicit source promotion can compile candidate RVAs into a certified acceptance build. + +**Tech Stack:** Node.js 20/CommonJS, `node:test`, C++20/MSVC, CMake 3.24+, existing CFB27 SDK framed protocol, Windows hardware debug-register watches, MMC 1.1.0.1. + +## Global Constraints + +- Preserve `hello.supportedBuild` and `status.supportedBuild` semantics: true means exact **certified** build only. +- Unknown builds require `allowUnsupportedBuild: true` for public reads/scans and cannot use research watches, native calls, or writes. +- Exact diagnostic builds may use explicit-opt-in reads/scans and research watches, but cannot use native calls or any game-data write path. +- `CFB27_SMOKE_ALLOW_WRITES=1` remains restricted to `cfb27_protocol_smoke.exe` and must not become a general bypass. +- Production code must never read `.frtk/board-reanchor/**`; only reviewed manifest data may enable board mutation. +- Raw addresses, memory samples, saves, and candidate artifacts remain under ignored `.frtk/` paths and are never packaged or committed. +- The guided workflow may observe vanilla game actions, but it must not synthesize a mutation while Patch 1 is diagnostic. +- Do not revive archived edit-player hooks, alter Brooks's SPEX data, or add online/anticheat bypass behavior. +- Use the installer for both game and MMC locations; do not manually replace proxy DLLs. +- Stop immediately on an identity mismatch, ambiguous table, inconsistent capture, PE-section mismatch, native fault, or failed postcondition. + +--- + +### Task 1: Add the authoritative build manifest and deterministic generator + +**Files:** + +- Create: `native/host/game_builds.json` +- Create: `scripts/game-build-manifest.cjs` +- Create: `scripts/generate-game-builds.cjs` +- Create: `native/host/game_builds.generated.h` +- Create: `tests/game-build-manifest.test.cjs` +- Modify: `package.json` + +- [ ] **Step 1: Write failing manifest-validation tests** + +Cover exact SHA normalization, duplicate size/SHA rejection, diagnostic entries rejecting a board layout, certified entries requiring all four nonzero RVAs, deterministic ordering, and generated-header stability. + +```js +test('diagnostic builds cannot carry a board layout', () => { + assert.throws(() => parseManifest({ + version: 1, + builds: [{ + label: 'patch-1-2026-07-16', + size: 249801616, + sha256: PATCH1_SHA, + support: 'diagnostic', + board: { genericRecordWrapperVtableRva: '0x1' }, + }], + }), /diagnostic.*board/i); +}); + +test('the checked-in generated header is current', () => { + const manifest = parseManifest(JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))); + assert.equal(fs.readFileSync(HEADER, 'utf8'), generateHeader(manifest)); +}); +``` + +- [ ] **Step 2: Run the focused test and confirm it fails** + +Run: + +```powershell +node --test tests/game-build-manifest.test.cjs +``` + +Expected: `ERR_MODULE_NOT_FOUND` or missing-export failure for `scripts/game-build-manifest.cjs`. + +- [ ] **Step 3: Implement strict manifest parsing and header generation** + +Export `parseManifest(raw)`, `generateHeader(manifest)`, `loadManifest(path)`, and `writeGeneratedHeader({ manifestPath, headerPath, check })`. Parse RVAs to `BigInt` internally, reject unknown keys, require uppercase 64-character SHA-256 values, and emit stable C++ entries ordered as they appear in the manifest. + +The generated header must contain only literals and a `constexpr` array; it must not contain filesystem access: + +```cpp +inline constexpr std::array kGeneratedBuilds{{ + {"july-11-2026", 247845776ULL, + "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8", + Support::kCertified, + BoardLayout{0xB093F68ULL, 0xB0B5BA8ULL, 0x8109060ULL, 0x8166090ULL}}, + {"patch-1-2026-07-16", 249801616ULL, + "A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD", + Support::kDiagnostic, std::nullopt}, +}}; +``` + +- [ ] **Step 4: Add the two confirmed builds to `game_builds.json`** + +The July 11 entry is `certified` with the four current RVAs. Patch 1 is `diagnostic` with `"board": null`. Do not invent version metadata beyond the labels, sizes, and hashes confirmed in the design. + +- [ ] **Step 5: Add generator CLI and package checks** + +`node scripts/generate-game-builds.cjs` writes the header. `--check` compares bytes and exits nonzero without changing files. Extend `npm run check` with syntax checks for the new scripts and `node scripts/generate-game-builds.cjs --check`. + +- [ ] **Step 6: Generate the header and make the tests pass** + +Run: + +```powershell +node scripts/generate-game-builds.cjs +node --test tests/game-build-manifest.test.cjs +npm run check +``` + +Expected: the focused test passes and the generator reports the header is current. + +- [ ] **Step 7: Commit the manifest foundation** + +```powershell +git add -- native/host/game_builds.json native/host/game_builds.generated.h scripts/game-build-manifest.cjs scripts/generate-game-builds.cjs tests/game-build-manifest.test.cjs package.json +git commit -m "feat: add compiled game build manifest" +``` + +--- + +### Task 2: Introduce the native build registry and inject board layouts + +**Files:** + +- Create: `native/host/game_builds.h` +- Create: `native/host/game_builds.cpp` +- Create: `native/smoke/game_builds_smoke.cpp` +- Modify: `native/host/board_mutation.h` +- Modify: `native/host/board_mutation.cpp` +- Modify: `native/smoke/board_mutation_smoke.cpp` +- Modify: `native/CMakeLists.txt` + +- [ ] **Step 1: Write a failing native registry smoke** + +Assert that exact size/hash lookup returns the July 11 certified entry and Patch 1 diagnostic entry, an incorrect size or one changed hash nibble returns null, and only the certified entry exposes a board layout. + +```cpp +const auto* patch1 = FindBuild( + 249801616ULL, + "A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD"); +if (!patch1 || patch1->support != Support::kDiagnostic || patch1->board) return 1; +``` + +- [ ] **Step 2: Add the smoke target and confirm it fails to build** + +Run: + +```powershell +cmake -S native -B native/build-patch1 -A x64 +cmake --build native/build-patch1 --config Release --target cfb27_game_builds_smoke +``` + +Expected: compilation fails because `game_builds.h/.cpp` do not exist yet. + +- [ ] **Step 3: Implement the registry API** + +Define a single shared layout type and exact lookup helpers: + +```cpp +namespace cfb27::game_builds { +enum class Support { kDiagnostic, kCertified }; +struct BoardLayout { + std::uintptr_t generic_record_wrapper_vtable_rva{}; + std::uintptr_t recruiting_controller_vtable_rva{}; + std::uintptr_t full_add_rva{}; + std::uintptr_t full_remove_rva{}; +}; +struct Build { + std::string_view label; + std::uintmax_t executable_size{}; + std::string_view executable_sha256; + Support support{Support::kDiagnostic}; + std::optional board; +}; +const Build* FindBuild(std::uintmax_t size, std::string_view uppercase_sha256); +bool IsCertified(const Build* build); +bool IsDiagnosticOrCertified(const Build* build); +} +``` + +`game_builds.cpp` includes the generated header and performs exact size plus hash matching. + +- [ ] **Step 4: Remove board RVAs from `board_mutation.cpp`** + +Change `Invoke` to accept `const game_builds::BoardLayout&`. Use only the supplied layout when locating objects and selecting add/remove targets. + +```cpp +Result Invoke(const game_builds::BoardLayout& layout, Operation operation, + std::uint32_t recruit_row, std::uint32_t team_row); +``` + +Update `board_mutation_smoke.cpp` to pass a nonzero synthetic layout and preserve the invalid-argument and unloaded-module assertions. + +- [ ] **Step 5: Build and run both native smokes** + +Run: + +```powershell +cmake --build native/build-patch1 --config Release --target cfb27_game_builds_smoke cfb27_board_mutation_smoke +native\build-patch1\Release\cfb27_game_builds_smoke.exe +native\build-patch1\Release\cfb27_board_mutation_smoke.exe +``` + +Expected: both output a line containing `smoke passed` and return exit code 0. + +- [ ] **Step 6: Commit the native registry** + +```powershell +git add -- native/host/game_builds.h native/host/game_builds.cpp native/host/board_mutation.h native/host/board_mutation.cpp native/smoke/game_builds_smoke.cpp native/smoke/board_mutation_smoke.cpp native/CMakeLists.txt +git commit -m "refactor: route board offsets through build registry" +``` + +--- + +### Task 3: Enforce unknown, diagnostic, and certified runtime gates + +**Files:** + +- Create: `native/host/build_policy.h` +- Create: `native/host/build_policy.cpp` +- Create: `native/smoke/build_policy_smoke.cpp` +- Modify: `native/host/lua_host.cpp` +- Modify: `native/smoke/protocol_smoke.cpp` +- Modify: `native/smoke/startup_host_smoke.cpp` +- Modify: `native/CMakeLists.txt` +- Modify: `docs/protocol.md` +- Modify: `docs/lua-api.md` +- Modify: `docs/safety.md` + +- [ ] **Step 1: Extend smoke assertions before changing the host** + +Keep the public hello/status key sets unchanged. Add assertions that the protocol smoke still reports `supportedBuild: false` yet can use its executable-name-restricted smoke override, while the normal startup smoke cannot. Add `build_policy_smoke.cpp` to exercise the policy matrix without relying on the host executable hash: + +| Identity | Research watch | Native calls/writes | +|---|---:|---:| +| unknown | false | false | +| diagnostic | true | false | +| certified, offline | true | true | +| certified, anticheat present | false | false | + +- [ ] **Step 2: Build the smokes and confirm the new assertions fail** + +Run: + +```powershell +cmake --build native/build-patch1 --config Release --target cfb27_build_policy_smoke cfb27_lua_host cfb27_protocol_smoke cfb27_startup_smoke +native\build-patch1\Release\cfb27_build_policy_smoke.exe +$env:CFB27_SMOKE_ALLOW_WRITES='1' +try { + native\build-patch1\Release\cfb27_protocol_smoke.exe native\build-patch1\Release\cfb27_lua_host.dll +} finally { + Remove-Item Env:CFB27_SMOKE_ALLOW_WRITES -ErrorAction SilentlyContinue +} +``` + +Expected: `cfb27_build_policy_smoke` fails to compile because the policy module is absent. + +- [ ] **Step 3: Replace the single supported-build atomic with matched-build state** + +At startup, compute the executable size and SHA once, resolve it through `game_builds::FindBuild`, and store the matched immutable `Build*` in `std::atomic`. Remove `kSupportedExecutableSize`, `kSupportedExecutableSha256`, and `VerifySupportedBuild()`. + +Implement pure policy functions in `build_policy.h/.cpp`, then thin host predicates, so call sites cannot conflate research and write authority: + +```cpp +namespace cfb27::build_policy { +bool ResearchWatchesAllowed(const game_builds::Build* build, + bool real_anticheat_running); +bool WritesAllowed(const game_builds::Build* build, + bool real_anticheat_running, + bool session_writes_disabled, + bool smoke_override); +} + +bool CertifiedBuild() { return game_builds::IsCertified(g_game_build.load()); } +bool DiagnosticOrCertifiedBuild() { + return game_builds::IsDiagnosticOrCertified(g_game_build.load()); +} +bool ResearchWatchesAllowed() { + return DiagnosticOrCertifiedBuild() && !RealAnticheatIsRunning(); +} +bool WriteEnvironmentAllowed() { + return (CertifiedBuild() || SmokeWritesAllowed()) && !RealAnticheatIsRunning(); +} +``` + +- [ ] **Step 4: Apply the gates to every sensitive path** + +- `supportedBuild` calls `CertifiedBuild()`. +- Public scan/read behavior remains unchanged and continues requiring explicit opt-in when `supportedBuild` is false. +- `cfb.watch_*` and watch clearing use `ResearchWatchesAllowed()`, not `NativeCallsAllowed()`. +- Native calls, transactions, FrTk writes, live-class replacement, and board mutations remain behind `NativeCallsAllowed()` or `WriteEnvironmentAllowed()`. +- `addBoard/removeBoard` require a certified matched build with a board layout and pass that layout to `board_mutation::Invoke`. +- `loadFrtkProfile` compares a supported production profile to the matched certified build hash; preserve the synthetic protocol-smoke exception. + +- [ ] **Step 5: Preserve protocol compatibility and improve errors** + +Do not add keys to `hello` or `status`. Update denial text to distinguish `UNKNOWN_BUILD`, `DIAGNOSTIC_BUILD_WRITE_BLOCKED`, and `RESEARCH_WATCH_NOT_ALLOWED` internally without weakening existing public validation. + +- [ ] **Step 6: Document the policy** + +Update the protocol, Lua API, and safety docs to state that research watches are allowed only for an exact diagnostic/certified offline identity, while writes/native calls require certification. Explicitly state that `.frtk` evidence cannot grant authority. + +- [ ] **Step 7: Run native protocol verification** + +Run: + +```powershell +cmake --build native/build-patch1 --config Release --target cfb27_build_policy_smoke cfb27_lua_host cfb27_protocol_smoke cfb27_startup_smoke cfb27_board_mutation_smoke cfb27_game_builds_smoke +native\build-patch1\Release\cfb27_build_policy_smoke.exe +native\build-patch1\Release\cfb27_game_builds_smoke.exe +native\build-patch1\Release\cfb27_board_mutation_smoke.exe +native\build-patch1\Release\cfb27_startup_smoke.exe native\build-patch1\Release\cfb27_lua_host.dll +$env:CFB27_SMOKE_ALLOW_WRITES='1' +try { + native\build-patch1\Release\cfb27_protocol_smoke.exe native\build-patch1\Release\cfb27_lua_host.dll +} finally { + Remove-Item Env:CFB27_SMOKE_ALLOW_WRITES -ErrorAction SilentlyContinue +} +``` + +Expected: every executable exits 0; hello/status shapes remain compatible. + +- [ ] **Step 8: Commit runtime gating** + +```powershell +git add -- native/host/build_policy.h native/host/build_policy.cpp native/host/lua_host.cpp native/smoke/build_policy_smoke.cpp native/smoke/protocol_smoke.cpp native/smoke/startup_host_smoke.cpp native/CMakeLists.txt docs/protocol.md docs/lua-api.md docs/safety.md +git commit -m "feat: add diagnostic build safety state" +``` + +--- + +### Task 4: Extract and strengthen reusable table-anchor validation + +**Files:** + +- Create: `scripts/board-verification/reanchor-lib.cjs` +- Create: `tests/board-reanchor.test.cjs` +- Modify: `scripts/board-verification/live-anchor.cjs` +- Modify: `scripts/board-verification/live-table-snapshot.cjs` +- Modify: `package.json` + +- [ ] **Step 1: Write fixture-based failing tests** + +Build small in-memory fixtures for all six table definitions. Test signature generation, reference decoding, header-to-data geometry, freelist/content scoring, ambiguous-top-score rejection, compact membership discovery, header reread mismatch, and scan/read requests carrying `allowUnsupportedBuild: true`. + +```js +test('selectTableCandidate rejects tied structural winners', () => { + assert.throws( + () => selectTableCandidate(TABLES.get(4168), [winnerA, winnerB]), + /ambiguous/i, + ); +}); +``` + +- [ ] **Step 2: Confirm the test fails before extraction** + +Run: + +```powershell +node --test tests/board-reanchor.test.cjs +``` + +Expected: missing module failure for `reanchor-lib.cjs`. + +- [ ] **Step 3: Extract pure helpers and async discovery** + +Export frozen `TABLES`, `canonical`, `signature`, `decodeRef`, `scoreCandidate`, `deriveDataAddress`, `selectTableCandidate`, `locateTable`, `findUserBoard`, `readRange`, and `validateAnchorReread`. + +Strengthen selection: the highest score must be positive and strictly greater than the runner-up; reread the 16-byte header, freelist head, and representative content/free rows before acceptance. All reads and scan pages used by this diagnostic tool must pass `allowUnsupportedBuild: true` explicitly. + +- [ ] **Step 4: Convert the existing scripts to consumers** + +Remove duplicate constants/helpers from `live-anchor.cjs`. Keep its current output shape for compatibility, but add executable SHA/session identity and the six validation summaries. Update `live-table-snapshot.cjs` to use opt-in reads and reject a mismatched PID or executable hash. + +- [ ] **Step 5: Add syntax checks and run tests** + +Extend `npm run check` for all three board-verification scripts. + +Run: + +```powershell +node --test tests/board-reanchor.test.cjs +npm run check +npm test +``` + +Expected: the focused fixtures and full Node suite pass. + +- [ ] **Step 6: Commit the reusable anchor layer** + +```powershell +git add -- scripts/board-verification/reanchor-lib.cjs scripts/board-verification/live-anchor.cjs scripts/board-verification/live-table-snapshot.cjs tests/board-reanchor.test.cjs package.json +git commit -m "refactor: make board anchors reusable and strict" +``` + +--- + +### Task 5: Implement evidence storage, PE validation, and capture analysis + +**Files:** + +- Create: `scripts/board-verification/reanchor-evidence.cjs` +- Create: `tests/board-reanchor-evidence.test.cjs` +- Modify: `package.json` + +- [ ] **Step 1: Write failing analyzer tests with sanitized fixtures** + +Test: + +- output root is exactly `.frtk/board-reanchor//`; +- atomic JSON writes use a temporary sibling then rename; +- evidence from another PID, host session token, or executable SHA is rejected; +- PE parsing classifies `.text` as executable and `.rdata` as readable/non-executable; +- module addresses convert to RVAs only inside the image; +- common stack-return candidates are ranked across two captures; +- wrapper/controller object shapes derive the two stable vtable RVAs; +- a low-level routine with wrong argument shapes is rejected; +- candidate output lists every gate with an explicit boolean and overall pass only when all pass. + +- [ ] **Step 2: Confirm the focused test fails** + +Run: + +```powershell +node --test tests/board-reanchor-evidence.test.cjs +``` + +Expected: missing module failure. + +- [ ] **Step 3: Implement deterministic local evidence helpers** + +Export `evidenceDirectory`, `writeEvidence`, `readEvidence`, `parsePeSections`, `classifyModuleAddress`, `rankRoutineCandidates`, `validateObjectShapes`, `deriveVtableRvas`, and `buildCandidateArtifact`. + +PE checks must require: + +- routine candidates inside an executable main-module section; +- vtables inside readable main-module image memory; +- sampled vtable entries inside executable main-module sections; +- all accepted addresses at or above module base and below `moduleBase + SizeOfImage`. + +- [ ] **Step 4: Define the candidate schema in code** + +Use schema version 1 and include: + +```js +{ + schemaVersion: 1, + build: { label, executableSize, executableSha256 }, + session: { pid, sessionId, moduleBase, capturedAt }, + tables: { + '4168': { passed, candidateCount, score, rereadPassed }, + '4176': { passed, candidateCount, score, rereadPassed }, + '4190': { passed, candidateCount, score, rereadPassed }, + '4251': { passed, candidateCount, score, rereadPassed }, + '5790': { passed, candidateCount, score, rereadPassed }, + '5847': { passed, candidateCount, score, rereadPassed }, + }, + captures: { + add: { writeCount, executeCount, consistent }, + remove: { writeCount, executeCount, consistent }, + }, + proposedBoard: { + genericRecordWrapperVtableRva, + recruitingControllerVtableRva, + fullAddRva, + fullRemoveRva, + }, + gates: [{ name, passed, detail }], + passed: true, +} +``` + +Hex RVAs in JSON are uppercase canonical strings. The artifact may contain local raw addresses, but generated committed material must use only the four RVAs and sanitized gate results. + +- [ ] **Step 5: Run focused and full tests** + +```powershell +node --test tests/board-reanchor-evidence.test.cjs +npm run check +npm test +``` + +- [ ] **Step 6: Commit evidence analysis** + +```powershell +git add -- scripts/board-verification/reanchor-evidence.cjs tests/board-reanchor-evidence.test.cjs package.json +git commit -m "feat: analyze board re-anchor evidence" +``` + +--- + +### Task 6: Build the guided Patch 1 re-anchor CLI + +**Files:** + +- Create: `scripts/board-verification/reanchor-build.cjs` +- Create: `tests/board-reanchor-cli.test.cjs` +- Modify: `package.json` +- Modify: `docs/development/building.md` + +- [ ] **Step 1: Write failing command-level tests with a fake SDK client** + +Cover `preflight`, `validate`, `capture-add-write`, `capture-add-execute`, `capture-remove-write`, `capture-remove-execute`, `transition-check`, `analyze`, and `status`. Require each capture command to reject missing prior phases, wrong PID/session, non-diagnostic host state, anticheat, stale table anchors, or an unverified save backup. + +- [ ] **Step 2: Confirm the test fails** + +Run: + +```powershell +node --test tests/board-reanchor-cli.test.cjs +``` + +Expected: missing CLI/module failure. + +- [ ] **Step 3: Implement strict CLI argument parsing and preflight** + +Required common arguments are `--game-dir`, `--save`, and optional `--output-root` defaulting to `.frtk/board-reanchor`. Preflight must: + +1. hash `\CollegeFB27.exe`; +2. require the exact diagnostic manifest entry; +3. compare the discovered process executable path/size/hash to disk; +4. require hello ready with `supportedBuild:false` and `writesAllowed:false`; +5. reject a real EA/Javelin anticheat process; +6. create and SHA-256-verify `save-backup\` inside the evidence directory; +7. record PID plus a session identifier derived from PID, process creation time, and host-start log/status evidence. + +Preflight must never arm a watch. + +- [ ] **Step 4: Implement table validation and before/after snapshots** + +`validate` discovers all six tables through `reanchor-lib.cjs`, rereads them, identifies exactly one user board, and writes `tables.json`. Every later capture revalidates the table headers and current board before arming watches, then records a post-action snapshot and validates the vanilla postcondition. + +- [ ] **Step 5: Implement write-watch capture commands** + +For add, arm at most four write watches over the 4168 freelist head and first free 5847 membership slot. For remove, arm the selected membership slot and the 4168/5790 freelist heads. Each command prints one explicit operator instruction, waits for Enter only after the user completes the vanilla UI action, calls `cfb.watch_hits(true)`, and serializes hits through `cfb.log` with a unique session/capture prefix. Fetch and parse only matching log records. + +Two independent write captures are required for each operation. Use different recruits, and store `add-write-1.json`, `add-write-2.json`, `remove-write-1.json`, and `remove-write-2.json`. + +- [ ] **Step 6: Implement execute-watch confirmation** + +`analyze --stage rank` intersects main-module stack return addresses across the two write captures and emits a bounded ranked list. `capture-*-execute` arms execute watches for at most four ranked executable addresses, prompts one more vanilla action, and requires a hit whose Windows x64 entry arguments match: + +- `RCX`: recruiting controller object; +- `RDX`: pointer cell containing the Team wrapper; +- `R8`: pointer cell containing the Recruit wrapper. + +Validate controller descriptor table `5003`, Recruit descriptor table `4269`, Team descriptor table `6334`, row identities, controller board store at `+0x138`, and readable wrapper fields at `+0x10/+0x18`. Reject inner allocation/table routines that do not receive this shape. + +- [ ] **Step 7: Implement transition and final analysis gates** + +`transition-check` asks the user to leave and re-enter Recruiting, rediscovers objects, and requires changed-or-still-valid object addresses but identical two vtable RVAs. Final `analyze` requires all six tables, two consistent write captures per operation, one argument-shaped execute capture per operation, stable vtables, executable routine sections, and transition stability before writing `candidate.json`. + +- [ ] **Step 8: Document the exact future-update procedure** + +Add the command sequence and explain that the candidate is local evidence only. Include the hard stop between backup creation and user-approved vanilla UI actions. + +- [ ] **Step 9: Run tests and syntax checks** + +```powershell +node --test tests/board-reanchor-cli.test.cjs +npm run check +npm test +``` + +- [ ] **Step 10: Commit the guided workflow** + +```powershell +git add -- scripts/board-verification/reanchor-build.cjs tests/board-reanchor-cli.test.cjs package.json docs/development/building.md +git commit -m "feat: guide vanilla board re-anchoring" +``` + +--- + +### Task 7: Add explicit, reviewable promotion and demotion + +**Files:** + +- Create: `scripts/promote-game-build.cjs` +- Modify: `scripts/game-build-manifest.cjs` +- Modify: `tests/game-build-manifest.test.cjs` +- Modify: `package.json` + +- [ ] **Step 1: Write failing promotion tests** + +Test that promotion rejects a failed artifact, wrong SHA/size, unknown build, noncanonical/zero RVAs, missing gates, raw RVAs outside valid PE sections, and attempts to overwrite another build. Test that successful promotion changes only Patch 1 to `certified`, installs exactly the four proposed RVAs, and regenerates the header. Test demotion sets support to `diagnostic`, removes the board layout, and regenerates the header. + +- [ ] **Step 2: Confirm the tests fail** + +```powershell +node --test tests/game-build-manifest.test.cjs +``` + +Expected: missing promotion exports/CLI. + +- [ ] **Step 3: Implement promotion as an explicit source edit** + +Support only these modes: + +```powershell +node scripts/promote-game-build.cjs --candidate --certify +node scripts/promote-game-build.cjs --sha --diagnostic +``` + +`--certify` rereads the candidate, requires `passed:true` and all gates true, matches the existing diagnostic identity exactly, rewrites `game_builds.json` atomically, and regenerates the header. `--diagnostic` removes the board layout. Neither command changes `.frtk` evidence or invokes Git. + +- [ ] **Step 4: Make tests and generator check pass** + +```powershell +node --test tests/game-build-manifest.test.cjs +node scripts/generate-game-builds.cjs --check +npm run check +npm test +``` + +- [ ] **Step 5: Commit promotion tooling** + +```powershell +git add -- scripts/promote-game-build.cjs scripts/game-build-manifest.cjs tests/game-build-manifest.test.cjs package.json +git commit -m "feat: require evidence for build promotion" +``` + +--- + +### Task 8: Build and install the diagnostic Patch 1 host + +**Files:** + +- Runtime output only: `native/build-patch1/Release/**` +- Runtime installation only: `F:\EA SPORTS College Football 27\**` +- Runtime installation only: `C:\Users\Eric Levinson\Downloads\MMC_Modding_Tools_v1.1.0.1\MMC_ModManager_v1.1.0.1\**` + +- [ ] **Step 1: Run the complete pre-install automated gate** + +```powershell +npm ci +npm run check +npm test +cmake -S native -B native/build-patch1 -A x64 +cmake --build native/build-patch1 --config Release +``` + +Run every native smoke from `docs/development/building.md`, including `cfb27_game_builds_smoke.exe`. Only wrap `cfb27_protocol_smoke.exe` with `CFB27_SMOKE_ALLOW_WRITES=1`, and remove the variable in `finally`. + +Expected: all Node tests and native smokes pass; no source or generated-header drift. + +- [ ] **Step 2: Close the game and MMC, then verify both are absent** + +Use read-only process checks. Do not install while `CollegeFB27.exe` or MMC is running. + +- [ ] **Step 3: Install through the supported CLI** + +```powershell +node packages/cli/bin/cfb27lua.cjs install ` + --game-dir "F:\EA SPORTS College Football 27" ` + --mmc-dir "C:\Users\Eric Levinson\Downloads\MMC_Modding_Tools_v1.1.0.1\MMC_ModManager_v1.1.0.1" ` + --artifacts-dir "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27-codex\saves\cfb27-lua-hook-restructure\native\build-patch1\Release" +``` + +Require the installer to recognize stock MMC `CryptBase.dll` size `95744` and SHA-256 `3E87682118E593F334BA665826E2A6AB85BA460F2E1FE95B173A7199863AD454`, preserve/verify both originals, and install matching host/proxy hashes in both locations. + +- [ ] **Step 4: Run doctor and record the installed hashes locally** + +```powershell +node packages/cli/bin/cfb27lua.cjs doctor ` + --game-dir "F:\EA SPORTS College Football 27" ` + --mmc-dir "C:\Users\Eric Levinson\Downloads\MMC_Modding_Tools_v1.1.0.1\MMC_ModManager_v1.1.0.1" +``` + +Expected while the game is closed: installation healthy; no claim that Patch 1 is certified. + +- [ ] **Step 5: Launch MMC and the game offline to the Dynasty hub** + +After the host connects, require exact Patch 1 identity, `supportedBuild:false`, `writesAllowed:false`, and `researchWatch` capability. If either public flag is true at this stage, close both applications and treat it as a release-blocking defect. + +--- + +### Task 9: Capture Patch 1 vanilla evidence and generate the candidate + +**Files:** + +- Ignored local evidence only: `.frtk/board-reanchor/A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD/**` +- Read-only source save plus verified local backup: `C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE` + +- [ ] **Step 1: Run preflight and create the verified backup** + +```powershell +node scripts/board-verification/reanchor-build.cjs preflight ` + --game-dir "F:\EA SPORTS College Football 27" ` + --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +``` + +Expected: exact diagnostic build, matching process/disk identity, offline host, no anticheat, and byte-identical backup copy. If the user selects a different disposable dynasty, rerun preflight with that exact path and do not mix evidence sets. + +- [ ] **Step 2: Pause for explicit user confirmation before vanilla changes** + +Report the save path, original SHA-256, backup path/SHA-256, PID, and session ID. Do not proceed until the user confirms that this dynasty may be changed through the normal game UI. + +- [ ] **Step 3: Validate all six live tables** + +```powershell +node scripts/board-verification/reanchor-build.cjs validate ` + --game-dir "F:\EA SPORTS College Football 27" ` + --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +``` + +Expected: one unambiguous validated candidate each for 4168, 4176, 4190, 4251, 5790, and 5847, plus exactly one user board. A tie or changed reread ends the session. + +- [ ] **Step 4: Capture two vanilla adds and confirm the full entry** + +Run the following sequence, selecting a different off-board recruit for the second write capture and another valid recruit for execute confirmation: + +```powershell +node scripts/board-verification/reanchor-build.cjs capture-add-write --capture 1 --game-dir "F:\EA SPORTS College Football 27" --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +node scripts/board-verification/reanchor-build.cjs capture-add-write --capture 2 --game-dir "F:\EA SPORTS College Football 27" --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +node scripts/board-verification/reanchor-build.cjs analyze --stage rank --operation add --game-dir "F:\EA SPORTS College Football 27" --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +node scripts/board-verification/reanchor-build.cjs capture-add-execute --game-dir "F:\EA SPORTS College Football 27" --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +``` + +Follow only the script's printed prompt; do not call `addBoard` while diagnostic. + +- [ ] **Step 5: Capture two vanilla removes and confirm the full entry** + +Choose on-board recruits with no visit, pitch, or assigned action that would make the trace ambiguous, then run: + +```powershell +node scripts/board-verification/reanchor-build.cjs capture-remove-write --capture 1 --game-dir "F:\EA SPORTS College Football 27" --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +node scripts/board-verification/reanchor-build.cjs capture-remove-write --capture 2 --game-dir "F:\EA SPORTS College Football 27" --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +node scripts/board-verification/reanchor-build.cjs analyze --stage rank --operation remove --game-dir "F:\EA SPORTS College Football 27" --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +node scripts/board-verification/reanchor-build.cjs capture-remove-execute --game-dir "F:\EA SPORTS College Football 27" --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +``` + +Require both freelist returns, cleared references, compact membership, and the full three-argument object shape. + +- [ ] **Step 6: Validate vtables across a screen transition** + +```powershell +node scripts/board-verification/reanchor-build.cjs transition-check ` + --game-dir "F:\EA SPORTS College Football 27" ` + --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +``` + +Leave Recruiting, return to it when prompted, and require the same wrapper/controller vtable RVAs from structurally valid new/current objects. + +- [ ] **Step 7: Generate and manually review `candidate.json`** + +```powershell +node scripts/board-verification/reanchor-build.cjs analyze ` + --game-dir "F:\EA SPORTS College Football 27" ` + --save "C:\Users\Eric Levinson\Documents\EA SPORTS College Football 27\saves\DYNASTY-JUL14-10h09m30-AUTOSAVE" +``` + +Review: + +`.frtk\board-reanchor\A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD\candidate.json` + +Require every gate true. Do not promote if the candidate selects a low-level allocation/table routine, contains a section mismatch, or does not show consistent entry arguments. + +--- + +### Task 10: Promote Patch 1 only into a local acceptance build + +**Files:** + +- Modify: `native/host/game_builds.json` +- Modify: `native/host/game_builds.generated.h` +- Create: `docs/research/patch1-build-reanchor.md` + +- [ ] **Step 1: Promote the reviewed candidate** + +```powershell +node scripts/promote-game-build.cjs ` + --candidate ".frtk\board-reanchor\A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD\candidate.json" ` + --certify +node scripts/generate-game-builds.cjs --check +``` + +Inspect the diff. It must change only the Patch 1 support state, its four board RVAs, and the deterministic generated header. + +- [ ] **Step 2: Write a sanitized research record** + +Record Patch 1 size/hash, four promoted RVAs, capture counts, table IDs, pass/fail gates, backup hash verification, and dates. Do not include process addresses, raw qwords, save contents, or `.frtk` paths beyond naming the ignored evidence convention. + +- [ ] **Step 3: Rebuild and rerun all automated gates** + +```powershell +npm run check +npm test +cmake --build native/build-patch1 --config Release --clean-first +``` + +Run every native smoke from the updated building guide. Expected: registry smoke now expects Patch 1 certified with exactly the candidate layout. + +- [ ] **Step 4: Reinstall the exact acceptance build with both apps closed** + +Repeat Task 8's supported CLI install command and doctor verification. Relaunch offline and require `supportedBuild:true`, `writesAllowed:true`, healthy ticks, and no session lockout. + +- [ ] **Step 5: Commit the local promotion only after automated gates pass** + +```powershell +git add -- native/host/game_builds.json native/host/game_builds.generated.h docs/research/patch1-build-reanchor.md +git commit -m "feat: certify patch 1 board layout" +``` + +This commit is still not releasable until Task 11 passes. + +--- + +### Task 11: Execute the guarded live acceptance gate + +**Files:** + +- Modify: `docs/research/patch1-build-reanchor.md` + +- [ ] **Step 1: Revalidate the backup and live tables** + +Recompute the backup SHA-256 and compare it with preflight. Re-anchor all six tables in the current PID/session before any mutation. + +- [ ] **Step 2: Exercise both no-op paths** + +Call guarded add for an already-present recruit and require `UNCHANGED` with no native invocation. Call guarded remove for an already-absent recruit and require the same. Confirm table snapshots and freelist heads are unchanged. + +- [ ] **Step 3: Exercise one real guarded add** + +On the approved disposable dynasty, add one absent recruit through the board mutation API. Require exactly: + +- one 4168 freelist allocation; +- one 5790 freelist allocation; +- one compact 5847 membership append; +- expected recruit/team references; +- healthy host status with writes still eligible. + +- [ ] **Step 4: Exercise one real guarded remove** + +Remove that same recruit. Require both allocated rows returned to their freelists, recruit/pitch references cleared, membership compacted, original board count restored, and no session lockout. + +- [ ] **Step 5: Verify UI, autosave, and reload** + +Leave and re-enter Recruiting, verify the rendered board, allow the game's normal autosave, return to the Dynasty hub, reload the dynasty, and revalidate the final board/table state. Do not edit the backup. + +- [ ] **Step 6: Record sanitized results** + +Update `docs/research/patch1-build-reanchor.md` with commands, hashes, counts, state transitions, and pass/fail results. Exclude addresses and raw memory. + +- [ ] **Step 7: Handle any failure by demoting immediately** + +If a native fault, postcondition mismatch, table ambiguity, UI failure, save/reload mismatch, or host lockout occurs: + +```powershell +node scripts/promote-game-build.cjs ` + --sha A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD ` + --diagnostic +node scripts/generate-game-builds.cjs --check +``` + +Rebuild before any later live session. Reload the disposable dynasty or restore a copy from the verified backup; never overwrite the backup itself. Record the failed gate and do not continue toward release. + +- [ ] **Step 8: Commit successful live evidence** + +Only if every live gate passes: + +```powershell +git add -- docs/research/patch1-build-reanchor.md +git commit -m "docs: verify patch 1 board mutation live" +``` + +--- + +### Task 12: Final verification and future-update handoff + +**Files:** + +- Modify: `docs/development/release-checklist.md` +- Modify: `docs/getting-started.md` +- Modify: `README.md` + +- [ ] **Step 1: Add a fresh Patch 1 release ledger** + +Do not rewrite the historical `0.2.0-dev.2` checks as though they apply to Patch 1. Add a new dated section or a separate copied ledger with every automated, installer, diagnostic-capture, promotion, live mutation, backup, and cleanup gate reset and then checked from actual evidence. + +- [ ] **Step 2: Document the next-update fast path** + +The public developer docs must give this concise sequence: + +1. hash the new executable and add it to `game_builds.json` as diagnostic; +2. regenerate, test, build, and install; +3. run preflight plus six-table validation; +4. capture two vanilla add/remove write traces and one execute confirmation each; +5. validate vtables across a screen transition; +6. review `candidate.json`; +7. promote into source and rebuild; +8. pass the complete guarded live gate; +9. demote on any failure. + +- [ ] **Step 3: Run the final clean gate** + +```powershell +npm ci +npm run check +npm test +cmake -S native -B native/build-patch1-final -A x64 +cmake --build native/build-patch1-final --config Release +``` + +Run every native smoke from `docs/development/building.md`, with only the protocol smoke receiving the temporary smoke override. + +- [ ] **Step 4: Verify packaging excludes all evidence** + +```powershell +$env:CFB27_NATIVE_ARTIFACTS = (Resolve-Path native/build-patch1-final/Release).Path +npm run pack:preview +git diff --check +``` + +Inspect the staged archive and npm tarballs. Require no `.frtk`, `board-reanchor`, save, raw address, memory dump, game binary, build intermediate, or MMC backup content. The package must contain the compiled host, not `candidate.json`. + +- [ ] **Step 5: Close both applications and verify uninstall recovery** + +After live work, close MMC and CFB27, use the supported uninstall command, and verify both restored stock proxies match SHA-256 `3E87682118E593F334BA665826E2A6AB85BA460F2E1FE95B173A7199863AD454`. Reinstall only if the user wants the repaired hook left active. + +- [ ] **Step 6: Commit the handoff documentation** + +```powershell +git add -- docs/development/release-checklist.md docs/getting-started.md README.md +git commit -m "docs: document repeatable game update recovery" +``` + +- [ ] **Step 7: Final integrity review** + +```powershell +git status --short +git log --oneline --decorate -12 +rg -n "candidate\.json|board-reanchor" native packages scripts docs README.md +``` + +Expected: clean worktree; all planned functions are implemented; `.frtk` references are documentation/tooling only; no production host path loads a candidate artifact; all commits are scoped and reviewable. From 0566ddc838da3b05c25736a5fee9eeb4090a42d4 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 20:44:32 -0500 Subject: [PATCH 03/16] feat: add compiled game build manifest --- native/host/game_builds.generated.h | 12 ++ native/host/game_builds.json | 24 ++++ package.json | 2 +- scripts/game-build-manifest.cjs | 164 +++++++++++++++++++++++++ scripts/generate-game-builds.cjs | 29 +++++ tests/game-build-manifest.test.cjs | 180 ++++++++++++++++++++++++++++ 6 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 native/host/game_builds.generated.h create mode 100644 native/host/game_builds.json create mode 100644 scripts/game-build-manifest.cjs create mode 100644 scripts/generate-game-builds.cjs create mode 100644 tests/game-build-manifest.test.cjs diff --git a/native/host/game_builds.generated.h b/native/host/game_builds.generated.h new file mode 100644 index 0000000..3823fa5 --- /dev/null +++ b/native/host/game_builds.generated.h @@ -0,0 +1,12 @@ +// Generated by scripts/generate-game-builds.cjs. Do not edit. +#pragma once + +inline constexpr std::array kGeneratedBuilds{{ + {"july-11-2026", 247845776ULL, + "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8", + Support::kCertified, + BoardLayout{0xB093F68ULL, 0xB0B5BA8ULL, 0x8109060ULL, 0x8166090ULL}}, + {"patch-1-2026-07-16", 249801616ULL, + "A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD", + Support::kDiagnostic, std::nullopt}, +}}; diff --git a/native/host/game_builds.json b/native/host/game_builds.json new file mode 100644 index 0000000..2df736b --- /dev/null +++ b/native/host/game_builds.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "builds": [ + { + "label": "july-11-2026", + "size": 247845776, + "sha256": "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8", + "support": "certified", + "board": { + "genericRecordWrapperVtableRva": "0xB093F68", + "recruitingControllerVtableRva": "0xB0B5BA8", + "fullAddRva": "0x8109060", + "fullRemoveRva": "0x8166090" + } + }, + { + "label": "patch-1-2026-07-16", + "size": 249801616, + "sha256": "A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD", + "support": "diagnostic", + "board": null + } + ] +} diff --git a/package.json b/package.json index 768508f..a07efec 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "packages/cli" ], "scripts": { - "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs", + "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs && node --check scripts/game-build-manifest.cjs && node --check scripts/generate-game-builds.cjs && node scripts/generate-game-builds.cjs --check", "test": "node scripts/run-tests.cjs", "build:frtk-profile": "node scripts/build-frtk-profile.cjs", "pack:preview": "node scripts/package-release.cjs" diff --git a/scripts/game-build-manifest.cjs b/scripts/game-build-manifest.cjs new file mode 100644 index 0000000..7c337fd --- /dev/null +++ b/scripts/game-build-manifest.cjs @@ -0,0 +1,164 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const BUILD_KEYS = ['label', 'size', 'sha256', 'support', 'board']; +const BOARD_KEYS = [ + 'genericRecordWrapperVtableRva', + 'recruitingControllerVtableRva', + 'fullAddRva', + 'fullRemoveRva', +]; +const MAX_UINT64 = 0xFFFFFFFFFFFFFFFFn; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function requireExactKeys(value, expected, context) { + if (!isObject(value)) { + throw new TypeError(`${context} must be an object`); + } + + const unknown = Object.keys(value).filter((key) => !expected.includes(key)); + if (unknown.length > 0) { + throw new TypeError(`${context} has unknown key ${unknown[0]}`); + } + + const missing = expected.filter((key) => !Object.hasOwn(value, key)); + if (missing.length > 0) { + throw new TypeError(`${context} is missing ${missing[0]}`); + } +} + +function parseRva(value, key, context) { + if (typeof value !== 'string' || !/^0x[0-9A-Fa-f]+$/.test(value)) { + throw new TypeError(`${context}.${key} must be a hexadecimal string with a 0x prefix`); + } + + const parsed = BigInt(value); + if (parsed === 0n) { + throw new RangeError(`${context}.${key} must be nonzero`); + } + if (parsed > MAX_UINT64) { + throw new RangeError(`${context}.${key} must fit in an unsigned 64-bit integer`); + } + return parsed; +} + +function parseBuild(raw, index) { + const context = `builds[${index}]`; + requireExactKeys(raw, BUILD_KEYS, context); + + if (typeof raw.label !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(raw.label)) { + throw new TypeError(`${context}.label must be a nonempty lowercase kebab-case string`); + } + if (!Number.isSafeInteger(raw.size) || raw.size <= 0) { + throw new TypeError(`${context}.size must be a positive safe integer`); + } + if (typeof raw.sha256 !== 'string' || !/^[0-9A-F]{64}$/.test(raw.sha256)) { + throw new TypeError(`${context}.sha256 must be uppercase 64-character hexadecimal`); + } + if (raw.support !== 'diagnostic' && raw.support !== 'certified') { + throw new TypeError(`${context}.support must be diagnostic or certified`); + } + + if (raw.support === 'diagnostic') { + if (raw.board !== null) { + throw new TypeError(`${context} diagnostic build cannot carry a board layout`); + } + return { ...raw }; + } + + requireExactKeys(raw.board, BOARD_KEYS, `${context}.board`); + const board = Object.fromEntries(BOARD_KEYS.map((key) => [ + key, + parseRva(raw.board[key], key, `${context}.board`), + ])); + return { ...raw, board }; +} + +function parseManifest(raw) { + requireExactKeys(raw, ['version', 'builds'], 'manifest'); + if (raw.version !== 1) { + throw new TypeError('manifest.version must be 1'); + } + if (!Array.isArray(raw.builds) || raw.builds.length === 0) { + throw new TypeError('manifest.builds must be a nonempty array'); + } + + const builds = raw.builds.map(parseBuild); + const sizes = new Set(); + const hashes = new Set(); + for (const build of builds) { + if (sizes.has(build.size)) { + throw new TypeError(`duplicate executable size ${build.size}`); + } + if (hashes.has(build.sha256)) { + throw new TypeError(`duplicate executable sha256 ${build.sha256}`); + } + sizes.add(build.size); + hashes.add(build.sha256); + } + + return { version: 1, builds }; +} + +function cppRva(value) { + return `0x${value.toString(16).toUpperCase()}ULL`; +} + +function generateHeader(manifest) { + const lines = [ + '// Generated by scripts/generate-game-builds.cjs. Do not edit.', + '#pragma once', + '', + `inline constexpr std::array kGeneratedBuilds{{`, + ]; + + for (const build of manifest.builds) { + lines.push(` {"${build.label}", ${build.size}ULL,`); + lines.push(` "${build.sha256}",`); + if (build.support === 'certified') { + lines.push(' Support::kCertified,'); + lines.push( + ` BoardLayout{${BOARD_KEYS.map((key) => cppRva(build.board[key])).join(', ')}}},`, + ); + } else { + lines.push(' Support::kDiagnostic, std::nullopt},'); + } + } + + lines.push('}};', ''); + return lines.join('\n'); +} + +function loadManifest(manifestPath) { + return parseManifest(JSON.parse(fs.readFileSync(manifestPath, 'utf8'))); +} + +function writeGeneratedHeader({ manifestPath, headerPath, check = false }) { + const generated = generateHeader(loadManifest(manifestPath)); + if (check) { + try { + return fs.readFileSync(headerPath, 'utf8') === generated; + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + throw error; + } + } + + fs.mkdirSync(path.dirname(headerPath), { recursive: true }); + fs.writeFileSync(headerPath, generated, 'utf8'); + return true; +} + +module.exports = { + generateHeader, + loadManifest, + parseManifest, + writeGeneratedHeader, +}; diff --git a/scripts/generate-game-builds.cjs b/scripts/generate-game-builds.cjs new file mode 100644 index 0000000..ad38543 --- /dev/null +++ b/scripts/generate-game-builds.cjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +'use strict'; + +const path = require('node:path'); +const { writeGeneratedHeader } = require('./game-build-manifest.cjs'); + +const args = process.argv.slice(2); +const unknown = args.filter((argument) => argument !== '--check'); +if (unknown.length > 0 || args.filter((argument) => argument === '--check').length > 1) { + console.error('Usage: node scripts/generate-game-builds.cjs [--check]'); + process.exitCode = 1; +} else { + const check = args.includes('--check'); + const root = path.resolve(__dirname, '..'); + const manifestPath = path.join(root, 'native', 'host', 'game_builds.json'); + const headerPath = path.join(root, 'native', 'host', 'game_builds.generated.h'); + const current = writeGeneratedHeader({ manifestPath, headerPath, check }); + + if (!current) { + console.error( + 'native/host/game_builds.generated.h is stale; run node scripts/generate-game-builds.cjs', + ); + process.exitCode = 1; + } else if (check) { + console.log('native/host/game_builds.generated.h is current'); + } else { + console.log('generated native/host/game_builds.generated.h'); + } +} diff --git a/tests/game-build-manifest.test.cjs b/tests/game-build-manifest.test.cjs new file mode 100644 index 0000000..e31ce95 --- /dev/null +++ b/tests/game-build-manifest.test.cjs @@ -0,0 +1,180 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + generateHeader, + loadManifest, + parseManifest, + writeGeneratedHeader, +} = require('../scripts/game-build-manifest.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const MANIFEST = path.join(ROOT, 'native', 'host', 'game_builds.json'); +const HEADER = path.join(ROOT, 'native', 'host', 'game_builds.generated.h'); +const JULY_11_SHA = + '9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8'; +const PATCH1_SHA = + 'A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD'; + +function diagnosticBuild(overrides = {}) { + return { + label: 'patch-1-2026-07-16', + size: 249801616, + sha256: PATCH1_SHA, + support: 'diagnostic', + board: null, + ...overrides, + }; +} + +function certifiedBuild(overrides = {}) { + return { + label: 'july-11-2026', + size: 247845776, + sha256: JULY_11_SHA, + support: 'certified', + board: { + genericRecordWrapperVtableRva: '0xB093F68', + recruitingControllerVtableRva: '0xB0B5BA8', + fullAddRva: '0x8109060', + fullRemoveRva: '0x8166090', + }, + ...overrides, + }; +} + +function manifest(builds) { + return { version: 1, builds }; +} + +test('SHA-256 values must already be normalized uppercase hexadecimal', () => { + for (const sha256 of [JULY_11_SHA.toLowerCase(), `0x${JULY_11_SHA}`, 'A'.repeat(63)]) { + assert.throws( + () => parseManifest(manifest([certifiedBuild({ sha256 })])), + /sha256.*uppercase.*64/i, + ); + } +}); + +test('duplicate executable sizes and hashes are rejected', () => { + assert.throws( + () => parseManifest(manifest([ + certifiedBuild(), + diagnosticBuild({ size: 247845776 }), + ])), + /duplicate.*size/i, + ); + assert.throws( + () => parseManifest(manifest([ + certifiedBuild(), + diagnosticBuild({ sha256: JULY_11_SHA }), + ])), + /duplicate.*sha256/i, + ); +}); + +test('diagnostic builds cannot carry a board layout', () => { + assert.throws(() => parseManifest(manifest([diagnosticBuild({ + board: { genericRecordWrapperVtableRva: '0x1' }, + })])), /diagnostic.*board/i); +}); + +test('certified builds require all four nonzero RVAs', () => { + const board = certifiedBuild().board; + for (const key of Object.keys(board)) { + const missing = { ...board }; + delete missing[key]; + assert.throws( + () => parseManifest(manifest([certifiedBuild({ board: missing })])), + new RegExp(key, 'i'), + ); + assert.throws( + () => parseManifest(manifest([certifiedBuild({ + board: { ...board, [key]: '0x0' }, + })])), + new RegExp(`${key}.*nonzero`, 'i'), + ); + } +}); + +test('unknown manifest, build, and board keys are rejected', () => { + assert.throws( + () => parseManifest({ ...manifest([]), extra: true }), + /unknown.*extra/i, + ); + assert.throws( + () => parseManifest(manifest([diagnosticBuild({ extra: true })])), + /unknown.*extra/i, + ); + assert.throws( + () => parseManifest(manifest([certifiedBuild({ + board: { ...certifiedBuild().board, extra: '0x1' }, + })])), + /unknown.*extra/i, + ); +}); + +test('RVA strings are parsed to BigInt and emitted canonically', () => { + const build = certifiedBuild({ + board: { + ...certifiedBuild().board, + fullAddRva: '0x0008109060', + fullRemoveRva: '0x816609a', + }, + }); + const parsed = parseManifest(manifest([build])); + + assert.equal(parsed.builds[0].board.fullAddRva, 0x8109060n); + assert.equal(parsed.builds[0].board.fullRemoveRva, 0x816609An); + assert.match(generateHeader(parsed), /0x8109060ULL, 0x816609AULL/); +}); + +test('generated entries preserve manifest order', () => { + const parsed = parseManifest(manifest([diagnosticBuild(), certifiedBuild()])); + const header = generateHeader(parsed); + + assert.ok(header.indexOf('patch-1-2026-07-16') < header.indexOf('july-11-2026')); + assert.match(header, /std::array/); + assert.doesNotMatch(header, /fstream|filesystem|readFile|game_builds\.json/i); +}); + +test('loadManifest reads and parses a JSON manifest', () => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'cfb27-builds-')); + const manifestPath = path.join(temporaryDirectory, 'game_builds.json'); + try { + fs.writeFileSync(manifestPath, JSON.stringify(manifest([diagnosticBuild()])), 'utf8'); + assert.deepEqual(loadManifest(manifestPath), parseManifest(manifest([diagnosticBuild()]))); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test('writeGeneratedHeader writes deterministically and check mode never changes files', () => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'cfb27-builds-')); + const manifestPath = path.join(temporaryDirectory, 'game_builds.json'); + const headerPath = path.join(temporaryDirectory, 'game_builds.generated.h'); + try { + fs.writeFileSync(manifestPath, JSON.stringify(manifest([diagnosticBuild()])), 'utf8'); + + assert.equal(writeGeneratedHeader({ manifestPath, headerPath, check: false }), true); + const generated = fs.readFileSync(headerPath, 'utf8'); + assert.equal(writeGeneratedHeader({ manifestPath, headerPath, check: true }), true); + assert.equal(fs.readFileSync(headerPath, 'utf8'), generated); + + fs.writeFileSync(headerPath, 'stale\n', 'utf8'); + assert.equal(writeGeneratedHeader({ manifestPath, headerPath, check: true }), false); + assert.equal(fs.readFileSync(headerPath, 'utf8'), 'stale\n'); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +}); + +test('the checked-in generated header is current', () => { + const parsed = parseManifest(JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))); + assert.equal(fs.readFileSync(HEADER, 'utf8'), generateHeader(parsed)); +}); From a53e4b83e48387d2bf039d94a6298590b1080b53 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 20:51:49 -0500 Subject: [PATCH 04/16] refactor: route board offsets through build registry --- native/CMakeLists.txt | 8 ++++++ native/host/board_mutation.cpp | 19 ++++++-------- native/host/board_mutation.h | 6 +++-- native/host/game_builds.cpp | 28 ++++++++++++++++++++ native/host/game_builds.h | 32 +++++++++++++++++++++++ native/smoke/board_mutation_smoke.cpp | 6 +++-- native/smoke/game_builds_smoke.cpp | 37 +++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 native/host/game_builds.cpp create mode 100644 native/host/game_builds.h create mode 100644 native/smoke/game_builds_smoke.cpp diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 2d1c21e..c11721c 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -45,6 +45,7 @@ set_target_properties(lua54 PROPERTIES POSITION_INDEPENDENT_CODE ON) add_library(cfb27_lua_host SHARED host/board_mutation.cpp + host/game_builds.cpp host/frtk_catalog.cpp host/frtk_discovery.cpp host/frtk_field_schema.cpp @@ -115,6 +116,13 @@ 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_game_builds_smoke + smoke/game_builds_smoke.cpp + host/game_builds.cpp +) +target_compile_features(cfb27_game_builds_smoke PRIVATE cxx_std_20) +target_link_options(cfb27_game_builds_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 index df8334c..4b8903e 100644 --- a/native/host/board_mutation.cpp +++ b/native/host/board_mutation.cpp @@ -16,10 +16,6 @@ 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; @@ -242,13 +238,14 @@ std::uint32_t DescriptorTableId(std::uintptr_t descriptor) { return static_cast(encoded >> 32); } -void FindRuntimeObjects(const std::vector& regions, std::uintptr_t module, +void FindRuntimeObjects(const game_builds::BoardLayout& layout, + 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_vtable = module + layout.generic_record_wrapper_vtable_rva; + const auto controller_vtable = module + layout.recruiting_controller_vtable_rva; const auto wrapper_bytes = QwordBytes(wrapper_vtable); const auto controller_bytes = QwordBytes(controller_vtable); for (const auto& region : regions) { @@ -329,8 +326,8 @@ Result BaseResult(Operation operation, std::uint32_t recruit_row, } // namespace -Result Invoke(Operation operation, std::uint32_t recruit_row, - std::uint32_t team_row) { +Result Invoke(const game_builds::BoardLayout& layout, 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; @@ -345,7 +342,7 @@ Result Invoke(Operation operation, std::uint32_t recruit_row, std::vector controllers; std::vector recruit_wrappers; std::vector team_wrappers; - FindRuntimeObjects(regions, module, recruit_row, team_row, controllers, + FindRuntimeObjects(layout, regions, module, recruit_row, team_row, controllers, recruit_wrappers, team_wrappers); if (controllers.empty() || recruit_wrappers.empty() || team_wrappers.empty()) { result.status = Status::kRecruitingNotLoaded; @@ -421,7 +418,7 @@ Result Invoke(Operation operation, std::uint32_t recruit_row, controllers[0], reinterpret_cast(&team_cell), reinterpret_cast(&recruit_cell)}; const auto target = module + - (operation == Operation::kAdd ? kFullAddRva : kFullRemoveRva); + (operation == Operation::kAdd ? layout.full_add_rva : layout.full_remove_rva); const auto call = native_call::Invoke(target, arguments); result.call_value = call.value; result.fault_code = call.fault_code; diff --git a/native/host/board_mutation.h b/native/host/board_mutation.h index 83627f0..9041fd0 100644 --- a/native/host/board_mutation.h +++ b/native/host/board_mutation.h @@ -1,5 +1,7 @@ #pragma once +#include "game_builds.h" + #include namespace cfb27::board_mutation { @@ -32,8 +34,8 @@ struct Result { std::uint32_t fault_code{}; }; -Result Invoke(Operation operation, std::uint32_t recruit_row, - std::uint32_t team_row); +Result Invoke(const game_builds::BoardLayout& layout, 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/game_builds.cpp b/native/host/game_builds.cpp new file mode 100644 index 0000000..c312937 --- /dev/null +++ b/native/host/game_builds.cpp @@ -0,0 +1,28 @@ +#include "game_builds.h" + +#include + +namespace cfb27::game_builds { + +#include "game_builds.generated.h" + +const Build* FindBuild(std::uintmax_t size, std::string_view uppercase_sha256) { + for (const auto& build : kGeneratedBuilds) { + if (build.executable_size == size && + build.executable_sha256 == uppercase_sha256) { + return &build; + } + } + return nullptr; +} + +bool IsCertified(const Build* build) { + return build && build->support == Support::kCertified; +} + +bool IsDiagnosticOrCertified(const Build* build) { + return build && (build->support == Support::kDiagnostic || + build->support == Support::kCertified); +} + +} // namespace cfb27::game_builds diff --git a/native/host/game_builds.h b/native/host/game_builds.h new file mode 100644 index 0000000..05d62c5 --- /dev/null +++ b/native/host/game_builds.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +namespace cfb27::game_builds { + +enum class Support { kDiagnostic, kCertified }; + +struct BoardLayout { + std::uintptr_t generic_record_wrapper_vtable_rva{}; + std::uintptr_t recruiting_controller_vtable_rva{}; + std::uintptr_t full_add_rva{}; + std::uintptr_t full_remove_rva{}; +}; + +struct Build { + std::string_view label; + std::uintmax_t executable_size{}; + std::string_view executable_sha256; + Support support{Support::kDiagnostic}; + std::optional board; +}; + +using GeneratedBuild = Build; + +const Build* FindBuild(std::uintmax_t size, std::string_view uppercase_sha256); +bool IsCertified(const Build* build); +bool IsDiagnosticOrCertified(const Build* build); + +} // namespace cfb27::game_builds diff --git a/native/smoke/board_mutation_smoke.cpp b/native/smoke/board_mutation_smoke.cpp index 949b338..18a3eeb 100644 --- a/native/smoke/board_mutation_smoke.cpp +++ b/native/smoke/board_mutation_smoke.cpp @@ -7,10 +7,12 @@ int main() { using cfb27::board_mutation::Operation; using cfb27::board_mutation::Status; - const auto invalid = Invoke(Operation::kAdd, 0x20000, 0); + const cfb27::game_builds::BoardLayout layout{1, 2, 3, 4}; + + const auto invalid = Invoke(layout, Operation::kAdd, 0x20000, 0); if (invalid.status != Status::kInvalidArgument) return 1; - const auto unloaded = Invoke(Operation::kRemove, 1, 1); + const auto unloaded = Invoke(layout, Operation::kRemove, 1, 1); if (unloaded.status != Status::kRecruitingNotLoaded) return 2; if (std::string(cfb27::board_mutation::StatusCode(Status::kBoardFull)) != diff --git a/native/smoke/game_builds_smoke.cpp b/native/smoke/game_builds_smoke.cpp new file mode 100644 index 0000000..2125c3d --- /dev/null +++ b/native/smoke/game_builds_smoke.cpp @@ -0,0 +1,37 @@ +#include "../host/game_builds.h" + +#include + +int main() { + using cfb27::game_builds::FindBuild; + using cfb27::game_builds::IsCertified; + using cfb27::game_builds::IsDiagnosticOrCertified; + using cfb27::game_builds::Support; + + const auto* july11 = FindBuild( + 247845776ULL, + "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8"); + if (!july11 || july11->label != "july-11-2026" || + july11->support != Support::kCertified || !july11->board || + !IsCertified(july11) || !IsDiagnosticOrCertified(july11)) return 1; + + const auto* patch1 = FindBuild( + 249801616ULL, + "A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD"); + if (!patch1 || patch1->label != "patch-1-2026-07-16" || + patch1->support != Support::kDiagnostic || patch1->board || + IsCertified(patch1) || !IsDiagnosticOrCertified(patch1)) return 2; + + if (FindBuild( + 247845777ULL, + "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8")) + return 3; + if (FindBuild( + 247845776ULL, + "8E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8")) + return 4; + if (IsCertified(nullptr) || IsDiagnosticOrCertified(nullptr)) return 5; + + std::cout << "game builds smoke passed\n"; + return 0; +} From 4e72fd0dfcaaa3598c04cafe292eb0856c31757a Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 20:57:42 -0500 Subject: [PATCH 05/16] fix: pass certified board layout to mutation --- native/host/lua_host.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index 8352b06..ad6940c 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -6,6 +6,7 @@ #include "memory_transaction.h" #include "native_call.h" #include "board_mutation.h" +#include "game_builds.h" #include "frtk_catalog.h" #include "frtk_lua_api.h" #include "frtk_profile.h" @@ -1520,6 +1521,12 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { return ErrorResponse(id, "UNSUPPORTED_BUILD", "Board mutations require the supported offline game build"); } + const auto* board_build = cfb27::game_builds::FindBuild( + kSupportedExecutableSize, kSupportedExecutableSha256); + if (!cfb27::game_builds::IsCertified(board_build) || !board_build->board) { + return ErrorResponse(id, "UNSUPPORTED_BUILD", + "Board mutations require a certified board layout"); + } const auto operation = command == "addBoard" ? cfb27::board_mutation::Operation::kAdd : cfb27::board_mutation::Operation::kRemove; @@ -1527,7 +1534,7 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { { std::scoped_lock call_lock(g_host_write_mutex, g_native_call_mutex); mutation = cfb27::board_mutation::Invoke( - operation, static_cast(recruit_row64), + *board_build->board, operation, static_cast(recruit_row64), static_cast(team_row64)); } using BoardStatus = cfb27::board_mutation::Status; From c0953bee914cb67448ec421c29a84c2f06644dcf Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 21:09:03 -0500 Subject: [PATCH 06/16] feat: add diagnostic build safety state --- docs/lua-api.md | 13 ++- docs/protocol.md | 24 ++++-- docs/safety.md | 23 ++++-- native/CMakeLists.txt | 9 +++ native/host/build_policy.cpp | 19 +++++ native/host/build_policy.h | 14 ++++ native/host/lua_host.cpp | 119 ++++++++++++++++------------ native/smoke/build_policy_smoke.cpp | 46 +++++++++++ native/smoke/protocol_smoke.cpp | 20 ++++- native/smoke/startup_host_smoke.cpp | 9 ++- 10 files changed, 226 insertions(+), 70 deletions(-) create mode 100644 native/host/build_policy.cpp create mode 100644 native/host/build_policy.h create mode 100644 native/smoke/build_policy_smoke.cpp diff --git a/docs/lua-api.md b/docs/lua-api.md index 3c6cbda..2e2a475 100644 --- a/docs/lua-api.md +++ b/docs/lua-api.md @@ -55,7 +55,7 @@ local base = cfb.module_base() local byte = cfb.read_u8(base) local matches = cfb.aob_scan("4D 5A ?? ??", 8) --- Writes require the supported build, offline safety gates, an exact expected +-- Writes require a certified build, offline safety gates, an exact expected -- byte, writable committed memory, and successful readback. local changed = cfb.write_u8(address, expected, replacement) @@ -90,7 +90,8 @@ 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 +integer result. It is enabled only for an exact registry-matched, certified +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 @@ -108,8 +109,12 @@ 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. +These functions, including hit collection and clearing, are enabled only for +an exact registry-matched diagnostic or certified build while no real +anticheat is present. They are current-process research tools; always collect +and disarm before continuing normal play. Diagnostic status grants no native- +call or write authority. A `.frtk` profile or its runtime evidence cannot grant +or elevate research, native-call, or write authority. 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/docs/protocol.md b/docs/protocol.md index cb2e447..7ff90a0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -68,6 +68,12 @@ writes as `memoryWriteTransaction`, and structured event registration as unverifiable rollback has permanently disabled writes for the current host session. +`hello.supportedBuild` and `status.supportedBuild` are `true` only for an exact +registry-matched certified executable identity. Research watches use a separate +gate: they require an exact diagnostic or certified identity and no real +anticheat. Native calls and every write path require certification. These +existing `hello` and `status` result shapes gain no additional policy keys. + The FrTk families are advertised as `frtkProfileV1`, `frtkCatalogV1`, `frtkRecordReadV1`, and `frtkFieldTransactionV1`. Public table selectors always use `uniqueId`; logical names are display text and current-build table IDs stay @@ -80,6 +86,10 @@ Discovery advances generation on every attempt and installs no partial catalog when a required table is unresolved. Inspection returns sanitized identity, capacity, authority, generation, and bounded evidence only. +A `.frtk` bundle and all evidence derived from it are data-validation inputs, +not runtime credentials. They cannot certify an executable or grant research, +native-call, or write authority. + Typed reads accept 1–64 record selectors. Each result has fixed keys `uniqueId`, `row`, and `values`; `values` is an ordered array of fixed-shape `{ field, value }` entries. A value is a number or a packed reference represented @@ -274,10 +284,11 @@ eight values. The host uses the Windows x64 integer/pointer ABI and returns the ``` 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 +the host must match a certified offline game-build identity. A diagnostic +identity can use research watches but cannot call native code. 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. @@ -288,9 +299,10 @@ The SDK method is `client.nativeCall({ address, arguments })` and negotiates the ## Recruiting board mutations `addBoard { recruitRow, teamRow }` and `removeBoard { recruitRow, teamRow }` -invoke the current supported build's verified full recruiting handlers. The +invoke the current certified 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 +is hardcoded. Both commands require a matched certified registry entry with a +board layout, 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. diff --git a/docs/safety.md b/docs/safety.md index 046655c..13f7557 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -23,17 +23,18 @@ provide, distribute, or document an anticheat bypass. `cfb.write_u8` rejects a write unless all of these conditions hold: -1. The process is the exact supported CFB27 executable build. +1. The process is an exact registry-matched, certified CFB27 executable build. 2. No real EA anticheat or Javelin process is detected. 3. The address belongs to committed writable memory. 4. The current byte equals the caller's expected byte. 5. Readback equals the requested replacement byte. -An unsupported build may load the host for diagnostics, but writes stay -disabled. The native `writeTransaction` command preserves the exact-build and -anticheat gates, validates and compares every operation before writing, applies -and verifies in request order, and rolls attempted operations back in reverse -order after an apply or verification failure. +An exact registry-matched diagnostic build may load the host for research, but +writes and native calls stay disabled. An unknown build has neither research- +watch nor write authority. The native `writeTransaction` command preserves the +exact-build and anticheat gates, validates and compares every operation before +writing, applies and verifies in request order, and rolls attempted operations +back in reverse order after an apply or verification failure. Transaction sequencing is not game-thread atomicity. The host does not suspend the game or provide a stable snapshot; callers must establish a stable window @@ -54,6 +55,14 @@ Unique-ID/row/field/value changes, reread live records, and use the existing guarded engine. Non-`direct_verified` authority fails closed before planning; rollback failure still disables raw, typed, and Lua writes for the session. +Research watches are allowed only when the running executable exactly matches +a diagnostic or certified registry identity and no real anticheat is present. +Writes, native calls, FrTk field transactions, live-class replacement, and +board mutations require a certified identity. A loaded `.frtk` profile, +discovered layout, runtime scan, or other evidence can validate data for the +matched build, but cannot create or elevate runtime authority. + `CFB27_SMOKE_ALLOW_WRITES=1` is a native test gate recognized only when the hosting executable is exactly `cfb27_protocol_smoke.exe`. It does not enable -writes in the game, MMC, or any other executable. +writes in the game, MMC, or any other executable, and it does not grant +research-watch authority. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index c11721c..bc70363 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -45,6 +45,7 @@ set_target_properties(lua54 PROPERTIES POSITION_INDEPENDENT_CODE ON) add_library(cfb27_lua_host SHARED host/board_mutation.cpp + host/build_policy.cpp host/game_builds.cpp host/frtk_catalog.cpp host/frtk_discovery.cpp @@ -123,6 +124,14 @@ add_executable(cfb27_game_builds_smoke target_compile_features(cfb27_game_builds_smoke PRIVATE cxx_std_20) target_link_options(cfb27_game_builds_smoke PRIVATE /STACK:1048576) +add_executable(cfb27_build_policy_smoke + smoke/build_policy_smoke.cpp + host/build_policy.cpp + host/game_builds.cpp +) +target_compile_features(cfb27_build_policy_smoke PRIVATE cxx_std_20) +target_link_options(cfb27_build_policy_smoke PRIVATE /STACK:1048576) + add_executable(cfb27_research_watch_smoke smoke/research_watch_smoke.cpp host/research_watch.cpp diff --git a/native/host/build_policy.cpp b/native/host/build_policy.cpp new file mode 100644 index 0000000..3f30549 --- /dev/null +++ b/native/host/build_policy.cpp @@ -0,0 +1,19 @@ +#include "build_policy.h" + +namespace cfb27::build_policy { + +bool ResearchWatchesAllowed(const game_builds::Build* build, + bool real_anticheat_running) { + return game_builds::IsDiagnosticOrCertified(build) && + !real_anticheat_running; +} + +bool WritesAllowed(const game_builds::Build* build, + bool real_anticheat_running, + bool session_writes_disabled, + bool smoke_override) { + return (game_builds::IsCertified(build) || smoke_override) && + !real_anticheat_running && !session_writes_disabled; +} + +} // namespace cfb27::build_policy diff --git a/native/host/build_policy.h b/native/host/build_policy.h new file mode 100644 index 0000000..779e3b0 --- /dev/null +++ b/native/host/build_policy.h @@ -0,0 +1,14 @@ +#pragma once + +#include "game_builds.h" + +namespace cfb27::build_policy { + +bool ResearchWatchesAllowed(const game_builds::Build* build, + bool real_anticheat_running); +bool WritesAllowed(const game_builds::Build* build, + bool real_anticheat_running, + bool session_writes_disabled, + bool smoke_override); + +} // namespace cfb27::build_policy diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index ad6940c..3a59095 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -6,6 +6,7 @@ #include "memory_transaction.h" #include "native_call.h" #include "board_mutation.h" +#include "build_policy.h" #include "game_builds.h" #include "frtk_catalog.h" #include "frtk_lua_api.h" @@ -49,8 +50,6 @@ namespace { constexpr wchar_t kPipePrefix[] = L"\\\\.\\pipe\\CFB27LuaHost."; constexpr wchar_t kV1PipePrefix[] = L"\\\\.\\pipe\\CFB27LuaHost.v1."; constexpr char kHostVersion[] = "0.2.0-dev.2"; -constexpr std::uintmax_t kSupportedExecutableSize = 247845776; -constexpr char kSupportedExecutableSha256[] = "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8"; constexpr DWORD kTickMilliseconds = 100; struct Callback { @@ -72,7 +71,7 @@ struct LogEntry { std::atomic g_running{true}; std::atomic g_ready{false}; -std::atomic g_supported_build{false}; +std::atomic g_game_build{nullptr}; std::atomic g_session_writes_disabled{false}; std::atomic g_scripts_run{0}; std::atomic g_ticks{0}; @@ -143,12 +142,6 @@ void Log(std::string_view message) { } } -bool EndsWithInsensitive(std::wstring value, const wchar_t* suffix) { - const std::wstring expected(suffix); - if (value.size() < expected.size()) return false; - return _wcsicmp(value.c_str() + value.size() - expected.size(), expected.c_str()) == 0; -} - std::string Sha256File(const std::filesystem::path& path) { BCRYPT_ALG_HANDLE algorithm{}; BCRYPT_HASH_HANDLE hash{}; @@ -186,18 +179,23 @@ std::string Sha256File(const std::filesystem::path& path) { return encoded.str(); } -bool VerifySupportedBuild() { +const cfb27::game_builds::Build* ResolveGameBuild() { wchar_t executable[MAX_PATH]{}; - if (!GetModuleFileNameW(nullptr, executable, MAX_PATH) || - !EndsWithInsensitive(executable, L"CollegeFB27.exe")) return false; + if (!GetModuleFileNameW(nullptr, executable, MAX_PATH)) return nullptr; WIN32_FILE_ATTRIBUTE_DATA data{}; - if (!GetFileAttributesExW(executable, GetFileExInfoStandard, &data)) return false; + if (!GetFileAttributesExW(executable, GetFileExInfoStandard, &data)) return nullptr; const auto size = (static_cast(data.nFileSizeHigh) << 32) | data.nFileSizeLow; - return size == kSupportedExecutableSize && Sha256File(executable) == kSupportedExecutableSha256; + return cfb27::game_builds::FindBuild(size, Sha256File(executable)); +} + +bool CertifiedBuild() { + return cfb27::game_builds::IsCertified( + g_game_build.load(std::memory_order_acquire)); } -bool SupportedBuild() { - return g_supported_build.load(std::memory_order_acquire); +bool DiagnosticOrCertifiedBuild() { + return cfb27::game_builds::IsDiagnosticOrCertified( + g_game_build.load(std::memory_order_acquire)); } bool EnvironmentIsOne(const wchar_t* name) { @@ -263,7 +261,12 @@ bool RealAnticheatIsRunning() { } bool WriteEnvironmentAllowed() { - return (SupportedBuild() || SmokeWritesAllowed()) && !RealAnticheatIsRunning(); + return (CertifiedBuild() || SmokeWritesAllowed()) && + !RealAnticheatIsRunning(); +} + +bool ResearchWatchesAllowed() { + return DiagnosticOrCertifiedBuild() && !RealAnticheatIsRunning(); } bool NativeCallsAllowed() { @@ -271,6 +274,15 @@ bool NativeCallsAllowed() { WriteEnvironmentAllowed(); } +const char* WriteBuildDenialText() { + const auto* build = g_game_build.load(std::memory_order_acquire); + if (!build) return "UNKNOWN_BUILD: executable identity is not registered"; + if (!cfb27::game_builds::IsCertified(build)) { + return "DIAGNOSTIC_BUILD_WRITE_BLOCKED: executable identity is diagnostic only"; + } + return "Write environment is not allowed"; +} + class ProcessDiscoveryBackend final : public cfb27::frtk::DiscoveryBackend { public: explicit ProcessDiscoveryBackend(bool smoke_timeout_progress = false) @@ -281,7 +293,7 @@ class ProcessDiscoveryBackend final : public cfb27::frtk::DiscoveryBackend { std::size_t max_matches, const cfb27::frtk::DescriptorMatchFilter& accept_match, const cfb27::frtk::DiscoveryDeadline& deadline) override { - if (!SupportedBuild()) { + if (!CertifiedBuild()) { return {.complete = true, .code = "DESCRIPTOR_SCAN_UNSUPPORTED"}; } cfb27::frtk::RowFingerprint fingerprint; @@ -525,10 +537,10 @@ int LuaWriteU8(lua_State* state) { std::lock_guard write_lock(g_host_write_mutex); if (g_session_writes_disabled.load(std::memory_order_acquire)) { error = "session writes are disabled"; - } else if (!SupportedBuild() && !SmokeWritesAllowed()) { - error = "unsupported College Football 27 build"; - } else if (RealAnticheatIsRunning()) { - error = "writes are disabled while EA anticheat is running"; + } else if (!WriteEnvironmentAllowed()) { + error = RealAnticheatIsRunning() + ? "writes are disabled while EA anticheat is running" + : WriteBuildDenialText(); } else if (expected < 0 || expected > 255 || value < 0 || value > 255) { error = "byte values must be between 0 and 255"; } else if (!IsAccessible(address, 1, true)) { @@ -554,7 +566,7 @@ int LuaNativeCall(lua_State* state) { 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"); + return luaL_error(state, "%s", WriteBuildDenialText()); } const auto target = static_cast(luaL_checkinteger(state, 1)); @@ -585,8 +597,9 @@ int LuaNativeCall(lua_State* state) { } int LuaArmWatch(lua_State* state, cfb27::research_watch::Kind kind) { - if (!NativeCallsAllowed()) { - return luaL_error(state, "research watches require the supported offline game build"); + if (!ResearchWatchesAllowed()) { + return luaL_error(state, + "RESEARCH_WATCH_NOT_ALLOWED: exact diagnostic or certified offline identity required"); } const auto address = static_cast(luaL_checkinteger(state, 1)); const auto length = kind == cfb27::research_watch::Kind::kExecute @@ -627,6 +640,10 @@ void PushPointerSnapshot( } int LuaWatchHits(lua_State* state) { + if (!ResearchWatchesAllowed()) { + return luaL_error(state, + "RESEARCH_WATCH_NOT_ALLOWED: exact diagnostic or certified offline identity required"); + } 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); @@ -668,6 +685,10 @@ int LuaWatchHits(lua_State* state) { } int LuaUnwatch(lua_State* state) { + if (!ResearchWatchesAllowed()) { + return luaL_error(state, + "RESEARCH_WATCH_NOT_ALLOWED: exact diagnostic or certified offline identity required"); + } lua_pushinteger(state, static_cast(cfb27::research_watch::Disarm())); return 1; @@ -1070,8 +1091,8 @@ void RunAutorun() { std::string StatusJson() { std::ostringstream out; out << "{\"ok\":true,\"ready\":" << (g_ready.load() ? "true" : "false") - << ",\"supportedBuild\":" << (SupportedBuild() ? "true" : "false") - << ",\"writesAllowed\":" << ((SupportedBuild() && !RealAnticheatIsRunning()) ? "true" : "false") + << ",\"supportedBuild\":" << (CertifiedBuild() ? "true" : "false") + << ",\"writesAllowed\":" << (NativeCallsAllowed() ? "true" : "false") << ",\"scriptsRun\":" << g_scripts_run.load() << ",\"ticks\":" << g_ticks.load() << ",\"lastError\":\"" << JsonEscape(g_last_error) << "\"}"; @@ -1396,7 +1417,7 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { return ErrorResponse(id, "INVALID_REQUEST", "Request params must be an object"); } - const bool supported = SupportedBuild(); + const bool supported = CertifiedBuild(); const bool session_writes_disabled = g_session_writes_disabled.load(std::memory_order_acquire); const bool writes_allowed = !session_writes_disabled && WriteEnvironmentAllowed(); @@ -1448,7 +1469,7 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { } if (!NativeCallsAllowed()) { return ErrorResponse(id, "UNSUPPORTED_BUILD", - "Native calls require the supported offline game build"); + WriteBuildDenialText()); } const auto canonical_target = CanonicalAddress(params["address"].get()); @@ -1519,10 +1540,9 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { } if (!NativeCallsAllowed()) { return ErrorResponse(id, "UNSUPPORTED_BUILD", - "Board mutations require the supported offline game build"); + WriteBuildDenialText()); } - const auto* board_build = cfb27::game_builds::FindBuild( - kSupportedExecutableSize, kSupportedExecutableSha256); + const auto* board_build = g_game_build.load(std::memory_order_acquire); if (!cfb27::game_builds::IsCertified(board_build) || !board_build->board) { return ErrorResponse(id, "UNSUPPORTED_BUILD", "Board mutations require a certified board layout"); @@ -1583,9 +1603,12 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { if (!parsed.ok()) { return ErrorResponse(id, "FRTK_PROFILE_INVALID", "FrTk profile is invalid"); } - if ((!supported && !(SmokeWritesAllowed() && - parsed.bundle->build_identity == "synthetic-protocol-smoke")) || - (supported && parsed.bundle->build_identity != kSupportedExecutableSha256)) { + const auto* matched_build = g_game_build.load(std::memory_order_acquire); + const bool synthetic_smoke = SmokeWritesAllowed() && + parsed.bundle->build_identity == "synthetic-protocol-smoke"; + const bool certified_profile = cfb27::game_builds::IsCertified(matched_build) && + parsed.bundle->build_identity == matched_build->executable_sha256; + if (!synthetic_smoke && !certified_profile) { return ErrorResponse(id, "UNSUPPORTED_BUILD", "FrTk profile does not match this executable build"); } @@ -1794,12 +1817,12 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { if (g_session_writes_disabled.load(std::memory_order_acquire)) return ErrorResponse(id, "SESSION_WRITES_DISABLED", "Writes are disabled for the remainder of this host session"); - if (!SupportedBuild() && !SmokeWritesAllowed()) - return ErrorResponse(id, "UNSUPPORTED_BUILD", - "Memory writes require the exact supported build"); - if (RealAnticheatIsRunning()) - return ErrorResponse(id, "MEMORY_ACCESS_DENIED", - "Writes are disabled while EA anticheat is running"); + if (!WriteEnvironmentAllowed()) { + if (RealAnticheatIsRunning()) + return ErrorResponse(id, "MEMORY_ACCESS_DENIED", + "Writes are disabled while EA anticheat is running"); + return ErrorResponse(id, "UNSUPPORTED_BUILD", WriteBuildDenialText()); + } struct ChangeGroup { std::uint32_t unique_id{}; @@ -1899,13 +1922,11 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { return ErrorResponse(id, "SESSION_WRITES_DISABLED", "Writes are disabled for the remainder of this host session"); } - if (!SupportedBuild() && !SmokeWritesAllowed()) { - return ErrorResponse(id, "UNSUPPORTED_BUILD", - "Memory writes require the exact supported build"); - } - if (RealAnticheatIsRunning()) { - return ErrorResponse(id, "MEMORY_ACCESS_DENIED", - "Writes are disabled while EA anticheat is running"); + if (!WriteEnvironmentAllowed()) { + if (RealAnticheatIsRunning()) + return ErrorResponse(id, "MEMORY_ACCESS_DENIED", + "Writes are disabled while EA anticheat is running"); + return ErrorResponse(id, "UNSUPPORTED_BUILD", WriteBuildDenialText()); } if (!HasOnlyKeys(params, {"transactionId", "operations"}) || !params.contains("transactionId") || !params["transactionId"].is_string() || @@ -2286,7 +2307,7 @@ DWORD WINAPI Start(void* module_value) { GetModuleFileNameW(module, path, MAX_PATH); g_host_directory = std::filesystem::path(path).parent_path(); g_log_path = g_host_directory / L"cfb27_lua_host.log"; - g_supported_build.store(VerifySupportedBuild(), std::memory_order_release); + g_game_build.store(ResolveGameBuild(), std::memory_order_release); g_lua = luaL_newstate(); if (!g_lua) { g_last_error = "could not create Lua state"; return 1; } luaL_openlibs(g_lua); diff --git a/native/smoke/build_policy_smoke.cpp b/native/smoke/build_policy_smoke.cpp new file mode 100644 index 0000000..5c4ed30 --- /dev/null +++ b/native/smoke/build_policy_smoke.cpp @@ -0,0 +1,46 @@ +#include "../host/build_policy.h" + +#include + +int main() { + using cfb27::build_policy::ResearchWatchesAllowed; + using cfb27::build_policy::WritesAllowed; + using cfb27::game_builds::Build; + using cfb27::game_builds::Support; + + const Build diagnostic{"diagnostic", 1, "DIAGNOSTIC", Support::kDiagnostic}; + const Build certified{"certified", 2, "CERTIFIED", Support::kCertified}; + + if (ResearchWatchesAllowed(nullptr, false) || + WritesAllowed(nullptr, false, false, false)) { + std::cerr << "unknown identity gained runtime authority\n"; + return 1; + } + if (!ResearchWatchesAllowed(&diagnostic, false) || + WritesAllowed(&diagnostic, false, false, false)) { + std::cerr << "diagnostic identity policy mismatch\n"; + return 2; + } + if (!ResearchWatchesAllowed(&certified, false) || + !WritesAllowed(&certified, false, false, false)) { + std::cerr << "certified offline identity was denied\n"; + return 3; + } + if (ResearchWatchesAllowed(&certified, true) || + WritesAllowed(&certified, true, false, false)) { + std::cerr << "anticheat did not close runtime authority\n"; + return 4; + } + if (WritesAllowed(&certified, false, true, false)) { + std::cerr << "session write lockdown was bypassed\n"; + return 5; + } + if (!WritesAllowed(nullptr, false, false, true) || + WritesAllowed(nullptr, true, false, true)) { + std::cerr << "smoke override policy mismatch\n"; + return 6; + } + + std::cout << "build policy smoke passed\n"; + return 0; +} diff --git a/native/smoke/protocol_smoke.cpp b/native/smoke/protocol_smoke.cpp index 2acb365..3f05565 100644 --- a/native/smoke/protocol_smoke.cpp +++ b/native/smoke/protocol_smoke.cpp @@ -432,7 +432,12 @@ int wmain(int argc, wchar_t** argv) { Json response; if (!Request(pipe, {{"protocol", 1}, {"id", "hello-1"}, {"command", "hello"}, {"params", Json::object()}}, response, true)) return 3; - if (!response.value("ok", false) || response["result"].value("protocolVersion", 0) != 1) return 4; + if (!response.value("ok", false) || response["result"].size() != 5 || + response["result"].value("protocolVersion", 0) != 1 || + !response["result"].contains("hostVersion") || + response["result"].value("supportedBuild", true) || + !response["result"].value("writesAllowed", false) || + !response["result"].contains("capabilities")) return 4; const auto capabilities = response["result"]["capabilities"]; if (std::find(capabilities.begin(), capabilities.end(), "evaluate") == capabilities.end()) return 5; if (std::find(capabilities.begin(), capabilities.end(), "telemetry") == capabilities.end()) return 51; @@ -477,7 +482,9 @@ int wmain(int argc, wchar_t** argv) { if (!Request(pipe, {{"protocol", 1}, {"id", "research-watch-lua"}, {"command", "evaluate"}, {"params", {{"source", watch_lua}}}}, - response, false) || !response.value("ok", false)) return 144; + response, false) || !IsError(response, "SCRIPT_ERROR") || + response["error"].value("message", "").find( + "RESEARCH_WATCH_NOT_ALLOWED") == std::string::npos) return 144; if (!Request(pipe, {{"protocol", 1}, {"id", "native-call-invalid"}, {"command", "nativeCall"}, {"params", {{"address", "0x1"}, @@ -790,7 +797,14 @@ int wmain(int argc, wchar_t** argv) { if (!Request(pipe, {{"protocol", 1}, {"id", "status-1"}, {"command", "status"}, {"params", Json::object()}}, response, false)) return 14; - if (!response.value("ok", false) || !response["result"].contains("ready")) return 15; + if (!response.value("ok", false) || response["result"].size() != 7 || + !response["result"].contains("ready") || + response["result"].value("supportedBuild", true) || + !response["result"].value("writesAllowed", false) || + !response["result"].contains("sessionWritesDisabled") || + !response["result"].contains("scriptsRun") || + !response["result"].contains("ticks") || + !response["result"].contains("lastError")) return 15; const Json write_params{ {"transactionId", "smoke.apply-1"}, diff --git a/native/smoke/startup_host_smoke.cpp b/native/smoke/startup_host_smoke.cpp index 1eea715..cf50ad3 100644 --- a/native/smoke/startup_host_smoke.cpp +++ b/native/smoke/startup_host_smoke.cpp @@ -340,7 +340,14 @@ int wmain(int argc, wchar_t** argv) { if (!Request(pipe, {{"protocol", 1}, {"id", "startup-ready"}, {"command", "status"}, {"params", Json::object()}}, response) || !response.value("ok", false) || - !response["result"].value("ready", false)) return 11; + response["result"].size() != 7 || + !response["result"].value("ready", false) || + response["result"].value("supportedBuild", true) || + response["result"].value("writesAllowed", true) || + !response["result"].contains("sessionWritesDisabled") || + !response["result"].contains("scriptsRun") || + !response["result"].contains("ticks") || + !response["result"].contains("lastError")) return 11; set_game_ready(FALSE); if (!Request(pipe, {{"protocol", 1}, {"id", "startup-not-ready"}, {"command", "status"}, {"params", Json::object()}}, From 664c4c2352ccae537b0714d886e4370c88bef4b0 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 21:16:38 -0500 Subject: [PATCH 07/16] fix: delegate runtime build gates --- native/host/lua_host.cpp | 14 +++++---- native/smoke/protocol_smoke.cpp | 4 +++ native/smoke/startup_host_smoke.cpp | 46 +++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index 3a59095..97e4f93 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -261,17 +261,21 @@ bool RealAnticheatIsRunning() { } bool WriteEnvironmentAllowed() { - return (CertifiedBuild() || SmokeWritesAllowed()) && - !RealAnticheatIsRunning(); + return cfb27::build_policy::WritesAllowed( + g_game_build.load(std::memory_order_acquire), RealAnticheatIsRunning(), + false, SmokeWritesAllowed()); } bool ResearchWatchesAllowed() { - return DiagnosticOrCertifiedBuild() && !RealAnticheatIsRunning(); + return cfb27::build_policy::ResearchWatchesAllowed( + g_game_build.load(std::memory_order_acquire), RealAnticheatIsRunning()); } bool NativeCallsAllowed() { - return !g_session_writes_disabled.load(std::memory_order_acquire) && - WriteEnvironmentAllowed(); + return cfb27::build_policy::WritesAllowed( + g_game_build.load(std::memory_order_acquire), RealAnticheatIsRunning(), + g_session_writes_disabled.load(std::memory_order_acquire), + SmokeWritesAllowed()); } const char* WriteBuildDenialText() { diff --git a/native/smoke/protocol_smoke.cpp b/native/smoke/protocol_smoke.cpp index 3f05565..be58fa4 100644 --- a/native/smoke/protocol_smoke.cpp +++ b/native/smoke/protocol_smoke.cpp @@ -453,6 +453,10 @@ int wmain(int argc, wchar_t** argv) { {"command", "addBoard"}, {"params", {{"recruitRow", 1}}}}, response, false) || !IsError(response, "INVALID_REQUEST")) return 146; + if (!Request(pipe, {{"protocol", 1}, {"id", "board-unknown-build"}, + {"command", "addBoard"}, + {"params", {{"recruitRow", 1}, {"teamRow", 1}}}}, + response, false) || !IsError(response, "UNSUPPORTED_BUILD")) return 147; Json native_arguments = Json::array(); for (std::uintptr_t value = 1; value <= 8; ++value) { native_arguments.push_back(FormatAddress(value)); diff --git a/native/smoke/startup_host_smoke.cpp b/native/smoke/startup_host_smoke.cpp index cf50ad3..68169e8 100644 --- a/native/smoke/startup_host_smoke.cpp +++ b/native/smoke/startup_host_smoke.cpp @@ -293,6 +293,48 @@ bool VerifyLuaWriteU8Source(const std::filesystem::path& path, return true; } +bool VerifyBuildPolicySource(const std::filesystem::path& path, + std::string& error) { + std::ifstream input(path, std::ios::binary); + if (!input) { + error = "could not open Lua host source"; + return false; + } + const std::string source((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); + const auto delegates = [&](std::string_view signature, + std::string_view policy_call, + std::string_view required_argument) { + const auto signature_at = source.find(signature); + const auto function_open = source.find('{', signature_at); + const auto function_close = function_open == std::string::npos + ? std::nullopt : MatchingBrace(source, function_open); + if (signature_at == std::string::npos || !function_close) return false; + const auto body = source.substr(function_open, *function_close - function_open); + return body.find(policy_call) != std::string::npos && + body.find(required_argument) != std::string::npos; + }; + if (!delegates("bool ResearchWatchesAllowed()", + "cfb27::build_policy::ResearchWatchesAllowed(", + "g_game_build.load(std::memory_order_acquire)")) { + error = "ResearchWatchesAllowed must delegate to the pure build policy"; + return false; + } + if (!delegates("bool WriteEnvironmentAllowed()", + "cfb27::build_policy::WritesAllowed(", + "false, SmokeWritesAllowed()")) { + error = "WriteEnvironmentAllowed must delegate without session lockdown"; + return false; + } + if (!delegates("bool NativeCallsAllowed()", + "cfb27::build_policy::WritesAllowed(", + "g_session_writes_disabled.load(std::memory_order_acquire)")) { + error = "NativeCallsAllowed must delegate with session lockdown"; + return false; + } + return true; +} + } // namespace int wmain(int argc, wchar_t** argv) { @@ -317,6 +359,10 @@ int wmain(int argc, wchar_t** argv) { std::cerr << "LuaWriteU8 source policy RED: " << source_error << '\n'; return 7; } + if (!VerifyBuildPolicySource(source_path, source_error)) { + std::cerr << "build policy source RED: " << source_error << '\n'; + return 13; + } if (!SetEnvironmentVariableW(L"CFB27_SMOKE_ALLOW_WRITES", L"1")) return 3; if (!SetEnvironmentVariableW(L"CFB27_SMOKE_FORCE_ROLLBACK_UNVERIFIED", L"1") || !SetEnvironmentVariableW(L"CFB27_SMOKE_HOLD_ROLLBACK", L"1") || From 83bc4811649e84537a16a76b6d738791c8e35378 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 21:28:08 -0500 Subject: [PATCH 08/16] refactor: make board anchors reusable and strict --- package.json | 2 +- scripts/board-verification/live-anchor.cjs | 206 +++---------- .../live-table-snapshot.cjs | 22 +- scripts/board-verification/reanchor-lib.cjs | 270 ++++++++++++++++++ tests/board-reanchor.test.cjs | 221 ++++++++++++++ 5 files changed, 554 insertions(+), 167 deletions(-) create mode 100644 scripts/board-verification/reanchor-lib.cjs create mode 100644 tests/board-reanchor.test.cjs diff --git a/package.json b/package.json index a07efec..a390b15 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "packages/cli" ], "scripts": { - "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs && node --check scripts/game-build-manifest.cjs && node --check scripts/generate-game-builds.cjs && node scripts/generate-game-builds.cjs --check", + "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs && node --check scripts/game-build-manifest.cjs && node --check scripts/generate-game-builds.cjs && node --check scripts/board-verification/reanchor-lib.cjs && node --check scripts/board-verification/live-anchor.cjs && node --check scripts/board-verification/live-table-snapshot.cjs && node scripts/generate-game-builds.cjs --check", "test": "node scripts/run-tests.cjs", "build:frtk-profile": "node scripts/build-frtk-profile.cjs", "pack:preview": "node scripts/package-release.cjs" diff --git a/scripts/board-verification/live-anchor.cjs b/scripts/board-verification/live-anchor.cjs index b00a51d..c705a2c 100644 --- a/scripts/board-verification/live-anchor.cjs +++ b/scripts/board-verification/live-anchor.cjs @@ -1,187 +1,65 @@ 'use strict'; +const crypto = require('node:crypto'); 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) }; +const { + TABLES, + canonical, + locateTable, + findUserBoard, +} = require('./reanchor-lib.cjs'); + +const OUTPUT = path.resolve(__dirname, '..', '..', '.frtk', 'board-verification', + 'live-mirror-bases.json'); + +function sha256File(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex').toUpperCase())); + stream.on('error', reject); + }); } async function main() { const game = await sdk.discoverGame(); + const executableSha256 = await sha256File(game.path); 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'); + if (!hello.capabilities.includes('researchWatch')) { + throw new Error('Loaded host does not support researchWatch'); + } + const sessionIdentity = { + pid: game.pid, + hostVersion: hello.hostVersion, + protocolVersion: hello.protocolVersion, + }; const located = []; - for (const table of TABLES) located.push(await locateTable(client, table)); + for (const table of TABLES.values()) 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 membershipSlot = membership.base + + BigInt(board.selected.teamRow * membership.stride + board.selected.firstFreeSlot * 4); + const validationSummaries = Object.fromEntries(located.map((table) => [String(table.id), { + ...table.validation, + score: table.score, + signatureMatches: table.signatureMatches, + }])); const output = { capturedAt: new Date().toISOString(), pid: game.pid, supportedBuild: hello.supportedBuild, + executableSha256, + sessionIdentity, + validationSummaries, tables: Object.fromEntries(located.map((table) => [String(table.id), { headerSignature: canonical(table.header), dataBase: canonical(table.base), @@ -193,8 +71,10 @@ async function main() { }])), userBoard: { ...board.selected, - boardIndexAddress: canonical(boardIndex.base + BigInt(board.selected.boardRow * boardIndex.stride)), - membershipRowAddress: canonical(membership.base + BigInt(board.selected.teamRow * membership.stride)), + boardIndexAddress: canonical(boardIndex.base + + BigInt(board.selected.boardRow * boardIndex.stride)), + membershipRowAddress: canonical(membership.base + + BigInt(board.selected.teamRow * membership.stride)), firstFreeSlotAddress: canonical(membershipSlot), }, captureAddresses: { diff --git a/scripts/board-verification/live-table-snapshot.cjs b/scripts/board-verification/live-table-snapshot.cjs index e0c58b0..4b3a84b 100644 --- a/scripts/board-verification/live-table-snapshot.cjs +++ b/scripts/board-verification/live-table-snapshot.cjs @@ -1,5 +1,6 @@ 'use strict'; +const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const sdk = require('../../packages/sdk'); @@ -12,19 +13,32 @@ if (!requestedOutput) { } const outputPath = path.resolve(requestedOutput); -function canonical(value) { - return `0x${value.toString(16).toUpperCase()}`; +function sha256File(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex').toUpperCase())); + stream.on('error', reject); + }); } 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 executableSha256 = await sha256File(game.path); + if (executableSha256 !== anchor.executableSha256) { + throw new Error('Anchor belongs to a different game executable; 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 }] }); + const result = await client.readMemory({ + ranges: [{ address: table.dataBase, length }], + allowUnsupportedBuild: true, + }); tables[id] = { dataBase: table.dataBase, stride: table.stride, @@ -36,6 +50,8 @@ async function main() { const capture = { capturedAt: new Date().toISOString(), pid: game.pid, + executableSha256, + sessionIdentity: anchor.sessionIdentity, userBoard: anchor.userBoard, tables, }; diff --git a/scripts/board-verification/reanchor-lib.cjs b/scripts/board-verification/reanchor-lib.cjs new file mode 100644 index 0000000..43afae1 --- /dev/null +++ b/scripts/board-verification/reanchor-lib.cjs @@ -0,0 +1,270 @@ +'use strict'; + +const TABLES = new Map([ + [4168, Object.freeze({ id: 4168, table1Length: 40444, words: 9, capacity: 1120, stride: 36, headerSize: 324, offsetStart: 0 })], + [4176, Object.freeze({ id: 4176, table1Length: 19364, words: 1, capacity: 4830, stride: 4, headerSize: 244, offsetStart: 0 })], + [4190, Object.freeze({ id: 4190, table1Length: 37560, words: 1, capacity: 9380, stride: 4, headerSize: 240, offsetStart: 0 })], + [4251, Object.freeze({ id: 4251, table1Length: 1704, words: 3, capacity: 138, stride: 12, headerSize: 248, offsetStart: 0 })], + [5790, Object.freeze({ id: 5790, table1Length: 77312, words: 3, capacity: 4830, stride: 12, headerSize: 265, offsetStart: 33, isArray: true })], + [5847, Object.freeze({ id: 5847, table1Length: 19904, words: 35, capacity: 138, stride: 140, headerSize: 263, offsetStart: 31, isArray: true })], +]); +for (const method of ['set', 'delete', 'clear']) { + Object.defineProperty(TABLES, method, { + value() { + throw new TypeError('TABLES is read-only'); + }, + configurable: false, + enumerable: false, + writable: false, + }); +} +Object.freeze(TABLES); + +function canonical(value) { + return `0x${BigInt(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 isFreeRow(table, data, row) { + const offset = row * table.stride; + if (data.readUInt32LE(offset) !== row + 1) return false; + for (let byte = offset + 4; byte < offset + table.stride; byte += 4) { + if (data.readUInt32LE(byte) !== 0) return false; + } + return true; +} + +function isContentRow(table, data, row) { + const offset = row * table.stride; + const first = data.readUInt32LE(offset); + if (table.id === 4168) return expectedRef(data.readUInt32LE(offset + 12), 4269); + if (table.id === 4251) return expectedRef(first, 5847, 138); + if (table.id === 5790) return expectedRef(first, 4190, 9380); + if (table.id === 5847) { + const ref = decodeRef(first); + return (ref.tableId === 4168 || ref.tableId === 4288) && ref.row < 0x20000; + } + return false; +} + +function sampledRowCount(table, data) { + return Math.min(table.capacity, 512, Math.floor(data.length / table.stride)); +} + +function scoreCandidate(table, data) { + let freeRows = 0; + let contentRows = 0; + const sampleRows = sampledRowCount(table, data); + for (let row = 0; row < sampleRows; row += 1) { + if (isFreeRow(table, data, row)) freeRows += 1; + if (isContentRow(table, data, row)) contentRows += 1; + } + return { freeRows, contentRows, score: freeRows + (contentRows * 8) }; +} + +function deriveDataAddress(table, header) { + const dataOffset = table.headerSize - 204 - table.offsetStart + + (table.isArray ? table.capacity * 4 : 0); + return BigInt(header) + BigInt(dataOffset); +} + +function selectTableCandidate(table, candidates) { + const ranked = [...candidates].sort((left, right) => right.score.score - left.score.score); + if (ranked.length === 0 || ranked[0].score.score <= 0) { + throw new Error(`Table ${table.id} signatures failed structural validation`); + } + if (ranked.length > 1 && ranked[0].score.score === ranked[1].score.score) { + throw new Error(`Table ${table.id} has ambiguous top structural candidates`); + } + return ranked[0]; +} + +async function readRange(client, address, length) { + const result = await client.readMemory({ + ranges: [{ address: canonical(address), length }], + allowUnsupportedBuild: true, + }); + return Buffer.from(result.ranges[0].bytesHex, 'hex'); +} + +function representativeRows(table, data) { + let freeRow = null; + let contentRow = null; + const sampleRows = sampledRowCount(table, data); + for (let row = 0; row < sampleRows && (freeRow === null || contentRow === null); row += 1) { + if (freeRow === null && isFreeRow(table, data, row)) freeRow = row; + if (contentRow === null && isContentRow(table, data, row)) contentRow = row; + } + return { freeRow, contentRow }; +} + +async function validateAnchorReread(client, table, candidate) { + const header = await readRange(client, candidate.header, 16); + if (header.toString('hex').toUpperCase() !== signature(table)) { + throw new Error(`Table ${table.id} header reread mismatch`); + } + + const freelistHead = (await readRange(client, candidate.header + 24n, 4)).readUInt32LE(0); + if (candidate.freelistHead !== undefined && candidate.freelistHead !== freelistHead) { + throw new Error(`Table ${table.id} freelist head reread mismatch`); + } + + const rows = representativeRows(table, candidate.data); + for (const [kind, row] of Object.entries(rows)) { + if (row === null) continue; + const offset = row * table.stride; + const reread = await readRange(client, candidate.base + BigInt(offset), table.stride); + if (!reread.equals(candidate.data.subarray(offset, offset + table.stride))) { + throw new Error(`Table ${table.id} representative ${kind} reread mismatch`); + } + } + + return Object.freeze({ + header: true, + freelistHead: true, + freelistHeadValue: freelistHead, + freeRow: rows.freeRow, + contentRow: rows.contentRow, + }); +} + +async function locateTable(client, table, { log = (message) => process.stderr.write(message) } = {}) { + log(`Locating table ${table.id}...\n`); + 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, + allowUnsupportedBuild: true, + cursor, + }); + signatureMatches += page.matches.length; + for (const match of page.matches) { + const header = BigInt(match.address); + const base = deriveDataAddress(table, header); + try { + const data = await readRange(client, base, table.capacity * table.stride); + candidates.push({ + header, + base, + data, + score: scoreCandidate(table, data), + allocationBase: match.allocationBase, + allocationSize: match.allocationSize, + }); + } catch { + // A signature at a page boundary can be valid while its derived region is not. + } + } + if (page.complete) break; + cursor = page.nextCursor; + if (pageNumber > 0 && pageNumber % 16 === 0) log(` scanned ${pageNumber + 1} pages...\n`); + } + + const selected = selectTableCandidate(table, candidates); + selected.freelistHead = (await readRange(client, selected.header + 24n, 4)).readUInt32LE(0); + const validation = await validateAnchorReread(client, table, selected); + log(` ${canonical(selected.base)} score=${selected.score.score} candidates=${candidates.length}\n`); + return { + ...table, + ...selected, + freelistHead: validation.freelistHeadValue, + signatureMatches, + validation, + }; +} + +function findUserBoard(tables) { + const boardIndex = tables.get(4251); + const membership = tables.get(5847); + if (!boardIndex || !membership) throw new Error('Board index and membership tables are required'); + 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; + let compact = true; + 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; + } + if (firstFreeSlot >= 0) compact = false; + 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, + compact, + }); + } + + candidates.sort((left, right) => + (right.userRefs - left.userRefs) || + (left.cpuRefs - right.cpuRefs) || + (right.occupied - left.occupied)); + const userCandidates = candidates.filter((candidate) => candidate.userRefs > 0 && candidate.cpuRefs === 0); + const compactCandidates = userCandidates.filter((candidate) => candidate.compact); + if (compactCandidates.length === 0 && userCandidates.length > 0) { + throw new Error('Could not identify a compact user board membership row'); + } + if (compactCandidates.length !== 1) { + throw new Error('Could not uniquely identify the user board from table 4168 membership references'); + } + const selected = compactCandidates[0]; + if (selected.firstFreeSlot < 0) throw new Error('The active recruiting board has no free membership slot'); + return { selected, candidates: candidates.slice(0, 8) }; +} + +module.exports = { + TABLES, + canonical, + signature, + decodeRef, + scoreCandidate, + deriveDataAddress, + selectTableCandidate, + locateTable, + findUserBoard, + readRange, + validateAnchorReread, +}; diff --git a/tests/board-reanchor.test.cjs b/tests/board-reanchor.test.cjs new file mode 100644 index 0000000..6769972 --- /dev/null +++ b/tests/board-reanchor.test.cjs @@ -0,0 +1,221 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { + TABLES, + canonical, + signature, + decodeRef, + scoreCandidate, + deriveDataAddress, + selectTableCandidate, + locateTable, + findUserBoard, + readRange, + validateAnchorReread, +} = require('../scripts/board-verification/reanchor-lib.cjs'); + +function encodedRef(tableId, row) { + return ((tableId << 17) | row) >>> 0; +} + +function tableData(table) { + return Buffer.alloc(table.capacity * table.stride); +} + +function setFreeRow(table, data, row) { + data.writeUInt32LE(row + 1, row * table.stride); +} + +function setContentRow(table, data, row) { + const offset = row * table.stride; + if (table.id === 4168) data.writeUInt32LE(encodedRef(4269, 7), offset + 12); + if (table.id === 4251) data.writeUInt32LE(encodedRef(5847, 7), offset); + if (table.id === 5790) data.writeUInt32LE(encodedRef(4190, 7), offset); + if (table.id === 5847) data.writeUInt32LE(encodedRef(4168, 7), offset); +} + +test('TABLES contains six frozen table definitions', () => { + assert.equal(TABLES.size, 6); + assert.deepEqual([...TABLES.keys()], [4168, 4176, 4190, 4251, 5790, 5847]); + assert.equal(Object.isFrozen(TABLES), true); + for (const table of TABLES.values()) assert.equal(Object.isFrozen(table), true); +}); + +test('TABLES rejects registry mutation', () => { + try { + assert.throws(() => TABLES.set(9999, {}), /read-only/i); + assert.throws(() => TABLES.delete(4168), /read-only/i); + assert.throws(() => TABLES.clear(), /read-only/i); + } finally { + Map.prototype.delete.call(TABLES, 9999); + } +}); + +test('signature serializes table identity as four little-endian words', () => { + assert.equal(signature(TABLES.get(4168)), 'FC9D0000FC9D00000900000060040000'); +}); + +test('canonical and decodeRef preserve unsigned address and row identity', () => { + assert.equal(canonical(0x7ff61234n), '0x7FF61234'); + assert.deepEqual(decodeRef(encodedRef(5847, 137)), { tableId: 5847, row: 137 }); +}); + +test('deriveDataAddress applies header, offset, and array geometry for all tables', () => { + const header = 0x100000n; + for (const table of TABLES.values()) { + const expectedOffset = table.headerSize - 204 - table.offsetStart + + (table.isArray ? table.capacity * 4 : 0); + assert.equal(deriveDataAddress(table, header), header + BigInt(expectedOffset), String(table.id)); + } +}); + +test('scoreCandidate recognizes freelist and table-specific content fixtures', () => { + for (const table of TABLES.values()) { + const data = tableData(table); + setFreeRow(table, data, 0); + if ([4168, 4251, 5790, 5847].includes(table.id)) setContentRow(table, data, 1); + assert.deepEqual(scoreCandidate(table, data), { + freeRows: 1, + contentRows: [4168, 4251, 5790, 5847].includes(table.id) ? 1 : 0, + score: [4168, 4251, 5790, 5847].includes(table.id) ? 9 : 1, + }, String(table.id)); + } +}); + +test('selectTableCandidate requires a positive structural winner', () => { + const table = TABLES.get(4168); + assert.throws(() => selectTableCandidate(table, [{ score: { score: 0 } }]), /structural validation/i); +}); + +test('selectTableCandidate rejects tied structural winners', () => { + const table = TABLES.get(4168); + const winnerA = { header: 0x1000n, score: { score: 9 } }; + const winnerB = { header: 0x2000n, score: { score: 9 } }; + assert.throws(() => selectTableCandidate(table, [winnerA, winnerB]), /ambiguous/i); +}); + +test('findUserBoard discovers one compact user membership row', () => { + const boardIndexDefinition = TABLES.get(4251); + const membershipDefinition = TABLES.get(5847); + const boardIndex = { ...boardIndexDefinition, data: tableData(boardIndexDefinition) }; + const membership = { ...membershipDefinition, data: tableData(membershipDefinition) }; + + boardIndex.data.writeUInt32LE(encodedRef(5847, 3), 2 * boardIndex.stride); + membership.data.writeUInt32LE(encodedRef(4168, 10), 3 * membership.stride); + membership.data.writeUInt32LE(encodedRef(4168, 11), 3 * membership.stride + 4); + + const result = findUserBoard(new Map([[4251, boardIndex], [5847, membership]])); + assert.equal(result.selected.boardRow, 2); + assert.equal(result.selected.teamRow, 3); + assert.equal(result.selected.firstFreeSlot, 2); + assert.equal(result.selected.compact, true); +}); + +test('findUserBoard rejects a user membership row with an interior hole', () => { + const boardIndexDefinition = TABLES.get(4251); + const membershipDefinition = TABLES.get(5847); + const boardIndex = { ...boardIndexDefinition, data: tableData(boardIndexDefinition) }; + const membership = { ...membershipDefinition, data: tableData(membershipDefinition) }; + boardIndex.data.writeUInt32LE(encodedRef(5847, 3), 2 * boardIndex.stride); + membership.data.writeUInt32LE(encodedRef(4168, 10), 3 * membership.stride); + membership.data.writeUInt32LE(encodedRef(4168, 11), 3 * membership.stride + 8); + + assert.throws(() => findUserBoard(new Map([[4251, boardIndex], [5847, membership]])), /compact/i); +}); + +test('readRange explicitly opts diagnostic reads into unsupported builds', async () => { + const requests = []; + const client = { + async readMemory(request) { + requests.push(request); + return { ranges: [{ bytesHex: '00010203' }] }; + }, + }; + assert.deepEqual(await readRange(client, 0x1000n, 4), Buffer.from('00010203', 'hex')); + assert.equal(requests[0].allowUnsupportedBuild, true); +}); + +test('validateAnchorReread rejects a changed table header', async () => { + const table = TABLES.get(4168); + const data = tableData(table); + setFreeRow(table, data, 0); + setContentRow(table, data, 1); + const candidate = { + header: 0x1000n, + base: deriveDataAddress(table, 0x1000n), + data, + score: scoreCandidate(table, data), + freelistHead: 0, + }; + const requests = []; + const client = { + async readMemory(request) { + requests.push(request); + return { ranges: [{ bytesHex: Buffer.alloc(request.ranges[0].length).toString('hex') }] }; + }, + }; + + await assert.rejects(() => validateAnchorReread(client, table, candidate), /header.*mismatch/i); + assert.equal(requests[0].ranges[0].length, 16); + assert.ok(requests.every((request) => request.allowUnsupportedBuild === true)); +}); + +test('locateTable explicitly opts scans and validation reads into unsupported builds', async () => { + const table = TABLES.get(4176); + const header = 0x1000n; + const base = deriveDataAddress(table, header); + const data = tableData(table); + setFreeRow(table, data, 0); + const scanRequests = []; + const readRequests = []; + const client = { + async scanMemoryPage(request) { + scanRequests.push(request); + return { + complete: true, + nextCursor: null, + matches: [{ address: canonical(header), allocationBase: '0x1000', allocationSize: 0x10000 }], + }; + }, + async readMemory(request) { + readRequests.push(request); + const range = request.ranges[0]; + const address = BigInt(range.address); + let bytes = Buffer.alloc(range.length); + if (address === base && range.length === data.length) bytes = data; + if (address === header && range.length === 16) bytes = Buffer.from(signature(table), 'hex'); + if (address === base && range.length === table.stride) bytes = data.subarray(0, table.stride); + return { ranges: [{ bytesHex: bytes.toString('hex') }] }; + }, + }; + + const located = await locateTable(client, table, { log: () => {} }); + assert.equal(located.base, base); + assert.equal(located.validation.header, true); + assert.ok(scanRequests.every((request) => request.allowUnsupportedBuild === true)); + assert.ok(readRequests.every((request) => request.allowUnsupportedBuild === true)); +}); + +test('live anchor consumes the reusable layer and records identity validation summaries', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'board-verification', + 'live-anchor.cjs'), 'utf8'); + assert.match(source, /require\(['"]\.\/reanchor-lib\.cjs['"]\)/); + assert.doesNotMatch(source, /const TABLES\s*=/); + assert.doesNotMatch(source, /function signature\(/); + assert.match(source, /executableSha256/); + assert.match(source, /sessionIdentity/); + assert.match(source, /validationSummaries/); +}); + +test('live snapshot rejects executable drift and explicitly opts every read into diagnostics', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'board-verification', + 'live-table-snapshot.cjs'), 'utf8'); + assert.match(source, /executableSha256/); + assert.match(source, /different game executable/i); + assert.match(source, /allowUnsupportedBuild:\s*true/); +}); From 4a623934abac4a5950cd3c9bdc76d702cf72e7eb Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 21:37:42 -0500 Subject: [PATCH 09/16] fix: harden board anchor validation --- .../live-table-snapshot.cjs | 52 +++-- scripts/board-verification/reanchor-lib.cjs | 76 +++++-- tests/board-reanchor.test.cjs | 196 ++++++++++++++++-- 3 files changed, 268 insertions(+), 56 deletions(-) diff --git a/scripts/board-verification/live-table-snapshot.cjs b/scripts/board-verification/live-table-snapshot.cjs index 4b3a84b..74535d4 100644 --- a/scripts/board-verification/live-table-snapshot.cjs +++ b/scripts/board-verification/live-table-snapshot.cjs @@ -5,13 +5,8 @@ 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); +const anchorPath = path.resolve(__dirname, '..', '..', '.frtk', 'board-verification', + 'live-mirror-bases.json'); function sha256File(filePath) { return new Promise((resolve, reject) => { @@ -23,15 +18,16 @@ function sha256File(filePath) { }); } -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 executableSha256 = await sha256File(game.path); +function assertAnchorIdentity(anchor, game, executableSha256) { + if (game.pid !== anchor.pid) { + throw new Error('Anchor belongs to a different game process; rerun live-anchor.cjs'); + } if (executableSha256 !== anchor.executableSha256) { throw new Error('Anchor belongs to a different game executable; rerun live-anchor.cjs'); } - const client = sdk.createClient({ pid: game.pid, timeoutMs: 30_000 }); +} + +async function readAnchoredTables(client, anchor) { const tables = {}; for (const [id, table] of Object.entries(anchor.tables)) { const length = table.stride * table.capacity; @@ -47,6 +43,21 @@ async function main() { bytesHex: result.ranges[0].bytesHex, }; } + return tables; +} + +async function main(requestedOutput = process.argv[2]) { + if (!requestedOutput) { + process.stderr.write('Usage: node live-table-snapshot.cjs \n'); + return 2; + } + const outputPath = path.resolve(requestedOutput); + const anchor = JSON.parse(fs.readFileSync(anchorPath, 'utf8')); + const game = await sdk.discoverGame(); + const executableSha256 = await sha256File(game.path); + assertAnchorIdentity(anchor, game, executableSha256); + const client = sdk.createClient({ pid: game.pid, timeoutMs: 30_000 }); + const tables = await readAnchoredTables(client, anchor); const capture = { capturedAt: new Date().toISOString(), pid: game.pid, @@ -63,9 +74,16 @@ async function main() { occupied: anchor.userBoard.occupied, bytes: Object.values(tables).reduce((sum, table) => sum + table.bytesHex.length / 2, 0), })}\n`); + return 0; +} + +if (require.main === module) { + main().then((exitCode) => { + process.exitCode = exitCode; + }).catch((error) => { + process.stderr.write(`${error.code || 'ERROR'}: ${error.message}\n`); + process.exitCode = 1; + }); } -main().catch((error) => { - process.stderr.write(`${error.code || 'ERROR'}: ${error.message}\n`); - process.exitCode = 1; -}); +module.exports = { assertAnchorIdentity, readAnchoredTables, main }; diff --git a/scripts/board-verification/reanchor-lib.cjs b/scripts/board-verification/reanchor-lib.cjs index 43afae1..72f97d3 100644 --- a/scripts/board-verification/reanchor-lib.cjs +++ b/scripts/board-verification/reanchor-lib.cjs @@ -1,24 +1,30 @@ 'use strict'; -const TABLES = new Map([ +const TABLE_ENTRIES = [ [4168, Object.freeze({ id: 4168, table1Length: 40444, words: 9, capacity: 1120, stride: 36, headerSize: 324, offsetStart: 0 })], [4176, Object.freeze({ id: 4176, table1Length: 19364, words: 1, capacity: 4830, stride: 4, headerSize: 244, offsetStart: 0 })], [4190, Object.freeze({ id: 4190, table1Length: 37560, words: 1, capacity: 9380, stride: 4, headerSize: 240, offsetStart: 0 })], [4251, Object.freeze({ id: 4251, table1Length: 1704, words: 3, capacity: 138, stride: 12, headerSize: 248, offsetStart: 0 })], [5790, Object.freeze({ id: 5790, table1Length: 77312, words: 3, capacity: 4830, stride: 12, headerSize: 265, offsetStart: 33, isArray: true })], [5847, Object.freeze({ id: 5847, table1Length: 19904, words: 35, capacity: 138, stride: 140, headerSize: 263, offsetStart: 31, isArray: true })], -]); -for (const method of ['set', 'delete', 'clear']) { - Object.defineProperty(TABLES, method, { - value() { - throw new TypeError('TABLES is read-only'); - }, - configurable: false, - enumerable: false, - writable: false, - }); -} -Object.freeze(TABLES); +]; +const TABLE_LOOKUP = new Map(TABLE_ENTRIES); +const readOnlyError = () => { throw new TypeError('TABLES is read-only'); }; +const TABLES = Object.freeze({ + get size() { return TABLE_LOOKUP.size; }, + get(id) { return TABLE_LOOKUP.get(id); }, + has(id) { return TABLE_LOOKUP.has(id); }, + keys() { return TABLE_LOOKUP.keys(); }, + values() { return TABLE_LOOKUP.values(); }, + entries() { return TABLE_LOOKUP.entries(); }, + forEach(callback, thisArg) { + TABLE_LOOKUP.forEach((value, key) => callback.call(thisArg, value, key, TABLES)); + }, + [Symbol.iterator]() { return TABLE_LOOKUP[Symbol.iterator](); }, + set: readOnlyError, + delete: readOnlyError, + clear: readOnlyError, +}); function canonical(value) { return `0x${BigInt(value).toString(16).toUpperCase()}`; @@ -150,6 +156,7 @@ async function locateTable(client, table, { log = (message) => process.stderr.wr const candidates = []; let cursor = process.env.CFB27_SCAN_START || '0x380000000'; let signatureMatches = 0; + let scanComplete = false; for (let pageNumber = 0; pageNumber < 512; pageNumber += 1) { const page = await client.scanMemoryPage({ patternHex: signature(table), @@ -179,11 +186,17 @@ async function locateTable(client, table, { log = (message) => process.stderr.wr // A signature at a page boundary can be valid while its derived region is not. } } - if (page.complete) break; + if (page.complete) { + scanComplete = true; + break; + } cursor = page.nextCursor; if (pageNumber > 0 && pageNumber % 16 === 0) log(` scanned ${pageNumber + 1} pages...\n`); } + if (!scanComplete) { + throw new Error(`Table ${table.id} scan did not complete within 512 pages`); + } const selected = selectTableCandidate(table, candidates); selected.freelistHead = (await readRange(client, selected.header + 24n, 4)).readUInt32LE(0); const validation = await validateAnchorReread(client, table, selected); @@ -198,9 +211,12 @@ async function locateTable(client, table, { log = (message) => process.stderr.wr } function findUserBoard(tables) { + const userRows = tables.get(4168); const boardIndex = tables.get(4251); const membership = tables.get(5847); - if (!boardIndex || !membership) throw new Error('Board index and membership tables are required'); + if (!userRows || !boardIndex || !membership) { + throw new Error('User rows, board index, and membership tables are required'); + } const candidates = []; for (let boardRow = 0; boardRow < boardIndex.capacity; boardRow += 1) { const boardOffset = boardRow * boardIndex.stride; @@ -211,6 +227,7 @@ function findUserBoard(tables) { const membershipOffset = boardRef.row * membership.stride; let userRefs = 0; let cpuRefs = 0; + let invalidUserRefs = 0; let occupied = 0; let firstFreeSlot = -1; let compact = true; @@ -223,7 +240,10 @@ function findUserBoard(tables) { if (firstFreeSlot >= 0) compact = false; occupied += 1; const ref = decodeRef(value); - if (ref.tableId === 4168) userRefs += 1; + if (ref.tableId === 4168) { + userRefs += 1; + if (ref.row >= userRows.capacity) invalidUserRefs += 1; + } if (ref.tableId === 4288) cpuRefs += 1; } candidates.push({ @@ -233,6 +253,7 @@ function findUserBoard(tables) { occupied, userRefs, cpuRefs, + invalidUserRefs, firstFreeSlot, compact, }); @@ -242,15 +263,26 @@ function findUserBoard(tables) { (right.userRefs - left.userRefs) || (left.cpuRefs - right.cpuRefs) || (right.occupied - left.occupied)); - const userCandidates = candidates.filter((candidate) => candidate.userRefs > 0 && candidate.cpuRefs === 0); - const compactCandidates = userCandidates.filter((candidate) => candidate.compact); - if (compactCandidates.length === 0 && userCandidates.length > 0) { - throw new Error('Could not identify a compact user board membership row'); + const userCandidates = candidates.filter((candidate) => candidate.userRefs > 0); + const eligibleCandidates = userCandidates.filter((candidate) => + candidate.compact && candidate.invalidUserRefs === 0 && + candidate.occupied === candidate.userRefs); + if (eligibleCandidates.length > 1) { + throw new Error('Could not uniquely identify the user board from table 4168 membership references'); } - if (compactCandidates.length !== 1) { + if (eligibleCandidates.length === 0) { + if (userCandidates.some((candidate) => candidate.invalidUserRefs > 0)) { + throw new Error('User board membership contains an out-of-range table 4168 reference'); + } + if (userCandidates.some((candidate) => candidate.occupied !== candidate.userRefs)) { + throw new Error('User board membership is not user-only and contains a mixed table reference'); + } + if (userCandidates.some((candidate) => !candidate.compact)) { + throw new Error('Could not identify a compact user board membership row'); + } throw new Error('Could not uniquely identify the user board from table 4168 membership references'); } - const selected = compactCandidates[0]; + const selected = eligibleCandidates[0]; if (selected.firstFreeSlot < 0) throw new Error('The active recruiting board has no free membership slot'); return { selected, candidates: candidates.slice(0, 8) }; } diff --git a/tests/board-reanchor.test.cjs b/tests/board-reanchor.test.cjs index 6769972..d9eb2e7 100644 --- a/tests/board-reanchor.test.cjs +++ b/tests/board-reanchor.test.cjs @@ -39,6 +39,67 @@ function setContentRow(table, data, row) { if (table.id === 5847) data.writeUInt32LE(encodedRef(4168, 7), offset); } +function boardFixtures() { + const userRowsDefinition = TABLES.get(4168); + const boardIndexDefinition = TABLES.get(4251); + const membershipDefinition = TABLES.get(5847); + const userRows = { ...userRowsDefinition, data: tableData(userRowsDefinition) }; + const boardIndex = { ...boardIndexDefinition, data: tableData(boardIndexDefinition) }; + const membership = { ...membershipDefinition, data: tableData(membershipDefinition) }; + return { + userRows, + boardIndex, + membership, + tables: new Map([[4168, userRows], [4251, boardIndex], [5847, membership]]), + }; +} + +function anchorFixture() { + const table = TABLES.get(4168); + const data = tableData(table); + setFreeRow(table, data, 0); + setContentRow(table, data, 1); + const header = 0x1000n; + return { + table, + candidate: { + header, + base: deriveDataAddress(table, header), + data, + score: scoreCandidate(table, data), + freelistHead: 7, + }, + }; +} + +function rereadClient(table, candidate, replacements = {}) { + const requests = []; + return { + requests, + client: { + async readMemory(request) { + requests.push(request); + const range = request.ranges[0]; + const address = BigInt(range.address); + let bytes; + if (address === candidate.header) bytes = Buffer.from(signature(table), 'hex'); + if (address === candidate.header + 24n) { + bytes = Buffer.alloc(4); + bytes.writeUInt32LE(replacements.freelistHead ?? candidate.freelistHead); + } + for (const row of [0, 1]) { + if (address !== candidate.base + BigInt(row * table.stride)) continue; + const key = row === 0 ? 'freeRow' : 'contentRow'; + bytes = replacements[key] ?? candidate.data.subarray( + row * table.stride, (row + 1) * table.stride); + } + assert.ok(bytes, `unexpected reread ${range.address}`); + return { ranges: [{ bytesHex: bytes.toString('hex') }] }; + }, + }, + }; +} + test('TABLES contains six frozen table definitions', () => { assert.equal(TABLES.size, 6); assert.deepEqual([...TABLES.keys()], [4168, 4176, 4190, 4251, 5790, 5847]); @@ -51,8 +112,9 @@ test('TABLES rejects registry mutation', () => { assert.throws(() => TABLES.set(9999, {}), /read-only/i); assert.throws(() => TABLES.delete(4168), /read-only/i); assert.throws(() => TABLES.clear(), /read-only/i); + assert.throws(() => Map.prototype.set.call(TABLES, 9999, {}), /incompatible|receiver/i); } finally { - Map.prototype.delete.call(TABLES, 9999); + if (TABLES instanceof Map) Map.prototype.delete.call(TABLES, 9999); } }); @@ -100,16 +162,13 @@ test('selectTableCandidate rejects tied structural winners', () => { }); test('findUserBoard discovers one compact user membership row', () => { - const boardIndexDefinition = TABLES.get(4251); - const membershipDefinition = TABLES.get(5847); - const boardIndex = { ...boardIndexDefinition, data: tableData(boardIndexDefinition) }; - const membership = { ...membershipDefinition, data: tableData(membershipDefinition) }; + const { boardIndex, membership, tables } = boardFixtures(); boardIndex.data.writeUInt32LE(encodedRef(5847, 3), 2 * boardIndex.stride); membership.data.writeUInt32LE(encodedRef(4168, 10), 3 * membership.stride); membership.data.writeUInt32LE(encodedRef(4168, 11), 3 * membership.stride + 4); - const result = findUserBoard(new Map([[4251, boardIndex], [5847, membership]])); + const result = findUserBoard(tables); assert.equal(result.selected.boardRow, 2); assert.equal(result.selected.teamRow, 3); assert.equal(result.selected.firstFreeSlot, 2); @@ -117,15 +176,39 @@ test('findUserBoard discovers one compact user membership row', () => { }); test('findUserBoard rejects a user membership row with an interior hole', () => { - const boardIndexDefinition = TABLES.get(4251); - const membershipDefinition = TABLES.get(5847); - const boardIndex = { ...boardIndexDefinition, data: tableData(boardIndexDefinition) }; - const membership = { ...membershipDefinition, data: tableData(membershipDefinition) }; + const { boardIndex, membership, tables } = boardFixtures(); boardIndex.data.writeUInt32LE(encodedRef(5847, 3), 2 * boardIndex.stride); membership.data.writeUInt32LE(encodedRef(4168, 10), 3 * membership.stride); membership.data.writeUInt32LE(encodedRef(4168, 11), 3 * membership.stride + 8); - assert.throws(() => findUserBoard(new Map([[4251, boardIndex], [5847, membership]])), /compact/i); + assert.throws(() => findUserBoard(tables), /compact/i); +}); + +test('findUserBoard rejects compact membership containing a mixed table reference', () => { + const { boardIndex, membership, tables } = boardFixtures(); + boardIndex.data.writeUInt32LE(encodedRef(5847, 3), 2 * boardIndex.stride); + membership.data.writeUInt32LE(encodedRef(4168, 10), 3 * membership.stride); + membership.data.writeUInt32LE(encodedRef(4190, 11), 3 * membership.stride + 4); + + assert.throws(() => findUserBoard(tables), /user-only|mixed|occupied/i); +}); + +test('findUserBoard rejects an out-of-range 4168 membership reference', () => { + const { userRows, boardIndex, membership, tables } = boardFixtures(); + boardIndex.data.writeUInt32LE(encodedRef(5847, 3), 2 * boardIndex.stride); + membership.data.writeUInt32LE(encodedRef(4168, userRows.capacity), 3 * membership.stride); + + assert.throws(() => findUserBoard(tables), /range|user-only/i); +}); + +test('findUserBoard rejects multiple eligible compact user rows', () => { + const { boardIndex, membership, tables } = boardFixtures(); + boardIndex.data.writeUInt32LE(encodedRef(5847, 3), 2 * boardIndex.stride); + boardIndex.data.writeUInt32LE(encodedRef(5847, 4), 3 * boardIndex.stride); + membership.data.writeUInt32LE(encodedRef(4168, 10), 3 * membership.stride); + membership.data.writeUInt32LE(encodedRef(4168, 11), 4 * membership.stride); + + assert.throws(() => findUserBoard(tables), /uniquely|ambiguous/i); }); test('readRange explicitly opts diagnostic reads into unsupported builds', async () => { @@ -165,6 +248,35 @@ test('validateAnchorReread rejects a changed table header', async () => { assert.ok(requests.every((request) => request.allowUnsupportedBuild === true)); }); +test('validateAnchorReread rejects a changed freelist head', async () => { + const { table, candidate } = anchorFixture(); + const { client, requests } = rereadClient(table, candidate, { freelistHead: 8 }); + + await assert.rejects(() => validateAnchorReread(client, table, candidate), /freelist.*mismatch/i); + assert.ok(requests.every((request) => request.allowUnsupportedBuild === true)); +}); + +test('validateAnchorReread rejects a changed representative free row', async () => { + const { table, candidate } = anchorFixture(); + const changed = Buffer.from(candidate.data.subarray(0, table.stride)); + changed.writeUInt32LE(99, 0); + const { client, requests } = rereadClient(table, candidate, { freeRow: changed }); + + await assert.rejects(() => validateAnchorReread(client, table, candidate), /freeRow.*mismatch/i); + assert.ok(requests.every((request) => request.allowUnsupportedBuild === true)); +}); + +test('validateAnchorReread rejects a changed representative content row', async () => { + const { table, candidate } = anchorFixture(); + const offset = table.stride; + const changed = Buffer.from(candidate.data.subarray(offset, offset + table.stride)); + changed.writeUInt32LE(99, 12); + const { client, requests } = rereadClient(table, candidate, { contentRow: changed }); + + await assert.rejects(() => validateAnchorReread(client, table, candidate), /contentRow.*mismatch/i); + assert.ok(requests.every((request) => request.allowUnsupportedBuild === true)); +}); + test('locateTable explicitly opts scans and validation reads into unsupported builds', async () => { const table = TABLES.get(4176); const header = 0x1000n; @@ -201,6 +313,33 @@ test('locateTable explicitly opts scans and validation reads into unsupported bu assert.ok(readRequests.every((request) => request.allowUnsupportedBuild === true)); }); +test('locateTable rejects a positive candidate when bounded scanning never completes', async () => { + const table = TABLES.get(4176); + const header = 0x1000n; + const base = deriveDataAddress(table, header); + const data = tableData(table); + setFreeRow(table, data, 0); + let pages = 0; + const client = { + async scanMemoryPage() { + pages += 1; + return { + complete: false, + nextCursor: canonical(BigInt(pages + 1) * 0x1000n), + matches: pages === 1 ? [{ address: canonical(header) }] : [], + }; + }, + async readMemory(request) { + const range = request.ranges[0]; + const bytes = BigInt(range.address) === base ? data : Buffer.alloc(range.length); + return { ranges: [{ bytesHex: bytes.toString('hex') }] }; + }, + }; + + await assert.rejects(() => locateTable(client, table, { log: () => {} }), /scan.*complete|incomplete/i); + assert.equal(pages, 512); +}); + test('live anchor consumes the reusable layer and records identity validation summaries', () => { const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'board-verification', 'live-anchor.cjs'), 'utf8'); @@ -212,10 +351,33 @@ test('live anchor consumes the reusable layer and records identity validation su assert.match(source, /validationSummaries/); }); -test('live snapshot rejects executable drift and explicitly opts every read into diagnostics', () => { - const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'board-verification', - 'live-table-snapshot.cjs'), 'utf8'); - assert.match(source, /executableSha256/); - assert.match(source, /different game executable/i); - assert.match(source, /allowUnsupportedBuild:\s*true/); +test('live snapshot behavior rejects identity drift and opts every read into diagnostics', async () => { + const priorExitCode = process.exitCode; + let snapshot; + try { + snapshot = require('../scripts/board-verification/live-table-snapshot.cjs'); + } finally { + process.exitCode = priorExitCode; + } + assert.equal(typeof snapshot.assertAnchorIdentity, 'function'); + assert.equal(typeof snapshot.readAnchoredTables, 'function'); + assert.throws(() => snapshot.assertAnchorIdentity( + { pid: 7, executableSha256: 'AA' }, { pid: 8 }, 'AA'), /different game process/i); + assert.throws(() => snapshot.assertAnchorIdentity( + { pid: 7, executableSha256: 'AA' }, { pid: 7 }, 'BB'), /different game executable/i); + + const requests = []; + const client = { + async readMemory(request) { + requests.push(request); + return { ranges: [{ bytesHex: '00'.repeat(request.ranges[0].length) }] }; + }, + }; + const tables = await snapshot.readAnchoredTables(client, { tables: { + 4168: { dataBase: '0x1000', stride: 4, capacity: 2, freelistHeadValue: 0 }, + 4176: { dataBase: '0x2000', stride: 4, capacity: 1, freelistHeadValue: 0 }, + } }); + assert.deepEqual(Object.keys(tables), ['4168', '4176']); + assert.equal(requests.length, 2); + assert.ok(requests.every((request) => request.allowUnsupportedBuild === true)); }); From 5a33345c1e69873426a98a8d08314f7938b9eb35 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 21:49:22 -0500 Subject: [PATCH 10/16] feat: analyze board re-anchor evidence --- package.json | 2 +- .../board-verification/reanchor-evidence.cjs | 406 ++++++++++++++++++ tests/board-reanchor-evidence.test.cjs | 280 ++++++++++++ 3 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 scripts/board-verification/reanchor-evidence.cjs create mode 100644 tests/board-reanchor-evidence.test.cjs diff --git a/package.json b/package.json index a390b15..dc9da98 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "packages/cli" ], "scripts": { - "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs && node --check scripts/game-build-manifest.cjs && node --check scripts/generate-game-builds.cjs && node --check scripts/board-verification/reanchor-lib.cjs && node --check scripts/board-verification/live-anchor.cjs && node --check scripts/board-verification/live-table-snapshot.cjs && node scripts/generate-game-builds.cjs --check", + "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs && node --check scripts/game-build-manifest.cjs && node --check scripts/generate-game-builds.cjs && node --check scripts/board-verification/reanchor-lib.cjs && node --check scripts/board-verification/reanchor-evidence.cjs && node --check scripts/board-verification/live-anchor.cjs && node --check scripts/board-verification/live-table-snapshot.cjs && node scripts/generate-game-builds.cjs --check", "test": "node scripts/run-tests.cjs", "build:frtk-profile": "node scripts/build-frtk-profile.cjs", "pack:preview": "node scripts/package-release.cjs" diff --git a/scripts/board-verification/reanchor-evidence.cjs b/scripts/board-verification/reanchor-evidence.cjs new file mode 100644 index 0000000..0269c9b --- /dev/null +++ b/scripts/board-verification/reanchor-evidence.cjs @@ -0,0 +1,406 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const DEFAULT_OUTPUT_ROOT = path.resolve(__dirname, '..', '..', '.frtk', 'board-reanchor'); +const TABLE_IDS = Object.freeze(['4168', '4176', '4190', '4251', '5790', '5847']); +const BOARD_RVAS = Object.freeze([ + 'genericRecordWrapperVtableRva', + 'recruitingControllerVtableRva', + 'fullAddRva', + 'fullRemoveRva', +]); +const IMAGE_SCN_MEM_EXECUTE = 0x20000000; +const IMAGE_SCN_MEM_READ = 0x40000000; + +function canonicalSha(value) { + if (typeof value !== 'string' || !/^[0-9A-Fa-f]{64}$/.test(value)) { + throw new TypeError('Executable SHA-256 must contain exactly 64 hexadecimal characters'); + } + return value.toUpperCase(); +} + +function toAddress(value, label = 'address') { + let result; + if (typeof value === 'bigint') { + result = value; + } else if (typeof value === 'number' && Number.isSafeInteger(value)) { + result = BigInt(value); + } else if (typeof value === 'string' && /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(value)) { + result = BigInt(value); + } else { + throw new TypeError(`${label} must be a bigint, safe integer, or hexadecimal string`); + } + if (result < 0n) throw new RangeError(`${label} must not be negative`); + return result; +} + +function canonicalHex(value, label) { + return `0x${toAddress(value, label).toString(16).toUpperCase()}`; +} + +function evidenceDirectory(executableSha256, outputRoot = DEFAULT_OUTPUT_ROOT) { + const root = path.resolve(outputRoot); + return path.join(root, canonicalSha(executableSha256)); +} + +function writeEvidence(filePath, value, { + fileSystem = fs, + temporaryToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`, +} = {}) { + const target = path.resolve(filePath); + const directory = path.dirname(target); + const safeToken = String(temporaryToken).replace(/[^A-Za-z0-9_-]/g, '_'); + if (safeToken.length === 0) throw new TypeError('Temporary token must not be empty'); + const temporary = path.join(directory, `.${path.basename(target)}.${safeToken}.tmp`); + const serialized = `${JSON.stringify(value, null, 2)}\n`; + fileSystem.mkdirSync(directory, { recursive: true }); + let temporaryCreated = false; + try { + fileSystem.writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx' }); + temporaryCreated = true; + fileSystem.renameSync(temporary, target); + } catch (error) { + if (temporaryCreated) { + try { + fileSystem.rmSync(temporary, { force: true }); + } catch { + // Preserve the original write or rename failure. + } + } + throw error; + } + return target; +} + +function evidenceIdentity(evidence) { + return { + pid: evidence?.session?.pid ?? evidence?.pid, + sessionId: evidence?.session?.sessionId ?? evidence?.sessionId ?? evidence?.hostSessionToken, + executableSha256: evidence?.build?.executableSha256 ?? evidence?.executableSha256, + }; +} + +function readEvidence(filePath, expectedIdentity, { fileSystem = fs } = {}) { + const evidence = JSON.parse(fileSystem.readFileSync(path.resolve(filePath), 'utf8')); + if (!expectedIdentity) return evidence; + + const actual = evidenceIdentity(evidence); + const expectedSessionId = expectedIdentity.sessionId ?? expectedIdentity.hostSessionToken; + if (actual.pid !== expectedIdentity.pid) { + throw new Error('Evidence belongs to a different process PID'); + } + if (actual.sessionId !== expectedSessionId) { + throw new Error('Evidence belongs to a different host session'); + } + if (canonicalSha(actual.executableSha256) !== canonicalSha(expectedIdentity.executableSha256)) { + throw new Error('Evidence belongs to a different executable SHA-256'); + } + return evidence; +} + +function requireBufferRange(buffer, offset, length, label) { + if (!Buffer.isBuffer(buffer)) throw new TypeError('PE image must be a Buffer'); + if (!Number.isInteger(offset) || offset < 0 || offset + length > buffer.length) { + throw new Error(`PE image is truncated before ${label}`); + } +} + +function parsePeSections(image) { + requireBufferRange(image, 0, 0x40, 'the DOS header'); + if (image.toString('ascii', 0, 2) !== 'MZ') throw new Error('PE image has no MZ signature'); + const peOffset = image.readUInt32LE(0x3C); + requireBufferRange(image, peOffset, 24, 'the PE file header'); + if (image.toString('binary', peOffset, peOffset + 4) !== 'PE\0\0') { + throw new Error('PE image has no PE signature'); + } + + const numberOfSections = image.readUInt16LE(peOffset + 6); + const optionalHeaderSize = image.readUInt16LE(peOffset + 20); + const optionalHeaderOffset = peOffset + 24; + if (numberOfSections === 0 || numberOfSections > 96) { + throw new Error('PE image has an invalid section count'); + } + if (optionalHeaderSize < 60) throw new Error('PE optional header is too small'); + requireBufferRange(image, optionalHeaderOffset, optionalHeaderSize, 'the PE optional header'); + const magic = image.readUInt16LE(optionalHeaderOffset); + if (magic !== 0x20B && magic !== 0x10B) throw new Error('PE optional header has an unsupported magic'); + const sizeOfImage = image.readUInt32LE(optionalHeaderOffset + 56); + if (sizeOfImage === 0) throw new Error('PE SizeOfImage must not be zero'); + + const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize; + requireBufferRange(image, sectionTableOffset, numberOfSections * 40, 'the PE section table'); + const sections = []; + for (let index = 0; index < numberOfSections; index += 1) { + const offset = sectionTableOffset + index * 40; + const name = image.toString('ascii', offset, offset + 8).replace(/\0.*$/, ''); + const virtualSize = image.readUInt32LE(offset + 8); + const virtualAddress = image.readUInt32LE(offset + 12); + const rawSize = image.readUInt32LE(offset + 16); + const characteristics = image.readUInt32LE(offset + 36); + const mappedSize = Math.max(virtualSize, rawSize); + if (mappedSize === 0 || virtualAddress >= sizeOfImage || + virtualAddress + mappedSize > sizeOfImage) { + throw new Error(`PE section ${name || index} escapes SizeOfImage`); + } + sections.push(Object.freeze({ + name, + virtualAddress, + virtualSize, + rawSize, + mappedSize, + characteristics, + readable: (characteristics & IMAGE_SCN_MEM_READ) !== 0, + executable: (characteristics & IMAGE_SCN_MEM_EXECUTE) !== 0, + })); + } + return Object.freeze({ sizeOfImage, sections: Object.freeze(sections) }); +} + +function classifyModuleAddress(address, moduleBase, pe) { + if (!pe || !Number.isInteger(pe.sizeOfImage) || !Array.isArray(pe.sections)) { + throw new TypeError('Parsed PE metadata is required'); + } + const target = toAddress(address); + const base = toAddress(moduleBase, 'module base'); + const end = base + BigInt(pe.sizeOfImage); + if (target < base || target >= end) { + return Object.freeze({ + address: canonicalHex(target), + insideImage: false, + rva: null, + section: null, + readable: false, + executable: false, + }); + } + + const rvaValue = target - base; + const section = pe.sections.find((candidate) => + rvaValue >= BigInt(candidate.virtualAddress) && + rvaValue < BigInt(candidate.virtualAddress + candidate.mappedSize)) ?? null; + return Object.freeze({ + address: canonicalHex(target), + insideImage: true, + rva: canonicalHex(rvaValue), + section, + readable: section?.readable === true, + executable: section?.executable === true, + }); +} + +function captureStackReturns(capture) { + if (Array.isArray(capture?.hits)) { + return capture.hits.flatMap((hit) => Array.isArray(hit?.stackReturnAddresses) ? hit.stackReturnAddresses : []); + } + return Array.isArray(capture?.stackReturnAddresses) ? capture.stackReturnAddresses : []; +} + +function rankRoutineCandidates(captures, { moduleBase, pe }) { + if (!Array.isArray(captures) || captures.length < 2) { + throw new Error('At least two independent captures are required to rank routine candidates'); + } + const captureCounts = captures.map((capture) => { + const counts = new Map(); + for (const address of captureStackReturns(capture)) { + const key = canonicalHex(address); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; + }); + + const ranked = []; + for (const address of captureCounts[0].keys()) { + if (!captureCounts.every((counts) => counts.has(address))) continue; + const classification = classifyModuleAddress(address, moduleBase, pe); + if (!classification.insideImage || !classification.executable) continue; + const captureCount = captureCounts.length; + const hitCount = captureCounts.reduce((sum, counts) => sum + counts.get(address), 0); + ranked.push({ + address, + rva: classification.rva, + captureCount, + hitCount, + score: captureCount * 100 + hitCount, + }); + } + ranked.sort((left, right) => + (right.score - left.score) || + (toAddress(left.address) < toAddress(right.address) ? -1 : 1)); + return ranked; +} + +function sameAddress(left, right) { + return toAddress(left) === toAddress(right); +} + +function validateVtable(object, label, moduleBase, pe) { + const table = classifyModuleAddress(object.vtableAddress, moduleBase, pe); + if (!table.insideImage || !table.readable) { + return `${label} vtable is not in readable main-module image memory`; + } + if (!Array.isArray(object.vtableEntries) || object.vtableEntries.length === 0) { + return `${label} vtable has no sampled entries`; + } + for (const entry of object.vtableEntries) { + const target = classifyModuleAddress(entry, moduleBase, pe); + if (!target.insideImage || !target.executable) { + return `${label} vtable entry is not in an executable main-module section`; + } + } + return null; +} + +function validateObjectShapes(capture, { moduleBase, pe }) { + const reject = (detail) => Object.freeze({ passed: false, detail }); + try { + const args = capture?.arguments; + const cells = capture?.pointerCells; + const controller = capture?.controller; + const team = capture?.team; + const recruit = capture?.recruit; + const expected = capture?.expected; + if (!args || !cells || !controller || !team || !recruit || !expected) { + return reject('Full entry object shape is incomplete'); + } + if (!sameAddress(args.rcx, controller.address)) return reject('RCX does not contain the recruiting controller'); + if (controller.readable !== true || controller.descriptorTableId !== 5003) { + return reject('RCX object is not a readable descriptor-table 5003 recruiting controller'); + } + if (controller.boardStore?.offset !== 0x138 || controller.boardStore?.readable !== true || + controller.boardStore?.membershipRow !== expected.membershipRow) { + return reject('Controller board store at +0x138 does not expose the expected membership row'); + } + + if (cells.team?.readable !== true || !sameAddress(args.rdx, cells.team.address) || + !sameAddress(cells.team.value, team.address)) { + return reject('RDX is not a readable pointer cell containing the Team wrapper'); + } + if (cells.recruit?.readable !== true || !sameAddress(args.r8, cells.recruit.address) || + !sameAddress(cells.recruit.value, recruit.address)) { + return reject('R8 is not a readable pointer cell containing the Recruit wrapper'); + } + if (team.readable !== true || team.descriptorTableId !== 6334 || team.row !== expected.teamRow || + team.field10Readable !== true || team.field18Readable !== true) { + return reject('Team wrapper descriptor, row identity, or +0x10/+0x18 fields are invalid'); + } + if (recruit.readable !== true || recruit.descriptorTableId !== 4269 || + recruit.row !== expected.recruitRow || recruit.field10Readable !== true || + recruit.field18Readable !== true) { + return reject('Recruit wrapper descriptor, row identity, or +0x10/+0x18 fields are invalid'); + } + if (!sameAddress(team.vtableAddress, recruit.vtableAddress)) { + return reject('Team and Recruit wrappers do not share the generic record-wrapper vtable'); + } + + for (const [object, label] of [[controller, 'Controller'], [team, 'Team'], [recruit, 'Recruit']]) { + const error = validateVtable(object, label, moduleBase, pe); + if (error) return reject(error); + } + return Object.freeze({ + passed: true, + detail: 'Full entry arguments and object shapes matched', + genericRecordWrapperVtableAddress: canonicalHex(team.vtableAddress), + recruitingControllerVtableAddress: canonicalHex(controller.vtableAddress), + }); + } catch (error) { + return reject(`Full entry object shape is malformed: ${error.message}`); + } +} + +function deriveVtableRvas(captures, { moduleBase, pe }) { + if (!Array.isArray(captures) || captures.length < 2) { + throw new Error('At least two object captures are required to prove stable vtables'); + } + const validations = captures.map((capture) => validateObjectShapes(capture, { moduleBase, pe })); + const rejected = validations.findIndex((validation) => !validation.passed); + if (rejected !== -1) { + throw new Error(`Capture ${rejected + 1} object shape rejected: ${validations[rejected].detail}`); + } + const wrapper = validations[0].genericRecordWrapperVtableAddress; + const controller = validations[0].recruitingControllerVtableAddress; + if (!validations.every((validation) => + validation.genericRecordWrapperVtableAddress === wrapper && + validation.recruitingControllerVtableAddress === controller)) { + throw new Error('Vtable addresses were not stable across captures'); + } + return Object.freeze({ + genericRecordWrapperVtableRva: classifyModuleAddress(wrapper, moduleBase, pe).rva, + recruitingControllerVtableRva: classifyModuleAddress(controller, moduleBase, pe).rva, + }); +} + +function normalizedCount(value) { + return Number.isInteger(value) && value >= 0 ? value : 0; +} + +function normalizedScore(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function buildCandidateArtifact(input) { + if (!input || typeof input !== 'object') throw new TypeError('Candidate input is required'); + const tables = Object.fromEntries(TABLE_IDS.map((id) => { + const summary = input.tables?.[id] ?? {}; + return [id, { + passed: summary.passed === true, + candidateCount: normalizedCount(summary.candidateCount), + score: normalizedScore(summary.score), + rereadPassed: summary.rereadPassed === true, + }]; + })); + const captures = Object.fromEntries(['add', 'remove'].map((operation) => { + const summary = input.captures?.[operation] ?? {}; + return [operation, { + writeCount: normalizedCount(summary.writeCount), + executeCount: normalizedCount(summary.executeCount), + consistent: summary.consistent === true, + }]; + })); + const proposedBoard = Object.fromEntries(BOARD_RVAS.map((name) => + [name, canonicalHex(input.proposedBoard?.[name], name)])); + const sourceGates = Array.isArray(input.gates) ? input.gates : []; + const gates = sourceGates.map((gate, index) => ({ + name: typeof gate?.name === 'string' && gate.name.length > 0 ? gate.name : `unnamed-gate-${index + 1}`, + passed: gate?.passed === true, + detail: typeof gate?.detail === 'string' ? gate.detail : String(gate?.detail ?? ''), + })); + const allTablesPassed = Object.values(tables).every((table) => table.passed && table.rereadPassed); + const allCapturesPassed = Object.values(captures).every((capture) => + capture.writeCount >= 2 && capture.executeCount >= 1 && capture.consistent); + const allGatesPassed = gates.length > 0 && gates.every((gate) => gate.passed); + + return { + schemaVersion: 1, + build: { + label: String(input.build?.label ?? ''), + executableSize: normalizedCount(input.build?.executableSize), + executableSha256: canonicalSha(input.build?.executableSha256), + }, + session: { + pid: normalizedCount(input.session?.pid), + sessionId: String(input.session?.sessionId ?? ''), + moduleBase: canonicalHex(input.session?.moduleBase, 'module base'), + capturedAt: String(input.session?.capturedAt ?? ''), + }, + tables, + captures, + proposedBoard, + gates, + passed: allTablesPassed && allCapturesPassed && allGatesPassed, + }; +} + +module.exports = { + evidenceDirectory, + writeEvidence, + readEvidence, + parsePeSections, + classifyModuleAddress, + rankRoutineCandidates, + validateObjectShapes, + deriveVtableRvas, + buildCandidateArtifact, +}; diff --git a/tests/board-reanchor-evidence.test.cjs b/tests/board-reanchor-evidence.test.cjs new file mode 100644 index 0000000..e002fd3 --- /dev/null +++ b/tests/board-reanchor-evidence.test.cjs @@ -0,0 +1,280 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + evidenceDirectory, + writeEvidence, + readEvidence, + parsePeSections, + classifyModuleAddress, + rankRoutineCandidates, + validateObjectShapes, + deriveVtableRvas, + buildCandidateArtifact, +} = require('../scripts/board-verification/reanchor-evidence.cjs'); + +const SHA = 'ab'.repeat(32); +const MODULE_BASE = 0x140000000n; + +function peFixture() { + const image = Buffer.alloc(0x400); + image.write('MZ', 0, 'ascii'); + image.writeUInt32LE(0x80, 0x3C); + image.write('PE\0\0', 0x80, 'binary'); + image.writeUInt16LE(0x8664, 0x84); + image.writeUInt16LE(2, 0x86); + image.writeUInt16LE(0xF0, 0x94); + image.writeUInt16LE(0x20B, 0x98); + image.writeUInt32LE(0x4000, 0x98 + 56); + + const sectionTable = 0x98 + 0xF0; + image.write('.text\0\0\0', sectionTable, 'ascii'); + image.writeUInt32LE(0x600, sectionTable + 8); + image.writeUInt32LE(0x1000, sectionTable + 12); + image.writeUInt32LE(0x600, sectionTable + 16); + image.writeUInt32LE(0x60000020, sectionTable + 36); + + const rdata = sectionTable + 40; + image.write('.rdata\0\0', rdata, 'ascii'); + image.writeUInt32LE(0x400, rdata + 8); + image.writeUInt32LE(0x2000, rdata + 12); + image.writeUInt32LE(0x400, rdata + 16); + image.writeUInt32LE(0x40000040, rdata + 36); + return image; +} + +function canonical(value) { + return `0x${BigInt(value).toString(16).toUpperCase()}`; +} + +function objectShape(heapOffset = 0n) { + const controllerAddress = 0x200000000n + heapOffset; + const teamCellAddress = 0x200001000n + heapOffset; + const recruitCellAddress = 0x200001100n + heapOffset; + const teamAddress = 0x200002000n + heapOffset; + const recruitAddress = 0x200003000n + heapOffset; + const wrapperVtableAddress = MODULE_BASE + 0x2100n; + const controllerVtableAddress = MODULE_BASE + 0x2200n; + const executableEntries = [MODULE_BASE + 0x1100n, MODULE_BASE + 0x1200n]; + return { + arguments: { + rcx: canonical(controllerAddress), + rdx: canonical(teamCellAddress), + r8: canonical(recruitCellAddress), + }, + pointerCells: { + team: { address: canonical(teamCellAddress), value: canonical(teamAddress), readable: true }, + recruit: { address: canonical(recruitCellAddress), value: canonical(recruitAddress), readable: true }, + }, + controller: { + address: canonical(controllerAddress), + readable: true, + descriptorTableId: 5003, + vtableAddress: canonical(controllerVtableAddress), + vtableEntries: executableEntries.map(canonical), + boardStore: { offset: 0x138, readable: true, membershipRow: 11 }, + }, + team: { + address: canonical(teamAddress), + readable: true, + descriptorTableId: 6334, + row: 22, + field10Readable: true, + field18Readable: true, + vtableAddress: canonical(wrapperVtableAddress), + vtableEntries: executableEntries.map(canonical), + }, + recruit: { + address: canonical(recruitAddress), + readable: true, + descriptorTableId: 4269, + row: 33, + field10Readable: true, + field18Readable: true, + vtableAddress: canonical(wrapperVtableAddress), + vtableEntries: executableEntries.map(canonical), + }, + expected: { membershipRow: 11, teamRow: 22, recruitRow: 33 }, + }; +} + +function tableSummaries() { + return Object.fromEntries(['4168', '4176', '4190', '4251', '5790', '5847'].map((id) => [id, { + passed: true, + candidateCount: 1, + score: 9, + rereadPassed: true, + }])); +} + +test('evidenceDirectory uses the ignored board-reanchor root and uppercase SHA', () => { + assert.equal( + evidenceDirectory(SHA), + path.resolve('.frtk', 'board-reanchor', SHA.toUpperCase()), + ); +}); + +test('writeEvidence writes a temporary sibling before atomically renaming it', () => { + const calls = []; + const fileSystem = { + mkdirSync(directory, options) { calls.push(['mkdir', directory, options]); }, + writeFileSync(filePath, contents, options) { calls.push(['write', filePath, contents, options]); }, + renameSync(from, to) { calls.push(['rename', from, to]); }, + rmSync(filePath, options) { calls.push(['remove', filePath, options]); }, + }; + const target = path.resolve('ignored', 'candidate.json'); + + writeEvidence(target, { schemaVersion: 1 }, { fileSystem, temporaryToken: 'TEST' }); + + const write = calls.find(([operation]) => operation === 'write'); + const rename = calls.find(([operation]) => operation === 'rename'); + assert.equal(path.dirname(write[1]), path.dirname(target)); + assert.notEqual(write[1], target); + assert.deepEqual(rename.slice(1), [write[1], target]); + assert.equal(write[2], '{\n "schemaVersion": 1\n}\n'); + assert.equal(calls.some(([operation]) => operation === 'remove'), false); +}); + +test('writeEvidence never removes a colliding temporary sibling it did not create', () => { + const calls = []; + const collision = Object.assign(new Error('temporary evidence already exists'), { code: 'EEXIST' }); + const fileSystem = { + mkdirSync() {}, + writeFileSync() { throw collision; }, + renameSync() { assert.fail('rename must not run after a temporary-file collision'); }, + rmSync(filePath) { calls.push(filePath); }, + }; + + assert.throws( + () => writeEvidence(path.resolve('ignored', 'candidate.json'), {}, { + fileSystem, + temporaryToken: 'COLLISION', + }), + (error) => error === collision, + ); + assert.deepEqual(calls, []); +}); + +test('readEvidence rejects evidence from another process, host session, or executable', () => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'cfb27-evidence-')); + const evidencePath = path.join(temporaryDirectory, 'capture.json'); + writeEvidence(evidencePath, { + build: { executableSha256: SHA.toUpperCase() }, + session: { pid: 77, sessionId: 'host-start-1' }, + }); + + const identity = { pid: 77, sessionId: 'host-start-1', executableSha256: SHA }; + assert.equal(readEvidence(evidencePath, identity).session.pid, 77); + assert.throws(() => readEvidence(evidencePath, { ...identity, pid: 78 }), /different process/i); + assert.throws(() => readEvidence(evidencePath, { ...identity, sessionId: 'host-start-2' }), /different host session/i); + assert.throws(() => readEvidence(evidencePath, { ...identity, executableSha256: 'cd'.repeat(32) }), /different executable/i); +}); + +test('parsePeSections distinguishes executable text from readable non-executable rdata', () => { + const pe = parsePeSections(peFixture()); + assert.equal(pe.sizeOfImage, 0x4000); + assert.deepEqual(pe.sections.map(({ name, readable, executable }) => ({ name, readable, executable })), [ + { name: '.text', readable: true, executable: true }, + { name: '.rdata', readable: true, executable: false }, + ]); +}); + +test('classifyModuleAddress emits an RVA only for addresses inside SizeOfImage', () => { + const pe = parsePeSections(peFixture()); + const text = classifyModuleAddress(MODULE_BASE + 0x1100n, MODULE_BASE, pe); + assert.deepEqual( + { insideImage: text.insideImage, rva: text.rva, section: text.section.name, executable: text.executable }, + { insideImage: true, rva: '0x1100', section: '.text', executable: true }, + ); + const below = classifyModuleAddress(MODULE_BASE - 1n, MODULE_BASE, pe); + const end = classifyModuleAddress(MODULE_BASE + 0x4000n, MODULE_BASE, pe); + assert.equal(below.rva, null); + assert.equal(end.rva, null); + assert.equal(below.insideImage, false); + assert.equal(end.insideImage, false); +}); + +test('rankRoutineCandidates ranks only common executable stack returns across captures', () => { + const pe = parsePeSections(peFixture()); + const common = canonical(MODULE_BASE + 0x1100n); + const captures = [ + { hits: [{ stackReturnAddresses: [common, canonical(MODULE_BASE + 0x1200n), canonical(MODULE_BASE + 0x2100n)] }, { stackReturnAddresses: [common] }] }, + { hits: [{ stackReturnAddresses: [canonical(MODULE_BASE + 0x1300n), common, canonical(MODULE_BASE + 0x2100n)] }] }, + ]; + + assert.deepEqual(rankRoutineCandidates(captures, { moduleBase: MODULE_BASE, pe }), [{ + address: common, + rva: '0x1100', + captureCount: 2, + hitCount: 3, + score: 203, + }]); +}); + +test('object validation and transition derivation require stable readable vtables with executable entries', () => { + const pe = parsePeSections(peFixture()); + const first = objectShape(); + const afterTransition = objectShape(0x100000n); + const validation = validateObjectShapes(first, { moduleBase: MODULE_BASE, pe }); + assert.equal(validation.passed, true); + assert.deepEqual(deriveVtableRvas([first, afterTransition], { moduleBase: MODULE_BASE, pe }), { + genericRecordWrapperVtableRva: '0x2100', + recruitingControllerVtableRva: '0x2200', + }); + + const nonExecutableEntry = objectShape(); + nonExecutableEntry.team.vtableEntries[0] = canonical(MODULE_BASE + 0x2100n); + assert.equal(validateObjectShapes(nonExecutableEntry, { moduleBase: MODULE_BASE, pe }).passed, false); +}); + +test('object validation decisively rejects a common low-level routine with wrong entry arguments', () => { + const pe = parsePeSections(peFixture()); + const lowLevel = objectShape(); + lowLevel.arguments.rcx = lowLevel.team.address; + const validation = validateObjectShapes(lowLevel, { moduleBase: MODULE_BASE, pe }); + assert.equal(validation.passed, false); + assert.match(validation.detail, /RCX/i); + assert.throws( + () => deriveVtableRvas([lowLevel, objectShape(0x100000n)], { moduleBase: MODULE_BASE, pe }), + /object shape/i, + ); +}); + +test('buildCandidateArtifact emits the complete schema and passes only when every gate passes', () => { + const input = { + build: { label: 'Patch 1', executableSize: 123, executableSha256: SHA }, + session: { pid: 77, sessionId: 'host-start-1', moduleBase: canonical(MODULE_BASE), capturedAt: '2026-07-16T12:00:00.000Z' }, + tables: tableSummaries(), + captures: { + add: { writeCount: 2, executeCount: 1, consistent: true }, + remove: { writeCount: 2, executeCount: 1, consistent: true }, + }, + proposedBoard: { + genericRecordWrapperVtableRva: '0x2100', + recruitingControllerVtableRva: '0x2200', + fullAddRva: '0x1100', + fullRemoveRva: '0x1200', + }, + gates: [ + { name: 'pe-sections', passed: true, detail: 'all addresses classified' }, + { name: 'argument-shapes', passed: true, detail: 'full entry arguments matched' }, + ], + }; + const candidate = buildCandidateArtifact(input); + assert.equal(candidate.schemaVersion, 1); + assert.deepEqual(Object.keys(candidate.tables), ['4168', '4176', '4190', '4251', '5790', '5847']); + assert.deepEqual(candidate.proposedBoard, input.proposedBoard); + assert.equal(candidate.gates.every((gate) => typeof gate.passed === 'boolean'), true); + assert.equal(candidate.passed, true); + + const failed = buildCandidateArtifact({ + ...input, + gates: input.gates.map((gate, index) => index === 0 ? { ...gate, passed: false } : gate), + }); + assert.equal(failed.passed, false); +}); From 5acaee67609efaccf45407702804dc0a53db9ef0 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 22:06:27 -0500 Subject: [PATCH 11/16] fix: harden board re-anchor evidence --- .../board-verification/reanchor-evidence.cjs | 645 +++++++++++++----- tests/board-reanchor-evidence.test.cjs | 375 ++++++---- 2 files changed, 732 insertions(+), 288 deletions(-) diff --git a/scripts/board-verification/reanchor-evidence.cjs b/scripts/board-verification/reanchor-evidence.cjs index 0269c9b..8372964 100644 --- a/scripts/board-verification/reanchor-evidence.cjs +++ b/scripts/board-verification/reanchor-evidence.cjs @@ -4,7 +4,8 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); -const DEFAULT_OUTPUT_ROOT = path.resolve(__dirname, '..', '..', '.frtk', 'board-reanchor'); +const REPOSITORY_ROOT = path.resolve(__dirname, '..', '..'); +const DEFAULT_OUTPUT_ROOT = path.join(REPOSITORY_ROOT, '.frtk', 'board-reanchor'); const TABLE_IDS = Object.freeze(['4168', '4176', '4190', '4251', '5790', '5847']); const BOARD_RVAS = Object.freeze([ 'genericRecordWrapperVtableRva', @@ -12,14 +13,104 @@ const BOARD_RVAS = Object.freeze([ 'fullAddRva', 'fullRemoveRva', ]); +const REQUIRED_GATE_NAMES = Object.freeze([ + 'buildIdentity', + 'sessionIdentity', + 'tableAnchors', + 'addCaptureConsistency', + 'removeCaptureConsistency', + 'routinePeSections', + 'argumentShapes', + 'vtablePeSections', + 'vtableTransitionStability', +]); const IMAGE_SCN_MEM_EXECUTE = 0x20000000; const IMAGE_SCN_MEM_READ = 0x40000000; +const PARSED_PE_VALUES = new WeakSet(); function canonicalSha(value) { - if (typeof value !== 'string' || !/^[0-9A-Fa-f]{64}$/.test(value)) { - throw new TypeError('Executable SHA-256 must contain exactly 64 hexadecimal characters'); + if (typeof value !== 'string' || !/^[0-9A-F]{64}$/.test(value)) { + throw new TypeError('Executable identity must be an uppercase SHA-256 string'); + } + return value; +} + +function positiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${label} must be a positive safe integer`); + return value; +} + +function nonnegativeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${label} must be a nonnegative safe integer`); + return value; +} + +function nonemptyString(value, label) { + if (typeof value !== 'string' || value.trim().length === 0) throw new TypeError(`${label} must be a nonempty string`); + return value; +} + +function plainObject(value, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError(`${label} must be a plain object`); + } + return value; +} + +function assertExactKeys(value, expected, label) { + plainObject(value, label); + const actual = Object.keys(value).sort(); + const required = [...expected].sort(); + if (actual.length !== required.length || actual.some((key, index) => key !== required[index])) { + throw new TypeError(`${label} must contain exactly: ${required.join(', ')}`); + } +} + +function assertPlainJson(value, label = 'Evidence') { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError(`${label} contains a non-finite number`); + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => assertPlainJson(entry, `${label}[${index}]`)); + return; } - return value.toUpperCase(); + plainObject(value, label); + for (const [key, entry] of Object.entries(value)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + throw new TypeError(`${label} contains a prohibited object key`); + } + assertPlainJson(entry, `${label}.${key}`); + } +} + +function validateIdentity(identity, label) { + plainObject(identity, label); + return Object.freeze({ + pid: positiveInteger(identity.pid, `${label} PID`), + sessionId: nonemptyString(identity.sessionId, `${label} session ID`), + executableSha256: canonicalSha(identity.executableSha256), + }); +} + +function evidenceIdentity(evidence) { + plainObject(evidence.build, 'Evidence build identity'); + plainObject(evidence.session, 'Evidence session identity'); + return validateIdentity({ + pid: evidence.session.pid, + sessionId: evidence.session.sessionId, + executableSha256: evidence.build.executableSha256, + }, 'Evidence identity'); +} + +function validateEvidenceEnvelope(evidence) { + assertPlainJson(evidence); + plainObject(evidence, 'Evidence envelope'); + positiveInteger(evidence.schemaVersion, 'Evidence schemaVersion'); + evidenceIdentity(evidence); + return evidence; } function toAddress(value, label = 'address') { @@ -41,33 +132,109 @@ function canonicalHex(value, label) { return `0x${toAddress(value, label).toString(16).toUpperCase()}`; } -function evidenceDirectory(executableSha256, outputRoot = DEFAULT_OUTPUT_ROOT) { - const root = path.resolve(outputRoot); - return path.join(root, canonicalSha(executableSha256)); +function canonicalNonzeroHex(value, label) { + if (typeof value !== 'string' || !/^0x[0-9A-F]+$/.test(value) || canonicalHex(value, label) !== value || + toAddress(value, label) === 0n) { + throw new TypeError(`${label} must be a nonzero canonical uppercase hexadecimal RVA`); + } + return value; +} + +function comparablePath(value) { + const normalized = path.normalize(value); + return process.platform === 'win32' ? normalized.toUpperCase() : normalized; +} + +function samePath(left, right) { + return comparablePath(left) === comparablePath(right); } -function writeEvidence(filePath, value, { - fileSystem = fs, - temporaryToken = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`, -} = {}) { - const target = path.resolve(filePath); +function realpath(filePath) { + return (fs.realpathSync.native ?? fs.realpathSync)(filePath); +} + +function ensureDirectoryComponents(components, create) { + let lexical = REPOSITORY_ROOT; + let expectedReal = realpath(REPOSITORY_ROOT); + for (const component of components) { + lexical = path.join(lexical, component); + expectedReal = path.join(expectedReal, component); + if (!fs.existsSync(lexical)) { + if (!create) throw Object.assign(new Error(`Evidence path does not exist: ${lexical}`), { code: 'ENOENT' }); + fs.mkdirSync(lexical); + } + const status = fs.lstatSync(lexical); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error(`Evidence directory containment rejected a junction or non-directory: ${lexical}`); + } + const actualReal = realpath(lexical); + if (!samePath(actualReal, expectedReal)) { + throw new Error(`Evidence directory real path escaped containment: ${lexical}`); + } + } + return lexical; +} + +function evidenceDirectory(executableSha256) { + if (arguments.length !== 1) throw new TypeError('evidenceDirectory accepts only the executable SHA argument; its root is fixed'); + return path.join(DEFAULT_OUTPUT_ROOT, canonicalSha(executableSha256)); +} + +function evidencePathParts(relativePath) { + if (typeof relativePath !== 'string' || relativePath.length === 0 || relativePath.includes('\0') || + path.isAbsolute(relativePath) || path.win32.isAbsolute(relativePath) || path.posix.isAbsolute(relativePath)) { + throw new TypeError('Evidence path must be a nonempty relative evidence path'); + } + const parts = relativePath.replace(/\\/g, '/').split('/'); + if (parts.some((part) => part.length === 0 || part === '.' || part === '..' || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(part))) { + throw new TypeError('Relative evidence path contains an escape or unsafe component'); + } + if (!parts.at(-1).endsWith('.json')) throw new TypeError('Evidence path must name a JSON file'); + return parts; +} + +function resolveContainedEvidencePath(relativePath, executableSha256, { createParents, requireFile }) { + const sha = canonicalSha(executableSha256); + const parts = evidencePathParts(relativePath); + const parentParts = ['.frtk', 'board-reanchor', sha, ...parts.slice(0, -1)]; + const parent = ensureDirectoryComponents(parentParts, createParents); + const target = path.join(parent, parts.at(-1)); + if (fs.existsSync(target)) { + const status = fs.lstatSync(target); + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`Evidence target is a junction or non-file: ${target}`); + } + const expectedReal = path.join(realpath(parent), parts.at(-1)); + if (!samePath(realpath(target), expectedReal)) throw new Error('Evidence target real path escaped containment'); + } else if (requireFile) { + throw Object.assign(new Error(`Evidence file does not exist: ${target}`), { code: 'ENOENT' }); + } + return target; +} + +function writeEvidence(relativePath, evidence) { + validateEvidenceEnvelope(evidence); + const sha = evidence.build.executableSha256; + const target = resolveContainedEvidencePath(relativePath, sha, { createParents: true, requireFile: false }); const directory = path.dirname(target); - const safeToken = String(temporaryToken).replace(/[^A-Za-z0-9_-]/g, '_'); - if (safeToken.length === 0) throw new TypeError('Temporary token must not be empty'); - const temporary = path.join(directory, `.${path.basename(target)}.${safeToken}.tmp`); - const serialized = `${JSON.stringify(value, null, 2)}\n`; - fileSystem.mkdirSync(directory, { recursive: true }); + const temporary = path.join(directory, + `.${path.basename(target)}.${process.pid}-${crypto.randomBytes(12).toString('hex')}.tmp`); + const serialized = `${JSON.stringify(evidence, null, 2)}\n`; let temporaryCreated = false; try { - fileSystem.writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx' }); + fs.writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx' }); temporaryCreated = true; - fileSystem.renameSync(temporary, target); + const rechecked = resolveContainedEvidencePath(relativePath, sha, { createParents: false, requireFile: false }); + if (!samePath(rechecked, target)) throw new Error('Evidence target changed during atomic write'); + fs.renameSync(temporary, target); + temporaryCreated = false; } catch (error) { if (temporaryCreated) { try { - fileSystem.rmSync(temporary, { force: true }); + fs.rmSync(temporary, { force: true }); } catch { - // Preserve the original write or rename failure. + // Preserve the original failure and never touch the destination. } } throw error; @@ -75,27 +242,19 @@ function writeEvidence(filePath, value, { return target; } -function evidenceIdentity(evidence) { - return { - pid: evidence?.session?.pid ?? evidence?.pid, - sessionId: evidence?.session?.sessionId ?? evidence?.sessionId ?? evidence?.hostSessionToken, - executableSha256: evidence?.build?.executableSha256 ?? evidence?.executableSha256, - }; -} - -function readEvidence(filePath, expectedIdentity, { fileSystem = fs } = {}) { - const evidence = JSON.parse(fileSystem.readFileSync(path.resolve(filePath), 'utf8')); - if (!expectedIdentity) return evidence; - - const actual = evidenceIdentity(evidence); - const expectedSessionId = expectedIdentity.sessionId ?? expectedIdentity.hostSessionToken; - if (actual.pid !== expectedIdentity.pid) { - throw new Error('Evidence belongs to a different process PID'); - } - if (actual.sessionId !== expectedSessionId) { - throw new Error('Evidence belongs to a different host session'); +function readEvidence(relativePath, expectedIdentity) { + if (arguments.length !== 2 || expectedIdentity === undefined) { + throw new TypeError('readEvidence requires an exact expected identity'); } - if (canonicalSha(actual.executableSha256) !== canonicalSha(expectedIdentity.executableSha256)) { + const expected = validateIdentity(expectedIdentity, 'Expected identity'); + const target = resolveContainedEvidencePath(relativePath, expected.executableSha256, + { createParents: false, requireFile: true }); + const evidence = JSON.parse(fs.readFileSync(target, 'utf8')); + validateEvidenceEnvelope(evidence); + const actual = evidenceIdentity(evidence); + if (actual.pid !== expected.pid) throw new Error('Evidence belongs to a different process PID'); + if (actual.sessionId !== expected.sessionId) throw new Error('Evidence belongs to a different host session'); + if (actual.executableSha256 !== expected.executableSha256) { throw new Error('Evidence belongs to a different executable SHA-256'); } return evidence; @@ -103,52 +262,98 @@ function readEvidence(filePath, expectedIdentity, { fileSystem = fs } = {}) { function requireBufferRange(buffer, offset, length, label) { if (!Buffer.isBuffer(buffer)) throw new TypeError('PE image must be a Buffer'); - if (!Number.isInteger(offset) || offset < 0 || offset + length > buffer.length) { + if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || + offset + length > buffer.length) { throw new Error(`PE image is truncated before ${label}`); } } +function isPowerOfTwo(value) { + return Number.isSafeInteger(value) && value > 0 && (value & (value - 1)) === 0; +} + +function rangesOverlap(left, right) { + return left.start < right.end && right.start < left.end; +} + function parsePeSections(image) { requireBufferRange(image, 0, 0x40, 'the DOS header'); if (image.toString('ascii', 0, 2) !== 'MZ') throw new Error('PE image has no MZ signature'); const peOffset = image.readUInt32LE(0x3C); requireBufferRange(image, peOffset, 24, 'the PE file header'); - if (image.toString('binary', peOffset, peOffset + 4) !== 'PE\0\0') { - throw new Error('PE image has no PE signature'); - } + if (image.toString('binary', peOffset, peOffset + 4) !== 'PE\0\0') throw new Error('PE image has no PE signature'); const numberOfSections = image.readUInt16LE(peOffset + 6); const optionalHeaderSize = image.readUInt16LE(peOffset + 20); const optionalHeaderOffset = peOffset + 24; - if (numberOfSections === 0 || numberOfSections > 96) { - throw new Error('PE image has an invalid section count'); - } - if (optionalHeaderSize < 60) throw new Error('PE optional header is too small'); + if (numberOfSections === 0 || numberOfSections > 96) throw new Error('PE image has an invalid section count'); + if (optionalHeaderSize < 64) throw new Error('PE optional header is too small'); requireBufferRange(image, optionalHeaderOffset, optionalHeaderSize, 'the PE optional header'); const magic = image.readUInt16LE(optionalHeaderOffset); if (magic !== 0x20B && magic !== 0x10B) throw new Error('PE optional header has an unsupported magic'); + const sectionAlignment = image.readUInt32LE(optionalHeaderOffset + 32); + const fileAlignment = image.readUInt32LE(optionalHeaderOffset + 36); const sizeOfImage = image.readUInt32LE(optionalHeaderOffset + 56); - if (sizeOfImage === 0) throw new Error('PE SizeOfImage must not be zero'); + const sizeOfHeaders = image.readUInt32LE(optionalHeaderOffset + 60); + if (!isPowerOfTwo(fileAlignment) || fileAlignment < 0x200 || fileAlignment > 0x10000) { + throw new Error('PE file alignment is invalid'); + } + if (!isPowerOfTwo(sectionAlignment) || + (sectionAlignment < 0x1000 ? sectionAlignment !== fileAlignment : sectionAlignment < fileAlignment)) { + throw new Error('PE section alignment is invalid'); + } + if (sizeOfImage === 0 || sizeOfImage % sectionAlignment !== 0) throw new Error('PE SizeOfImage is not section-aligned'); + if (sizeOfHeaders === 0 || sizeOfHeaders % fileAlignment !== 0 || sizeOfHeaders > image.length) { + throw new Error('PE SizeOfHeaders is invalid'); + } const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize; - requireBufferRange(image, sectionTableOffset, numberOfSections * 40, 'the PE section table'); + const sectionTableLength = numberOfSections * 40; + requireBufferRange(image, sectionTableOffset, sectionTableLength, 'the PE section table'); + if (sectionTableOffset + sectionTableLength > sizeOfHeaders) throw new Error('PE section table escapes SizeOfHeaders'); + const sections = []; + const rawRanges = []; + const virtualRanges = []; for (let index = 0; index < numberOfSections; index += 1) { const offset = sectionTableOffset + index * 40; const name = image.toString('ascii', offset, offset + 8).replace(/\0.*$/, ''); const virtualSize = image.readUInt32LE(offset + 8); const virtualAddress = image.readUInt32LE(offset + 12); const rawSize = image.readUInt32LE(offset + 16); + const rawAddress = image.readUInt32LE(offset + 20); const characteristics = image.readUInt32LE(offset + 36); const mappedSize = Math.max(virtualSize, rawSize); - if (mappedSize === 0 || virtualAddress >= sizeOfImage || - virtualAddress + mappedSize > sizeOfImage) { - throw new Error(`PE section ${name || index} escapes SizeOfImage`); + if (mappedSize === 0) throw new Error(`PE section ${name || index} has no mapped content`); + if (virtualAddress === 0 || virtualAddress % sectionAlignment !== 0) { + throw new Error(`PE section ${name || index} violates section alignment`); } + const virtualRange = { start: virtualAddress, end: virtualAddress + mappedSize }; + if (virtualRange.end > sizeOfImage) throw new Error(`PE section ${name || index} escapes SizeOfImage`); + if (virtualRanges.some((range) => rangesOverlap(range, virtualRange))) { + throw new Error(`PE virtual section ranges overlap at ${name || index}`); + } + virtualRanges.push(virtualRange); + + if (rawSize > 0) { + if (rawSize % fileAlignment !== 0 || rawAddress < sizeOfHeaders || rawAddress % fileAlignment !== 0) { + throw new Error(`PE section ${name || index} has an invalid raw range alignment`); + } + const rawRange = { start: rawAddress, end: rawAddress + rawSize }; + if (rawRange.end > image.length) throw new Error(`PE section ${name || index} raw range is truncated`); + if (rawRanges.some((range) => rangesOverlap(range, rawRange))) { + throw new Error(`PE raw section ranges overlap at ${name || index}`); + } + rawRanges.push(rawRange); + } else if (rawAddress !== 0) { + throw new Error(`PE section ${name || index} has a raw address without raw data`); + } + sections.push(Object.freeze({ name, virtualAddress, virtualSize, + rawAddress, rawSize, mappedSize, characteristics, @@ -156,27 +361,28 @@ function parsePeSections(image) { executable: (characteristics & IMAGE_SCN_MEM_EXECUTE) !== 0, })); } - return Object.freeze({ sizeOfImage, sections: Object.freeze(sections) }); + const result = Object.freeze({ + sizeOfImage, + sizeOfHeaders, + sectionAlignment, + fileAlignment, + sections: Object.freeze(sections), + }); + PARSED_PE_VALUES.add(result); + return result; } function classifyModuleAddress(address, moduleBase, pe) { - if (!pe || !Number.isInteger(pe.sizeOfImage) || !Array.isArray(pe.sections)) { - throw new TypeError('Parsed PE metadata is required'); - } + if (!PARSED_PE_VALUES.has(pe)) throw new TypeError('PE metadata must come from parsePeSections'); const target = toAddress(address); const base = toAddress(moduleBase, 'module base'); const end = base + BigInt(pe.sizeOfImage); if (target < base || target >= end) { return Object.freeze({ - address: canonicalHex(target), - insideImage: false, - rva: null, - section: null, - readable: false, - executable: false, + address: canonicalHex(target), insideImage: false, rva: null, section: null, + readable: false, executable: false, }); } - const rvaValue = target - base; const section = pe.sections.find((candidate) => rvaValue >= BigInt(candidate.virtualAddress) && @@ -195,40 +401,51 @@ function captureStackReturns(capture) { if (Array.isArray(capture?.hits)) { return capture.hits.flatMap((hit) => Array.isArray(hit?.stackReturnAddresses) ? hit.stackReturnAddresses : []); } - return Array.isArray(capture?.stackReturnAddresses) ? capture.stackReturnAddresses : []; + return []; +} + +function captureIdentity(capture) { + plainObject(capture, 'Capture'); + return validateIdentity({ + pid: capture.session?.pid, + sessionId: capture.session?.sessionId, + executableSha256: capture.build?.executableSha256, + }, 'Capture identity'); } function rankRoutineCandidates(captures, { moduleBase, pe }) { - if (!Array.isArray(captures) || captures.length < 2) { - throw new Error('At least two independent captures are required to rank routine candidates'); + if (!Array.isArray(captures) || captures.length !== 2) { + throw new Error('Exactly two captures are required to rank routine candidates'); } - const captureCounts = captures.map((capture) => { + const captureIds = captures.map((entry) => nonemptyString(entry?.captureId, 'Capture ID')); + if (captureIds[0] === captureIds[1]) throw new Error('Two distinct capture IDs are required'); + const identities = captures.map(captureIdentity); + if (identities[0].pid !== identities[1].pid || identities[0].sessionId !== identities[1].sessionId || + identities[0].executableSha256 !== identities[1].executableSha256) { + throw new Error('Routine captures must have the same exact process, host session, and executable identity'); + } + const captureCounts = captures.map((entry) => { const counts = new Map(); - for (const address of captureStackReturns(capture)) { + for (const address of captureStackReturns(entry)) { const key = canonicalHex(address); counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; }); - const ranked = []; for (const address of captureCounts[0].keys()) { - if (!captureCounts.every((counts) => counts.has(address))) continue; + if (!captureCounts[1].has(address)) continue; const classification = classifyModuleAddress(address, moduleBase, pe); if (!classification.insideImage || !classification.executable) continue; - const captureCount = captureCounts.length; - const hitCount = captureCounts.reduce((sum, counts) => sum + counts.get(address), 0); - ranked.push({ - address, - rva: classification.rva, - captureCount, - hitCount, - score: captureCount * 100 + hitCount, - }); + const hitCount = captureCounts[0].get(address) + captureCounts[1].get(address); + ranked.push({ address, rva: classification.rva, captureCount: 2, hitCount, score: 200 + hitCount }); } - ranked.sort((left, right) => - (right.score - left.score) || - (toAddress(left.address) < toAddress(right.address) ? -1 : 1)); + ranked.sort((left, right) => { + if (right.score !== left.score) return right.score - left.score; + const leftAddress = toAddress(left.address); + const rightAddress = toAddress(right.address); + return leftAddress < rightAddress ? -1 : leftAddress > rightAddress ? 1 : 0; + }); return ranked; } @@ -236,21 +453,35 @@ function sameAddress(left, right) { return toAddress(left) === toAddress(right); } +function validRow(value) { + return Number.isSafeInteger(value) && value >= 0; +} + function validateVtable(object, label, moduleBase, pe) { - const table = classifyModuleAddress(object.vtableAddress, moduleBase, pe); - if (!table.insideImage || !table.readable) { - return `${label} vtable is not in readable main-module image memory`; - } - if (!Array.isArray(object.vtableEntries) || object.vtableEntries.length === 0) { - return `${label} vtable has no sampled entries`; - } - for (const entry of object.vtableEntries) { - const target = classifyModuleAddress(entry, moduleBase, pe); - if (!target.insideImage || !target.executable) { - return `${label} vtable entry is not in an executable main-module section`; + try { + const table = classifyModuleAddress(object?.vtableAddress, moduleBase, pe); + if (!table.insideImage || !table.readable) return `${label} vtable is not in readable main-module image memory`; + if (!Array.isArray(object.vtableEntries) || object.vtableEntries.length === 0) { + return `${label} vtable has no sampled entries`; + } + for (const entry of object.vtableEntries) { + const target = classifyModuleAddress(entry, moduleBase, pe); + if (!target.insideImage || !target.executable) { + return `${label} vtable entry is not in an executable main-module section`; + } } + return null; + } catch (error) { + return `${label} vtable is malformed: ${error.message}`; } - return null; +} + +function validateCaptureVtables(capture, moduleBase, pe) { + for (const [object, label] of [[capture?.controller, 'Controller'], [capture?.team, 'Team'], [capture?.recruit, 'Recruit']]) { + const error = validateVtable(object, label, moduleBase, pe); + if (error) return Object.freeze({ passed: false, detail: error }); + } + return Object.freeze({ passed: true, detail: 'All sampled vtables passed PE checks' }); } function validateObjectShapes(capture, { moduleBase, pe }) { @@ -262,18 +493,22 @@ function validateObjectShapes(capture, { moduleBase, pe }) { const team = capture?.team; const recruit = capture?.recruit; const expected = capture?.expected; - if (!args || !cells || !controller || !team || !recruit || !expected) { - return reject('Full entry object shape is incomplete'); + if (!args || !cells || !controller || !team || !recruit || !expected) return reject('Full entry object shape is incomplete'); + if (![expected.membershipRow, expected.teamRow, expected.recruitRow].every(validRow)) { + return reject('Expected membership, Team, and Recruit rows must be nonnegative safe integers'); + } + if (!validRow(controller.membershipRow) || !validRow(controller.boardStore?.membershipRow) || + !validRow(team.row) || !validRow(recruit.row)) { + return reject('Captured membership, Team, and Recruit rows must be nonnegative safe integers'); } if (!sameAddress(args.rcx, controller.address)) return reject('RCX does not contain the recruiting controller'); if (controller.readable !== true || controller.descriptorTableId !== 5003) { return reject('RCX object is not a readable descriptor-table 5003 recruiting controller'); } - if (controller.boardStore?.offset !== 0x138 || controller.boardStore?.readable !== true || - controller.boardStore?.membershipRow !== expected.membershipRow) { + if (controller.membershipRow !== expected.membershipRow || controller.boardStore.offset !== 0x138 || + controller.boardStore.readable !== true || controller.boardStore.membershipRow !== expected.membershipRow) { return reject('Controller board store at +0x138 does not expose the expected membership row'); } - if (cells.team?.readable !== true || !sameAddress(args.rdx, cells.team.address) || !sameAddress(cells.team.value, team.address)) { return reject('RDX is not a readable pointer cell containing the Team wrapper'); @@ -286,19 +521,15 @@ function validateObjectShapes(capture, { moduleBase, pe }) { team.field10Readable !== true || team.field18Readable !== true) { return reject('Team wrapper descriptor, row identity, or +0x10/+0x18 fields are invalid'); } - if (recruit.readable !== true || recruit.descriptorTableId !== 4269 || - recruit.row !== expected.recruitRow || recruit.field10Readable !== true || - recruit.field18Readable !== true) { + if (recruit.readable !== true || recruit.descriptorTableId !== 4269 || recruit.row !== expected.recruitRow || + recruit.field10Readable !== true || recruit.field18Readable !== true) { return reject('Recruit wrapper descriptor, row identity, or +0x10/+0x18 fields are invalid'); } if (!sameAddress(team.vtableAddress, recruit.vtableAddress)) { return reject('Team and Recruit wrappers do not share the generic record-wrapper vtable'); } - - for (const [object, label] of [[controller, 'Controller'], [team, 'Team'], [recruit, 'Recruit']]) { - const error = validateVtable(object, label, moduleBase, pe); - if (error) return reject(error); - } + const vtables = validateCaptureVtables(capture, moduleBase, pe); + if (!vtables.passed) return reject(vtables.detail); return Object.freeze({ passed: true, detail: 'Full entry arguments and object shapes matched', @@ -311,19 +542,14 @@ function validateObjectShapes(capture, { moduleBase, pe }) { } function deriveVtableRvas(captures, { moduleBase, pe }) { - if (!Array.isArray(captures) || captures.length < 2) { - throw new Error('At least two object captures are required to prove stable vtables'); - } - const validations = captures.map((capture) => validateObjectShapes(capture, { moduleBase, pe })); + if (!Array.isArray(captures) || captures.length < 2) throw new Error('At least two object captures are required to prove stable vtables'); + const validations = captures.map((entry) => validateObjectShapes(entry, { moduleBase, pe })); const rejected = validations.findIndex((validation) => !validation.passed); - if (rejected !== -1) { - throw new Error(`Capture ${rejected + 1} object shape rejected: ${validations[rejected].detail}`); - } + if (rejected !== -1) throw new Error(`Capture ${rejected + 1} object shape rejected: ${validations[rejected].detail}`); const wrapper = validations[0].genericRecordWrapperVtableAddress; const controller = validations[0].recruitingControllerVtableAddress; - if (!validations.every((validation) => - validation.genericRecordWrapperVtableAddress === wrapper && - validation.recruitingControllerVtableAddress === controller)) { + if (!validations.every((validation) => validation.genericRecordWrapperVtableAddress === wrapper && + validation.recruitingControllerVtableAddress === controller)) { throw new Error('Vtable addresses were not stable across captures'); } return Object.freeze({ @@ -332,68 +558,167 @@ function deriveVtableRvas(captures, { moduleBase, pe }) { }); } -function normalizedCount(value) { - return Number.isInteger(value) && value >= 0 ? value : 0; +function validateBuild(build) { + assertExactKeys(build, ['label', 'executableSize', 'executableSha256'], 'Candidate build'); + return { + label: nonemptyString(build.label, 'Build label'), + executableSize: positiveInteger(build.executableSize, 'Executable size'), + executableSha256: canonicalSha(build.executableSha256), + }; } -function normalizedScore(value) { - return typeof value === 'number' && Number.isFinite(value) ? value : 0; +function validateSession(session) { + assertExactKeys(session, ['pid', 'sessionId', 'moduleBase', 'capturedAt'], 'Candidate session'); + const moduleBase = canonicalNonzeroHex(session.moduleBase, 'Module base'); + const capturedAt = nonemptyString(session.capturedAt, 'Capture timestamp'); + const parsedDate = new Date(capturedAt); + if (!Number.isFinite(parsedDate.valueOf()) || parsedDate.toISOString() !== capturedAt) { + throw new TypeError('Capture timestamp must be a canonical ISO-8601 instant'); + } + return { + pid: positiveInteger(session.pid, 'Session PID'), + sessionId: nonemptyString(session.sessionId, 'Session ID'), + moduleBase, + capturedAt, + }; } -function buildCandidateArtifact(input) { - if (!input || typeof input !== 'object') throw new TypeError('Candidate input is required'); - const tables = Object.fromEntries(TABLE_IDS.map((id) => { - const summary = input.tables?.[id] ?? {}; +function validateTables(tables) { + assertExactKeys(tables, TABLE_IDS, 'Candidate tables (exactly six table summaries)'); + return Object.fromEntries(TABLE_IDS.map((id) => { + const summary = tables[id]; + assertExactKeys(summary, ['passed', 'candidateCount', 'score', 'rereadPassed'], `Table ${id} summary`); + if (typeof summary.passed !== 'boolean' || typeof summary.rereadPassed !== 'boolean' || + typeof summary.score !== 'number' || !Number.isFinite(summary.score) || summary.score < 0) { + throw new TypeError(`Table ${id} summary is invalid`); + } return [id, { - passed: summary.passed === true, - candidateCount: normalizedCount(summary.candidateCount), - score: normalizedScore(summary.score), - rereadPassed: summary.rereadPassed === true, + passed: summary.passed, + candidateCount: nonnegativeInteger(summary.candidateCount, `Table ${id} candidate count`), + score: summary.score, + rereadPassed: summary.rereadPassed, }]; })); - const captures = Object.fromEntries(['add', 'remove'].map((operation) => { - const summary = input.captures?.[operation] ?? {}; +} + +function validateCaptures(captures) { + assertExactKeys(captures, ['add', 'remove'], 'Candidate captures'); + return Object.fromEntries(['add', 'remove'].map((operation) => { + const summary = captures[operation]; + assertExactKeys(summary, ['writeCount', 'executeCount', 'consistent'], `${operation} capture summary`); + if (typeof summary.consistent !== 'boolean') throw new TypeError(`${operation} capture summary is invalid`); return [operation, { - writeCount: normalizedCount(summary.writeCount), - executeCount: normalizedCount(summary.executeCount), - consistent: summary.consistent === true, + writeCount: nonnegativeInteger(summary.writeCount, `${operation} capture summary write count`), + executeCount: nonnegativeInteger(summary.executeCount, `${operation} capture summary execute count`), + consistent: summary.consistent, }]; })); - const proposedBoard = Object.fromEntries(BOARD_RVAS.map((name) => - [name, canonicalHex(input.proposedBoard?.[name], name)])); - const sourceGates = Array.isArray(input.gates) ? input.gates : []; - const gates = sourceGates.map((gate, index) => ({ - name: typeof gate?.name === 'string' && gate.name.length > 0 ? gate.name : `unnamed-gate-${index + 1}`, - passed: gate?.passed === true, - detail: typeof gate?.detail === 'string' ? gate.detail : String(gate?.detail ?? ''), - })); - const allTablesPassed = Object.values(tables).every((table) => table.passed && table.rereadPassed); - const allCapturesPassed = Object.values(captures).every((capture) => - capture.writeCount >= 2 && capture.executeCount >= 1 && capture.consistent); - const allGatesPassed = gates.length > 0 && gates.every((gate) => gate.passed); +} + +function validateBoardRvas(board) { + assertExactKeys(board, BOARD_RVAS, 'Proposed board'); + return Object.fromEntries(BOARD_RVAS.map((name) => [name, canonicalNonzeroHex(board[name], `Nonzero ${name} RVA`)])); +} + +function validateGates(gates) { + if (!Array.isArray(gates) || gates.length !== REQUIRED_GATE_NAMES.length) { + throw new TypeError('Candidate must contain the exact required gate set'); + } + const byName = new Map(); + for (const gate of gates) { + assertExactKeys(gate, ['name', 'passed', 'detail'], 'Candidate gate'); + if (typeof gate.name !== 'string' || !REQUIRED_GATE_NAMES.includes(gate.name)) { + throw new TypeError('Candidate contains a gate outside the required gate set'); + } + if (byName.has(gate.name)) throw new TypeError(`Candidate contains duplicate required gate ${gate.name}`); + if (typeof gate.passed !== 'boolean' || typeof gate.detail !== 'string') { + throw new TypeError(`Required gate ${gate.name} must contain a boolean and detail string`); + } + byName.set(gate.name, gate); + } + if (REQUIRED_GATE_NAMES.some((name) => !byName.has(name))) throw new TypeError('Candidate is missing a required gate'); + return byName; +} + +function validateProof(proof) { + assertExactKeys(proof, [ + 'pe', 'fullAddAddress', 'fullRemoveAddress', 'addObjectCapture', + 'removeObjectCapture', 'transitionObjectCapture', + ], 'Candidate proof'); + if (!PARSED_PE_VALUES.has(proof.pe)) throw new TypeError('Candidate proof PE metadata must come from parsePeSections'); + toAddress(proof.fullAddAddress, 'full add address'); + toAddress(proof.fullRemoveAddress, 'full remove address'); + plainObject(proof.addObjectCapture, 'Add object proof'); + plainObject(proof.removeObjectCapture, 'Remove object proof'); + plainObject(proof.transitionObjectCapture, 'Transition object proof'); + return proof; +} + +function buildCandidateArtifact(input) { + assertExactKeys(input, ['build', 'session', 'tables', 'captures', 'proposedBoard', 'proof', 'gates'], + 'Candidate input'); + const build = validateBuild(input.build); + const session = validateSession(input.session); + const tables = validateTables(input.tables); + const captures = validateCaptures(input.captures); + const proposedBoard = validateBoardRvas(input.proposedBoard); + const callerGates = validateGates(input.gates); + const proof = validateProof(input.proof); + const moduleBase = session.moduleBase; + + const addRoutine = classifyModuleAddress(proof.fullAddAddress, moduleBase, proof.pe); + const removeRoutine = classifyModuleAddress(proof.fullRemoveAddress, moduleBase, proof.pe); + const routinePeSections = addRoutine.executable && removeRoutine.executable && + addRoutine.rva === proposedBoard.fullAddRva && removeRoutine.rva === proposedBoard.fullRemoveRva; + const addShape = validateObjectShapes(proof.addObjectCapture, { moduleBase, pe: proof.pe }); + const removeShape = validateObjectShapes(proof.removeObjectCapture, { moduleBase, pe: proof.pe }); + const argumentShapes = addShape.passed && removeShape.passed; + const allObjectCaptures = [proof.addObjectCapture, proof.removeObjectCapture, proof.transitionObjectCapture]; + const vtablePeSections = allObjectCaptures.every((entry) => + validateCaptureVtables(entry, moduleBase, proof.pe).passed); + let vtableTransitionStability = false; + try { + const derived = deriveVtableRvas(allObjectCaptures, { moduleBase, pe: proof.pe }); + vtableTransitionStability = derived.genericRecordWrapperVtableRva === + proposedBoard.genericRecordWrapperVtableRva && derived.recruitingControllerVtableRva === + proposedBoard.recruitingControllerVtableRva; + } catch { + vtableTransitionStability = false; + } + const tableAnchors = Object.values(tables).every((table) => + table.passed && table.candidateCount > 0 && table.score > 0 && table.rereadPassed); + const addCaptureConsistency = captures.add.writeCount >= 2 && captures.add.executeCount >= 1 && captures.add.consistent; + const removeCaptureConsistency = captures.remove.writeCount >= 2 && captures.remove.executeCount >= 1 && captures.remove.consistent; + const derivedConditions = { + buildIdentity: true, + sessionIdentity: true, + tableAnchors, + addCaptureConsistency, + removeCaptureConsistency, + routinePeSections, + argumentShapes, + vtablePeSections, + vtableTransitionStability, + }; + const gates = REQUIRED_GATE_NAMES.map((name) => { + const supplied = callerGates.get(name); + return { name, passed: supplied.passed && derivedConditions[name], detail: supplied.detail }; + }); return { schemaVersion: 1, - build: { - label: String(input.build?.label ?? ''), - executableSize: normalizedCount(input.build?.executableSize), - executableSha256: canonicalSha(input.build?.executableSha256), - }, - session: { - pid: normalizedCount(input.session?.pid), - sessionId: String(input.session?.sessionId ?? ''), - moduleBase: canonicalHex(input.session?.moduleBase, 'module base'), - capturedAt: String(input.session?.capturedAt ?? ''), - }, + build, + session, tables, captures, proposedBoard, gates, - passed: allTablesPassed && allCapturesPassed && allGatesPassed, + passed: gates.every((gate) => gate.passed), }; } module.exports = { + REQUIRED_GATE_NAMES, evidenceDirectory, writeEvidence, readEvidence, diff --git a/tests/board-reanchor-evidence.test.cjs b/tests/board-reanchor-evidence.test.cjs index e002fd3..a737e82 100644 --- a/tests/board-reanchor-evidence.test.cjs +++ b/tests/board-reanchor-evidence.test.cjs @@ -1,12 +1,13 @@ 'use strict'; const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); const fs = require('node:fs'); -const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); const { + REQUIRED_GATE_NAMES, evidenceDirectory, writeEvidence, readEvidence, @@ -18,11 +19,31 @@ const { buildCandidateArtifact, } = require('../scripts/board-verification/reanchor-evidence.cjs'); -const SHA = 'ab'.repeat(32); const MODULE_BASE = 0x140000000n; +const SHA = crypto.createHash('sha256').update('board-reanchor-evidence-test').digest('hex').toUpperCase(); +const IDENTITY = Object.freeze({ pid: 77, sessionId: 'host-start-1', executableSha256: SHA }); +const EVIDENCE_ROOT = path.resolve('.frtk', 'board-reanchor'); + +function shaFor(label) { + return crypto.createHash('sha256').update(`board-reanchor-evidence-test:${label}`).digest('hex').toUpperCase(); +} + +function cleanupSha(t, sha) { + t.after(() => fs.rmSync(path.join(EVIDENCE_ROOT, sha), { recursive: true, force: true })); +} + +function evidenceEnvelope(sha = SHA, overrides = {}) { + return { + schemaVersion: 1, + build: { executableSha256: sha }, + session: { pid: 77, sessionId: 'host-start-1' }, + payload: { captureId: 'add-1' }, + ...overrides, + }; +} function peFixture() { - const image = Buffer.alloc(0x400); + const image = Buffer.alloc(0x600); image.write('MZ', 0, 'ascii'); image.writeUInt32LE(0x80, 0x3C); image.write('PE\0\0', 0x80, 'binary'); @@ -30,20 +51,25 @@ function peFixture() { image.writeUInt16LE(2, 0x86); image.writeUInt16LE(0xF0, 0x94); image.writeUInt16LE(0x20B, 0x98); - image.writeUInt32LE(0x4000, 0x98 + 56); + image.writeUInt32LE(0x1000, 0x98 + 32); + image.writeUInt32LE(0x200, 0x98 + 36); + image.writeUInt32LE(0x3000, 0x98 + 56); + image.writeUInt32LE(0x200, 0x98 + 60); const sectionTable = 0x98 + 0xF0; image.write('.text\0\0\0', sectionTable, 'ascii'); image.writeUInt32LE(0x600, sectionTable + 8); image.writeUInt32LE(0x1000, sectionTable + 12); - image.writeUInt32LE(0x600, sectionTable + 16); + image.writeUInt32LE(0x200, sectionTable + 16); + image.writeUInt32LE(0x200, sectionTable + 20); image.writeUInt32LE(0x60000020, sectionTable + 36); const rdata = sectionTable + 40; image.write('.rdata\0\0', rdata, 'ascii'); image.writeUInt32LE(0x400, rdata + 8); image.writeUInt32LE(0x2000, rdata + 12); - image.writeUInt32LE(0x400, rdata + 16); + image.writeUInt32LE(0x200, rdata + 16); + image.writeUInt32LE(0x400, rdata + 20); image.writeUInt32LE(0x40000040, rdata + 36); return image; } @@ -52,6 +78,16 @@ function canonical(value) { return `0x${BigInt(value).toString(16).toUpperCase()}`; } +function capture(captureId, addresses, identity = IDENTITY) { + return { + captureId, + schemaVersion: 1, + build: { executableSha256: identity.executableSha256 }, + session: { pid: identity.pid, sessionId: identity.sessionId }, + hits: [{ stackReturnAddresses: addresses.map(canonical) }], + }; +} + function objectShape(heapOffset = 0n) { const controllerAddress = 0x200000000n + heapOffset; const teamCellAddress = 0x200001000n + heapOffset; @@ -75,6 +111,7 @@ function objectShape(heapOffset = 0n) { address: canonical(controllerAddress), readable: true, descriptorTableId: 5003, + membershipRow: 11, vtableAddress: canonical(controllerVtableAddress), vtableEntries: executableEntries.map(canonical), boardStore: { offset: 0x138, readable: true, membershipRow: 11 }, @@ -112,169 +149,251 @@ function tableSummaries() { }])); } -test('evidenceDirectory uses the ignored board-reanchor root and uppercase SHA', () => { - assert.equal( - evidenceDirectory(SHA), - path.resolve('.frtk', 'board-reanchor', SHA.toUpperCase()), - ); -}); - -test('writeEvidence writes a temporary sibling before atomically renaming it', () => { - const calls = []; - const fileSystem = { - mkdirSync(directory, options) { calls.push(['mkdir', directory, options]); }, - writeFileSync(filePath, contents, options) { calls.push(['write', filePath, contents, options]); }, - renameSync(from, to) { calls.push(['rename', from, to]); }, - rmSync(filePath, options) { calls.push(['remove', filePath, options]); }, +function candidateInput() { + const pe = parsePeSections(peFixture()); + return { + build: { label: 'Patch 1', executableSize: 123, executableSha256: SHA }, + session: { pid: 77, sessionId: 'host-start-1', moduleBase: canonical(MODULE_BASE), capturedAt: '2026-07-16T12:00:00.000Z' }, + tables: tableSummaries(), + captures: { + add: { writeCount: 2, executeCount: 1, consistent: true }, + remove: { writeCount: 2, executeCount: 1, consistent: true }, + }, + proposedBoard: { + genericRecordWrapperVtableRva: '0x2100', + recruitingControllerVtableRva: '0x2200', + fullAddRva: '0x1100', + fullRemoveRva: '0x1200', + }, + proof: { + pe, + fullAddAddress: canonical(MODULE_BASE + 0x1100n), + fullRemoveAddress: canonical(MODULE_BASE + 0x1200n), + addObjectCapture: objectShape(), + removeObjectCapture: objectShape(0x100000n), + transitionObjectCapture: objectShape(0x200000n), + }, + gates: REQUIRED_GATE_NAMES.map((name) => ({ name, passed: true, detail: `${name} passed` })), }; - const target = path.resolve('ignored', 'candidate.json'); +} + +test('storage root is fixed to the ignored uppercase-SHA directory', () => { + assert.equal(evidenceDirectory(SHA), path.join(EVIDENCE_ROOT, SHA)); + assert.throws(() => evidenceDirectory(SHA.toLowerCase()), /uppercase SHA-256/i); + assert.throws(() => evidenceDirectory(SHA, 'elsewhere'), /argument|root/i); +}); - writeEvidence(target, { schemaVersion: 1 }, { fileSystem, temporaryToken: 'TEST' }); +test('writeEvidence atomically writes and readEvidence requires exact identity', (t) => { + const sha = shaFor('atomic'); + cleanupSha(t, sha); + const envelope = evidenceEnvelope(sha); + const identity = { ...IDENTITY, executableSha256: sha }; + const target = writeEvidence('captures/add-1.json', envelope); + assert.equal(target, path.join(evidenceDirectory(sha), 'captures', 'add-1.json')); + assert.deepEqual(readEvidence('captures/add-1.json', identity), envelope); + assert.deepEqual(fs.readdirSync(path.dirname(target)), ['add-1.json']); - const write = calls.find(([operation]) => operation === 'write'); - const rename = calls.find(([operation]) => operation === 'rename'); - assert.equal(path.dirname(write[1]), path.dirname(target)); - assert.notEqual(write[1], target); - assert.deepEqual(rename.slice(1), [write[1], target]); - assert.equal(write[2], '{\n "schemaVersion": 1\n}\n'); - assert.equal(calls.some(([operation]) => operation === 'remove'), false); + assert.throws(() => readEvidence('captures/add-1.json'), /expected identity/i); + assert.throws(() => readEvidence('captures/add-1.json', { ...identity, pid: 78 }), /different process/i); + assert.throws(() => readEvidence('captures/add-1.json', { ...identity, sessionId: 'other' }), /different host session/i); + assert.throws(() => readEvidence('captures/add-1.json', { ...identity, executableSha256: SHA }), /does not exist|different executable/i); + assert.throws(() => readEvidence('captures/add-1.json', { ...identity, executableSha256: sha.toLowerCase() }), /uppercase SHA-256/i); }); -test('writeEvidence never removes a colliding temporary sibling it did not create', () => { - const calls = []; - const collision = Object.assign(new Error('temporary evidence already exists'), { code: 'EEXIST' }); - const fileSystem = { - mkdirSync() {}, - writeFileSync() { throw collision; }, - renameSync() { assert.fail('rename must not run after a temporary-file collision'); }, - rmSync(filePath) { calls.push(filePath); }, - }; +test('storage rejects traversal, absolute paths, junction escapes, and malformed envelopes', (t) => { + const sha = shaFor('containment'); + cleanupSha(t, sha); + const envelope = evidenceEnvelope(sha); + assert.throws(() => writeEvidence('../escape.json', envelope), /relative evidence path|escape/i); + assert.throws(() => writeEvidence(path.resolve('escape.json'), envelope), /relative evidence path/i); + for (const invalid of [ + undefined, + [], + { ...envelope, schemaVersion: 0 }, + { ...envelope, build: { executableSha256: sha.toLowerCase() } }, + { ...envelope, session: { pid: 0, sessionId: '' } }, + { ...envelope, payload: { invalid: undefined } }, + ]) { + assert.throws(() => writeEvidence('invalid.json', invalid), /evidence|schema|uppercase SHA-256|PID|session/i); + } - assert.throws( - () => writeEvidence(path.resolve('ignored', 'candidate.json'), {}, { - fileSystem, - temporaryToken: 'COLLISION', - }), - (error) => error === collision, - ); - assert.deepEqual(calls, []); + const shaDirectory = evidenceDirectory(sha); + const outside = path.join(EVIDENCE_ROOT, `${sha}-OUTSIDE`); + t.after(() => fs.rmSync(outside, { recursive: true, force: true })); + fs.mkdirSync(shaDirectory, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + const junction = path.join(shaDirectory, 'junction'); + try { + fs.symlinkSync(outside, junction, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + t.skip(`junction creation unavailable: ${error.code}`); + return; + } + assert.throws(() => writeEvidence('junction/escaped.json', envelope), /junction|real path|containment/i); }); -test('readEvidence rejects evidence from another process, host session, or executable', () => { - const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'cfb27-evidence-')); - const evidencePath = path.join(temporaryDirectory, 'capture.json'); - writeEvidence(evidencePath, { - build: { executableSha256: SHA.toUpperCase() }, - session: { pid: 77, sessionId: 'host-start-1' }, - }); +test('readEvidence rejects invalid JSON and missing actual identity fields', (t) => { + const sha = shaFor('invalid-read'); + cleanupSha(t, sha); + const directory = evidenceDirectory(sha); + fs.mkdirSync(directory, { recursive: true }); + const identity = { ...IDENTITY, executableSha256: sha }; + fs.writeFileSync(path.join(directory, 'invalid.json'), '{not-json', 'utf8'); + fs.writeFileSync(path.join(directory, 'missing.json'), JSON.stringify({ schemaVersion: 1 }), 'utf8'); + assert.throws(() => readEvidence('invalid.json', identity), /JSON|Unexpected/i); + assert.throws(() => readEvidence('missing.json', identity), /PID|session|SHA-256|identity/i); +}); - const identity = { pid: 77, sessionId: 'host-start-1', executableSha256: SHA }; - assert.equal(readEvidence(evidencePath, identity).session.pid, 77); - assert.throws(() => readEvidence(evidencePath, { ...identity, pid: 78 }), /different process/i); - assert.throws(() => readEvidence(evidencePath, { ...identity, sessionId: 'host-start-2' }), /different host session/i); - assert.throws(() => readEvidence(evidencePath, { ...identity, executableSha256: 'cd'.repeat(32) }), /different executable/i); +test('rename failure removes only the owned temp and preserves the existing destination', (t) => { + const sha = shaFor('rename-failure'); + cleanupSha(t, sha); + const envelope = evidenceEnvelope(sha); + const destination = writeEvidence('blocked.json', envelope); + const originalContents = fs.readFileSync(destination, 'utf8'); + const originalRename = fs.renameSync; + t.after(() => { fs.renameSync = originalRename; }); + fs.renameSync = () => { throw Object.assign(new Error('simulated rename failure'), { code: 'EACCES' }); }; + assert.throws(() => writeEvidence('blocked.json', { + ...envelope, + payload: { captureId: 'replacement' }, + }), /simulated rename failure/i); + assert.equal(fs.readFileSync(destination, 'utf8'), originalContents); + assert.deepEqual(fs.readdirSync(evidenceDirectory(sha)), ['blocked.json']); }); -test('parsePeSections distinguishes executable text from readable non-executable rdata', () => { - const pe = parsePeSections(peFixture()); - assert.equal(pe.sizeOfImage, 0x4000); +test('parsePeSections classifies valid aligned sections and rejects malformed layouts', () => { + const valid = peFixture(); + const pe = parsePeSections(valid); + assert.equal(pe.sizeOfImage, 0x3000); assert.deepEqual(pe.sections.map(({ name, readable, executable }) => ({ name, readable, executable })), [ { name: '.text', readable: true, executable: true }, { name: '.rdata', readable: true, executable: false }, ]); + + const truncated = Buffer.from(valid.subarray(0, 0x300)); + const rawOverlap = Buffer.from(valid); + rawOverlap.writeUInt32LE(0x200, 0x98 + 0xF0 + 40 + 20); + const virtualOverlap = Buffer.from(valid); + virtualOverlap.writeUInt32LE(0x1000, 0x98 + 0xF0 + 40 + 12); + const misaligned = Buffer.from(valid); + misaligned.writeUInt32LE(0x1800, 0x98 + 0xF0 + 40 + 12); + const badAlignment = Buffer.from(valid); + badAlignment.writeUInt32LE(0x300, 0x98 + 36); + for (const [image, message] of [ + [truncated, /raw range|truncated/i], + [rawOverlap, /raw.*overlap/i], + [virtualOverlap, /virtual.*overlap/i], + [misaligned, /section alignment/i], + [badAlignment, /file alignment/i], + ]) assert.throws(() => parsePeSections(image), message); }); -test('classifyModuleAddress emits an RVA only for addresses inside SizeOfImage', () => { +test('classifyModuleAddress emits RVAs only inside the image', () => { const pe = parsePeSections(peFixture()); const text = classifyModuleAddress(MODULE_BASE + 0x1100n, MODULE_BASE, pe); assert.deepEqual( { insideImage: text.insideImage, rva: text.rva, section: text.section.name, executable: text.executable }, { insideImage: true, rva: '0x1100', section: '.text', executable: true }, ); - const below = classifyModuleAddress(MODULE_BASE - 1n, MODULE_BASE, pe); - const end = classifyModuleAddress(MODULE_BASE + 0x4000n, MODULE_BASE, pe); - assert.equal(below.rva, null); - assert.equal(end.rva, null); - assert.equal(below.insideImage, false); - assert.equal(end.insideImage, false); + assert.equal(classifyModuleAddress(MODULE_BASE - 1n, MODULE_BASE, pe).rva, null); + assert.equal(classifyModuleAddress(MODULE_BASE + 0x3000n, MODULE_BASE, pe).rva, null); }); -test('rankRoutineCandidates ranks only common executable stack returns across captures', () => { +test('rankRoutineCandidates requires exactly two distinct same-identity captures and breaks ties by address', () => { const pe = parsePeSections(peFixture()); - const common = canonical(MODULE_BASE + 0x1100n); - const captures = [ - { hits: [{ stackReturnAddresses: [common, canonical(MODULE_BASE + 0x1200n), canonical(MODULE_BASE + 0x2100n)] }, { stackReturnAddresses: [common] }] }, - { hits: [{ stackReturnAddresses: [canonical(MODULE_BASE + 0x1300n), common, canonical(MODULE_BASE + 0x2100n)] }] }, - ]; - - assert.deepEqual(rankRoutineCandidates(captures, { moduleBase: MODULE_BASE, pe }), [{ - address: common, - rva: '0x1100', - captureCount: 2, - hitCount: 3, - score: 203, - }]); + const lower = MODULE_BASE + 0x1100n; + const higher = MODULE_BASE + 0x1200n; + const first = capture('add-1', [higher, lower, MODULE_BASE + 0x2100n]); + const second = capture('add-2', [lower, higher, MODULE_BASE + 0x2100n]); + assert.deepEqual(rankRoutineCandidates([first, second], { moduleBase: MODULE_BASE, pe }).map(({ rva }) => rva), [ + '0x1100', + '0x1200', + ]); + assert.throws(() => rankRoutineCandidates([first], { moduleBase: MODULE_BASE, pe }), /exactly two/i); + assert.throws(() => rankRoutineCandidates([first, first], { moduleBase: MODULE_BASE, pe }), /distinct capture/i); + assert.throws(() => rankRoutineCandidates([first, { ...second, session: { ...second.session, pid: 78 } }], { moduleBase: MODULE_BASE, pe }), /same.*identity|different process/i); + assert.throws(() => rankRoutineCandidates([first, { ...second, session: { ...second.session, sessionId: 'other' } }], { moduleBase: MODULE_BASE, pe }), /same.*identity|host session/i); + assert.throws(() => rankRoutineCandidates([first, { ...second, build: { executableSha256: 'A'.repeat(64) } }], { moduleBase: MODULE_BASE, pe }), /same.*identity|executable/i); }); -test('object validation and transition derivation require stable readable vtables with executable entries', () => { +test('object validation requires integer expected and captured membership, Team, and Recruit rows', () => { const pe = parsePeSections(peFixture()); - const first = objectShape(); - const afterTransition = objectShape(0x100000n); - const validation = validateObjectShapes(first, { moduleBase: MODULE_BASE, pe }); - assert.equal(validation.passed, true); - assert.deepEqual(deriveVtableRvas([first, afterTransition], { moduleBase: MODULE_BASE, pe }), { - genericRecordWrapperVtableRva: '0x2100', - recruitingControllerVtableRva: '0x2200', - }); - - const nonExecutableEntry = objectShape(); - nonExecutableEntry.team.vtableEntries[0] = canonical(MODULE_BASE + 0x2100n); - assert.equal(validateObjectShapes(nonExecutableEntry, { moduleBase: MODULE_BASE, pe }).passed, false); + assert.equal(validateObjectShapes(objectShape(), { moduleBase: MODULE_BASE, pe }).passed, true); + for (const mutate of [ + (shape) => { delete shape.expected.membershipRow; }, + (shape) => { shape.expected.teamRow = 1.5; }, + (shape) => { shape.expected.recruitRow = '33'; }, + (shape) => { delete shape.controller.membershipRow; }, + (shape) => { shape.controller.boardStore.membershipRow = -1; }, + (shape) => { delete shape.team.row; }, + (shape) => { delete shape.recruit.row; }, + ]) { + const shape = objectShape(); + mutate(shape); + assert.equal(validateObjectShapes(shape, { moduleBase: MODULE_BASE, pe }).passed, false); + } }); -test('object validation decisively rejects a common low-level routine with wrong entry arguments', () => { +test('wrong arguments and PE-invalid vtables are decisive object-shape rejections', () => { const pe = parsePeSections(peFixture()); const lowLevel = objectShape(); lowLevel.arguments.rcx = lowLevel.team.address; - const validation = validateObjectShapes(lowLevel, { moduleBase: MODULE_BASE, pe }); - assert.equal(validation.passed, false); - assert.match(validation.detail, /RCX/i); - assert.throws( - () => deriveVtableRvas([lowLevel, objectShape(0x100000n)], { moduleBase: MODULE_BASE, pe }), - /object shape/i, - ); + assert.match(validateObjectShapes(lowLevel, { moduleBase: MODULE_BASE, pe }).detail, /RCX/i); + assert.throws(() => deriveVtableRvas([lowLevel, objectShape(0x100000n)], { moduleBase: MODULE_BASE, pe }), /object shape/i); + + const invalidVtable = objectShape(); + invalidVtable.team.vtableEntries[0] = canonical(MODULE_BASE + 0x2100n); + assert.equal(validateObjectShapes(invalidVtable, { moduleBase: MODULE_BASE, pe }).passed, false); }); -test('buildCandidateArtifact emits the complete schema and passes only when every gate passes', () => { - const input = { - build: { label: 'Patch 1', executableSize: 123, executableSha256: SHA }, - session: { pid: 77, sessionId: 'host-start-1', moduleBase: canonical(MODULE_BASE), capturedAt: '2026-07-16T12:00:00.000Z' }, - tables: tableSummaries(), - captures: { - add: { writeCount: 2, executeCount: 1, consistent: true }, - remove: { writeCount: 2, executeCount: 1, consistent: true }, - }, - proposedBoard: { - genericRecordWrapperVtableRva: '0x2100', - recruitingControllerVtableRva: '0x2200', - fullAddRva: '0x1100', - fullRemoveRva: '0x1200', - }, - gates: [ - { name: 'pe-sections', passed: true, detail: 'all addresses classified' }, - { name: 'argument-shapes', passed: true, detail: 'full entry arguments matched' }, - ], - }; +test('buildCandidateArtifact requires exact schema, exact gates, and independently derived proofs', () => { + assert.deepEqual(REQUIRED_GATE_NAMES, [ + 'buildIdentity', + 'sessionIdentity', + 'tableAnchors', + 'addCaptureConsistency', + 'removeCaptureConsistency', + 'routinePeSections', + 'argumentShapes', + 'vtablePeSections', + 'vtableTransitionStability', + ]); + const input = candidateInput(); const candidate = buildCandidateArtifact(input); assert.equal(candidate.schemaVersion, 1); assert.deepEqual(Object.keys(candidate.tables), ['4168', '4176', '4190', '4251', '5790', '5847']); assert.deepEqual(candidate.proposedBoard, input.proposedBoard); + assert.deepEqual(candidate.gates.map(({ name }) => name), REQUIRED_GATE_NAMES); assert.equal(candidate.gates.every((gate) => typeof gate.passed === 'boolean'), true); assert.equal(candidate.passed, true); - const failed = buildCandidateArtifact({ - ...input, - gates: input.gates.map((gate, index) => index === 0 ? { ...gate, passed: false } : gate), - }); - assert.equal(failed.passed, false); + const nonExecutableRoutine = candidateInput(); + nonExecutableRoutine.proof.fullAddAddress = canonical(MODULE_BASE + 0x2100n); + assert.equal(buildCandidateArtifact(nonExecutableRoutine).passed, false); + const wrongArguments = candidateInput(); + wrongArguments.proof.addObjectCapture.arguments.rcx = wrongArguments.proof.addObjectCapture.team.address; + assert.equal(buildCandidateArtifact(wrongArguments).passed, false); +}); + +test('buildCandidateArtifact rejects invalid metadata, zero RVAs, incomplete summaries, proofs, and gate sets', () => { + const mutateAndReject = (mutate, message) => { + const input = candidateInput(); + mutate(input); + assert.throws(() => buildCandidateArtifact(input), message); + }; + mutateAndReject((input) => { input.build.label = ''; }, /build label/i); + mutateAndReject((input) => { input.build.executableSize = 0; }, /executable size/i); + mutateAndReject((input) => { input.build.executableSha256 = SHA.toLowerCase(); }, /uppercase SHA-256/i); + mutateAndReject((input) => { input.session.pid = 0; }, /session PID/i); + mutateAndReject((input) => { input.session.sessionId = ''; }, /session ID/i); + mutateAndReject((input) => { input.session.moduleBase = '0x0'; }, /module base/i); + mutateAndReject((input) => { input.proposedBoard.fullAddRva = '0x0'; }, /nonzero.*RVA/i); + mutateAndReject((input) => { delete input.tables['5847']; }, /exactly six table/i); + mutateAndReject((input) => { input.tables.extra = input.tables['4168']; }, /exactly six table/i); + mutateAndReject((input) => { input.captures.add.writeCount = -1; }, /capture summary/i); + mutateAndReject((input) => { delete input.proof.pe; }, /proof/i); + mutateAndReject((input) => { input.unreviewedEvidence = true; }, /candidate input.*exactly/i); + mutateAndReject((input) => { input.gates.pop(); }, /required gate/i); + mutateAndReject((input) => { input.gates.push({ name: 'extra', passed: true, detail: 'x' }); }, /required gate/i); + mutateAndReject((input) => { input.gates[1].name = input.gates[0].name; }, /duplicate|required gate/i); }); From 5a4cec1a5c4e1eabe67f4e4432a680c0913e4ec9 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 22:20:59 -0500 Subject: [PATCH 12/16] fix: bind board evidence provenance --- .../board-verification/reanchor-evidence.cjs | 275 ++++++++++++++---- tests/board-reanchor-evidence.test.cjs | 116 +++++++- 2 files changed, 321 insertions(+), 70 deletions(-) diff --git a/scripts/board-verification/reanchor-evidence.cjs b/scripts/board-verification/reanchor-evidence.cjs index 8372964..aed978c 100644 --- a/scripts/board-verification/reanchor-evidence.cjs +++ b/scripts/board-verification/reanchor-evidence.cjs @@ -27,6 +27,7 @@ const REQUIRED_GATE_NAMES = Object.freeze([ const IMAGE_SCN_MEM_EXECUTE = 0x20000000; const IMAGE_SCN_MEM_READ = 0x40000000; const PARSED_PE_VALUES = new WeakSet(); +let evidenceWriteTestHook = null; function canonicalSha(value) { if (typeof value !== 'string' || !/^[0-9A-F]{64}$/.test(value)) { @@ -140,6 +141,14 @@ function canonicalNonzeroHex(value, label) { return value; } +function canonicalNonzeroAddress(value, label) { + if (typeof value !== 'string' || !/^0x[0-9A-F]+$/.test(value) || canonicalHex(value, label) !== value || + toAddress(value, label) === 0n) { + throw new TypeError(`${label} must be a nonzero canonical uppercase hexadecimal address`); + } + return value; +} + function comparablePath(value) { const normalized = path.normalize(value); return process.platform === 'win32' ? normalized.toUpperCase() : normalized; @@ -213,6 +222,53 @@ function resolveContainedEvidencePath(relativePath, executableSha256, { createPa return target; } +function setEvidenceWriteTestHook(hook) { + if (hook !== null && typeof hook !== 'function') throw new TypeError('Evidence write test hook must be a function or null'); + evidenceWriteTestHook = hook; +} + +function sameFileIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function verifyOwnedTemporary(relativePath, sha, temporary, identity, requireEmpty) { + const parts = evidencePathParts(relativePath); + const parent = ensureDirectoryComponents( + ['.frtk', 'board-reanchor', sha, ...parts.slice(0, -1)], false); + if (!samePath(path.dirname(temporary), parent)) throw new Error('Evidence temporary parent changed containment'); + const status = fs.lstatSync(temporary, { bigint: true }); + if (!status.isFile() || status.isSymbolicLink() || !sameFileIdentity(status, identity)) { + throw new Error('Evidence temporary identity changed containment'); + } + if (requireEmpty && status.size !== 0n) throw new Error('Exclusive evidence temporary was not empty before content write'); + const expectedReal = path.join(realpath(parent), path.basename(temporary)); + if (!samePath(realpath(temporary), expectedReal)) throw new Error('Evidence temporary real path escaped containment'); +} + +function removeOwnedTemporary(sha, temporaryName, identity) { + let root; + try { + root = ensureDirectoryComponents(['.frtk', 'board-reanchor', sha], false); + } catch { + return; + } + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const status = fs.lstatSync(entryPath, { bigint: true }); + if (status.isSymbolicLink()) continue; + if (status.isDirectory()) { + pending.push(entryPath); + } else if (status.isFile() && entry.name === temporaryName && sameFileIdentity(status, identity)) { + fs.rmSync(entryPath); + return; + } + } + } +} + function writeEvidence(relativePath, evidence) { validateEvidenceEnvelope(evidence); const sha = evidence.build.executableSha256; @@ -220,24 +276,52 @@ function writeEvidence(relativePath, evidence) { const directory = path.dirname(target); const temporary = path.join(directory, `.${path.basename(target)}.${process.pid}-${crypto.randomBytes(12).toString('hex')}.tmp`); - const serialized = `${JSON.stringify(evidence, null, 2)}\n`; - let temporaryCreated = false; + const serialized = Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`, 'utf8'); + let descriptor = null; + let temporaryIdentity = null; + let committed = false; try { - fs.writeFileSync(temporary, serialized, { encoding: 'utf8', flag: 'wx' }); - temporaryCreated = true; + descriptor = fs.openSync(temporary, 'wx', 0o600); + temporaryIdentity = fs.fstatSync(descriptor, { bigint: true }); + if (!temporaryIdentity.isFile() || temporaryIdentity.size !== 0n) { + throw new Error('Exclusive evidence temporary was not a zero-byte regular file'); + } + evidenceWriteTestHook?.({ temporaryPath: temporary, targetPath: target }); + verifyOwnedTemporary(relativePath, sha, temporary, temporaryIdentity, true); + const written = fs.writeSync(descriptor, serialized, 0, serialized.length, 0); + if (written !== serialized.length) throw new Error('Evidence temporary write was incomplete'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + verifyOwnedTemporary(relativePath, sha, temporary, temporaryIdentity, false); const rechecked = resolveContainedEvidencePath(relativePath, sha, { createParents: false, requireFile: false }); if (!samePath(rechecked, target)) throw new Error('Evidence target changed during atomic write'); fs.renameSync(temporary, target); - temporaryCreated = false; + committed = true; } catch (error) { - if (temporaryCreated) { + if (descriptor !== null) { + try { + fs.ftruncateSync(descriptor, 0); + fs.fsyncSync(descriptor); + } catch { + // Preserve the original failure. + } try { - fs.rmSync(temporary, { force: true }); + fs.closeSync(descriptor); } catch { - // Preserve the original failure and never touch the destination. + // Preserve the original failure. } + descriptor = null; } throw error; + } finally { + if (!committed && temporaryIdentity !== null) { + try { + removeOwnedTemporary(sha, path.basename(temporary), temporaryIdentity); + } catch { + // Cleanup is identity-bound and must not mask the original failure. + } + } } return target; } @@ -276,6 +360,10 @@ function rangesOverlap(left, right) { return left.start < right.end && right.start < left.end; } +function alignUp(value, alignment) { + return Math.ceil(value / alignment) * alignment; +} + function parsePeSections(image) { requireBufferRange(image, 0, 0x40, 'the DOS header'); if (image.toString('ascii', 0, 2) !== 'MZ') throw new Error('PE image has no MZ signature'); @@ -306,6 +394,8 @@ function parsePeSections(image) { if (sizeOfHeaders === 0 || sizeOfHeaders % fileAlignment !== 0 || sizeOfHeaders > image.length) { throw new Error('PE SizeOfHeaders is invalid'); } + if (sizeOfHeaders > sizeOfImage) throw new Error('PE SizeOfHeaders exceeds SizeOfImage'); + const headerImageRange = { start: 0, end: alignUp(sizeOfHeaders, sectionAlignment) }; const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize; const sectionTableLength = numberOfSections * 40; @@ -330,6 +420,9 @@ function parsePeSections(image) { } const virtualRange = { start: virtualAddress, end: virtualAddress + mappedSize }; if (virtualRange.end > sizeOfImage) throw new Error(`PE section ${name || index} escapes SizeOfImage`); + if (rangesOverlap(headerImageRange, virtualRange)) { + throw new Error(`PE header image range overlaps section ${name || index}`); + } if (virtualRanges.some((range) => rangesOverlap(range, virtualRange))) { throw new Error(`PE virtual section ranges overlap at ${name || index}`); } @@ -362,6 +455,8 @@ function parsePeSections(image) { })); } const result = Object.freeze({ + fileSize: image.length, + executableSha256: crypto.createHash('sha256').update(image).digest('hex').toUpperCase(), sizeOfImage, sizeOfHeaders, sectionAlignment, @@ -459,13 +554,15 @@ function validRow(value) { function validateVtable(object, label, moduleBase, pe) { try { - const table = classifyModuleAddress(object?.vtableAddress, moduleBase, pe); + const vtableAddress = canonicalNonzeroAddress(object?.vtableAddress, `${label} vtable address`); + const table = classifyModuleAddress(vtableAddress, moduleBase, pe); if (!table.insideImage || !table.readable) return `${label} vtable is not in readable main-module image memory`; if (!Array.isArray(object.vtableEntries) || object.vtableEntries.length === 0) { return `${label} vtable has no sampled entries`; } for (const entry of object.vtableEntries) { - const target = classifyModuleAddress(entry, moduleBase, pe); + const target = classifyModuleAddress( + canonicalNonzeroAddress(entry, `${label} vtable entry`), moduleBase, pe); if (!target.insideImage || !target.executable) { return `${label} vtable entry is not in an executable main-module section`; } @@ -494,6 +591,25 @@ function validateObjectShapes(capture, { moduleBase, pe }) { const recruit = capture?.recruit; const expected = capture?.expected; if (!args || !cells || !controller || !team || !recruit || !expected) return reject('Full entry object shape is incomplete'); + for (const [address, label] of [ + [args.rcx, 'RCX'], [args.rdx, 'RDX'], [args.r8, 'R8'], + [controller.address, 'Controller object'], [team.address, 'Team wrapper'], + [recruit.address, 'Recruit wrapper'], [cells.team?.address, 'Team pointer cell'], + [cells.team?.value, 'Team pointer value'], [cells.recruit?.address, 'Recruit pointer cell'], + [cells.recruit?.value, 'Recruit pointer value'], + ]) canonicalNonzeroAddress(address, label); + const distinctObjectsAndCells = [ + controller.address, team.address, recruit.address, cells.team.address, cells.recruit.address, + ].map((address) => canonicalHex(address)); + if (new Set(distinctObjectsAndCells).size !== distinctObjectsAndCells.length) { + return reject('Controller, wrappers, and pointer cells must have distinct nonzero addresses'); + } + canonicalNonzeroAddress(controller.vtableAddress, 'Controller vtable'); + canonicalNonzeroAddress(team.vtableAddress, 'Team vtable'); + canonicalNonzeroAddress(recruit.vtableAddress, 'Recruit vtable'); + if (sameAddress(controller.vtableAddress, team.vtableAddress)) { + return reject('Controller and generic record-wrapper vtables must be distinct'); + } if (![expected.membershipRow, expected.teamRow, expected.recruitRow].every(validRow)) { return reject('Expected membership, Team, and Recruit rows must be nonnegative safe integers'); } @@ -620,60 +736,73 @@ function validateBoardRvas(board) { return Object.fromEntries(BOARD_RVAS.map((name) => [name, canonicalNonzeroHex(board[name], `Nonzero ${name} RVA`)])); } -function validateGates(gates) { - if (!Array.isArray(gates) || gates.length !== REQUIRED_GATE_NAMES.length) { - throw new TypeError('Candidate must contain the exact required gate set'); +function validateProofCapture(capture, label, build, session, requireEntryAddress) { + plainObject(capture, `${label} proof capture`); + nonemptyString(capture.captureId, `${label} proof capture ID`); + assertExactKeys(capture.build, ['executableSize', 'executableSha256'], `${label} proof capture build identity`); + assertExactKeys(capture.session, ['pid', 'sessionId'], `${label} proof capture session identity`); + const captureSize = positiveInteger(capture.build.executableSize, `${label} proof capture executable size`); + const captureSha = canonicalSha(capture.build.executableSha256); + const capturePid = positiveInteger(capture.session.pid, `${label} proof capture PID`); + const captureSessionId = nonemptyString(capture.session.sessionId, `${label} proof capture session ID`); + if (captureSize !== build.executableSize || captureSha !== build.executableSha256) { + throw new Error(`${label} proof capture build identity does not match the authenticated candidate build size and SHA`); } - const byName = new Map(); - for (const gate of gates) { - assertExactKeys(gate, ['name', 'passed', 'detail'], 'Candidate gate'); - if (typeof gate.name !== 'string' || !REQUIRED_GATE_NAMES.includes(gate.name)) { - throw new TypeError('Candidate contains a gate outside the required gate set'); - } - if (byName.has(gate.name)) throw new TypeError(`Candidate contains duplicate required gate ${gate.name}`); - if (typeof gate.passed !== 'boolean' || typeof gate.detail !== 'string') { - throw new TypeError(`Required gate ${gate.name} must contain a boolean and detail string`); - } - byName.set(gate.name, gate); + if (capturePid !== session.pid || captureSessionId !== session.sessionId) { + throw new Error(`${label} proof capture session identity does not match candidate PID and session ID`); } - if (REQUIRED_GATE_NAMES.some((name) => !byName.has(name))) throw new TypeError('Candidate is missing a required gate'); - return byName; + if (requireEntryAddress) canonicalNonzeroAddress(capture.entryAddress, `${label} proof capture entry address`); + return capture; } -function validateProof(proof) { +function validateProof(proof, build, session) { assertExactKeys(proof, [ - 'pe', 'fullAddAddress', 'fullRemoveAddress', 'addObjectCapture', - 'removeObjectCapture', 'transitionObjectCapture', + 'pe', 'fullAddCapture', 'fullRemoveCapture', 'transitionObjectCapture', ], 'Candidate proof'); if (!PARSED_PE_VALUES.has(proof.pe)) throw new TypeError('Candidate proof PE metadata must come from parsePeSections'); - toAddress(proof.fullAddAddress, 'full add address'); - toAddress(proof.fullRemoveAddress, 'full remove address'); - plainObject(proof.addObjectCapture, 'Add object proof'); - plainObject(proof.removeObjectCapture, 'Remove object proof'); - plainObject(proof.transitionObjectCapture, 'Transition object proof'); - return proof; + const peIdentityMatches = proof.pe.fileSize === build.executableSize && + proof.pe.executableSha256 === build.executableSha256; + if (!peIdentityMatches) { + throw new Error('Candidate build size and SHA must exactly match authenticated PE metadata'); + } + const fullAddCapture = validateProofCapture(proof.fullAddCapture, 'Add execute', build, session, true); + const fullRemoveCapture = validateProofCapture(proof.fullRemoveCapture, 'Remove execute', build, session, true); + const transitionObjectCapture = validateProofCapture( + proof.transitionObjectCapture, 'Transition object', build, session, false); + const captureIds = [fullAddCapture.captureId, fullRemoveCapture.captureId, transitionObjectCapture.captureId]; + if (new Set(captureIds).size !== captureIds.length) throw new Error('Proof captures must have distinct capture IDs'); + return { + pe: proof.pe, + fullAddCapture, + fullRemoveCapture, + transitionObjectCapture, + buildIdentityPassed: peIdentityMatches && [fullAddCapture, fullRemoveCapture, transitionObjectCapture].every( + (capture) => capture.build.executableSize === build.executableSize && + capture.build.executableSha256 === build.executableSha256), + sessionIdentityPassed: [fullAddCapture, fullRemoveCapture, transitionObjectCapture].every( + (capture) => capture.session.pid === session.pid && capture.session.sessionId === session.sessionId), + }; } function buildCandidateArtifact(input) { - assertExactKeys(input, ['build', 'session', 'tables', 'captures', 'proposedBoard', 'proof', 'gates'], + assertExactKeys(input, ['build', 'session', 'tables', 'captures', 'proposedBoard', 'proof'], 'Candidate input'); const build = validateBuild(input.build); const session = validateSession(input.session); const tables = validateTables(input.tables); const captures = validateCaptures(input.captures); const proposedBoard = validateBoardRvas(input.proposedBoard); - const callerGates = validateGates(input.gates); - const proof = validateProof(input.proof); + const proof = validateProof(input.proof, build, session); const moduleBase = session.moduleBase; - const addRoutine = classifyModuleAddress(proof.fullAddAddress, moduleBase, proof.pe); - const removeRoutine = classifyModuleAddress(proof.fullRemoveAddress, moduleBase, proof.pe); + const addRoutine = classifyModuleAddress(proof.fullAddCapture.entryAddress, moduleBase, proof.pe); + const removeRoutine = classifyModuleAddress(proof.fullRemoveCapture.entryAddress, moduleBase, proof.pe); const routinePeSections = addRoutine.executable && removeRoutine.executable && addRoutine.rva === proposedBoard.fullAddRva && removeRoutine.rva === proposedBoard.fullRemoveRva; - const addShape = validateObjectShapes(proof.addObjectCapture, { moduleBase, pe: proof.pe }); - const removeShape = validateObjectShapes(proof.removeObjectCapture, { moduleBase, pe: proof.pe }); + const addShape = validateObjectShapes(proof.fullAddCapture, { moduleBase, pe: proof.pe }); + const removeShape = validateObjectShapes(proof.fullRemoveCapture, { moduleBase, pe: proof.pe }); const argumentShapes = addShape.passed && removeShape.passed; - const allObjectCaptures = [proof.addObjectCapture, proof.removeObjectCapture, proof.transitionObjectCapture]; + const allObjectCaptures = [proof.fullAddCapture, proof.fullRemoveCapture, proof.transitionObjectCapture]; const vtablePeSections = allObjectCaptures.every((entry) => validateCaptureVtables(entry, moduleBase, proof.pe).passed); let vtableTransitionStability = false; @@ -686,24 +815,53 @@ function buildCandidateArtifact(input) { vtableTransitionStability = false; } const tableAnchors = Object.values(tables).every((table) => - table.passed && table.candidateCount > 0 && table.score > 0 && table.rereadPassed); + table.passed && table.candidateCount === 1 && table.score > 0 && table.rereadPassed); const addCaptureConsistency = captures.add.writeCount >= 2 && captures.add.executeCount >= 1 && captures.add.consistent; const removeCaptureConsistency = captures.remove.writeCount >= 2 && captures.remove.executeCount >= 1 && captures.remove.consistent; - const derivedConditions = { - buildIdentity: true, - sessionIdentity: true, - tableAnchors, - addCaptureConsistency, - removeCaptureConsistency, - routinePeSections, - argumentShapes, - vtablePeSections, - vtableTransitionStability, + const derivedGates = { + buildIdentity: { + passed: proof.buildIdentityPassed, + detail: `Authenticated PE and all proof captures match executable size ${build.executableSize} and SHA-256 ${build.executableSha256}`, + }, + sessionIdentity: { + passed: proof.sessionIdentityPassed, + detail: `All proof captures match PID ${session.pid} and host session ${session.sessionId}`, + }, + tableAnchors: { + passed: tableAnchors, + detail: tableAnchors ? 'All six tables have exactly one positive, reread-verified candidate' : + 'One or more tables lack exactly one positive, reread-verified candidate', + }, + addCaptureConsistency: { + passed: addCaptureConsistency, + detail: `Add evidence has ${captures.add.writeCount} write captures, ${captures.add.executeCount} execute captures, consistent=${captures.add.consistent}`, + }, + removeCaptureConsistency: { + passed: removeCaptureConsistency, + detail: `Remove evidence has ${captures.remove.writeCount} write captures, ${captures.remove.executeCount} execute captures, consistent=${captures.remove.consistent}`, + }, + routinePeSections: { + passed: routinePeSections, + detail: routinePeSections ? `Add ${addRoutine.rva} and remove ${removeRoutine.rva} entries match executable image sections` : + 'An executed entry is non-executable or does not equal the proposed operation RVA', + }, + argumentShapes: { + passed: argumentShapes, + detail: argumentShapes ? 'Add and remove executed entries carry the required RCX/RDX/R8 object shapes' : + `Add: ${addShape.detail}; remove: ${removeShape.detail}`, + }, + vtablePeSections: { + passed: vtablePeSections, + detail: vtablePeSections ? 'All proof vtables are readable with executable sampled entries' : + 'A proof vtable or sampled entry failed main-module PE checks', + }, + vtableTransitionStability: { + passed: vtableTransitionStability, + detail: vtableTransitionStability ? 'Wrapper and controller vtable RVAs remain stable across all identity-bound proofs' : + 'Wrapper or controller vtable RVAs are invalid, mismatched, or unstable', + }, }; - const gates = REQUIRED_GATE_NAMES.map((name) => { - const supplied = callerGates.get(name); - return { name, passed: supplied.passed && derivedConditions[name], detail: supplied.detail }; - }); + const gates = REQUIRED_GATE_NAMES.map((name) => ({ name, ...derivedGates[name] })); return { schemaVersion: 1, @@ -719,6 +877,7 @@ function buildCandidateArtifact(input) { module.exports = { REQUIRED_GATE_NAMES, + setEvidenceWriteTestHook, evidenceDirectory, writeEvidence, readEvidence, diff --git a/tests/board-reanchor-evidence.test.cjs b/tests/board-reanchor-evidence.test.cjs index a737e82..d9517c2 100644 --- a/tests/board-reanchor-evidence.test.cjs +++ b/tests/board-reanchor-evidence.test.cjs @@ -8,6 +8,7 @@ const test = require('node:test'); const { REQUIRED_GATE_NAMES, + setEvidenceWriteTestHook, evidenceDirectory, writeEvidence, readEvidence, @@ -140,6 +141,16 @@ function objectShape(heapOffset = 0n) { }; } +function proofCapture(captureId, heapOffset, pe, entryAddress) { + return { + captureId, + build: { executableSize: pe.fileSize, executableSha256: pe.executableSha256 }, + session: { pid: 77, sessionId: 'host-start-1' }, + ...(entryAddress === undefined ? {} : { entryAddress: canonical(entryAddress) }), + ...objectShape(heapOffset), + }; +} + function tableSummaries() { return Object.fromEntries(['4168', '4176', '4190', '4251', '5790', '5847'].map((id) => [id, { passed: true, @@ -152,7 +163,7 @@ function tableSummaries() { function candidateInput() { const pe = parsePeSections(peFixture()); return { - build: { label: 'Patch 1', executableSize: 123, executableSha256: SHA }, + build: { label: 'Patch 1', executableSize: pe.fileSize, executableSha256: pe.executableSha256 }, session: { pid: 77, sessionId: 'host-start-1', moduleBase: canonical(MODULE_BASE), capturedAt: '2026-07-16T12:00:00.000Z' }, tables: tableSummaries(), captures: { @@ -167,13 +178,10 @@ function candidateInput() { }, proof: { pe, - fullAddAddress: canonical(MODULE_BASE + 0x1100n), - fullRemoveAddress: canonical(MODULE_BASE + 0x1200n), - addObjectCapture: objectShape(), - removeObjectCapture: objectShape(0x100000n), - transitionObjectCapture: objectShape(0x200000n), + fullAddCapture: proofCapture('add-execute', 0n, pe, MODULE_BASE + 0x1100n), + fullRemoveCapture: proofCapture('remove-execute', 0x100000n, pe, MODULE_BASE + 0x1200n), + transitionObjectCapture: proofCapture('transition', 0x200000n, pe), }, - gates: REQUIRED_GATE_NAMES.map((name) => ({ name, passed: true, detail: `${name} passed` })), }; } @@ -261,10 +269,41 @@ test('rename failure removes only the owned temp and preserves the existing dest assert.deepEqual(fs.readdirSync(evidenceDirectory(sha)), ['blocked.json']); }); +test('parent swap after exclusive temp open writes zero raw bytes outside containment', (t) => { + const sha = shaFor('parent-swap'); + cleanupSha(t, sha); + const envelope = evidenceEnvelope(sha); + const shaDirectory = evidenceDirectory(sha); + const parent = path.join(shaDirectory, 'captures'); + const movedParent = path.join(shaDirectory, 'captures-moved'); + const outside = path.join(EVIDENCE_ROOT, `${sha}-OUTSIDE`); + fs.mkdirSync(parent, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + t.after(() => { + try { + if (typeof setEvidenceWriteTestHook === 'function') setEvidenceWriteTestHook(null); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } + }); + setEvidenceWriteTestHook(({ temporaryPath }) => { + assert.equal(path.dirname(temporaryPath), parent); + fs.renameSync(parent, movedParent); + fs.symlinkSync(outside, parent, process.platform === 'win32' ? 'junction' : 'dir'); + }); + + assert.throws(() => writeEvidence('captures/swap.json', envelope), /junction|real path|containment|EPERM/i); + assert.deepEqual(fs.readdirSync(outside), []); + assert.deepEqual(fs.readdirSync(fs.existsSync(movedParent) ? movedParent : parent), []); +}); + test('parsePeSections classifies valid aligned sections and rejects malformed layouts', () => { const valid = peFixture(); const pe = parsePeSections(valid); assert.equal(pe.sizeOfImage, 0x3000); + assert.equal(pe.fileSize, valid.length); + assert.equal(pe.executableSha256, crypto.createHash('sha256').update(valid).digest('hex').toUpperCase()); + assert.equal(Object.isFrozen(pe), true); assert.deepEqual(pe.sections.map(({ name, readable, executable }) => ({ name, readable, executable })), [ { name: '.text', readable: true, executable: true }, { name: '.rdata', readable: true, executable: false }, @@ -279,12 +318,20 @@ test('parsePeSections classifies valid aligned sections and rejects malformed la misaligned.writeUInt32LE(0x1800, 0x98 + 0xF0 + 40 + 12); const badAlignment = Buffer.from(valid); badAlignment.writeUInt32LE(0x300, 0x98 + 36); + const headersLargerThanImage = Buffer.alloc(0x4000); + valid.copy(headersLargerThanImage); + headersLargerThanImage.writeUInt32LE(0x4000, 0x98 + 60); + const headersOverlapSection = Buffer.alloc(0x1400); + valid.copy(headersOverlapSection); + headersOverlapSection.writeUInt32LE(0x1200, 0x98 + 60); for (const [image, message] of [ [truncated, /raw range|truncated/i], [rawOverlap, /raw.*overlap/i], [virtualOverlap, /virtual.*overlap/i], [misaligned, /section alignment/i], [badAlignment, /file alignment/i], + [headersLargerThanImage, /headers.*image/i], + [headersOverlapSection, /header image range.*overlap/i], ]) assert.throws(() => parsePeSections(image), message); }); @@ -334,6 +381,27 @@ test('object validation requires integer expected and captured membership, Team, } }); +test('object validation rejects zero and mutually contradictory object or pointer addresses', () => { + const pe = parsePeSections(peFixture()); + for (const mutate of [ + (shape) => { shape.arguments.rcx = '0x0'; shape.controller.address = '0x0'; }, + (shape) => { shape.arguments.rdx = '0x0'; shape.pointerCells.team.address = '0x0'; }, + (shape) => { + shape.team.address = shape.recruit.address; + shape.pointerCells.team.value = shape.recruit.address; + }, + (shape) => { + shape.pointerCells.team.address = shape.pointerCells.recruit.address; + shape.arguments.rdx = shape.arguments.r8; + }, + (shape) => { shape.controller.vtableAddress = shape.team.vtableAddress; }, + ]) { + const shape = objectShape(); + mutate(shape); + assert.equal(validateObjectShapes(shape, { moduleBase: MODULE_BASE, pe }).passed, false); + } +}); + test('wrong arguments and PE-invalid vtables are decisive object-shape rejections', () => { const pe = parsePeSections(peFixture()); const lowLevel = objectShape(); @@ -365,14 +433,37 @@ test('buildCandidateArtifact requires exact schema, exact gates, and independent assert.deepEqual(candidate.proposedBoard, input.proposedBoard); assert.deepEqual(candidate.gates.map(({ name }) => name), REQUIRED_GATE_NAMES); assert.equal(candidate.gates.every((gate) => typeof gate.passed === 'boolean'), true); + assert.equal(candidate.gates.every((gate) => gate.detail.length > 0 && !gate.detail.includes('caller')), true); assert.equal(candidate.passed, true); const nonExecutableRoutine = candidateInput(); - nonExecutableRoutine.proof.fullAddAddress = canonical(MODULE_BASE + 0x2100n); + nonExecutableRoutine.proof.fullAddCapture.entryAddress = canonical(MODULE_BASE + 0x2100n); assert.equal(buildCandidateArtifact(nonExecutableRoutine).passed, false); const wrongArguments = candidateInput(); - wrongArguments.proof.addObjectCapture.arguments.rcx = wrongArguments.proof.addObjectCapture.team.address; + wrongArguments.proof.fullAddCapture.arguments.rcx = wrongArguments.proof.fullAddCapture.team.address; assert.equal(buildCandidateArtifact(wrongArguments).passed, false); + const wrongEntry = candidateInput(); + wrongEntry.proof.fullAddCapture.entryAddress = canonical(MODULE_BASE + 0x1200n); + const wrongEntryCandidate = buildCandidateArtifact(wrongEntry); + assert.equal(wrongEntryCandidate.passed, false); + assert.equal(wrongEntryCandidate.gates.find(({ name }) => name === 'routinePeSections').passed, false); + const ambiguousTable = candidateInput(); + ambiguousTable.tables['4168'].candidateCount = 2; + assert.equal(buildCandidateArtifact(ambiguousTable).passed, false); +}); + +test('buildCandidateArtifact binds authenticated PE and every proof capture to build and session identity', () => { + const mutateAndReject = (mutate, message) => { + const input = candidateInput(); + mutate(input); + assert.throws(() => buildCandidateArtifact(input), message); + }; + mutateAndReject((input) => { input.build.executableSize += 1; }, /authenticated PE.*size|build.*PE/i); + mutateAndReject((input) => { input.build.executableSha256 = 'A'.repeat(64); }, /authenticated PE.*SHA|build.*PE/i); + mutateAndReject((input) => { input.proof.fullAddCapture.session.pid = 78; }, /proof capture.*PID|session identity/i); + mutateAndReject((input) => { input.proof.fullRemoveCapture.session.sessionId = 'other'; }, /proof capture.*session|session identity/i); + mutateAndReject((input) => { input.proof.transitionObjectCapture.build.executableSha256 = 'A'.repeat(64); }, /proof capture.*SHA|build identity/i); + mutateAndReject((input) => { input.proof.fullAddCapture.build.executableSize += 1; }, /proof capture.*size|build identity/i); }); test('buildCandidateArtifact rejects invalid metadata, zero RVAs, incomplete summaries, proofs, and gate sets', () => { @@ -392,8 +483,9 @@ test('buildCandidateArtifact rejects invalid metadata, zero RVAs, incomplete sum mutateAndReject((input) => { input.tables.extra = input.tables['4168']; }, /exactly six table/i); mutateAndReject((input) => { input.captures.add.writeCount = -1; }, /capture summary/i); mutateAndReject((input) => { delete input.proof.pe; }, /proof/i); + mutateAndReject((input) => { delete input.proof.fullAddCapture; }, /proof/i); mutateAndReject((input) => { input.unreviewedEvidence = true; }, /candidate input.*exactly/i); - mutateAndReject((input) => { input.gates.pop(); }, /required gate/i); - mutateAndReject((input) => { input.gates.push({ name: 'extra', passed: true, detail: 'x' }); }, /required gate/i); - mutateAndReject((input) => { input.gates[1].name = input.gates[0].name; }, /duplicate|required gate/i); + mutateAndReject((input) => { + input.gates = REQUIRED_GATE_NAMES.map((name) => ({ name, passed: true, detail: 'caller says pass' })); + }, /candidate input.*exactly/i); }); From b8be80168d98a77ad841d1f9d57800019632bbaa Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 22:36:01 -0500 Subject: [PATCH 13/16] feat: automate game build re-anchoring --- docs/development/building.md | 27 + package.json | 2 +- scripts/board-verification/reanchor-build.cjs | 618 ++++++++++++++++++ scripts/board-verification/reanchor-lib.cjs | 1 + scripts/promote-game-build.cjs | 152 +++++ tests/board-reanchor-cli.test.cjs | 92 +++ tests/game-build-promotion.test.cjs | 76 +++ 7 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 scripts/board-verification/reanchor-build.cjs create mode 100644 scripts/promote-game-build.cjs create mode 100644 tests/board-reanchor-cli.test.cjs create mode 100644 tests/game-build-promotion.test.cjs diff --git a/docs/development/building.md b/docs/development/building.md index d635da9..4229111 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -47,3 +47,30 @@ npm run pack:preview directory to use. Set `SOURCE_DATE_EPOCH` to normalize staged file timestamps. The packager rejects archive content, game/save data, logs, dependencies, and build intermediates. + +## Re-anchor after a game executable update + +Add the exact executable size and SHA-256 to `native/host/game_builds.json` as +`diagnostic`, regenerate the header, build, and install the diagnostic host. +With the game offline and a disposable dynasty selected, run: + +```powershell +node scripts/board-verification/reanchor-build.cjs preflight --game-dir "F:\EA SPORTS College Football 27" --save "C:\path\to\disposable-dynasty" +node scripts/board-verification/reanchor-build.cjs validate --game-dir "F:\EA SPORTS College Football 27" --save "C:\path\to\disposable-dynasty" +``` + +Stop after preflight and inspect the printed source/backup paths and matching +hashes before allowing normal game UI actions. Capture two vanilla write traces +per operation, rank them, and confirm one full execute entry: + +```powershell +node scripts/board-verification/reanchor-build.cjs capture-add-write --capture 1 --recruit-row 100 --team-row 22 --game-dir "F:\EA SPORTS College Football 27" --save "C:\path\to\disposable-dynasty" +node scripts/board-verification/reanchor-build.cjs capture-add-write --capture 2 --recruit-row 101 --team-row 22 --game-dir "F:\EA SPORTS College Football 27" --save "C:\path\to\disposable-dynasty" +node scripts/board-verification/reanchor-build.cjs analyze --stage rank --operation add --game-dir "F:\EA SPORTS College Football 27" --save "C:\path\to\disposable-dynasty" +node scripts/board-verification/reanchor-build.cjs capture-add-execute --recruit-row 102 --team-row 22 --game-dir "F:\EA SPORTS College Football 27" --save "C:\path\to\disposable-dynasty" +``` + +Repeat the sequence with `remove`, then run `transition-check` with an +operation and valid recruit/team rows after leaving and re-entering Recruiting. +Final `analyze` writes the ignored, identity-bound `candidate.json`. The host +never loads that file; only explicit source promotion can enable writes. diff --git a/package.json b/package.json index dc9da98..8261fd2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "packages/cli" ], "scripts": { - "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs && node --check scripts/game-build-manifest.cjs && node --check scripts/generate-game-builds.cjs && node --check scripts/board-verification/reanchor-lib.cjs && node --check scripts/board-verification/reanchor-evidence.cjs && node --check scripts/board-verification/live-anchor.cjs && node --check scripts/board-verification/live-table-snapshot.cjs && node scripts/generate-game-builds.cjs --check", + "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/sdk/src/live-class-generator.cjs && node --check packages/sdk/src/live-class-locator.cjs && node --check packages/sdk/src/live-class-replace.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs && node --check scripts/game-build-manifest.cjs && node --check scripts/generate-game-builds.cjs && node --check scripts/promote-game-build.cjs && node --check scripts/board-verification/reanchor-lib.cjs && node --check scripts/board-verification/reanchor-evidence.cjs && node --check scripts/board-verification/live-anchor.cjs && node --check scripts/board-verification/live-table-snapshot.cjs && node --check scripts/board-verification/reanchor-build.cjs && node scripts/generate-game-builds.cjs --check", "test": "node scripts/run-tests.cjs", "build:frtk-profile": "node scripts/build-frtk-profile.cjs", "pack:preview": "node scripts/package-release.cjs" diff --git a/scripts/board-verification/reanchor-build.cjs b/scripts/board-verification/reanchor-build.cjs new file mode 100644 index 0000000..6106c35 --- /dev/null +++ b/scripts/board-verification/reanchor-build.cjs @@ -0,0 +1,618 @@ +'use strict'; + +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); +const readline = require('node:readline/promises'); +const { promisify } = require('node:util'); +const sdk = require('../../packages/sdk'); +const { loadManifest } = require('../game-build-manifest.cjs'); +const { + TABLES, + canonical, + decodeRef, + locateTable, + findUserBoard, + readRange, + validateAnchorReread, +} = require('./reanchor-lib.cjs'); +const { + evidenceDirectory, + writeEvidence, + readEvidence, + parsePeSections, + classifyModuleAddress, + rankRoutineCandidates, + validateObjectShapes, + deriveVtableRvas, + buildCandidateArtifact, +} = require('./reanchor-evidence.cjs'); + +const execFile = promisify(childProcess.execFile); +const REPOSITORY_ROOT = path.resolve(__dirname, '..', '..'); +const MANIFEST_PATH = path.join(REPOSITORY_ROOT, 'native', 'host', 'game_builds.json'); +const COMMANDS = new Set([ + 'preflight', 'validate', 'capture-add-write', 'capture-add-execute', + 'capture-remove-write', 'capture-remove-execute', 'transition-check', + 'analyze', 'status', +]); +const VALUE_FLAGS = new Set([ + '--game-dir', '--save', '--capture', '--operation', '--stage', '--recruit-row', + '--team-row', '--board-slot', +]); +const POWERSHELL = path.join(process.env.SystemRoot || 'C:\\Windows', + 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); +const PATCH1_SHA = 'A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD'; + +function parseArgs(argv) { + if (!Array.isArray(argv) || argv.length < 1 || !COMMANDS.has(argv[0])) { + throw new Error(`Usage: node reanchor-build.cjs <${[...COMMANDS].join('|')}> --game-dir --save `); + } + const options = { command: argv[0] }; + for (let index = 1; index < argv.length; index += 2) { + const flag = argv[index]; + if (!VALUE_FLAGS.has(flag) || index + 1 >= argv.length) throw new Error(`Invalid or missing option: ${flag}`); + const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + if (Object.hasOwn(options, key)) throw new Error(`Duplicate option: ${flag}`); + options[key] = argv[index + 1]; + } + for (const key of ['capture', 'recruitRow', 'teamRow', 'boardSlot']) { + if (options[key] !== undefined) { + const value = Number(options[key]); + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${key} must be a nonnegative integer`); + options[key] = value; + } + } + if (options.operation !== undefined && !['add', 'remove'].includes(options.operation)) { + throw new Error('--operation must be add or remove'); + } + if (options.stage !== undefined && options.stage !== 'rank') throw new Error('--stage must be rank'); + return options; +} + +function sha256Buffer(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex').toUpperCase(); +} + +async function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + for await (const chunk of stream) hash.update(chunk); + return hash.digest('hex').toUpperCase(); +} + +function processScript(pid) { + if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('PID is invalid'); + return `$ErrorActionPreference='Stop'; Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\" | ` + + 'Select-Object ProcessId,ExecutablePath,CreationDate | ConvertTo-Json -Compress'; +} + +async function queryProcess(pid, execFileImpl = execFile) { + const { stdout } = await execFileImpl(POWERSHELL, + ['-NoProfile', '-NonInteractive', '-Command', processScript(pid)], + { windowsHide: true, encoding: 'utf8' }); + const value = JSON.parse(String(stdout).trim() || 'null'); + if (!value || value.ProcessId !== pid || typeof value.ExecutablePath !== 'string' || + typeof value.CreationDate !== 'string') throw new Error('Could not establish exact game process identity'); + return { pid, path: value.ExecutablePath, creationDate: value.CreationDate }; +} + +async function anticheatProcesses(execFileImpl = execFile) { + const script = "$ErrorActionPreference='Stop'; @(Get-Process | Where-Object { $_.ProcessName -match 'Javelin|EAAntiCheat|EAAntiCheat.GameService' } | Select-Object Id,ProcessName) | ConvertTo-Json -Compress"; + const { stdout } = await execFileImpl(POWERSHELL, + ['-NoProfile', '-NonInteractive', '-Command', script], + { windowsHide: true, encoding: 'utf8' }); + const text = String(stdout).trim(); + if (!text) return []; + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? parsed : [parsed]; +} + +function luaHex(value) { + return `0x${BigInt(value).toString(16).toUpperCase()}`; +} + +function luaCaptureSerializer(prefix) { + const quoted = JSON.stringify(prefix); + return ` +local prefix=${quoted} +local function hx(v) if v == nil then return "0x0" end return string.format("0x%X", v) end +local function list(v) local out={} if v then for i=1,#v do out[#out+1]=hx(v[i]) end end return table.concat(out,",") end +local hits=cfb.watch_hits(true) +local count=0 +for i,h in ipairs(hits) do + count=count+1 + cfb.log(prefix.."|HIT|"..i.."|"..h.slot.."|"..h.thread_id.."|"..hx(h.rip).."|"..hx(h.rsp).."|"..hx(h.rax).."|"..hx(h.rbx).."|"..hx(h.rbp).."|"..hx(h.rsi).."|"..hx(h.rdi).."|"..hx(h.rcx).."|"..hx(h.rdx).."|"..hx(h.r8).."|"..hx(h.r9).."|"..hx(h.r10).."|"..hx(h.r11)) + cfb.log(prefix.."|STACK|"..i.."|"..list(h.stack)) + cfb.log(prefix.."|RBX|"..i.."|"..list(h.rbx_memory)) + cfb.log(prefix.."|RSI|"..i.."|"..list(h.rsi_memory)) + cfb.log(prefix.."|RDI|"..i.."|"..list(h.rdi_memory)) + cfb.log(prefix.."|RCX|"..i.."|"..list(h.rcx_memory)) + cfb.log(prefix.."|RDX|"..i.."|"..list(h.rdx_memory)) + cfb.log(prefix.."|R8|"..i.."|"..list(h.r8_memory)) + cfb.log(prefix.."|R9|"..i.."|"..list(h.r9_memory)) +end +cfb.unwatch() +cfb.log(prefix.."|META|"..count.."|"..(hits.dropped or 0))`; +} + +function parseHexList(text) { + if (!text) return []; + return text.split(',').filter(Boolean).map((value) => canonical(value)); +} + +function parseCaptureLogs(logs, prefix) { + const hits = new Map(); + let declaredCount = null; + let dropped = null; + for (const entry of logs) { + if (!entry || typeof entry.message !== 'string' || !entry.message.startsWith(`${prefix}|`)) continue; + const parts = entry.message.split('|'); + const type = parts[1]; + if (type === 'META') { + declaredCount = Number(parts[2]); + dropped = Number(parts[3]); + continue; + } + const index = Number(parts[2]); + if (!Number.isSafeInteger(index) || index <= 0) continue; + const hit = hits.get(index) || { index }; + if (type === 'HIT') { + const names = ['slot', 'threadId', 'rip', 'rsp', 'rax', 'rbx', 'rbp', 'rsi', 'rdi', + 'rcx', 'rdx', 'r8', 'r9', 'r10', 'r11']; + names.forEach((name, offset) => { + const value = parts[offset + 3]; + hit[name] = offset < 2 ? Number(value) : canonical(value); + }); + } else { + const field = type === 'STACK' ? 'stackReturnAddresses' : `${type.toLowerCase()}Memory`; + hit[field] = parseHexList(parts[3]); + } + hits.set(index, hit); + } + const ordered = [...hits.values()].sort((left, right) => left.index - right.index); + if (!Number.isSafeInteger(declaredCount) || declaredCount !== ordered.length || dropped !== 0) { + throw new Error(`Watch capture is incomplete: declared=${declaredCount}, parsed=${ordered.length}, dropped=${dropped}`); + } + return ordered; +} + +async function moduleBase(client) { + const prefix = `REANCHOR_MODULE_${crypto.randomBytes(8).toString('hex').toUpperCase()}`; + await client.evaluateLua(`cfb.log(${JSON.stringify(prefix)}.."|"..string.format("0x%X",cfb.module_base()))`); + const result = await client.getLogs({ limit: 256 }); + const entry = result.logs.findLast((item) => item.message.startsWith(`${prefix}|`)); + if (!entry) throw new Error('Could not read the main module base from the host'); + return canonical(entry.message.slice(prefix.length + 1)); +} + +function sessionId({ pid, creationDate, hostVersion, readyTimestampMs }) { + return sha256Buffer(Buffer.from(`${pid}|${creationDate}|${hostVersion}|${readyTimestampMs}`, 'utf8')); +} + +function evidenceIdentity(runtime) { + return { + pid: runtime.session.pid, + sessionId: runtime.session.sessionId, + executableSha256: runtime.build.executableSha256, + }; +} + +function envelope(runtime, extra = {}) { + return { schemaVersion: 1, build: runtime.build, session: runtime.session, ...extra }; +} + +async function establishRuntime(options, dependencies = {}) { + const discoverGame = dependencies.discoverGame || sdk.discoverGame; + const createClient = dependencies.createClient || sdk.createClient; + const query = dependencies.queryProcess || queryProcess; + const anticheat = dependencies.anticheatProcesses || anticheatProcesses; + const executable = path.resolve(options.gameDir || '', 'CollegeFB27.exe'); + const stat = await fsp.stat(executable); + if (!stat.isFile()) throw new Error('CollegeFB27.exe is not a file'); + const sha = await (dependencies.sha256File || sha256File)(executable); + const manifest = loadManifest(MANIFEST_PATH); + const build = manifest.builds.find((entry) => entry.size === stat.size && entry.sha256 === sha); + if (!build) throw new Error(`Executable is absent from the build registry: ${sha}`); + const game = await discoverGame({ expectedSize: stat.size, expectedSha256: sha }); + const process = await query(game.pid); + if (path.resolve(process.path).toLowerCase() !== executable.toLowerCase()) { + throw new Error('Running executable path does not match --game-dir'); + } + const activeAnticheat = await anticheat(); + if (activeAnticheat.length > 0) throw new Error('Real EA/Javelin anticheat process is running'); + const client = createClient({ pid: game.pid, timeoutMs: 60_000 }); + const hello = await client.hello(); + const status = await client.status(); + if (!status.ready || !hello.capabilities.includes('researchWatch')) throw new Error('Research-capable host is not ready'); + const logs = (await client.getLogs({ limit: 256 })).logs; + const ready = logs.findLast((entry) => entry.message === 'CFB27 Lua host ready'); + if (!ready) throw new Error('Host-ready session marker is absent'); + const base = await moduleBase(client); + const runtime = { + build: { label: build.label, executableSize: build.size, executableSha256: build.sha256 }, + session: { + pid: game.pid, + sessionId: sessionId({ pid: game.pid, creationDate: process.creationDate, + hostVersion: hello.hostVersion, readyTimestampMs: ready.timestampMs }), + moduleBase: base, + capturedAt: new Date().toISOString(), + }, + registrySupport: build.support, + hello, + status, + process, + executable, + client, + }; + return runtime; +} + +async function backupSave(savePath, runtime) { + const source = path.resolve(savePath || ''); + const stat = await fsp.stat(source); + if (!stat.isFile()) throw new Error('--save must identify an existing save file'); + const directory = path.join(evidenceDirectory(runtime.build.executableSha256), 'save-backup'); + await fsp.mkdir(directory, { recursive: true }); + const target = path.join(directory, path.basename(source)); + const sourceHash = await sha256File(source); + if (fs.existsSync(target)) { + if (await sha256File(target) !== sourceHash) throw new Error('Existing save backup does not match the selected save'); + } else { + await fsp.copyFile(source, target, fs.constants.COPYFILE_EXCL); + } + const backupHash = await sha256File(target); + if (backupHash !== sourceHash) throw new Error('Save backup verification failed'); + return { source, sourceHash, backupPath: target, backupHash, size: stat.size, verified: true }; +} + +async function locateAll(client, log = (text) => process.stderr.write(text)) { + const located = []; + for (const table of TABLES.values()) located.push(await locateTable(client, table, { log })); + const tables = new Map(located.map((table) => [table.id, table])); + return { located, tables, board: findUserBoard(tables) }; +} + +function serializeTables(result) { + return { + tables: Object.fromEntries(result.located.map((table) => [String(table.id), { + header: canonical(table.header), base: canonical(table.base), stride: table.stride, + capacity: table.capacity, words: table.words, candidateCount: table.candidateCount, + signatureMatches: table.signatureMatches, freelistHead: table.freelistHead, + score: table.score.score, rereadPassed: true, + }])), + tableSummaries: Object.fromEntries(result.located.map((table) => [String(table.id), { + passed: true, candidateCount: table.candidateCount, score: table.score.score, rereadPassed: true, + }])), + userBoard: result.board.selected, + }; +} + +async function hydrateTables(client, stored) { + const located = []; + for (const [idText, saved] of Object.entries(stored.tables)) { + const spec = TABLES.get(Number(idText)); + if (!spec) throw new Error(`Unknown stored table ${idText}`); + const data = await readRange(client, BigInt(saved.base), spec.capacity * spec.stride); + const freelistHead = (await readRange(client, BigInt(saved.header) + 24n, 4)).readUInt32LE(0); + const table = { ...spec, header: BigInt(saved.header), base: BigInt(saved.base), data, + freelistHead, score: { score: saved.score }, candidateCount: saved.candidateCount, + signatureMatches: saved.signatureMatches }; + await validateAnchorReread(client, spec, table); + located.push(table); + } + const tables = new Map(located.map((entry) => [entry.id, entry])); + return { located, tables, board: findUserBoard(tables) }; +} + +function findBoardSlot(tables, teamRow, recruitRow) { + const membership = tables.get(5847); + const targets = tables.get(4168); + if (!Number.isSafeInteger(teamRow) || teamRow < 0 || teamRow >= membership.capacity) { + throw new Error('teamRow is outside table 5847'); + } + const offset = teamRow * membership.stride; + for (let slot = 0; slot < membership.words; slot += 1) { + const membershipRef = decodeRef(membership.data.readUInt32LE(offset + slot * 4)); + if (membershipRef.tableId !== 4168 || membershipRef.row >= targets.capacity) continue; + const recruitRef = decodeRef(targets.data.readUInt32LE(membershipRef.row * targets.stride + 12)); + if (recruitRef.tableId === 4269 && recruitRef.row === recruitRow) return slot; + } + throw new Error(`Recruit row ${recruitRow} is not on membership row ${teamRow}`); +} + +function armScript(watches, execute) { + const calls = watches.map(({ address, length = 4 }) => execute + ? `cfb.watch_exec(${luaHex(address)})` + : `cfb.watch(${luaHex(address)},${length})`).join('\n'); + return `cfb.unwatch()\n${calls}`; +} + +async function promptAction(message, input = process.stdin, output = process.stdout) { + if (!input.isTTY) throw new Error('Interactive vanilla action confirmation requires a TTY'); + const rl = readline.createInterface({ input, output }); + try { await rl.question(`${message}\nPress Enter only after the vanilla UI action finishes... `); } + finally { rl.close(); } +} + +async function collectWatch(client, prefix) { + await client.evaluateLua(luaCaptureSerializer(prefix)); + const result = await client.getLogs({ limit: 256 }); + return parseCaptureLogs(result.logs, prefix); +} + +async function readBytes(client, address, length) { + const result = await client.readMemory({ allowUnsupportedBuild: true, + ranges: [{ address: canonical(address), length }] }); + return Buffer.from(result.ranges[0].bytesHex, 'hex'); +} + +async function readQword(client, address) { + return (await readBytes(client, address, 8)).readBigUInt64LE(0); +} + +async function descriptorTableId(client, descriptor) { + return Number((await readQword(client, descriptor + 40n)) >> 32n); +} + +async function vtableEntries(client, address) { + const bytes = await readBytes(client, address, 16); + return [canonical(bytes.readBigUInt64LE(0)), canonical(bytes.readBigUInt64LE(8))]; +} + +async function enrichExecuteHit(client, hit, expected, runtime, captureId) { + const controllerAddress = BigInt(hit.rcx); + const teamCell = BigInt(hit.rdx); + const recruitCell = BigInt(hit.r8); + const teamAddress = await readQword(client, teamCell); + const recruitAddress = await readQword(client, recruitCell); + const controller = await readBytes(client, controllerAddress, 0x140); + const team = await readBytes(client, teamAddress, 32); + const recruit = await readBytes(client, recruitAddress, 32); + const controllerDescriptor = controller.readBigUInt64LE(16); + const teamDescriptor = team.readBigUInt64LE(16); + const recruitDescriptor = recruit.readBigUInt64LE(16); + const controllerVtable = controller.readBigUInt64LE(0); + const wrapperVtable = team.readBigUInt64LE(0); + const recruitVtable = recruit.readBigUInt64LE(0); + const membershipRow = Number(controller.readBigUInt64LE(8)); + const boardStore = controller.readBigUInt64LE(0x138); + await readBytes(client, boardStore, 8); + return { + captureId, + build: { executableSize: runtime.build.executableSize, + executableSha256: runtime.build.executableSha256 }, + session: { pid: runtime.session.pid, sessionId: runtime.session.sessionId }, + entryAddress: hit.rip, + arguments: { rcx: hit.rcx, rdx: hit.rdx, r8: hit.r8 }, + pointerCells: { + team: { address: hit.rdx, value: canonical(teamAddress), readable: true }, + recruit: { address: hit.r8, value: canonical(recruitAddress), readable: true }, + }, + controller: { + address: hit.rcx, readable: true, + descriptorTableId: await descriptorTableId(client, controllerDescriptor), + membershipRow, vtableAddress: canonical(controllerVtable), + vtableEntries: await vtableEntries(client, controllerVtable), + boardStore: { offset: 0x138, readable: true, membershipRow }, + }, + team: { + address: canonical(teamAddress), readable: true, + descriptorTableId: await descriptorTableId(client, teamDescriptor), + row: Number(team.readBigUInt64LE(24)), field10Readable: true, field18Readable: true, + vtableAddress: canonical(wrapperVtable), vtableEntries: await vtableEntries(client, wrapperVtable), + }, + recruit: { + address: canonical(recruitAddress), readable: true, + descriptorTableId: await descriptorTableId(client, recruitDescriptor), + row: Number(recruit.readBigUInt64LE(24)), field10Readable: true, field18Readable: true, + vtableAddress: canonical(recruitVtable), vtableEntries: await vtableEntries(client, recruitVtable), + }, + expected: { membershipRow: expected.membershipRow, teamRow: expected.teamRow, + recruitRow: expected.recruitRow }, + }; +} + +async function requirePreflight(options, dependencies) { + const runtime = await establishRuntime(options, dependencies); + const identity = evidenceIdentity(runtime); + const preflight = readEvidence('preflight.json', identity); + if (preflight.saveBackup?.verified !== true) throw new Error('Preflight has no verified save backup'); + return { runtime, identity, preflight }; +} + +async function commandPreflight(options, dependencies) { + const runtime = await establishRuntime(options, dependencies); + if (runtime.registrySupport !== 'diagnostic' || runtime.hello.supportedBuild !== false || + runtime.hello.writesAllowed !== false || runtime.status.writesAllowed !== false) { + throw new Error('Preflight requires an exact diagnostic host with writes disabled'); + } + const saveBackup = await backupSave(options.save, runtime); + const record = envelope(runtime, { + process: { path: runtime.process.path, creationDate: runtime.process.creationDate }, + host: { version: runtime.hello.hostVersion, supportedBuild: runtime.hello.supportedBuild, + writesAllowed: runtime.hello.writesAllowed, ready: runtime.status.ready }, + saveBackup, + }); + writeEvidence('preflight.json', record); + return record; +} + +async function commandValidate(options, dependencies) { + const { runtime } = await requirePreflight(options, dependencies); + const result = await locateAll(runtime.client); + const serialized = serializeTables(result); + const record = envelope(runtime, serialized); + writeEvidence('tables.json', record); + return record; +} + +async function commandWriteCapture(options, dependencies, operation) { + if (![1, 2].includes(options.capture)) throw new Error('--capture must be 1 or 2'); + if (!Number.isSafeInteger(options.recruitRow) || !Number.isSafeInteger(options.teamRow)) { + throw new Error('Write capture requires --recruit-row and --team-row'); + } + const { runtime, identity } = await requirePreflight(options, dependencies); + const stored = readEvidence('tables.json', identity); + const live = await hydrateTables(runtime.client, stored); + const membership = live.tables.get(5847); + const userTargets = live.tables.get(4168); + const pitches = live.tables.get(5790); + const teamRow = options.teamRow; + const watches = []; + if (operation === 'add') { + if (teamRow !== live.board.selected.teamRow) throw new Error('teamRow is not the validated user board'); + watches.push({ address: userTargets.header + 24n }, { + address: membership.base + BigInt(teamRow * membership.stride + live.board.selected.firstFreeSlot * 4), + }); + } else { + const slot = options.boardSlot ?? findBoardSlot(live.tables, teamRow, options.recruitRow); + watches.push({ address: membership.base + BigInt(teamRow * membership.stride + slot * 4) }, + { address: userTargets.header + 24n }, { address: pitches.header + 24n }); + } + await runtime.client.evaluateLua(armScript(watches, false)); + await (dependencies.promptAction || promptAction)( + `Perform ONE vanilla ${operation.toUpperCase()} for recruit row ${options.recruitRow} now.`); + const captureId = `${operation}-write-${options.capture}`; + const prefix = `REANCHOR_${runtime.session.sessionId.slice(0, 12)}_${captureId.toUpperCase()}`; + const hits = await collectWatch(runtime.client, prefix); + await hydrateTables(runtime.client, stored); + const record = envelope(runtime, { captureId, operation, recruitRow: options.recruitRow, + teamRow, hits, postconditionVerified: true }); + writeEvidence(`captures/${captureId}.json`, record); + return record; +} + +async function rankedCandidates(operation, runtime, identity) { + const captures = [1, 2].map((index) => readEvidence(`captures/${operation}-write-${index}.json`, identity)); + const pe = parsePeSections(fs.readFileSync(runtime.executable)); + return rankRoutineCandidates(captures, { moduleBase: runtime.session.moduleBase, pe }); +} + +async function commandRank(options, dependencies) { + if (!options.operation) throw new Error('Rank analysis requires --operation add or remove'); + const { runtime, identity } = await requirePreflight(options, dependencies); + const candidates = await rankedCandidates(options.operation, runtime, identity); + const record = envelope(runtime, { operation: options.operation, candidates }); + writeEvidence(`rank-${options.operation}.json`, record); + return record; +} + +async function commandExecuteCapture(options, dependencies, operation, transition = false) { + if (!Number.isSafeInteger(options.recruitRow) || !Number.isSafeInteger(options.teamRow)) { + throw new Error('Execute capture requires --recruit-row and --team-row'); + } + const { runtime, identity } = await requirePreflight(options, dependencies); + const stored = readEvidence('tables.json', identity); + const live = await hydrateTables(runtime.client, stored); + const candidates = await rankedCandidates(operation, runtime, identity); + if (candidates.length < 1) throw new Error(`No executable ${operation} candidate was ranked`); + const watches = candidates.slice(0, 4).map((entry) => ({ address: BigInt(runtime.session.moduleBase) + BigInt(entry.rva) })); + await runtime.client.evaluateLua(armScript(watches, true)); + await (dependencies.promptAction || promptAction)(transition + ? `Leave and re-enter Recruiting, then perform ONE vanilla ${operation.toUpperCase()} for recruit row ${options.recruitRow}.` + : `Perform ONE vanilla ${operation.toUpperCase()} for recruit row ${options.recruitRow} to confirm the full entry.`); + const captureId = transition ? 'transition' : `${operation}-execute`; + const prefix = `REANCHOR_${runtime.session.sessionId.slice(0, 12)}_${captureId.toUpperCase()}`; + const hits = await collectWatch(runtime.client, prefix); + const expected = { membershipRow: live.board.selected.teamRow, teamRow: options.teamRow, + recruitRow: options.recruitRow }; + const pe = parsePeSections(fs.readFileSync(runtime.executable)); + const enriched = []; + for (const hit of hits) { + try { + const capture = await enrichExecuteHit(runtime.client, hit, expected, runtime, captureId); + if (validateObjectShapes(capture, { moduleBase: runtime.session.moduleBase, pe }).passed) enriched.push(capture); + } catch { + // Incidental execute candidates are expected; only full entry shapes survive. + } + } + if (enriched.length !== 1) throw new Error(`Expected exactly one full ${operation} entry shape; found ${enriched.length}`); + const record = envelope(runtime, { captureId, operation, objectCapture: enriched[0], rawHitCount: hits.length }); + writeEvidence(`captures/${captureId}.json`, record); + return record; +} + +async function commandAnalyzeFinal(options, dependencies) { + const { runtime, identity } = await requirePreflight(options, dependencies); + const tables = readEvidence('tables.json', identity); + const addWrite = [1, 2].map((index) => readEvidence(`captures/add-write-${index}.json`, identity)); + const removeWrite = [1, 2].map((index) => readEvidence(`captures/remove-write-${index}.json`, identity)); + const addExecute = readEvidence('captures/add-execute.json', identity).objectCapture; + const removeExecute = readEvidence('captures/remove-execute.json', identity).objectCapture; + const transition = readEvidence('captures/transition.json', identity).objectCapture; + const pe = parsePeSections(fs.readFileSync(runtime.executable)); + const vtables = deriveVtableRvas([addExecute, removeExecute, transition], { + moduleBase: runtime.session.moduleBase, pe, + }); + const addEntry = classifyModuleAddress(addExecute.entryAddress, runtime.session.moduleBase, pe); + const removeEntry = classifyModuleAddress(removeExecute.entryAddress, runtime.session.moduleBase, pe); + const input = { + build: runtime.build, + session: runtime.session, + tables: tables.tableSummaries, + captures: { + add: { writeCount: addWrite.length, executeCount: 1, + consistent: addWrite.every((capture) => capture.postconditionVerified) }, + remove: { writeCount: removeWrite.length, executeCount: 1, + consistent: removeWrite.every((capture) => capture.postconditionVerified) }, + }, + proposedBoard: { ...vtables, fullAddRva: addEntry.rva, fullRemoveRva: removeEntry.rva }, + proof: { pe, fullAddCapture: addExecute, fullRemoveCapture: removeExecute, + transitionObjectCapture: transition }, + }; + const candidate = buildCandidateArtifact(input); + writeEvidence('candidate.json', candidate); + return candidate; +} + +async function commandStatus(options, dependencies) { + const runtime = await establishRuntime(options, dependencies); + const directory = evidenceDirectory(runtime.build.executableSha256); + const files = fs.existsSync(directory) + ? fs.readdirSync(directory, { recursive: true }).map(String).sort() + : []; + return { build: runtime.build, session: runtime.session, registrySupport: runtime.registrySupport, + hello: runtime.hello, status: runtime.status, evidenceFiles: files }; +} + +async function run(options, dependencies = {}) { + if (!options.gameDir) throw new Error('--game-dir is required'); + if (options.command !== 'status' && !options.save) throw new Error('--save is required'); + switch (options.command) { + case 'preflight': return commandPreflight(options, dependencies); + case 'validate': return commandValidate(options, dependencies); + case 'capture-add-write': return commandWriteCapture(options, dependencies, 'add'); + case 'capture-remove-write': return commandWriteCapture(options, dependencies, 'remove'); + case 'capture-add-execute': return commandExecuteCapture(options, dependencies, 'add'); + case 'capture-remove-execute': return commandExecuteCapture(options, dependencies, 'remove'); + case 'transition-check': return commandExecuteCapture(options, dependencies, options.operation || 'add', true); + case 'analyze': return options.stage === 'rank' + ? commandRank(options, dependencies) : commandAnalyzeFinal(options, dependencies); + case 'status': return commandStatus(options, dependencies); + default: throw new Error('Unsupported command'); + } +} + +async function main() { + const result = await run(parseArgs(process.argv.slice(2))); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`${error.code || 'ERROR'}: ${error.message}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + parseArgs, + parseCaptureLogs, + sessionId, + findBoardSlot, + serializeTables, + run, +}; diff --git a/scripts/board-verification/reanchor-lib.cjs b/scripts/board-verification/reanchor-lib.cjs index 72f97d3..bf03be3 100644 --- a/scripts/board-verification/reanchor-lib.cjs +++ b/scripts/board-verification/reanchor-lib.cjs @@ -205,6 +205,7 @@ async function locateTable(client, table, { log = (message) => process.stderr.wr ...table, ...selected, freelistHead: validation.freelistHeadValue, + candidateCount: candidates.length, signatureMatches, validation, }; diff --git a/scripts/promote-game-build.cjs b/scripts/promote-game-build.cjs new file mode 100644 index 0000000..4a1e843 --- /dev/null +++ b/scripts/promote-game-build.cjs @@ -0,0 +1,152 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { + parseManifest, + writeGeneratedHeader, +} = require('./game-build-manifest.cjs'); +const { + REQUIRED_GATE_NAMES, + evidenceDirectory, +} = require('./board-verification/reanchor-evidence.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const MANIFEST_PATH = path.join(ROOT, 'native', 'host', 'game_builds.json'); +const HEADER_PATH = path.join(ROOT, 'native', 'host', 'game_builds.generated.h'); +const BOARD_KEYS = [ + 'genericRecordWrapperVtableRva', + 'recruitingControllerVtableRva', + 'fullAddRva', + 'fullRemoveRva', +]; + +function exactKeys(value, keys, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} must be an object`); + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new TypeError(`${label} must contain exactly: ${expected.join(', ')}`); + } +} + +function canonicalSha(value) { + if (typeof value !== 'string' || !/^[0-9A-F]{64}$/.test(value)) { + throw new TypeError('Build SHA must be uppercase SHA-256'); + } + return value; +} + +function canonicalRva(value, label) { + if (typeof value !== 'string' || !/^0x[0-9A-F]+$/.test(value) || BigInt(value) === 0n || + `0x${BigInt(value).toString(16).toUpperCase()}` !== value) { + throw new TypeError(`${label} must be a nonzero canonical uppercase RVA`); + } + return value; +} + +function validateCandidate(candidate) { + exactKeys(candidate, ['schemaVersion', 'build', 'session', 'tables', 'captures', + 'proposedBoard', 'gates', 'passed'], 'Candidate'); + if (candidate.schemaVersion !== 1 || candidate.passed !== true) throw new Error('Candidate did not pass all evidence gates'); + exactKeys(candidate.build, ['label', 'executableSize', 'executableSha256'], 'Candidate build'); + if (typeof candidate.build.label !== 'string' || candidate.build.label.length === 0 || + !Number.isSafeInteger(candidate.build.executableSize) || candidate.build.executableSize <= 0) { + throw new TypeError('Candidate build identity is invalid'); + } + canonicalSha(candidate.build.executableSha256); + exactKeys(candidate.proposedBoard, BOARD_KEYS, 'Candidate board layout'); + const board = Object.fromEntries(BOARD_KEYS.map((key) => [key, + canonicalRva(candidate.proposedBoard[key], key)])); + if (!Array.isArray(candidate.gates) || candidate.gates.length !== REQUIRED_GATE_NAMES.length) { + throw new Error('Candidate has an incomplete evidence gate set'); + } + const names = candidate.gates.map((gate) => gate?.name); + if (new Set(names).size !== names.length || + names.some((name, index) => name !== REQUIRED_GATE_NAMES[index]) || + candidate.gates.some((gate) => gate.passed !== true || typeof gate.detail !== 'string' || !gate.detail)) { + throw new Error('Candidate evidence gates are missing, reordered, duplicated, or failed'); + } + return { build: { ...candidate.build }, board }; +} + +function certifyManifest(rawManifest, candidate) { + parseManifest(rawManifest); + const validated = validateCandidate(candidate); + const output = JSON.parse(JSON.stringify(rawManifest)); + const matches = output.builds.filter((build) => build.size === validated.build.executableSize && + build.sha256 === validated.build.executableSha256); + if (matches.length !== 1) throw new Error('Candidate does not match exactly one registered build'); + const build = matches[0]; + if (build.support !== 'diagnostic' || build.board !== null) throw new Error('Only a diagnostic build can be certified'); + if (build.label !== validated.build.label) throw new Error('Candidate build label does not match the registry'); + build.support = 'certified'; + build.board = validated.board; + parseManifest(output); + return output; +} + +function demoteManifest(rawManifest, sha256) { + parseManifest(rawManifest); + const sha = canonicalSha(sha256); + const output = JSON.parse(JSON.stringify(rawManifest)); + const matches = output.builds.filter((build) => build.sha256 === sha); + if (matches.length !== 1) throw new Error('Demotion SHA does not match exactly one registered build'); + matches[0].support = 'diagnostic'; + matches[0].board = null; + parseManifest(output); + return output; +} + +function containedCandidatePath(candidatePath) { + const absolute = path.resolve(candidatePath); + const parent = path.dirname(absolute); + const sha = path.basename(parent); + canonicalSha(sha); + if (path.basename(absolute) !== 'candidate.json' || path.resolve(evidenceDirectory(sha), 'candidate.json') !== absolute) { + throw new Error('Candidate path must be the fixed .frtk/board-reanchor//candidate.json'); + } + const status = fs.lstatSync(absolute); + if (!status.isFile() || status.isSymbolicLink() || fs.realpathSync.native(absolute) !== absolute) { + throw new Error('Candidate path is not a contained regular file'); + } + return absolute; +} + +function writeManifest(rawManifest) { + const temporary = `${MANIFEST_PATH}.${process.pid}.tmp`; + fs.writeFileSync(temporary, `${JSON.stringify(rawManifest, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); + try { fs.renameSync(temporary, MANIFEST_PATH); } + finally { fs.rmSync(temporary, { force: true }); } + writeGeneratedHeader({ manifestPath: MANIFEST_PATH, headerPath: HEADER_PATH }); +} + +function parseCli(argv) { + if (argv.length === 3 && argv[0] === '--candidate' && argv[2] === '--certify') { + return { mode: 'certify', candidatePath: argv[1] }; + } + if (argv.length === 3 && argv[0] === '--sha' && argv[2] === '--diagnostic') { + return { mode: 'diagnostic', sha256: argv[1] }; + } + throw new Error('Usage: promote-game-build.cjs --candidate --certify | --sha --diagnostic'); +} + +function main(argv = process.argv.slice(2)) { + const options = parseCli(argv); + const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + const output = options.mode === 'certify' + ? certifyManifest(manifest, JSON.parse(fs.readFileSync(containedCandidatePath(options.candidatePath), 'utf8'))) + : demoteManifest(manifest, options.sha256); + writeManifest(output); + process.stdout.write(`${options.mode === 'certify' ? 'certified' : 'demoted'} ${ + options.mode === 'certify' ? output.builds.find((build) => build.support === 'certified' && + build.sha256 !== manifest.builds.find((entry) => entry.support === 'certified')?.sha256)?.sha256 || 'build' + : options.sha256}\n`); +} + +if (require.main === module) { + try { main(); } + catch (error) { process.stderr.write(`${error.code || 'ERROR'}: ${error.message}\n`); process.exitCode = 1; } +} + +module.exports = { validateCandidate, certifyManifest, demoteManifest, parseCli }; diff --git a/tests/board-reanchor-cli.test.cjs b/tests/board-reanchor-cli.test.cjs new file mode 100644 index 0000000..5901a47 --- /dev/null +++ b/tests/board-reanchor-cli.test.cjs @@ -0,0 +1,92 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { + parseArgs, + parseCaptureLogs, + sessionId, + findBoardSlot, + serializeTables, + run, +} = require('../scripts/board-verification/reanchor-build.cjs'); + +test('guided CLI parses only phase-scoped options', () => { + assert.deepEqual(parseArgs(['capture-add-write', '--game-dir', 'G', '--save', 'S', + '--capture', '2', '--recruit-row', '33', '--team-row', '22']), { + command: 'capture-add-write', gameDir: 'G', save: 'S', capture: 2, + recruitRow: 33, teamRow: 22, + }); + assert.throws(() => parseArgs(['unknown']), /Usage/); + assert.throws(() => parseArgs(['validate', '--output-root', 'elsewhere']), /Invalid/); + assert.throws(() => parseArgs(['analyze', '--stage', 'final']), /stage/); + assert.throws(() => parseArgs(['capture-add-write', '--capture', '-1']), /nonnegative/); +}); + +test('watch log parser requires complete zero-drop evidence', () => { + const prefix = 'CAPTURE'; + const base = [ + { message: `${prefix}|HIT|1|0|77|0x140001100|0x2000|0x1|0x2|0x3|0x4|0x5|0x6|0x7|0x8|0x9|0xA|0xB` }, + { message: `${prefix}|STACK|1|0x140001200,0x140001300` }, + { message: `${prefix}|RCX|1|0x10,0x20` }, + { message: `${prefix}|META|1|0` }, + ]; + const hits = parseCaptureLogs(base, prefix); + assert.equal(hits.length, 1); + assert.equal(hits[0].rip, '0x140001100'); + assert.deepEqual(hits[0].stackReturnAddresses, ['0x140001200', '0x140001300']); + assert.deepEqual(hits[0].rcxMemory, ['0x10', '0x20']); + assert.throws(() => parseCaptureLogs(base.slice(0, -1), prefix), /incomplete/); + assert.throws(() => parseCaptureLogs([...base.slice(0, -1), + { message: `${prefix}|META|1|1` }], prefix), /dropped=1/); +}); + +test('session identity is stable and sensitive to host start evidence', () => { + const input = { pid: 77, creationDate: '20260716120000.000000-300', + hostVersion: '0.2.0-dev.2', readyTimestampMs: 1234 }; + assert.match(sessionId(input), /^[0-9A-F]{64}$/); + assert.equal(sessionId(input), sessionId({ ...input })); + assert.notEqual(sessionId(input), sessionId({ ...input, readyTimestampMs: 1235 })); +}); + +test('board slot lookup follows membership to the recruit target', () => { + const membershipData = Buffer.alloc(140); + membershipData.writeUInt32LE((4168 << 17) | 4, 3 * 4); + const targetData = Buffer.alloc(36 * 10); + targetData.writeUInt32LE((4269 << 17) | 33, 4 * 36 + 12); + const tables = new Map([ + [5847, { capacity: 138, stride: 140, words: 35, data: membershipData }], + [4168, { capacity: 10, stride: 36, data: targetData }], + ]); + assert.equal(findBoardSlot(tables, 0, 33), 3); + assert.throws(() => findBoardSlot(tables, 0, 34), /not on membership/); +}); + +test('serialized validation retains strict candidate counts and six summaries', () => { + const located = [4168, 4176, 4190, 4251, 5790, 5847].map((id) => ({ + id, header: 0x1000n + BigInt(id), base: 0x2000n + BigInt(id), stride: 4, + capacity: 8, words: 1, candidateCount: 1, signatureMatches: 2, freelistHead: 0, + score: { score: 9 }, + })); + const output = serializeTables({ located, board: { selected: { teamRow: 2, firstFreeSlot: 3 } } }); + assert.equal(Object.keys(output.tableSummaries).length, 6); + assert.equal(output.tableSummaries['4168'].candidateCount, 1); + assert.equal(output.tables['5847'].rereadPassed, true); +}); + +test('run refuses missing common gates before process discovery', async () => { + await assert.rejects(run({ command: 'validate', save: 'S' }), /game-dir/); + await assert.rejects(run({ command: 'validate', gameDir: 'G' }), /save/); +}); + +test('CLI source is import-safe and contains no custom evidence root authority', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'board-verification', + 'reanchor-build.cjs'), 'utf8'); + assert.match(source, /require\.main === module/); + assert.doesNotMatch(source, /--output-root/); + assert.match(source, /allowUnsupportedBuild: true/); + assert.match(source, /supportedBuild !== false/); + assert.match(source, /writesAllowed !== false/); +}); diff --git a/tests/game-build-promotion.test.cjs b/tests/game-build-promotion.test.cjs new file mode 100644 index 0000000..8ffa442 --- /dev/null +++ b/tests/game-build-promotion.test.cjs @@ -0,0 +1,76 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { REQUIRED_GATE_NAMES } = require('../scripts/board-verification/reanchor-evidence.cjs'); +const { certifyManifest, demoteManifest, parseCli } = require('../scripts/promote-game-build.cjs'); + +const OLD_SHA = '9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8'; +const PATCH_SHA = 'A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD'; + +function manifest() { + return { version: 1, builds: [ + { label: 'july-11-2026', size: 247845776, sha256: OLD_SHA, support: 'certified', board: { + genericRecordWrapperVtableRva: '0xB093F68', recruitingControllerVtableRva: '0xB0B5BA8', + fullAddRva: '0x8109060', fullRemoveRva: '0x8166090', + } }, + { label: 'patch-1-2026-07-16', size: 249801616, sha256: PATCH_SHA, + support: 'diagnostic', board: null }, + ] }; +} + +function candidate() { + return { + schemaVersion: 1, + build: { label: 'patch-1-2026-07-16', executableSize: 249801616, executableSha256: PATCH_SHA }, + session: { pid: 77, sessionId: 'session', moduleBase: '0x140000000', capturedAt: '2026-07-16T12:00:00.000Z' }, + tables: {}, captures: {}, + proposedBoard: { genericRecordWrapperVtableRva: '0xB193F68', + recruitingControllerVtableRva: '0xB1B5BA8', fullAddRva: '0x8209060', fullRemoveRva: '0x8266090' }, + gates: REQUIRED_GATE_NAMES.map((name) => ({ name, passed: true, detail: `${name} passed` })), + passed: true, + }; +} + +test('certification changes only the exact diagnostic build and four RVAs', () => { + const input = manifest(); + const output = certifyManifest(input, candidate()); + assert.deepEqual(input, manifest()); + assert.equal(output.builds[0].support, 'certified'); + assert.equal(output.builds[1].support, 'certified'); + assert.deepEqual(output.builds[1].board, candidate().proposedBoard); +}); + +test('certification rejects failed, wrong-identity, zero-RVA, and gate-set candidates', () => { + const cases = [ + (value) => { value.passed = false; }, + (value) => { value.build.executableSha256 = 'A'.repeat(64); }, + (value) => { value.proposedBoard.fullAddRva = '0x0'; }, + (value) => { value.gates.pop(); }, + (value) => { value.gates[0].name = 'anything'; }, + (value) => { value.gates[0].passed = false; }, + ]; + for (const mutate of cases) { + const value = candidate(); mutate(value); + assert.throws(() => certifyManifest(manifest(), value), /candidate|gate|RVA|registered|pass/i); + } +}); + +test('demotion removes layout and preserves other builds', () => { + const certified = certifyManifest(manifest(), candidate()); + const output = demoteManifest(certified, PATCH_SHA); + assert.equal(output.builds[1].support, 'diagnostic'); + assert.equal(output.builds[1].board, null); + assert.deepEqual(output.builds[0], manifest().builds[0]); + assert.throws(() => demoteManifest(manifest(), 'A'.repeat(64)), /exactly one/); +}); + +test('promotion CLI accepts only explicit certify or diagnostic modes', () => { + assert.deepEqual(parseCli(['--candidate', 'candidate.json', '--certify']), { + mode: 'certify', candidatePath: 'candidate.json', + }); + assert.deepEqual(parseCli(['--sha', PATCH_SHA, '--diagnostic']), { + mode: 'diagnostic', sha256: PATCH_SHA, + }); + assert.throws(() => parseCli(['--candidate', 'candidate.json']), /Usage/); +}); From d954b51d8763d433145a827fa1f11730b0b350f8 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 16 Jul 2026 22:40:18 -0500 Subject: [PATCH 14/16] fix: let doctor verify installed artifacts --- packages/cli/src/main.cjs | 8 +++++++- packages/cli/test/main.test.cjs | 11 +++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/main.cjs b/packages/cli/src/main.cjs index 90ed630..6d1dfa6 100644 --- a/packages/cli/src/main.cjs +++ b/packages/cli/src/main.cjs @@ -392,7 +392,13 @@ async function main(argv, { if (positionals.length) throw usageError('doctor does not accept positional arguments'); const gameDir = requireDirectory(options.gameDir || env.CFB27_GAME_DIR, '--game-dir'); const mmcDir = requireDirectory(options.mmcDir || env.CFB27_MMC_DIR, '--mmc-dir'); - result = await sdk.doctor({ gameDir, mmcDir }); + const artifactsDir = options.artifactsDir || env.CFB27_HOOK_ARTIFACTS; + const doctorOptions = { gameDir, mmcDir }; + if (artifactsDir) { + doctorOptions.proxyDll = path.resolve(artifactsDir, 'cfb27_cryptbase_proxy.dll'); + doctorOptions.hostDll = path.resolve(artifactsDir, 'cfb27_lua_host.dll'); + } + result = await sdk.doctor(doctorOptions); } else if (command === 'logs') { if (positionals.length) throw usageError('logs does not accept positional arguments'); const game = await sdk.discoverGame(); diff --git a/packages/cli/test/main.test.cjs b/packages/cli/test/main.test.cjs index 0999923..a77087f 100644 --- a/packages/cli/test/main.test.cjs +++ b/packages/cli/test/main.test.cjs @@ -113,13 +113,20 @@ test('run delegates the complete file and eval preserves separate source tokens' test('doctor dispatch performs no installation writes', async () => { const { io } = memoryIo({ CFB27_GAME_DIR: 'F:\\game', CFB27_MMC_DIR: 'F:\\mmc' }); let installs = 0; + let doctorOptions; const sdk = { - doctor: async () => ({ checks: [] }), + doctor: async (options) => { doctorOptions = options; return { checks: [] }; }, installHook: async () => { installs += 1; }, restoreMmcHook: async () => { installs += 1; }, }; - assert.equal(await main(['doctor'], { sdk, io }), 0); + assert.equal(await main(['doctor', '--artifacts-dir', 'F:\\artifacts'], { sdk, io }), 0); assert.equal(installs, 0); + assert.deepEqual(doctorOptions, { + gameDir: 'F:\\game', + mmcDir: 'F:\\mmc', + proxyDll: path.resolve('F:\\artifacts', 'cfb27_cryptbase_proxy.dll'), + hostDll: path.resolve('F:\\artifacts', 'cfb27_lua_host.dll'), + }); }); test('logs and events dispatch through cursor-aware SDK methods', async () => { From 5e76b6fa07ce19095e6a1e8ce1a8e142549aba22 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 17 Jul 2026 15:55:32 -0500 Subject: [PATCH 15/16] fix: re-anchor Patch 1 board mutations --- docs/protocol.md | 15 +- native/host/board_mutation.cpp | 335 +++++++++++++++--- native/host/game_builds.generated.h | 5 +- native/host/game_builds.h | 6 + native/host/game_builds.json | 23 +- native/smoke/board_mutation_smoke.cpp | 3 +- native/smoke/game_builds_smoke.cpp | 8 +- packages/sdk/src/client.cjs | 17 +- scripts/board-verification/reanchor-build.cjs | 267 ++++++++++++-- .../board-verification/reanchor-evidence.cjs | 93 ++++- scripts/board-verification/reanchor-lib.cjs | 72 +++- scripts/game-build-manifest.cjs | 6 + scripts/promote-game-build.cjs | 6 + tests/board-reanchor-cli.test.cjs | 4 +- tests/board-reanchor-evidence.test.cjs | 8 +- tests/board-reanchor.test.cjs | 24 +- tests/game-build-manifest.test.cjs | 8 +- tests/game-build-promotion.test.cjs | 10 +- 18 files changed, 786 insertions(+), 124 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 7ff90a0..e4c273f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -306,10 +306,17 @@ board layout, 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`. +On the first board mutation in a game session, the host resolves the recruiting +controller, both record wrappers, and required tables in one snapshot pass. It +caches those addresses only while all object and table signatures continue to +validate. Later calls normally reuse that cache. The SDK allows up to 120 +seconds for this first discovery when the client uses its default timeout; +an explicitly configured client timeout remains authoritative. + +Every call validates compact membership and freelist state before invoking the +handler 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}} diff --git a/native/host/board_mutation.cpp b/native/host/board_mutation.cpp index 4b8903e..1322008 100644 --- a/native/host/board_mutation.cpp +++ b/native/host/board_mutation.cpp @@ -10,18 +10,16 @@ #include #include #include +#include #include #include namespace cfb27::board_mutation { namespace { -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 kUserTargetTableRole = 4168; +constexpr std::uint32_t kActivePitchTableRole = 5790; +constexpr std::uint32_t kMembershipTableRole = 5847; constexpr std::uint32_t kMembershipCapacity = 138; constexpr std::uint32_t kBoardSlots = 35; constexpr std::uint32_t kReferenceRowMask = 0x1FFFF; @@ -48,6 +46,20 @@ struct TableView { std::uint32_t score{}; }; +struct RuntimeCache { + std::uintptr_t module{}; + std::uint32_t recruit_row{}; + std::uint32_t team_row{}; + std::uintptr_t controller{}; + std::uintptr_t recruit_wrapper{}; + std::uintptr_t team_wrapper{}; + TableView targets; + TableView pitches; + TableView membership; +}; + +std::optional g_runtime_cache; + struct BoardItem { std::uint32_t slot{}; std::uint32_t target_row{}; @@ -77,6 +89,13 @@ bool ReadableProtection(DWORD protection) { base == PAGE_EXECUTE_READWRITE || base == PAGE_EXECUTE_WRITECOPY; } +bool WritableProtection(DWORD protection) { + if (protection & (PAGE_GUARD | PAGE_NOACCESS)) return false; + const DWORD base = protection & 0xFF; + return base == PAGE_READWRITE || base == PAGE_WRITECOPY || + 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; @@ -122,7 +141,7 @@ std::vector PrivateReadableRegions() { 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) { + WritableProtection(info.Protect) && info.RegionSize >= 32) { regions.push_back({reinterpret_cast(base), info.RegionSize}); } const auto next = base + info.RegionSize; @@ -135,13 +154,89 @@ std::vector PrivateReadableRegions() { 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; + constexpr std::size_t kChunkSize = 256 * 1024; + const auto process = GetCurrentProcess(); + std::vector snapshot(kChunkSize + needle.size() - 1); + std::size_t carry = 0; + std::uintptr_t cursor = reinterpret_cast(region.begin); + const auto end = cursor + region.size; + std::uintptr_t last_reported = 0; + while (cursor < end) { + const auto requested = (std::min)(kChunkSize, static_cast(end - cursor)); + SIZE_T copied = 0; + if (!ReadProcessMemory(process, reinterpret_cast(cursor), + snapshot.data() + carry, requested, &copied) || + copied != requested) { + carry = 0; + cursor += requested; + continue; + } + const auto available = carry + requested; + auto search = snapshot.begin(); + const auto finish = snapshot.begin() + available; + while (search + needle.size() <= finish) { + const auto found = std::search(search, finish, needle.begin(), needle.end()); + if (found == finish) break; + const auto address = cursor - carry + + static_cast(found - snapshot.begin()); + if (address != last_reported) { + callback(address); + last_reported = address; + } + search = found + 1; + } + carry = (std::min)(needle.size() - 1, available); + if (carry) std::memmove(snapshot.data(), snapshot.data() + available - carry, carry); + cursor += requested; + } +} + +template +void FindPatterns( + const Region& region, + const std::array, Count>& needles, + Callback callback) { + constexpr std::size_t kChunkSize = 256 * 1024; + std::size_t maximum_needle = 0; + for (const auto needle : needles) maximum_needle = (std::max)(maximum_needle, needle.size()); + if (!maximum_needle) return; + + const auto process = GetCurrentProcess(); + std::vector snapshot(kChunkSize + maximum_needle - 1); + std::array last_reported{}; + std::size_t carry = 0; + std::uintptr_t cursor = reinterpret_cast(region.begin); + const auto end = cursor + region.size; + while (cursor < end) { + const auto requested = (std::min)(kChunkSize, static_cast(end - cursor)); + SIZE_T copied = 0; + if (!ReadProcessMemory(process, reinterpret_cast(cursor), + snapshot.data() + carry, requested, &copied) || + copied != requested) { + carry = 0; + cursor += requested; + continue; + } + const auto available = carry + requested; + const auto finish = snapshot.begin() + available; + for (std::size_t pattern = 0; pattern < Count; ++pattern) { + const auto needle = needles[pattern]; + auto search = snapshot.begin(); + while (search + needle.size() <= finish) { + const auto found = std::search(search, finish, needle.begin(), needle.end()); + if (found == finish) break; + const auto address = cursor - carry + + static_cast(found - snapshot.begin()); + if (address != last_reported[pattern]) { + callback(pattern, address); + last_reported[pattern] = address; + } + search = found + 1; + } + } + carry = (std::min)(maximum_needle - 1, available); + if (carry) std::memmove(snapshot.data(), snapshot.data() + available - carry, carry); + cursor += requested; } } @@ -162,7 +257,8 @@ std::array TableSignature(const TableSpec& spec) { 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) { +std::uint32_t ScoreTable(const TableSpec& spec, std::uintptr_t data, + const game_builds::BoardLayout& layout) { if (!ReadableRange(data, static_cast(spec.capacity) * spec.stride)) return 0; std::uint32_t free_rows = 0; @@ -172,7 +268,7 @@ std::uint32_t ScoreTable(const TableSpec& spec, std::uintptr_t data) { const auto record = data + static_cast(row) * spec.stride; std::uint32_t first{}; if (!ReadValue(record, first)) return 0; - if (spec.id == kMembershipTableId) { + if (spec.id == kMembershipTableRole) { bool structural = true; bool saw_zero = false; for (std::uint32_t slot = 0; slot < spec.words; ++slot) { @@ -183,7 +279,7 @@ std::uint32_t ScoreTable(const TableSpec& spec, std::uintptr_t data) { continue; } const auto table = ReferenceTable(reference); - if (saw_zero || (table != kUserTargetTableId && table != 4288)) { + if (saw_zero || table == 0) { structural = false; break; } @@ -199,11 +295,11 @@ std::uint32_t ScoreTable(const TableSpec& spec, std::uintptr_t data) { if (word != 0) rest_zero = false; } if (first == row + 1 && rest_zero) ++free_rows; - if (spec.id == kUserTargetTableId) { + if (spec.id == kUserTargetTableRole) { std::uint32_t recruit{}; if (!ReadValue(record + 12, recruit)) return 0; - if (ReferenceTable(recruit) == kRecruitTableId) ++content_rows; - } else if (spec.id == kActivePitchTableId) { + if (ReferenceTable(recruit) == layout.recruit_table_id) ++content_rows; + } else if (spec.id == kActivePitchTableRole) { if (ReferenceTable(first) == 4190) ++content_rows; } } @@ -211,13 +307,13 @@ std::uint32_t ScoreTable(const TableSpec& spec, std::uintptr_t data) { } bool LocateTable(const std::vector& regions, const TableSpec& spec, - TableView& selected) { + const game_builds::BoardLayout& layout, 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); + const auto score = ScoreTable(spec, data, layout); std::uint32_t head{}; if (score && ReadValue(header + 24, head)) candidates.push_back({&spec, header, data, head, score}); @@ -232,12 +328,74 @@ bool LocateTable(const std::vector& regions, const TableSpec& spec, return true; } +bool SelectTable(std::vector& candidates, TableView& selected) { + 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; +} + +void AddTableCandidate(const TableSpec& spec, + const game_builds::BoardLayout& layout, + std::uintptr_t header, + std::vector& candidates) { + const auto data = header + spec.data_offset; + const auto score = ScoreTable(spec, data, layout); + std::uint32_t head{}; + if (score && ReadValue(header + 24, head)) + candidates.push_back({&spec, header, data, head, score}); +} + std::uint32_t DescriptorTableId(std::uintptr_t descriptor) { std::uint64_t encoded{}; if (!ReadValue(descriptor + 40, encoded)) return 0; return static_cast(encoded >> 32); } +bool ValidateController(const game_builds::BoardLayout& layout, + std::uintptr_t module, std::uintptr_t address) { + std::uintptr_t vtable{}; + std::uint64_t membership_row{}; + std::uintptr_t descriptor{}; + std::uintptr_t board_store{}; + return ReadValue(address, vtable) && + vtable == module + layout.recruiting_controller_vtable_rva && + ReadValue(address + 8, membership_row) && membership_row < kMembershipCapacity && + ReadValue(address + 16, descriptor) && + DescriptorTableId(descriptor) == layout.controller_descriptor_table_id && + ReadValue(address + 0x138, board_store) && ReadableRange(board_store, 8); +} + +bool ValidateWrapper(const game_builds::BoardLayout& layout, + std::uintptr_t module, std::uintptr_t address, + std::uint32_t table_id, std::uint32_t row) { + std::uintptr_t vtable{}; + std::uintptr_t descriptor{}; + std::uint64_t captured_row{}; + return ReadValue(address, vtable) && + vtable == module + layout.generic_record_wrapper_vtable_rva && + ReadValue(address + 16, descriptor) && DescriptorTableId(descriptor) == table_id && + ReadValue(address + 24, captured_row) && captured_row == row; +} + +bool RefreshTable(TableView& view) { + if (!view.spec || !ReadableRange(view.header, 32)) return false; + const auto expected = TableSignature(*view.spec); + std::array actual{}; + std::uint32_t head{}; + if (!ReadValue(view.header, actual) || actual != expected || + !ReadValue(view.header + 24, head) || + !ReadableRange(view.data, + static_cast(view.spec->capacity) * view.spec->stride)) { + return false; + } + view.head = head; + return true; +} + void FindRuntimeObjects(const game_builds::BoardLayout& layout, const std::vector& regions, std::uintptr_t module, std::uint32_t recruit_row, std::uint32_t team_row, @@ -256,7 +414,7 @@ void FindRuntimeObjects(const game_builds::BoardLayout& layout, std::uintptr_t board_store{}; if (!ReadValue(address + 8, membership_row) || membership_row >= kMembershipCapacity || !ReadValue(address + 16, descriptor) || - DescriptorTableId(descriptor) != kControllerDescriptorTableId || + DescriptorTableId(descriptor) != layout.controller_descriptor_table_id || !ReadValue(address + 0x138, board_store) || !ReadableRange(board_store, 8)) return; controllers.push_back(address); }); @@ -266,16 +424,74 @@ void FindRuntimeObjects(const game_builds::BoardLayout& layout, 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) + if (row == recruit_row && table_id == layout.recruit_table_id) recruit_wrappers.push_back(address); - if (row == team_row && table_id == kTeamTableId) + if (row == team_row && table_id == layout.team_table_id) team_wrappers.push_back(address); }); } } +bool DiscoverRuntime(const game_builds::BoardLayout& layout, + 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, + TableView& targets, TableView& pitches, + TableView& membership) { + const auto wrapper_vtable = module + layout.generic_record_wrapper_vtable_rva; + const auto controller_vtable = module + layout.recruiting_controller_vtable_rva; + const auto controller_bytes = QwordBytes(controller_vtable); + const auto wrapper_bytes = QwordBytes(wrapper_vtable); + const auto target_signature = TableSignature(kUserTarget); + const auto pitch_signature = TableSignature(kActivePitch); + const auto membership_signature = TableSignature(kMembership); + const std::array, 5> patterns{ + controller_bytes, wrapper_bytes, target_signature, pitch_signature, + membership_signature}; + std::vector target_candidates; + std::vector pitch_candidates; + std::vector membership_candidates; + + for (const auto& region : regions) { + FindPatterns(region, patterns, [&](std::size_t pattern, std::uintptr_t address) { + if (pattern == 0) { + if ((address & 7) != 0 || + !ValidateController(layout, module, address)) return; + controllers.push_back(address); + return; + } + if (pattern == 1) { + 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 == layout.recruit_table_id) + recruit_wrappers.push_back(address); + if (row == team_row && table_id == layout.team_table_id) + team_wrappers.push_back(address); + return; + } + if (pattern == 2) { + AddTableCandidate(kUserTarget, layout, address, target_candidates); + } else if (pattern == 3) { + AddTableCandidate(kActivePitch, layout, address, pitch_candidates); + } else { + AddTableCandidate(kMembership, layout, address, membership_candidates); + } + }); + } + return SelectTable(target_candidates, targets) && + SelectTable(pitch_candidates, pitches) && + SelectTable(membership_candidates, membership); +} + BoardSnapshot ReadBoard(const TableView& targets, const TableView& pitches, - const TableView& membership, std::uint32_t membership_row) { + const TableView& membership, + const game_builds::BoardLayout& layout, + 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 + @@ -290,7 +506,7 @@ BoardSnapshot ReadBoard(const TableView& targets, const TableView& pitches, continue; } if (saw_zero) result.compact = false; - if (ReferenceTable(reference) != kUserTargetTableId) return result; + if (ReferenceTable(reference) != layout.user_target_table_id) return result; const auto target_row = ReferenceRow(reference); if (target_row >= targets.spec->capacity) return result; const auto target_address = targets.data + @@ -299,10 +515,10 @@ BoardSnapshot ReadBoard(const TableView& targets, const TableView& pitches, std::uint32_t pitch_reference{}; if (!ReadValue(target_address + 12, recruit_reference) || !ReadValue(target_address + 16, pitch_reference) || - ReferenceTable(recruit_reference) != kRecruitTableId) return result; + ReferenceTable(recruit_reference) != layout.recruit_table_id) return result; std::uint32_t pitch_row = UINT32_MAX; if (pitch_reference) { - if (ReferenceTable(pitch_reference) != kActivePitchTableId || + if (ReferenceTable(pitch_reference) != layout.active_pitch_table_id || ReferenceRow(pitch_reference) >= pitches.spec->capacity) return result; pitch_row = ReferenceRow(pitch_reference); } @@ -338,30 +554,51 @@ Result Invoke(const game_builds::BoardLayout& layout, Operation operation, result.status = Status::kRecruitingNotLoaded; return result; } - const auto regions = PrivateReadableRegions(); std::vector controllers; std::vector recruit_wrappers; std::vector team_wrappers; - FindRuntimeObjects(layout, 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; + const bool cache_valid = g_runtime_cache && g_runtime_cache->module == module && + g_runtime_cache->recruit_row == recruit_row && + g_runtime_cache->team_row == team_row && + ValidateController(layout, module, g_runtime_cache->controller) && + ValidateWrapper(layout, module, g_runtime_cache->recruit_wrapper, + layout.recruit_table_id, recruit_row) && + ValidateWrapper(layout, module, g_runtime_cache->team_wrapper, + layout.team_table_id, team_row) && + RefreshTable(g_runtime_cache->targets) && + RefreshTable(g_runtime_cache->pitches) && + RefreshTable(g_runtime_cache->membership); + if (cache_valid) { + controllers.push_back(g_runtime_cache->controller); + recruit_wrappers.push_back(g_runtime_cache->recruit_wrapper); + team_wrappers.push_back(g_runtime_cache->team_wrapper); + targets = g_runtime_cache->targets; + pitches = g_runtime_cache->pitches; + membership = g_runtime_cache->membership; + } else { + g_runtime_cache.reset(); + const auto regions = PrivateReadableRegions(); + const bool tables_found = DiscoverRuntime( + layout, regions, module, recruit_row, team_row, controllers, + recruit_wrappers, team_wrappers, targets, pitches, membership); + 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; + } + if (!tables_found) { + result.status = Status::kTableDiscoveryFailed; + return result; + } + g_runtime_cache = RuntimeCache{module, recruit_row, team_row, controllers[0], + recruit_wrappers[0], team_wrappers[0], targets, pitches, membership}; } std::uint64_t membership_row64{}; if (!ReadValue(controllers[0] + 8, membership_row64) || @@ -370,7 +607,7 @@ Result Invoke(const game_builds::BoardLayout& layout, Operation operation, return result; } result.membership_row = static_cast(membership_row64); - const auto before = ReadBoard(targets, pitches, membership, result.membership_row); + const auto before = ReadBoard(targets, pitches, membership, layout, result.membership_row); if (!before.valid) { result.status = Status::kBoardStateInvalid; return result; @@ -434,7 +671,7 @@ Result Invoke(const game_builds::BoardLayout& layout, Operation operation, result.status = Status::kPostconditionFailed; return result; } - const auto after = ReadBoard(targets, pitches, membership, result.membership_row); + const auto after = ReadBoard(targets, pitches, membership, layout, result.membership_row); const auto after_matches = Matching(after, recruit_row); if (!after.valid) { result.status = Status::kPostconditionFailed; diff --git a/native/host/game_builds.generated.h b/native/host/game_builds.generated.h index 3823fa5..8d48c09 100644 --- a/native/host/game_builds.generated.h +++ b/native/host/game_builds.generated.h @@ -5,8 +5,9 @@ inline constexpr std::array kGeneratedBuilds{{ {"july-11-2026", 247845776ULL, "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8", Support::kCertified, - BoardLayout{0xB093F68ULL, 0xB0B5BA8ULL, 0x8109060ULL, 0x8166090ULL}}, + BoardLayout{0xB093F68ULL, 0xB0B5BA8ULL, 0x8109060ULL, 0x8166090ULL, 0x10ADULL, 0x18BEULL, 0x138BULL, 0x1048ULL, 0x169EULL, 0x16D7ULL}}, {"patch-1-2026-07-16", 249801616ULL, "A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD", - Support::kDiagnostic, std::nullopt}, + Support::kCertified, + BoardLayout{0xB098088ULL, 0xB0B9D18ULL, 0x810AC70ULL, 0x8168AA0ULL, 0x10B1ULL, 0x18B1ULL, 0x138DULL, 0x104BULL, 0x1691ULL, 0x16CAULL}}, }}; diff --git a/native/host/game_builds.h b/native/host/game_builds.h index 05d62c5..e858d69 100644 --- a/native/host/game_builds.h +++ b/native/host/game_builds.h @@ -13,6 +13,12 @@ struct BoardLayout { std::uintptr_t recruiting_controller_vtable_rva{}; std::uintptr_t full_add_rva{}; std::uintptr_t full_remove_rva{}; + std::uint32_t recruit_table_id{}; + std::uint32_t team_table_id{}; + std::uint32_t controller_descriptor_table_id{}; + std::uint32_t user_target_table_id{}; + std::uint32_t active_pitch_table_id{}; + std::uint32_t membership_table_id{}; }; struct Build { diff --git a/native/host/game_builds.json b/native/host/game_builds.json index 2df736b..564c171 100644 --- a/native/host/game_builds.json +++ b/native/host/game_builds.json @@ -10,15 +10,32 @@ "genericRecordWrapperVtableRva": "0xB093F68", "recruitingControllerVtableRva": "0xB0B5BA8", "fullAddRva": "0x8109060", - "fullRemoveRva": "0x8166090" + "fullRemoveRva": "0x8166090", + "recruitTableId": "0x10AD", + "teamTableId": "0x18BE", + "controllerDescriptorTableId": "0x138B", + "userTargetTableId": "0x1048", + "activePitchTableId": "0x169E", + "membershipTableId": "0x16D7" } }, { "label": "patch-1-2026-07-16", "size": 249801616, "sha256": "A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD", - "support": "diagnostic", - "board": null + "support": "certified", + "board": { + "genericRecordWrapperVtableRva": "0xB098088", + "recruitingControllerVtableRva": "0xB0B9D18", + "fullAddRva": "0x810AC70", + "fullRemoveRva": "0x8168AA0", + "recruitTableId": "0x10B1", + "teamTableId": "0x18B1", + "controllerDescriptorTableId": "0x138D", + "userTargetTableId": "0x104B", + "activePitchTableId": "0x1691", + "membershipTableId": "0x16CA" + } } ] } diff --git a/native/smoke/board_mutation_smoke.cpp b/native/smoke/board_mutation_smoke.cpp index 18a3eeb..acc224d 100644 --- a/native/smoke/board_mutation_smoke.cpp +++ b/native/smoke/board_mutation_smoke.cpp @@ -7,7 +7,8 @@ int main() { using cfb27::board_mutation::Operation; using cfb27::board_mutation::Status; - const cfb27::game_builds::BoardLayout layout{1, 2, 3, 4}; + const cfb27::game_builds::BoardLayout layout{ + 1, 2, 3, 4, 4269, 6334, 5003, 4168, 5790, 5847}; const auto invalid = Invoke(layout, Operation::kAdd, 0x20000, 0); if (invalid.status != Status::kInvalidArgument) return 1; diff --git a/native/smoke/game_builds_smoke.cpp b/native/smoke/game_builds_smoke.cpp index 2125c3d..2c2bd08 100644 --- a/native/smoke/game_builds_smoke.cpp +++ b/native/smoke/game_builds_smoke.cpp @@ -19,8 +19,12 @@ int main() { 249801616ULL, "A048578530F7ED5967DF38803B63AD9B9F04FC71287F1E151C901A94AB240BFD"); if (!patch1 || patch1->label != "patch-1-2026-07-16" || - patch1->support != Support::kDiagnostic || patch1->board || - IsCertified(patch1) || !IsDiagnosticOrCertified(patch1)) return 2; + patch1->support != Support::kCertified || !patch1->board || + patch1->board->full_add_rva != 0x810AC70ULL || + patch1->board->full_remove_rva != 0x8168AA0ULL || + patch1->board->recruit_table_id != 0x10B1 || + patch1->board->team_table_id != 0x18B1 || + !IsCertified(patch1) || !IsDiagnosticOrCertified(patch1)) return 2; if (FindBuild( 247845777ULL, diff --git a/packages/sdk/src/client.cjs b/packages/sdk/src/client.cjs index 076208c..b6c3ffb 100644 --- a/packages/sdk/src/client.cjs +++ b/packages/sdk/src/client.cjs @@ -814,7 +814,9 @@ function validateTelemetryRegistration(result, types) { return result; } -function createClient({ pid, pipeName, timeoutMs = 20000 } = {}) { +function createClient(options = {}) { + const { pid, pipeName, timeoutMs = 20000 } = options; + const boardMutationTimeoutMs = options.timeoutMs === undefined ? 120000 : timeoutMs; if (!pipeName && (!Number.isInteger(pid) || pid <= 0)) { throw new Cfb27HookError('INVALID_REQUEST', 'createClient requires a positive PID or pipe name'); } @@ -824,6 +826,7 @@ function createClient({ pid, pipeName, timeoutMs = 20000 } = {}) { function request(command, params = {}, { hostErrorValidator, successResponseValidator, + requestTimeoutMs = timeoutMs, } = {}) { if (typeof command !== 'string' || !command || !params || typeof params !== 'object') { return Promise.reject(new Cfb27HookError('INVALID_REQUEST', 'Command and params are invalid')); @@ -837,10 +840,10 @@ function createClient({ pid, pipeName, timeoutMs = 20000 } = {}) { let commandSent = false; let settled = false; const timer = setTimeout(() => { - finish(new Cfb27HookError('PIPE_TIMEOUT', `Host did not respond within ${timeoutMs} ms`, { + finish(new Cfb27HookError('PIPE_TIMEOUT', `Host did not respond within ${requestTimeoutMs} ms`, { pipeName: resolvedPipeName, })); - }, timeoutMs); + }, requestTimeoutMs); function finish(error, result) { if (settled) return; @@ -1036,12 +1039,16 @@ function createClient({ pid, pipeName, timeoutMs = 20000 } = {}) { async addBoard(options = {}) { const params = cloneBoardMutationOptions(options); await requireBoardMutationCapability(); - return validateBoardMutationResult(await request('addBoard', params), params, 'add'); + return validateBoardMutationResult(await request('addBoard', params, { + requestTimeoutMs: boardMutationTimeoutMs, + }), params, 'add'); }, async removeBoard(options = {}) { const params = cloneBoardMutationOptions(options); await requireBoardMutationCapability(); - return validateBoardMutationResult(await request('removeBoard', params), params, 'remove'); + return validateBoardMutationResult(await request('removeBoard', params, { + requestTimeoutMs: boardMutationTimeoutMs, + }), params, 'remove'); }, getLogs({ limit = 100 } = {}) { return request('logs', { limit }); diff --git a/scripts/board-verification/reanchor-build.cjs b/scripts/board-verification/reanchor-build.cjs index 6106c35..dfcef7e 100644 --- a/scripts/board-verification/reanchor-build.cjs +++ b/scripts/board-verification/reanchor-build.cjs @@ -100,7 +100,10 @@ async function queryProcess(pid, execFileImpl = execFile) { } async function anticheatProcesses(execFileImpl = execFile) { - const script = "$ErrorActionPreference='Stop'; @(Get-Process | Where-Object { $_.ProcessName -match 'Javelin|EAAntiCheat|EAAntiCheat.GameService' } | Select-Object Id,ProcessName) | ConvertTo-Json -Compress"; + const script = "$ErrorActionPreference='Stop'; @(Get-CimInstance Win32_Process | " + + "Where-Object { $_.Name -match 'Javelin|EAAntiCheat|EAAntiCheat.GameService' } | " + + "ForEach-Object { $bytes=$null; if ($_.ExecutablePath) { try { $bytes=(Get-Item -LiteralPath $_.ExecutablePath).Length } catch {} }; " + + "if ($null -eq $bytes -or $bytes -ge 1MB) { [pscustomobject]@{ Id=$_.ProcessId; ProcessName=$_.Name; ExecutablePath=$_.ExecutablePath; Bytes=$bytes } } }) | ConvertTo-Json -Compress"; const { stdout } = await execFileImpl(POWERSHELL, ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, encoding: 'utf8' }); @@ -188,8 +191,8 @@ async function moduleBase(client) { return canonical(entry.message.slice(prefix.length + 1)); } -function sessionId({ pid, creationDate, hostVersion, readyTimestampMs }) { - return sha256Buffer(Buffer.from(`${pid}|${creationDate}|${hostVersion}|${readyTimestampMs}`, 'utf8')); +function sessionId({ pid, creationDate, hostVersion }) { + return sha256Buffer(Buffer.from(`${pid}|${creationDate}|${hostVersion}`, 'utf8')); } function evidenceIdentity(runtime) { @@ -227,16 +230,13 @@ async function establishRuntime(options, dependencies = {}) { const hello = await client.hello(); const status = await client.status(); if (!status.ready || !hello.capabilities.includes('researchWatch')) throw new Error('Research-capable host is not ready'); - const logs = (await client.getLogs({ limit: 256 })).logs; - const ready = logs.findLast((entry) => entry.message === 'CFB27 Lua host ready'); - if (!ready) throw new Error('Host-ready session marker is absent'); const base = await moduleBase(client); const runtime = { build: { label: build.label, executableSize: build.size, executableSha256: build.sha256 }, session: { pid: game.pid, sessionId: sessionId({ pid: game.pid, creationDate: process.creationDate, - hostVersion: hello.hostVersion, readyTimestampMs: ready.timestampMs }), + hostVersion: hello.hostVersion }), moduleBase: base, capturedAt: new Date().toISOString(), }, @@ -268,6 +268,43 @@ async function backupSave(savePath, runtime) { return { source, sourceHash, backupPath: target, backupHash, size: stat.size, verified: true }; } +function archiveStaleSession(runtime) { + const directory = evidenceDirectory(runtime.build.executableSha256); + const preflightPath = path.join(directory, 'preflight.json'); + if (!fs.existsSync(preflightPath)) return null; + const preflightStatus = fs.lstatSync(preflightPath); + if (!preflightStatus.isFile() || preflightStatus.isSymbolicLink()) { + throw new Error('Existing preflight evidence is not a regular file'); + } + const previous = JSON.parse(fs.readFileSync(preflightPath, 'utf8')); + const previousSession = previous?.session?.sessionId; + if (previous?.session?.pid === runtime.session.pid && + previousSession === runtime.session.sessionId && + previous?.build?.executableSha256 === runtime.build.executableSha256) { + return previous; + } + if (typeof previousSession !== 'string' || !/^[0-9A-F]{64}$/.test(previousSession)) { + throw new Error('Existing preflight has no valid session identity'); + } + const archiveRoot = path.join(directory, 'archive'); + if (fs.existsSync(archiveRoot)) { + const status = fs.lstatSync(archiveRoot); + if (!status.isDirectory() || status.isSymbolicLink()) { + throw new Error('Evidence archive is not a regular directory'); + } + } else { + fs.mkdirSync(archiveRoot); + } + const sessionArchive = path.join(archiveRoot, previousSession); + fs.mkdirSync(sessionArchive, { recursive: false }); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.name === 'archive' || entry.name === 'save-backup') continue; + if (entry.isSymbolicLink()) throw new Error(`Refusing to archive evidence symlink: ${entry.name}`); + fs.renameSync(path.join(directory, entry.name), path.join(sessionArchive, entry.name)); + } + return null; +} + async function locateAll(client, log = (text) => process.stderr.write(text)) { const located = []; for (const table of TABLES.values()) located.push(await locateTable(client, table, { log })); @@ -290,7 +327,25 @@ function serializeTables(result) { }; } -async function hydrateTables(client, stored) { +function recoverEmptyStoredBoard(tables, storedBoard) { + if (!storedBoard || !Number.isSafeInteger(storedBoard.boardRow) || + !Number.isSafeInteger(storedBoard.teamRow)) return null; + const boardIndex = tables.get(4251); + const membership = tables.get(5847); + if (storedBoard.boardRow < 0 || storedBoard.boardRow >= boardIndex.capacity || + storedBoard.teamRow < 0 || storedBoard.teamRow >= membership.capacity) return null; + const boardRefValue = boardIndex.data.readUInt32LE(storedBoard.boardRow * boardIndex.stride); + const boardRef = decodeRef(boardRefValue); + if (boardRef.tableId <= 0 || boardRef.row !== storedBoard.teamRow) return null; + const offset = storedBoard.teamRow * membership.stride; + for (let slot = 0; slot < membership.words; slot += 1) { + if (membership.data.readUInt32LE(offset + slot * 4) !== 0) return null; + } + return { selected: { ...storedBoard, boardRefValue, occupied: 0, userRefs: 0, cpuRefs: 0, + invalidUserRefs: 0, firstFreeSlot: 0, compact: true }, candidates: [] }; +} + +async function hydrateTables(client, stored, { requireBoard = true } = {}) { const located = []; for (const [idText, saved] of Object.entries(stored.tables)) { const spec = TABLES.get(Number(idText)); @@ -304,7 +359,15 @@ async function hydrateTables(client, stored) { located.push(table); } const tables = new Map(located.map((entry) => [entry.id, entry])); - return { located, tables, board: findUserBoard(tables) }; + if (!requireBoard) return { located, tables, board: null }; + let board; + try { + board = findUserBoard(tables); + } catch (error) { + board = recoverEmptyStoredBoard(tables, stored.userBoard); + if (!board) throw error; + } + return { located, tables, board }; } function findBoardSlot(tables, teamRow, recruitRow) { @@ -316,9 +379,9 @@ function findBoardSlot(tables, teamRow, recruitRow) { const offset = teamRow * membership.stride; for (let slot = 0; slot < membership.words; slot += 1) { const membershipRef = decodeRef(membership.data.readUInt32LE(offset + slot * 4)); - if (membershipRef.tableId !== 4168 || membershipRef.row >= targets.capacity) continue; + if (membershipRef.tableId <= 0 || membershipRef.row >= targets.capacity) continue; const recruitRef = decodeRef(targets.data.readUInt32LE(membershipRef.row * targets.stride + 12)); - if (recruitRef.tableId === 4269 && recruitRef.row === recruitRow) return slot; + if (recruitRef.tableId > 0 && recruitRef.row === recruitRow) return slot; } throw new Error(`Recruit row ${recruitRow} is not on membership row ${teamRow}`); } @@ -330,11 +393,35 @@ function armScript(watches, execute) { return `cfb.unwatch()\n${calls}`; } -async function promptAction(message, input = process.stdin, output = process.stdout) { - if (!input.isTTY) throw new Error('Interactive vanilla action confirmation requires a TTY'); - const rl = readline.createInterface({ input, output }); - try { await rl.question(`${message}\nPress Enter only after the vanilla UI action finishes... `); } - finally { rl.close(); } +async function watchState(client) { + const prefix = `REANCHOR_WAIT_${crypto.randomBytes(8).toString('hex').toUpperCase()}`; + await client.evaluateLua(`local h=cfb.watch_hits(false); cfb.log(${JSON.stringify(prefix)}.."|"..#h.."|"..(h.dropped or 0))`); + const logs = (await client.getLogs({ limit: 64 })).logs; + const entry = logs.findLast((item) => item.message.startsWith(`${prefix}|`)); + if (!entry) throw new Error('Could not poll the armed research watch'); + const [, countText, droppedText] = entry.message.split('|'); + return { count: Number(countText), dropped: Number(droppedText) }; +} + +async function promptAction(message, client, input = process.stdin, output = process.stdout) { + output.write(`${message}\n`); + if (input.isTTY) { + const rl = readline.createInterface({ input, output }); + try { await rl.question('Press Enter only after the vanilla UI action finishes... '); } + finally { rl.close(); } + return; + } + output.write('Watch armed; waiting up to 120 seconds for the vanilla UI action...\n'); + for (let attempt = 0; attempt < 240; attempt += 1) { + const state = await watchState(client); + if (state.dropped !== 0) throw new Error(`Research watch dropped ${state.dropped} hits`); + if (state.count > 0) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error('Timed out waiting for the vanilla UI action'); } async function collectWatch(client, prefix) { @@ -357,8 +444,21 @@ async function descriptorTableId(client, descriptor) { return Number((await readQword(client, descriptor + 40n)) >> 32n); } +async function readLuaBytes(client, address, length) { + const prefix = `REANCHOR_BYTES_${crypto.randomBytes(8).toString('hex').toUpperCase()}`; + await client.evaluateLua(`local s='' for i=0,${length - 1} do s=s..string.format('%02X',cfb.read_u8(${luaHex(address)}+i)) end cfb.log(${JSON.stringify(prefix)}..'|'..s)`); + const logs = (await client.getLogs({ limit: 64 })).logs; + const entry = logs.findLast((item) => item.message.startsWith(`${prefix}|`)); + if (!entry) throw new Error('Could not collect executable-memory bytes'); + const bytesHex = entry.message.slice(prefix.length + 1); + if (!new RegExp(`^[0-9A-F]{${length * 2}}$`).test(bytesHex)) { + throw new Error('Executable-memory byte capture was malformed'); + } + return Buffer.from(bytesHex, 'hex'); +} + async function vtableEntries(client, address) { - const bytes = await readBytes(client, address, 16); + const bytes = await readLuaBytes(client, address, 16); return [canonical(bytes.readBigUInt64LE(0)), canonical(bytes.readBigUInt64LE(8))]; } @@ -378,6 +478,7 @@ async function enrichExecuteHit(client, hit, expected, runtime, captureId) { const wrapperVtable = team.readBigUInt64LE(0); const recruitVtable = recruit.readBigUInt64LE(0); const membershipRow = Number(controller.readBigUInt64LE(8)); + const teamRow = Number(team.readBigUInt64LE(24)); const boardStore = controller.readBigUInt64LE(0x138); await readBytes(client, boardStore, 8); return { @@ -401,7 +502,7 @@ async function enrichExecuteHit(client, hit, expected, runtime, captureId) { team: { address: canonical(teamAddress), readable: true, descriptorTableId: await descriptorTableId(client, teamDescriptor), - row: Number(team.readBigUInt64LE(24)), field10Readable: true, field18Readable: true, + row: teamRow, field10Readable: true, field18Readable: true, vtableAddress: canonical(wrapperVtable), vtableEntries: await vtableEntries(client, wrapperVtable), }, recruit: { @@ -410,8 +511,8 @@ async function enrichExecuteHit(client, hit, expected, runtime, captureId) { row: Number(recruit.readBigUInt64LE(24)), field10Readable: true, field18Readable: true, vtableAddress: canonical(recruitVtable), vtableEntries: await vtableEntries(client, recruitVtable), }, - expected: { membershipRow: expected.membershipRow, teamRow: expected.teamRow, - recruitRow: expected.recruitRow }, + expected: { membershipRow: expected.membershipRow, teamRow, + recruitRow: expected.recruitRow, recruitTableId: expected.recruitTableId }, }; } @@ -429,7 +530,15 @@ async function commandPreflight(options, dependencies) { runtime.hello.writesAllowed !== false || runtime.status.writesAllowed !== false) { throw new Error('Preflight requires an exact diagnostic host with writes disabled'); } + const existing = archiveStaleSession(runtime); const saveBackup = await backupSave(options.save, runtime); + if (existing) { + if (existing.saveBackup?.verified !== true || existing.saveBackup.sourceHash !== saveBackup.sourceHash || + existing.saveBackup.backupHash !== saveBackup.backupHash) { + throw new Error('Existing preflight does not match the selected verified save backup'); + } + return existing; + } const record = envelope(runtime, { process: { path: runtime.process.path, creationDate: runtime.process.creationDate }, host: { version: runtime.hello.hostVersion, supportedBuild: runtime.hello.supportedBuild, @@ -474,11 +583,11 @@ async function commandWriteCapture(options, dependencies, operation) { } await runtime.client.evaluateLua(armScript(watches, false)); await (dependencies.promptAction || promptAction)( - `Perform ONE vanilla ${operation.toUpperCase()} for recruit row ${options.recruitRow} now.`); + `Perform ONE vanilla ${operation.toUpperCase()} for recruit row ${options.recruitRow} now.`, runtime.client); const captureId = `${operation}-write-${options.capture}`; const prefix = `REANCHOR_${runtime.session.sessionId.slice(0, 12)}_${captureId.toUpperCase()}`; const hits = await collectWatch(runtime.client, prefix); - await hydrateTables(runtime.client, stored); + await hydrateTables(runtime.client, stored, { requireBoard: false }); const record = envelope(runtime, { captureId, operation, recruitRow: options.recruitRow, teamRow, hits, postconditionVerified: true }); writeEvidence(`captures/${captureId}.json`, record); @@ -488,7 +597,80 @@ async function commandWriteCapture(options, dependencies, operation) { async function rankedCandidates(operation, runtime, identity) { const captures = [1, 2].map((index) => readEvidence(`captures/${operation}-write-${index}.json`, identity)); const pe = parsePeSections(fs.readFileSync(runtime.executable)); - return rankRoutineCandidates(captures, { moduleBase: runtime.session.moduleBase, pe }); + const unwindCandidates = rankRoutineCandidates(captures, { moduleBase: runtime.session.moduleBase, pe }); + const calls = await directCallCandidates(runtime.client, captures, runtime, pe); + const merged = new Map(unwindCandidates.map((candidate) => [candidate.address, candidate])); + for (const candidate of calls) { + const existing = merged.get(candidate.address); + if (!existing || candidate.score > existing.score) merged.set(candidate.address, candidate); + } + return [...merged.values()].sort((left, right) => right.score - left.score || + (BigInt(left.address) < BigInt(right.address) ? -1 : 1)); +} + +function prioritizeExecuteCandidates(operation, candidates) { + const manifest = loadManifest(MANIFEST_PATH); + const prior = [...manifest.builds].reverse().find((build) => build.support === 'certified' && build.board); + const key = operation === 'add' ? 'fullAddRva' : 'fullRemoveRva'; + if (!prior) return candidates; + const target = BigInt(prior.board[key]); + const distance = (candidate) => { + const value = BigInt(candidate.rva); + return value >= target ? value - target : target - value; + }; + return [...candidates].sort((left, right) => { + const leftDistance = distance(left); + const rightDistance = distance(right); + if (leftDistance !== rightDistance) return leftDistance < rightDistance ? -1 : 1; + return right.score - left.score; + }); +} + +function captureStackAddresses(capture, runtime, pe) { + const addresses = new Set(); + for (const hit of capture.hits || []) { + for (const address of hit.stackReturnAddresses || []) { + const classification = classifyModuleAddress(address, runtime.session.moduleBase, pe); + if (classification.insideImage && classification.executable) addresses.add(classification.address); + } + } + return addresses; +} + +async function directCallCandidates(client, captures, runtime, pe) { + const perCapture = captures.map((capture) => captureStackAddresses(capture, runtime, pe)); + const common = [...perCapture[0]].filter((address) => perCapture[1].has(address)).slice(0, 128); + if (common.length === 0) return []; + const prefix = `REANCHOR_CALLS_${crypto.randomBytes(8).toString('hex').toUpperCase()}`; + const values = common.map((address) => luaHex(BigInt(address))).join(','); + await client.evaluateLua(`local p=${JSON.stringify(prefix)} local a={${values}} ` + + "for i,v in ipairs(a) do local s='' for j=-5,-1 do s=s..string.format('%02X',cfb.read_u8(v+j)) end cfb.log(p..'|'..i..'|'..s) end"); + const logs = (await client.getLogs({ limit: 256 })).logs; + const bytesByIndex = new Map(); + for (const entry of logs) { + if (!entry.message.startsWith(`${prefix}|`)) continue; + const [, indexText, bytesHex] = entry.message.split('|'); + const index = Number(indexText); + if (Number.isSafeInteger(index) && /^[0-9A-F]{10}$/.test(bytesHex)) { + bytesByIndex.set(index, Buffer.from(bytesHex, 'hex')); + } + } + const targets = new Map(); + common.forEach((returnAddress, index) => { + const bytes = bytesByIndex.get(index + 1); + if (!bytes || bytes[0] !== 0xE8) return; + const target = BigInt(returnAddress) + BigInt(bytes.readInt32LE(1)); + const classification = classifyModuleAddress(target, runtime.session.moduleBase, pe); + if (!classification.insideImage || !classification.executable) return; + targets.set(classification.address, (targets.get(classification.address) || 0) + 1); + }); + return [...targets].map(([address, hitCount]) => ({ + address, + rva: classifyModuleAddress(address, runtime.session.moduleBase, pe).rva, + captureCount: 2, + hitCount: hitCount * 2, + score: 1000 + hitCount * 2, + })); } async function commandRank(options, dependencies) { @@ -507,29 +689,40 @@ async function commandExecuteCapture(options, dependencies, operation, transitio const { runtime, identity } = await requirePreflight(options, dependencies); const stored = readEvidence('tables.json', identity); const live = await hydrateTables(runtime.client, stored); - const candidates = await rankedCandidates(operation, runtime, identity); + const candidates = prioritizeExecuteCandidates(operation, + await rankedCandidates(operation, runtime, identity)); if (candidates.length < 1) throw new Error(`No executable ${operation} candidate was ranked`); - const watches = candidates.slice(0, 4).map((entry) => ({ address: BigInt(runtime.session.moduleBase) + BigInt(entry.rva) })); + // Arm one entry at a time. Nearby stack candidates can be very hot shared + // routines and overflow the finite research-watch buffer before the UI action. + const watches = candidates.slice(0, 1).map((entry) => ({ address: BigInt(runtime.session.moduleBase) + BigInt(entry.rva) })); await runtime.client.evaluateLua(armScript(watches, true)); await (dependencies.promptAction || promptAction)(transition ? `Leave and re-enter Recruiting, then perform ONE vanilla ${operation.toUpperCase()} for recruit row ${options.recruitRow}.` - : `Perform ONE vanilla ${operation.toUpperCase()} for recruit row ${options.recruitRow} to confirm the full entry.`); + : `Perform ONE vanilla ${operation.toUpperCase()} for recruit row ${options.recruitRow} to confirm the full entry.`, runtime.client); const captureId = transition ? 'transition' : `${operation}-execute`; const prefix = `REANCHOR_${runtime.session.sessionId.slice(0, 12)}_${captureId.toUpperCase()}`; const hits = await collectWatch(runtime.client, prefix); const expected = { membershipRow: live.board.selected.teamRow, teamRow: options.teamRow, - recruitRow: options.recruitRow }; + recruitRow: options.recruitRow, recruitTableId: live.board.selected.recruitTableId }; const pe = parsePeSections(fs.readFileSync(runtime.executable)); const enriched = []; + const diagnostics = []; for (const hit of hits) { try { const capture = await enrichExecuteHit(runtime.client, hit, expected, runtime, captureId); - if (validateObjectShapes(capture, { moduleBase: runtime.session.moduleBase, pe }).passed) enriched.push(capture); - } catch { - // Incidental execute candidates are expected; only full entry shapes survive. + const validation = validateObjectShapes(capture, { moduleBase: runtime.session.moduleBase, pe }); + diagnostics.push({ hitIndex: hit.index, entryAddress: hit.rip, validation, capture }); + if (validation.passed) enriched.push(capture); + } catch (error) { + diagnostics.push({ hitIndex: hit.index, entryAddress: hit.rip, error: error.message, rawHit: hit }); } } - if (enriched.length !== 1) throw new Error(`Expected exactly one full ${operation} entry shape; found ${enriched.length}`); + if (enriched.length !== 1) { + const diagnostic = envelope(runtime, { captureId, operation, expected, diagnostics }); + writeEvidence(`captures/${captureId}-diagnostic-${Date.now()}.json`, diagnostic); + const summary = diagnostics.map((entry) => entry.validation?.detail || entry.error).join('; '); + throw new Error(`Expected exactly one full ${operation} entry shape; found ${enriched.length}: ${summary}`); + } const record = envelope(runtime, { captureId, operation, objectCapture: enriched[0], rawHitCount: hits.length }); writeEvidence(`captures/${captureId}.json`, record); return record; @@ -559,7 +752,17 @@ async function commandAnalyzeFinal(options, dependencies) { remove: { writeCount: removeWrite.length, executeCount: 1, consistent: removeWrite.every((capture) => capture.postconditionVerified) }, }, - proposedBoard: { ...vtables, fullAddRva: addEntry.rva, fullRemoveRva: removeEntry.rva }, + proposedBoard: { + ...vtables, + fullAddRva: addEntry.rva, + fullRemoveRva: removeEntry.rva, + recruitTableId: canonical(BigInt(addExecute.recruit.descriptorTableId)), + teamTableId: canonical(BigInt(addExecute.team.descriptorTableId)), + controllerDescriptorTableId: canonical(BigInt(addExecute.controller.descriptorTableId)), + userTargetTableId: canonical(BigInt(tables.userBoard.userTableId)), + activePitchTableId: canonical(BigInt(tables.userBoard.activePitchTableId)), + membershipTableId: canonical(BigInt(tables.userBoard.membershipTableId)), + }, proof: { pe, fullAddCapture: addExecute, fullRemoveCapture: removeExecute, transitionObjectCapture: transition }, }; diff --git a/scripts/board-verification/reanchor-evidence.cjs b/scripts/board-verification/reanchor-evidence.cjs index aed978c..34ab14c 100644 --- a/scripts/board-verification/reanchor-evidence.cjs +++ b/scripts/board-verification/reanchor-evidence.cjs @@ -12,6 +12,12 @@ const BOARD_RVAS = Object.freeze([ 'recruitingControllerVtableRva', 'fullAddRva', 'fullRemoveRva', + 'recruitTableId', + 'teamTableId', + 'controllerDescriptorTableId', + 'userTargetTableId', + 'activePitchTableId', + 'membershipTableId', ]); const REQUIRED_GATE_NAMES = Object.freeze([ 'buildIdentity', @@ -27,6 +33,7 @@ const REQUIRED_GATE_NAMES = Object.freeze([ const IMAGE_SCN_MEM_EXECUTE = 0x20000000; const IMAGE_SCN_MEM_READ = 0x40000000; const PARSED_PE_VALUES = new WeakSet(); +const PE_RUNTIME_FUNCTIONS = new WeakMap(); let evidenceWriteTestHook = null; function canonicalSha(value) { @@ -464,6 +471,40 @@ function parsePeSections(image) { sections: Object.freeze(sections), }); PARSED_PE_VALUES.add(result); + let runtimeFunctions = new Uint32Array(0); + const directoryBase = optionalHeaderOffset + (magic === 0x20B ? 112 : 96); + const directoryCountOffset = optionalHeaderOffset + (magic === 0x20B ? 108 : 92); + if (directoryCountOffset + 4 <= optionalHeaderOffset + optionalHeaderSize && + image.readUInt32LE(directoryCountOffset) > 3 && + directoryBase + 32 <= optionalHeaderOffset + optionalHeaderSize) { + const exceptionRva = image.readUInt32LE(directoryBase + 24); + const exceptionSize = image.readUInt32LE(directoryBase + 28); + if (exceptionRva !== 0 || exceptionSize !== 0) { + if (exceptionRva === 0 || exceptionSize === 0 || exceptionSize % 12 !== 0) { + throw new Error('PE exception directory is malformed'); + } + const section = sections.find((candidate) => exceptionRva >= candidate.virtualAddress && + exceptionRva + exceptionSize <= candidate.virtualAddress + candidate.rawSize); + if (!section) throw new Error('PE exception directory is not backed by section raw data'); + const rawOffset = section.rawAddress + exceptionRva - section.virtualAddress; + requireBufferRange(image, rawOffset, exceptionSize, 'the PE exception directory'); + const entries = []; + for (let offset = rawOffset; offset < rawOffset + exceptionSize; offset += 12) { + const begin = image.readUInt32LE(offset); + const end = image.readUInt32LE(offset + 4); + if (begin === 0 && end === 0) continue; + if (begin >= end || end > sizeOfImage) throw new Error('PE runtime function entry is malformed'); + entries.push([begin, end]); + } + entries.sort((left, right) => left[0] - right[0]); + runtimeFunctions = new Uint32Array(entries.length * 2); + entries.forEach(([begin, end], index) => { + runtimeFunctions[index * 2] = begin; + runtimeFunctions[index * 2 + 1] = end; + }); + } + } + PE_RUNTIME_FUNCTIONS.set(result, runtimeFunctions); return result; } @@ -508,6 +549,24 @@ function captureIdentity(capture) { }, 'Capture identity'); } +function containingRuntimeFunction(address, moduleBase, pe) { + const entries = PE_RUNTIME_FUNCTIONS.get(pe); + if (!entries || entries.length === 0) return null; + const rva = toAddress(address) - toAddress(moduleBase, 'module base'); + if (rva < 0n || rva > 0xFFFFFFFFn) return null; + const target = Number(rva); + let low = 0; + let high = entries.length / 2; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (entries[middle * 2] <= target) low = middle + 1; + else high = middle; + } + const index = low - 1; + if (index < 0 || target >= entries[index * 2 + 1]) return null; + return toAddress(moduleBase, 'module base') + BigInt(entries[index * 2]); +} + function rankRoutineCandidates(captures, { moduleBase, pe }) { if (!Array.isArray(captures) || captures.length !== 2) { throw new Error('Exactly two captures are required to rank routine candidates'); @@ -522,7 +581,9 @@ function rankRoutineCandidates(captures, { moduleBase, pe }) { const captureCounts = captures.map((entry) => { const counts = new Map(); for (const address of captureStackReturns(entry)) { - const key = canonicalHex(address); + const classification = classifyModuleAddress(address, moduleBase, pe); + if (!classification.insideImage || !classification.executable) continue; + const key = canonicalHex(containingRuntimeFunction(address, moduleBase, pe) ?? address); counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; @@ -610,7 +671,8 @@ function validateObjectShapes(capture, { moduleBase, pe }) { if (sameAddress(controller.vtableAddress, team.vtableAddress)) { return reject('Controller and generic record-wrapper vtables must be distinct'); } - if (![expected.membershipRow, expected.teamRow, expected.recruitRow].every(validRow)) { + if (![expected.membershipRow, expected.teamRow, expected.recruitRow].every(validRow) || + !Number.isSafeInteger(expected.recruitTableId) || expected.recruitTableId <= 0) { return reject('Expected membership, Team, and Recruit rows must be nonnegative safe integers'); } if (!validRow(controller.membershipRow) || !validRow(controller.boardStore?.membershipRow) || @@ -618,8 +680,9 @@ function validateObjectShapes(capture, { moduleBase, pe }) { return reject('Captured membership, Team, and Recruit rows must be nonnegative safe integers'); } if (!sameAddress(args.rcx, controller.address)) return reject('RCX does not contain the recruiting controller'); - if (controller.readable !== true || controller.descriptorTableId !== 5003) { - return reject('RCX object is not a readable descriptor-table 5003 recruiting controller'); + if (controller.readable !== true || !Number.isSafeInteger(controller.descriptorTableId) || + controller.descriptorTableId <= 0) { + return reject('RCX object is not a readable recruiting controller descriptor'); } if (controller.membershipRow !== expected.membershipRow || controller.boardStore.offset !== 0x138 || controller.boardStore.readable !== true || controller.boardStore.membershipRow !== expected.membershipRow) { @@ -633,11 +696,13 @@ function validateObjectShapes(capture, { moduleBase, pe }) { !sameAddress(cells.recruit.value, recruit.address)) { return reject('R8 is not a readable pointer cell containing the Recruit wrapper'); } - if (team.readable !== true || team.descriptorTableId !== 6334 || team.row !== expected.teamRow || + if (team.readable !== true || !Number.isSafeInteger(team.descriptorTableId) || + team.descriptorTableId <= 0 || team.row !== expected.teamRow || team.field10Readable !== true || team.field18Readable !== true) { return reject('Team wrapper descriptor, row identity, or +0x10/+0x18 fields are invalid'); } - if (recruit.readable !== true || recruit.descriptorTableId !== 4269 || recruit.row !== expected.recruitRow || + if (recruit.readable !== true || recruit.descriptorTableId !== expected.recruitTableId || + recruit.row !== expected.recruitRow || recruit.field10Readable !== true || recruit.field18Readable !== true) { return reject('Recruit wrapper descriptor, row identity, or +0x10/+0x18 fields are invalid'); } @@ -651,6 +716,9 @@ function validateObjectShapes(capture, { moduleBase, pe }) { detail: 'Full entry arguments and object shapes matched', genericRecordWrapperVtableAddress: canonicalHex(team.vtableAddress), recruitingControllerVtableAddress: canonicalHex(controller.vtableAddress), + controllerDescriptorTableId: controller.descriptorTableId, + teamDescriptorTableId: team.descriptorTableId, + recruitDescriptorTableId: recruit.descriptorTableId, }); } catch (error) { return reject(`Full entry object shape is malformed: ${error.message}`); @@ -665,8 +733,11 @@ function deriveVtableRvas(captures, { moduleBase, pe }) { const wrapper = validations[0].genericRecordWrapperVtableAddress; const controller = validations[0].recruitingControllerVtableAddress; if (!validations.every((validation) => validation.genericRecordWrapperVtableAddress === wrapper && - validation.recruitingControllerVtableAddress === controller)) { - throw new Error('Vtable addresses were not stable across captures'); + validation.recruitingControllerVtableAddress === controller && + validation.controllerDescriptorTableId === validations[0].controllerDescriptorTableId && + validation.teamDescriptorTableId === validations[0].teamDescriptorTableId && + validation.recruitDescriptorTableId === validations[0].recruitDescriptorTableId)) { + throw new Error('Vtable addresses or descriptor table IDs were not stable across captures'); } return Object.freeze({ genericRecordWrapperVtableRva: classifyModuleAddress(wrapper, moduleBase, pe).rva, @@ -801,7 +872,11 @@ function buildCandidateArtifact(input) { addRoutine.rva === proposedBoard.fullAddRva && removeRoutine.rva === proposedBoard.fullRemoveRva; const addShape = validateObjectShapes(proof.fullAddCapture, { moduleBase, pe: proof.pe }); const removeShape = validateObjectShapes(proof.fullRemoveCapture, { moduleBase, pe: proof.pe }); - const argumentShapes = addShape.passed && removeShape.passed; + const descriptorIdsMatch = [proof.fullAddCapture, proof.fullRemoveCapture].every((capture) => + BigInt(capture.recruit.descriptorTableId) === BigInt(proposedBoard.recruitTableId) && + BigInt(capture.team.descriptorTableId) === BigInt(proposedBoard.teamTableId) && + BigInt(capture.controller.descriptorTableId) === BigInt(proposedBoard.controllerDescriptorTableId)); + const argumentShapes = addShape.passed && removeShape.passed && descriptorIdsMatch; const allObjectCaptures = [proof.fullAddCapture, proof.fullRemoveCapture, proof.transitionObjectCapture]; const vtablePeSections = allObjectCaptures.every((entry) => validateCaptureVtables(entry, moduleBase, proof.pe).passed); diff --git a/scripts/board-verification/reanchor-lib.cjs b/scripts/board-verification/reanchor-lib.cjs index bf03be3..40cb75f 100644 --- a/scripts/board-verification/reanchor-lib.cjs +++ b/scripts/board-verification/reanchor-lib.cjs @@ -60,12 +60,18 @@ function isFreeRow(table, data, row) { function isContentRow(table, data, row) { const offset = row * table.stride; const first = data.readUInt32LE(offset); - if (table.id === 4168) return expectedRef(data.readUInt32LE(offset + 12), 4269); - if (table.id === 4251) return expectedRef(first, 5847, 138); + if (table.id === 4168) { + const ref = decodeRef(data.readUInt32LE(offset + 12)); + return ref.tableId > 0; + } + if (table.id === 4251) { + const ref = decodeRef(first); + return ref.tableId > 0 && ref.row < 138; + } if (table.id === 5790) return expectedRef(first, 4190, 9380); if (table.id === 5847) { const ref = decodeRef(first); - return (ref.tableId === 4168 || ref.tableId === 4288) && ref.row < 0x20000; + return ref.tableId > 0 && ref.row < 0x20000; } return false; } @@ -218,12 +224,57 @@ function findUserBoard(tables) { if (!userRows || !boardIndex || !membership) { throw new Error('User rows, board index, and membership tables are required'); } + const membershipTableIds = new Set(); + for (let boardRow = 0; boardRow < boardIndex.capacity; boardRow += 1) { + const value = boardIndex.data.readUInt32LE(boardRow * boardIndex.stride); + const ref = decodeRef(value); + if (ref.tableId > 0 && ref.row < membership.capacity) membershipTableIds.add(ref.tableId); + } + if (membershipTableIds.size !== 1) { + throw new Error('Board index does not identify one stable membership table ID'); + } + const membershipTableId = [...membershipTableIds][0]; + const userTableIds = new Set(); + for (let row = 0; row < membership.capacity; row += 1) { + const offset = row * membership.stride; + for (let slot = 0; slot < membership.words; slot += 1) { + const ref = decodeRef(membership.data.readUInt32LE(offset + slot * 4)); + if (ref.tableId > 0 && ref.row < userRows.capacity && isContentRow(userRows, userRows.data, ref.row)) { + userTableIds.add(ref.tableId); + } + } + } + if (userTableIds.size !== 1) { + throw new Error('Membership rows contain mixed or out-of-range user-board table IDs'); + } + const userTableId = [...userTableIds][0]; + const recruitTableIds = new Set(); + const activePitchTableIds = new Set(); + for (let row = 0; row < membership.capacity; row += 1) { + const offset = row * membership.stride; + for (let slot = 0; slot < membership.words; slot += 1) { + const ref = decodeRef(membership.data.readUInt32LE(offset + slot * 4)); + if (ref.tableId !== userTableId || ref.row >= userRows.capacity) continue; + const recruitRef = decodeRef(userRows.data.readUInt32LE(ref.row * userRows.stride + 12)); + if (recruitRef.tableId > 0) recruitTableIds.add(recruitRef.tableId); + const pitchRef = decodeRef(userRows.data.readUInt32LE(ref.row * userRows.stride + 16)); + if (pitchRef.tableId > 0) activePitchTableIds.add(pitchRef.tableId); + } + } + if (recruitTableIds.size !== 1) { + throw new Error('User-board rows contain mixed or missing recruit table IDs'); + } + const recruitTableId = [...recruitTableIds][0]; + if (activePitchTableIds.size !== 1) { + throw new Error('User-board rows contain mixed or missing active-pitch table IDs'); + } + const activePitchTableId = [...activePitchTableIds][0]; 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; + if (boardRef.tableId !== membershipTableId || boardRef.row >= membership.capacity) continue; const membershipOffset = boardRef.row * membership.stride; let userRefs = 0; @@ -241,11 +292,11 @@ function findUserBoard(tables) { if (firstFreeSlot >= 0) compact = false; occupied += 1; const ref = decodeRef(value); - if (ref.tableId === 4168) { + if (ref.tableId === userTableId) { userRefs += 1; if (ref.row >= userRows.capacity) invalidUserRefs += 1; } - if (ref.tableId === 4288) cpuRefs += 1; + if (ref.tableId !== userTableId) cpuRefs += 1; } candidates.push({ boardRow, @@ -269,11 +320,11 @@ function findUserBoard(tables) { candidate.compact && candidate.invalidUserRefs === 0 && candidate.occupied === candidate.userRefs); if (eligibleCandidates.length > 1) { - throw new Error('Could not uniquely identify the user board from table 4168 membership references'); + throw new Error('Could not uniquely identify the user board from user-record membership references'); } if (eligibleCandidates.length === 0) { if (userCandidates.some((candidate) => candidate.invalidUserRefs > 0)) { - throw new Error('User board membership contains an out-of-range table 4168 reference'); + throw new Error('User board membership contains an out-of-range user-record reference'); } if (userCandidates.some((candidate) => candidate.occupied !== candidate.userRefs)) { throw new Error('User board membership is not user-only and contains a mixed table reference'); @@ -281,9 +332,10 @@ function findUserBoard(tables) { if (userCandidates.some((candidate) => !candidate.compact)) { throw new Error('Could not identify a compact user board membership row'); } - throw new Error('Could not uniquely identify the user board from table 4168 membership references'); + throw new Error('Could not uniquely identify the user board from user-record membership references'); } - const selected = eligibleCandidates[0]; + const selected = { ...eligibleCandidates[0], membershipTableId, userTableId, + recruitTableId, activePitchTableId }; if (selected.firstFreeSlot < 0) throw new Error('The active recruiting board has no free membership slot'); return { selected, candidates: candidates.slice(0, 8) }; } diff --git a/scripts/game-build-manifest.cjs b/scripts/game-build-manifest.cjs index 7c337fd..fc4baee 100644 --- a/scripts/game-build-manifest.cjs +++ b/scripts/game-build-manifest.cjs @@ -9,6 +9,12 @@ const BOARD_KEYS = [ 'recruitingControllerVtableRva', 'fullAddRva', 'fullRemoveRva', + 'recruitTableId', + 'teamTableId', + 'controllerDescriptorTableId', + 'userTargetTableId', + 'activePitchTableId', + 'membershipTableId', ]; const MAX_UINT64 = 0xFFFFFFFFFFFFFFFFn; diff --git a/scripts/promote-game-build.cjs b/scripts/promote-game-build.cjs index 4a1e843..606a219 100644 --- a/scripts/promote-game-build.cjs +++ b/scripts/promote-game-build.cjs @@ -19,6 +19,12 @@ const BOARD_KEYS = [ 'recruitingControllerVtableRva', 'fullAddRva', 'fullRemoveRva', + 'recruitTableId', + 'teamTableId', + 'controllerDescriptorTableId', + 'userTargetTableId', + 'activePitchTableId', + 'membershipTableId', ]; function exactKeys(value, keys, label) { diff --git a/tests/board-reanchor-cli.test.cjs b/tests/board-reanchor-cli.test.cjs index 5901a47..7a171ae 100644 --- a/tests/board-reanchor-cli.test.cjs +++ b/tests/board-reanchor-cli.test.cjs @@ -45,10 +45,10 @@ test('watch log parser requires complete zero-drop evidence', () => { test('session identity is stable and sensitive to host start evidence', () => { const input = { pid: 77, creationDate: '20260716120000.000000-300', - hostVersion: '0.2.0-dev.2', readyTimestampMs: 1234 }; + hostVersion: '0.2.0-dev.2' }; assert.match(sessionId(input), /^[0-9A-F]{64}$/); assert.equal(sessionId(input), sessionId({ ...input })); - assert.notEqual(sessionId(input), sessionId({ ...input, readyTimestampMs: 1235 })); + assert.notEqual(sessionId(input), sessionId({ ...input, creationDate: '20260716120001.000000-300' })); }); test('board slot lookup follows membership to the recruit target', () => { diff --git a/tests/board-reanchor-evidence.test.cjs b/tests/board-reanchor-evidence.test.cjs index d9517c2..2416c3e 100644 --- a/tests/board-reanchor-evidence.test.cjs +++ b/tests/board-reanchor-evidence.test.cjs @@ -137,7 +137,7 @@ function objectShape(heapOffset = 0n) { vtableAddress: canonical(wrapperVtableAddress), vtableEntries: executableEntries.map(canonical), }, - expected: { membershipRow: 11, teamRow: 22, recruitRow: 33 }, + expected: { membershipRow: 11, teamRow: 22, recruitRow: 33, recruitTableId: 4269 }, }; } @@ -175,6 +175,12 @@ function candidateInput() { recruitingControllerVtableRva: '0x2200', fullAddRva: '0x1100', fullRemoveRva: '0x1200', + recruitTableId: '0x10AD', + teamTableId: '0x18BE', + controllerDescriptorTableId: '0x138B', + userTargetTableId: '0x1048', + activePitchTableId: '0x169E', + membershipTableId: '0x16D7', }, proof: { pe, diff --git a/tests/board-reanchor.test.cjs b/tests/board-reanchor.test.cjs index d9eb2e7..6e3eb4e 100644 --- a/tests/board-reanchor.test.cjs +++ b/tests/board-reanchor.test.cjs @@ -33,7 +33,10 @@ function setFreeRow(table, data, row) { function setContentRow(table, data, row) { const offset = row * table.stride; - if (table.id === 4168) data.writeUInt32LE(encodedRef(4269, 7), offset + 12); + if (table.id === 4168) { + data.writeUInt32LE(encodedRef(4269, 7), offset + 12); + data.writeUInt32LE(encodedRef(5790, 7), offset + 16); + } if (table.id === 4251) data.writeUInt32LE(encodedRef(5847, 7), offset); if (table.id === 5790) data.writeUInt32LE(encodedRef(4190, 7), offset); if (table.id === 5847) data.writeUInt32LE(encodedRef(4168, 7), offset); @@ -46,6 +49,8 @@ function boardFixtures() { const userRows = { ...userRowsDefinition, data: tableData(userRowsDefinition) }; const boardIndex = { ...boardIndexDefinition, data: tableData(boardIndexDefinition) }; const membership = { ...membershipDefinition, data: tableData(membershipDefinition) }; + setContentRow(userRowsDefinition, userRows.data, 10); + setContentRow(userRowsDefinition, userRows.data, 11); return { userRows, boardIndex, @@ -149,6 +154,13 @@ test('scoreCandidate recognizes freelist and table-specific content fixtures', ( } }); +test('board index scoring accepts a build-specific membership table ID', () => { + const table = TABLES.get(4251); + const data = tableData(table); + data.writeUInt32LE(encodedRef(5834, 7), table.stride); + assert.deepEqual(scoreCandidate(table, data), { freeRows: 0, contentRows: 1, score: 8 }); +}); + test('selectTableCandidate requires a positive structural winner', () => { const table = TABLES.get(4168); assert.throws(() => selectTableCandidate(table, [{ score: { score: 0 } }]), /structural validation/i); @@ -175,6 +187,16 @@ test('findUserBoard discovers one compact user membership row', () => { assert.equal(result.selected.compact, true); }); +test('findUserBoard infers the Patch 1 membership table ID', () => { + const { boardIndex, membership, tables } = boardFixtures(); + boardIndex.data.writeUInt32LE(encodedRef(5834, 3), 2 * boardIndex.stride); + membership.data.writeUInt32LE(encodedRef(4168, 10), 3 * membership.stride); + + const result = findUserBoard(tables); + assert.equal(result.selected.boardRow, 2); + assert.equal(result.selected.teamRow, 3); +}); + test('findUserBoard rejects a user membership row with an interior hole', () => { const { boardIndex, membership, tables } = boardFixtures(); boardIndex.data.writeUInt32LE(encodedRef(5847, 3), 2 * boardIndex.stride); diff --git a/tests/game-build-manifest.test.cjs b/tests/game-build-manifest.test.cjs index e31ce95..4f38a3b 100644 --- a/tests/game-build-manifest.test.cjs +++ b/tests/game-build-manifest.test.cjs @@ -43,6 +43,12 @@ function certifiedBuild(overrides = {}) { recruitingControllerVtableRva: '0xB0B5BA8', fullAddRva: '0x8109060', fullRemoveRva: '0x8166090', + recruitTableId: '0x10AD', + teamTableId: '0x18BE', + controllerDescriptorTableId: '0x138B', + userTargetTableId: '0x1048', + activePitchTableId: '0x169E', + membershipTableId: '0x16D7', }, ...overrides, }; @@ -84,7 +90,7 @@ test('diagnostic builds cannot carry a board layout', () => { })])), /diagnostic.*board/i); }); -test('certified builds require all four nonzero RVAs', () => { +test('certified builds require all nonzero layout values', () => { const board = certifiedBuild().board; for (const key of Object.keys(board)) { const missing = { ...board }; diff --git a/tests/game-build-promotion.test.cjs b/tests/game-build-promotion.test.cjs index 8ffa442..119b250 100644 --- a/tests/game-build-promotion.test.cjs +++ b/tests/game-build-promotion.test.cjs @@ -13,6 +13,9 @@ function manifest() { { label: 'july-11-2026', size: 247845776, sha256: OLD_SHA, support: 'certified', board: { genericRecordWrapperVtableRva: '0xB093F68', recruitingControllerVtableRva: '0xB0B5BA8', fullAddRva: '0x8109060', fullRemoveRva: '0x8166090', + recruitTableId: '0x10AD', teamTableId: '0x18BE', + controllerDescriptorTableId: '0x138B', userTargetTableId: '0x1048', + activePitchTableId: '0x169E', membershipTableId: '0x16D7', } }, { label: 'patch-1-2026-07-16', size: 249801616, sha256: PATCH_SHA, support: 'diagnostic', board: null }, @@ -26,13 +29,16 @@ function candidate() { session: { pid: 77, sessionId: 'session', moduleBase: '0x140000000', capturedAt: '2026-07-16T12:00:00.000Z' }, tables: {}, captures: {}, proposedBoard: { genericRecordWrapperVtableRva: '0xB193F68', - recruitingControllerVtableRva: '0xB1B5BA8', fullAddRva: '0x8209060', fullRemoveRva: '0x8266090' }, + recruitingControllerVtableRva: '0xB1B5BA8', fullAddRva: '0x8209060', fullRemoveRva: '0x8266090', + recruitTableId: '0x10B1', teamTableId: '0x18B1', + controllerDescriptorTableId: '0x138D', userTargetTableId: '0x104B', + activePitchTableId: '0x1691', membershipTableId: '0x16CA' }, gates: REQUIRED_GATE_NAMES.map((name) => ({ name, passed: true, detail: `${name} passed` })), passed: true, }; } -test('certification changes only the exact diagnostic build and four RVAs', () => { +test('certification changes only the exact diagnostic build and layout', () => { const input = manifest(); const output = certifyManifest(input, candidate()); assert.deepEqual(input, manifest()); From 6bc555e8cd8b49edc68d037af0bb8530fbcb0779 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 17 Jul 2026 16:03:38 -0500 Subject: [PATCH 16/16] fix: normalize generated header newlines --- scripts/game-build-manifest.cjs | 6 +++++- tests/game-build-manifest.test.cjs | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/game-build-manifest.cjs b/scripts/game-build-manifest.cjs index fc4baee..2a8fda7 100644 --- a/scripts/game-build-manifest.cjs +++ b/scripts/game-build-manifest.cjs @@ -144,11 +144,15 @@ function loadManifest(manifestPath) { return parseManifest(JSON.parse(fs.readFileSync(manifestPath, 'utf8'))); } +function normalizeNewlines(value) { + return value.replace(/\r\n?/g, '\n'); +} + function writeGeneratedHeader({ manifestPath, headerPath, check = false }) { const generated = generateHeader(loadManifest(manifestPath)); if (check) { try { - return fs.readFileSync(headerPath, 'utf8') === generated; + return normalizeNewlines(fs.readFileSync(headerPath, 'utf8')) === generated; } catch (error) { if (error.code === 'ENOENT') { return false; diff --git a/tests/game-build-manifest.test.cjs b/tests/game-build-manifest.test.cjs index 4f38a3b..370070e 100644 --- a/tests/game-build-manifest.test.cjs +++ b/tests/game-build-manifest.test.cjs @@ -172,6 +172,11 @@ test('writeGeneratedHeader writes deterministically and check mode never changes assert.equal(writeGeneratedHeader({ manifestPath, headerPath, check: true }), true); assert.equal(fs.readFileSync(headerPath, 'utf8'), generated); + const windowsCheckout = generated.replace(/\n/g, '\r\n'); + fs.writeFileSync(headerPath, windowsCheckout, 'utf8'); + assert.equal(writeGeneratedHeader({ manifestPath, headerPath, check: true }), true); + assert.equal(fs.readFileSync(headerPath, 'utf8'), windowsCheckout); + fs.writeFileSync(headerPath, 'stale\n', 'utf8'); assert.equal(writeGeneratedHeader({ manifestPath, headerPath, check: true }), false); assert.equal(fs.readFileSync(headerPath, 'utf8'), 'stale\n'); @@ -182,5 +187,5 @@ test('writeGeneratedHeader writes deterministically and check mode never changes test('the checked-in generated header is current', () => { const parsed = parseManifest(JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))); - assert.equal(fs.readFileSync(HEADER, 'utf8'), generateHeader(parsed)); + assert.equal(fs.readFileSync(HEADER, 'utf8').replace(/\r\n?/g, '\n'), generateHeader(parsed)); });