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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/skills/dynamic-analysis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Semicolon-separated fields. Each field:
| `register` | Register value as hex | `ecx`, `eax`, `ebp` |
| `[reg+OFFSET]:SIZE:TYPE` | Read SIZE bytes from reg+OFFSET | `[esp+4]:12:float32` |
| `*[reg+OFFSET]:SIZE:TYPE` | Double-deref: follow pointer first | `*[esp+4]:64:float32` |
| `[entry.reg+OFFSET]:SIZE:TYPE` | Leave specs only: the register's value at ENTRY, memory read at LEAVE -- reads an out-parameter | `[entry.r8]:4:uint32` |
| `st0` | FPU top-of-stack (best-effort) | `st0` |

Types: `hex` (default), `float32`, `float64`, `uint32`, `int32`, `uint16`, `int16`, `uint8`, `int8`, `ascii`, `utf16`, `ptr`
Expand Down Expand Up @@ -482,4 +483,6 @@ python -m livetools analyze scene.jsonl --export-csv scene.csv

**Do NOT explain away zero data.** If a trace returns 0 samples, the answer is "I got no data and need to troubleshoot" — not "the game doesn't appear to use this code path." Follow the escalation in pattern #10.

**Do NOT trace or collect a mid-function address.** Both install an exit hook, which replaces the return address at `[esp]`/`[rsp]` on entry -- mid-function that slot is a local, and overwriting it corrupts the stack. Hook the function entry and read out-parameters with `[entry.<reg>]` leave specs instead.

**Do NOT pass DLL-internal addresses to trace/bp.** Addresses from a DLL's export table or vtable layout belong to the DLL's code. Hooking them gives you the wrong context. Always prefer hooking in the game's own .text section.
8 changes: 7 additions & 1 deletion livetools/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,8 +784,12 @@ def build_parser() -> argparse.ArgumentParser:
" register: ecx, eax, ebp, ...\n"
" memory: [reg+OFFSET]:SIZE:TYPE\n"
" double-deref: *[reg+OFFSET]:SIZE:TYPE\n"
" out-parameter: [entry.reg+OFFSET]:SIZE:TYPE (--read-leave only:\n"
" the register's value at entry, memory read at exit)\n"
" Types: hex, float32, float64, uint32, int32, uint16, int16,\n"
" uint8, int8, ascii, utf16, ptr\n\n"
"Hook only a real function entry: the exit hook replaces the return\n"
"address at [esp]/[rsp] on entry, which mid-function is a local.\n\n"
"Filter format:\n"
" [esp+8]==0x2 | eax!=0 | [ecx+0x54]:4:float32>0.5\n\n"
"Examples:\n"
Expand All @@ -794,7 +798,9 @@ def build_parser() -> argparse.ArgumentParser:
" python -m livetools trace 0x402000 --count 5 "
'--filter "[esp+8]==0x2" --read "[esp+c]:64:float32"\n'
" python -m livetools trace 0x403000 --count 20 "
'--read "ecx; [esp+4]:12:float32" --read-leave "eax"\n\n'
'--read "ecx; [esp+4]:12:float32" --read-leave "eax"\n'
" python -m livetools trace 0x140404000 --count 5 "
'--read "rdx" --read-leave "rax; [entry.r8]:4:uint32"\n\n'
"NOTE: Some games only execute rendering/logic when their window\n"
"is focused. If trace times out with 0 samples, alt-tab to the\n"
"game before running."
Expand Down
28 changes: 21 additions & 7 deletions livetools/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ function verifyHook(addrStr, origFirstByte) {
// "[esp+0x4]:12:float32" -> read 12 bytes at esp+4, decode as float32
// "*[esp+4]:64:float32" -> double-deref: read ptr at esp+4, then 64 bytes
// "st0" -> FPU top (best-effort)
// "[entry.r8]:4:uint32" -> leave specs only: the register's value at ENTRY,
// memory read at LEAVE -- how an out-parameter is read,
// since the register itself is clobbered by then

var REG_NAMES_32 = ['eax','ebx','ecx','edx','esi','edi','ebp','esp','eip'];
var REG_NAMES_64 = ['rax','rbx','rcx','rdx','rsi','rdi','rbp','rsp','rip',
Expand Down Expand Up @@ -150,7 +153,7 @@ function parseOneSpec(s) {
var work = s;
if (work.charAt(0) === '*') { deref = true; work = work.substring(1); }

var m = work.match(/^\[(\w+)(?:\s*\+\s*(0x[\da-fA-F]+|\d+))?\]\s*(?::(\d+))?(?::(\w+))?$/);
var m = work.match(/^\[((?:entry\.)?\w+)(?:\s*\+\s*(0x[\da-fA-F]+|\d+))?\]\s*(?::(\d+))?(?::(\w+))?$/);
if (m) {
var base = m[1].toLowerCase();
var off = m[2] ? parseInt(m[2], m[2].indexOf('0x') === 0 ? 16 : 10) : 0;
Expand All @@ -162,23 +165,34 @@ function parseOneSpec(s) {
return { kind: 'reg', name: lower, raw: s };
}

function evalReadSpecs(specs, ctx) {
// entryRegs: the snapshotRegs() taken at entry, which "entry.<reg>" names resolve against.
function evalReadSpecs(specs, ctx, entryRegs) {
var results = [];
for (var i = 0; i < specs.length; i++) {
results.push(evalOneSpec(specs[i], ctx));
results.push(evalOneSpec(specs[i], ctx, entryRegs));
}
return results;
}

function evalOneSpec(spec, ctx) {
function specRegVal(ctx, name, entryRegs) {
if (name.indexOf('entry.') !== 0) return getRegVal(ctx, name);
if (!entryRegs) throw new Error(name + ' is only valid in a leave spec');
var reg = name.substring(6);
if (reg === 'pc') reg = is64 ? 'rip' : 'eip';
var hex = entryRegs[reg];
if (hex === undefined) throw new Error('no entry register ' + reg);
return ptr('0x' + hex);
}

function evalOneSpec(spec, ctx, entryRegs) {
try {
if (spec.kind === 'reg') {
return { spec: spec.raw, value: ptrToHex(getRegVal(ctx, spec.name)) };
return { spec: spec.raw, value: ptrToHex(specRegVal(ctx, spec.name, entryRegs)) };
}
if (spec.kind === 'st0') {
return { spec: spec.raw, value: null };
}
var baseVal = getRegVal(ctx, spec.base);
var baseVal = specRegVal(ctx, spec.base, entryRegs);
var addr = baseVal.add(spec.offset);

if (spec.kind === 'deref') {
Expand Down Expand Up @@ -357,7 +371,7 @@ function installTrace(addrStr, config) {
var leaveData = {
eax: ptrToHex(this.context.eax || this.context.rax || ptr(0)),
retval: ptrToHex(retval),
reads: evalReadSpecs(leaveSpecs, this.context)
reads: evalReadSpecs(leaveSpecs, this.context, this._traceEnter.regs)
};

var record = {
Expand Down