diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index fff394e..556bc8c 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -5,6 +5,8 @@ on: [push, pull_request] jobs: verify: runs-on: windows-latest + env: + CFB27_NATIVE_ARTIFACTS: ${{ github.workspace }}\native\build-release\Release steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -14,12 +16,14 @@ jobs: - run: npm ci - run: npm run check - run: npm test - - run: cmake -S native -B native/build-active -A x64 - - run: cmake --build native/build-active --config Release - - run: native/build-active/Release/cfb27_startup_smoke.exe native/build-active/Release/cfb27_lua_host.dll - - run: native/build-active/Release/cfb27_protocol_smoke.exe native/build-active/Release/cfb27_lua_host.dll + - run: cmake -S native -B native/build-release -A x64 + - run: cmake --build native/build-release --config Release + - run: native/build-release/Release/cfb27_startup_smoke.exe native/build-release/Release/cfb27_lua_host.dll + - run: native/build-release/Release/cfb27_memory_reader_smoke.exe + - run: native/build-release/Release/cfb27_telemetry_smoke.exe + - run: native/build-release/Release/cfb27_protocol_smoke.exe native/build-release/Release/cfb27_lua_host.dll - run: npm run pack:preview - uses: actions/upload-artifact@v4 with: - name: cfb27-lua-hook-0.1.0-dev.1 + name: cfb27-lua-hook-0.2.0-dev.1 path: dist/ diff --git a/README.md b/README.md index 3213277..cb0c9e5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Offline Lua scripting runtime, Node SDK, and MMC startup tooling for EA SPORTS College Football 27 on PC. -> Developer preview `0.1.0-dev.1`. The runtime supports one verified game +> Developer preview `0.2.0-dev.1`. The runtime supports one verified game > build, is intended only for offline play, and does not include or provide an > anticheat bypass. @@ -35,7 +35,7 @@ cfb27lua logs [--follow] cfb27lua doctor ``` -These commands are implemented in the `0.1.0-dev.1` developer preview. One-shot +These commands are implemented in the `0.2.0-dev.1` developer preview. One-shot `--json` output is a single object; followed logs use JSON Lines. ## Start here diff --git a/docs/cli.md b/docs/cli.md index 8712548..af74c38 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,6 +16,45 @@ node packages/cli/bin/cfb27lua.cjs --help - `doctor` — perform read-only game, install, host, build, and write checks. - `logs [--follow]` — read bounded logs or follow new log events. - `events [--after N]` — read a cursor page. +- `memory scan` — scan bounded private readable memory through the validated SDK. +- `memory read` — read one or more bounded canonical address ranges through the validated SDK. +- `telemetry register ` — register structured telemetry type names for the host session. + +The memory commands are read-only developer diagnostics. A scan requires +`--pattern`, `--mask`, `--max-matches`, and `--context`; context is applied on +each side of a match. The CLI automatically follows native continuation pages, +uses a ten-second timeout for each scan page, and accepts `--max-pages` from 1 +through 4,096 (default 4,096). Callers cannot supply raw cursors or start/stop +ranges. A read accepts one or more +`--range 0xUPPERCASE_ADDRESS:length` options. Addresses must be canonical (for +example, `0x7FF612340000`, not a lowercase or zero-padded form). Write-like +options are not accepted. + +Use `--allow-unsupported-build` only when intentionally running a memory +diagnostic against an unsupported build. Without that explicit flag, the SDK +rejects an unsupported-build result. The flag does not enable writes. + +Examples: + +```powershell +node packages/cli/bin/cfb27lua.cjs memory scan ` + --pattern CFB27A1100A1B2C3D4E5F60718293A4B ` + --mask FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF ` + --max-matches 8 --context 32 --max-pages 4096 ` + --allow-unsupported-build --json + +node packages/cli/bin/cfb27lua.cjs memory read ` + --range 0x7FF612340000:192 --allow-unsupported-build --json + +node packages/cli/bin/cfb27lua.cjs telemetry register ` + recruiting.snapshot recruiting.stability --json +``` + +Human memory output reports bounded counts and the canonical addresses returned +by the SDK. JSON output keeps the validated SDK result unchanged under the +standard `{ "ok": true, "command": "...", "result": ... }` CLI envelope. +Only `memory scan` selects the ten-second per-page client timeout; all other +commands retain the SDK default timeout. Directory options are `--game-dir`, `--mmc-dir`, and `--artifacts-dir`. Their environment fallbacks are `CFB27_GAME_DIR`, `CFB27_MMC_DIR`, and diff --git a/docs/development/building.md b/docs/development/building.md index fbad55f..2c85634 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -7,24 +7,27 @@ Visual Studio 2022 with the C++ desktop workload. npm ci npm run check npm test -cmake -S native -B native/build-active -A x64 -cmake --build native/build-active --config Release +cmake -S native -B native/build-release -A x64 +cmake --build native/build-release --config Release ``` -Verify both native harnesses: +Verify all four native harnesses: ```powershell -native/build-active/Release/cfb27_startup_smoke.exe native/build-active/Release/cfb27_lua_host.dll -native/build-active/Release/cfb27_protocol_smoke.exe native/build-active/Release/cfb27_lua_host.dll +native/build-release/Release/cfb27_startup_smoke.exe native/build-release/Release/cfb27_lua_host.dll +native/build-release/Release/cfb27_memory_reader_smoke.exe +native/build-release/Release/cfb27_telemetry_smoke.exe +native/build-release/Release/cfb27_protocol_smoke.exe native/build-release/Release/cfb27_lua_host.dll ``` -Create the allowlisted preview bundle and checksum: +Create the allowlisted preview bundle and checksum from that exact build: ```powershell +$env:CFB27_NATIVE_ARTIFACTS = (Resolve-Path native/build-release/Release).Path npm run pack:preview ``` -Set `CFB27_NATIVE_ARTIFACTS` when the native DLLs are in a different Release -directory. Set `SOURCE_DATE_EPOCH` to normalize staged file timestamps. The -packager rejects archive content, game/save data, logs, dependencies, and build -intermediates. +`CFB27_NATIVE_ARTIFACTS` is required; the packager never guesses which build +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. diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index ea80cf7..eef9a7d 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -1,15 +1,31 @@ # Developer-preview release checklist -- [ ] Confirm version `0.1.0-dev.1` in the root, SDK, CLI, and native hello. +- [ ] Complete the read-only memory and telemetry automated gate before changing + the preview version. - [ ] Run `npm ci`, `npm run check`, and `npm test`. - [ ] Configure and build all native targets with Windows x64 MSVC. -- [ ] Run the one-MiB-stack startup smoke and framed protocol smoke. -- [ ] Run `npm run pack:preview`. +- [ ] Run startup, memory-reader, telemetry, and framed-protocol smoke + executables from the full Release build. +- [ ] Confirm CLI memory scans automatically follow continuation pages with a + bounded `--max-pages` value and retain the scan-only timeout. +- [ ] Set `CFB27_NATIVE_ARTIFACTS` to the absolute path of that exact Release + directory, then run `npm run pack:preview`. +- [ ] Run `git diff --check`. - [ ] Confirm the staged package and both npm tarballs contain no archive, game/save data, schema, logs, dependencies, or build intermediates. -- [ ] Verify `dist/SHA256SUMS.txt` against the preview zip. +- [ ] Verify the external `dist/SHA256SUMS.txt` against the preview zip. The ZIP + checksum cannot be embedded in documentation inside that same ZIP. - [ ] Confirm Windows CI is green. -- [ ] Perform the documented offline runtime checklist without an additional - game-data write. +- [ ] With the game closed, install the exact automated-gate candidate host and + relaunch MMC offline so no previous DLL remains loaded. +- [ ] Perform the documented offline read-only runtime checklist: confirm hello + capabilities, bounded sentinel scan/read, advancing registered telemetry, + ten minutes of responsiveness, and a Dynasty hub transition. Do not use or + attempt a memory write. +- [ ] Record the date, executable hash, exact commands, and observed results in + `docs/research/runtime-verification.md` only after observing them. +- [ ] After the manual gate succeeds, set root, SDK, CLI, lockfile, release + packager, and native hello versions to `0.2.0-dev.1`, then repeat every + automated build, test, smoke, package-inspection, and diff-check step. - [ ] Close the game and verify uninstall restores both known MMC hashes. - [ ] Publish GitHub artifacts only; npm publication is not part of this preview. diff --git a/docs/getting-started.md b/docs/getting-started.md index 028cd70..da3a57e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting started -CFB27 Lua Hook `0.1.0-dev.1` is a Windows x64 developer preview. It requires +CFB27 Lua Hook `0.2.0-dev.1` is a Windows x64 developer preview. It requires Node.js 20 or later, CMake 3.24 or later, Visual Studio 2022 C++ build tools, MMC, and a separately launched offline CFB27 session. @@ -52,6 +52,50 @@ node packages/cli/bin/cfb27lua.cjs events --after 0 --json eligibility separately. A failed write-eligibility check does not prevent safe read-only scripts. +## Read live memory from a trusted Node process + +The SDK exposes bounded, read-only memory discovery for trusted Node.js and +Electron main-process code. Do not expose these methods or their raw byte and +address results to an Electron renderer. + +```js +const { createClient } = require('@cfb27/lua-hook'); + +const client = createClient({ pid: gamePid }); +// scanMemory automatically follows validated continuation cursors. +const scan = await client.scanMemory({ + patternHex: 'CFB27A1100A1B2C3D4E5F60718293A4B', + maskHex: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + maxMatches: 2, + contextBefore: 4, + contextAfter: 4, + maxPages: 4096, +}); + +const read = await client.readMemory({ + ranges: [{ address: scan.matches[0].address, length: 16 }], +}); +``` + +Hex byte strings must be uppercase and addresses must use canonical uppercase +`0x[0-9A-F]+` strings; JavaScript numbers are never accepted as addresses. A +scan pattern is 8–4096 bytes, requests at most 64 matches, and allows at most +512 total context bytes before and after each match. Each native scan page is +bounded to 32 MiB of eligible memory using 4 MiB read chunks. Automatic scans +accept 1–4,096 pages and default to the 4,096-page, 128 GiB ceiling. Use +`client.scanMemoryPage(options)` when a trusted main-process caller needs manual +page control; its `nextCursor` may be passed only to the next page request. +A batch read contains at most 64 ranges of 64 KiB each and at most 256 KiB +total. Unsupported game builds +require `allowUnsupportedBuild: true` and report `supportedBuild: false`. + +All memory methods validate requests before opening the pipe and every host +response field before returning it. A multi-page scan observes a live, +non-atomic memory map, so re-read and validate every selected candidate before +interpreting it. The methods can report `MEMORY_ACCESS_DENIED`, +`SCAN_LIMIT_EXCEEDED`, or `TOO_MANY_MATCHES`; malformed host results report +`INVALID_RESPONSE`. The SDK does not provide a memory-write API. + ## Restore MMC Close the game, then restore both verified original proxies: diff --git a/docs/lua-api.md b/docs/lua-api.md index 3655899..037fafb 100644 --- a/docs/lua-api.md +++ b/docs/lua-api.md @@ -17,6 +17,10 @@ local changed = cfb.write_u8(address, expected, replacement) cfb.log("script loaded") +-- The trusted main-process client must register this type first with +-- client.registerTelemetryTypes(["probe.snapshot"]). +cfb.emit("probe.snapshot", { sequence = 1, stable = true }) + cfb.on("game_ready", function() cfb.log("game ready") end) @@ -30,6 +34,24 @@ Supported callback names are `game_ready` and `tick`. The host runs `tick` callbacks approximately every 100 ms. The event protocol coalesces observable tick events to at most one per second. +## Structured telemetry + +`cfb.emit(type, payload)` appends exactly one structured event to the existing +cursor-paged event ring and returns `true`. It does not write to the host log. +The type must first be registered for the host session through the +`registerTelemetry` protocol command or SDK `registerTelemetryTypes(types)`. +Custom types use `^[a-z][a-z0-9_.-]{0,63}$`; `game_ready`, `tick`, and `log` are +reserved, and no more than 16 distinct custom types may be registered per +session. + +Payloads may contain Lua nil, booleans, finite numbers, strings, dense arrays, +and string-keyed objects. Tables are converted recursively and must not be +cyclic, sparse, mixed between array and object keys, or contain unsupported Lua +values. Limits are depth 4, 64 keys per object, 128 entries per array, 1,024 +bytes per string, and 16 KiB after JSON serialization. Address and raw-byte +fields are rejected at every depth, including `address`, `addressHex`, +`regionBase`, `bytesHex`, `contextAddress`, and `contextHex`. + ## Script execution Protocol v1 accepts complete UTF-8 source buffers through `evaluate` and diff --git a/docs/protocol.md b/docs/protocol.md index 5943497..89046ec 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -37,6 +37,113 @@ Error response: - `evaluate { source }` — execute one complete multiline Lua buffer. - `logs { limit }` — return up to 256 recent bounded log entries. - `events { after, limit }` — return an ordered cursor page and `nextCursor`. +- `registerTelemetry { types }` — add trusted structured event names for the + current host session. +- `scanMemory { patternHex, maskHex, maxMatches, contextBefore, + contextAfter, allowUnsupportedBuild?, cursor? }` — scan one bounded page of + readable private memory and optionally resume from a continuation cursor. +- `readMemory { ranges, allowUnsupportedBuild? }` — read a bounded batch of + readable private-memory ranges. + +`hello.capabilities` advertises the memory commands as `memoryScan` and +`memoryRead`, and structured event registration as `telemetry`. They are +read-only host operations and do not expose a write API. + +### Structured telemetry + +`registerTelemetry` accepts exactly one parameter, `types`, containing 1–16 +unique strings. Names match `^[a-z][a-z0-9_.-]{0,63}$`; `game_ready`, `tick`, +and `log` are reserved. Registration is additive for the session and repeating +an already registered name in a later request is idempotent. At most 16 +distinct custom names may be registered in total. + +Request: + +```json +{"protocol":1,"id":"telemetry-1","command":"registerTelemetry","params":{"types":["probe.snapshot"]}} +``` + +Result: + +```json +{"types":["probe.snapshot"]} +``` + +The SDK method is `client.registerTelemetryTypes(types)`. Both the SDK and host +strictly validate the request and response contract. Registered Lua code may +call `cfb.emit(type, payload)` to append exactly one event to the cursor ring; +emission never writes to the file log. + +Payloads are JSON-compatible and limited to depth 4, 64 keys per object, 128 +entries per array, 1,024 bytes per string, and 16 KiB serialized. Numbers must +be finite. Address and raw-byte keys are rejected at every object depth, +including `address`, `addressHex`, `regionBase`, `bytesHex`, `contextAddress`, +and `contextHex`. Lua conversion additionally rejects cycles, functions, +userdata, threads, mixed or sparse tables, and non-string object keys. + +### Memory scan + +`patternHex` and `maskHex` are equal-length uppercase hexadecimal byte strings. +The pattern is 8–4,096 bytes. A mask byte selects significant bits using +`(live & mask) == (pattern & mask)`. `maxMatches` is an integer from 1 through +64. `contextBefore` and `contextAfter` are nonnegative integers no greater than +512, with at most 512 context bytes requested in total. One request scans at +most 32 MiB of eligible memory in chunks no larger than 4 MiB plus boundary +lookahead. An optional `cursor` is a canonical uppercase address identifying +the first virtual byte not covered by the preceding page. + +Request: + +```json +{"protocol":1,"id":"scan-1","command":"scanMemory","params":{"patternHex":"CFB27A1100A1B2C3D4E5F60718293A4B","maskHex":"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF","maxMatches":2,"contextBefore":4,"contextAfter":4,"allowUnsupportedBuild":true}} +``` + +Result: + +```json +{"supportedBuild":false,"complete":false,"nextCursor":"0x7FF614340000","scannedBytes":33554432,"matches":[{"address":"0x7FF612340080","regionBase":"0x7FF612340000","regionSize":65536,"protection":4,"contextAddress":"0x7FF61234007C","contextHex":"00000000CFB27A1100A1B2C3D4E5F60718293A4B00000000"}]} +``` + +Every successful page contains exactly `complete`, `nextCursor`, +`scannedBytes`, `matches`, and `supportedBuild`. A partial page has +`complete:false` and a canonical string `nextCursor`; a terminal page has +`complete:true` and `nextCursor:null`. Completion means the address-space walk +reached the process maximum, not that the game was paused or that multiple +pages form an atomic snapshot. Eligible read failures return an error instead +of silently advancing the cursor. + +The SDK exposes `client.scanMemoryPage(options)` for one protocol request and +`client.scanMemory(options)` for automatic aggregation. The aggregate method +owns continuation cursors, accepts `maxPages` from 1 through 4,096 (default +4,096), applies `maxMatches` globally, and rejects repeated or decreasing +cursors. The default bounds total eligible-byte work to 128 GiB. Before using a +candidate for interpretation, re-read it and validate its expected structure; +the live memory map can change between pages. + +### Memory read + +`ranges` contains 1–64 objects with exactly `address` and `length` keys. +Addresses use canonical uppercase `0x[0-9A-F]+` strings without redundant +leading zeroes. Each length is an integer from 1 through 65,536 bytes, and the +aggregate request is capped at 262,144 bytes. Every range is validated before +any bytes are copied, so a failure never returns partial results. + +Request: + +```json +{"protocol":1,"id":"read-1","command":"readMemory","params":{"allowUnsupportedBuild":true,"ranges":[{"address":"0x7FF612340080","length":16}]}} +``` + +Result: + +```json +{"supportedBuild":false,"ranges":[{"address":"0x7FF612340080","length":16,"bytesHex":"CFB27A1100A1B2C3D4E5F60718293A4B"}]} +``` + +Both commands reject unknown parameter keys; range objects also reject unknown +keys. On an unsupported executable, `allowUnsupportedBuild` must be the JSON +boolean `true` or the command returns `UNSUPPORTED_BUILD`. Successful diagnostic +requests then return `supportedBuild:false`. This override never enables writes. The host retains at most 512 log entries and 1,024 events. Event cursors are monotonic for one host session. Tick events are coalesced to at most one per @@ -49,5 +156,10 @@ timeout, invalid request/response, script failure, installation conflict, and backup-verification failure. Consumers should branch on `error.code`, not error message text. +Memory commands additionally return `MEMORY_ACCESS_DENIED` when a requested +range is not wholly readable private memory, `SCAN_LIMIT_EXCEEDED` when the +aggregate scan bound would be crossed, and `TOO_MANY_MATCHES` rather than +silently truncating a scan. These errors do not include memory or region dumps. + The unversioned legacy text pipe remains temporarily available for migration, but it is not the integration contract for new tools. diff --git a/docs/research/runtime-verification.md b/docs/research/runtime-verification.md index 57f3126..0952737 100644 --- a/docs/research/runtime-verification.md +++ b/docs/research/runtime-verification.md @@ -29,3 +29,104 @@ a smoke executable with the same one-MiB stack reserve. Earlier request-detour and save-editor findings are retained separately in `legacy-hook-findings.md` and the repository archive. + +## Read-only discovery preview verified on July 11, 2026 + +- The manually tested process was PID `21900`; `CollegeFB27.exe` matched the + supported SHA-256 above. The installed forwarding proxy SHA-256 was + `4638D7E54A6715538119254069B075C94EB7AB41A6914907AAD96750ABD0F756`; + the manually tested host SHA-256 was + `1420F4BCAA089153E671FD41D7B89F3162EFF8AAD94B4D1EFD18039E6590D3CE`. +- The live hello response advertised `memoryScan`, `memoryRead`, and + `telemetry` capabilities. No memory write was attempted during this gate. +- An initial automatic scan failed between pages with `ENOENT`. Retrying with + the corrected SDK-only continuation handling completed the scan; this was a + client retry correction, not a host reinstall. +- The complete scan covered `10,670,854,144` eligible bytes in `69,379` ms and + returned three candidates. Under the 32 MiB page contract, that is exactly + 319 pages: 318 full pages plus one 544,768-byte terminal page. This count is + derived from the retained completed-byte total rather than a separate live + page counter. Batch re-read confirmed the exact 16-byte + sentinel at `0x25DDC14D0` and `0x34CC50048`; the transient candidate at + `0x273FEB930` had changed and was correctly rejected. +- Registered telemetry sequence `2` appeared exactly once while the event + cursor advanced from `718` to `720`. +- After entering Recruiting and returning to the Dynasty hub, a 639-second + responsiveness watch retained PID `21900`. Tick count advanced from `8632` + to `14986`, the event cursor advanced from `871` to `1506`, and no error was + observed. + +The native version bump and final typed-parameter contract correction performed +after this manual gate necessarily change the final host binary hash. That +final packaged hash was verified by the automated release gate in the final +section, but was not the binary exercised by this manual live session. + +### Retained manual commands + +The sentinel was allocated without embedding its byte sequence as a literal in +the Lua source: + +```powershell +node packages/cli/bin/cfb27lua.cjs eval "_G.__cfb27_manual_sentinel = string.char(199,91,39,161,14,210,76,147,184,6,253,113,42,229,56,143)" --json +``` + +The complete paged scan used the exact pattern, mask, match, context, page, and +JSON controls below: + +```powershell +$scan = node packages/cli/bin/cfb27lua.cjs memory scan ` + --pattern C75B27A10ED24C93B806FD712AE5388F ` + --mask FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF ` + --max-matches 8 --context 8 --max-pages 4096 --json | ConvertFrom-Json +``` + +All three returned addresses were batch re-read in one SDK request (the CLI +accepts one `--range` per address): + +```powershell +node packages/cli/bin/cfb27lua.cjs memory read ` + --range "0x25DDC14D0:16" ` + --range "0x273FEB930:16" ` + --range "0x34CC50048:16" --json +``` + +Telemetry was registered, emitted once, and read using cursor pagination: + +```powershell +node packages/cli/bin/cfb27lua.cjs events --after 718 --json +node packages/cli/bin/cfb27lua.cjs telemetry register probe.snapshot --json +node packages/cli/bin/cfb27lua.cjs eval "cfb.emit('probe.snapshot', {sequence=2, stable=true})" --json +node packages/cli/bin/cfb27lua.cjs events --after 718 --json +``` + +Responsiveness was checked by polling `status --json` and cursor-paged +`events --after --json` throughout the 639-second watch, while +also confirming PID `21900` remained alive and the Dynasty UI remained usable: + +```powershell +node packages/cli/bin/cfb27lua.cjs status --json +node packages/cli/bin/cfb27lua.cjs events --after 871 --json +``` + +### Final automated release artifacts + +After the manual gate, the final native rebuild and typed-parameter contract +correction passed the complete Node suite, Windows x64 release build, startup, +memory-reader, telemetry, and framed-protocol smokes, package preview, checksum +verification, and archive inspection. The packager was explicitly bound to that +build's absolute `Release` directory. Its retained SHA-256 values are: + +- Forwarding proxy: `4638D7E54A6715538119254069B075C94EB7AB41A6914907AAD96750ABD0F756` +- Final host: `72C4CF08BA19F526F9E89F5B54F7EE70C3B5B630D9C7BA4658523F862AF5CB98` +- CLI tarball: `A8FA2C550FCC85A51070C3F937CB6CD3A6FC0DC0213037D55C0EDFABB6CB7494` +- SDK tarball: `94527FC3D1D832001647E176FEB0CA5D025C4451CACCF750376B3309627A92A8` + +The ZIP checksum is generated externally in `dist/SHA256SUMS.txt` after the +archive is complete. It cannot be embedded in this document because this file +is itself included in the ZIP; embedding that value would change the archive +being hashed. + +The final host above was automated- and smoke-tested after the version bump and +final contract correction, but it was not manually live-tested in CFB27. The +manual evidence in this document applies to host +`1420F4BCAA089153E671FD41D7B89F3162EFF8AAD94B4D1EFD18039E6590D3CE`. diff --git a/docs/superpowers/plans/2026-07-11-brooks-live-recruiting-calibration.md b/docs/superpowers/plans/2026-07-11-brooks-live-recruiting-calibration.md new file mode 100644 index 0000000..e768192 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-brooks-live-recruiting-calibration.md @@ -0,0 +1,332 @@ +# Brooks Live Recruiting Calibration 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:** Connect Brooks's recruiting Electron app to `cfb27-lua-hook` v0.2.0-dev.2, locate authoritative live recruiting records, prove relocation and safe timing, and complete one reversible plus one advance/autosave CPU-influence test. + +**Architecture:** A read-only profile builder opens the selected save through `openCollegeSave`, resolves an existing active CPU suitor, and produces private record fingerprints and public labels. A main-process-only calibration service uses typed SDK scan/read/transaction methods, never raw renderer calls. Absolute addresses remain in memory for one host session and are invalidated on PID/session/data changes. + +**Tech Stack:** Electron 31, CommonJS Node.js 20+, `@cfb27/lua-hook` v0.2.0-dev.2 release tarball, madden-franchise through `franchise-lab/college-franchise.js`, Node test runner. + +## Global Constraints + +- Implement in `brooksg357-a11y/cfb27-dynasty-modding` from the merged read-only hook integration; use the user's fork for the branch and open an upstream draft PR. +- Never call madden-franchise `file.save()` or `franchise.save()`. +- Never modify the selected save directly; create a byte-identical backup before the first live write proof. +- Use only an existing active CPU suitor. Do not allocate/retarget rows, add schools, change references, stage, score, offer, action, visit, pitch, or commitment state. +- Exact full-record matching is attempted first. Masked fingerprints are allowed only when their bit masks are derived from schema metadata and verified against empirical `_data` mutations. +- A content fingerprint is not “layout agnostic”; failure to match returns `LAYOUT_UNVERIFIED` and performs no write. +- Confirm at least three rows per table before accepting a stride/layout inference. +- Store addresses only in main-process memory and clear them on PID change, host event-cursor regression, game-ready false, failed validation, menu/allocation transition, or weekly change. +- Stable window default: eight identical complete snapshots at 250 ms intervals (two seconds); any changed byte resets the counter. +- The renderer receives no address, bytes, masks, scan requests, memory ranges, transaction operations, logs, Lua source, SDK object, or raw host error. +- Stage explicit paths only; never use `git add -A` or `git add .`. +- Before any host installation, explicitly tell the user to close MMC and CFB27. Explicitly tell the user when to relaunch MMC/game and which Dynasty screen to open. + +--- + +### Task 1: Build private save-derived calibration profiles + +**Files (Brooks repo):** +- Create at execution start: `docs/plans/live-recruiting-calibration.md` (copy this plan into Brooks's active-plan location) +- Create: `recruiting-app/src/live-recruiting-profile.cjs` +- Create: `recruiting-app/src/build-live-recruiting-profile.js` +- Create: `recruiting-app/test/live-recruiting-profile.test.cjs` +- Modify: `recruiting-app/package.json` + +**Interfaces:** +- Produces `buildLiveRecruitingProfile(savePath, recruitRow): Promise`. +- Produces CLI `build-live-recruiting-profile.js SAVE_PATH RECRUIT_ROW .live-profiles/profile.json`. +- Produces `publicSummary(profile)` without raw records or schema metadata. + +- [ ] **Step 1: Write pure failing selection and fingerprint tests** + +Use fake tables matching madden-franchise's `records[].fieldsArray`, `_fields`, and `_data` shapes. Prove the selector traverses: + +```text +Team.RecruitingBoard + -> RecruitingBoard.Recruits[] + -> RecruitTarget[] slots + -> RecruitTarget.Recruit == selected Recruit row +``` + +and chooses the highest-influence existing CPU suitor. Reject the user team's `UserRecruitTarget` table, empty refs, missing board ownership, zero active CPU suitors, and duplicate ownership. + +Test `recordProfile(record, fields)` returns full uppercase `recordHex`, field metadata, decoded expected values, and a field mask. For each masked field, mutate a legal alternate value on a cloned record and assert every changed `_data` bit is covered by the schema-derived mask; restore the original and assert exact bytes. + +- [ ] **Step 2: Run and verify RED** + +```powershell +cd recruiting-app +node --test test/live-recruiting-profile.test.cjs +``` + +Expected: module-not-found failure. + +- [ ] **Step 3: Implement ref decoding and active-suitor selection** + +Reuse the existing 32-bit reference rule from `build-recruiting-data.js`. Add `RecruitTarget` table ID `4288`. Load `Recruit`, `Player`, `Team`, `RecruitingBoard`, the board target-array table, `RecruitTarget`, `UserRecruitTarget`, `ProspectTargetSchool[]`, and `ProspectTargetSchool` read-only. + +The selected target must have a nonempty `RecruitTarget`, `ProspectInfluenceTotal > 0`, and a matching team board. Return team index/name, recruit row, target row, board row, influence, last-week influence, delta, scholarship state, and related refs in the private profile. + +- [ ] **Step 4: Implement raw-record profiles and three-row controls** + +Read `Buffer.from(record._data)`. Capture metadata from `record._fields[field]._offset` using the archived helper's keys: `type`, `isReference`, `offset`, `indexOffset`, `length`, `minValue`, and `maxValue`. + +Profile: + +- primary selected `Recruit`; +- two additional nonempty Recruit control rows with distinctive multi-field values; +- selected `RecruitTarget`; +- two additional active target control rows from the same owning board or other known CPU boards; +- the owning `RecruitingBoard` record. + +For Recruit use `Player`, `CommitScore`, `RecruitStage`, `NationalRank`, `PositionRank`, and `TopSchoolsList`. For RecruitTarget use `Recruit`, `ProspectInfluenceTotal`, `ProspectInfluenceTotalLastWeek`, `ProspectInfluenceDelta`, and `ScholarshipStatus`. Include exact `recordHex`, schema mask, field values, record size, table ID/unique ID, and save row only in the private profile. + +- [ ] **Step 5: Implement the CLI wrapper without writing the save** + +The CLI opens with `{useSchema:true}`, writes only the output JSON, and refuses an output path outside `recruiting-app/.live-profiles/`. Add `.live-profiles/` to `recruiting-app/.gitignore`. + +- [ ] **Step 6: Verify and commit** + +```powershell +node --test test/live-recruiting-profile.test.cjs +npm run check +git add -- recruiting-app/src/live-recruiting-profile.cjs recruiting-app/src/build-live-recruiting-profile.js recruiting-app/test/live-recruiting-profile.test.cjs recruiting-app/package.json recruiting-app/.gitignore +git commit -m "Build live recruiting calibration profiles" +``` + +--- + +### Task 2: Implement main-process calibration and relocation + +**Files (Brooks repo):** +- Create: `recruiting-app/src/live-recruiting-calibration.cjs` +- Create: `recruiting-app/test/live-recruiting-calibration.test.cjs` +- Modify: `recruiting-app/src/live-hook-service.cjs` +- Modify: `recruiting-app/test/live-hook-service.test.cjs` +- Modify: `recruiting-app/package.json` +- Modify: `recruiting-app/package-lock.json` + +**Interfaces:** +- Produces `new LiveRecruitingCalibration({ liveHook, profileBuilder, sampleMs?, stableSamples? })`. +- Produces `calibrate({ savePath, recruitRow }): Promise`. +- Produces `invalidate(reason): void`, `getPublicStatus()`, and `stop()`. +- Keeps `PrivateCalibrationProfile` and canonical addresses private. + +- [ ] **Step 1: Pin v0.2.0-dev.2 and write failing capability tests** + +Install the immutable SDK release tarball. Extend fake SDK clients with `scanMemory`, `readMemory`, and `writeTransaction`. Require capabilities `memoryScan`, `memoryRead`, `memoryWriteTransaction`, `status`, and `events`; do not require renderer access to any method. + +- [ ] **Step 2: Write failing calibration tests** + +Assert exact-record scans run for all six control records. Calibration accepts only one match per record, then batch-reads every complete record and validates record size plus all expected fields. Cover: + +- no match -> `LAYOUT_UNVERIFIED`; +- multiple matches -> `AMBIGUOUS_LIVE_RECORD`; +- one field mismatch -> `LIVE_VALIDATION_FAILED`; +- controls that do not produce a constant stride -> `STRIDE_UNVERIFIED`; +- PID/session change during calibration -> `SESSION_CHANGED`; +- success returns only public recruit/team labels, state `ready`, layout/stride booleans, and sample counts. + +Assert public objects do not contain keys matching `/address|hex|bytes|mask|offset|range|operation/i`. + +- [ ] **Step 3: Implement exact matching before any masked fallback** + +Call `scanMemory` with the full `recordHex`, an all-`FF` mask, `maxMatches:2`, and zero context. If every record matches uniquely, batch-read and validate them. + +If exact matching fails, try the schema-derived multi-field mask only when `maskVerified === true` from Task 1. A masked hit must pass a full batch-read field decode. If neither method passes, stop with `LAYOUT_UNVERIFIED`; never broaden to a value-only or single-field scan. + +- [ ] **Step 4: Confirm stride and packed-ref relationships** + +For each table, sort the three `(saveRow,address)` pairs and require `(addressB-addressA)/(rowB-rowA)` to be the same positive integer record size for all pairs. Confirm it equals the record byte length before marking `layoutVerified`. + +Use the already confirmed field metadata to check `Recruit.Player` packed refs and `RecruitTarget.Recruit` refs. These relationships classify candidates; they are not used to assume addresses before validation. + +- [ ] **Step 5: Add invalidation and reconnect behavior** + +`LiveHookService` must expose a main-only connection/session identity `{pid, hostVersion, protocolVersion}` and notify calibration on PID changes, event-cursor regression, disconnected status, or `game_ready:false`. Do not add these details to preload beyond existing sanitized status. + +- [ ] **Step 6: Verify and commit** + +```powershell +npm test +npm run check +git add -- recruiting-app/package.json recruiting-app/package-lock.json recruiting-app/src/live-hook-service.cjs recruiting-app/src/live-recruiting-calibration.cjs recruiting-app/test/live-hook-service.test.cjs recruiting-app/test/live-recruiting-calibration.test.cjs +git commit -m "Calibrate live recruiting records" +``` + +--- + +### Task 3: Add stable-window sampling and a domain-only proof action + +**Files (Brooks repo):** +- Create: `recruiting-app/src/live-recruiting-stability.cjs` +- Create: `recruiting-app/test/live-recruiting-stability.test.cjs` +- Modify: `recruiting-app/src/live-recruiting-calibration.cjs` +- Modify: `recruiting-app/test/live-recruiting-calibration.test.cjs` + +**Interfaces:** +- Produces `new StabilityWindow({requiredSamples:8})`. +- Produces `observe(snapshotHash): {stable, count, changed}`. +- Produces calibration method `runReversibleInfluenceProof({delta}): Promise`. + +- [ ] **Step 1: Write failing stability tests** + +Assert eight identical hashes become stable, any change resets count to one, invalidation resets to zero, and no proof can start while disconnected, unsupported, uncalibrated, unstable, already running, or outside delta range `-5..5` excluding zero. + +- [ ] **Step 2: Implement complete-snapshot polling** + +Every 250 ms, `readMemory` the primary Recruit, primary RecruitTarget, owning RecruitingBoard, and control records in one batch. Hash the complete validated response after decoding all expected fields. Any read/validation failure invalidates calibration instead of incrementing stability. + +The initial proof requires eight identical snapshots. The advance observation later treats the first changed snapshot as an advance/allocation transition, invalidates addresses, then relocates and requires a new stable window. + +- [ ] **Step 3: Build a single-field guarded transaction privately** + +Clone the target record bytes and assign `ProspectInfluenceTotal = current + delta` on an in-memory schema record to derive exact changed bytes. Construct one `writeTransaction` operation per contiguous changed-byte run, using current live bytes as expected bytes. Do not let the renderer select values beyond the bounded delta. + +After `applied_verified`, poll until the changed value is observed. Then construct the reverse transaction from freshly read live bytes and restore the original immediately. Return only: + +```js +{ + ok: true, + state: 'restored_verified', + recruit: 'Public Name', + team: 'Public Team', + beforeInfluence: 178, + testInfluence: 180, + restoredInfluence: 178, +} +``` + +- [ ] **Step 4: Test failure and rollback paths** + +Cover mismatch-before-write, SDK `rolled_back_verified`, SDK `rollback_unverified`, changed session, changed snapshot, restore failure, and response objects containing forbidden private keys. `rollback_unverified` permanently sets public state `writes_disabled` until restart. + +- [ ] **Step 5: Verify and commit** + +```powershell +npm test +npm run check +git add -- recruiting-app/src/live-recruiting-stability.cjs recruiting-app/src/live-recruiting-calibration.cjs recruiting-app/test/live-recruiting-stability.test.cjs recruiting-app/test/live-recruiting-calibration.test.cjs +git commit -m "Add guarded recruiting influence proof" +``` + +--- + +### Task 4: Expose a minimal user-facing Live Lab workflow + +**Files (Brooks repo):** +- Modify: `recruiting-app/electron-main.cjs` +- Modify: `recruiting-app/electron-preload.cjs` +- Modify: `recruiting-app/index.html` +- Modify: `recruiting-app/src/renderer.js` +- Modify: `recruiting-app/src/renderer.css` +- Modify: `recruiting-app/test/live-hook-boundary.test.cjs` +- Create: `recruiting-app/test/live-recruiting-ui.test.cjs` + +**Interfaces:** +- IPC `recruiting:live-calibrate` consumes only `{recruitRow}`. +- IPC `recruiting:live-proof` consumes only `{delta}`. +- Preload exposes only `calibrateLiveRecruiting(recruitRow)`, `runLiveInfluenceProof(delta)`, and `onLiveRecruitingUpdate(callback)`. + +- [ ] **Step 1: Write failing boundary tests** + +Assert preload does not contain `scanMemory`, `readMemory`, `writeTransaction`, addresses, bytes, masks, operations, Lua, logs, SDK, or generic IPC send. Assert main stores the currently loaded save path privately and refuses calibration unless that exact save is still selected. + +- [ ] **Step 2: Add main-owned IPC orchestration** + +Instantiate one calibration service. On successful save load, record the resolved save path and invalidate any old calibration. On file/folder change, generator failure, disconnect, or shutdown, invalidate/stop. IPC validates integer recruit rows and delta bounds, then calls only domain methods. + +- [ ] **Step 3: Add a compact collapsed Live Lab panel** + +Place it under the existing Advanced area or selected-recruit detail, collapsed by default. User copy is limited to: + +- `Calibrate live recruiting`; +- progress states `Finding record`, `Validating`, `Watching for stability`, `Ready`; +- chosen existing CPU suitor label; +- `Run reversible +2 influence test`; +- result or a stable public error code. + +Do not display addresses, rows, masks, record sizes, capabilities, logs, or engineering diagnostics. + +- [ ] **Step 4: Verify renderer isolation and commit** + +```powershell +npm test +npm run check +git add -- recruiting-app/electron-main.cjs recruiting-app/electron-preload.cjs recruiting-app/index.html recruiting-app/src/renderer.js recruiting-app/src/renderer.css recruiting-app/test/live-hook-boundary.test.cjs recruiting-app/test/live-recruiting-ui.test.cjs +git commit -m "Add live recruiting calibration workflow" +``` + +--- + +### Task 5: Run the live calibration, reversible write, and advance/autosave gates + +**Files (Brooks repo):** +- Modify: `recruiting-app/SETUP.md` +- Modify: `docs/MAP.md` +- Create: `docs/reports/live-recruiting-calibration-v1.md` +- Move after shipping: `docs/plans/live-recruiting-calibration.md` to `docs/archive/plans/live-recruiting-calibration.md` + +**Interfaces:** +- Produces recorded evidence for one verified `Recruit` and one active CPU `RecruitTarget` relationship. + +- [ ] **Step 1: Run all automated checks before involving the game** + +```powershell +cd recruiting-app +npm ci +npm run check +npm test +cd .. +git diff --check +git status --short +``` + +Expected: no generated profiles, saves, schemas, assets, `node_modules`, or memory diagnostics are tracked. + +- [ ] **Step 2: Stop at the installation/relaunch checkpoint** + +**USER RELAUNCH CHECKPOINT:** Tell the user to close CFB27, MMC, and the recruiting Electron app. Confirm all three are closed. Install the v0.2.0-dev.2 host with the supported CLI. Then explicitly tell the user to relaunch MMC, launch CFB27 offline, open the correct Dynasty, stop at the Dynasty hub, and relaunch the recruiting app. + +- [ ] **Step 3: Create the safety backup and run read-only calibration** + +Copy the selected save beside itself with timestamp and SHA-256 before any write. Load the original save in the app. Choose a recruit with at least one existing active CPU suitor. Run calibration and record: + +- exact vs masked discovery method; +- unique candidate counts; +- three-row stride checks for Recruit and RecruitTarget; +- packed-ref validation results; +- relocation after backing out/re-entering relevant menus; +- two-second stability result. + +Do not record addresses, raw bytes, player likeness data, or full save paths in the committed report. + +- [ ] **Step 4: Run the reversible influence proof** + +Tell the user exactly which recruit/team/value is being tested and where to look in game. Apply `+2` or the nearest legal delta, confirm the UI if it exposes the value, restore immediately, and confirm restoration. If the UI does not display the exact influence, verify through typed reread and note that limitation without claiming UI proof. + +On any crash, mismatch, unexpected autosave, or rollback issue: stop, preserve logs locally, disable session writes, and do not proceed to the weekly advance. + +- [ ] **Step 5: Run one advance/autosave proof** + +After a fresh calibration and stable window, apply a second small influence change and leave it for one weekly advance. Tell the user when to advance. Detect changed snapshots, invalidate/relocate, re-establish stability, and read the resulting value. Wait for autosave completion, then parse the newly written save read-only and compare the CPU target/influence relationship with the live result. + +The engine may transform the influence during advance; success requires a consistent documented transformation across live state, UI where visible, and autosave—not necessarily preservation of the pre-advance number. + +- [ ] **Step 6: Verify reload and recovery** + +Tell the user when to return to the Dynasty hub or relaunch the Dynasty if required. Recalibrate after reload and confirm the autosaved state. Finally verify the timestamped backup remains byte-identical to its recorded SHA-256 and can be parsed. Do not overwrite the user's active save with the backup unless the user explicitly requests restoration. + +- [ ] **Step 7: Document, archive, and open the draft PR** + +Record the supported build hash, hook version, public recruit/team identifiers, proof steps, sanitized outcomes, and all limitations in `docs/reports/live-recruiting-calibration-v1.md`. Update SETUP/MAP. Move the active implementation plan to `docs/archive/plans/` with a SHIPPED banner only after all gates pass. + +```powershell +git add -- recruiting-app/SETUP.md docs/MAP.md docs/reports/live-recruiting-calibration-v1.md docs/archive/plans/live-recruiting-calibration.md +git commit -m "Document live recruiting calibration proof" +git push -u origin codex/live-recruiting-calibration +``` + +Open a draft upstream PR against Brooks's `main`. Link both hook releases and their checksums. Do not start governor policy work until Brooks reviews the domain assumptions and the complete live proof is repeatable. diff --git a/docs/superpowers/plans/2026-07-11-cursor-paged-private-memory-scan.md b/docs/superpowers/plans/2026-07-11-cursor-paged-private-memory-scan.md new file mode 100644 index 0000000..fec4f24 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-cursor-paged-private-memory-scan.md @@ -0,0 +1,526 @@ +# Cursor-Paged Private-Memory Scan 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:** Replace the non-deterministic whole-process scan with bounded cursor-paged scanning that can cover every eligible private-memory byte in the live CFB27 process. + +**Architecture:** The native host scans at most 32 MiB of eligible memory per request using a dedicated 4 MiB chunk buffer and returns a canonical continuation cursor. Protocol and SDK layers validate exact page shapes; the SDK offers both one-page control and bounded automatic aggregation. The CLI uses the aggregate SDK method, after which the exact candidate is reinstalled and the offline live gate is repeated. + +**Tech Stack:** Windows x64 C++20, Win32 `VirtualQuery`/`ReadProcessMemory`/`VirtualAlloc`, nlohmann JSON, Lua 5.4, CommonJS Node.js 20+, Node test runner, PowerShell, CMake/Visual Studio 2022. + +## Global Constraints + +- All memory operations in this plan are read-only. +- One native page scans at most 32 MiB of eligible bytes. +- Native read chunks are at most 4 MiB plus `patternLength - 1` lookahead. +- Cursors and addresses are canonical strings, never JavaScript numbers. +- `nextCursor` is canonical uppercase when `complete:false` and `null` when `complete:true`. +- Patterns crossing chunk or page boundaries are returned exactly once. +- Failed eligible reads never produce `complete:true` or silently skipped coverage. +- SDK automatic scanning accepts `maxPages` from 1 through 4,096 and defaults to 4,096, bounding eligible-byte work to 128 GiB. +- Raw addresses, patterns, masks, contexts, and bytes remain prohibited from Electron renderer exposure. +- No anticheat bypass, memory write, recruiting schema, or game-domain mutation is introduced. +- Do not edit `docs/research/runtime-verification.md`, bump versions, or commit the final Task 5 slice until the repeated manual gate succeeds. +- Do not install a new native candidate until the user confirms CFB27 and MMC are both closed; explicitly tell the user when to relaunch. + +--- + +### Task 1: Implement the bounded native scan page + +**Files:** +- Modify: `native/host/memory_reader.h` +- Modify: `native/host/memory_reader.cpp` +- Modify: `native/smoke/memory_reader_smoke.cpp` + +**Interfaces:** +- Consumes: existing `ParseAddress`, `FormatAddress`, `ScanMatch`, and eligible-region policy. +- Produces: `ScanRequest::cursor`, `ScanResult::next_cursor`, `kScanChunkBytes`, `kMaxScanPageBytes`, and page-correct `ScanPrivateMemory(const ScanRequest&)`. + +- [ ] **Step 1: Add failing large-region, continuation, and boundary tests** + +Extend `memory_reader_smoke.cpp` before changing production code. Reserve one +contiguous private region larger than 64 MiB and place sentinels after the old +limit and across chunk/page boundaries: + +```cpp +constexpr std::size_t kLargeBytes = 80ull * 1024 * 1024; +auto* large = static_cast(VirtualAlloc( + nullptr, kLargeBytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)); +Expect(large != nullptr, "allocate large region"); +std::copy(sentinel.begin(), sentinel.end(), large + (64ull * 1024 * 1024) + 128); +std::copy(sentinel.begin(), sentinel.end(), large + kScanChunkBytes - 4); + +ScanRequest page{sentinel, exact_mask, 8, 4, 4, std::nullopt}; +std::vector all; +std::optional previous; +for (std::size_t pages = 0; pages < 4096; ++pages) { + const auto result = ScanPrivateMemory(page); + Expect(result.code.empty(), "page succeeds"); + Expect(result.scanned_bytes <= kMaxScanPageBytes, "page is bounded"); + all.insert(all.end(), result.matches.begin(), result.matches.end()); + if (result.complete) { + Expect(!result.next_cursor.has_value(), "complete page has no cursor"); + break; + } + Expect(result.next_cursor.has_value(), "partial page has cursor"); + Expect(result.next_cursor != previous, "cursor advances"); + previous = result.next_cursor; + page.cursor = result.next_cursor; +} +Expect(CountAddress(all, large + (64ull * 1024 * 1024) + 128) == 1, + "large-region tail found once"); +Expect(CountAddress(all, large + kScanChunkBytes - 4) == 1, + "chunk-boundary match found once"); +``` + +Add rejection cases for a cursor above the system maximum, an overflowing +cursor, and a repeated/non-advancing page. Retain the mask-buffer regression. +Replace the old test that expects `SCAN_LIMIT_EXCEEDED` after 512 MiB with one +that allocates at least 544 MiB before the target and proves later pages reach +the target. Add a deterministic read-failure case through the function-pointer +seam defined in Step 3: + +```cpp +g_fail_read_at = reinterpret_cast(large); +const auto failed = ScanPrivateMemory(page, FailSelectedRead); +Expect(failed.code == "MEMORY_ACCESS_DENIED", "eligible read failure is explicit"); +Expect(!failed.complete, "failed read is never complete"); +Expect(!failed.next_cursor.has_value(), "failed read cannot advance cursor"); +g_fail_read_at = 0; +``` + +- [ ] **Step 2: Run the native smoke and verify RED** + +```powershell +$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' +& $cmake --build native/build-pr-a --config Release --target cfb27_memory_reader_smoke +& native/build-pr-a/Release/cfb27_memory_reader_smoke.exe +``` + +Expected: compilation fails because `cursor`, `next_cursor`, +`kScanChunkBytes`, and `kMaxScanPageBytes` do not exist, or the old scanner +fails the first large-region continuation assertion. + +- [ ] **Step 3: Define the native page contract** + +In `memory_reader.h`, replace the old aggregate constants and extend the types: + +```cpp +constexpr std::size_t kScanChunkBytes = 4ull * 1024 * 1024; +constexpr std::size_t kMaxScanPageBytes = 32ull * 1024 * 1024; +using ScanReadFunction = bool (*)(const void* source, void* destination, + std::size_t length, std::size_t& copied); + +struct ScanRequest { + std::vector pattern; + std::vector mask; + std::size_t max_matches{}; + std::size_t context_before{}; + std::size_t context_after{}; + std::optional cursor; +}; + +struct ScanResult { + bool complete{}; + std::string code; + std::size_t scanned_bytes{}; + std::optional next_cursor; + std::vector matches; +}; + +ScanResult ScanPrivateMemory(const ScanRequest& request, + ScanReadFunction read = nullptr); +``` + +`nullptr` selects the production `ReadProcessMemory` adapter. The smoke passes a +non-allocating function pointer only to force a selected eligible read to fail; +the protocol never accepts or exposes this seam. + +- [ ] **Step 4: Implement chunked traversal with exact progress** + +In `memory_reader.cpp`, allocate the scan buffer with `VirtualAlloc`, free it by +RAII, select the production reader when `read == nullptr`, and scan from the +parsed cursor or system minimum. The loop must: + +```cpp +const auto unique_bytes = std::min({ + region_end - cursor, + static_cast(kScanChunkBytes), + static_cast(kMaxScanPageBytes - result.scanned_bytes), +}); +const auto lookahead = std::min( + request.pattern.size() - 1, region_end - (cursor + unique_bytes)); +const auto read_bytes = static_cast(unique_bytes + lookahead); +``` + +Read `read_bytes`, accept only matches whose start is below +`cursor + unique_bytes`, and advance by `unique_bytes`. When the eligible-byte +budget is exhausted, return `complete:false` and `next_cursor=FormatAddress(cursor)`. +When virtual-address traversal reaches the system maximum, return +`complete:true` and no cursor. Skip the dedicated buffer allocation without +counting it. A failed read of an eligible chunk returns +`MEMORY_ACCESS_DENIED` immediately. + +- [ ] **Step 5: Verify native GREEN repeatedly** + +```powershell +$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' +& $cmake --build native/build-pr-a --config Release --target cfb27_memory_reader_smoke +1..5 | ForEach-Object { + & native/build-pr-a/Release/cfb27_memory_reader_smoke.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} +git diff --check +``` + +Expected: five `memory reader smoke passed` messages and exit `0`. + +- [ ] **Step 6: Commit the native page** + +```powershell +git add -- native/host/memory_reader.h native/host/memory_reader.cpp native/smoke/memory_reader_smoke.cpp +git commit -m "Implement cursor-paged memory scanning" +``` + +--- + +### Task 2: Expose page cursors and SDK aggregation + +**Files:** +- Modify: `native/host/lua_host.cpp` +- Modify: `native/smoke/protocol_smoke.cpp` +- Modify: `packages/sdk/src/client.cjs` +- Modify: `packages/sdk/test/client.test.cjs` +- Modify: `docs/protocol.md` +- Modify: `docs/getting-started.md` + +**Interfaces:** +- Consumes: Task 1 `ScanRequest::cursor` and `ScanResult::next_cursor`. +- Produces: protocol `scanMemory.params.cursor`, exact page results, + `client.scanMemoryPage(options)`, and aggregate `client.scanMemory(options)`. + +- [ ] **Step 1: Write failing protocol page tests** + +Update `protocol_smoke.cpp` to require `nextCursor` in every successful result: + +```cpp +Expect(page["complete"] == false, "first page is partial"); +Expect(page["nextCursor"].is_string(), "partial page has cursor"); +const auto cursor = page["nextCursor"].get(); +params["cursor"] = cursor; +Expect(Request(host, "scanMemory", params, page2), "resume page"); +Expect(page2["nextCursor"].is_null() || + page2["nextCursor"].get() != cursor, + "cursor progresses"); +``` + +Add unknown-field, lowercase, redundant-zero, numeric, overflowing, and +above-maximum cursor requests. Require `nextCursor:null` exactly when complete. + +- [ ] **Step 2: Write failing SDK pagination tests** + +Use the fake pipe server to return three pages and assert exact outbound cursors: + +```js +const pages = [ + { supportedBuild: true, complete: false, nextCursor: '0x1000', scannedBytes: 32, matches: [] }, + { supportedBuild: true, complete: false, nextCursor: '0x2000', scannedBytes: 32, matches: [MATCH] }, + { supportedBuild: true, complete: true, nextCursor: null, scannedBytes: 8, matches: [] }, +]; +const result = await client.scanMemory({ ...VALID_SCAN_OPTIONS, maxPages: 3 }); +assert.equal(result.complete, true); +assert.equal(result.scannedBytes, 72); +assert.deepEqual(result.matches, [MATCH]); +assert.deepEqual(seen.map((request) => request.params.cursor), + [undefined, '0x1000', '0x2000']); +``` + +Add hostile-response cases for missing/null mismatch, numeric/lowercase/repeated/ +decreasing cursors, aggregate unsafe integer overflow, more than `maxMatches`, +and exhaustion of `maxPages`. Prove request validation occurs before socket +creation. + +- [ ] **Step 3: Run protocol and SDK tests and verify RED** + +```powershell +$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' +& $cmake --build native/build-pr-a --config Release --target cfb27_lua_host cfb27_protocol_smoke +& native/build-pr-a/Release/cfb27_protocol_smoke.exe native/build-pr-a/Release/cfb27_lua_host.dll +node --test packages/sdk/test/client.test.cjs +``` + +Expected: protocol shape/cursor assertions fail and SDK tests fail because +`scanMemoryPage` and aggregation are absent. + +- [ ] **Step 4: Implement strict protocol cursor handling** + +Allow exactly optional `cursor` in `scanMemory` params. Parse it with the same +canonical uppercase address validator used for read ranges, move it into +`ScanRequest`, and serialize: + +```cpp +Json result = { + {"supportedBuild", supported_build}, + {"complete", scan.complete}, + {"nextCursor", scan.next_cursor ? Json(CanonicalAddress(*scan.next_cursor)) : Json(nullptr)}, + {"scannedBytes", scan.scanned_bytes}, + {"matches", std::move(matches)}, +}; +``` + +Retain strict unsupported-build gating and sanitized errors. + +- [ ] **Step 5: Implement SDK page validation and aggregation** + +Set `maxScanPageBytes` to 32 MiB and `maxPages` to 4,096. Keep `maxPages` +SDK-only; do not send it to the host. Implement: + +```js +async function scanMemoryPage(options = {}) { + const params = cloneScanPageOptions(options); + return validateScanPageResult(await request('scanMemory', params), params); +} + +async function scanMemory(options = {}) { + const { maxPages = MEMORY_LIMITS.maxPages, ...base } = cloneAggregateScanOptions(options); + const matches = []; + const cursors = new Set(); + let cursor; + let scannedBytes = 0; + for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) { + const page = await scanMemoryPage(cursor ? { ...base, cursor } : base); + scannedBytes = safeAddScanBytes(scannedBytes, page.scannedBytes); + matches.push(...page.matches); + if (matches.length > base.maxMatches) throw tooManyMatches(); + if (page.complete) return { supportedBuild: page.supportedBuild, complete: true, + scannedBytes, matches }; + if (cursors.has(page.nextCursor) || + (cursor && BigInt(page.nextCursor) <= BigInt(cursor))) { + throw invalidResponse('Host returned a non-progressing scan cursor'); + } + cursors.add(page.nextCursor); + cursor = page.nextCursor; + } + throw new Cfb27HookError('SCAN_LIMIT_EXCEEDED', 'scanMemory exceeded maxPages'); +} +``` + +Do not expose either method to renderer code. + +- [ ] **Step 6: Document and verify Task 2** + +Document page and aggregate semantics, 32 MiB/4 MiB bounds, 4,096-page ceiling, +live-map non-atomicity, and required re-read validation. + +```powershell +$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' +& $cmake --build native/build-pr-a --config Release +& native/build-pr-a/Release/cfb27_memory_reader_smoke.exe +& native/build-pr-a/Release/cfb27_telemetry_smoke.exe +& native/build-pr-a/Release/cfb27_protocol_smoke.exe native/build-pr-a/Release/cfb27_lua_host.dll +node --test packages/sdk/test/client.test.cjs +npm run check +npm test +git diff --check +``` + +Expected: all native smokes pass and the full Node suite exits `0`. + +- [ ] **Step 7: Commit protocol and SDK pagination** + +```powershell +git add -- native/host/lua_host.cpp native/smoke/protocol_smoke.cpp packages/sdk/src/client.cjs packages/sdk/test/client.test.cjs docs/protocol.md docs/getting-started.md +git commit -m "Expose cursor-paged memory scans" +``` + +--- + +### Task 3: Integrate automatic paging into the developer CLI + +**Files:** +- Modify: `packages/cli/src/args.cjs` +- Modify: `packages/cli/src/main.cjs` +- Modify: `packages/cli/test/main.test.cjs` +- Modify: `docs/cli.md` +- Modify: `docs/development/release-checklist.md` + +**Interfaces:** +- Consumes: Task 2 aggregate `client.scanMemory(options)`. +- Produces: automatic bounded CLI paging and per-page scan timeout selection. + +This task must preserve the existing uncommitted Task 5 CLI work already +verified at 18/18 focused tests. Do not discard or replace those user-approved +changes. + +- [ ] **Step 1: Add failing multi-page CLI and timeout tests** + +Extend the existing `memory scan` tests: + +```js +assert.deepEqual(calls[0], ['createClient', { pid: 42, timeoutMs: 10_000 }]); +assert.deepEqual(calls[1], ['scanMemory', { + patternHex: PATTERN, + maskHex: MASK, + maxMatches: 8, + contextBefore: 32, + contextAfter: 32, + maxPages: 4096, +}]); +``` + +Return an aggregate result and assert JSON output is unchanged. Add +`--max-pages 0`, `4097`, unsafe integer, duplicate option, and attempts to use +`--cursor` directly; the CLI owns continuation and rejects raw cursor input. + +- [ ] **Step 2: Run focused CLI tests and verify RED** + +```powershell +node --test packages/cli/test/main.test.cjs +``` + +Expected: new timeout/max-pages assertions fail. + +- [ ] **Step 3: Add bounded CLI parsing and dispatch** + +In `args.cjs`, parse `--max-pages` as an integer from 1 through 4,096. In +`main.cjs`, construct only the scan client with a 10-second per-page timeout: + +```js +const client = sdk.createClient({ pid: game.pid, timeoutMs: 10_000 }); +result = await client.scanMemory({ ...scanOptions, maxPages: options.maxPages || 4096 }); +``` + +Reads, telemetry, status, logs, and events retain the three-second SDK default. +Reject all raw `--cursor`, start/stop-range, and write-like options. + +- [ ] **Step 4: Run the automated release gate without installing** + +```powershell +npm ci +npm run check +npm test +$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' +& $cmake -S native -B native/build-release -A x64 +& $cmake --build native/build-release --config Release +& native/build-release/Release/cfb27_memory_reader_smoke.exe +& native/build-release/Release/cfb27_telemetry_smoke.exe +& native/build-release/Release/cfb27_protocol_smoke.exe native/build-release/Release/cfb27_lua_host.dll +$env:CFB27_NATIVE_ARTIFACTS = (Resolve-Path native/build-release/Release).Path +npm run pack:preview +git diff --check +``` + +Inspect the preview ZIP and SDK/CLI tarballs. Reject build directories, saves, +schemas, archives outside documented release contents, logs, or local game data. + +- [ ] **Step 5: Stop at the installation checkpoint** + +Do not install automatically. Tell the user to close CFB27 and MMC. Confirm both +processes are absent, restore the recognized original proxies if the installed +candidate hash differs, then install the exact `native/build-release/Release` +proxy and host through the supported CLI. Hash all installed files and only then +tell the user to relaunch MMC offline, launch a fresh CFB27 process, open the +correct Dynasty, and stop at the Dynasty hub. + +--- + +### Task 4: Repeat the live gate, version, commit, and open the draft PR + +**Files:** +- Modify: `docs/research/runtime-verification.md` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `packages/sdk/package.json` +- Modify: `packages/cli/package.json` +- Commit the five Task 3 CLI/doc files after the live gate. + +**Interfaces:** +- Consumes: exact installed Task 3 release candidate. +- Produces: verified `v0.2.0-dev.1` source state and a draft PR. + +- [ ] **Step 1: Verify live capabilities and allocate the sentinel** + +At the Dynasty hub, record the PID and SHA-256 of `CollegeFB27.exe`. Require +`hello.capabilities` to contain `memoryScan`, `memoryRead`, and `telemetry`. +Allocate the binary sentinel without embedding its raw bytes in the Lua source: + +```powershell +node packages/cli/bin/cfb27lua.cjs eval "_G.__cfb27_manual_sentinel = string.char(199,91,39,161,14,210,76,147,184,6,253,113,42,229,56,143)" --json +``` + +- [ ] **Step 2: Prove paged scan and exact read** + +```powershell +$scan = node packages/cli/bin/cfb27lua.cjs memory scan ` + --pattern C75B27A10ED24C93B806FD712AE5388F ` + --mask FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF ` + --max-matches 8 --context 8 --max-pages 4096 --json | ConvertFrom-Json +$match = $scan.result.matches | Where-Object { + $_.contextHex -match 'C75B27A10ED24C93B806FD712AE5388F' +} | Select-Object -First 1 +node packages/cli/bin/cfb27lua.cjs memory read --range "$($match.address):16" --json +``` + +Require the exact 16-byte value and no memory write attempt. If multiple matches +exist, re-read each and identify the persistent Lua allocation; do not claim +uniqueness without completing all pages. + +- [ ] **Step 3: Prove telemetry and responsiveness** + +Capture the current event cursor, register `probe.snapshot`, emit +`{sequence=1,stable=true}`, and require exactly one matching event with a larger +cursor. Observe the game for ten minutes, then enter one Dynasty sub-screen and +return to the hub. Require the process, pipe, ticks, status, and UI to remain +responsive. + +- [ ] **Step 4: Record only observed evidence** + +Append the date, executable hash, installed host/proxy hashes, exact commands, +page count, scanned bytes, sentinel match/read result, telemetry cursors, +ten-minute observation, and hub transition to +`docs/research/runtime-verification.md`. Record failures verbatim; never convert +a failed or incomplete check into a pass. + +- [ ] **Step 5: Bump versions after the live gate** + +Set root, SDK, CLI, and lockfile versions to `0.2.0-dev.1`. Do not change +dependency ranges or package contents. + +- [ ] **Step 6: Repeat every automated gate after the bump** + +```powershell +npm ci +npm run check +npm test +$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' +& $cmake -S native -B native/build-release -A x64 +& $cmake --build native/build-release --config Release +& native/build-release/Release/cfb27_memory_reader_smoke.exe +& native/build-release/Release/cfb27_telemetry_smoke.exe +& native/build-release/Release/cfb27_protocol_smoke.exe native/build-release/Release/cfb27_lua_host.dll +$env:CFB27_NATIVE_ARTIFACTS = (Resolve-Path native/build-release/Release).Path +npm run pack:preview +git diff --check +``` + +Expected: all commands exit `0`, all package contents are clean, and fresh +SHA-256 checksums match the staged artifacts. + +- [ ] **Step 7: Commit the final developer preview slice** + +```powershell +git add -- packages/cli/src/args.cjs packages/cli/src/main.cjs packages/cli/test/main.test.cjs docs/cli.md docs/development/release-checklist.md docs/research/runtime-verification.md package.json package-lock.json packages/sdk/package.json packages/cli/package.json +git commit -m "Prepare live discovery developer preview" +``` + +- [ ] **Step 8: Final review, push, and draft PR** + +Run an independent whole-branch review from the approved design/plans baseline. +Fix and re-review all Critical and Important findings. Then: + +```powershell +git push -u origin codex/live-memory-discovery-telemetry +``` + +Open a draft PR against `main`. Do not tag or publish `v0.2.0-dev.1` until the +draft is reviewed, made ready, merged, and the release checklist is rerun. diff --git a/docs/superpowers/plans/2026-07-11-guarded-memory-transactions.md b/docs/superpowers/plans/2026-07-11-guarded-memory-transactions.md new file mode 100644 index 0000000..e05af5d --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-guarded-memory-transactions.md @@ -0,0 +1,335 @@ +# Guarded Memory Transactions 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:** Publish `cfb27-lua-hook` v0.2.0-dev.2 with bounded whole-request memory transactions, complete preflight comparison, readback, rollback, and session write lockdown after an unverifiable rollback. + +**Architecture:** A standalone transaction engine operates through an injectable memory backend so all failure paths are deterministic in native tests. The production backend accesses only validated current-process writable memory. Protocol and SDK layers use canonical hex-string addresses and hex byte sequences; the existing one-byte Lua API remains compatible but observes the same session-lockdown flag. + +**Tech Stack:** Windows C++20, Win32 memory APIs, nlohmann/json, CommonJS Node.js 20+, Node test runner, CMake 3.24+. + +## Global Constraints + +- This plan starts only after v0.2.0-dev.1 discovery/telemetry assets and checksums are published. +- Transaction limits: 32 operations, 4096 bytes per operation, 64 KiB aggregate replacement bytes, and a 64-character transaction ID matching `^[A-Za-z0-9._-]+$`. +- Every operation uses canonical uppercase hex-string address, equal-length nonempty `expectedHex` and `replacementHex`, and a non-overlapping address range. +- Preflight every operation before writing the first byte; any mismatch causes zero writes. +- Apply and verify in request order; rollback every applied operation in reverse order. +- A rollback verification failure permanently disables all host writes until the game/host restarts. +- Preserve exact-build, writable-memory, expected-byte, anticheat, and readback gates. +- Do not claim game-thread atomicity and do not expose the command through an Electron renderer. +- Preserve `cfb.write_u8` behavior for successful requests; add only the session-lockdown rejection. +- No native build output, packages, game files, addresses, or diagnostic memory dumps may be committed. + +--- + +### Task 1: Implement a backend-driven transaction engine + +**Files:** +- Create: `native/host/memory_transaction.h` +- Create: `native/host/memory_transaction.cpp` +- Create: `native/smoke/memory_transaction_smoke.cpp` +- Modify: `native/CMakeLists.txt` + +**Interfaces:** +- Produces abstract `MemoryBackend`. +- Produces production `ProcessMemoryBackend`. +- Produces `RunTransaction(const TransactionRequest&, MemoryBackend&): TransactionResult`. + +- [ ] **Step 1: Write the fake-backend smoke tests** + +Define a fake 256-byte backend that records write calls and can fail a chosen write, verification read, restore write, or restore read. Cover: + +```cpp +Require(RunTransaction(valid, backend).status == Status::kAppliedVerified, "happy path"); +Require(backend.bytes == expected_after, "replacement present"); + +backend.Reset(); backend.bytes[expected_offset] ^= 1; +const auto mismatch = RunTransaction(valid, backend); +Require(mismatch.status == Status::kRejected && backend.write_calls == 0, + "preflight mismatch writes nothing"); + +backend.Reset(); backend.fail_write_index = 1; +const auto rolled_back = RunTransaction(valid, backend); +Require(rolled_back.status == Status::kRolledBackVerified, "failed apply rolls back"); +Require(backend.bytes == original, "all originals restored"); + +backend.Reset(); backend.fail_write_index = 1; backend.fail_restore = true; +Require(RunTransaction(valid, backend).status == Status::kRollbackUnverified, + "rollback failure is explicit"); +``` + +Also reject empty operations, 33 operations, 4097-byte operations, aggregate bytes above 64 KiB, unequal lengths, overflowed ranges, duplicate/overlapping ranges, invalid transaction IDs, and operations submitted out of ascending address order only if they overlap (order itself remains caller-defined). + +- [ ] **Step 2: Add the smoke target and verify RED** + +```powershell +cmake -S native -B native/build-transaction -A x64 +cmake --build native/build-transaction --config Release --target cfb27_memory_transaction_smoke +``` + +Expected: compile failure because the transaction module is absent. + +- [ ] **Step 3: Define the engine types** + +In `memory_transaction.h`: + +```cpp +namespace cfb27::memory { +constexpr std::size_t kMaxTransactionOperations = 32; +constexpr std::size_t kMaxOperationBytes = 4096; +constexpr std::size_t kMaxTransactionBytes = 64ull * 1024; + +struct TransactionOperation { + std::string address; + std::vector expected; + std::vector replacement; +}; +struct TransactionRequest { + std::string transaction_id; + std::vector operations; +}; +enum class TransactionStatus { + kRejected, + kAppliedVerified, + kRolledBackVerified, + kRollbackUnverified, +}; +struct OperationResult { std::size_t index{}; bool applied{}; bool verified{}; }; +struct TransactionResult { + TransactionStatus status{}; + std::string code; + std::vector operations; +}; +class MemoryBackend { + public: + virtual ~MemoryBackend() = default; + virtual bool Validate(std::uintptr_t address, std::size_t size, bool writable) = 0; + virtual bool Read(std::uintptr_t address, std::span output) = 0; + virtual bool Write(std::uintptr_t address, std::span input) = 0; +}; +class ProcessMemoryBackend final : public MemoryBackend { /* three overrides */ }; +TransactionResult RunTransaction(const TransactionRequest& request, MemoryBackend& backend); +} +``` + +- [ ] **Step 4: Implement validation, preflight, apply, verify, and rollback** + +Parse addresses once, sort copies of ranges only for overlap detection, and retain request order for application. Preflight validates all ranges and reads all originals before comparing all expected bytes. Do not call `Write` during preflight. + +On any apply/write/readback failure, restore every operation whose write was attempted, in reverse order, from the captured originals; then read and compare every restored range. Return `kRollbackUnverified` if any restore or verification fails. + +- [ ] **Step 5: Verify the engine and commit** + +```powershell +cmake --build native/build-transaction --config Release --target cfb27_memory_transaction_smoke +native\build-transaction\Release\cfb27_memory_transaction_smoke.exe +git add -- native/host/memory_transaction.h native/host/memory_transaction.cpp native/smoke/memory_transaction_smoke.cpp native/CMakeLists.txt +git commit -m "Add guarded memory transaction engine" +``` + +--- + +### Task 2: Expose write transactions through the native host + +**Files:** +- Modify: `native/host/lua_host.cpp` +- Modify: `native/CMakeLists.txt` +- Modify: `native/smoke/protocol_smoke.cpp` +- Modify: `native/smoke/startup_host_smoke.cpp` +- Modify: `docs/protocol.md` +- Modify: `docs/safety.md` + +**Interfaces:** +- Consumes `RunTransaction` from Task 1. +- Produces `writeTransaction` protocol command and `memoryWriteTransaction` capability. +- Produces status field `sessionWritesDisabled`. + +- [ ] **Step 1: Add failing protocol smoke cases** + +Allocate two writable buffers in the smoke process. Submit: + +```json +{ + "command":"writeTransaction", + "params":{ + "transactionId":"smoke.apply-1", + "operations":[ + {"address":"0x...","expectedHex":"1020","replacementHex":"1121"}, + {"address":"0x...","expectedHex":"3040","replacementHex":"3141"} + ] + } +} +``` + +The smoke executable is not the supported game build, so introduce a test-only host environment variable `CFB27_SMOKE_ALLOW_WRITES=1` honored only when the executable name is `cfb27_protocol_smoke.exe`. Assert no other executable can use it. + +Cover applied/verified, complete preflight mismatch with unchanged memory, overlapping operations, malformed hex, invalid address, unsupported build without the smoke gate, and status/capability reporting. + +- [ ] **Step 2: Run protocol smoke and verify RED** + +Expected: unknown-command failure for `writeTransaction`. + +- [ ] **Step 3: Add request parsing and the session lockdown** + +Add `std::atomic g_session_writes_disabled{false}`. Parse only `transactionId` and `operations`; reject extra fields. Map results as: + +```json +{"transactionId":"smoke.apply-1","status":"applied_verified","operations":[{"index":0,"applied":true,"verified":true}]} +``` + +Use stable error codes `MEMORY_MISMATCH`, `MEMORY_ACCESS_DENIED`, `TRANSACTION_LIMIT_EXCEEDED`, `TRANSACTION_APPLY_FAILED`, `ROLLBACK_VERIFICATION_FAILED`, and `SESSION_WRITES_DISABLED`. + +When result is `kRollbackUnverified`, set the lockdown before returning. Add `sessionWritesDisabled` to `status`. `writeTransaction` and `LuaWriteU8` check this flag before all other write work. + +- [ ] **Step 4: Link the transaction engine and advertise capability** + +Add `host/memory_transaction.cpp` to `cfb27_lua_host`; add `memoryWriteTransaction` to `hello.capabilities`. + +- [ ] **Step 5: Verify native host behavior** + +```powershell +cmake --build native/build-transaction --config Release --target cfb27_lua_host cfb27_protocol_smoke cfb27_startup_smoke +$env:CFB27_SMOKE_ALLOW_WRITES='1' +native\build-transaction\Release\cfb27_protocol_smoke.exe native\build-transaction\Release\cfb27_lua_host.dll +Remove-Item Env:CFB27_SMOKE_ALLOW_WRITES +``` + +Expected: all smokes exit `0`; the test-only gate source test proves the executable-name restriction. + +- [ ] **Step 6: Document and commit** + +Document non-game-thread atomicity, rollback statuses, lockdown behavior, and the fact that callers must establish a stable window. + +```powershell +git add -- native/host/lua_host.cpp native/CMakeLists.txt native/smoke/protocol_smoke.cpp native/smoke/startup_host_smoke.cpp docs/protocol.md docs/safety.md +git commit -m "Expose guarded memory transactions" +``` + +--- + +### Task 3: Add SDK and developer CLI transaction clients + +**Files:** +- Modify: `packages/sdk/src/client.cjs` +- Modify: `packages/sdk/src/errors.cjs` +- Modify: `packages/sdk/test/client.test.cjs` +- Modify: `packages/cli/src/args.cjs` +- Modify: `packages/cli/src/main.cjs` +- Modify: `packages/cli/test/main.test.cjs` +- Modify: `docs/cli.md` + +**Interfaces:** +- Produces `client.writeTransaction({ transactionId, operations })`. +- Produces `cfb27lua memory transact proof-transaction.json --json`. + +- [ ] **Step 1: Write failing SDK tests** + +Assert the SDK sends exactly: + +```js +await client.writeTransaction({ + transactionId: 'recruiting.influence-proof-1', + operations: [{ + address: '0x7FF612340000', + expectedHex: '1020', + replacementHex: '1121', + }], +}); +``` + +Assert local rejection before connection for invalid IDs, numeric/lowercase addresses, odd/invalid hex, unequal byte lengths, overlaps, limits, unknown keys, and caller-provided result/status fields. Validate every response property and reject host-supplied addresses/raw bytes in result objects. + +- [ ] **Step 2: Verify SDK RED and implement GREEN** + +Run `node --test packages/sdk/test/client.test.cjs`, implement frozen-clone input validation and strict response validation, add the six stable error codes, and rerun until green. + +- [ ] **Step 3: Write failing CLI tests** + +The JSON file contains only the SDK request object. Assert the CLI refuses stdin, absolute paths outside the current working directory unless `--allow-external-file` is passed, non-JSON extensions, and human output that prints addresses or bytes. Human output may print only transaction ID, status, and operation counts; `--json` returns the validated SDK result. + +- [ ] **Step 4: Implement the thin CLI handler and verify** + +```powershell +node --test packages/cli/test/main.test.cjs +npm run check +npm test +``` + +- [ ] **Step 5: Commit Task 3** + +```powershell +git add -- packages/sdk/src/client.cjs packages/sdk/src/errors.cjs packages/sdk/test/client.test.cjs packages/cli/src/args.cjs packages/cli/src/main.cjs packages/cli/test/main.test.cjs docs/cli.md +git commit -m "Add guarded transaction SDK and CLI" +``` + +--- + +### Task 4: Complete controlled-memory and live reversible-write gates + +**Files:** +- Modify: `docs/development/release-checklist.md` +- Modify: `docs/research/runtime-verification.md` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `packages/sdk/package.json` +- Modify: `packages/cli/package.json` + +**Interfaces:** +- Produces immutable release `v0.2.0-dev.2`. + +- [ ] **Step 1: Run the complete automated gate** + +```powershell +npm ci +npm run check +npm test +cmake -S native -B native/build-release -A x64 +cmake --build native/build-release --config Release +native\build-release\Release\cfb27_memory_reader_smoke.exe +native\build-release\Release\cfb27_telemetry_smoke.exe +native\build-release\Release\cfb27_memory_transaction_smoke.exe +$env:CFB27_SMOKE_ALLOW_WRITES='1' +native\build-release\Release\cfb27_protocol_smoke.exe native\build-release\Release\cfb27_lua_host.dll +Remove-Item Env:CFB27_SMOKE_ALLOW_WRITES +npm run pack:preview +git diff --check +``` + +- [ ] **Step 2: Stop at the user relaunch checkpoint** + +**USER RELAUNCH CHECKPOINT:** Tell the user to close CFB27 and MMC. Do not install or replace the host while either process is running. Confirm both are closed, install the candidate DLL through the supported SDK/CLI workflow, then explicitly tell the user when to relaunch MMC and CFB27 offline and return to the Dynasty hub. + +- [ ] **Step 3: Perform only a reversible controlled live write** + +Use one of the previously verified authoritative Dynasty permission records +(`LeagueSetting.AbilityEditControls` or `FranchiseUser.AdminLevel`), rediscovered +from the selected save's exact full record and revalidated immediately before +the transaction. Do not use a recruiting field yet. Apply the alternate enum +value, read it back, restore the original immediately, read it back again, and +verify game responsiveness. Do not advance a week during this host-only gate. + +- [ ] **Step 4: Record evidence and bump the version** + +Record executable hash, PID, transaction request hash (not addresses/bytes), statuses, rollback/restoration results, and game responsiveness in `docs/research/runtime-verification.md`. Set root, SDK, CLI, and lockfile versions to `0.2.0-dev.2` only after the gate passes. + +- [ ] **Step 5: Re-run release verification and commit** + +```powershell +npm ci +npm run check +npm test +cmake --build native/build-release --config Release +npm run pack:preview +git diff --check +git add -- docs/development/release-checklist.md docs/research/runtime-verification.md package.json package-lock.json packages/sdk/package.json packages/cli/package.json +git commit -m "Prepare guarded write developer preview" +``` + +- [ ] **Step 6: Push a draft PR and release after merge** + +```powershell +git push -u origin codex/guarded-memory-transactions +``` + +Open a draft PR against `main`. After review and merge, tag `v0.2.0-dev.2`, publish immutable assets and checksums, and verify a fresh SDK install before starting the Brooks integration implementation. diff --git a/docs/superpowers/plans/2026-07-11-live-memory-discovery-telemetry.md b/docs/superpowers/plans/2026-07-11-live-memory-discovery-telemetry.md new file mode 100644 index 0000000..81f1a30 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-live-memory-discovery-telemetry.md @@ -0,0 +1,421 @@ +# Live Memory Discovery and Telemetry 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:** Publish `cfb27-lua-hook` v0.2.0-dev.1 with bounded private-memory discovery, batch reads, and registered structured telemetry for trusted main-process consumers. + +**Architecture:** A new native `memory_reader` module owns current-process region enumeration, masked scanning, canonical address parsing, and bounded reads. Protocol v1 adds typed commands and the SDK validates their inputs/outputs. Lua gains `cfb.emit`, but only session-registered event names and bounded JSON-compatible payloads may enter the existing cursor-paged event ring. + +**Tech Stack:** Windows C++20, Win32 `VirtualQuery`, nlohmann/json 3.11.3, Lua 5.4.8, CommonJS Node.js 20+, Node test runner, CMake 3.24+. + +## Global Constraints + +- Read only `MEM_COMMIT` + `MEM_PRIVATE` regions with readable protection and no `PAGE_GUARD`/`PAGE_NOACCESS`. +- Encode every address crossing JSON as uppercase canonical `0x[0-9A-F]+`; never use JavaScript numbers for addresses. +- `scanMemory` limits: pattern 8–4096 bytes, at most 64 requested matches, at most 512 context bytes on either side, at most 64 MiB per region, and at most 512 MiB aggregate scanning. +- `readMemory` limits: at most 64 ranges, 64 KiB per range, and 256 KiB aggregate bytes. +- Unsupported builds require `allowUnsupportedBuild:true` for reads and must return `supportedBuild:false`; writes remain unavailable. +- Telemetry limits: 16 registered names per host session, 64 characters per name, depth 4, 64 object keys, 128 array entries, 1024 bytes per string, and 16 KiB serialized payload. +- Reserve `game_ready`, `tick`, and `log`; custom names match `^[a-z][a-z0-9_.-]{0,63}$`. +- Do not expose any new operation to an Electron renderer. +- Preserve protocol v1 framing, existing commands, Lua APIs, legacy text pipe, and Node 20 compatibility. +- Do not commit `native/build-*`, native binaries, package tarballs, or local game data. + +--- + +### Task 1: Extract bounded memory discovery into a testable native module + +**Files:** +- Create: `native/host/memory_reader.h` +- Create: `native/host/memory_reader.cpp` +- Create: `native/smoke/memory_reader_smoke.cpp` +- Modify: `native/CMakeLists.txt` + +**Interfaces:** +- Produces `ParseAddress(std::string_view): std::optional`. +- Produces `FormatAddress(std::uintptr_t): std::string`. +- Produces `IsEligiblePrivateReadableRegion(const MEMORY_BASIC_INFORMATION&): bool`. +- Produces `ScanPrivateMemory(const ScanRequest&): ScanResult`. +- Produces `ReadMemoryBatch(const std::vector&): BatchReadResult`. + +- [ ] **Step 1: Write the native smoke test before the implementation** + +Create a 64 KiB `VirtualAlloc(..., MEM_PRIVATE, PAGE_READWRITE)` allocation containing two unique 16-byte sentinels. Assert: + +```cpp +using cfb27::memory::ReadMemoryBatch; +using cfb27::memory::ScanPrivateMemory; + +const auto scan = ScanPrivateMemory({ + .pattern = HexBytes("CFB27A1100A1B2C3D4E5F60718293A4B"), + .mask = std::vector(16, 0xFF), + .max_matches = 2, + .context_before = 4, + .context_after = 4, +}); +Require(scan.complete && scan.matches.size() == 1, "unique private match"); +Require(scan.matches[0].context.size() == 24, "bounded context"); + +const auto read = ReadMemoryBatch({ + {FormatAddress(reinterpret_cast(allocation) + 128), 16}, +}); +Require(read.ok && read.ranges[0].bytes == sentinel, "batch read"); +``` + +Also assert rejection of a 7-byte pattern, 65 requested matches, a cross-region read, `PAGE_NOACCESS`, `MEM_IMAGE`, invalid/overflowing hex addresses, and a scan that exceeds the aggregate limit. + +- [ ] **Step 2: Add the smoke target and verify RED** + +Add `cfb27_memory_reader_smoke` to `native/CMakeLists.txt`, link `host/memory_reader.cpp`, and run: + +```powershell +cmake -S native -B native/build-memory -A x64 +cmake --build native/build-memory --config Release --target cfb27_memory_reader_smoke +``` + +Expected: compile failure because `memory_reader.h/.cpp` do not exist. + +- [ ] **Step 3: Define the native data contract** + +In `memory_reader.h`, define: + +```cpp +namespace cfb27::memory { +constexpr std::size_t kMinPatternBytes = 8; +constexpr std::size_t kMaxPatternBytes = 4096; +constexpr std::size_t kMaxMatches = 64; +constexpr std::size_t kMaxContextBytes = 512; +constexpr std::size_t kMaxRegionBytes = 64ull * 1024 * 1024; +constexpr std::size_t kMaxScanBytes = 512ull * 1024 * 1024; +constexpr std::size_t kMaxReadRanges = 64; +constexpr std::size_t kMaxReadRangeBytes = 64ull * 1024; +constexpr std::size_t kMaxReadBytes = 256ull * 1024; + +struct ReadRange { std::string address; std::size_t length{}; }; +struct ReadResult { std::string address; std::vector bytes; }; +struct BatchReadResult { bool ok{}; std::string code; std::vector ranges; }; +struct ScanRequest { + std::vector pattern; + std::vector mask; + std::size_t max_matches{}; + std::size_t context_before{}; + std::size_t context_after{}; +}; +struct ScanMatch { + std::string address; + std::string region_base; + std::size_t region_size{}; + DWORD protection{}; + std::string context_address; + std::vector context; +}; +struct ScanResult { + bool complete{}; + std::string code; + std::size_t scanned_bytes{}; + std::vector matches; +}; +std::optional ParseAddress(std::string_view text); +std::string FormatAddress(std::uintptr_t address); +bool IsEligiblePrivateReadableRegion(const MEMORY_BASIC_INFORMATION& info); +BatchReadResult ReadMemoryBatch(const std::vector& ranges); +ScanResult ScanPrivateMemory(const ScanRequest& request); +} +``` + +- [ ] **Step 4: Implement region filtering, scanning, and batch reads** + +Walk the current process address space with `VirtualQuery`. Accept only complete eligible regions, cap each scanned region to `kMaxRegionBytes`, and abort with `SCAN_LIMIT_EXCEEDED` before passing `kMaxScanBytes`. Search byte-by-byte using `(live & mask) == (pattern & mask)`. Continue until `max_matches + 1` matches so the host can return `TOO_MANY_MATCHES` instead of silently truncating. + +For batch reads, parse and validate all ranges first, verify aggregate limits, then copy only after every range passes. Return `MEMORY_ACCESS_DENIED` on any invalid region and no partial range results. + +- [ ] **Step 5: Build and run the native smoke test** + +```powershell +cmake --build native/build-memory --config Release --target cfb27_memory_reader_smoke +native\build-memory\Release\cfb27_memory_reader_smoke.exe +``` + +Expected: `memory reader smoke passed` and exit `0`. + +- [ ] **Step 6: Commit Task 1** + +```powershell +git add -- native/host/memory_reader.h native/host/memory_reader.cpp native/smoke/memory_reader_smoke.cpp native/CMakeLists.txt +git commit -m "Add bounded live memory reader" +``` + +--- + +### Task 2: Add typed scan and batch-read protocol commands + +**Files:** +- Modify: `native/host/lua_host.cpp` +- Modify: `native/CMakeLists.txt` +- Modify: `native/smoke/protocol_smoke.cpp` +- Modify: `docs/protocol.md` + +**Interfaces:** +- Consumes `ScanPrivateMemory` and `ReadMemoryBatch` from Task 1. +- Produces protocol capabilities `memoryScan` and `memoryRead`. +- Produces commands `scanMemory` and `readMemory`. + +- [ ] **Step 1: Extend protocol smoke with failing command tests** + +Allocate a sentinel in `protocol_smoke.cpp`, then request: + +```json +{"command":"scanMemory","params":{"patternHex":"CFB27A1100A1B2C3D4E5F60718293A4B","maskHex":"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF","maxMatches":2,"contextBefore":4,"contextAfter":4}} +``` + +Assert one match with canonical address strings, `complete:true`, bounded `contextHex`, and `supportedBuild:false` in the smoke process only when `allowUnsupportedBuild:true` is supplied. Assert `UNSUPPORTED_BUILD` without that flag. Then call: + +```json +{"command":"readMemory","params":{"allowUnsupportedBuild":true,"ranges":[{"address":"0x...","length":16}]}} +``` + +Assert exact uppercase `bytesHex`. Add malformed-hex, mismatched-mask, excessive-range, and unknown-field tests. + +- [ ] **Step 2: Run protocol smoke and verify RED** + +Expected: `INVALID_REQUEST`/unknown command for `scanMemory` and `readMemory`. + +- [ ] **Step 3: Add strict JSON parsing helpers and command handlers** + +In `lua_host.cpp`, add `HexToBytes`, `BytesToHex`, strict unsigned-limit readers, and canonical address validation. Reject extra params by comparing keys against per-command allowlists. Both commands must reject unsupported builds unless `allowUnsupportedBuild` is exactly `true`. + +Return these stable shapes: + +```json +{"supportedBuild":true,"complete":true,"scannedBytes":123,"matches":[{"address":"0x...","regionBase":"0x...","regionSize":41943040,"protection":4,"contextAddress":"0x...","contextHex":"..."}]} +``` + +```json +{"supportedBuild":true,"ranges":[{"address":"0x...","length":16,"bytesHex":"..."}]} +``` + +Map module failures to `MEMORY_ACCESS_DENIED`, `SCAN_LIMIT_EXCEEDED`, and `TOO_MANY_MATCHES` without including region dumps in error details. + +- [ ] **Step 4: Advertise capabilities and link the module** + +Add `memoryScan` and `memoryRead` to `hello.capabilities`; add `host/memory_reader.cpp` to `cfb27_lua_host` in `native/CMakeLists.txt`. + +- [ ] **Step 5: Verify native targets** + +```powershell +cmake --build native/build-memory --config Release --target cfb27_lua_host cfb27_protocol_smoke +native\build-memory\Release\cfb27_protocol_smoke.exe native\build-memory\Release\cfb27_lua_host.dll +``` + +Expected: protocol and memory smoke targets exit `0`. + +- [ ] **Step 6: Document protocol commands and commit** + +Document exact request/response shapes and limits in `docs/protocol.md`. + +```powershell +git add -- native/host/lua_host.cpp native/CMakeLists.txt native/smoke/protocol_smoke.cpp docs/protocol.md +git commit -m "Expose bounded memory discovery protocol" +``` + +--- + +### Task 3: Add SDK validation and typed memory APIs + +**Files:** +- Modify: `packages/sdk/src/client.cjs` +- Modify: `packages/sdk/src/errors.cjs` +- Modify: `packages/sdk/test/client.test.cjs` +- Modify: `docs/getting-started.md` + +**Interfaces:** +- Produces `client.scanMemory(options)`. +- Produces `client.readMemory(options)`. + +- [ ] **Step 1: Write failing SDK contract tests** + +Use the existing fake pipe server to assert exact outbound commands and validate returned shapes. Required calls: + +```js +await client.scanMemory({ + patternHex: 'CFB27A1100A1B2C3D4E5F60718293A4B', + maskHex: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + maxMatches: 2, + contextBefore: 4, + contextAfter: 4, +}); +await client.readMemory({ ranges: [{ address: '0x7FF612340000', length: 16 }] }); +``` + +Assert local rejection before socket creation for noncanonical addresses, lowercase/odd/invalid hex, unequal pattern/mask lengths, unsupported keys, unsafe integers, and exceeded limits. Assert response rejection for numeric addresses, lowercase addresses, invalid byte lengths, missing `complete`, and oversized arrays. + +- [ ] **Step 2: Run the SDK test and verify RED** + +```powershell +node --test packages/sdk/test/client.test.cjs +``` + +Expected: failures because both client methods are absent. + +- [ ] **Step 3: Implement request and response validators** + +Add frozen local constants matching native limits. Clone inputs before sending. Normalize hex bytes to uppercase but require canonical address strings. Validate every host response field before returning it; throw `Cfb27HookError('INVALID_RESPONSE', ...)` on any mismatch. + +- [ ] **Step 4: Add stable error codes** + +Append `MEMORY_ACCESS_DENIED`, `SCAN_LIMIT_EXCEEDED`, and `TOO_MANY_MATCHES` to `ERROR_CODES`. + +- [ ] **Step 5: Verify SDK tests and commit** + +```powershell +node --test packages/sdk/test/client.test.cjs +npm run check +git add -- packages/sdk/src/client.cjs packages/sdk/src/errors.cjs packages/sdk/test/client.test.cjs docs/getting-started.md +git commit -m "Add typed memory discovery SDK" +``` + +--- + +### Task 4: Add registered structured telemetry + +**Files:** +- Create: `native/host/telemetry.h` +- Create: `native/host/telemetry.cpp` +- Create: `native/smoke/telemetry_smoke.cpp` +- Modify: `native/host/lua_host.cpp` +- Modify: `native/CMakeLists.txt` +- Modify: `native/smoke/protocol_smoke.cpp` +- Modify: `packages/sdk/src/client.cjs` +- Modify: `packages/sdk/test/client.test.cjs` +- Modify: `docs/lua-api.md` +- Modify: `docs/protocol.md` + +**Interfaces:** +- Produces `registerTelemetry { types: string[] }` protocol command. +- Produces `client.registerTelemetryTypes(types)`. +- Produces Lua `cfb.emit(type, payload)`. +- Produces `telemetry` capability. + +- [ ] **Step 1: Write failing native policy tests** + +In `telemetry_smoke.cpp`, assert registration rejects reserved/invalid/duplicate names and more than 16 types. Assert payload validation accepts nested scalar JSON within limits and rejects depth 5, 65 object keys, 129 array entries, 1025-byte strings, binary/address-like keys (`address`, `addressHex`, `regionBase`, `bytesHex`), nonfinite numbers, and serialized output above 16 KiB. + +- [ ] **Step 2: Define and implement telemetry policy** + +`telemetry.h` exports: + +```cpp +bool IsTelemetryTypeName(std::string_view type); +bool RegisterTelemetryTypes(const std::vector& types, std::string& error); +bool IsTelemetryTypeRegistered(std::string_view type); +bool ValidateTelemetryPayload(const Json& payload, std::string& error); +``` + +Use a mutex-protected session set. Registration is additive and idempotent for an identical name. Reject the reserved event names and address/raw-byte keys at every object depth. + +- [ ] **Step 3: Verify telemetry smoke GREEN** + +Add/link `cfb27_telemetry_smoke`; run it and expect `telemetry smoke passed`. + +- [ ] **Step 4: Add protocol registration and Lua conversion** + +Add `registerTelemetry` with only a `types` array param. Register `cfb.emit` in the existing `cfb` table. Convert Lua nil/boolean/finite number/string/table values recursively to JSON with cycle detection and the same depth/count/string limits; reject functions, userdata, threads, mixed array/object tables, sparse arrays, and non-string object keys. + +`cfb.emit(type, payload)` must verify the type was registered, validate the converted payload, call `AppendEvent(type, payload)`, and return `true`. It never writes to the file log. + +- [ ] **Step 5: Extend protocol and SDK tests** + +Register `probe.snapshot`, run a Lua script that emits `{sequence=1, stable=true}`, and assert exactly one cursor event. Assert an unregistered type returns `SCRIPT_ERROR`. Add SDK local/response validation for `registerTelemetryTypes`. + +- [ ] **Step 6: Verify and commit** + +```powershell +cmake --build native/build-memory --config Release --target cfb27_lua_host cfb27_protocol_smoke cfb27_telemetry_smoke +native\build-memory\Release\cfb27_telemetry_smoke.exe +native\build-memory\Release\cfb27_protocol_smoke.exe native\build-memory\Release\cfb27_lua_host.dll +node --test packages/sdk/test/client.test.cjs packages/sdk/test/logs.test.cjs +npm run check +git add -- native/host/telemetry.h native/host/telemetry.cpp native/smoke/telemetry_smoke.cpp native/host/lua_host.cpp native/CMakeLists.txt native/smoke/protocol_smoke.cpp packages/sdk/src/client.cjs packages/sdk/test/client.test.cjs docs/lua-api.md docs/protocol.md +git commit -m "Add registered structured telemetry" +``` + +--- + +### Task 5: Add developer CLI commands, release gates, and v0.2.0-dev.1 packaging + +**Files:** +- Modify: `packages/cli/src/args.cjs` +- Modify: `packages/cli/src/main.cjs` +- Modify: `packages/cli/test/main.test.cjs` +- Modify: `docs/cli.md` +- Modify: `docs/development/release-checklist.md` +- Modify: `docs/research/runtime-verification.md` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `packages/sdk/package.json` +- Modify: `packages/cli/package.json` + +**Interfaces:** +- Produces `cfb27lua memory scan` and `cfb27lua memory read` developer commands. +- Produces `cfb27lua telemetry register` developer command. +- Produces release `v0.2.0-dev.1` after manual runtime verification. + +- [ ] **Step 1: Write failing CLI tests** + +Assert parsing/output for: + +```text +cfb27lua memory scan --pattern CFB27A1100A1B2C3D4E5F60718293A4B --mask FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF --max-matches 8 --context 32 --json +cfb27lua memory read --range 0x7FF612340000:192 --json +cfb27lua telemetry register recruiting.snapshot recruiting.stability --json +``` + +Require `--allow-unsupported-build` for unsupported diagnostic reads. Reject write-like arguments and noncanonical address ranges. + +- [ ] **Step 2: Implement thin SDK-backed CLI handlers** + +Keep parsing in `args.cjs`; handlers in `main.cjs` call only the new SDK client methods. Human output reports counts and canonical addresses; `--json` returns the untouched validated SDK result. + +- [ ] **Step 3: Run the complete automated gate** + +```powershell +npm ci +npm run check +npm test +cmake -S native -B native/build-release -A x64 +cmake --build native/build-release --config Release +native\build-release\Release\cfb27_memory_reader_smoke.exe +native\build-release\Release\cfb27_telemetry_smoke.exe +native\build-release\Release\cfb27_protocol_smoke.exe native\build-release\Release\cfb27_lua_host.dll +npm run pack:preview +git diff --check +``` + +Expected: all commands exit `0`; packaged file lists contain no build directories, saves, schemas, or archives outside the documented release contents. + +- [ ] **Step 4: Perform the manual offline read-only gate** + +Install the candidate host while the game is closed, launch through MMC offline, and record: + +- `hello` advertises `memoryScan`, `memoryRead`, and `telemetry`; +- a bounded scan can find an intentionally allocated/session-known private sentinel; +- a bounded read returns its exact bytes; +- registered telemetry appears once with advancing cursor; +- no memory writes are attempted; +- the game remains responsive for ten minutes and through a Dynasty hub transition. + +Append the date, executable hash, commands, and observed results to `docs/research/runtime-verification.md`. + +- [ ] **Step 5: Bump versions only after the manual gate** + +Set root, SDK, CLI, and lockfile versions to `0.2.0-dev.1`. Re-run `npm ci`, tests, native release build, and package inspection. + +- [ ] **Step 6: Commit and publish a draft PR** + +```powershell +git add -- packages/cli/src/args.cjs packages/cli/src/main.cjs packages/cli/test/main.test.cjs docs/cli.md docs/development/release-checklist.md docs/research/runtime-verification.md package.json package-lock.json packages/sdk/package.json packages/cli/package.json +git commit -m "Prepare live discovery developer preview" +git push -u origin codex/live-memory-discovery-telemetry +``` + +Open a draft PR against `main`. After review and merge, tag `v0.2.0-dev.1`, run the release checklist, publish immutable SDK/CLI/archive assets, and verify their SHA-256 checksums before starting Hook PR B. diff --git a/docs/superpowers/specs/2026-07-11-cursor-paged-private-memory-scan-design.md b/docs/superpowers/specs/2026-07-11-cursor-paged-private-memory-scan-design.md new file mode 100644 index 0000000..e8abc2f --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-cursor-paged-private-memory-scan-design.md @@ -0,0 +1,161 @@ +# Cursor-Paged Private-Memory Scan Design + +**Date:** 2026-07-11 +**Status:** Approved for implementation +**Scope:** Correct the read-only private-memory discovery path exposed by the CFB27 Lua host, SDK, and developer CLI. + +## Problem + +The first live CFB27 runtime gate proved that the original whole-process scan +contract is not viable for a multi-gigabyte game process: + +- scanning proceeds from low to high virtual addresses and aborts before the + aggregate would exceed 512 MiB; +- only the first 64 MiB of a larger eligible region is inspected, while the + result can still report `complete:true`; +- an arbitrary live allocation after either boundary is therefore unreachable; +- a 512 MiB scan approaches or exceeds the SDK's three-second timeout; +- destroying the timed-out client socket does not cancel the synchronous native + scan, so later protocol requests wait for the scan to finish; +- internal scan buffers themselves create private allocations in the address + space being scanned. + +Increasing the timeout or changing address order would not correct deterministic +coverage. The scan must be resumable and must cover large region tails. + +## Goals + +- Cover every byte of eligible committed, readable `MEM_PRIVATE` memory without + one unbounded request. +- Keep each native request bounded in bytes, allocation size, and latency. +- Return explicit progress that the trusted SDK can resume without rescanning + earlier memory. +- Preserve patterns that cross native chunk and page boundaries. +- Never claim completion after a failed read or skipped eligible byte. +- Keep all operations read-only and all raw addresses inside trusted host, + SDK, CLI, or Electron main-process code. + +## Non-goals + +- Snapshot-atomic coverage while the game mutates its heaps. +- Cancellation of an already executing protocol request in this PR. +- Parallel or multi-core scanning. +- Memory writes, recruiting schemas, or renderer-facing memory APIs. +- A host-owned test-only sentinel that would conceal general scan limitations. + +## Native page contract + +`ScanRequest` gains an optional canonical continuation address named `cursor`. +When omitted, traversal begins at the system minimum application address. The +cursor identifies the first virtual byte not covered by preceding pages. + +One native request scans at most 32 MiB of eligible bytes. Native reads use a +dedicated 4 MiB `VirtualAlloc` buffer plus at most `patternLength - 1` bytes of +lookahead. The dedicated buffer's allocation is excluded from traversal and +does not consume the page budget. + +The scanner walks `VirtualQuery` regions from the supplied cursor. An eligible +region larger than the remaining page budget is split; the response cursor +lands inside that region and the next page resumes its tail. Ineligible regions +are skipped without consuming the eligible-byte budget. + +For each chunk, the scanner reads enough lookahead to detect a pattern beginning +before the unique page boundary and ending after it. Matches whose first byte is +at or beyond the next cursor are deferred to the next page. This prevents gaps +and duplicate boundary matches. + +The result contains: + +```json +{ + "supportedBuild": true, + "complete": false, + "nextCursor": "0x1F4A8000000", + "scannedBytes": 33554432, + "matches": [] +} +``` + +`nextCursor` is a canonical uppercase address string when `complete:false` and +is `null` when `complete:true`. `complete:true` means traversal reached the +maximum application address during this host process session. It does not mean +the game was paused or that all pages formed an atomic snapshot. + +An eligible-region query or read failure returns a stable error and no +`complete:true` result. The client may retry that page; the host never silently +skips a failed eligible range. Arithmetic overflow, noncanonical cursors, and a +cursor above the maximum application address are rejected. + +## Match limits + +`maxMatches` remains the caller's global maximum. A native page detects +`maxMatches + 1` within that page and returns `TOO_MANY_MATCHES` as before. + +The SDK convenience scan carries the remaining global allowance into each page +and rejects as soon as accumulated matches exceed the original maximum. It does +not silently truncate matches. A caller that needs only a validated first match +may use the page API and stop early; a caller claiming uniqueness must continue +until `complete:true` or `TOO_MANY_MATCHES`. + +## Protocol and SDK + +`scanMemory` becomes the native page operation and accepts optional `cursor`. +Every response must contain the exact `complete`, `nextCursor`, `scannedBytes`, +and `matches` shape. The existing build gate and all pattern, mask, context, and +match limits remain enforced. + +The SDK exposes two levels: + +- `client.scanMemoryPage(options)` validates one page request and response. +- `client.scanMemory(options)` repeatedly calls the page operation, aggregates + matches and scanned bytes, and returns only after completion, overflow, or a + caller-supplied `maxPages` ceiling from 1 through 4,096. + +The convenience method defaults to 4,096 pages, bounding eligible-byte work to +128 GiB. It rejects non-progressing or repeated cursors immediately as +`INVALID_RESPONSE`. It is bound to one PID; a game restart naturally invalidates +the pipe and continuation state. + +The CLI `memory scan` uses the convenience method. Human output may report page +progress without exposing it to an Electron renderer. JSON output returns the +validated aggregate SDK result. The CLI uses a scan-appropriate timeout for +each page; status, events, reads, and telemetry keep their shorter default. + +## Safety and consistency + +- The API remains read-only. +- Cursors and matches are strings, never JavaScript numbers. +- Raw addresses, patterns, contexts, and bytes remain prohibited from an + Electron renderer. +- Found candidates must be re-read and validated before later interpretation or + any future write transaction. +- A live memory-map change may invalidate a candidate between pages; downstream + calibration treats PID, host session, failed validation, and relevant game + transitions as invalidation events. +- The scanner does not pause the game or bypass anticheat. + +## Verification + +Native tests must demonstrate: + +1. a sentinel after offset 64 MiB in one large eligible region is found across + pages; +2. a sentinel after more than 512 MiB of earlier eligible memory is reachable; +3. a pattern crossing a 4 MiB chunk boundary and a 32 MiB page boundary is + returned exactly once; +4. cursors advance monotonically and terminate with `complete:true`; +5. internal scan storage does not appear as a match or consume page budget; +6. a failed eligible read cannot produce `complete:true`; +7. invalid, overflowing, repeated, and non-progressing cursors are rejected. + +Protocol and SDK tests must validate exact request/response shapes, hostile +numeric or noncanonical cursors, multi-page aggregation, global match limits, +page ceilings, and repeated-cursor rejection. CLI tests must prove automatic +pagination, scan-specific timeout selection, JSON aggregation, and continued +rejection of write-like arguments. + +The manual offline gate must be repeated with a fresh installed candidate. It +must find the deliberately allocated Lua sentinel, read back its exact 16 bytes, +emit registered telemetry exactly once with an advancing cursor, remain +responsive for ten minutes, and survive a Dynasty hub transition without any +memory write. diff --git a/docs/superpowers/specs/2026-07-11-live-recruiting-memory-bridge-design.md b/docs/superpowers/specs/2026-07-11-live-recruiting-memory-bridge-design.md new file mode 100644 index 0000000..a8dc557 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-live-recruiting-memory-bridge-design.md @@ -0,0 +1,347 @@ +# Live Recruiting Memory Bridge Design + +**Status:** Approved design + +**Date:** 2026-07-11 + +**Repositories:** + +- `eric-levinson/cfb27-lua-hook` +- `brooksg357-a11y/cfb27-dynasty-modding` + +## Purpose + +Build the smallest safe bridge from the existing CFB27 Lua host to verified +live Dynasty recruiting data. The work must first prove that recruiting records +can be discovered, validated, and relocated in the running game, then add one +bounded guarded write transaction and verify its behavior through the game UI, +weekly advance, autosave, reload, and recovery. + +This is the foundation for a later CPU recruiting governor. It is not the +governor itself. + +## Goals + +1. Discover recruiting records in committed readable private memory without + assuming save-file offsets equal live-memory offsets. +2. Batch-read candidate memory without performing one `VirtualQuery` call per + byte. +3. Emit bounded structured telemetry without treating log messages as a data + protocol. +4. Validate live `Recruit` and `RecruitTarget` candidates against multiple + independent save and UI values. +5. Relocate candidates after menu transitions, weekly advances, and game + restarts instead of persisting absolute addresses. +6. Add bounded guarded write transactions with complete expected-byte + validation, readback, and automatic rollback. +7. Prove one reversible influence edit, followed by one advance/autosave + persistence test. +8. Keep the Electron renderer isolated from addresses, raw memory primitives, + unrestricted Lua evaluation, and SDK internals. + +## Non-goals + +- Implementing league-wide parity, anti-hoarding, storyline, pitching, scouting, + RNG, or generator policies in this slice. +- Assuming arbitrary zero-interest school placement works. Initial tests use an + existing active CPU suitor. +- Allocating or retargeting recruiting rows, pitches, visits, references, or + arrays. +- Forcing commits or changing `RecruitStage` in the first write proof. +- Exposing heap scanning or write transactions through an Electron preload or + renderer. +- Supporting online play, bypassing anticheat, or writing on an unrecognized + executable build. +- Claiming a game-thread-atomic transaction. The host can validate and roll back + its own writes but cannot pause every game thread. + +## Repository ownership + +### `cfb27-lua-hook` + +The hook repository owns game-domain-neutral runtime facilities: + +- bounded readable-region discovery; +- bounded masked scanning across eligible regions; +- bounded batch reads; +- registered structured telemetry types and payload limits; +- guarded batch-write transactions; +- exact-build and offline safety gates; +- complete expected-byte validation, readback, and rollback; +- protocol, SDK, CLI, native smoke, and package documentation. + +The hook does not contain recruit, team, board, influence, or policy knowledge. + +### `cfb27-dynasty-modding` + +Brooks's repository owns recruiting-domain behavior: + +- read-only parsing of the selected Dynasty save; +- selection of calibration records with distinctive values; +- conversion of schema fields into candidate patterns and validation rules; +- classification of authoritative, cached, duplicate, and stale objects; +- session-relative discovery recipes; +- weekly-transition and stable-window detection; +- construction of domain-approved write requests in Electron main; +- UI, diagnostics, policies, and later governor behavior. + +The renderer never receives addresses, memory bytes, write operations, or the +unrestricted hook SDK. + +## Delivery sequence + +### Hook PR A: read-only discovery and telemetry + +Add region discovery, masked scanning, batch reads, and structured telemetry. +Publish a developer release after native, SDK, protocol, CLI, and package gates +pass. + +### Hook PR B: guarded write transactions + +Add bounded transactional write requests, complete preflight comparison, +readback, rollback, and stable error contracts. Publish a second developer +release after live reversible-write verification. + +### Brooks integration PR + +Update the pinned SDK, implement recruiting calibration, perform the reversible +influence proof, and document UI/advance/autosave/reload results. The existing +read-only connection PR remains independently mergeable and is the base +integration layer. + +Governor work begins only after all proof gates in this design pass. + +## Read-only host contract + +### Eligible memory regions + +Discovery considers only regions that are: + +- `MEM_COMMIT`; +- readable and not `PAGE_GUARD` or `PAGE_NOACCESS`; +- private memory unless an explicitly registered future use case adds another + type; +- within configured per-region and aggregate byte limits; +- inside the current CFB27 process. + +Requests must bound total regions, total scanned bytes, pattern length, result +count, surrounding-byte length, and encoded response size. Hitting a limit +returns a stable error rather than a partial result presented as complete. + +### Masked scanning + +A scan request contains a byte pattern plus a same-length mask. The host returns +only bounded candidate metadata: address, region base/size/protection, and the +requested bounded match context. It does not dump complete regions. + +The SDK treats addresses as opaque session values. They are never serialized to +application settings, save metadata, logs, renderer messages, or long-lived +caches. + +### Batch reads + +A batch read accepts bounded `{ address, length }` ranges. The host validates +each complete range before reading and returns one result per requested range. +No range may cross an unreadable region boundary. A failed range fails the whole +request so consumers cannot mistake an incomplete snapshot for a consistent +one. + +### Structured telemetry + +The host exposes a bounded custom telemetry operation separate from `cfb.log`. +Only registered event type names are accepted. Payloads use JSON-compatible +scalars and bounded objects/arrays, reject addresses and oversized strings, and +enter the existing cursor-paged event ring. Recruiting-specific event names are +registered by the consuming integration contract, not hardcoded into the memory +implementation. + +## Recruiting calibration + +The Electron main process opens the selected Dynasty save read-only. It selects +one recruit and one already-active CPU `RecruitTarget` pairing with distinctive, +visible, and independently checkable values. + +Candidate validation uses multiple fields. A single influence, stage, rank, or +identifier match is insufficient. Recruit validation should combine identity, +stage, score, ranks, and linked values. Target validation should combine its +recruit relationship, influence values, offer/action state, and board context. + +Calibration passes only when: + +1. the candidate set is uniquely identified or all duplicates are explicitly + classified; +2. decoded values agree with both the read-only save parse and visible game + state; +3. authoritative candidates can be relocated after leaving and re-entering the + relevant Dynasty views; +4. candidates can be relocated after a weekly advance; +5. stale or presentation-only copies are excluded; and +6. the entire calibration run performs no writes. + +The integration stores a session-relative discovery recipe made from patterns, +masks, validation fields, and relationships. It does not store addresses. Every +game launch, PID change, host-session change, failed validation, or relevant +allocation transition invalidates the current candidates and forces relocation. + +## Stable-window detection + +Lua callbacks run on the host thread, not the game's recruiting transaction +thread. A 100 ms tick does not by itself prove that an advance is finished. + +The Brooks integration observes the Dynasty week plus a small verified set of +recruiting counters and target values. A pending write becomes eligible only +after the week transition is observed and all watched values remain unchanged +for a configured number of consecutive samples. Any change resets the stability +counter. Candidate relocation and full expected-byte validation occur after the +stable window is established and immediately before the transaction. + +The exact watched fields and required consecutive sample count are empirical +outputs of the read-only calibration. They are not guessed in the hook. + +## Guarded write contract + +A write request contains: + +```text +transactionId +operations[]: + address + expectedBytes + replacementBytes +``` + +The host enforces bounded operation count, per-operation length, aggregate byte +count, nonempty equal-length byte arrays, and non-overlapping address ranges. + +Before the first write, the host: + +1. verifies the exact supported executable build; +2. verifies that real EA/Javelin anticheat is absent; +3. validates every complete range as committed and writable; +4. compares every complete live range with its expected bytes; and +5. captures every original byte in a rollback image. + +If any preflight check fails, zero bytes are written. After preflight, the host +applies operations in request order and reads every complete replacement range +back. If any application or verification step fails, it attempts to restore all +original ranges and verifies the restoration. The result distinguishes: + +- rejected before write; +- applied and verified; +- application failed but rollback verified; and +- rollback could not be verified. + +A rollback-verification failure disables further writes for that host session. +No failed transaction is automatically retried. + +This contract is atomic with respect to host validation and recovery, not with +respect to arbitrary game threads. Stable-window gating and minimal operation +size remain mandatory. + +## First live write proof + +The first write uses one existing active CPU suitor and one already-allocated, +verified influence field. It does not modify references, board membership, +offers, actions, stage, score, or commitment state. + +Proof sequence: + +1. Calibrate and capture the authoritative expected bytes. +2. Establish the stable window. +3. Change the influence by a small nearby amount. +4. Verify transaction readback and the visible game UI. +5. Restore the original value with a second guarded transaction. +6. Verify readback, UI restoration, game responsiveness, and that the restored + recruiting field is present in any newly written autosave. +7. Repeat with a second controlled value and advance one week. +8. Verify the post-advance UI and newly written autosave independently. +9. Reload the Dynasty and confirm the expected value or documented engine + transformation. +10. Restore from the known-good backup and verify recovery. + +Any mismatch disables writes for the session and produces a sanitized +diagnostic bundle. + +## Electron boundary + +Only Electron main imports `@cfb27/lua-hook`. The preload remains limited to +recruiting-domain methods such as connection state, calibration progress, and a +specific approved test action. It does not expose region discovery, batch reads, +addresses, raw bytes, arbitrary transactions, logs, `evaluate`, or `runScript`. + +Electron main validates that the selected save, current game session, calibrated +records, and requested recruiting relationship still match before constructing +a host transaction. Renderer input cannot choose addresses, expected bytes, +replacement bytes, or operation count. + +## Error handling + +Every layer fails closed: + +- unsupported builds may report diagnostics but cannot write; +- ambiguous or missing candidates prevent calibration; +- PID, host-session, week, or validation changes invalidate cached candidates; +- an unstable window cancels the pending transaction; +- any expected-byte mismatch produces zero writes; +- any readback failure triggers rollback; +- any rollback-verification failure disables session writes; +- no write failure is retried automatically; and +- renderer messages use stable public codes and locally defined text, not raw + native, SDK, path, memory, or Lua errors. + +## Verification strategy + +### Hook PR A gates + +- Native tests cover region protection/type filtering, boundary handling, + aggregate limits, masked matches, result truncation errors, and bounded + context reads. +- Protocol and SDK tests cover malformed ranges, excessive requests, fragmented + frames, invalid payload types, and response-size limits. +- Telemetry tests cover registered types, rejected unknown types, payload depth, + string/array/object limits, address-field rejection, cursor ordering, and ring + rollover. +- Native smoke proves discovery and batch reads against controlled private + allocations without touching the game. +- A manual offline game smoke exercises region enumeration and bounded reads + without interpreting recruiting fields or performing writes. + +### Hook PR B gates + +- Preflight mismatch tests prove zero-write behavior. +- Overlap, range, count, and aggregate-size tests prove request rejection. +- Injected application/readback failures prove full rollback. +- Injected rollback failure proves that session writes become disabled. +- Build and anticheat tests prove whole-transaction refusal. +- Existing Lua `write_u8` behavior and protocol v1 consumers remain compatible. +- Native smoke uses controlled memory to verify apply, readback, rollback, and + sanitized results. + +### Brooks integration gates + +- Save-to-live values match for the chosen recruit and target. +- Relocation succeeds after menu transitions and a weekly advance. +- Duplicate/cache classification is repeatable. +- Stable-window detection never reports eligible during the observed advance. +- The reversible influence edit appears and restores without a crash. +- The advance/autosave/reload proof produces the expected verified result. +- The original backup remains byte-identical and recoverable. +- Preload source and behavior tests prove no raw hook capability reaches the + renderer. + +## Completion criteria + +This design is complete when: + +- Hook PR A and its developer release provide verified bounded discovery, + batch-read, and telemetry contracts; +- Hook PR B and its developer release provide verified guarded transaction and + rollback contracts; +- Brooks's integration can locate and relocate one authoritative `Recruit` and + one authoritative `RecruitTarget` relationship; +- one reversible influence edit passes UI and restoration checks; +- one controlled edit passes advance, autosave, reload, and recovery checks; +- no raw address, memory primitive, Lua execution, or unrestricted transaction + reaches the renderer; and +- the next governor design can rely on measured live layout and timing evidence + rather than save-layout assumptions. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 5e0cca2..8e63218 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -45,7 +45,9 @@ set_target_properties(lua54 PROPERTIES POSITION_INDEPENDENT_CODE ON) add_library(cfb27_lua_host SHARED host/lua_host.cpp + host/memory_reader.cpp host/protocol.cpp + host/telemetry.cpp ) target_compile_features(cfb27_lua_host PRIVATE cxx_std_20) target_compile_definitions(cfb27_lua_host PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) @@ -68,3 +70,20 @@ target_compile_features(cfb27_protocol_smoke PRIVATE cxx_std_20) target_compile_definitions(cfb27_protocol_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) target_link_libraries(cfb27_protocol_smoke PRIVATE nlohmann_json::nlohmann_json) target_link_options(cfb27_protocol_smoke PRIVATE /STACK:1048576) + +add_executable(cfb27_memory_reader_smoke + smoke/memory_reader_smoke.cpp + host/memory_reader.cpp +) +target_compile_features(cfb27_memory_reader_smoke PRIVATE cxx_std_20) +target_compile_definitions(cfb27_memory_reader_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_options(cfb27_memory_reader_smoke PRIVATE /STACK:1048576) + +add_executable(cfb27_telemetry_smoke + smoke/telemetry_smoke.cpp + host/telemetry.cpp +) +target_compile_features(cfb27_telemetry_smoke PRIVATE cxx_std_20) +target_compile_definitions(cfb27_telemetry_smoke PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +target_link_libraries(cfb27_telemetry_smoke PRIVATE nlohmann_json::nlohmann_json) +target_link_options(cfb27_telemetry_smoke PRIVATE /STACK:1048576) diff --git a/native/host/lua_host.cpp b/native/host/lua_host.cpp index 64d1ebf..3bc6867 100644 --- a/native/host/lua_host.cpp +++ b/native/host/lua_host.cpp @@ -2,23 +2,30 @@ #include #include +#include "memory_reader.h" #include "protocol.h" +#include "telemetry.h" #include #include #include #include +#include #include #include +#include #include #include #include +#include +#include #include #include #include #include #include #include +#include #include extern "C" { @@ -31,7 +38,7 @@ namespace { constexpr wchar_t kPipePrefix[] = L"\\\\.\\pipe\\CFB27LuaHost."; constexpr wchar_t kV1PipePrefix[] = L"\\\\.\\pipe\\CFB27LuaHost.v1."; -constexpr char kHostVersion[] = "0.1.0-dev.1"; +constexpr char kHostVersion[] = "0.2.0-dev.1"; constexpr std::uintmax_t kSupportedExecutableSize = 247845776; constexpr char kSupportedExecutableSha256[] = "9E654AD49C4702D8F9FA4E38FD1110ABE657DD38926D4124B30C70E7D29ADFE8"; constexpr DWORD kTickMilliseconds = 100; @@ -319,6 +326,228 @@ int LuaLog(lua_State* state) { return 0; } +bool AddLuaTelemetryBytes(std::size_t bytes, std::size_t& total, std::string& error) { + constexpr std::size_t kMaxSerializedBytes = 16 * 1024; + if (bytes > kMaxSerializedBytes - total) { + error = "serialized telemetry payload must not exceed 16 KiB"; + return false; + } + total += bytes; + return true; +} + +bool AddLuaTelemetryValueBytes(const cfb27::protocol::Json& value, + std::size_t& total, std::string& error) { + try { + return AddLuaTelemetryBytes(value.dump().size(), total, error); + } catch (const std::exception&) { + error = "telemetry strings must contain valid UTF-8"; + return false; + } +} + +bool IsForbiddenTelemetryKey(std::string_view key) { + return key == "address" || key == "addressHex" || key == "regionBase" || + key == "bytesHex" || key == "contextAddress" || key == "contextHex"; +} + +bool LuaToTelemetryJson(lua_State* state, int index, std::size_t depth, + std::unordered_set& visiting, + std::size_t& serialized_bytes, + cfb27::protocol::Json& output, std::string& error) { + using Json = cfb27::protocol::Json; + constexpr std::size_t kMaxDepth = 4; + constexpr std::size_t kMaxObjectKeys = 64; + constexpr std::size_t kMaxArrayEntries = 128; + constexpr std::size_t kMaxStringBytes = 1024; + + index = lua_absindex(state, index); + switch (lua_type(state, index)) { + case LUA_TNIL: + output = nullptr; + return AddLuaTelemetryValueBytes(output, serialized_bytes, error); + case LUA_TBOOLEAN: + output = lua_toboolean(state, index) != 0; + return AddLuaTelemetryValueBytes(output, serialized_bytes, error); + case LUA_TNUMBER: + if (lua_isinteger(state, index)) { + output = lua_tointeger(state, index); + return AddLuaTelemetryValueBytes(output, serialized_bytes, error); + } else { + const auto value = lua_tonumber(state, index); + if (!std::isfinite(value)) { + error = "telemetry numbers must be finite"; + return false; + } + output = value; + return AddLuaTelemetryValueBytes(output, serialized_bytes, error); + } + case LUA_TSTRING: { + std::size_t length = 0; + const char* value = lua_tolstring(state, index, &length); + if (length > kMaxStringBytes) { + error = "telemetry strings must not exceed 1024 bytes"; + return false; + } + output = std::string(value, length); + return AddLuaTelemetryValueBytes(output, serialized_bytes, error); + } + case LUA_TTABLE: + break; + default: + error = "telemetry payload contains an unsupported Lua value"; + return false; + } + + if (depth > kMaxDepth) { + error = "telemetry payload depth must not exceed 4"; + return false; + } + const void* identity = lua_topointer(state, index); + if (!visiting.insert(identity).second) { + error = "telemetry payload contains a table cycle"; + return false; + } + if (!AddLuaTelemetryBytes(2, serialized_bytes, error)) { + visiting.erase(identity); + return false; + } + + bool saw_array_key = false; + bool saw_object_key = false; + std::vector> array_items; + Json object = Json::object(); + bool valid = true; + lua_pushnil(state); + while (lua_next(state, index) != 0) { + if (lua_type(state, -2) == LUA_TNUMBER && lua_isinteger(state, -2)) { + if (saw_object_key) { + error = "telemetry tables must not mix array and object keys"; + lua_pop(state, 1); + valid = false; + break; + } + saw_array_key = true; + const auto key = lua_tointeger(state, -2); + if (key < 1 || static_cast(key) > kMaxArrayEntries) { + error = "telemetry arrays must be dense and contain at most 128 entries"; + lua_pop(state, 1); + valid = false; + break; + } + if (!array_items.empty() && !AddLuaTelemetryBytes(1, serialized_bytes, error)) { + lua_pop(state, 1); + valid = false; + break; + } + } else if (lua_type(state, -2) == LUA_TSTRING) { + if (saw_array_key) { + error = "telemetry tables must not mix array and object keys"; + lua_pop(state, 1); + valid = false; + break; + } + saw_object_key = true; + std::size_t key_length = 0; + const char* key = lua_tolstring(state, -2, &key_length); + if (key_length > kMaxStringBytes || object.size() >= kMaxObjectKeys) { + error = "telemetry objects must contain at most 64 bounded string keys"; + lua_pop(state, 1); + valid = false; + break; + } + const std::string key_text(key, key_length); + if (IsForbiddenTelemetryKey(key_text)) { + error = "telemetry payloads must not contain address or raw-byte keys"; + lua_pop(state, 1); + valid = false; + break; + } + Json encoded_key = key_text; + const std::size_t punctuation = object.empty() ? 1 : 2; + if (!AddLuaTelemetryValueBytes(encoded_key, serialized_bytes, error) || + !AddLuaTelemetryBytes(punctuation, serialized_bytes, error)) { + lua_pop(state, 1); + valid = false; + break; + } + } else { + error = "telemetry object keys must be strings"; + lua_pop(state, 1); + valid = false; + break; + } + Json item; + if (!LuaToTelemetryJson(state, -1, depth + 1, visiting, serialized_bytes, + item, error)) { + lua_pop(state, 1); + valid = false; + break; + } + if (saw_array_key) { + array_items.emplace_back( + static_cast(lua_tointeger(state, -2)), std::move(item)); + } else { + std::size_t key_length = 0; + const char* key = lua_tolstring(state, -2, &key_length); + object[std::string(key, key_length)] = std::move(item); + } + lua_pop(state, 1); + } + if (!valid) { + lua_settop(state, lua_gettop(state) - 1); + visiting.erase(identity); + return false; + } + + if (saw_array_key) { + if (array_items.size() > kMaxArrayEntries) { + error = "telemetry arrays must not exceed 128 entries"; + visiting.erase(identity); + return false; + } + std::sort(array_items.begin(), array_items.end(), + [](const auto& left, const auto& right) { return left.first < right.first; }); + output = Json::array(); + for (std::size_t item_index = 0; item_index < array_items.size(); ++item_index) { + if (array_items[item_index].first != item_index + 1) { + error = "telemetry arrays must not be sparse"; + visiting.erase(identity); + return false; + } + output.push_back(std::move(array_items[item_index].second)); + } + } else { + output = std::move(object); + } + visiting.erase(identity); + return true; +} + +int LuaEmit(lua_State* state) { + if (lua_gettop(state) != 2 || lua_type(state, 1) != LUA_TSTRING) { + return luaL_error(state, "cfb.emit requires a telemetry type and payload"); + } + std::size_t type_length = 0; + const char* type_value = lua_tolstring(state, 1, &type_length); + const std::string type(type_value, type_length); + if (!cfb27::telemetry::IsTelemetryTypeRegistered(type)) { + return luaL_error(state, "telemetry type is not registered"); + } + + cfb27::protocol::Json payload; + std::string error; + std::unordered_set visiting; + std::size_t serialized_bytes = 0; + if (!LuaToTelemetryJson(state, 2, 1, visiting, serialized_bytes, payload, error) || + !cfb27::telemetry::ValidateTelemetryPayload(payload, error)) { + return luaL_error(state, "%s", error.c_str()); + } + AppendEvent(type, std::move(payload)); + lua_pushboolean(state, 1); + return 1; +} + int LuaOn(lua_State* state) { const std::string event = luaL_checkstring(state, 1); luaL_checktype(state, 2, LUA_TFUNCTION); @@ -350,6 +579,7 @@ void RegisterApi(lua_State* state) { lua_pushcfunction(state, LuaWriteU8); lua_setfield(state, -2, "write_u8"); lua_pushcfunction(state, LuaAobScan); lua_setfield(state, -2, "aob_scan"); lua_pushcfunction(state, LuaLog); lua_setfield(state, -2, "log"); + lua_pushcfunction(state, LuaEmit); lua_setfield(state, -2, "emit"); lua_pushcfunction(state, LuaOn); lua_setfield(state, -2, "on"); lua_setglobal(state, "cfb"); } @@ -423,6 +653,108 @@ std::optional ReadLimit( return std::nullopt; } +bool HasOnlyKeys(const cfb27::protocol::Json& value, + std::initializer_list allowed) { + for (const auto& [key, unused] : value.items()) { + if (std::find(allowed.begin(), allowed.end(), key) == allowed.end()) return false; + } + return true; +} + +std::optional ReadUnsigned( + const cfb27::protocol::Json& params, std::string_view key, + std::size_t minimum, std::size_t maximum) { + const auto found = params.find(std::string(key)); + if (found == params.end()) return std::nullopt; + std::uint64_t value = 0; + if (found->is_number_unsigned()) { + value = found->get(); + } else if (found->is_number_integer()) { + const auto signed_value = found->get(); + if (signed_value < 0) return std::nullopt; + value = static_cast(signed_value); + } else { + return std::nullopt; + } + if (value < minimum || value > maximum || + value > std::numeric_limits::max()) return std::nullopt; + return static_cast(value); +} + +std::optional> HexToBytes( + const cfb27::protocol::Json& value) { + if (!value.is_string()) return std::nullopt; + const auto& text = value.get_ref(); + if (text.empty() || text.size() % 2 != 0) return std::nullopt; + auto nibble = [](char character) -> std::optional { + if (character >= '0' && character <= '9') { + return static_cast(character - '0'); + } + if (character >= 'A' && character <= 'F') { + return static_cast(character - 'A' + 10); + } + return std::nullopt; + }; + std::vector bytes; + bytes.reserve(text.size() / 2); + for (std::size_t index = 0; index < text.size(); index += 2) { + const auto high = nibble(text[index]); + const auto low = nibble(text[index + 1]); + if (!high || !low) return std::nullopt; + bytes.push_back(static_cast((*high << 4) | *low)); + } + return bytes; +} + +std::string BytesToHex(const std::vector& bytes) { + constexpr char digits[] = "0123456789ABCDEF"; + std::string encoded(bytes.size() * 2, '0'); + for (std::size_t index = 0; index < bytes.size(); ++index) { + encoded[index * 2] = digits[bytes[index] >> 4]; + encoded[index * 2 + 1] = digits[bytes[index] & 0x0F]; + } + return encoded; +} + +std::string FormatCanonicalAddress(std::uintptr_t address) { + char digits[sizeof(address) * 2]{}; + const auto [end, error] = std::to_chars( + std::begin(digits), std::end(digits), address, 16); + if (error != std::errc{}) return {}; + std::string result("0x"); + result.reserve(2 + static_cast(end - digits)); + for (auto current = digits; current != end; ++current) { + result.push_back(*current >= 'a' && *current <= 'f' + ? static_cast(*current - 'a' + 'A') + : *current); + } + return result; +} + +std::optional CanonicalAddress(std::string_view text) { + if (text.size() < 3 || text[0] != '0' || text[1] != 'x') return std::nullopt; + const auto parsed = cfb27::memory::ParseAddress(text); + if (!parsed) return std::nullopt; + const auto formatted = FormatCanonicalAddress(*parsed); + if (formatted != text) return std::nullopt; + return formatted; +} + +cfb27::protocol::Json MemoryError( + const std::string& id, std::string_view code) { + using cfb27::protocol::ErrorResponse; + if (code == "MEMORY_ACCESS_DENIED") { + return ErrorResponse(id, "MEMORY_ACCESS_DENIED", "Requested memory is not readable"); + } + if (code == "SCAN_LIMIT_EXCEEDED") { + return ErrorResponse(id, "SCAN_LIMIT_EXCEEDED", "Memory scan byte limit exceeded"); + } + if (code == "TOO_MANY_MATCHES") { + return ErrorResponse(id, "TOO_MANY_MATCHES", "Memory scan found too many matches"); + } + return ErrorResponse(id, "INVALID_REQUEST", "Invalid memory request"); +} + cfb27::protocol::Json LogsResult(std::size_t limit) { cfb27::protocol::Json logs = cfb27::protocol::Json::array(); std::lock_guard lock(g_event_mutex); @@ -483,7 +815,8 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { {"hostVersion", kHostVersion}, {"supportedBuild", supported}, {"writesAllowed", writes_allowed}, - {"capabilities", {"status", "runScript", "evaluate", "logs", "events"}}, + {"capabilities", {"status", "runScript", "evaluate", "logs", "events", + "memoryScan", "memoryRead", "telemetry"}}, }); } @@ -503,6 +836,163 @@ cfb27::protocol::Json HandleV1Request(const cfb27::protocol::Json& request) { }); } + if (command == "registerTelemetry") { + if (!HasOnlyKeys(params, {"types"}) || !params.contains("types") || + !params["types"].is_array() || params["types"].empty() || + params["types"].size() > 16) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid registerTelemetry params"); + } + std::vector types; + types.reserve(params["types"].size()); + for (const auto& type : params["types"]) { + if (!type.is_string()) { + return ErrorResponse(id, "INVALID_REQUEST", "Telemetry types must be strings"); + } + types.push_back(type.get()); + } + std::string error; + if (!cfb27::telemetry::RegisterTelemetryTypes(types, error)) { + return ErrorResponse(id, "INVALID_REQUEST", std::move(error)); + } + return SuccessResponse(id, {{"types", std::move(types)}}); + } + + if (command == "scanMemory") { + if (params.contains("allowUnsupportedBuild") && + !params["allowUnsupportedBuild"].is_boolean()) { + return ErrorResponse(id, "INVALID_REQUEST", + "allowUnsupportedBuild must be a boolean"); + } + const bool allow_unsupported = params.contains("allowUnsupportedBuild") && + params["allowUnsupportedBuild"].get(); + if (!supported && !allow_unsupported) { + return ErrorResponse(id, "UNSUPPORTED_BUILD", + "Memory scanning requires a supported build or explicit override"); + } + if (!HasOnlyKeys(params, {"patternHex", "maskHex", "maxMatches", "contextBefore", + "contextAfter", "allowUnsupportedBuild", "cursor"}) || + !params.contains("patternHex") || !params.contains("maskHex")) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid scanMemory params"); + } + auto pattern = HexToBytes(params["patternHex"]); + auto mask = HexToBytes(params["maskHex"]); + const auto max_matches = ReadUnsigned( + params, "maxMatches", 1, cfb27::memory::kMaxMatches); + const auto context_before = ReadUnsigned( + params, "contextBefore", 0, cfb27::memory::kMaxContextBytes); + const auto context_after = ReadUnsigned( + params, "contextAfter", 0, cfb27::memory::kMaxContextBytes); + if (!pattern || pattern->size() < cfb27::memory::kMinPatternBytes || + pattern->size() > cfb27::memory::kMaxPatternBytes || !mask || + mask->size() != pattern->size() || !max_matches || !context_before || + !context_after || *context_before > cfb27::memory::kMaxContextBytes - *context_after) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid scanMemory params"); + } + + std::optional cursor; + if (params.contains("cursor")) { + if (!params["cursor"].is_string()) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid scanMemory params"); + } + cursor = CanonicalAddress(params["cursor"].get_ref()); + if (!cursor) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid scanMemory params"); + } + } + + const auto scan = cfb27::memory::ScanPrivateMemory({ + .pattern = std::move(*pattern), + .mask = std::move(*mask), + .max_matches = *max_matches, + .context_before = *context_before, + .context_after = *context_after, + .cursor = std::move(cursor), + }); + if (!scan.code.empty()) return MemoryError(id, scan.code); + + Json matches = Json::array(); + for (const auto& match : scan.matches) { + const auto address = cfb27::memory::ParseAddress(match.address); + const auto region_base = cfb27::memory::ParseAddress(match.region_base); + const auto context_address = cfb27::memory::ParseAddress(match.context_address); + if (!address || !region_base || !context_address) { + return ErrorResponse(id, "MEMORY_ACCESS_DENIED", "Memory scan returned an invalid address"); + } + matches.push_back({ + {"address", FormatCanonicalAddress(*address)}, + {"regionBase", FormatCanonicalAddress(*region_base)}, + {"regionSize", match.region_size}, + {"protection", match.protection}, + {"contextAddress", FormatCanonicalAddress(*context_address)}, + {"contextHex", BytesToHex(match.context)}, + }); + } + return SuccessResponse(id, { + {"supportedBuild", supported}, + {"complete", scan.complete}, + {"nextCursor", scan.next_cursor ? Json(*scan.next_cursor) : Json(nullptr)}, + {"scannedBytes", scan.scanned_bytes}, + {"matches", std::move(matches)}, + }); + } + + if (command == "readMemory") { + if (params.contains("allowUnsupportedBuild") && + !params["allowUnsupportedBuild"].is_boolean()) { + return ErrorResponse(id, "INVALID_REQUEST", + "allowUnsupportedBuild must be a boolean"); + } + const bool allow_unsupported = params.contains("allowUnsupportedBuild") && + params["allowUnsupportedBuild"].get(); + if (!supported && !allow_unsupported) { + return ErrorResponse(id, "UNSUPPORTED_BUILD", + "Memory reads require a supported build or explicit override"); + } + if (!HasOnlyKeys(params, {"ranges", "allowUnsupportedBuild"}) || + !params.contains("ranges") || !params["ranges"].is_array() || + params["ranges"].empty() || + params["ranges"].size() > cfb27::memory::kMaxReadRanges) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid readMemory params"); + } + + std::vector ranges; + ranges.reserve(params["ranges"].size()); + std::size_t total_bytes = 0; + for (const auto& range : params["ranges"]) { + if (!range.is_object() || !HasOnlyKeys(range, {"address", "length"}) || + !range.contains("address") || !range["address"].is_string()) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid readMemory range"); + } + const auto address = CanonicalAddress(range["address"].get_ref()); + const auto length = ReadUnsigned( + range, "length", 1, cfb27::memory::kMaxReadRangeBytes); + if (!address || !length || total_bytes > cfb27::memory::kMaxReadBytes - *length) { + return ErrorResponse(id, "INVALID_REQUEST", "Invalid readMemory range"); + } + total_bytes += *length; + ranges.push_back({*address, *length}); + } + + const auto read = cfb27::memory::ReadMemoryBatch(ranges); + if (!read.ok) return MemoryError(id, read.code); + Json results = Json::array(); + for (const auto& range : read.ranges) { + const auto address = cfb27::memory::ParseAddress(range.address); + if (!address) { + return ErrorResponse(id, "MEMORY_ACCESS_DENIED", "Memory read returned an invalid address"); + } + results.push_back({ + {"address", FormatCanonicalAddress(*address)}, + {"length", range.bytes.size()}, + {"bytesHex", BytesToHex(range.bytes)}, + }); + } + return SuccessResponse(id, { + {"supportedBuild", supported}, + {"ranges", std::move(results)}, + }); + } + if (command == "logs") { const auto limit = ReadLimit(params, 100); if (!limit) return ErrorResponse(id, "INVALID_REQUEST", "limit must be an integer from 1 to 256"); diff --git a/native/host/memory_reader.cpp b/native/host/memory_reader.cpp new file mode 100644 index 0000000..dde9555 --- /dev/null +++ b/native/host/memory_reader.cpp @@ -0,0 +1,331 @@ +#include "memory_reader.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace cfb27::memory { +namespace { + +constexpr char kInvalidRequest[] = "INVALID_REQUEST"; +constexpr char kMemoryAccessDenied[] = "MEMORY_ACCESS_DENIED"; + +bool AddOverflows(std::uintptr_t left, std::size_t right) { + return right > std::numeric_limits::max() - left; +} + +bool SizeAddOverflows(std::size_t left, std::size_t right) { + return right > std::numeric_limits::max() - left; +} + +bool IsReadableProtection(DWORD protection) { + if ((protection & (PAGE_GUARD | PAGE_NOACCESS)) != 0) return false; + switch (protection & 0xFF) { + case PAGE_READONLY: + case PAGE_READWRITE: + case PAGE_WRITECOPY: + case PAGE_EXECUTE_READ: + case PAGE_EXECUTE_READWRITE: + case PAGE_EXECUTE_WRITECOPY: + return true; + default: + return false; + } +} + +struct ValidatedRead { + std::uintptr_t address{}; + std::size_t length{}; + std::string formatted_address; +}; + +bool IsWithinOneEligibleRegion(std::uintptr_t address, std::size_t length) { + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(address), &info, sizeof(info)) != sizeof(info) || + !IsEligiblePrivateReadableRegion(info)) { + return false; + } + const auto base = reinterpret_cast(info.BaseAddress); + if (address < base || AddOverflows(base, info.RegionSize) || AddOverflows(address, length)) { + return false; + } + return address + length <= base + info.RegionSize; +} + +bool PatternMatches(const std::uint8_t* bytes, const ScanRequest& request) { + for (std::size_t i = 0; i < request.pattern.size(); ++i) { + if ((bytes[i] & request.mask[i]) != (request.pattern[i] & request.mask[i])) return false; + } + return true; +} + +bool OverlapsRange(std::uintptr_t candidate, std::size_t candidate_length, + const void* excluded_data, std::size_t excluded_length) { + if (excluded_data == nullptr || excluded_length == 0) return false; + const auto excluded = reinterpret_cast(excluded_data); + if (AddOverflows(candidate, candidate_length) || AddOverflows(excluded, excluded_length)) { + return false; + } + return candidate < excluded + excluded_length && excluded < candidate + candidate_length; +} + +bool OverlapsMatchContext(std::uintptr_t candidate, std::size_t candidate_length, + const ScanResult& result) { + for (const auto& match : result.matches) { + if (OverlapsRange(candidate, candidate_length, match.context.data(), + match.context.capacity())) { + return true; + } + } + return false; +} + +bool ProductionRead(const void* source, void* destination, std::size_t length, + std::size_t& copied) { + SIZE_T process_copied = 0; + const bool ok = ReadProcessMemory(GetCurrentProcess(), source, destination, length, + &process_copied) != FALSE; + copied = static_cast(process_copied); + return ok; +} + +struct VirtualFreeDeleter { + void operator()(std::uint8_t* allocation) const { + if (allocation != nullptr) VirtualFree(allocation, 0, MEM_RELEASE); + } +}; + +} // namespace + +std::optional ParseAddress(std::string_view text) { + if (text.size() >= 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X')) { + text.remove_prefix(2); + } + if (text.empty()) return std::nullopt; + + std::uintptr_t address{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), address, 16); + if (error != std::errc{} || end != text.data() + text.size()) return std::nullopt; + return address; +} + +std::string FormatAddress(std::uintptr_t address) { + char digits[sizeof(std::uintptr_t) * 2]{}; + const auto [end, error] = std::to_chars(std::begin(digits), std::end(digits), address, 16); + if (error != std::errc{}) return {}; + std::string formatted("0x"); + formatted.append(digits, end); + std::transform(formatted.begin() + 2, formatted.end(), formatted.begin() + 2, + [](unsigned char character) { + return static_cast(std::toupper(character)); + }); + return formatted; +} + +bool IsEligiblePrivateReadableRegion(const MEMORY_BASIC_INFORMATION& info) { + return info.State == MEM_COMMIT && info.Type == MEM_PRIVATE && info.RegionSize != 0 && + IsReadableProtection(info.Protect); +} + +BatchReadResult ReadMemoryBatch(const std::vector& ranges) { + BatchReadResult result; + if (ranges.empty() || ranges.size() > kMaxReadRanges) { + result.code = kInvalidRequest; + return result; + } + + std::vector validated; + validated.reserve(ranges.size()); + std::size_t total_bytes = 0; + for (const auto& range : ranges) { + const auto address = ParseAddress(range.address); + if (!address || range.length == 0 || range.length > kMaxReadRangeBytes || + SizeAddOverflows(total_bytes, range.length) || + total_bytes + range.length > kMaxReadBytes) { + result.code = kInvalidRequest; + return result; + } + if (AddOverflows(*address, range.length) || + !IsWithinOneEligibleRegion(*address, range.length)) { + result.code = kMemoryAccessDenied; + return result; + } + total_bytes += range.length; + validated.push_back({*address, range.length, FormatAddress(*address)}); + } + + std::vector read_results; + read_results.reserve(validated.size()); + for (const auto& range : validated) { + ReadResult read{range.formatted_address, std::vector(range.length)}; + SIZE_T copied = 0; + if (!ReadProcessMemory(GetCurrentProcess(), reinterpret_cast(range.address), + read.bytes.data(), read.bytes.size(), &copied) || + copied != read.bytes.size()) { + result.code = kMemoryAccessDenied; + return result; + } + read_results.push_back(std::move(read)); + } + + result.ok = true; + result.ranges = std::move(read_results); + return result; +} + +ScanResult ScanPrivateMemory(const ScanRequest& request, ScanReadFunction read) { + ScanResult result; + if (request.pattern.size() < kMinPatternBytes || + request.pattern.size() > kMaxPatternBytes || + request.mask.size() != request.pattern.size() || request.max_matches == 0 || + request.max_matches > kMaxMatches || + SizeAddOverflows(request.context_before, request.context_after) || + request.context_before + request.context_after > kMaxContextBytes) { + result.code = kInvalidRequest; + return result; + } + + SYSTEM_INFO system_info{}; + GetSystemInfo(&system_info); + const auto minimum = + reinterpret_cast(system_info.lpMinimumApplicationAddress); + const auto maximum = reinterpret_cast(system_info.lpMaximumApplicationAddress); + auto cursor = minimum; + if (request.cursor) { + const auto parsed = ParseAddress(*request.cursor); + if (!parsed || FormatAddress(*parsed) != *request.cursor || *parsed < minimum || + *parsed > maximum) { + result.code = kInvalidRequest; + return result; + } + cursor = *parsed; + } + + const auto buffer_capacity = kScanChunkBytes + request.pattern.size() - 1; + std::unique_ptr scan_buffer( + static_cast(VirtualAlloc(nullptr, buffer_capacity, + MEM_RESERVE | MEM_COMMIT, + PAGE_READWRITE))); + if (!scan_buffer) { + result.code = kMemoryAccessDenied; + return result; + } + const auto buffer_begin = reinterpret_cast(scan_buffer.get()); + const auto buffer_end = buffer_begin + buffer_capacity; + const auto read_chunk = read != nullptr ? read : ProductionRead; + result.matches.reserve(request.max_matches + 1); + + while (cursor <= maximum) { + MEMORY_BASIC_INFORMATION info{}; + if (VirtualQuery(reinterpret_cast(cursor), &info, sizeof(info)) != sizeof(info)) { + result.code = kMemoryAccessDenied; + return result; + } + + const auto region_base = reinterpret_cast(info.BaseAddress); + if (AddOverflows(region_base, info.RegionSize)) { + result.code = kInvalidRequest; + return result; + } + const auto region_end = region_base + info.RegionSize; + if (region_end <= cursor) { + result.code = kInvalidRequest; + return result; + } + + const bool is_scan_buffer = + cursor < buffer_end && buffer_begin < region_end; + if (!IsEligiblePrivateReadableRegion(info) || is_scan_buffer) { + if (region_end > maximum) { + result.complete = true; + return result; + } + cursor = region_end; + continue; + } + + const auto remaining_budget = kMaxScanPageBytes - result.scanned_bytes; + if (remaining_budget == 0) { + result.next_cursor = FormatAddress(cursor); + return result; + } + const auto unique_bytes = std::min({ + region_end - cursor, + static_cast(kScanChunkBytes), + static_cast(remaining_budget), + }); + const auto after_unique = cursor + unique_bytes; + const auto lookahead = std::min( + request.pattern.size() - 1, region_end - after_unique); + const auto read_bytes = static_cast(unique_bytes + lookahead); + std::size_t copied = 0; + if (!read_chunk(reinterpret_cast(cursor), scan_buffer.get(), read_bytes, + copied) || copied != read_bytes) { + result.code = kMemoryAccessDenied; + return result; + } + + if (read_bytes >= request.pattern.size()) { + const auto last_offset = read_bytes - request.pattern.size(); + for (std::size_t offset = 0; offset <= last_offset; ++offset) { + const auto match_address = cursor + offset; + if (match_address >= after_unique) break; + if (OverlapsRange(match_address, request.pattern.size(), request.pattern.data(), + request.pattern.size()) || + OverlapsRange(match_address, request.pattern.size(), request.mask.data(), + request.mask.size()) || + OverlapsMatchContext(match_address, request.pattern.size(), result) || + !PatternMatches(scan_buffer.get() + offset, request)) { + continue; + } + + const auto context_start = offset > request.context_before + ? offset - request.context_before + : std::size_t{0}; + const auto after_start = offset + request.pattern.size(); + const auto context_end = request.context_after > read_bytes - after_start + ? read_bytes + : after_start + request.context_after; + ScanMatch match; + match.address = FormatAddress(match_address); + match.region_base = FormatAddress(region_base); + match.region_size = info.RegionSize; + match.protection = info.Protect; + match.context_address = FormatAddress(cursor + context_start); + match.context.assign(scan_buffer.get() + context_start, + scan_buffer.get() + context_end); + result.matches.push_back(std::move(match)); + + if (result.matches.size() > request.max_matches) { + result.code = "TOO_MANY_MATCHES"; + return result; + } + } + } + + result.scanned_bytes += static_cast(unique_bytes); + cursor = after_unique; + switch (detail::ClassifyScanPageBoundary(cursor, maximum, + result.scanned_bytes)) { + case detail::ScanPageBoundary::kComplete: + result.complete = true; + return result; + case detail::ScanPageBoundary::kIncomplete: + result.next_cursor = FormatAddress(cursor); + return result; + case detail::ScanPageBoundary::kContinue: + break; + } + + if (cursor == region_end && region_end > maximum) break; + } + + result.complete = true; + return result; +} + +} // namespace cfb27::memory diff --git a/native/host/memory_reader.h b/native/host/memory_reader.h new file mode 100644 index 0000000..2d1a727 --- /dev/null +++ b/native/host/memory_reader.h @@ -0,0 +1,93 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace cfb27::memory { + +constexpr std::size_t kMinPatternBytes = 8; +constexpr std::size_t kMaxPatternBytes = 4096; +constexpr std::size_t kMaxMatches = 64; +constexpr std::size_t kMaxContextBytes = 512; +constexpr std::size_t kScanChunkBytes = 4ull * 1024 * 1024; +constexpr std::size_t kMaxScanPageBytes = 32ull * 1024 * 1024; +constexpr std::size_t kMaxReadRanges = 64; +constexpr std::size_t kMaxReadRangeBytes = 64ull * 1024; +constexpr std::size_t kMaxReadBytes = 256ull * 1024; + +namespace detail { + +enum class ScanPageBoundary { + kContinue, + kIncomplete, + kComplete, +}; + +constexpr ScanPageBoundary ClassifyScanPageBoundary( + std::uintptr_t cursor, std::uintptr_t maximum, std::size_t scanned_bytes) { + if (cursor > maximum) return ScanPageBoundary::kComplete; + if (scanned_bytes == kMaxScanPageBytes) return ScanPageBoundary::kIncomplete; + return ScanPageBoundary::kContinue; +} + +} // namespace detail + +struct ReadRange { + std::string address; + std::size_t length{}; +}; + +struct ReadResult { + std::string address; + std::vector bytes; +}; + +struct BatchReadResult { + bool ok{}; + std::string code; + std::vector ranges; +}; + +struct ScanRequest { + std::vector pattern; + std::vector mask; + std::size_t max_matches{}; + std::size_t context_before{}; + std::size_t context_after{}; + std::optional cursor; +}; + +using ScanReadFunction = bool (*)(const void* source, void* destination, + std::size_t length, std::size_t& copied); + +struct ScanMatch { + std::string address; + std::string region_base; + std::size_t region_size{}; + DWORD protection{}; + std::string context_address; + std::vector context; +}; + +struct ScanResult { + bool complete{}; + std::string code; + std::size_t scanned_bytes{}; + std::optional next_cursor; + std::vector matches; +}; + +std::optional ParseAddress(std::string_view text); +std::string FormatAddress(std::uintptr_t address); +bool IsEligiblePrivateReadableRegion(const MEMORY_BASIC_INFORMATION& info); +BatchReadResult ReadMemoryBatch(const std::vector& ranges); +ScanResult ScanPrivateMemory(const ScanRequest& request, + ScanReadFunction read = nullptr); + +} // namespace cfb27::memory diff --git a/native/host/telemetry.cpp b/native/host/telemetry.cpp new file mode 100644 index 0000000..f9efe0a --- /dev/null +++ b/native/host/telemetry.cpp @@ -0,0 +1,175 @@ +#include "telemetry.h" + +#include +#include +#include +#include + +namespace cfb27::telemetry { +namespace { + +constexpr std::size_t kMaxTelemetryTypes = 16; +constexpr std::size_t kMaxDepth = 4; +constexpr std::size_t kMaxObjectKeys = 64; +constexpr std::size_t kMaxArrayEntries = 128; +constexpr std::size_t kMaxStringBytes = 1024; +constexpr std::size_t kMaxSerializedBytes = 16 * 1024; + +std::mutex g_types_mutex; +std::unordered_set g_registered_types; + +bool IsReservedType(std::string_view type) { + return type == "game_ready" || type == "tick" || type == "log"; +} + +bool IsForbiddenKey(std::string_view key) { + return key == "address" || key == "addressHex" || key == "regionBase" || + key == "bytesHex" || key == "contextAddress" || key == "contextHex"; +} + +bool AddSerializedBytes(std::size_t bytes, std::size_t& total, std::string& error) { + if (bytes > kMaxSerializedBytes - total) { + error = "Serialized telemetry payload must not exceed 16 KiB"; + return false; + } + total += bytes; + return true; +} + +bool ValidateValue(const Json& value, std::size_t depth, std::size_t& serialized_bytes, + std::string& error) { + if (value.is_null() || value.is_boolean() || value.is_number_integer() || + value.is_number_unsigned()) { + return AddSerializedBytes(value.dump().size(), serialized_bytes, error); + } + if (value.is_number_float()) { + if (!std::isfinite(value.get())) { + error = "Telemetry numbers must be finite"; + return false; + } + return AddSerializedBytes(value.dump().size(), serialized_bytes, error); + } + if (value.is_string()) { + if (value.get_ref().size() > kMaxStringBytes) { + error = "Telemetry strings must not exceed 1024 bytes"; + return false; + } + return AddSerializedBytes(value.dump().size(), serialized_bytes, error); + } + if (value.is_array()) { + if (depth > kMaxDepth) { + error = "Telemetry payload depth must not exceed 4"; + return false; + } + if (value.size() > kMaxArrayEntries) { + error = "Telemetry arrays must not exceed 128 entries"; + return false; + } + if (!AddSerializedBytes(2, serialized_bytes, error)) return false; + bool first = true; + for (const auto& item : value) { + if (!first && !AddSerializedBytes(1, serialized_bytes, error)) return false; + first = false; + if (!ValidateValue(item, depth + 1, serialized_bytes, error)) return false; + } + return true; + } + if (value.is_object()) { + if (depth > kMaxDepth) { + error = "Telemetry payload depth must not exceed 4"; + return false; + } + if (value.size() > kMaxObjectKeys) { + error = "Telemetry objects must not exceed 64 keys"; + return false; + } + if (!AddSerializedBytes(2, serialized_bytes, error)) return false; + bool first = true; + for (const auto& [key, item] : value.items()) { + if (key.size() > kMaxStringBytes) { + error = "Telemetry strings must not exceed 1024 bytes"; + return false; + } + if (IsForbiddenKey(key)) { + error = "Telemetry payloads must not contain address or raw-byte keys"; + return false; + } + const std::size_t punctuation = first ? 1 : 2; + first = false; + if (!AddSerializedBytes(Json(key).dump().size() + punctuation, + serialized_bytes, error) || + !ValidateValue(item, depth + 1, serialized_bytes, error)) { + return false; + } + } + return true; + } + error = "Telemetry payload must contain only JSON-compatible values"; + return false; +} + +} // namespace + +bool IsTelemetryTypeName(std::string_view type) { + if (type.empty() || type.size() > 64 || IsReservedType(type) || + type.front() < 'a' || type.front() > 'z') { + return false; + } + for (const char character : type.substr(1)) { + if ((character < 'a' || character > 'z') && + (character < '0' || character > '9') && character != '_' && + character != '.' && character != '-') { + return false; + } + } + return true; +} + +bool RegisterTelemetryTypes(const std::vector& types, std::string& error) { + if (types.empty() || types.size() > kMaxTelemetryTypes) { + error = "Telemetry registration requires 1 to 16 types"; + return false; + } + std::unordered_set requested; + for (const auto& type : types) { + if (!IsTelemetryTypeName(type)) { + error = "Telemetry type name is invalid or reserved"; + return false; + } + if (!requested.insert(type).second) { + error = "Telemetry registration contains duplicate types"; + return false; + } + } + + std::lock_guard lock(g_types_mutex); + std::size_t new_types = 0; + for (const auto& type : requested) { + if (!g_registered_types.contains(type)) ++new_types; + } + if (new_types > kMaxTelemetryTypes - g_registered_types.size()) { + error = "Telemetry registration exceeds the 16-type session limit"; + return false; + } + g_registered_types.insert(requested.begin(), requested.end()); + error.clear(); + return true; +} + +bool IsTelemetryTypeRegistered(std::string_view type) { + std::lock_guard lock(g_types_mutex); + return g_registered_types.contains(std::string(type)); +} + +bool ValidateTelemetryPayload(const Json& payload, std::string& error) { + error.clear(); + try { + std::size_t serialized_bytes = 0; + return ValidateValue(payload, 1, serialized_bytes, error); + } catch (const std::exception&) { + error = "Telemetry payload could not be serialized"; + return false; + } +} + +} // namespace cfb27::telemetry diff --git a/native/host/telemetry.h b/native/host/telemetry.h new file mode 100644 index 0000000..b93dd11 --- /dev/null +++ b/native/host/telemetry.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include +#include +#include + +namespace cfb27::telemetry { + +using Json = nlohmann::json; + +bool IsTelemetryTypeName(std::string_view type); +bool RegisterTelemetryTypes(const std::vector& types, std::string& error); +bool IsTelemetryTypeRegistered(std::string_view type); +bool ValidateTelemetryPayload(const Json& payload, std::string& error); + +} // namespace cfb27::telemetry diff --git a/native/smoke/memory_reader_smoke.cpp b/native/smoke/memory_reader_smoke.cpp new file mode 100644 index 0000000..9b8b8e8 --- /dev/null +++ b/native/smoke/memory_reader_smoke.cpp @@ -0,0 +1,396 @@ +#include "../host/memory_reader.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using cfb27::memory::FormatAddress; +using cfb27::memory::ReadMemoryBatch; +using cfb27::memory::ScanPrivateMemory; + +std::uintptr_t g_fail_read_at{}; +std::uintptr_t g_scan_destination{}; +bool g_attempted_scan_buffer_read{}; + +bool TestRead(const void* source, void* destination, std::size_t length, + std::size_t& copied) { + copied = 0; + const auto begin = reinterpret_cast(source); + if (g_scan_destination == 0) { + g_scan_destination = reinterpret_cast(destination); + } else if (g_scan_destination >= begin && g_scan_destination - begin < length) { + g_attempted_scan_buffer_read = true; + } + if (g_fail_read_at >= begin && g_fail_read_at - begin < length) return false; + std::memcpy(destination, source, length); + copied = length; + return true; +} + +void Require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +std::vector HexBytes(const std::string& text) { + Require(text.size() % 2 == 0, "even hex byte text"); + std::vector bytes; + bytes.reserve(text.size() / 2); + for (std::size_t i = 0; i < text.size(); i += 2) { + bytes.push_back(static_cast(std::stoul(text.substr(i, 2), nullptr, 16))); + } + return bytes; +} + +class Allocation { + public: + Allocation(std::size_t size, DWORD protection = PAGE_READWRITE) + : address_(VirtualAlloc(nullptr, size, MEM_RESERVE | MEM_COMMIT, protection)) { + Require(address_ != nullptr, "VirtualAlloc"); + } + + ~Allocation() { + if (address_) VirtualFree(address_, 0, MEM_RELEASE); + } + + Allocation(const Allocation&) = delete; + Allocation& operator=(const Allocation&) = delete; + + void* get() const { return address_; } + + private: + void* address_{}; +}; + +void TestAddressParsing() { + const auto value = reinterpret_cast(&TestAddressParsing); + const auto formatted = FormatAddress(value); + Require(cfb27::memory::ParseAddress(formatted) == value, "address round trip"); + Require(!cfb27::memory::ParseAddress("0xnot-hex"), "invalid hex address"); + Require(!cfb27::memory::ParseAddress("0x10000000000000000"), "overflowing hex address"); +} + +void TestRegionEligibility() { + MEMORY_BASIC_INFORMATION info{}; + info.State = MEM_COMMIT; + info.Type = MEM_PRIVATE; + info.Protect = PAGE_READWRITE; + info.RegionSize = 4096; + Require(cfb27::memory::IsEligiblePrivateReadableRegion(info), "private readable region"); + + info.Protect = PAGE_NOACCESS; + Require(!cfb27::memory::IsEligiblePrivateReadableRegion(info), "PAGE_NOACCESS rejection"); + info.Protect = PAGE_READWRITE; + info.Type = MEM_IMAGE; + Require(!cfb27::memory::IsEligiblePrivateReadableRegion(info), "MEM_IMAGE rejection"); +} + +void TestScanAndRead() { + constexpr std::size_t kAllocationSize = 64 * 1024; + Allocation allocation(kAllocationSize); + auto sentinel = HexBytes("CFB27A1100A1B2C3D4E5F60718293A4B"); + auto other = HexBytes("CFB27A220102030405060708090A0B0C"); + std::memcpy(static_cast(allocation.get()) + 128, sentinel.data(), sentinel.size()); + std::memcpy(static_cast(allocation.get()) + 4096, other.data(), other.size()); + SecureZeroMemory(sentinel.data(), sentinel.size()); + sentinel.clear(); + sentinel.shrink_to_fit(); + other.clear(); + other.shrink_to_fit(); + + const auto scan = ScanPrivateMemory({ + .pattern = HexBytes("CFB27A1100A1B2C3D4E5F60718293A4B"), + .mask = std::vector(16, 0xFF), + .max_matches = 2, + .context_before = 4, + .context_after = 4, + }); + Require(scan.complete && scan.matches.size() == 1, "unique private match"); + Require(scan.matches[0].context.size() == 24, "bounded context"); + + sentinel = HexBytes("CFB27A1100A1B2C3D4E5F60718293A4B"); + const auto read = ReadMemoryBatch({ + {FormatAddress(reinterpret_cast(allocation.get()) + 128), 16}, + }); + Require(read.ok && read.ranges.size() == 1 && read.ranges[0].bytes == sentinel, + "batch read"); + + const auto short_pattern = ScanPrivateMemory({ + .pattern = std::vector(7, 0x11), + .mask = std::vector(7, 0xFF), + .max_matches = 1, + }); + Require(!short_pattern.complete, "7-byte pattern rejection"); + + const auto excessive_matches = ScanPrivateMemory({ + .pattern = std::vector(8, 0x11), + .mask = std::vector(8, 0xFF), + .max_matches = 65, + }); + Require(!excessive_matches.complete, "65 requested matches rejection"); +} + +void TestScanExcludesMaskBuffer() { + cfb27::memory::ScanRequest request{ + .pattern = std::vector(4096, 0xFF), + .mask = std::vector(4096, 0xFF), + .max_matches = 2, + }; + const auto mask_begin = reinterpret_cast(request.mask.data()); + const auto mask_end = mask_begin + request.mask.size(); + + g_scan_destination = 0; + g_attempted_scan_buffer_read = false; + const auto scan = ScanPrivateMemory(request, TestRead); + Require(scan.complete, "mask buffer exclusion scan completes"); + Require(!g_attempted_scan_buffer_read, "dedicated scan buffer excluded from traversal"); + for (const auto& match : scan.matches) { + const auto address = cfb27::memory::ParseAddress(match.address); + Require(address && (*address < mask_begin || *address >= mask_end), + "scan returned request mask buffer"); + } +} + +std::size_t CountAddress(const std::vector& matches, + const void* address) { + const auto expected = reinterpret_cast(address); + return static_cast(std::count_if( + matches.begin(), matches.end(), [expected](const auto& match) { + return cfb27::memory::ParseAddress(match.address) == expected; + })); +} + +void TestPagedLargeRegionAndBoundaries() { + constexpr std::size_t kLargeBytes = 80ull * 1024 * 1024; + Allocation large(kLargeBytes); + auto* bytes = static_cast(large.get()); + const auto sentinel = HexBytes("92F4C76B19A35DE804286ACE13579BDF"); + const std::vector mask(sentinel.size(), 0xFF); + const auto chunk_boundary = cfb27::memory::kScanChunkBytes - 4; + const auto page_boundary = cfb27::memory::kMaxScanPageBytes - 4; + const auto old_tail = (64ull * 1024 * 1024) + 128; + std::memcpy(bytes + chunk_boundary, sentinel.data(), sentinel.size()); + std::memcpy(bytes + page_boundary, sentinel.data(), sentinel.size()); + std::memcpy(bytes + old_tail, sentinel.data(), sentinel.size()); + + cfb27::memory::ScanRequest request{ + .pattern = sentinel, + .mask = mask, + .max_matches = 8, + .context_before = 4, + .context_after = 4, + .cursor = FormatAddress(reinterpret_cast(bytes)), + }; + std::vector matches; + std::set cursors; + bool completed = false; + for (std::size_t pages = 0; pages < 4096; ++pages) { + const auto input_cursor = cfb27::memory::ParseAddress(*request.cursor); + Require(input_cursor.has_value(), "page input cursor is valid"); + const auto result = ScanPrivateMemory(request); + Require(result.code.empty(), "large-region page succeeds"); + Require(result.scanned_bytes <= cfb27::memory::kMaxScanPageBytes, + "scan page is bounded"); + matches.insert(matches.end(), result.matches.begin(), result.matches.end()); + if (result.complete) { + Require(!result.next_cursor.has_value(), "complete page has no cursor"); + completed = true; + break; + } + Require(result.next_cursor.has_value(), "partial page has cursor"); + const auto next_cursor = cfb27::memory::ParseAddress(*result.next_cursor); + Require(next_cursor && *next_cursor > *input_cursor, + "every partial page advances beyond its input cursor"); + Require(cursors.insert(*result.next_cursor).second, + "partial page cursor is never repeated"); + request.cursor = result.next_cursor; + } + Require(completed, "paged scan terminates"); + Require(CountAddress(matches, bytes + chunk_boundary) == 1, + "chunk-boundary match found once"); + Require(CountAddress(matches, bytes + page_boundary) == 1, + "page-boundary match found once"); + Require(CountAddress(matches, bytes + old_tail) == 1, + "large-region tail found once"); + + request.cursor = FormatAddress(reinterpret_cast(bytes)); + g_scan_destination = 0; + g_attempted_scan_buffer_read = false; + g_fail_read_at = reinterpret_cast(bytes); + const auto failed = ScanPrivateMemory(request, TestRead); + Require(failed.code == "MEMORY_ACCESS_DENIED", "eligible read failure is explicit"); + Require(!failed.complete, "failed read is never complete"); + Require(!failed.next_cursor.has_value(), "failed read cannot advance cursor"); + g_fail_read_at = 0; +} + +void TestInvalidPageCursors() { + SYSTEM_INFO system_info{}; + GetSystemInfo(&system_info); + const auto maximum = + reinterpret_cast(system_info.lpMaximumApplicationAddress); + const auto above_maximum = FormatAddress(maximum + 1); + const auto result = ScanPrivateMemory({ + .pattern = HexBytes("A1B2C3D4E5F60718"), + .mask = std::vector(8, 0xFF), + .max_matches = 1, + .cursor = above_maximum, + }); + Require(!result.complete && result.code == "INVALID_REQUEST", + "cursor above system maximum rejected"); + + const auto overflowing = ScanPrivateMemory({ + .pattern = HexBytes("A1B2C3D4E5F60718"), + .mask = std::vector(8, 0xFF), + .max_matches = 1, + .cursor = "0x10000000000000000", + }); + Require(!overflowing.complete && overflowing.code == "INVALID_REQUEST", + "overflowing cursor rejected"); + + const auto canonical = FormatAddress(maximum - 0xA); + auto lowercase_digit = canonical; + std::transform(lowercase_digit.begin() + 2, lowercase_digit.end(), + lowercase_digit.begin() + 2, [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + const std::vector noncanonical{ + canonical.substr(2), + "0x0" + canonical.substr(2), + lowercase_digit, + "0X" + canonical.substr(2), + }; + for (const auto& cursor : noncanonical) { + const auto rejected = ScanPrivateMemory({ + .pattern = HexBytes("A1B2C3D4E5F60718"), + .mask = std::vector(8, 0xFF), + .max_matches = 1, + .cursor = cursor, + }); + Require(!rejected.complete && rejected.code == "INVALID_REQUEST", + "noncanonical cursor rejected"); + } +} + +void TestTerminalCompletionPrecedesPageBudget() { + using cfb27::memory::detail::ClassifyScanPageBoundary; + using cfb27::memory::detail::ScanPageBoundary; + + Require(ClassifyScanPageBoundary(0x2000, 0x1FFF, + cfb27::memory::kMaxScanPageBytes) == + ScanPageBoundary::kComplete, + "terminal completion wins when page budget is reached exactly"); + Require(ClassifyScanPageBoundary(0x1FFF, 0x1FFF, + cfb27::memory::kMaxScanPageBytes) == + ScanPageBoundary::kIncomplete, + "exhausted budget remains incomplete before terminal traversal"); +} + +void TestDeniedReads() { + SYSTEM_INFO system_info{}; + GetSystemInfo(&system_info); + const auto page_size = static_cast(system_info.dwPageSize); + void* reserved = VirtualAlloc(nullptr, page_size * 2, MEM_RESERVE, PAGE_NOACCESS); + Require(reserved != nullptr, "reserve cross-region pages"); + Require(VirtualAlloc(reserved, page_size, MEM_COMMIT, PAGE_READWRITE) == reserved, + "commit readable page"); + Require(VirtualAlloc(static_cast(reserved) + page_size, page_size, + MEM_COMMIT, PAGE_NOACCESS) != nullptr, + "commit noaccess page"); + + const auto cross_region = ReadMemoryBatch({ + {FormatAddress(reinterpret_cast(reserved) + page_size - 8), 16}, + }); + Require(!cross_region.ok && cross_region.code == "MEMORY_ACCESS_DENIED" && + cross_region.ranges.empty(), + "cross-region read rejection"); + + const auto noaccess = ReadMemoryBatch({ + {FormatAddress(reinterpret_cast(reserved) + page_size), 1}, + }); + Require(!noaccess.ok && noaccess.code == "MEMORY_ACCESS_DENIED" && + noaccess.ranges.empty(), + "PAGE_NOACCESS read rejection"); + VirtualFree(reserved, 0, MEM_RELEASE); + + const auto invalid = ReadMemoryBatch({{"0xnot-hex", 1}}); + Require(!invalid.ok && invalid.ranges.empty(), "invalid batch address rejection"); + const auto overflowing = ReadMemoryBatch({{"0xfffffffffffffff8", 16}}); + Require(!overflowing.ok && overflowing.ranges.empty(), "overflowing batch address rejection"); +} + +void TestPagedScanBeyondOldAggregateLimit() { + constexpr std::size_t kRegionSize = 64ull * 1024 * 1024; + std::vector regions; + regions.reserve(9); + for (int i = 0; i < 9; ++i) { + void* region = VirtualAlloc(nullptr, kRegionSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); + Require(region != nullptr, "allocate aggregate scan region"); + regions.push_back(region); + } + std::sort(regions.begin(), regions.end(), [](const void* left, const void* right) { + return reinterpret_cast(left) < + reinterpret_cast(right); + }); + + auto sentinel = HexBytes("D13C579B2468ACE00123456789ABCDEF"); + constexpr std::size_t kTargetOffset = cfb27::memory::kMaxScanPageBytes + 1024; + std::memcpy(static_cast(regions.back()) + kTargetOffset, sentinel.data(), + sentinel.size()); + cfb27::memory::ScanRequest request{ + .pattern = sentinel, + .mask = std::vector(16, 0xFF), + .max_matches = 1, + .cursor = FormatAddress(reinterpret_cast(regions.front())), + }; + SecureZeroMemory(sentinel.data(), sentinel.size()); + sentinel.clear(); + sentinel.shrink_to_fit(); + bool found = false; + for (std::size_t page = 0; page < 4096; ++page) { + const auto result = ScanPrivateMemory(request); + Require(result.code.empty(), "aggregate continuation succeeds"); + Require(result.scanned_bytes <= cfb27::memory::kMaxScanPageBytes, + "aggregate continuation page bounded"); + if (CountAddress(result.matches, + static_cast(regions.back()) + kTargetOffset) == 1) { + found = true; + break; + } + if (result.complete) break; + Require(result.next_cursor.has_value(), "aggregate continuation cursor"); + request.cursor = result.next_cursor; + } + Require(found, "target after 512 MiB is reachable"); + + for (void* region : regions) VirtualFree(region, 0, MEM_RELEASE); +} + +} // namespace + +int main() { + try { + TestAddressParsing(); + TestRegionEligibility(); + TestScanAndRead(); + TestScanExcludesMaskBuffer(); + TestPagedLargeRegionAndBoundaries(); + TestInvalidPageCursors(); + TestTerminalCompletionPrecedesPageBudget(); + TestDeniedReads(); + TestPagedScanBeyondOldAggregateLimit(); + std::cout << "memory reader smoke passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "memory reader smoke failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/native/smoke/protocol_smoke.cpp b/native/smoke/protocol_smoke.cpp index 6f43ba5..890f4ce 100644 --- a/native/smoke/protocol_smoke.cpp +++ b/native/smoke/protocol_smoke.cpp @@ -2,14 +2,73 @@ #include #include +#include #include #include +#include #include +#include #include #include using Json = nlohmann::json; +namespace { + +constexpr char kSentinelHex[] = "CFB27A1100A1B2C3D4E5F60718293A4B"; +constexpr std::array kSentinel{ + 0xCF, 0xB2, 0x7A, 0x11, 0x00, 0xA1, 0xB2, 0xC3, + 0xD4, 0xE5, 0xF6, 0x07, 0x18, 0x29, 0x3A, 0x4B, +}; + +class Allocation { + public: + explicit Allocation(std::size_t size) + : address_(VirtualAlloc(nullptr, size, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) {} + ~Allocation() { + if (address_) VirtualFree(address_, 0, MEM_RELEASE); + } + Allocation(const Allocation&) = delete; + Allocation& operator=(const Allocation&) = delete; + void* get() const { return address_; } + + private: + void* address_{}; +}; + +std::string FormatAddress(std::uintptr_t address) { + std::ostringstream out; + out << "0x" << std::uppercase << std::hex << address; + return out.str(); +} + +bool IsCanonicalAddress(const Json& value) { + if (!value.is_string()) return false; + const auto text = value.get(); + if (text.size() < 3 || text[0] != '0' || text[1] != 'x' || text[2] == '0') return false; + return std::all_of(text.begin() + 2, text.end(), [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'A' && character <= 'F'); + }); +} + +bool IsUpperHex(const Json& value) { + if (!value.is_string()) return false; + const auto text = value.get(); + return text.size() % 2 == 0 && + std::all_of(text.begin(), text.end(), [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'A' && character <= 'F'); + }); +} + +bool IsError(const Json& response, const char* code) { + return !response.value("ok", true) && response.contains("error") && + response["error"].value("code", "") == code; +} + +} // namespace + bool WriteAll(HANDLE pipe, const std::uint8_t* data, std::size_t size) { while (size) { DWORD written = 0; @@ -94,6 +153,10 @@ bool RequestOversizedFrame(const std::wstring& pipe_name, Json& response) { int wmain(int argc, wchar_t** argv) { if (argc != 2 || !LoadLibraryW(argv[1])) return 2; + Allocation allocation(64 * 1024); + if (!allocation.get()) return 23; + auto* sentinel_address = static_cast(allocation.get()) + 128; + std::memcpy(sentinel_address, kSentinel.data(), kSentinel.size()); const std::wstring pipe = L"\\\\.\\pipe\\CFB27LuaHost.v1." + std::to_wstring(GetCurrentProcessId()); Json response; @@ -102,6 +165,7 @@ int wmain(int argc, wchar_t** argv) { if (!response.value("ok", false) || response["result"].value("protocolVersion", 0) != 1) 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; if (!Request(pipe, {{"protocol", 1}, {"id", "status-1"}, {"command", "status"}, {"params", Json::object()}}, response, false)) return 14; @@ -115,6 +179,277 @@ int wmain(int argc, wchar_t** argv) { if (!RequestOversizedFrame(pipe, response)) return 10; if (response.value("ok", true) || response["error"].value("code", "") != "INVALID_REQUEST") return 11; + const Json scan_params{ + {"patternHex", kSentinelHex}, + {"maskHex", "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"}, + {"maxMatches", 2}, + {"contextBefore", 4}, + {"contextAfter", 4}, + }; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-gated"}, + {"command", "scanMemory"}, {"params", scan_params}}, response, false)) return 25; + if (!IsError(response, "UNSUPPORTED_BUILD")) return 26; + Json false_scan_params = scan_params; + false_scan_params["allowUnsupportedBuild"] = 1; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-exact-gate"}, + {"command", "scanMemory"}, {"params", false_scan_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 40; + + Json allowed_scan_params = scan_params; + allowed_scan_params["allowUnsupportedBuild"] = true; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-1"}, + {"command", "scanMemory"}, {"params", allowed_scan_params}}, response, false)) return 27; + if (!response.value("ok", false)) return 28; + if (std::find(capabilities.begin(), capabilities.end(), "memoryScan") == capabilities.end() || + std::find(capabilities.begin(), capabilities.end(), "memoryRead") == capabilities.end()) return 24; + Json scan = response["result"]; + std::vector found_matches; + std::string previous_cursor; + for (std::size_t page_number = 0; page_number < 4096; ++page_number) { + if (scan.size() != 5 || scan.value("supportedBuild", true) || + !scan.contains("scannedBytes") || !scan["scannedBytes"].is_number_unsigned() || + scan["scannedBytes"].get() > 32ull * 1024 * 1024 || + !scan.contains("matches") || !scan["matches"].is_array() || + !scan.contains("nextCursor")) return 29; + for (const auto& candidate : scan["matches"]) found_matches.push_back(candidate); + if (scan.value("complete", false)) { + if (!scan["nextCursor"].is_null()) return 63; + break; + } + if (!IsCanonicalAddress(scan["nextCursor"])) return 64; + const auto cursor = scan["nextCursor"].get(); + if (cursor == previous_cursor) return 65; + previous_cursor = cursor; + allowed_scan_params["cursor"] = cursor; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-page"}, + {"command", "scanMemory"}, {"params", allowed_scan_params}}, + response, false) || !response.value("ok", false)) return 66; + scan = response["result"]; + } + if (!scan.value("complete", false) || found_matches.size() != 1) { + std::cerr << "scan response: " << response.dump() << '\n'; + return 29; + } + const auto& match = found_matches[0]; + if (match.size() != 6 || !IsCanonicalAddress(match["address"]) || + !IsCanonicalAddress(match["regionBase"]) || + !IsCanonicalAddress(match["contextAddress"]) || + match.value("address", "") != FormatAddress(reinterpret_cast(sentinel_address)) || + match.value("contextAddress", "") != + FormatAddress(reinterpret_cast(sentinel_address) - 4) || + !match.contains("regionSize") || !match["regionSize"].is_number_unsigned() || + !match.contains("protection") || !match["protection"].is_number_unsigned() || + !IsUpperHex(match["contextHex"]) || + match.value("contextHex", "") != std::string("00000000") + kSentinelHex + "00000000") return 30; + allowed_scan_params.erase("cursor"); + + const auto address = FormatAddress(reinterpret_cast(sentinel_address)); + const Json read_params{ + {"allowUnsupportedBuild", true}, + {"ranges", Json::array({{{"address", address}, {"length", 16}}})}, + }; + Json gated_read_params = read_params; + gated_read_params.erase("allowUnsupportedBuild"); + if (!Request(pipe, {{"protocol", 1}, {"id", "read-gated"}, + {"command", "readMemory"}, {"params", gated_read_params}}, response, false) || + !IsError(response, "UNSUPPORTED_BUILD")) return 41; + if (!Request(pipe, {{"protocol", 1}, {"id", "read-1"}, + {"command", "readMemory"}, {"params", read_params}}, response, false)) return 31; + if (!response.value("ok", false) || response["result"].size() != 2 || + response["result"].value("supportedBuild", true) || + response["result"]["ranges"].size() != 1 || + response["result"]["ranges"][0].size() != 3 || + response["result"]["ranges"][0].value("address", "") != address || + response["result"]["ranges"][0].value("length", 0) != 16 || + response["result"]["ranges"][0].value("bytesHex", "") != kSentinelHex) return 32; + + Json invalid_params = allowed_scan_params; + invalid_params["patternHex"] = "CFB27A1Z"; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-bad-hex"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 33; + invalid_params = allowed_scan_params; + invalid_params["patternHex"] = "cfb27a1100a1b2c3d4e5f60718293a4b"; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-lower-hex"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 47; + invalid_params = allowed_scan_params; + invalid_params["patternHex"] = "CFB27A1100A1B2C3D4E5F60718293A4"; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-odd-hex"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 48; + invalid_params = allowed_scan_params; + invalid_params["maskHex"] = "FFFFFFFFFFFFFFFF"; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-bad-mask"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 34; + invalid_params = allowed_scan_params; + invalid_params["maxMatches"] = 65; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-limit"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 35; + invalid_params = allowed_scan_params; + invalid_params["contextBefore"] = 513; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-context-limit"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 43; + invalid_params = allowed_scan_params; + invalid_params["maxMatches"] = 2.5; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-integer"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 44; + invalid_params = allowed_scan_params; + invalid_params["cursor"] = "0xabcdef"; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-lower-cursor"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 67; + invalid_params = allowed_scan_params; + invalid_params["cursor"] = "0x0001"; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-zero-cursor"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 68; + invalid_params = allowed_scan_params; + invalid_params["cursor"] = 4096; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-numeric-cursor"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 69; + invalid_params = allowed_scan_params; + invalid_params["cursor"] = "0x10000000000000000"; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-overflow-cursor"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 70; + SYSTEM_INFO system_info{}; + GetSystemInfo(&system_info); + const auto above_max = reinterpret_cast(system_info.lpMaximumApplicationAddress) + 1; + invalid_params = allowed_scan_params; + invalid_params["cursor"] = FormatAddress(above_max); + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-above-max-cursor"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 71; + invalid_params = allowed_scan_params; + invalid_params["unexpected"] = 1; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-extra"}, + {"command", "scanMemory"}, {"params", invalid_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 36; + + Json invalid_read_params = read_params; + invalid_read_params["ranges"][0]["length"] = 65537; + if (!Request(pipe, {{"protocol", 1}, {"id", "read-limit"}, + {"command", "readMemory"}, {"params", invalid_read_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 37; + invalid_read_params = read_params; + invalid_read_params["ranges"] = Json::array(); + for (int index = 0; index < 65; ++index) { + invalid_read_params["ranges"].push_back({{"address", address}, {"length", 1}}); + } + if (!Request(pipe, {{"protocol", 1}, {"id", "read-range-count"}, + {"command", "readMemory"}, {"params", invalid_read_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 49; + invalid_read_params = read_params; + invalid_read_params["ranges"] = Json::array(); + for (int index = 0; index < 5; ++index) { + invalid_read_params["ranges"].push_back({{"address", address}, {"length", 65536}}); + } + if (!Request(pipe, {{"protocol", 1}, {"id", "read-aggregate"}, + {"command", "readMemory"}, {"params", invalid_read_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 50; + invalid_read_params = read_params; + invalid_read_params["ranges"][0]["unexpected"] = true; + if (!Request(pipe, {{"protocol", 1}, {"id", "read-extra"}, + {"command", "readMemory"}, {"params", invalid_read_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 38; + invalid_read_params = read_params; + invalid_read_params["allowUnsupportedBuild"] = 1; + if (!Request(pipe, {{"protocol", 1}, {"id", "read-exact-gate"}, + {"command", "readMemory"}, {"params", invalid_read_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 39; + invalid_read_params = read_params; + invalid_read_params["ranges"][0]["address"] = "0xabcdef"; + if (!Request(pipe, {{"protocol", 1}, {"id", "read-address"}, + {"command", "readMemory"}, {"params", invalid_read_params}}, response, false) || + !IsError(response, "INVALID_REQUEST")) return 42; + invalid_read_params = read_params; + invalid_read_params["ranges"][0]["address"] = "0x1"; + if (!Request(pipe, {{"protocol", 1}, {"id", "read-denied"}, + {"command", "readMemory"}, {"params", invalid_read_params}}, response, false) || + !IsError(response, "MEMORY_ACCESS_DENIED") || + !response["error"].value("details", Json::object()).empty()) return 45; + + std::memcpy(static_cast(allocation.get()) + 256, + kSentinel.data(), kSentinel.size()); + Json crowded_scan_params = allowed_scan_params; + crowded_scan_params["maxMatches"] = 1; + if (!Request(pipe, {{"protocol", 1}, {"id", "scan-crowded"}, + {"command", "scanMemory"}, {"params", crowded_scan_params}}, response, false) || + !IsError(response, "TOO_MANY_MATCHES") || + !response["error"].value("details", Json::object()).empty()) return 46; + SecureZeroMemory(static_cast(allocation.get()) + 256, kSentinel.size()); + + if (!Request(pipe, {{"protocol", 1}, {"id", "emit-unregistered"}, + {"command", "evaluate"}, + {"params", {{"source", "cfb.emit('probe.unregistered', {value=1})"}}}}, + response, false) || !IsError(response, "SCRIPT_ERROR")) return 52; + + if (!Request(pipe, {{"protocol", 1}, {"id", "telemetry-extra"}, + {"command", "registerTelemetry"}, + {"params", {{"types", Json::array({"probe.snapshot"})}, {"extra", true}}}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 53; + if (!Request(pipe, {{"protocol", 1}, {"id", "telemetry-duplicate"}, + {"command", "registerTelemetry"}, + {"params", {{"types", Json::array({"probe.snapshot", "probe.snapshot"})}}}}, + response, false) || !IsError(response, "INVALID_REQUEST")) return 54; + if (!Request(pipe, {{"protocol", 1}, {"id", "telemetry-register"}, + {"command", "registerTelemetry"}, + {"params", {{"types", Json::array({"probe.snapshot"})}}}}, + response, false) || !response.value("ok", false) || + response["result"] != Json({{"types", Json::array({"probe.snapshot"})}})) return 55; + + if (!Request(pipe, {{"protocol", 1}, {"id", "events-baseline"}, {"command", "events"}, + {"params", {{"after", 0}, {"limit", 256}}}}, response, false) || + !response.value("ok", false)) return 56; + const auto telemetry_after = response["result"].value("nextCursor", 0ull); + + const std::string telemetry_source = + "assert(cfb.emit('probe.snapshot', {sequence=1, stable=true}))"; + if (!Request(pipe, {{"protocol", 1}, {"id", "emit-registered"}, {"command", "evaluate"}, + {"params", {{"source", telemetry_source}}}}, response, false) || + !response.value("ok", false)) return 57; + if (!Request(pipe, {{"protocol", 1}, {"id", "events-telemetry"}, {"command", "events"}, + {"params", {{"after", telemetry_after}, {"limit", 256}}}}, response, false) || + !response.value("ok", false)) return 58; + int telemetry_count = 0; + for (const auto& event : response["result"]["events"]) { + if (event.value("type", "") == "probe.snapshot") { + ++telemetry_count; + if (event.value("payload", Json::object()) != Json({{"sequence", 1}, {"stable", true}})) { + return 59; + } + } + } + if (telemetry_count != 1) return 60; + + const std::vector invalid_telemetry_sources{ + "local t={}; t.self=t; cfb.emit('probe.snapshot', t)", + "cfb.emit('probe.snapshot', {value=function() end})", + "cfb.emit('probe.snapshot', {[1]='a', name='b'})", + "cfb.emit('probe.snapshot', {[1]='a', [3]='c'})", + "cfb.emit('probe.snapshot', {[true]='value'})", + "cfb.emit('probe.snapshot', {nested={address='0x1'}})", + }; + for (std::size_t index = 0; index < invalid_telemetry_sources.size(); ++index) { + if (!Request(pipe, {{"protocol", 1}, {"id", "emit-invalid-" + std::to_string(index)}, + {"command", "evaluate"}, + {"params", {{"source", invalid_telemetry_sources[index]}}}}, + response, false) || !IsError(response, "SCRIPT_ERROR")) return 61; + } + const std::string budget_source = + "local t={}; for i=1,17 do t[i]=string.rep('x',1024) end; " + "t[18]=function() end; cfb.emit('probe.snapshot', t)"; + if (!Request(pipe, {{"protocol", 1}, {"id", "emit-budget"}, {"command", "evaluate"}, + {"params", {{"source", budget_source}}}}, response, false) || + !IsError(response, "SCRIPT_ERROR") || + response["error"].value("message", "").find("16 KiB") == std::string::npos) return 62; + const std::string source = "local x=40\nx=x+2\ncfb.log(\"protocol-smoke=\"..tostring(x))"; if (!Request(pipe, {{"protocol", 1}, {"id", "eval-1"}, {"command", "evaluate"}, {"params", {{"source", source}}}}, response, false)) return 6; diff --git a/native/smoke/telemetry_smoke.cpp b/native/smoke/telemetry_smoke.cpp new file mode 100644 index 0000000..92e5763 --- /dev/null +++ b/native/smoke/telemetry_smoke.cpp @@ -0,0 +1,88 @@ +#include "../host/telemetry.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +using cfb27::telemetry::Json; + +void Require(bool condition, const char* message) { + if (!condition) { + std::cerr << "FAILED: " << message << '\n'; + std::exit(1); + } +} + +bool Registers(const std::vector& types) { + std::string error; + return cfb27::telemetry::RegisterTelemetryTypes(types, error); +} + +bool ValidPayload(const Json& payload) { + std::string error; + return cfb27::telemetry::ValidateTelemetryPayload(payload, error); +} + +} // namespace + +int main() { + Require(cfb27::telemetry::IsTelemetryTypeName("probe.snapshot"), "valid type name"); + for (const auto* type : {"", "Probe.snapshot", "probe snapshot", ".probe", + "game_ready", "tick", "log"}) { + Require(!cfb27::telemetry::IsTelemetryTypeName(type), "invalid or reserved type name"); + Require(!Registers({type}), "reject invalid or reserved registration"); + } + Require(!Registers({"probe.duplicate", "probe.duplicate"}), + "reject duplicate names in one request"); + std::vector too_many; + for (int index = 0; index < 17; ++index) { + too_many.push_back("probe.bulk" + std::to_string(index)); + } + Require(!Registers(too_many), "reject more than 16 names"); + Require(!cfb27::telemetry::IsTelemetryTypeRegistered("probe.bulk0"), + "failed registration is atomic"); + + Require(Registers({"probe.snapshot"}), "register telemetry type"); + Require(Registers({"probe.snapshot"}), "identical registration is idempotent"); + Require(cfb27::telemetry::IsTelemetryTypeRegistered("probe.snapshot"), + "registered type is visible"); + + const Json valid = { + {"sequence", 1}, + {"stable", true}, + {"label", "ready"}, + {"nested", {{"items", Json::array({1, nullptr, false, "ok"})}}}, + }; + Require(ValidPayload(valid), "accept nested scalar JSON within limits"); + + Json depth_five = Json::object(); + depth_five["a"]["b"]["c"]["d"]["e"] = 1; + Require(!ValidPayload(depth_five), "reject depth 5"); + + Json object_65 = Json::object(); + for (int index = 0; index < 65; ++index) object_65["key" + std::to_string(index)] = index; + Require(!ValidPayload(object_65), "reject 65 object keys"); + Require(!ValidPayload(Json(std::vector(129, 1))), "reject 129 array entries"); + Require(!ValidPayload(std::string(1025, 'x')), "reject 1025-byte strings"); + + for (const auto* key : {"address", "addressHex", "regionBase", "bytesHex"}) { + Json payload = {{"nested", {{key, "0x1"}}}}; + Require(!ValidPayload(payload), "reject address or raw-byte keys at every depth"); + } + Require(!ValidPayload(std::numeric_limits::infinity()), "reject infinity"); + Require(!ValidPayload(std::numeric_limits::quiet_NaN()), "reject NaN"); + + Json oversized = Json::object(); + for (int index = 0; index < 17; ++index) { + oversized["field" + std::to_string(index)] = std::string(1024, 'x'); + } + Require(!ValidPayload(oversized), "reject serialized payload above 16 KiB"); + + std::cout << "telemetry smoke passed\n"; + return 0; +} diff --git a/package-lock.json b/package-lock.json index ba706f5..8256069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cfb27-lua-hook-workspace", - "version": "0.1.0-dev.1", + "version": "0.2.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cfb27-lua-hook-workspace", - "version": "0.1.0-dev.1", + "version": "0.2.0-dev.1", "license": "MIT", "workspaces": [ "packages/sdk", @@ -26,10 +26,10 @@ }, "packages/cli": { "name": "cfb27-lua-hook", - "version": "0.1.0-dev.1", + "version": "0.2.0-dev.1", "license": "MIT", "dependencies": { - "@cfb27/lua-hook": "0.1.0-dev.1" + "@cfb27/lua-hook": "0.2.0-dev.1" }, "bin": { "cfb27lua": "bin/cfb27lua.cjs" @@ -40,7 +40,7 @@ }, "packages/sdk": { "name": "@cfb27/lua-hook", - "version": "0.1.0-dev.1", + "version": "0.2.0-dev.1", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 368c31f..aa94da6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cfb27-lua-hook-workspace", - "version": "0.1.0-dev.1", + "version": "0.2.0-dev.1", "private": true, "description": "Offline CFB27 Lua hook, scripting SDK, and MMC startup tooling for PC.", "license": "MIT", diff --git a/packages/cli/package.json b/packages/cli/package.json index 72d47a9..4e191a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "cfb27-lua-hook", - "version": "0.1.0-dev.1", + "version": "0.2.0-dev.1", "description": "Developer CLI for the offline CFB27 Lua hook.", "type": "commonjs", "license": "MIT", @@ -15,6 +15,6 @@ "src" ], "dependencies": { - "@cfb27/lua-hook": "0.1.0-dev.1" + "@cfb27/lua-hook": "0.2.0-dev.1" } } diff --git a/packages/cli/src/args.cjs b/packages/cli/src/args.cjs index 11a5cda..c357306 100644 --- a/packages/cli/src/args.cjs +++ b/packages/cli/src/args.cjs @@ -6,6 +6,26 @@ function usageError(message) { return error; } +const CANONICAL_ADDRESS = /^0x(?:0|[1-9A-F][0-9A-F]{0,15})$/; + +function parseInteger(value, option, minimum, maximum) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw usageError(`${option} must be an integer from ${minimum} to ${maximum}`); + } + return parsed; +} + +function parseRange(value) { + const separator = value.lastIndexOf(':'); + const address = value.slice(0, separator); + const lengthText = value.slice(separator + 1); + if (separator < 0 || !CANONICAL_ADDRESS.test(address)) { + throw usageError('--range must use a canonical address followed by :length'); + } + return { address, length: parseInteger(lengthText, '--range length', 1, 65536) }; +} + function parseArgs(argv) { let command; let json = false; @@ -18,6 +38,13 @@ function parseArgs(argv) { artifactsDir: undefined, follow: false, after: undefined, + pattern: undefined, + mask: undefined, + maxMatches: undefined, + maxPages: undefined, + context: undefined, + ranges: [], + allowUnsupportedBuild: false, }; const seen = new Set(); const values = new Map([ @@ -25,6 +52,11 @@ function parseArgs(argv) { ['--mmc-dir', 'mmcDir'], ['--artifacts-dir', 'artifactsDir'], ['--after', 'after'], + ['--pattern', 'pattern'], + ['--mask', 'mask'], + ['--max-matches', 'maxMatches'], + ['--max-pages', 'maxPages'], + ['--context', 'context'], ]); for (let index = 0; index < argv.length; index += 1) { @@ -42,11 +74,21 @@ function parseArgs(argv) { help = true; continue; } - if (token === '--json' || token === '--follow') { + if (token === '--json' || token === '--follow' || token === '--allow-unsupported-build') { if (seen.has(token)) throw usageError(`Duplicate option: ${token}`); seen.add(token); if (token === '--json') json = true; - else options.follow = true; + else if (token === '--follow') options.follow = true; + else options.allowUnsupportedBuild = true; + continue; + } + if (token === '--range') { + const value = argv[index + 1]; + if (value === undefined || value.startsWith('--')) { + throw usageError('Missing value for --range'); + } + options.ranges.push(parseRange(value)); + index += 1; continue; } if (values.has(token)) { @@ -66,9 +108,16 @@ function parseArgs(argv) { } if (options.after !== undefined) { - const after = Number(options.after); - if (!Number.isSafeInteger(after) || after < 0) throw usageError('--after must be a nonnegative integer'); - options.after = after; + options.after = parseInteger(options.after, '--after', 0, Number.MAX_SAFE_INTEGER); + } + if (options.maxMatches !== undefined) { + options.maxMatches = parseInteger(options.maxMatches, '--max-matches', 1, 64); + } + if (options.maxPages !== undefined) { + options.maxPages = parseInteger(options.maxPages, '--max-pages', 1, 4096); + } + if (options.context !== undefined) { + options.context = parseInteger(options.context, '--context', 0, 256); } if (help) command = 'help'; return { command, json, positionals, options }; diff --git a/packages/cli/src/main.cjs b/packages/cli/src/main.cjs index cf8cd67..de82404 100644 --- a/packages/cli/src/main.cjs +++ b/packages/cli/src/main.cjs @@ -15,6 +15,10 @@ Commands: doctor Run read-only diagnostics logs Read recent host logs events Read host events after a cursor + memory scan Scan bounded private readable memory (diagnostic) + memory read Read bounded canonical address ranges (diagnostic) + telemetry register + Register structured telemetry type names Options: --game-dir College Football 27 directory @@ -23,6 +27,14 @@ Options: --json Emit one JSON object --follow Follow new log events as JSONL with --json --after Start event reads after this cursor + --pattern Uppercase scan pattern bytes + --mask Uppercase scan mask bytes + --max-matches Maximum scan matches (1-64) + --max-pages Maximum scan pages (1-4096; default 4096) + --context Context bytes on each side (0-256) + --range Canonical read range; may be repeated + --allow-unsupported-build + Explicitly allow unsupported-build diagnostics -h, --help Show this help`; const defaultIo = { @@ -45,6 +57,72 @@ function requireDirectory(value, label) { return value; } +function requireMemoryScanOptions(options) { + if (!options.pattern) throw usageError('--pattern is required for memory scan'); + if (!options.mask) throw usageError('--mask is required for memory scan'); + if (options.maxMatches === undefined) throw usageError('--max-matches is required for memory scan'); + if (options.context === undefined) throw usageError('--context is required for memory scan'); + return { + patternHex: options.pattern, + maskHex: options.mask, + maxMatches: options.maxMatches, + contextBefore: options.context, + contextAfter: options.context, + maxPages: options.maxPages || 4096, + ...(options.allowUnsupportedBuild ? { allowUnsupportedBuild: true } : {}), + }; +} + +function rejectMisplacedDeveloperOptions(command, positionals, options) { + const operation = command === 'memory' ? positionals[0] : undefined; + const scanOptions = [ + options.pattern, options.mask, options.maxMatches, options.maxPages, options.context, + ]; + if (command !== 'memory' && (scanOptions.some((value) => value !== undefined) || + options.ranges.length || options.allowUnsupportedBuild)) { + throw usageError('Memory diagnostic options are only valid for memory diagnostics; they are not valid for this command'); + } + if (operation === 'scan' && options.ranges.length) { + throw usageError('--range is not valid for memory scan'); + } + if (operation === 'read' && scanOptions.some((value) => value !== undefined)) { + throw usageError('Scan options are not valid for memory read'); + } +} + +function memoryHumanLines(command, result) { + if (command === 'memory scan') { + const count = result.matches.length; + return [ + `${count} ${count === 1 ? 'match' : 'matches'}, ${result.scannedBytes} bytes scanned`, + ...result.matches.map((match) => `${match.address} (region ${match.regionBase})`), + ]; + } + const bytes = result.ranges.reduce((total, range) => total + range.length, 0); + return [ + `${result.ranges.length} ${result.ranges.length === 1 ? 'range' : 'ranges'}, ${bytes} bytes`, + ...result.ranges.map((range) => `${range.address}: ${range.length} bytes`), + ]; +} + +function printCommandSuccess(io, command, result, json) { + if (json) { + printSuccess(io, command, result, true); + return; + } + if (command.startsWith('memory ')) { + for (const line of memoryHumanLines(command, result)) io.out(line); + return; + } + if (command === 'telemetry register') { + const count = result.types.length; + io.out(`${count} telemetry ${count === 1 ? 'type' : 'types'} registered`); + for (const type of result.types) io.out(type); + return; + } + printSuccess(io, command, result, false); +} + async function main(argv, { sdk = require('@cfb27/lua-hook'), io = defaultIo } = {}) { let parsed = { json: false }; try { @@ -55,6 +133,7 @@ async function main(argv, { sdk = require('@cfb27/lua-hook'), io = defaultIo } = io.out(HELP); return 0; } + rejectMisplacedDeveloperOptions(command, positionals, options); let result; if (command === 'install') { @@ -116,11 +195,37 @@ async function main(argv, { sdk = require('@cfb27/lua-hook'), io = defaultIo } = after: options.after || 0, limit: 256, }); + } else if (command === 'memory') { + const [operation, ...extra] = positionals; + if (operation === 'scan') { + if (extra.length) throw usageError('memory scan does not accept positional arguments'); + const scanOptions = requireMemoryScanOptions(options); + const game = await sdk.discoverGame(); + result = await sdk.createClient({ pid: game.pid, timeoutMs: 10_000 }).scanMemory(scanOptions); + } else if (operation === 'read') { + if (extra.length) throw usageError('memory read does not accept positional arguments'); + if (!options.ranges.length) throw usageError('memory read requires at least one --range'); + const readOptions = { ranges: options.ranges }; + if (options.allowUnsupportedBuild) readOptions.allowUnsupportedBuild = true; + const game = await sdk.discoverGame(); + result = await sdk.createClient({ pid: game.pid }).readMemory(readOptions); + } else { + throw usageError('memory requires scan or read'); + } + } else if (command === 'telemetry') { + const [operation, ...types] = positionals; + if (operation !== 'register') throw usageError('telemetry requires register'); + if (!types.length) throw usageError('telemetry register requires at least one type name'); + const game = await sdk.discoverGame(); + result = await sdk.createClient({ pid: game.pid }).registerTelemetryTypes(types); } else { throw usageError(`Unknown command: ${command}`); } - printSuccess(io, command, result, json); + const displayCommand = command === 'memory' || command === 'telemetry' + ? `${command} ${positionals[0]}` + : command; + printCommandSuccess(io, displayCommand, result, json); return 0; } catch (error) { printError(io, error, parsed.json === true); diff --git a/packages/cli/test/main.test.cjs b/packages/cli/test/main.test.cjs index 7b29b33..f841b1b 100644 --- a/packages/cli/test/main.test.cjs +++ b/packages/cli/test/main.test.cjs @@ -153,3 +153,285 @@ test('--json errors emit exactly one object to stdout', async () => { assert.equal(output.stdout.trim().split('\n').length, 1); assert.equal(JSON.parse(output.stdout).error.code, 'HOST_NOT_READY'); }); + +test('memory scan parses diagnostic options and preserves the validated SDK result in JSON', async () => { + const result = { + supportedBuild: false, + complete: true, + scannedBytes: 65536, + matches: [{ + address: '0x7FF612340080', + regionBase: '0x7FF612340000', + regionSize: 65536, + protection: 4, + contextAddress: '0x7FF612340060', + contextHex: `${'00'.repeat(32)}CFB27A1100A1B2C3D4E5F60718293A4B${'00'.repeat(32)}`, + }], + }; + const calls = []; + const { io, output } = memoryIo(); + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: (options) => { + calls.push(['createClient', options]); + return { + scanMemory: async (options) => { calls.push(['scanMemory', options]); return result; }, + }; + }, + }; + + assert.equal(await main([ + 'memory', 'scan', + '--pattern', 'CFB27A1100A1B2C3D4E5F60718293A4B', + '--mask', 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + '--max-matches', '8', + '--context', '32', + '--max-pages', '3', + '--allow-unsupported-build', + '--json', + ], { sdk, io }), 0); + assert.deepEqual(calls, [ + ['createClient', { pid: 27, timeoutMs: 10_000 }], + ['scanMemory', { + patternHex: 'CFB27A1100A1B2C3D4E5F60718293A4B', + maskHex: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + maxMatches: 8, + contextBefore: 32, + contextAfter: 32, + maxPages: 3, + allowUnsupportedBuild: true, + }], + ]); + assert.deepEqual(JSON.parse(output.stdout), { + ok: true, + command: 'memory scan', + result, + }); +}); + +test('memory scan defaults to 4096 pages and only scan selects the ten-second timeout', async () => { + const calls = []; + const sdk = { + discoverGame: async () => ({ pid: 42 }), + createClient: (options) => { + calls.push(['createClient', options]); + return { + scanMemory: async (options) => { + calls.push(['scanMemory', options]); + return { supportedBuild: true, complete: true, scannedBytes: 96, matches: [] }; + }, + readMemory: async (options) => { + calls.push(['readMemory', options]); + return { supportedBuild: true, ranges: [] }; + }, + }; + }, + }; + + assert.equal(await main([ + 'memory', 'scan', '--pattern', '0011223344556677', + '--mask', 'FFFFFFFFFFFFFFFF', '--max-matches', '8', '--context', '32', '--json', + ], { sdk, io: memoryIo().io }), 0); + assert.equal(await main([ + 'memory', 'read', '--range', '0x1000:8', '--json', + ], { sdk, io: memoryIo().io }), 0); + + assert.deepEqual(calls, [ + ['createClient', { pid: 42, timeoutMs: 10_000 }], + ['scanMemory', { + patternHex: '0011223344556677', + maskHex: 'FFFFFFFFFFFFFFFF', + maxMatches: 8, + contextBefore: 32, + contextAfter: 32, + maxPages: 4096, + }], + ['createClient', { pid: 42 }], + ['readMemory', { ranges: [{ address: '0x1000', length: 8 }] }], + ]); +}); + +test('memory scan rejects invalid max-pages and caller-owned continuation or range controls', async () => { + const base = [ + 'memory', 'scan', '--pattern', '0011223344556677', + '--mask', 'FFFFFFFFFFFFFFFF', '--max-matches', '8', '--context', '32', + ]; + const cases = [ + [[...base, '--max-pages', '0'], /--max-pages/], + [[...base, '--max-pages', '4097'], /--max-pages/], + [[...base, '--max-pages', '9007199254740992'], /--max-pages/], + [[...base, '--max-pages', '2', '--max-pages', '3'], /Duplicate option/], + [[...base, '--cursor', '0x1000'], /Unknown option/], + [[...base, '--start', '0x1000'], /Unknown option/], + [[...base, '--stop', '0x2000'], /Unknown option/], + [[...base, '--write', '00'], /Unknown option/], + ]; + for (const [argv, pattern] of cases) { + const { io, output } = memoryIo(); + assert.equal(await main(argv, { sdk: {}, io }), 2, argv.join(' ')); + assert.match(output.stderr, pattern, argv.join(' ')); + } +}); + +test('memory read parses repeated canonical ranges and reports canonical addresses', async () => { + const calls = []; + const result = { + supportedBuild: true, + ranges: [ + { address: '0x7FF612340000', length: 192, bytesHex: '00'.repeat(192) }, + { address: '0x7FF612350000', length: 16, bytesHex: 'FF'.repeat(16) }, + ], + }; + const { io, output } = memoryIo(); + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ + readMemory: async (options) => { calls.push(options); return result; }, + }), + }; + + assert.equal(await main([ + 'memory', 'read', + '--range', '0x7FF612340000:192', + '--range', '0x7FF612350000:16', + ], { sdk, io }), 0); + assert.deepEqual(calls, [{ ranges: [ + { address: '0x7FF612340000', length: 192 }, + { address: '0x7FF612350000', length: 16 }, + ] }]); + assert.match(output.stdout, /2 ranges, 208 bytes/); + assert.match(output.stdout, /0x7FF612340000/); + assert.match(output.stdout, /0x7FF612350000/); +}); + +test('memory read JSON preserves the validated SDK result', async () => { + const result = { + supportedBuild: true, + ranges: [{ + address: '0xFFFFFFFFFFFFFFFF', + length: 1, + bytesHex: 'CF', + }], + }; + const { io, output } = memoryIo(); + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ readMemory: async () => result }), + }; + assert.equal(await main([ + 'memory', 'read', '--range', '0xFFFFFFFFFFFFFFFF:1', '--json', + ], { sdk, io }), 0); + assert.deepEqual(JSON.parse(output.stdout), { + ok: true, + command: 'memory read', + result, + }); +}); + +test('memory scan human output reports match count and canonical addresses', async () => { + const { io, output } = memoryIo(); + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ + scanMemory: async () => ({ + supportedBuild: true, + complete: true, + scannedBytes: 4096, + matches: [{ + address: '0x7FF612340080', + regionBase: '0x7FF612340000', + regionSize: 65536, + protection: 4, + contextAddress: '0x7FF612340060', + contextHex: '00'.repeat(80), + }], + }), + }), + }; + assert.equal(await main([ + 'memory', 'scan', + '--pattern', 'CFB27A1100A1B2C3D4E5F60718293A4B', + '--mask', 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + '--max-matches', '8', + '--context', '32', + ], { sdk, io }), 0); + assert.match(output.stdout, /1 match, 4096 bytes scanned/); + assert.match(output.stdout, /0x7FF612340080/); + assert.match(output.stdout, /region 0x7FF612340000/); +}); + +test('telemetry register delegates exact type names and preserves JSON result', async () => { + const calls = []; + const result = { types: ['recruiting.snapshot', 'recruiting.stability'] }; + const { io, output } = memoryIo(); + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ + registerTelemetryTypes: async (types) => { calls.push(types); return result; }, + }), + }; + assert.equal(await main([ + 'telemetry', 'register', 'recruiting.snapshot', 'recruiting.stability', '--json', + ], { sdk, io }), 0); + assert.deepEqual(calls, [['recruiting.snapshot', 'recruiting.stability']]); + assert.deepEqual(JSON.parse(output.stdout), { + ok: true, + command: 'telemetry register', + result, + }); +}); + +test('developer commands reject missing gates, write-like arguments, and malformed ranges', async () => { + const cases = [ + [['memory', 'scan', '--pattern', 'CFB27A1100A1B2C3D4E5F60718293A4B'], /--mask/], + [['memory', 'scan', '--pattern', 'CFB27A1100A1B2C3D4E5F60718293A4B', '--mask', 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', '--max-matches', '8', '--context', '32', '--write', '00'], /Unknown option/], + [['memory', 'read', '--range', '0x7ff612340000:192'], /canonical/], + [['memory', 'read', '--range', '0x00007FF612340000:192'], /canonical/], + [['memory', 'read', '--range', '0x10000000000000000:1'], /canonical/], + [['memory', 'read', '--range', '0x7FF612340000:0'], /length/], + [['memory', 'read', '--address', '0x7FF612340000', '--value', '00'], /Unknown option/], + [['telemetry', 'register'], /type name/], + ]; + for (const [argv, pattern] of cases) { + const { io, output } = memoryIo(); + assert.equal(await main(argv, { sdk: {}, io }), 2, argv.join(' ')); + assert.match(output.stderr, pattern, argv.join(' ')); + } +}); + +test('allow-unsupported-build is explicit and only valid for memory diagnostics', async () => { + const calls = []; + const sdk = { + discoverGame: async () => ({ pid: 27 }), + createClient: () => ({ + readMemory: async (options) => { calls.push(options); return { supportedBuild: true, ranges: [] }; }, + }), + }; + assert.equal(await main([ + 'memory', 'read', '--range', '0x7FF612340000:192', '--allow-unsupported-build', + ], { sdk, io: memoryIo().io }), 0); + assert.deepEqual(calls, [{ + ranges: [{ address: '0x7FF612340000', length: 192 }], + allowUnsupportedBuild: true, + }]); + + const invalid = memoryIo(); + assert.equal(await main(['status', '--allow-unsupported-build'], { + sdk: {}, io: invalid.io, + }), 2); + assert.match(invalid.output.stderr, /only valid for memory/); +}); + +test('developer-only options are rejected outside their exact diagnostic operation', async () => { + const cases = [ + ['status', '--pattern', '0011223344556677'], + ['memory', 'read', '--range', '0x7FF612340000:16', '--context', '4'], + ['memory', 'scan', '--pattern', '0011223344556677', '--mask', 'FFFFFFFFFFFFFFFF', '--max-matches', '1', '--context', '0', '--range', '0x1:1'], + ['telemetry', 'register', 'probe.snapshot', '--max-matches', '1'], + ]; + for (const argv of cases) { + const { io, output } = memoryIo(); + assert.equal(await main(argv, { sdk: {}, io }), 2, argv.join(' ')); + assert.match(output.stderr, /not valid/, argv.join(' ')); + } +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 44dca95..09aa28e 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@cfb27/lua-hook", - "version": "0.1.0-dev.1", + "version": "0.2.0-dev.1", "description": "Node SDK for the offline CFB27 Lua hook.", "main": "index.cjs", "type": "commonjs", diff --git a/packages/sdk/src/client.cjs b/packages/sdk/src/client.cjs index 00f6d09..4e4b2e0 100644 --- a/packages/sdk/src/client.cjs +++ b/packages/sdk/src/client.cjs @@ -8,6 +8,243 @@ const { Cfb27HookError } = require('./errors.cjs'); const { encodeFrame, FrameDecoder } = require('./frame.cjs'); const { discoverGame } = require('./process.cjs'); +const MEMORY_LIMITS = Object.freeze({ + minPatternBytes: 8, + maxPatternBytes: 4096, + maxMatches: 64, + maxContextBytes: 512, + maxScanPageBytes: 32 * 1024 * 1024, + maxPages: 4096, + maxReadRanges: 64, + maxReadRangeBytes: 64 * 1024, + maxReadBytes: 256 * 1024, +}); + +const CANONICAL_ADDRESS = /^0x(?:0|[1-9A-F][0-9A-F]{0,15})$/; +const UPPER_HEX_BYTES = /^(?:[0-9A-F]{2})+$/; +const TELEMETRY_TYPE = /^[a-z][a-z0-9_.-]{0,63}$/; +const RESERVED_TELEMETRY_TYPES = new Set(['game_ready', 'tick', 'log']); +const PIPE_CONNECT_RETRY_DELAY_MS = 10; + +function invalidRequest(message) { + return new Cfb27HookError('INVALID_REQUEST', message); +} + +function invalidResponse(message) { + return new Cfb27HookError('INVALID_RESPONSE', message); +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasExactKeys(value, keys) { + if (!isObject(value)) return false; + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function hasOnlyKeys(value, keys) { + return isObject(value) && Object.keys(value).every((key) => keys.includes(key)); +} + +function isSafeIntegerBetween(value, minimum, maximum) { + return Number.isSafeInteger(value) && value >= minimum && value <= maximum; +} + +function isCanonicalAddress(value) { + return typeof value === 'string' && CANONICAL_ADDRESS.test(value); +} + +function cloneUpperHex(value, minimumBytes, maximumBytes, fieldName) { + if (typeof value !== 'string' || !UPPER_HEX_BYTES.test(value)) { + throw invalidRequest(`${fieldName} must contain uppercase hexadecimal bytes`); + } + const byteLength = value.length / 2; + if (byteLength < minimumBytes || byteLength > maximumBytes) { + throw invalidRequest(`${fieldName} byte length is outside the supported limits`); + } + return value.toUpperCase(); +} + +function cloneScanPageOptions(options) { + const keys = ['patternHex', 'maskHex', 'maxMatches', 'contextBefore', 'contextAfter', + 'allowUnsupportedBuild', 'cursor']; + if (!hasOnlyKeys(options, keys)) throw invalidRequest('scanMemory options are invalid'); + const patternHex = cloneUpperHex( + options.patternHex, + MEMORY_LIMITS.minPatternBytes, + MEMORY_LIMITS.maxPatternBytes, + 'patternHex', + ); + const maskHex = cloneUpperHex( + options.maskHex, + MEMORY_LIMITS.minPatternBytes, + MEMORY_LIMITS.maxPatternBytes, + 'maskHex', + ); + if (maskHex.length !== patternHex.length) { + throw invalidRequest('maskHex must have the same byte length as patternHex'); + } + if (!isSafeIntegerBetween(options.maxMatches, 1, MEMORY_LIMITS.maxMatches) || + !isSafeIntegerBetween(options.contextBefore, 0, MEMORY_LIMITS.maxContextBytes) || + !isSafeIntegerBetween(options.contextAfter, 0, MEMORY_LIMITS.maxContextBytes) || + options.contextBefore + options.contextAfter > MEMORY_LIMITS.maxContextBytes) { + throw invalidRequest('scanMemory numeric options are outside the supported limits'); + } + if (Object.hasOwn(options, 'allowUnsupportedBuild') && + typeof options.allowUnsupportedBuild !== 'boolean') { + throw invalidRequest('allowUnsupportedBuild must be a boolean'); + } + if (Object.hasOwn(options, 'cursor') && !isCanonicalAddress(options.cursor)) { + throw invalidRequest('cursor must be a canonical uppercase address'); + } + + const clone = { + patternHex, + maskHex, + maxMatches: options.maxMatches, + contextBefore: options.contextBefore, + contextAfter: options.contextAfter, + }; + if (Object.hasOwn(options, 'allowUnsupportedBuild')) { + clone.allowUnsupportedBuild = options.allowUnsupportedBuild; + } + if (Object.hasOwn(options, 'cursor')) clone.cursor = options.cursor; + return clone; +} + +function cloneAggregateScanOptions(options) { + if (!hasOnlyKeys(options, ['patternHex', 'maskHex', 'maxMatches', 'contextBefore', + 'contextAfter', 'allowUnsupportedBuild', 'maxPages']) || Object.hasOwn(options, 'cursor')) { + throw invalidRequest('scanMemory aggregate options are invalid'); + } + const maxPages = Object.hasOwn(options, 'maxPages') ? options.maxPages : MEMORY_LIMITS.maxPages; + if (!isSafeIntegerBetween(maxPages, 1, MEMORY_LIMITS.maxPages)) { + throw invalidRequest('maxPages must be an integer from 1 through 4096'); + } + const pageOptions = { ...options }; + delete pageOptions.maxPages; + return { pageOptions: cloneScanPageOptions(pageOptions), maxPages }; +} + +function cloneReadOptions(options) { + if (!hasOnlyKeys(options, ['ranges', 'allowUnsupportedBuild']) || + !Array.isArray(options.ranges) || + options.ranges.length < 1 || + options.ranges.length > MEMORY_LIMITS.maxReadRanges) { + throw invalidRequest('readMemory options are invalid'); + } + if (Object.hasOwn(options, 'allowUnsupportedBuild') && + typeof options.allowUnsupportedBuild !== 'boolean') { + throw invalidRequest('allowUnsupportedBuild must be a boolean'); + } + + let totalBytes = 0; + const ranges = options.ranges.map((range) => { + if (!hasExactKeys(range, ['address', 'length']) || !isCanonicalAddress(range.address) || + !isSafeIntegerBetween(range.length, 1, MEMORY_LIMITS.maxReadRangeBytes) || + totalBytes > MEMORY_LIMITS.maxReadBytes - range.length) { + throw invalidRequest('readMemory range is invalid or exceeds the supported limits'); + } + totalBytes += range.length; + return { address: range.address, length: range.length }; + }); + + const clone = { ranges }; + if (Object.hasOwn(options, 'allowUnsupportedBuild')) { + clone.allowUnsupportedBuild = options.allowUnsupportedBuild; + } + return clone; +} + +function validateScanPageResult(result, params) { + if (!hasExactKeys(result, ['supportedBuild', 'complete', 'nextCursor', 'scannedBytes', 'matches']) || + typeof result.supportedBuild !== 'boolean' || + (result.supportedBuild === false && params.allowUnsupportedBuild !== true) || + typeof result.complete !== 'boolean' || + (result.complete ? result.nextCursor !== null : !isCanonicalAddress(result.nextCursor)) || + !isSafeIntegerBetween(result.scannedBytes, 0, MEMORY_LIMITS.maxScanPageBytes) || + !Array.isArray(result.matches) || result.matches.length > params.maxMatches) { + throw invalidResponse('Host returned an invalid scanMemory result'); + } + if (!result.complete && Object.hasOwn(params, 'cursor') && + BigInt(result.nextCursor) <= BigInt(params.cursor)) { + throw invalidResponse('Host returned a non-advancing scanMemory cursor'); + } + + const maximumContextBytes = params.patternHex.length / 2 + + params.contextBefore + params.contextAfter; + for (const match of result.matches) { + if (!hasExactKeys(match, ['address', 'regionBase', 'regionSize', 'protection', + 'contextAddress', 'contextHex']) || + !isCanonicalAddress(match.address) || !isCanonicalAddress(match.regionBase) || + !isSafeIntegerBetween(match.regionSize, 1, Number.MAX_SAFE_INTEGER) || + !isSafeIntegerBetween(match.protection, 0, 0xFFFFFFFF) || + !isCanonicalAddress(match.contextAddress) || + typeof match.contextHex !== 'string' || !UPPER_HEX_BYTES.test(match.contextHex) || + !isSafeIntegerBetween( + match.contextHex.length / 2, + params.patternHex.length / 2, + maximumContextBytes, + )) { + throw invalidResponse('Host returned an invalid scanMemory match'); + } + } + return result; +} + +function validateReadResult(result, params) { + if (!hasExactKeys(result, ['supportedBuild', 'ranges']) || + typeof result.supportedBuild !== 'boolean' || + (result.supportedBuild === false && params.allowUnsupportedBuild !== true) || + !Array.isArray(result.ranges) || + result.ranges.length !== params.ranges.length || + result.ranges.length > MEMORY_LIMITS.maxReadRanges) { + throw invalidResponse('Host returned an invalid readMemory result'); + } + + for (let index = 0; index < result.ranges.length; index += 1) { + const range = result.ranges[index]; + const requested = params.ranges[index]; + if (!hasExactKeys(range, ['address', 'length', 'bytesHex']) || + !isCanonicalAddress(range.address) || range.address !== requested.address || + range.length !== requested.length || + typeof range.bytesHex !== 'string' || !UPPER_HEX_BYTES.test(range.bytesHex) || + range.bytesHex.length !== range.length * 2) { + throw invalidResponse('Host returned an invalid readMemory range'); + } + } + return result; +} + +function cloneTelemetryTypes(types) { + if (!Array.isArray(types) || types.length < 1 || types.length > 16) { + throw invalidRequest('registerTelemetryTypes requires 1 to 16 type names'); + } + const clone = []; + const seen = new Set(); + for (const type of types) { + if (typeof type !== 'string' || !TELEMETRY_TYPE.test(type) || + RESERVED_TELEMETRY_TYPES.has(type) || seen.has(type)) { + throw invalidRequest('Telemetry type names are invalid, reserved, or duplicated'); + } + seen.add(type); + clone.push(type); + } + return clone; +} + +function validateTelemetryRegistration(result, types) { + if (!hasExactKeys(result, ['types']) || !Array.isArray(result.types) || + result.types.length !== types.length || + !result.types.every((type, index) => type === types[index])) { + throw invalidResponse('Host returned an invalid registerTelemetry result'); + } + return result; +} + function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { if (!pipeName && (!Number.isInteger(pid) || pid <= 0)) { throw new Cfb27HookError('INVALID_REQUEST', 'createClient requires a positive PID or pipe name'); @@ -22,7 +259,9 @@ function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { const id = crypto.randomUUID(); return new Promise((resolve, reject) => { const decoder = new FrameDecoder(); - const socket = net.createConnection(resolvedPipeName); + let socket; + let retryTimer; + let commandSent = false; let settled = false; const timer = setTimeout(() => { finish(new Cfb27HookError('PIPE_TIMEOUT', `Host did not respond within ${timeoutMs} ms`, { @@ -34,57 +273,76 @@ function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { if (settled) return; settled = true; clearTimeout(timer); - socket.destroy(); + clearTimeout(retryTimer); + socket?.destroy(); if (error) reject(error); else resolve(result); } - socket.once('connect', () => { - try { - socket.write(encodeFrame({ protocol: 1, id, command, params })); - } catch (error) { - finish(error); - } - }); - socket.on('data', (chunk) => { - let responses; - try { - responses = decoder.push(chunk); - } catch (error) { - finish(error); - return; - } - for (const response of responses) { - if (!response || response.protocol !== 1) { - finish(new Cfb27HookError('PROTOCOL_MISMATCH', 'Host protocol version does not match')); + function connect() { + if (settled) return; + const attemptSocket = net.createConnection(resolvedPipeName); + socket = attemptSocket; + let connected = false; + + attemptSocket.once('connect', () => { + connected = true; + commandSent = true; + try { + attemptSocket.write(encodeFrame({ protocol: 1, id, command, params })); + } catch (error) { + finish(error); + } + }); + attemptSocket.on('data', (chunk) => { + let responses; + try { + responses = decoder.push(chunk); + } catch (error) { + finish(error); return; } - if (response.id !== id || typeof response.ok !== 'boolean') { - finish(new Cfb27HookError('INVALID_RESPONSE', 'Host response does not match the request')); + for (const response of responses) { + if (!response || response.protocol !== 1) { + finish(new Cfb27HookError('PROTOCOL_MISMATCH', 'Host protocol version does not match')); + return; + } + if (response.id !== id || typeof response.ok !== 'boolean') { + finish(new Cfb27HookError('INVALID_RESPONSE', 'Host response does not match the request')); + return; + } + if (!response.ok) { + const hostError = response.error || {}; + finish(new Cfb27HookError( + typeof hostError.code === 'string' ? hostError.code : 'INVALID_RESPONSE', + typeof hostError.message === 'string' ? hostError.message : 'Host request failed', + hostError.details, + )); + return; + } + finish(null, response.result); return; } - if (!response.ok) { - const hostError = response.error || {}; - finish(new Cfb27HookError( - typeof hostError.code === 'string' ? hostError.code : 'INVALID_RESPONSE', - typeof hostError.message === 'string' ? hostError.message : 'Host request failed', - hostError.details, - )); + }); + attemptSocket.once('end', () => { + if (!settled) { + finish(new Cfb27HookError('INVALID_RESPONSE', 'Host closed without a response')); + } + }); + attemptSocket.once('error', (error) => { + if (!connected && !commandSent && error.code === 'ENOENT') { + attemptSocket.destroy(); + retryTimer = setTimeout(connect, PIPE_CONNECT_RETRY_DELAY_MS); return; } - finish(null, response.result); - return; - } - }); - socket.once('end', () => { - if (!settled) finish(new Cfb27HookError('INVALID_RESPONSE', 'Host closed without a response')); - }); - socket.once('error', (error) => { - finish(new Cfb27HookError('HOST_NOT_READY', 'Could not connect to the Lua host', { - pipeName: resolvedPipeName, - cause: error.message, - })); - }); + finish(new Cfb27HookError('HOST_NOT_READY', 'Could not connect to the Lua host', { + pipeName: resolvedPipeName, + cause: error.message, + })); + }); + } + + connect(); }); } @@ -112,6 +370,58 @@ function createClient({ pid, pipeName, timeoutMs = 3000 } = {}) { getEvents({ after = 0, limit = 100 } = {}) { return request('events', { after, limit }); }, + async scanMemoryPage(options = {}) { + const params = cloneScanPageOptions(options); + return validateScanPageResult(await request('scanMemory', params), params); + }, + async scanMemory(options = {}) { + const { pageOptions, maxPages } = cloneAggregateScanOptions(options); + const matches = []; + const cursors = new Set(); + let cursor; + let scannedBytes = 0; + let supportedBuild; + + for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) { + const remainingMatches = pageOptions.maxMatches - matches.length; + const params = { ...pageOptions, maxMatches: Math.max(1, remainingMatches) }; + if (cursor) params.cursor = cursor; + const page = validateScanPageResult(await request('scanMemory', params), params); + if (supportedBuild === undefined) supportedBuild = page.supportedBuild; + else if (page.supportedBuild !== supportedBuild) { + throw invalidResponse('Host changed supportedBuild during scanMemory'); + } + if (!Number.isSafeInteger(scannedBytes + page.scannedBytes)) { + throw invalidResponse('Host scan byte total exceeds the safe integer range'); + } + scannedBytes += page.scannedBytes; + matches.push(...page.matches); + if (matches.length > pageOptions.maxMatches) { + throw new Cfb27HookError('TOO_MANY_MATCHES', 'Memory scan found too many matches'); + } + if (page.complete) { + return { supportedBuild, complete: true, scannedBytes, matches }; + } + if (cursors.has(page.nextCursor) || + (cursor && BigInt(page.nextCursor) <= BigInt(cursor))) { + throw invalidResponse('Host returned a non-progressing scan cursor'); + } + cursors.add(page.nextCursor); + cursor = page.nextCursor; + } + throw new Cfb27HookError('SCAN_LIMIT_EXCEEDED', 'scanMemory exceeded maxPages'); + }, + async readMemory(options = {}) { + const params = cloneReadOptions(options); + return validateReadResult(await request('readMemory', params), params); + }, + async registerTelemetryTypes(types) { + const clonedTypes = cloneTelemetryTypes(types); + return validateTelemetryRegistration( + await request('registerTelemetry', { types: clonedTypes }), + clonedTypes, + ); + }, }); } diff --git a/packages/sdk/src/errors.cjs b/packages/sdk/src/errors.cjs index 034ccc1..ef6c89b 100644 --- a/packages/sdk/src/errors.cjs +++ b/packages/sdk/src/errors.cjs @@ -12,6 +12,9 @@ const ERROR_CODES = Object.freeze([ 'INVALID_REQUEST', 'INVALID_RESPONSE', 'SCRIPT_ERROR', + 'MEMORY_ACCESS_DENIED', + 'SCAN_LIMIT_EXCEEDED', + 'TOO_MANY_MATCHES', 'INSTALLATION_CONFLICT', 'BACKUP_VERIFICATION_FAILED', ]); diff --git a/packages/sdk/test/client.test.cjs b/packages/sdk/test/client.test.cjs index e2bbd0e..b265b76 100644 --- a/packages/sdk/test/client.test.cjs +++ b/packages/sdk/test/client.test.cjs @@ -4,6 +4,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const net = require('node:net'); const { createClient } = require('../src/client.cjs'); +const { ERROR_CODES } = require('../src/errors.cjs'); const { FrameDecoder, encodeFrame } = require('../src/frame.cjs'); function listen(server, pipeName) { @@ -13,6 +14,69 @@ function listen(server, pipeName) { }); } +function testPipeName(label) { + return `\\\\.\\pipe\\cfb27-${label}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +async function fakeClient(t, responder) { + const pipeName = testPipeName('memory'); + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + const response = encodeFrame({ + protocol: 1, + id: request.id, + ok: true, + result: responder(request), + }); + socket.end(response); + } + }); + }); + await listen(server, pipeName); + t.after(() => server.close()); + return createClient({ pipeName, timeoutMs: 1000 }); +} + +const VALID_SCAN_OPTIONS = Object.freeze({ + patternHex: 'CFB27A1100A1B2C3D4E5F60718293A4B', + maskHex: 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + maxMatches: 2, + contextBefore: 4, + contextAfter: 4, +}); + +const VALID_SCAN_RESULT = Object.freeze({ + supportedBuild: true, + complete: true, + nextCursor: null, + scannedBytes: 65536, + matches: Object.freeze([Object.freeze({ + address: '0x7FF612340080', + regionBase: '0x7FF612340000', + regionSize: 65536, + protection: 4, + contextAddress: '0x7FF61234007C', + contextHex: '00000000CFB27A1100A1B2C3D4E5F60718293A4B00000000', + })]), +}); + +const VALID_READ_RESULT = Object.freeze({ + supportedBuild: true, + ranges: Object.freeze([Object.freeze({ + address: '0x7FF612340000', + length: 16, + bytesHex: 'CFB27A1100A1B2C3D4E5F60718293A4B', + })]), +}); + +test('SDK publishes stable memory error codes', () => { + for (const code of ['MEMORY_ACCESS_DENIED', 'SCAN_LIMIT_EXCEEDED', 'TOO_MANY_MATCHES']) { + assert.ok(ERROR_CODES.includes(code), `missing ${code}`); + } +}); + test('client negotiates hello and preserves multiline evaluate', async (t) => { const pipeName = `\\\\.\\pipe\\cfb27-test-${process.pid}-${Date.now()}`; const server = net.createServer((socket) => { @@ -38,10 +102,444 @@ test('client negotiates hello and preserves multiline evaluate', async (t) => { test('client maps a silent host to PIPE_TIMEOUT', async (t) => { const pipeName = `\\\\.\\pipe\\cfb27-timeout-${process.pid}-${Date.now()}`; - const server = net.createServer(() => {}); + let connections = 0; + const server = net.createServer(() => { + connections += 1; + }); await listen(server, pipeName); t.after(() => server.close()); const client = createClient({ pipeName, timeoutMs: 25 }); await assert.rejects(client.status(), (error) => error.code === 'PIPE_TIMEOUT'); + assert.equal(connections, 1); +}); + +test('client applies one total deadline while a named pipe stays absent', async () => { + const timeoutMs = 35; + const startedAt = Date.now(); + const client = createClient({ pipeName: testPipeName('absent'), timeoutMs }); + + await assert.rejects(client.status(), (error) => error.code === 'PIPE_TIMEOUT'); + const elapsedMs = Date.now() - startedAt; + assert.ok(elapsedMs >= timeoutMs, `request ended before its deadline (${elapsedMs} ms)`); + assert.ok(elapsedMs < 250, `request deadline was reset by retries (${elapsedMs} ms)`); +}); + +test('scanMemory retries a transient gap between named-pipe server instances', async (t) => { + const pipeName = testPipeName('pipe-gap'); + const server = net.createServer(); + const requests = []; + let relistenTimer; + + server.on('connection', (socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + requests.push(request); + const firstPage = requests.length === 1; + const result = firstPage + ? { supportedBuild: true, complete: false, nextCursor: '0x1000', + scannedBytes: 32, matches: [] } + : { supportedBuild: true, complete: true, nextCursor: null, + scannedBytes: 8, matches: [] }; + + if (firstPage) { + server.close(() => { + relistenTimer = setTimeout(() => server.listen(pipeName), 40); + }); + } + socket.end(encodeFrame({ protocol: 1, id: request.id, ok: true, result })); + } + }); + }); + await listen(server, pipeName); + t.after(async () => { + clearTimeout(relistenTimer); + if (server.listening) await new Promise((resolve) => server.close(resolve)); + }); + + const client = createClient({ pipeName, timeoutMs: 500 }); + assert.deepEqual( + await client.scanMemory({ ...VALID_SCAN_OPTIONS, maxPages: 2 }), + { supportedBuild: true, complete: true, scannedBytes: 40, matches: [] }, + ); + assert.deepEqual(requests.map((request) => request.params.cursor), [undefined, '0x1000']); +}); + +test('client does not retry after a host response error', async (t) => { + const pipeName = testPipeName('host-error'); + let requests = 0; + const server = net.createServer((socket) => { + const decoder = new FrameDecoder(); + socket.on('data', (chunk) => { + for (const request of decoder.push(chunk)) { + requests += 1; + server.close(); + socket.end(encodeFrame({ + protocol: 1, + id: request.id, + ok: false, + error: { code: 'TEST_HOST_ERROR', message: 'Host rejected the request' }, + })); + } + }); + }); + await listen(server, pipeName); + t.after(async () => { + if (server.listening) await new Promise((resolve) => server.close(resolve)); + }); + + const client = createClient({ pipeName, timeoutMs: 100 }); + await assert.rejects(client.status(), (error) => error.code === 'TEST_HOST_ERROR'); + assert.equal(requests, 1); +}); + +test('memory APIs clone options and send exact typed commands', async (t) => { + const requests = []; + const client = await fakeClient(t, (request) => { + requests.push({ command: request.command, params: request.params }); + return request.command === 'scanMemory' ? VALID_SCAN_RESULT : VALID_READ_RESULT; + }); + + const scanOptions = { ...VALID_SCAN_OPTIONS }; + const scanPromise = client.scanMemoryPage(scanOptions); + scanOptions.patternHex = '0000000000000000'; + scanOptions.maxMatches = 64; + scanOptions.extra = true; + assert.deepEqual(await scanPromise, VALID_SCAN_RESULT); + + const readOptions = { ranges: [{ address: '0x7FF612340000', length: 16 }] }; + const readPromise = client.readMemory(readOptions); + readOptions.ranges[0].address = '0x1'; + readOptions.ranges.push({ address: '0x2', length: 1 }); + assert.deepEqual(await readPromise, VALID_READ_RESULT); + + assert.deepEqual(requests, [ + { command: 'scanMemory', params: VALID_SCAN_OPTIONS }, + { + command: 'readMemory', + params: { ranges: [{ address: '0x7FF612340000', length: 16 }] }, + }, + ]); +}); + +test('memory APIs reject invalid requests before creating a socket', async () => { + const originalCreateConnection = net.createConnection; + let socketCreations = 0; + net.createConnection = (...args) => { + socketCreations += 1; + return originalCreateConnection(...args); + }; + + try { + const client = createClient({ pipeName: testPipeName('unused'), timeoutMs: 25 }); + const scanCases = [ + { ...VALID_SCAN_OPTIONS, extra: true }, + { ...VALID_SCAN_OPTIONS, patternHex: VALID_SCAN_OPTIONS.patternHex.toLowerCase() }, + { ...VALID_SCAN_OPTIONS, patternHex: `${VALID_SCAN_OPTIONS.patternHex}F`, maskHex: `${VALID_SCAN_OPTIONS.maskHex}F` }, + { ...VALID_SCAN_OPTIONS, patternHex: 'GGGGGGGGGGGGGGGG' }, + { ...VALID_SCAN_OPTIONS, maskHex: 'FFFFFFFFFFFFFFFF' }, + { ...VALID_SCAN_OPTIONS, patternHex: '00112233445566', maskHex: 'FFFFFFFFFFFFFF' }, + { ...VALID_SCAN_OPTIONS, patternHex: '00'.repeat(4097), maskHex: 'FF'.repeat(4097) }, + { ...VALID_SCAN_OPTIONS, maxMatches: 65 }, + { ...VALID_SCAN_OPTIONS, maxMatches: Number.MAX_SAFE_INTEGER + 1 }, + { ...VALID_SCAN_OPTIONS, contextBefore: 256, contextAfter: 257 }, + { ...VALID_SCAN_OPTIONS, allowUnsupportedBuild: 'true' }, + { ...VALID_SCAN_OPTIONS, cursor: '0xabcdef' }, + { ...VALID_SCAN_OPTIONS, cursor: 4096 }, + ]; + for (const options of scanCases) { + await assert.rejects( + Promise.resolve().then(() => client.scanMemoryPage(options)), + (error) => error.code === 'INVALID_REQUEST', + ); + } + await assert.rejects( + Promise.resolve().then(() => client.scanMemory({ ...VALID_SCAN_OPTIONS, cursor: '0x1000' })), + (error) => error.code === 'INVALID_REQUEST', + ); + for (const maxPages of [0, 4097, Number.MAX_SAFE_INTEGER + 1, 1.5]) { + await assert.rejects( + Promise.resolve().then(() => client.scanMemory({ ...VALID_SCAN_OPTIONS, maxPages })), + (error) => error.code === 'INVALID_REQUEST', + ); + } + + const validRange = { address: '0x7FF612340000', length: 16 }; + const readCases = [ + { ranges: [validRange], extra: true }, + { ranges: [{ ...validRange, extra: true }] }, + { ranges: [{ address: 0x1234, length: 16 }] }, + { ranges: [{ address: '0x7ff612340000', length: 16 }] }, + { ranges: [{ address: '0x0001', length: 16 }] }, + { ranges: [{ address: '7FF612340000', length: 16 }] }, + { ranges: [{ address: validRange.address, length: Number.MAX_SAFE_INTEGER + 1 }] }, + { ranges: [{ address: validRange.address, length: 65537 }] }, + { ranges: Array.from({ length: 65 }, () => ({ ...validRange })) }, + { ranges: Array.from({ length: 5 }, (_, index) => ({ address: `0x${index + 1}`, length: 65536 })) }, + { ranges: [] }, + { ranges: [validRange], allowUnsupportedBuild: 1 }, + ]; + for (const options of readCases) { + await assert.rejects( + Promise.resolve().then(() => client.readMemory(options)), + (error) => error.code === 'INVALID_REQUEST', + ); + } + assert.equal(socketCreations, 0); + } finally { + net.createConnection = originalCreateConnection; + } +}); + +test('scanMemoryPage rejects malformed host result fields', async (t) => { + const invalidResults = [ + { ...VALID_SCAN_RESULT, supportedBuild: 1 }, + { ...VALID_SCAN_RESULT, complete: undefined }, + { ...VALID_SCAN_RESULT, complete: false, nextCursor: null }, + { ...VALID_SCAN_RESULT, nextCursor: '0x1000' }, + { ...VALID_SCAN_RESULT, complete: false, nextCursor: 4096 }, + { ...VALID_SCAN_RESULT, complete: false, nextCursor: '0xabcdef' }, + { ...VALID_SCAN_RESULT, scannedBytes: Number.MAX_SAFE_INTEGER + 1 }, + { ...VALID_SCAN_RESULT, matches: Array.from({ length: 65 }, () => VALID_SCAN_RESULT.matches[0]) }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], address: 140694844080256 }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], address: '0x0001' }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], regionBase: '0x7ff612340000' }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], regionBase: '0x0001' }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], regionSize: -1 }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], protection: 4.5 }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], contextAddress: '0x7ff61234007C' }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], contextAddress: '0x0001' }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], contextHex: 'ABC' }] }, + { ...VALID_SCAN_RESULT, matches: [{ ...VALID_SCAN_RESULT.matches[0], contextHex: '00'.repeat(25) }] }, + ]; + let index = 0; + const client = await fakeClient(t, () => invalidResults[index++]); + + for (const ignored of invalidResults) { + await assert.rejects( + client.scanMemoryPage(VALID_SCAN_OPTIONS), + (error) => error.code === 'INVALID_RESPONSE', + ); + } +}); + +test('scanMemoryPage rejects non-advancing continuation cursors', async (t) => { + const invalidResults = [ + { ...VALID_SCAN_RESULT, complete: false, nextCursor: '0x2000' }, + { ...VALID_SCAN_RESULT, complete: false, nextCursor: '0x1FFF' }, + ]; + let index = 0; + const client = await fakeClient(t, () => invalidResults[index++]); + + for (const ignored of invalidResults) { + await assert.rejects( + client.scanMemoryPage({ ...VALID_SCAN_OPTIONS, cursor: '0x2000' }), + (error) => error.code === 'INVALID_RESPONSE', + ); + } +}); + +test('readMemory rejects malformed host result fields', async (t) => { + const invalidResults = [ + { ...VALID_READ_RESULT, supportedBuild: 'true' }, + { ...VALID_READ_RESULT, ranges: Array.from({ length: 65 }, () => VALID_READ_RESULT.ranges[0]) }, + { ...VALID_READ_RESULT, ranges: [{ ...VALID_READ_RESULT.ranges[0], address: 140694844080128 }] }, + { ...VALID_READ_RESULT, ranges: [{ ...VALID_READ_RESULT.ranges[0], address: '0x7ff612340000' }] }, + { ...VALID_READ_RESULT, ranges: [{ ...VALID_READ_RESULT.ranges[0], address: '0x0001' }] }, + { ...VALID_READ_RESULT, ranges: [{ ...VALID_READ_RESULT.ranges[0], length: 15 }] }, + { ...VALID_READ_RESULT, ranges: [{ ...VALID_READ_RESULT.ranges[0], bytesHex: '00'.repeat(15) }] }, + ]; + let index = 0; + const client = await fakeClient(t, () => invalidResults[index++]); + + for (const ignored of invalidResults) { + await assert.rejects( + client.readMemory({ ranges: [{ address: '0x7FF612340000', length: 16 }] }), + (error) => error.code === 'INVALID_RESPONSE', + ); + } +}); + +test('memory APIs reject unsupported-build results unless explicitly allowed', async (t) => { + const client = await fakeClient(t, (request) => request.command === 'scanMemory' + ? { ...VALID_SCAN_RESULT, supportedBuild: false } + : { ...VALID_READ_RESULT, supportedBuild: false }); + + await assert.rejects( + client.scanMemoryPage(VALID_SCAN_OPTIONS), + (error) => error.code === 'INVALID_RESPONSE', + ); + await assert.rejects( + client.readMemory({ ranges: [{ address: '0x7FF612340000', length: 16 }] }), + (error) => error.code === 'INVALID_RESPONSE', + ); + await assert.rejects( + client.scanMemoryPage({ ...VALID_SCAN_OPTIONS, allowUnsupportedBuild: false }), + (error) => error.code === 'INVALID_RESPONSE', + ); + await assert.rejects( + client.readMemory({ + ranges: [{ address: '0x7FF612340000', length: 16 }], + allowUnsupportedBuild: false, + }), + (error) => error.code === 'INVALID_RESPONSE', + ); + + assert.deepEqual( + await client.scanMemoryPage({ ...VALID_SCAN_OPTIONS, allowUnsupportedBuild: true }), + { ...VALID_SCAN_RESULT, supportedBuild: false }, + ); + assert.deepEqual( + await client.readMemory({ + ranges: [{ address: '0x7FF612340000', length: 16 }], + allowUnsupportedBuild: true, + }), + { ...VALID_READ_RESULT, supportedBuild: false }, + ); +}); + +test('scanMemory aggregates pages and sends exact continuation cursors', async (t) => { + const pages = [ + { supportedBuild: true, complete: false, nextCursor: '0x1000', scannedBytes: 32, matches: [] }, + { supportedBuild: true, complete: false, nextCursor: '0x2000', scannedBytes: 32, + matches: [VALID_SCAN_RESULT.matches[0]] }, + { supportedBuild: true, complete: true, nextCursor: null, scannedBytes: 8, matches: [] }, + ]; + const seen = []; + const client = await fakeClient(t, (request) => { + seen.push(request); + return pages.shift(); + }); + + assert.deepEqual( + await client.scanMemory({ ...VALID_SCAN_OPTIONS, maxPages: 3 }), + { supportedBuild: true, complete: true, scannedBytes: 72, + matches: [VALID_SCAN_RESULT.matches[0]] }, + ); + assert.deepEqual(seen.map((request) => request.params.cursor), + [undefined, '0x1000', '0x2000']); + assert.ok(seen.every((request) => !Object.hasOwn(request.params, 'maxPages'))); +}); + +test('scanMemory rejects hostile pagination responses', async (t) => { + const scenarios = [ + [ + { supportedBuild: true, complete: false, nextCursor: '0x1000', scannedBytes: 1, matches: [] }, + { supportedBuild: true, complete: false, nextCursor: '0x1000', scannedBytes: 1, matches: [] }, + ], + [ + { supportedBuild: true, complete: false, nextCursor: '0x2000', scannedBytes: 1, matches: [] }, + { supportedBuild: true, complete: false, nextCursor: '0x1000', scannedBytes: 1, matches: [] }, + ], + [ + { supportedBuild: true, complete: false, nextCursor: '0x1000', + scannedBytes: Number.MAX_SAFE_INTEGER, matches: [] }, + { supportedBuild: true, complete: true, nextCursor: null, scannedBytes: 1, matches: [] }, + ], + [ + { supportedBuild: true, complete: false, nextCursor: '0x1000', scannedBytes: 1, + matches: [VALID_SCAN_RESULT.matches[0]] }, + { supportedBuild: true, complete: true, nextCursor: null, scannedBytes: 1, + matches: [VALID_SCAN_RESULT.matches[0]] }, + ], + [ + { supportedBuild: true, complete: false, nextCursor: '0x1000', scannedBytes: 1, matches: [] }, + { supportedBuild: false, complete: true, nextCursor: null, scannedBytes: 1, matches: [] }, + ], + ]; + + for (const scenario of scenarios) { + const pages = [...scenario]; + const client = await fakeClient(t, () => pages.shift()); + const options = scenario === scenarios[3] + ? { ...VALID_SCAN_OPTIONS, maxMatches: 1 } + : { ...VALID_SCAN_OPTIONS, allowUnsupportedBuild: true }; + await assert.rejects( + client.scanMemory(options), + (error) => error.code === 'INVALID_RESPONSE' || error.code === 'TOO_MANY_MATCHES', + ); + } +}); + +test('scanMemory enforces maxPages without returning partial coverage', async (t) => { + let cursor = 0x1000; + const client = await fakeClient(t, () => { + const result = { supportedBuild: true, complete: false, + nextCursor: `0x${cursor.toString(16).toUpperCase()}`, scannedBytes: 32, matches: [] }; + cursor += 0x1000; + return result; + }); + await assert.rejects( + client.scanMemory({ ...VALID_SCAN_OPTIONS, maxPages: 2 }), + (error) => error.code === 'SCAN_LIMIT_EXCEEDED', + ); +}); + +test('registerTelemetryTypes clones names and sends the exact typed command', async (t) => { + const requests = []; + const client = await fakeClient(t, (request) => { + requests.push({ command: request.command, params: request.params }); + return { types: ['probe.snapshot', 'recruiting.stability'] }; + }); + const types = ['probe.snapshot', 'recruiting.stability']; + const pending = client.registerTelemetryTypes(types); + types[0] = 'mutated'; + types.push('extra'); + assert.deepEqual(await pending, { types: ['probe.snapshot', 'recruiting.stability'] }); + assert.deepEqual(requests, [{ + command: 'registerTelemetry', + params: { types: ['probe.snapshot', 'recruiting.stability'] }, + }]); +}); + +test('registerTelemetryTypes rejects invalid names before creating a socket', async () => { + const originalCreateConnection = net.createConnection; + let socketCreations = 0; + net.createConnection = (...args) => { + socketCreations += 1; + return originalCreateConnection(...args); + }; + try { + const client = createClient({ pipeName: testPipeName('unused'), timeoutMs: 25 }); + const cases = [ + undefined, + [], + 'probe.snapshot', + ['game_ready'], + ['tick'], + ['log'], + ['Probe.snapshot'], + ['probe snapshot'], + ['probe.snapshot', 'probe.snapshot'], + Array.from({ length: 17 }, (_, index) => `probe.type${index}`), + ]; + for (const types of cases) { + await assert.rejects( + Promise.resolve().then(() => client.registerTelemetryTypes(types)), + (error) => error.code === 'INVALID_REQUEST', + ); + } + assert.equal(socketCreations, 0); + } finally { + net.createConnection = originalCreateConnection; + } +}); + +test('registerTelemetryTypes rejects malformed host results', async (t) => { + const invalidResults = [ + undefined, + {}, + { types: 'probe.snapshot' }, + { types: ['other.type'] }, + { types: ['probe.snapshot'], extra: true }, + ]; + let index = 0; + const client = await fakeClient(t, () => invalidResults[index++]); + for (const ignored of invalidResults) { + await assert.rejects( + client.registerTelemetryTypes(['probe.snapshot']), + (error) => error.code === 'INVALID_RESPONSE', + ); + } }); diff --git a/scripts/package-release.cjs b/scripts/package-release.cjs index 9651899..bc411f5 100644 --- a/scripts/package-release.cjs +++ b/scripts/package-release.cjs @@ -6,7 +6,7 @@ const fs = require('node:fs/promises'); const path = require('node:path'); const root = path.resolve(__dirname, '..'); -const version = '0.1.0-dev.1'; +const version = '0.2.0-dev.1'; function releaseEntries() { return ['native', 'packages', 'examples', 'docs', 'README.md', 'LICENSE']; @@ -103,12 +103,13 @@ async function packWorkspace(workspace, destination, outputName) { } async function main({ artifactsDir } = {}) { + const configuredArtifactsDir = artifactsDir || process.env.CFB27_NATIVE_ARTIFACTS; + if (!configuredArtifactsDir) { + throw new Error('Provide artifactsDir or set CFB27_NATIVE_ARTIFACTS to the native Release directory'); + } const dist = path.join(root, 'dist'); const stage = path.join(dist, `cfb27-lua-hook-${version}`); - const nativeSource = path.resolve( - artifactsDir || process.env.CFB27_NATIVE_ARTIFACTS || - path.join(root, 'native', 'build-active', 'Release'), - ); + const nativeSource = path.resolve(configuredArtifactsDir); await fs.rm(dist, { recursive: true, force: true }); await fs.mkdir(path.join(stage, 'native'), { recursive: true }); await fs.mkdir(path.join(stage, 'packages'), { recursive: true }); diff --git a/tests/package-layout.test.cjs b/tests/package-layout.test.cjs index 3582c59..2b0be92 100644 --- a/tests/package-layout.test.cjs +++ b/tests/package-layout.test.cjs @@ -21,7 +21,7 @@ test('SDK and CLI package identities are stable', () => { const sdk = readJson('packages/sdk/package.json'); const cli = readJson('packages/cli/package.json'); assert.equal(sdk.name, '@cfb27/lua-hook'); - assert.equal(sdk.version, '0.1.0-dev.1'); + assert.equal(sdk.version, '0.2.0-dev.1'); assert.equal(cli.name, 'cfb27-lua-hook'); assert.equal(cli.bin.cfb27lua, 'bin/cfb27lua.cjs'); }); diff --git a/tests/release-package.test.cjs b/tests/release-package.test.cjs index 18d2a34..d0ac9b9 100644 --- a/tests/release-package.test.cjs +++ b/tests/release-package.test.cjs @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); -const { releaseEntries, assertAllowedEntry } = require('../scripts/package-release.cjs'); +const { releaseEntries, assertAllowedEntry, main } = require('../scripts/package-release.cjs'); test('release allowlist contains only supported product areas', () => { assert.deepEqual(releaseEntries(), [ @@ -28,3 +28,17 @@ test('release rejects archive and generated/private material', () => { } assert.doesNotThrow(() => assertAllowedEntry(path.join('examples', 'lua', 'autorun.lua'))); }); + +test('preview packaging requires an explicit native artifact directory', async () => { + const previous = process.env.CFB27_NATIVE_ARTIFACTS; + delete process.env.CFB27_NATIVE_ARTIFACTS; + try { + await assert.rejects( + main(), + /Provide artifactsDir or set CFB27_NATIVE_ARTIFACTS/, + ); + } finally { + if (previous === undefined) delete process.env.CFB27_NATIVE_ARTIFACTS; + else process.env.CFB27_NATIVE_ARTIFACTS = previous; + } +});