Skip to content

Latest commit

 

History

History
416 lines (341 loc) · 34.3 KB

File metadata and controls

416 lines (341 loc) · 34.3 KB

Tamper (aka Apprentice) — codebase map

Written for an agent picking this up cold. Read this instead of exploring; open a file only when you need to change it. Line counts are indicative.

What it is: an offline Windows game trainer. Electron + React + TypeScript over a C++ N-API addon, plus a standalone read-only MCP server exposing the same native primitives for live reverse-engineering sessions. Four ways to cheat: value cheats (find an address, write it repeatedly, through one of four CheatTarget kinds — see below), code patches (rewrite the instruction that writes the value, one of nine modes), Lua scripts (sandboxed, hotkey- or toggle-driven, for effects too dynamic for a patch), and importing an existing Cheat Engine .CT table. Engines: Mono JIT (Valheim, Aviassembly), IL2CPP (Phasmophobia, Palworld, Schedule I, Supermarket Together), Unreal Engine reflection (Subnautica 2, UE 5.6), and native/hand-signatured (Elden Ring, start_protected_game.json). games/*.json lists every bundled profile; a same-named games/<game>-notes.md (Valheim, Palworld, Phasmophobia, Schedule I, Subnautica 2, Aviassembly) holds that game's RE gotchas — check it before re-deriving something already found.


Layer map

renderer (React)  ──IPC──▶  main (TypeScript)  ──N-API──▶  native (C++)  ──▶  game process
src/renderer/src            src/main                       native/src

mcp-server (stdio MCP)  ──N-API (same addon)──▶  native (C++)  ──▶  game process

The renderer never touches memory. All process access is in native/, exposed through src/main/nativeAddon.ts, and reached from the UI only via the channels in src/main/ipc.ts bridged in src/preload/index.ts. The MCP server (mcp-server/) is a separate package that require()s the same built memory_addon.node directly — it does not go through Electron or IPC at all, and it is read-only (no write/patch/inject tools).


native/src — everything that touches the game

File Lines Responsibility
mono_bridge.cc 2604 Mono runtime introspection. Resolves classes/fields by name, gets a static field's address, compiles a method to get its live JIT entry, lists every field/method/assembly/class name, and injects a small collector stub (BuildAssemblyCollectorStub) to walk mono_assembly_foreach-style APIs that have no batch query. Every export attaches a throwaway thread to the Mono runtime first and detaches after — calling into Mono from an unattached thread is unsafe, and a managed method's own body may itself call back into Mono (e.g. a ZDO lookup), so this is the one mechanism the rest of the file exists to make safe.
write_watch.cc ~800 Find-what-writes. Hardware breakpoints (Dr0/Dr7) via a debugger loop; decodes the faulting instruction with Zydis; builds the AOB signature. Handles signed displacement and DLL-load session stability. One of the most subtle files here.
cave_ops.cc 945 Code-cave primitives: allocateCave, freeMemory, decodeRun, and the instruction encoders — encodeStore, encodeStoreRegister, encodeScale, encodeConditionalScale, encodeCaptureOnce, encodeGuardedSkip, encodeImmuneGuard, encodeJump — plus thread suspend/resume wrappers. One encoder per patch mode (see below).
scanner.cc 376 Value scanning: scanFirst / scanNext, generalized over every DataType width via value_type.h (int8/16/32/64, float, double), chunked region reads, process-liveness checks, and optional range bounds.
mono_call.cc 392 callRemoteFunction/callRemoteFunctionFloat/monoCallAttached — calls an arbitrary function (game code, not just Mono API) on an injected thread, up to 4 pointer-sized args, returning both the integer (RAX) and float (XMM0) result. Frees its own scratch cave after the call.
pointer.cc 387 Pointer-chain discovery: CollectPointers + CollectChains walk every loaded module's static roots (not just the .exe — Mono/Unity runtime DLLs commonly own the real anchor) and score candidate chains in one pass — the same problem Cheat Engine's multi-restart pointer scan solves, done here via module-anchor scoring instead of repeated rescans. Capped and chunked to bound worst-case cost.
patch_ops.cc 306 readBytes / writeBytes (protect → write → restore → flush) and scanAob, bounded by optional (rangeStart, rangeEnd), plus resolveAddress/resolveExport.
platform/platform_win32.cc 393 The OS backend: read/write/query/allocate-near/free/suspend-all, ListModules, and ListThreads/GetThreadRegisters (live register inspection).
script_ops.cc 712 Lua 5.4 sandbox. Runs a script cheat's enable/disable Lua on a background AsyncWorker, with an allowlisted global set, a memory cap, and a sticky 5-second timeout (delivered as an ordinary Lua error so a script wrapping it in pcall can't fake a clean run — see scriptRuntime.ts for the thread-leak accounting this forces). Binds resolvePointer and memory read/write into the sandbox.
disasm_ops.cc 97 disassembleBuffer — Zydis-backed disassembly of a raw byte buffer, for Memory Viewer's disassembly view.
process_utils.cc 88 Process enumeration, attach, and detach (closes the handle rather than leaking it).
thread_ops.cc 78 listThreads/getThreadRegisters JS-facing wrappers over the platform layer.
memory_ops.cc 98 readValue / writeValue through an offset chain, generalized over every DataType width.
addon.cc 113 N-API export table (36 exports — see below).
sigbuild.cc 283 The AOB signature builder, extracted from write_watch.cc: BuildSignature(SigMemory&, insnAddr, insnBytes) covers the caught instruction plus its method (lead-in back to the method start or region edge, forward to ret/48 bytes), wildcarding RIP-relative displacements and imm64s. Reads code only through the SigMemory interface (sigbuild.h), which has a live-process implementation (write_watch.cc) and a snapshot one (snapshot_ops.cc). aob.h holds the shared PatternByte/ParseSignature.
snapshot_ops.cc 236 Replay support. listExecRegions, readRegionBuffer (bulk read for the recorder) and the snapshot* exports (snapshotBuildSignature, snapshotScanAob, snapshotDecodeRun) that run the builder, the AOB scan and decodeRun over recorded regions instead of a process. Snapshot reads are all-or-nothing across a region plus its recorded margins, mirroring live ReadProcessMemory; margins are never scanned.
module_info.cc 37 listModules: every module loaded in the target (name, base, SizeOfImage, TimeDateStamp, version string) — the PE fields a build fingerprint is made of. Returns [] rather than throwing on a protected/exiting process.
chain_walk.h 55 Shared forward pointer-walk helper, hoisted out of mono_bridge.cc/pointer.cc duplication.
protected_write.h 161 Shared protect → write → restore → flush helper every write path (patch, Lua, UI byte edit) routes through, so a failed write is reported rather than silently dropped or left unprotected.
platform/platform_linux.cc 49 Stub: compiles, loads, refuses (IsSupported() === false).

Addon exports (36): ping listProcesses attach detach scanFirst scanNext resolvePointerChain getModuleBase readValue writeValue resolveAddress startWriteWatch pollWriteWatch stopWriteWatch readBytes writeBytes scanAob platformName allocateCave freeMemory decodeRun encodeStore encodeStoreRegister encodeScale encodeConditionalScale encodeCaptureOnce encodeGuardedSkip encodeImmuneGuard encodeJump suspendThreads resumeThreads listModules resolveExport createRemoteThread callRemoteFunction callRemoteFunctionFloat monoResolveClass monoResolveField monoStaticFieldAddress monoCompileMethod monoListFieldNames monoListMethodNames monoListAssemblies monoListAssemblyNames monoListClassesInImage monoCallAttached runScript disassembleBuffer listThreads getThreadRegisters

scanAob takes optional (rangeStart, rangeEnd) bounds (inclusive-exclusive) after the signature; absent bounds walk all executable memory. Bounding to a module's [base, base+size) is what lets a scan recover without paying to re-search the whole process.

The platform seam (platform/platform.h) exists so injection can be ported to Linux. Only new code uses it — cave_ops.cc must contain no Win32 call. The older modules (scanner, pointer, memory_ops, write_watch, patch_ops) still call Win32 directly; porting them is a separate sub-project.


src/main — decisions, all testable against a fake process

Game library — steamLibrary.ts (finds Steam from the registry/default folders, reads libraryfolders.vdf and each appmanifest_*.acf, collects executables, locates cached cover art; registry and file system injected) and library.ts (ties games to cheat profiles via profile.ts's listProfiles, running state, art as data URLs, a short-lived scan cache). Exposed as library:* IPC. Renderer: screens/Library.tsx (cover grid), screens/GamePage.tsx (header plus the cheat list, with the attach/waiting/unsupported states from library.ts's gamePageState), components/Sidebar.tsx (My Games list, tools), gameArt.ts (lazy art cache, placeholders). See docs/superpowers/specs/2026-09-20-game-library-design.md.

replay/ — snapshotFile.ts (the .snap format: gzip of header JSON + region bytes with margins) and replayOps.ts (ReplayOps, a read-only PatchOps over a snapshot so the real PatchEngine.locate() runs against recorded game code). See docs/superpowers/specs/2026-09-19-fixture-replay-design.md. Tests: tests/main/replay.synthetic.test.ts (always; hand-assembled traps), tests/replay/ (recorder, manifest suite, and the real tier that runs against local snapshots).

UE (Unreal Engine) reflection targets — a fourth CheatTarget kind (UeTarget, store.ts) alongside chain/anchor/mono, for games without Mono to introspect. ueDiscover.ts walks GUObjectArray to find a class by name (anchor form) or resolve a root instance directly (root form, optionally filtered by rootOuterClass — e.g. the attribute-set object owned by the player character, not a creature's). ueTargetResolve.ts then walks an FField reflection chain (path, fieldName, valueOffset for a struct member like FGameplayAttributeData.CurrentValue) to the final address, supports allInstances (write every live instance, e.g. every loaded recipe asset, restored per-address) and valueFrom (hold a field at another field's own live value, e.g. Oxygen pinned to MaxOxygen). moduleBaseCache.ts avoids re-resolving the module base every tick. See docs/superpowers/specs/2026-09-17-ue-target-wiring-design.md and the UE 5.6/Subnautica 2 reference in games/subnautica2-notes.md.

Four CheatTarget kinds (store.ts) — what a value cheat can read/write through:

Kind Resolves via Distinguishing capabilities
ChainTarget module + static offset chain (found by scanning) plain; survives until the runtime restarts
AnchorTarget a capture-mode patch's recorded pointer derefOffset/derefOffsets (chained pointer hops), readAsString (decode a managed .NET String — int32 length @+0x10, UTF-16LE chars @+0x14), bitIndex
MonoTarget class/static-field name via live Mono metadata instanceFieldName/instanceClassName (one hop, cross-class), pointerFieldOffset (a second raw-offset hop past that)
UeTarget GUObjectArray scan + FField reflection path/valueOffset/allInstances/valueFrom (see UE paragraph above)

Every kind also takes a per-target value/dataType override and bitIndex (single-bit read-modify-write inside a byte other bits still own).

File Lines Responsibility
ipc.ts ~1950 Channel handlers (see full list below), the live patchOps/AnchorOps implementations, anchor resolution (readManagedString for a readAsString target), freeze/script/hotkey wiring, refreshModuleContext/attachTo (shared by manual attach and the watcher), CT import/export handlers, and the push-event senders — every one guarded against a destroyed renderer window.
patchEngine.ts ~1230 The core. Locate / apply / restore for code patches across all nine modes, and the cave assembly for every injection mode. Takes a PatchOps interface, so every path — especially every refusal — is tested without a game. Also owns setAnchorContext (module map + verified set), onRelearn, and monoMethodOffset (patching mid-method Mono JIT sites, not just the method entry).
store.ts ~565 Types (CheatDefinition, PatchCheat, ScriptCheat, and the four CheatTarget kinds above) and thin CRUD over profile.ts's loadProfile/saveProfile. DataType is int8|int16|int32|int64|float|double — every width scanner.cc/memory_ops.cc handle uniformly, and the only widths a force-mode patch's value/dataType may use (it encodes a 32-bit immediate). offValue/captureOriginal (store.ts's own doc comments) control what a freeze cheat leaves behind on disable. companions (on both CheatDefinition and PatchCheat) names internal patches that arm/disarm together with this one — see companions.ts. PatchCheat.reArmWhenDestroyed auto-rebuilds a capture cave (fresh zeroed slot) when the object it captured turns out to be a destroyed UnityEngine.Object — for a per-round-recreated capture target (a boss, a ghost) where EncodeCaptureOnce's default "first object wins for the session" behavior goes stale after round 1.
anchorResolve.ts 63 resolveAnchorAddress: the pure, engine-free half of AnchorTarget resolution (capture slot → optional derefOffset/derefOffsets chain → +offset), factored out so it's unit-testable without a native addon. ipc.ts's resolveAnchor wraps it with the real tryReadBytes.
companions.ts 64 CompanionTracker: arms an internal patch the first time any cheat naming it in companions turns on, disarms it only when the last one turns off (several cheats can share one companion). Used both for a cheat's own side-effect patch (Valheim tags pieces/drops "cheated") and to collapse several related force patches into one visible toggle (Phasmophobia's five-site "Maximum Sanity").
ctImport.ts 653 Imports Cheat Engine .CT tables: plain (non-Auto-Assembly) entries map onto ChainTarget/freeze; nop-shape, register-copy-shape, and force-shape Auto Assembly scripts map onto the matching patch mode. No general AA interpreter — a script that doesn't reduce to one of these shapes is reported skipped, not guessed at. Hardened against regex/complexity DoS (quadratic tag matching, oversized inputs) and run through ctImportSafe.ts/ctImportWorker.ts on a worker-thread execution budget.
ctExport.ts 291 The reverse: builds a .CT table from this app's own cheats. nop/replace/force patches have a direct Auto Assembly equivalent. capture/guard/immune/scale/copy modes and any Mono-resolved target are reported skipped rather than approximated — they rely on live Mono metadata resolved fresh per install, which AA has no equivalent for.
nativeAddon.ts 349 Typed wrappers over all 36 addon exports. Throwing / non-throwing pairs: readValue/tryReadValue, readBytes/tryReadBytes.
anchor.ts 216 resolvePatchAddress: where a module-anchored patch lives right now. Tries module-base + RVA first (only when the module's fingerprint is verified), falls back to a scan bounded to that module's address range, and verifies captured bytes on both paths before trusting an address. Returns an AnchorReason (module-missing / no-match / ambiguous / bytes-differ / not-yet-compiled) rather than a bare failure. A successful scan writes its RVA back to the profile (relearnedOffset).
cheatRuntime.ts 197 CheatRuntime: the state machine behind a patch chip (idle → arming → active, plus degraded/failed), with exponential backoff retrying only RETRYABLE reasons — not-yet-compiled is not an error, it means Mono hasn't JITted the method yet. Generation counters guard against a disarm()+arm() race.
monoTargetResolve.ts 140 Resolves a MonoTarget to a live address: find the class, find the static field, either use its storage address directly or dereference to an object pointer and add an instance field's offset (the [LocalPlayer]+Player.m_godMode shape). Never throws — every failure returns null.
freezeLoop.ts 144 Rewrites frozen values on a tick, routed through the shared protect/restore helper; skips an overlapping tick if a write is slow; marks a cheat degraded after repeated failure.
hotkeys.ts 152 HotkeyManager: register/fire/conflict logic behind an injectable ops interface, covering freeze, one-shot, patch-arm, and script cheats uniformly.
scriptRuntime.ts 133 ScriptRuntime: async enable/disable for Lua script cheats with an in-flight guard and state handoff. Caps concurrent script runs at 2 (MAX_CONCURRENT_SCRIPT_RUNS) — a stuck script strands its libuv worker thread permanently (Lua's sticky timeout can't kill it from outside), so the cap bounds how much of the 16-thread pool (see threadpool.ts) a leak can ever consume.
monoResolver.ts 60 Thin, never-throwing wrappers over the native Mono bridge exports.
monoClassLocations.ts 36 Finds every assembly defining a class with a given name — surfaces an ambiguous resolve (native MonoResolveClass silently stops at the first match) so Mono Explorer can warn instead of trusting a resolve blindly.
captureStore.ts 36 Runtime record of a cheat's pre-freeze value, for captureOriginal restore mode: captured on enable, consumed (and removed) on every disable path — manual toggle, delete, or the quit/process-switch restore sweep.
watcher.ts 87 ProcessWatcher: polls listProcesses for a process this game has a profile for, fires onAppear/onVanish. Attaches only — auto-arming into an unverified build risks corrupting a save.
profile.ts 161 games/<exe>.json schema 2: { schema, exe, modules, cheats }. Schema 1 (bare cheat array) loads as an empty-fingerprint profile. Saves go through a temp-file-and-rename, not a direct write.
threadpool.ts 20 Side-effect-only, imported first: sets UV_THREADPOOL_SIZE=16 before anything else can touch libuv's threadpool (see scriptRuntime.ts).
index.ts 46 Electron main entry: window creation, lifecycle, wiring threadpool.ts first.

IPC channels: process:list process:attach detach game:current game:state cheats:load cheats:save cheats:delete cheats:isEnabled cheats:toggleFreeze cheats:oneShot cheats:verify cheats:resolveTargetAddress scripts:toggle scripts:isEnabled scripts:run scan:first scan:next scan:resolveChain writeWatch:start writeWatch:poll writeWatch:stop patch:locate patch:apply patch:restore patch:slot memory:readBlock memory:writeByte memory:disassemble memory:resolveTargetAddress threads:list threads:registers mono:resolveClass mono:listFields mono:listMethods mono:listAssemblyNames mono:listClassesInImage mono:classLocations mono:resolvePlayerPointer mono:readLiveValue mono:resolveMethodBytes hotkeys:conflicts ct:import ct:export

Push events (main → renderer): game:state (attach, and watched-process vanish — { exe, pid, changedModules }), cheat:state (every CheatRuntime transition), cheat:broken/cheat:recovered (freeze-loop degraded/recovered, mirrored into CheatRuntime), hotkey:fired/hotkey:conflict.


mcp-server — read-only MCP tools over the same native addon

Standalone package, require()s native/build/Release/memory_addon.node directly (src/addon.ts) rather than shipping its own copy or going through Electron/IPC. Registered in .mcp.json at the repo root, run over stdio. No write, patch, or inject tools — attach/scan/read/disassemble/write-watch only, for live reverse-engineering sessions against a running game without risking the target.

File Lines Responsibility
addon.ts 138 Native addon wrapper, mirrors src/main/nativeAddon.ts's conventions independently (separate package, no shared import).
tools/mono.ts 123 mono_resolve_class, mono_resolve_field, mono_static_field_address, mono_list_field_names, mono_list_method_names, mono_list_assemblies, mono_list_assembly_names, mono_list_classes_in_image.
tools/scan.ts 90 scan_first/scan_next/scan_aob, range-bounded, process-liveness checked.
tools/disasm.ts 45 disassemble_buffer, rejects malformed hex rather than silently truncating.
tools/watch.ts 38 start_write_watch/poll_write_watch/stop_write_watch.
tools/read.ts 39 read_bytes/read_value.
tools/process.ts 37 list_processes/attach/list_modules.
server.ts / index.ts 22 / 13 MCP server wiring, stdio registration.

@modelcontextprotocol/sdk is pinned to an exact version (see its README) to avoid a registerTool TS2589 build failure. npm install builds dist/ automatically via the prepare script.


src/renderer — deliberately plain React

screens/ProcessPicker.tsx → screens/CheatList.tsx (rows, toggles, patch status chips, hotkey capture UI, "View in Memory"/"Edit…" entry points) and screens/Scanner.tsx (scan → narrow → find-what-writes → create cheat or patch). screens/MonoExplorer.tsx (browse assemblies/classes/fields/methods, or resolve by exact name; warns on an ambiguous cross-assembly match) and screens/MemoryViewer.tsx (live hex+ASCII grid with inline byte edit, scrollable disassembly view, Structure Dissect panel, live Registers panel) — both reachable from Scanner/CheatList's "View in Memory". tamper.d.ts types the preload bridge. No component library, no styling system — match the surrounding code.

App.tsx keeps Scanner mounted but hidden when you navigate away, so a scan isn't thrown away.


The nine patch modes

Set by PatchCheat.mode; absent means 'nop' so pre-injection saved patches keep working.

Mode Cave body Reaches
nop (no cave — writes NOPs at the site) every object that code runs for
replace (no cave — fixed-length in-place instruction swap) every object
force effect + tail + jmpBack — the captured store is replaced, not replayed every object
copy effect + displaced + jmpBack — copies a live register into [reg+offset] via encodeStoreRegister every object
scale effect + displaced + jmpBack — multiplies the captured value via encodeScale; with a compareMonoMethod set, becomes conditional scale (encodeConditionalScale) — multiplies only when a live Mono call gates it true (e.g. attacker-only damage multipliers) every object, or attacker-gated subset
capture effect + displaced + jmpBack — records the object pointer into the slot only while the slot is still zero (EncodeCaptureOnce), changes nothing else. First object wins for the whole attached session — right for a player-lifetime singleton, wrong for something recreated per round (opt into PatchCheat.reArmWhenDestroyed, store.ts, for those) n/a (feeds an anchored cheat)
guard guardBlob + displaced + jmpBack — compares the object against the slot, skips the write for that one one object only
immune encodeImmuneGuard variant of guard, arm pointer resolved dynamically (Mono or non-Mono games) rather than fixed at capture time one object only
strip (no single fieldOffset/value/dataType — see fields) — writes several fields at once off the SAME baseRegister, re-read fresh on every invocation rather than a captured/anchored one; replays the original instruction(s) after, unlike force whichever object is current at each call (e.g. a shared crafting-requirement row)

Cave layout is fixed: slot at cave+0 (8 bytes, holds a captured pointer), code at cave+8.

The effect always runs first, before any replayed instruction. This is not stylistic: decodeRun rounds up to whole instructions to reach the 5 a jmp rel32 needs, so a short captured store drags in whatever follows — and if that clobbers the base register, an effect running afterwards dereferences garbage. This crashed Valheim.

monoMethodOffset lets a patch anchor mid-method (a Mono JIT site past the method entry) rather than only at the compiled entry point.


Non-negotiable safety rules (all enforced in code, all learned the hard way)

  • Never displace a run containing a RIP-relative instruction, a relative branch, or a flow terminator (ret, indirect jmp/call, int3, ud2, hlt). A replayed ret returns before the effect runs — an installed cheat that silently does nothing.
  • Never install when the located bytes don't match the capture, or when the signature matches 0 or >1 places. Never guess.
  • Suspend every thread while writing an injection site. Install refuses if suspension fails; restore deliberately proceeds anyway — refusing to restore leaves a game permanently patched, which is worse.
  • Never free a cave while the process lives; a thread may be inside it. Failed installs free their leaked cave; a failed restore is reported, not silently treated as idle.
  • Restore on disable, on delete, on detach, on quit, and on process switch — including freeze cheats (captureOriginal mode restores the pre-freeze value captured at enable time, via captureStore.ts).
  • restore writes back the full displaced run, not patch.length — they differ whenever the captured instruction is under 5 bytes.
  • Calling into Mono (any mono_bridge.cc/mono_call.cc export) always attaches a throwaway thread first and detaches after — an unattached-thread call is unsafe, and a managed method's own body may call back into Mono.
  • A Lua script's 5-second timeout is sticky — delivered as an ordinary error so a pcall-wrapped infinite loop can't fake a clean run. The worker thread it stranded is still lost for the process's lifetime; the concurrency cap in scriptRuntime.ts bounds the damage, it doesn't fix the leak.
  • Every main→renderer push is guarded against a destroyed window.
  • Profile saves go through a temp-file-and-rename, never a direct write.

Relocation (anchor.ts): two paths, one verification

A module-anchored patch is relocated by resolvePatchAddress (anchor.ts), which tries module base + RVA arithmetic first, not the signature scan — arithmetic is exact and free, where a scan walks executable memory. Arithmetic is only trusted when the module's live fingerprint (profile.ts's verifiedModules) still matches what was recorded at save time; otherwise it falls straight to a scan bounded to that module's [base, base+size). Both paths verify the captured bytes before returning an address — an RVA that no longer points at the captured instruction (a build changed layout) is discarded rather than patched, and a scan match that found the right pattern but wrong bytes at the target offset (bytes-differ) is likewise refused. A successful scan on a module-anchored patch writes its RVA back to the profile (relearnedOffset) so the next launch of that exact build takes the arithmetic path instead of re-scanning.


Signatures (the part that survives a game restart)

Built in write_watch.cc. A signature covers the whole enclosing method, extending backward from the captured instruction, with signatureOffset recording how many bytes precede it (a scan match is the pattern start, so the instruction is at match + signatureOffset; absent means 0).

Rules, each from a real failure:

  • Wildcard imm64. A 64-bit immediate is how x86-64 embeds an absolute address, and JIT allocations move every launch. imm32 stays literal — it can't hold an address, and it earns uniqueness.
  • Never cross a method boundary (ret/jmp) in either direction — past it is padding, JIT metadata, then an unrelated method, none of it stable.
  • Never cross a memory region boundary — scanAob searches one region at a time, so a straddling pattern matches nothing.
  • Stop at padding runs (0x00/0xCC, 4+ bytes) and reject candidate alignments that decode opcode 0x00 — x86 self-synchronizes, so a misaligned chain can decode cleanly, land correctly, and hide the movabs the wildcarding needed to see.

Tuning constants (kMinSigBytes = 48, kLookBack = 64, kPadRun = 4) come from one game's evidence. A Cheat Engine table for the same game used an 11-byte pattern, so 48 is conservative.


Interop with Cheat Engine

Bidirectional, file-format-level only — Apprentice never drives the CE process or its network protocol. Both directions are deliberately partial: ctImport.ts recognizes plain entries plus three Auto-Assembly shapes (nop/copy/force) and reports anything else skipped rather than guessing; ctExport.ts covers nop/replace/force patches and plain-address value targets, and skips the modes/targets that depend on live Mono metadata resolved fresh per install (no fixed address a .CT entry could hold). Apprentice's own native primitives already cover CE's core feature set — value/AOB scan, pointer-chain discovery (pointer.cc's module-anchor scoring solves what CE's multi-restart pointer scan solves, in one pass), find-what- writes, code injection, remote function calls, Mono introspection, a disassembler, and Lua scripting — so there's no case for adding a CE-process dependency (e.g. driving ceserver's network protocol) on top of this.


Tests — ~1150 across 75 files (root), and what they can't tell you

npx vitest run · npx tsc --noEmit · npm run build

Native tests drive a real child process: test-harness/harness.exe, built from harness.c, driven over stdin (drainloop, forceloop, wideloop, shieldloop, tight_write, loaddll, loaddll2, unloaddll, negdisploop, plus int8/int16/int64/double coverage commands, …).

bigalloc / bigallocfree and bigcode / bigcodefree allocate one 8 MiB region (data / executable respectively), replying OK <0xbase>, with a marker value deliberately placed ACROSS the 4 MiB chunk boundary the native region readers use. scanner.test.ts and patch_ops.test.ts use these to prove chunked region reads never drop a boundary-straddling candidate.

loaddll / loaddll2 / unloaddll load and unload a real DLL (probe.dll / probe2.dll, a size/timestamp-varied variant of the same DLL) into the harness process, replying OK <0xbase>. This is what tests/native/module_info.test.ts uses to prove listModules sees a module appear/disappear with a plausible fingerprint, that two builds of "the same" DLL fingerprint differently, and that the same bytes read at a fixed RVA survive the DLL being unloaded and reloaded at a different base address.

The MCP server has its own ~170 tests across 20 files (mcp-server/tests/), including an integration test that spawns the built dist/index.js — requires cd mcp-server && npm install to have run first (gitignored dist/). CI does not run them (ci.yml's mcp-server step only npm cis and builds it) — check by hand before trusting that package's tests are green.

The harness is a static MSVC binary; the real target is Mono JIT. Almost every defect found in-game was invisible here for that reason: stable bytes past a ret, no absolute addresses in code, no shared setters, well-behaved threads. When a fix passes here, that is necessary and not sufficient.

Two hazards when editing tests:

  • tests/native/cave_ops.test.ts and tests/native/module_info.test.ts must each keep exactly one top-level beforeAll — awaiting an AsyncWorker promise in a second one reliably segfaults the vitest worker. New describe blocks in these files share the file's single beforeAll-spawned harness and its send() helper rather than adding their own setup.
  • Scans in the native tests are one-shot: they key off a field's initial value, which the first test to run overwrites.

Build and run

npm install                                    # electron postinstall may need approving
cd native && npx node-gyp configure && npx node-gyp build && cd ..
npm run build
  • Stop Tamper before rebuilding the addon — a running Electron locks memory_addon.node and the link fails with permission denied.
  • After changing binding.gyp sources, configure before build.
  • Rebuild the harness from PowerShell, never Bash (Bash won't run vcvars and fails silently): & cmd.exe /c 'call "…\vcvars64.bat" >nul 2>&1 && cl.exe /nologo /Fe:test-harness\harness.exe test-harness\harness.c' then delete harness.obj and verify the timestamp changed.
  • Apprentice.cmd at the repo root launches the built app.
  • The MCP server is a separate opt-in build: cd mcp-server && npm install (builds dist/ via its prepare script) — needed before .mcp.json's registration or the integration test will work.

Conventions

  • Addresses: 0x-prefixed lowercase. Byte blobs: unspaced lowercase. AOB signatures: space-separated ??-or-hex-pair tokens.
  • Absent mode on a patch means 'nop'; absent signatureOffset means 0. Backwards compatibility with saved games/*.json is a hard requirement.
  • Hex helpers (ParseHex/ToHex) are duplicated across several native files — known debt, a shared header is due.

Read these before deep work

  • docs/superpowers/specs/2026-07-25-code-injection-design.md — the original injection design.
  • docs/superpowers/follow-ups/2026-07-28-valheim-session.md — eight real defects found in-game and why Valheim health is still unsolved. Read this before touching signatures or cave layout.
  • docs/superpowers/specs/ has a design doc per major feature since, in date order — mono-resolver, mono-authoring-tools, rename-and-ct-export, numeric-data-types, hotkeys, memory-viewer-scripting, live-registers-panel, ct-table-import, mcp-memory-server, scale-patch-mode. Read the one for the area you're touching; each captures why, not just what.

Highest-value open item: a background retry on a failed locate. Mono compiles a method on first call, so a patch legitimately cannot be found until the game runs that code — and the current message ("may have been re-compiled — re-capture it") sends users exactly the wrong way.