Skip to main content
This page documents the Frida-based runtime instrumentation workflow used to capture ground truth from the original Crimsonland binary.

Overview

Frida is a dynamic instrumentation toolkit that allows:
  • Function hooking - Intercept calls and inspect arguments/return values
  • Memory inspection - Read struct fields and global state at runtime
  • Call tracing - Record function call sequences (RNG, damage calculations)
  • State capture - Snapshot game state at specific ticks for differential testing

Setup

Installation

pip install frida frida-tools

Attach to Process

Frida scripts run by attaching to the running game:
frida -n crimsonland.exe -l script.js
Important: Use -n (attach by name) instead of spawn. Spawning caused texture crashes in testing.

Basic Hooking

Intercepting a Function

// Hook player_update @ 0x004136b0
const playerUpdateAddr = ptr("0x004136b0");

Interceptor.attach(playerUpdateAddr, {
  onEnter: function(args) {
    console.log("player_update called");
  },
  onLeave: function(retval) {
    console.log("player_update returned");
  }
});

Reading Memory

// Read player health (float at 0x004908d4)
const playerHealthAddr = ptr("0x004908d4");
const health = playerHealthAddr.readFloat();
console.log(`Player health: ${health}`);

Writing Memory

// God mode: Set health to 999
playerHealthAddr.writeFloat(999.0);

Capture Scripts

The scripts/frida/ directory contains specialized instrumentation scripts:

gameplay_diff_capture.js

Tick-aligned state capture for differential testing:
// Captures:
// - Player position, health, weapon state
// - Creature pool (active entries, positions, health)
// - Projectile pool (active entries, positions, types)
// - RNG call sequences

// Hook game tick
Interceptor.attach(ptr("0x00402d10"), {  // gameplay_update
  onEnter: function() {
    captureState();
  }
});

function captureState() {
  const state = {
    tick: tickCounter++,
    player: capturePlayer(),
    creatures: captureCreatures(),
    projectiles: captureProjectiles(),
    rng_calls: rngCallCount
  };
  
  logEvent("state_snapshot", state);
}
Output: gameplay_diff_capture.json with tick-by-tick state.

grim_hooks.js

Grim2D engine call tracing:
// Hook all Grim vtable functions
const grimVtable = ptr("0x1004c238");

for (let i = 0; i < 84; i++) {
  const funcPtr = grimVtable.add(i * 4).readPointer();
  
  Interceptor.attach(funcPtr, {
    onEnter: function(args) {
      console.log(`Grim[${i}] called`);
    }
  });
}
Use case: Identify unknown Grim2D functions by index.

survival_autoplay.js

Automated gameplay for unattended capture runs:
// Override input state for static movement + computer aim
const inputScheme = ptr("0x00480364");
inputScheme.writeU32(2);  // Static movement

const aimScheme = ptr("0x0048038c");
aimScheme.writeU32(4);  // Computer aim

console.log("Autoplay enabled: static move + AI aim");
Use case: Record long Survival runs without manual input.

Evidence Collection Patterns

RNG Call Tracing

let rngCallCount = 0;
const rngCalls = [];

Interceptor.attach(ptr("0x00461746"), {  // crt_rand
  onLeave: function(retval) {
    const value = retval.toInt32();
    rngCallCount++;
    rngCalls.push({call: rngCallCount, value: value});
    
    if (rngCallCount % 100 === 0) {
      console.log(`RNG call ${rngCallCount}: ${value}`);
    }
  }
});
Why: Verify rewrite uses identical RNG call order.

Damage Calculation Validation

Interceptor.attach(ptr("0x004207c0"), {  // creature_apply_damage
  onEnter: function(args) {
    const creatureIdx = args[0].toInt32();
    const damage = args[1].toFloat();
    
    const creatureBase = ptr("0x0048d0a8");
    const entry = creatureBase.add(creatureIdx * 0x98);
    const healthBefore = entry.add(0x10).readFloat();
    
    this.creatureIdx = creatureIdx;
    this.damage = damage;
    this.healthBefore = healthBefore;
  },
  onLeave: function(retval) {
    const entry = ptr("0x0048d0a8").add(this.creatureIdx * 0x98);
    const healthAfter = entry.add(0x10).readFloat();
    
    console.log(`Creature ${this.creatureIdx}:`);
    console.log(`  damage: ${this.damage}`);
    console.log(`  health: ${this.healthBefore} -> ${healthAfter}`);
  }
});
Output:
Creature 5:
  damage: 15.0
  health: 30.0 -> 15.0

Pool Iteration

function captureProjectiles() {
  const pool = ptr("0x004926b8");
  const entries = [];
  
  for (let i = 0; i < 0x60; i++) {
    const entry = pool.add(i * 0x40);
    const active = entry.readU8();
    
    if (active) {
      entries.push({
        index: i,
        type: entry.add(0x20).readS32(),
        pos_x: entry.add(0x08).readFloat(),
        pos_y: entry.add(0x0c).readFloat(),
        life_timer: entry.add(0x24).readFloat()
      });
    }
  }
  
  return entries;
}

