From 6f4bea1a670481adf1b0fc44fecd9284e8f125fb Mon Sep 17 00:00:00 2001 From: HALS8 <61231642+HALS8@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:38:05 +0100 Subject: [PATCH] livetools: read out-parameters with [entry.] leave specs A function that returns its result through an out-pointer (r8, [esp+0xC], ...) could not be read with trace: by the time --read-leave runs, the register holding the pointer has been clobbered, and trace/collect can only hook a function entry, so there is no later point to probe. Leave specs now accept [entry.+OFFSET]:SIZE:TYPE (and bare entry.): the register's value as snapshotted at entry, with memory read at exit. For example, to read a bool Get(obj, id, uint32* out) accessor: livetools trace --read "rdx" --read-leave "rax; [entry.r8]:4:uint32" Using entry. anywhere but a leave spec is an error in the record, not a silent zero. Also documents, in the skill and in trace --help, why trace/collect must only hook a real function entry: the exit hook replaces the return address at [esp]/[rsp] on entry, which mid-function is a local, so the target's stack is corrupted. Co-Authored-By: Claude Opus 5 --- .claude/skills/dynamic-analysis/SKILL.md | 3 +++ livetools/__main__.py | 8 ++++++- livetools/agent.js | 28 ++++++++++++++++++------ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.claude/skills/dynamic-analysis/SKILL.md b/.claude/skills/dynamic-analysis/SKILL.md index 38255d66..ea8506d5 100644 --- a/.claude/skills/dynamic-analysis/SKILL.md +++ b/.claude/skills/dynamic-analysis/SKILL.md @@ -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` @@ -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.]` 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. diff --git a/livetools/__main__.py b/livetools/__main__.py index 0304987d..b6fcc907 100644 --- a/livetools/__main__.py +++ b/livetools/__main__.py @@ -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" @@ -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." diff --git a/livetools/agent.js b/livetools/agent.js index 50990513..1669958b 100644 --- a/livetools/agent.js +++ b/livetools/agent.js @@ -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', @@ -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; @@ -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." 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') { @@ -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 = {