| name | pcx-patch-day-playbook |
| description | Ordered triage workflow for recovering a PCX script after a game update. Triggers when sigs return 0, reads return garbage after a patch, or the user says "broken", "updated", "patch day", "hotfix", "season drop", or "DLC dropped". Keeps diagnosis short and fixes targeted.
|
| license | MIT |
Patch Day Playbook — Recovering After a Game Update
The ordered triage workflow for when a game update lands and your Perception.cx script stops working. This is the single most painful recurring scenario in scripting work; the cost is dominated by not knowing what changed, not by the re-RE itself. This playbook keeps the diagnosis short and the fix targeted.
Trigger when: the target game updated, sigs return 0, the script throws on first run after a patch, ref_process().alive() is fine but reads return garbage, or the user says any of: "broken", "updated", "patch day", "hotfix", "season drop", "DLC dropped".
Prerequisite: knowledge/offset-methodology.md for sig resolution mechanics, tools/offset-diff.py for batch sig diffing between binary versions, tools/sig-uniqueness-checker.py for re-sig validation. Also requires that you saved the previous-version binary and the working offsets.em before the update landed (see Step 1).
Trigger
.em script suddenly throws on launch after a game update, overlay draws at (0,0), ESP renders no entities, sigs return 0, RIP-resolved addresses point outside the module, the user updates the game and runs the script and nothing works.
1. Snapshot the Broken State Before You Touch Anything
Patch day is destructive. Save the old binary and old offsets before you do anything else. Diffing is impossible if you've already overwritten history.
The single most common amateur mistake is "let me just update the offsets and see." Two hours later you can't remember what worked yesterday because everything is overwritten and the game's auto-updater wiped the old binary.
# Before any debugging — make a snapshot directory:
mkdir patch-2026-06-17
# 1. Copy the new game binary out (it's already on disk after the patch)
cp "C:/Games/MyGame/MyGame.exe" patch-2026-06-17/MyGame-new.exe
# 2. The OLD binary should already be in your previous snapshot dir.
# If you don't have one, the lesson is: make one TODAY before the next patch.
# The toolkit's `tools/offset-diff.py` needs both binaries to diff.
# 3. Save the last-known-good offsets:
cp scripts/offsets.em patch-2026-06-17/offsets-old.em
# 4. Save the broken script output for reference:
# (in the IDE, copy the error trace; or run `check_script` and capture output)
Why: Without a snapshot, you're guessing what changed. With one, offset-diff.py and radiff2 will tell you exactly which sigs moved, which are still valid, and which are gone. The 30 seconds of snapshotting saves the 2 hours of guesswork.
2. Run tools/offset-diff.py Before Editing Anything
Most sigs survive a patch. You want to find the few that didn't — not re-do every one.
The natural reflex is to open IDA on the new binary and start re-deriving offsets from scratch. Don't. The diff tool tells you in 30 seconds which sigs are intact, which moved (delta only — still resolvable), and which are gone (need re-sig).
cat > sigs.json <<EOF
[
{"name": "entity_list", "pattern": "48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 8B D8", "kind": "rip", "rip_offset": 3, "insn_len": 7},
{"name": "local_player", "pattern": "48 8B 0D ?? ?? ?? ?? 48 85 C9 74 ?? 8B 81", "kind": "rip", "rip_offset": 3, "insn_len": 7},
{"name": "view_matrix", "pattern": "48 8D 15 ?? ?? ?? ?? 48 8D 4C 24 ?? E8", "kind": "rip", "rip_offset": 3, "insn_len": 7}
]
EOF
python3 tools/offset-diff.py --old patch-old/MyGame.exe \
--new patch-new/MyGame.exe \
--sigs sigs.json
Read the output table top to bottom:
| Status | What it means | What to do |
|---|
UNCHANGED | sig hits same address in both binaries | Nothing. Keep the offset. |
MOVED | sig hits, but the resolved address differs (recompile shifted code) | Update the resolved address; sig itself is still good. |
LOST_IN_NEW | sig hit old, doesn't hit new | Re-sig needed; instruction sequence changed. Go to Step 4. |
NEW_IN_NEW | sig hit new but not old | Probably a typo in the old sig; ignore unless suspicious. |
MULTIPLE_HITS_OLD / MULTIPLE_HITS_NEW | sig is ambiguous | Sig is too broad; tighten before trusting either result. Go to Step 4. |
Why: Triage before surgery. A patch typically moves 5-15% of sigs and breaks 1-3% outright. The diff tells you which 1-3% to spend the next hour on, instead of re-checking the 95% that survived.
3. Bisect the Cascade: Find the Earliest Failure, Not the Loudest
A broken script after a patch shows ten errors. Nine of them are downstream of one bad pointer. Find the first one.
Failure cascades trick you into chasing the wrong fix. The script log says "no entities drawn"; the actual cause is g_entity_list = 0 because the base address resolution failed because the module name in the script changed (rare, but happens with engine version bumps). Fixing the entity-list sig won't help.
Bisect in dependency order:
1. Process attach → ref_process("game.exe").alive() == true?
If false: process name changed? Anti-cheat blocking attach?
2. Base resolve → get_module_base("game.exe") returns non-zero?
If 0: module renamed (e.g. CSGO → CS2 binary swap).
3. Module size → get_module_size("game.exe") plausible (hundreds of MB)?
If wildly different: you're looking at the wrong binary.
4. First sig hit → find_code_pattern returns non-zero for the FIRST sig you try?
If 0: the .text section may have moved (rare) or the binary
is encrypted/packed at runtime (e.g. Denuvo VM re-emergence).
5. RIP resolve → resolved_addr is in [base, base+size]?
If outside: RIP math is wrong (Step 5).
6. Field reads → ru64() on the resolved address returns non-zero?
If 0: pointer chain broken, struct layout changed.
Stop at the first failing step. Fix that. Re-run. Most of the cascade evaporates.
int64 main() {
proc_t p = ref_process("game.exe");
if (!p.alive()) { println("STEP 1 FAIL: process not attached"); return 0; }
uint64 base = p.base_address();
if (base == 0) { println("STEP 2 FAIL: no module base"); return 0; }
uint64 size = p.get_module_size("game.exe");
println(format("base={x} size={x}", base, size));
uint64 hit = p.find_code_pattern(base, size, SIG_ENTITY_LIST);
if (hit == 0) { println("STEP 4 FAIL: entity_list sig stale"); return 0; }
uint64 entity_list = resolve_rip(p, hit, 3, 7);
if (entity_list < base || entity_list > base + size) {
println(format("STEP 5 FAIL: rip resolve out of range: {x}", entity_list));
return 0;
}
uint64 first = p.ru64(entity_list);
println(format("first entity={x}", first));
return 1;
}
Why: Without bisection you fix symptoms. You'll spend 30 minutes re-deriving an entity-list sig that was fine the whole time because the real failure was the module name. Bisection points the spotlight.
4. Re-Sig the Broken Ones with the Near-Miss Checker
A sig that was unique yesterday may collide today, or vice versa. Don't trust your old sigs after a patch — validate.
tools/sig-uniqueness-checker.py gives a verdict per sig: UNIQUE, AMBIGUOUS, STALE, BRITTLE. The --near-misses N flag is the killer feature on patch day — it scans for sigs whose first N bytes survive but trailing bytes drift, telling you exactly how to extend or narrow the wildcards.
python3 tools/sig-uniqueness-checker.py patch-new/MyGame.exe \
--sig-file sigs.txt --near-misses 2
For each broken sig:
- STALE with near-miss → the instruction is still there but a register/offset byte changed. Update the sig (often a single byte) and retest.
- STALE with no near-miss → the whole code path was rewritten. Go to the xref — find the function this sig was inside, find the new version in the patched binary by string xrefs or call patterns, derive a new sig from there.
- AMBIGUOUS → tighten with 2-4 more bytes of leading or trailing context. Aim for
margin between 2 and 6 — margin=0 is brittle (one-byte change kills it), margin>10 is overspecified (more likely to drift on the next patch).
- BRITTLE (
margin=0) → widen the sig until margin ≥ 2 even if the diff said it's fine — you got lucky this patch, you won't next time.
Why: Treating sigs as "either works or doesn't" misses the gradient. Most patch breakage is one-byte drift, which the near-miss check finds in seconds. Re-sigging from xrefs is the fallback when drift exceeds the threshold.
5. Re-Verify RIP-Relative Resolution After Every Sig Change
Half of patch-day breakage is correct sig hits with wrong RIP math because the instruction length changed.
A sig matching 48 8D 0D ?? ?? ?? ?? (7-byte LEA rcx, [rip+disp]) becomes 48 8B 0D ?? ?? ?? ?? (7-byte MOV rcx, [rip+disp]) — same length, same RIP math, fine. But a recompile can also turn a 7-byte LEA r64, [rip+disp32] into a 4-byte LEA r64, [rip+disp8] (small displacement form) — different length, different RIP math, your resolved address is now 3 bytes off. The script "works" but reads from the wrong location.
The check:
uint64 hit = p.find_code_pattern(base, size, SIG_ENTITY_LIST);
if (hit == 0) return 0;
int32 disp = p.r32(hit + 3);
uint64 resolved = hit + 7 + cast<uint64>(disp);
if (resolved >= base && resolved < base + size) {
uint64 first_field = p.ru64(resolved);
println(format("resolved={x} first_field={x}", resolved, first_field));
} else {
println(format("RIP resolve out of module: hit={x} disp={x} resolved={x}", hit, disp, resolved));
}
Patterns that change instruction length:
| From | To | Length delta | Common trigger |
|---|
LEA r64, [rip+disp32] (7B) | LEA r64, [rip+disp8] (4B) | -3 | small-data global moved closer to code |
MOV r64, [rip+disp32] (7B) | MOV r64, mem direct (10B) | +3 | global moved out of .rdata range |
| Standalone instruction | Instruction fused with prologue/epilogue change | varies | inliner heuristic changed in compiler |
Why: A wrong RIP math produces a perfectly plausible-looking address that's wrong by a small offset. Your reads return garbage that doesn't crash. You'll spend an hour blaming the struct layout for what's actually a 3-byte miscalculation in your resolver.
6. Validate End-to-End on the Live Target, Not Just "No Crash"
Compile-clean is not the bar. Visible-correct on the live target is the bar.
A script that doesn't crash and an overlay that draws something tells you almost nothing — every previous bug shipped the same way. Concrete validation:
End-to-end checklist after a patch fix:
[ ] Run the script on the live target (not a paused process)
[ ] Move the camera 90° — overlay tracks correctly?
[ ] Walk forward 10 meters — distance text updates plausibly?
[ ] Find a known entity (a teammate, a stationary object) — ESP box positioned over them?
[ ] Open the menu — every widget responds, no GUI freezes?
[ ] Run for 60 seconds without an exception — no late-binding errors?
[ ] Open the in-game scoreboard — entity count matches expected?
If you can't tick all seven, you're not done — keep bisecting.
Why: "It compiled" lulls you into the false sense of completion that costs you the next hour when a teammate reports the ESP is 50 pixels off. Five minutes of live verification on patch day is cheaper than any post-merge debugging.
7. Commit the Diff with a Changelog Note
Every patch is data for the next patch. Record what moved, where it moved, and how you found it.
A two-line note per patch turns into the most valuable file in your project after the third patch. It tells you which sigs are stable across patches (keep them), which drift every patch (rewrite from xrefs each time, don't bother updating in place), and which are version-tied (deprecate them entirely).
# patch-log.md
## 2026-06-17 — Game v1.42.3
### Moved
- view_matrix: +0x1C0 (recompile shift, sig still valid)
- local_player: +0x0 (no movement, listed for completeness)
### Re-sigged
- entity_list: old sig `48 8D 0D ?? ?? ?? ?? E8` matched at 3 places (ambiguous)
new sig: `48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 8B D8` (margin=5)
### Lost — deprecated
- ammo_count: function inlined into shoot routine; not recoverable as a global,
folded into per-weapon offset table
### Notes
- ETW Threat Intel callbacks (per anti-cheat-architecture.md) saw activity for
the first time on this build — driver may have updated. Flag for review.
Why: Future-you needs this. The third patch when a sig regresses is when you'll discover that it's been brittle since v1.40 and you should rewrite it from xrefs once and for all instead of patching it again.
Decision: When to Patch vs When to Re-RE from Scratch
Not every patch is a patch — sometimes the game shipped a real engine change and the old offsets are gone, not moved. Heuristics for when the playbook above doesn't apply and you need to start from knowledge/offset-methodology.md again:
| Signal | Likely cause | Action |
|---|
| Module name changed | Engine swap or major rebrand (CSGO → CS2) | Full re-RE; old offsets are reference-only |
| Module size changed >30% | Major engine update or large content drop with code refactor | Bisect aggressively; expect 30-50% sig loss |
Most sigs STALE with no near-miss | Compiler upgrade (Clang version, LTO change) | Re-derive from xrefs; sigs based on RIP-relative globals usually survive better than register-allocation-sensitive ones |
IL2CPP rebuild signal (Unity titles) | metadata.dat changed → entire struct layout rotated | Re-dump with IL2CPPDumper; use tools/dumper-to-enma.py to regenerate offsets.em |
| Schema system reset (Source 2 titles) | Schema registration order changed at runtime | Offsets are runtime-resolved; sigs for the schema getter are usually stable; revalidate the resolver, not the offsets |
| New anti-cheat driver loaded | AC vendor pushed an update | See skill://anti-cheat-re — driver behavior may have changed, not just code layout |
The general rule: if Steps 2-5 are fixing 70%+ of sigs with one-byte tweaks, you're in patch territory — keep going. If they're failing to find any near-misses for the broken sigs, you're in re-RE territory — close the playbook, open IDA, start over from the methodology.
Summary
| # | Step | One-liner |
|---|
| 1 | Snapshot first | Save old binary, old offsets, error log before touching anything |
| 2 | Diff before editing | offset-diff.py triages which sigs survived, moved, lost |
| 3 | Bisect the cascade | Find the first failure, not the loudest |
| 4 | Re-sig with near-miss check | One-byte drift is the common case — find it in seconds |
| 5 | Re-verify RIP math | Instruction-length changes silently break resolved addresses |
| 6 | Live validation | Seven concrete in-game checks before declaring done |
| 7 | Patch log entry | Two lines per patch; the third patch will thank you |
Decision: if Steps 2-5 aren't recovering 70%+ of broken sigs, stop patching and re-RE from scratch via knowledge/offset-methodology.md.
Cross-references: skill://pcx-re-discipline (the rules of RE work), knowledge/offset-methodology.md (sig mechanics), tools/offset-diff.py, tools/sig-uniqueness-checker.py, tools/dumper-to-enma.py (for engines with structured dumpers).