Workflow

1. Instrument and Run

Attach script and play the game:
cd /mnt/c/share/frida
frida -n crimsonland.exe -l gameplay_diff_capture.js
Script writes to C:\share\frida\gameplay_diff_capture.json.

2. Copy Logs to Repo

mkdir -p analysis/frida/raw
cp /mnt/c/share/frida/*.json analysis/frida/raw/

3. Reduce to Evidence

Normalize captures into machine-readable facts:
uv run scripts/frida_reduce.py \
  --log analysis/frida/raw/gameplay_diff_capture.json \
  --out-dir analysis/frida
Output:
  • analysis/frida/facts.jsonl - Normalized evidence
  • analysis/frida/evidence_summary.json - Per-function call counts
  • analysis/frida/name_map_candidates.json - Suggested symbol names

4. Promote to Maps

Review candidates and merge into authoritative maps:
# Manually edit:
analysis/ghidra/maps/name_map.json
analysis/ghidra/maps/data_map.json

# Reapply to Ghidra:
just ghidra-exe

Advanced Techniques

Conditional Breakpoints

Interceptor.attach(ptr("0x00420b90"), {  // projectile_update
  onEnter: function() {
    const pool = ptr("0x004926b8");
    
    // Only log when specific projectile is active
    const proj5 = pool.add(5 * 0x40);
    if (proj5.readU8() !== 0) {
      const type = proj5.add(0x20).readS32();
      if (type === 0x2d) {  // Fire projectile
        console.log("Fire projectile active at index 5");
      }
    }
  }
});

Backtraces

Interceptor.attach(ptr("0x00461746"), {  // crt_rand
  onEnter: function() {
    console.log("RNG call from:");
    console.log(Thread.backtrace(this.context).map(DebugSymbol.fromAddress).join("\n"));
  }
});
Output:
RNG call from:
0x00420b90 projectile_update+0x150
0x00402d10 gameplay_update+0x420
0x00401234 game_loop+0x80

Unknown Field Discovery

// Watch player struct for frequently-changing fields
const playerBase = ptr("0x004908d4");
const prevValues = {};

setInterval(() => {
  for (let offset = 0; offset < 0x360; offset += 4) {
    const addr = playerBase.add(offset);
    const value = addr.readFloat();
    
    if (prevValues[offset] !== value) {
      console.log(`Offset 0x${offset.toString(16)}: ${prevValues[offset]} -> ${value}`);
      prevValues[offset] = value;
    }
  }
}, 100);
Use case: Identify unknown timer/state fields by watching for changes.

Output Formats

JSONL (JSON Lines)

One JSON object per line:
{"event": "player_damage", "tick": 347, "damage": 5.0, "health": 95.0}
{"event": "projectile_spawn", "tick": 348, "type": 1, "owner": -100}
{"event": "rng_call", "tick": 348, "value": 12345}
Advantages:
  • Streamable (process line-by-line)
  • Appendable (no array wrapper)
  • Grep-friendly

Structured JSON

Full state snapshots:
{
  "tick": 1000,
  "player": {
    "health": 85.0,
    "pos": [432.5, 300.0],
    "weapon_id": 6
  },
  "creatures": [
    {"index": 0, "type": 3, "health": 20.0},
    {"index": 2, "type": 5, "health": 50.0}
  ]
}

Just Shortcuts

Common capture workflows:
# Differential capture (tick-aligned state)
just frida-gameplay-diff-capture

# Survival autoplay mode
just frida-survival-autoplay

# UI state sweep (all resolutions/panels)
just frida-panel-state-resolution-sweep

# Import all raw logs to repo
just frida-import-raw

# Reduce logs to evidence
just frida-reduce

Troubleshooting

Symptom: Failed to attach: process not foundSolution: Ensure game is running first, then attach:
# Start game manually
# Wait for main menu
frida -n crimsonland.exe -l script.js
Symptom: Black screen or crashed texturesSolution: Use -n (attach) instead of -f (spawn):
# Bad (spawn):
frida -f crimsonland.exe -l script.js

# Good (attach):
frida -n crimsonland.exe -l script.js
Symptom: Console output disappears on detachSolution: Log to file in script:
const logFile = new File("C:\\share\\frida\\output.jsonl", "w");

function logEvent(event, data) {
  logFile.write(JSON.stringify({event, ...data}) + "\n");
  logFile.flush();
}

WinDbg Debugging

Complementary debugger-based inspection

Differential Testing

Using captures to verify rewrite parity

Struct Recovery

Cross-referencing runtime data with static analysis

Build docs developers (and LLMs) love