Create Preprocessor Scripts from Scratch
Create an ida_preprocessor_scripts/find-XXXX.py preprocessor script and add the corresponding
configs/<GAMEVER>.yaml entries for a newly requested function, vtable, or struct member offset.
Resolve GAMEVER from the user's explicit request or CS2VIBE_GAMEVER, set the edit target to
configs/$GAMEVER.yaml, and stop if that exact file does not exist. Never edit another version as a fallback.
When to Use
- A GitHub issue or user instruction requests adding support for finding a new function/symbol
- No existing
.claude/skills/find-XXXX/SKILL.mdneeds conversion (for that, useconvert-finder-skill-to-preprocessor-scripts)
Inputs
The user or issue will provide some or all of:
| Field | Description | Example |
|---|---|---|
| Function name(s) | Target symbol(s) to find | CPlayer_MovementServices_PlayWaterStepSound |
| Module | Which DLL/SO the function lives in | server, engine, networksystem, client |
| Category | Symbol type | func, vfunc, structmember, patch, vtable |
| xref_strings | Debug strings for xref-based discovery | "CT_Water.StepLeft" |
| xref_gvs | Global variable VA (e.g. vtable address) to find functions that reference it | vtable VA from SomeClass_vtable.{platform}.yaml |
| xref_funcs | Known callee function name to find its callers | "CPlayerCommandQueue_ctor" |
| Predecessor function | Function to decompile for LLM_DECOMPILE patterns | CBaseEntity_TakeDamageOld |
| VTable class | Class owning the vtable (for vfuncs) | CBasePlayerPawn |
| Desired YAML fields | Which fields the output YAML needs | func_name, func_sig, func_va, func_rva, func_size |
| Dependencies | Input YAMLs this skill depends on | CCSPlayer_MovementServices_vtable.{platform}.yaml |
| Aliases | Alternative names for the symbol | CPlayer_MovementServices::PlayWaterStepSound |
Overview
Twelve preprocessor patterns exist. The discovery method and target type determine which to use:
| Pattern | Discovery Method | Has FUNC_XREFS | Has LLM_DECOMPILE | Has INHERIT_VFUNCS | Has FUNC_VTABLE_RELATIONS | preprocess_skill has llm_config |
|---|---|---|---|---|---|---|
| A -- Regular function via xref strings | find_regex + xrefs_to on debug strings |
Yes | No | No | No | No |
| B -- Virtual function via xref strings | Same as A, but function is in a vtable | Yes | No | No | Yes | No |
| C -- Virtual function via LLM_DECOMPILE | Decompile a known predecessor function, identify vfunc call offsets | No | Yes | No | Yes | Yes |
| D -- Regular function via LLM_DECOMPILE | Decompile a known predecessor function, identify direct call targets | No | Yes | No | No | Yes |
| E -- Struct member offset via LLM_DECOMPILE | Decompile a known predecessor function, identify struct field access offsets | No | Yes | No | No | Yes |
| F -- Virtual function via INHERIT_VFUNCS | Inherit vtable slot index from a known base-class vfunc, look up same slot in derived-class vtable (standard); or slot-only mode for abstract/interface vfuncs where only offset/index is needed | No | No | Yes | No | No |
| G -- ConCommand handler function | Find the handler callback registered via RegisterConCommand by matching command name and help string |
No (uses COMMAND_NAME/HELP_STRING) | No | No | No | No |
| H -- Secondary (ordinal) vtable | Locate a class's secondary vtable via mangled symbol (Windows) or offset-to-top (Linux) | No | No | No | No | No |
| I -- Interface vfunc offset via thunk instruction walk | Walk a known concrete-class thunk via py_eval + idaapi.decode_insn, extract jmp [reg+disp] displacement as vfunc_offset |
No | No | No | No | No |
| J -- IGameSystem vfunc via dispatch scan | Scan IGameSystem_DispatchCall(idx, callback, ...) call sites in a known predecessor; map targets by scan/index order using _igamesystem_dispatch_common |
No | No | No | No | No |
| K -- IGameSystem vfunc via slot dispatch scan | Walk an IGameSystem_Loop*AllSystems dispatcher function body; extract [rax+offset] vtable call displacements via _igamesystem_slot_dispatch_common; output is slot-only (no func_sig) |
No | No | No | No | No |
| L -- Interface vfunc slot via indirect vcall scan | Scan a known thunk/caller for its unique register-indirect vtable call (jmp/call qword ptr [reg+disp]) via _indirect_vcall_target_common; read the displacement as vfunc_offset; output is slot-only (no func_sig). Reusable form of Pattern I |
No | No | No | No | No |
Additionally, struct member offsets can be mixed into any pattern as a secondary target (see "Struct Member Mixin" section below).
Step 1: Determine the Pattern
From the user's input, determine:
Is the target a function, vfunc, or struct member offset?
- Has
xref_strings+ categoryfunc-> Pattern A - Has
xref_strings+ categoryvfunc-> Pattern B - Has
xref_gvs(vtable VA from a vtable YAML) + categoryfunc-> Pattern A with dynamic FUNC_XREFS (read vtable VA at runtime; see "Dynamic FUNC_XREFS via xref_gvs" note) - Has
xref_gvs(vtable VA from a vtable YAML) + categoryvfunc-> Pattern B with dynamic FUNC_XREFS - Has
xref_funcs(known callee function name) + categoryfunc-> Pattern A (static FUNC_XREFS; see "xref_funcs: finding callers of a known function" note) - Has
xref_funcs(known callee function name) + categoryvfunc-> Pattern B (static FUNC_XREFS) - Has predecessor function + category
vfunc-> Pattern C (vfunc_sigis ALWAYS required inGENERATE_YAML_DESIRED_FIELDS-- see "vfunc_sig is MANDATORY for Pattern C" note below) - Has predecessor function + category
func-> Pattern D - Has predecessor function + category
structmember-> Pattern E - Has base vfunc name + category
vfunc(derived-class override of known base vfunc) -> Pattern F- If the target is an abstract/interface vfunc (no real function body, only
vfunc_offset/vfunc_indexneeded) -> use Pattern F slot-only:generate_func_sig=False, desired fields ={func_name, vtable_name, vfunc_offset, vfunc_index}, NO vtable YAML required for the interface class
- If the target is an abstract/interface vfunc (no real function body, only
- Has
COMMAND_NAME+HELP_STRING(ConCommand handler callback) -> Pattern G - Has mangled vtable symbol / offset-to-top + category
vtable(secondary vtable for a class) -> Pattern H - Target is an interface vfunc offset with no feasible
func_sig/vfunc_sig, and the offset can be read from a concrete-class thunk'sjmp [reg+disp]instruction -> Pattern L (preferred, reusable helper) or Pattern I (bespokepy_evalwalk; use only if you need the old-gamever reuse fast path or a custom operand filter) - Target is an
IGameSystemvfunc visible as the callback argument toIGameSystem_DispatchCall(...)in a known predecessor's decompile -> Pattern J - Target is an
IGameSystemabstract vfunc (slot-only output:func_name, vtable_name, vfunc_offset, vfunc_index; nofunc_sig) dispatched by a knownIGameSystem_Loop*AllSystemsfunction that iterates all game systems via vtable; the dispatcher's output YAML (func_va) is already available -> Pattern K - Target is an abstract/interface vfunc dispatched by a thin thunk/caller whose body has exactly one register-indirect vtable call (
jmp/call qword ptr [reg+disp]), and nofunc_sig/vfunc_sigis feasible (ajmp [reg+disp8]for offset<= 0x7Fis only 3 bytes and cannot be signed uniquely) -> Pattern L (slot-only output:func_name, vtable_name, vfunc_offset, vfunc_index; a downstream Pattern F standard override consumes thevfunc_index) - Target
Xwas found by a single Pattern A/B finder, but a helper that used to be inlined intoXde-inlined on some build (the anchor string/call leftX, soX.{platform}.yamlstopped being produced and the fail-fast run aborts the module) -> Pattern M (split into a helper +X-noinline+X-inlinedfallback chain)
- Has
Do xref strings differ between Windows and Linux? If yes, use platform-specific
FUNC_XREFS_WINDOWS/FUNC_XREFS_LINUXvariant.Are there multiple functions? If they share the same discovery method and starting point, put them in the same script with
-AND-in the name. Otherwise, split into separate scripts.
CRITICAL -- LLM_DECOMPILE dependency chains: When LLM_DECOMPILE targets form a chain (FuncA -> FuncB -> FuncC, where each is the predecessor of the next), they MUST be in separate scripts -- one script per link in the chain. A single script CANNOT handle chained LLM_DECOMPILE predecessors because the LLM_DECOMPILE fallback resolves the predecessor's address from its output YAML (func_va field), and within a single script run the predecessor's output YAML doesn't exist yet. The IDA name-lookup fallback also fails because the predecessor wasn't renamed yet.
Step 2: Create the Preprocessor Script
Script location: ida_preprocessor_scripts/find-{skill_name}.py
The filename MUST match the name field in configs/<GAMEVER>.yaml skill entry.
The legacy new_binary_dir parameter name in preprocessor ABIs now receives the active artifact module directory.
Per-symbol YAML reads/writes must stay under the explicit artifact root; only binary/IDA operations use bin/.
Read the reference for your chosen pattern:
- Pattern A -- Regular function via xref strings
- Pattern B -- Virtual function via xref strings
- Pattern C -- Virtual function via LLM_DECOMPILE
- Pattern D -- Regular function via LLM_DECOMPILE
- Pattern E -- Struct member offset via LLM_DECOMPILE
- Pattern F -- Virtual function via INHERIT_VFUNCS (standard + slot-only variant)
- Pattern G -- ConCommand handler function
- Pattern H -- Secondary (ordinal) vtable
- Pattern I -- Interface vfunc offset via thunk walk
- Pattern J -- IGameSystem vfunc via dispatch scan
- Pattern L -- Interface vfunc slot via indirect vcall scan (reusable)
- Pattern M -- Inline/noinline fallback chain (de-inlined helper)
Cross-Cutting Notes
FULLMATCH: Prefix for Xref Strings (Patterns A & B)
When the xref string is short or generic (e.g. "Precache", "userid", "team"), use the FULLMATCH: prefix to require exact string matching instead of substring matching. Without it, "Precache" would match "PrecacheModel", "PrecacheSound", etc.
FUNC_XREFS = [
{
"func_name": "CEntityInstance_Precache",
"xref_strings": [
"FULLMATCH:Precache", # Only matches the exact string "Precache"
],
"xref_gvs": [], "xref_signatures": [], "xref_funcs": [],
"exclude_funcs": [], "exclude_strings": [], "exclude_gvs": [], "exclude_signatures": [],
},
]
Dynamic FUNC_XREFS via xref_gvs (vtable VA)
When the target function is the constructor (or any other function that references a class's vtable), use xref_gvs with the vtable's virtual address. Because the vtable VA is only known after IDA analysis, it cannot be hardcoded -- it must be read from the vtable's output YAML at runtime.
This requires a custom preprocess_skill that:
- Reads
vtable_vafrom{VtableClass}_vtable.{platform}.yamlinnew_binary_dir - Builds
func_xrefsdynamically with the VA inxref_gvs - Passes the dynamic list to
preprocess_common_skill
import os
try:
import yaml
except ImportError:
yaml = None
def _read_vtable_va(yaml_path):
try:
with open(yaml_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if isinstance(data, dict):
va = data.get("vtable_va")
if va:
return str(va)
except Exception:
pass
return None
async def preprocess_skill(
session, skill_name, expected_outputs, old_yaml_map,
new_binary_dir, platform, image_base, debug=False,
):
vtable_yaml_path = os.path.join(new_binary_dir, f"SomeClass_vtable.{platform}.yaml")
vtable_va = _read_vtable_va(vtable_yaml_path)
if not vtable_va:
if debug:
print(" Preprocess: SomeClass_vtable vtable_va not found, cannot resolve xref_gvs")
return False
func_xrefs = [
{
"func_name": "SomeClass_ctor",
"xref_strings": [],
"xref_gvs": [str(vtable_va)],
"xref_signatures": [],
"xref_funcs": [],
"exclude_funcs": [],
"exclude_strings": [],
"exclude_gvs": [],
"exclude_signatures": [],
},
]
return await preprocess_common_skill(
session=session,
expected_outputs=expected_outputs,
old_yaml_map=old_yaml_map,
new_binary_dir=new_binary_dir,
platform=platform,
image_base=image_base,
func_names=TARGET_FUNCTION_NAMES,
func_xrefs=func_xrefs,
generate_yaml_desired_fields=GENERATE_YAML_DESIRED_FIELDS,
debug=debug,
)
configs/.yaml expected_input: must include the vtable YAML so it is guaranteed to be resolved before this script runs.
Multiple xrefs / exclude_signatures: If more than one function references the vtable (e.g. constructor + destructor), the intersection yields >1 result and the skill fails. Use exclude_signatures to exclude the unwanted function(s). If the ambiguity is platform-specific, make the exclusion conditional:
exclude_signatures = ["66 83 ?? FF"] if platform == "linux" else []
To find the right bytes to exclude: look up the two candidate addresses in IDA, read the first ~4 bytes of the function to exclude, and use those as the exclude_signatures pattern with ?? wildcards where needed.
xref_funcs: Finding Callers of a Known Function (Patterns A & B)
When the target function is discoverable as a caller of another already-known function, use xref_funcs with the callee's name. Unlike xref_gvs, the function name is available at script-write time, so FUNC_XREFS can be a static module-level constant -- no dynamic building required.
FUNC_XREFS = [
{
"func_name": "TargetFunc",
"xref_strings": [],
"xref_gvs": [],
"xref_signatures": [],
"xref_funcs": ["KnownCalleeFunc"], # callee that the target calls
"exclude_funcs": [],
"exclude_strings": [],
"exclude_gvs": [],
"exclude_signatures": [],
},
]
configs/.yaml expected_input: include the callee's output YAML to guarantee it is renamed in IDA before this script runs (the name lookup requires the rename to have happened):
expected_input:
- KnownCalleeFunc.{platform}.yaml # ensures callee is renamed first
- TargetClass_vtable.{platform}.yaml # if target is a vfunc (Pattern B)
Struct Member Mixin (for any pattern)
Struct member offsets can also be mixed into a function-finding script when they are discovered from the same function via signature matching (not LLM_DECOMPILE). Add TARGET_STRUCT_MEMBER_NAMES alongside TARGET_FUNCTION_NAMES and pass struct_member_names= to preprocess_common_skill:
Choose size from the locating instruction, not from the member name. Include size only when the annotated
instruction reads or writes the member and therefore has a natural operand width (for example, mov, movss, or
cmp). When the locating instruction is lea reg, [base+offset], it only computes the address of an embedded
member; omit size from GENERATE_YAML_DESIRED_FIELDS. A lea result can identify a member offset but cannot
reliably establish the member's extent.
TARGET_FUNCTION_NAMES = [
"SomeFunction",
]
TARGET_STRUCT_MEMBER_NAMES = [
"SomeStruct_m_someField",
]
GENERATE_YAML_DESIRED_FIELDS = [
("SomeFunction", ["func_name", "func_sig", "func_va", "func_rva", "func_size"]),
# This target is located by `lea`; add "size" only for a real memory access.
("SomeStruct_m_someField", ["struct_name", "member_name", "offset", "offset_sig", "offset_sig_disp"]),
]
# In preprocess_skill:
return await preprocess_common_skill(
...
func_names=TARGET_FUNCTION_NAMES,
struct_member_names=TARGET_STRUCT_MEMBER_NAMES,
...
)
CRITICAL -- FUNC_VTABLE_RELATIONS and vfunc fields
FUNC_VTABLE_RELATIONS is required for ANY target whose GENERATE_YAML_DESIRED_FIELDS includes vtable_name or vfunc_sig -- not just Pattern B and C. Without it, the LLM_DECOMPILE slot-only fallback fails with "slot-only fallback missing vtable_name" and the entire skill fails.
This applies even when:
- The target is a vfunc call-site offset (e.g.
call [rax+128h]) rather than an actual function body in a vtable - No vtable YAML exists for that class in configs/.yaml (no
expected_inputfor the vtable needed) - The script also finds non-vfunc targets (global variables, struct offsets) alongside the vfunc target
The vtable_name from FUNC_VTABLE_RELATIONS is used as metadata written to the output YAML -- it does NOT require an actual vtable lookup. For example, ("IGameTypes_CreateWorkshopMapGroup", "IGameTypes") provides the vtable class name IGameTypes even though no IGameTypes_vtable.{platform}.yaml exists.
Rule of thumb: If any field in GENERATE_YAML_DESIRED_FIELDS starts with vfunc_ or equals vtable_name, the target MUST have an entry in FUNC_VTABLE_RELATIONS.
CRITICAL -- vfunc_sig is MANDATORY for Pattern C (vfunc via LLM_DECOMPILE)
For ANY vfunc discovered via LLM_DECOMPILE (Pattern C), GENERATE_YAML_DESIRED_FIELDS MUST include vfunc_sig. This is non-negotiable -- the slot index alone is not stable across binary updates without a signature anchor on the actual vfunc body.
This rule applies to BOTH variants:
- Standard Pattern C (also a downstream predecessor):
func_name, func_va, func_rva, func_size, vfunc_sig, vfunc_offset, vfunc_index, vtable_name - Slim Pattern C (not a downstream predecessor):
func_name, vfunc_sig, vfunc_offset, vfunc_index, vtable_name
Pure slot-only output (func_name, vtable_name, vfunc_offset, vfunc_index with NO vfunc_sig) is reserved for Pattern F slot-only / Pattern I / Pattern K / Pattern L -- it is NOT a valid output shape for Pattern C. Examples that follow this rule: find-CEntityInstance_ScriptEntityIO.py, find-CEntityInstance_Restore.py, find-CEntityInstance_RequiredEdictIndex.py, find-CEntityInstance_PreDataUpdate.py, find-CEntityInstance_PostDataUpdate.py, find-CEntityInstance_NetworkUpdateState.py.
Key Differences Between Patterns
| Aspect | Pattern A (func + xref) | Pattern B (vfunc + xref) | Pattern C (vfunc + LLM) | Pattern D (func + LLM) | Pattern E (structmember + LLM) | Pattern F (vfunc + inherit) | Pattern G (ConCommand handler) | Pattern H (ordinal vtable) | Pattern I (iface vfunc thunk walk) | Pattern J (IGameSystem dispatch) | Pattern K (IGameSystem slot dispatch) | Pattern L (iface vfunc vcall scan) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| FUNC_XREFS | Yes | Yes | No | No | No | No | No (uses COMMAND_NAME/HELP_STRING) | No | No | No | No | No |
| FUNC_VTABLE_RELATIONS | No | Yes | Yes | No | No | No | No | No | No | No | No | No |
| INHERIT_VFUNCS | No | No | No | No | No | Yes | No | No | No | No | No | No |
| LLM_DECOMPILE | No | No | Yes | Yes | Yes | No | No | No | No | No | No | No |
llm_config param |
No | No | Yes | Yes | Yes | No | No | No | No | No | No | No |
| Helper module | preprocess_common_skill |
preprocess_common_skill |
preprocess_common_skill |
preprocess_common_skill |
preprocess_common_skill |
preprocess_common_skill |
preprocess_registerconcommand_skill |
preprocess_ordinal_vtable_via_mcp |
py_eval + write_func_yaml (custom) |
preprocess_igamesystem_dispatch_skill |
preprocess_igamesystem_slot_dispatch_skill (from _igamesystem_slot_dispatch_common) |
preprocess_indirect_vcall_target_skill (from _indirect_vcall_target_common) |
| Target list | TARGET_FUNCTION_NAMES |
TARGET_FUNCTION_NAMES |
TARGET_FUNCTION_NAMES |
TARGET_FUNCTION_NAMES |
TARGET_STRUCT_MEMBER_NAMES |
(none -- defined in INHERIT_VFUNCS) | TARGET_FUNCTION_NAMES |
TARGET_CLASS_NAME (single string) |
TARGET_FUNC_NAME + PREDECESSOR_STEM (module-level constants) |
TARGET_SPECS (list of dicts with target_name, rename_to, optional dispatch_rank) |
TARGET_SPECS (list of dicts with target_name, vtable_name, optional dispatch_rank) |
SOURCE_FUNCTION_NAME + TARGET_FUNCTION_NAME + VTABLE_CLASS (module-level constants) |
| preprocess param | func_names= |
func_names= |
func_names= |
func_names= |
struct_member_names= |
inherit_vfuncs= |
command_name=, help_string= |
class_name=, ordinal= |
(custom: reads YAML, calls py_eval) |
source_yaml_stem=, target_specs=, via_internal_wrapper=, multi_order= |
dispatcher_yaml_stem=, target_specs=, multi_order=, expected_dispatch_count= |
source_yaml_stem=, target_name=, vtable_name= |
| YAML fields | func_name, func_sig, func_va, func_rva, func_size | Same + vtable_name, vfunc_offset, vfunc_index | vfunc_sig ALWAYS required. Standard: func_name, func_va, func_rva, func_size, vfunc_sig, vfunc_offset, vfunc_index, vtable_name. Slim (not a downstream predecessor): func_name, vfunc_sig, vfunc_offset, vfunc_index, vtable_name | func_name, func_sig, func_va, func_rva, func_size | struct_name, member_name, offset, offset_sig, offset_sig_disp; add size only for a non-lea memory access |
Standard: func_name, func_va, func_rva, func_size, func_sig, vtable_name, vfunc_offset, vfunc_index; Slot-only: func_name, vtable_name, vfunc_offset, vfunc_index | func_name, func_sig, func_va, func_rva, func_size | (vtable YAML via write_vtable_yaml) | func_name, vtable_name, vfunc_offset, vfunc_index | func_name, func_va, func_rva, func_size, func_sig, vtable_name, vfunc_offset, vfunc_index | func_name, vtable_name, vfunc_offset, vfunc_index | func_name, vtable_name, vfunc_offset, vfunc_index |
| config category | func |
vfunc |
vfunc |
func |
structmember |
vfunc |
func |
vtable |
vfunc |
vfunc |
vfunc |
vfunc |
Step 3: Update configs/.yaml
3a. Skills Section
Each preprocessor script needs a corresponding skill entry under the appropriate module's skills: list.
Find the module section (e.g. server, engine, networksystem) and add entries in logical order (near related functions).
Template:
- name: find-{SKILL_NAME}
expected_output:
- {FUNC_NAME_1}.{platform}.yaml
# - {FUNC_NAME_2}.{platform}.yaml # One per target function
# expected_input only if the skill depends on other YAMLs:
expected_input:
- {PREDECESSOR_FUNC}.{platform}.yaml # For Patterns C & D: the reference function
- {VTABLE_CLASS}_vtable.{platform}.yaml # For Patterns B & C: the vtable
Rules:
expected_output: One.{platform}.yamlper target function in the scriptexpected_input: Include predecessor function YAML (Patterns C & D) and/or vtable YAML (Patterns B & C & F)- Pattern A with no vtable: typically NO
expected_input - Pattern F (standard): needs both the derived class vtable YAML and the base vfunc YAML in
expected_input - Pattern F (slot-only): needs ONLY the base vfunc YAML in
expected_input-- no vtable YAML for the interface class - Pattern J: needs both the predecessor function YAML and
IGameSystem_vtable.{platform}.yamlinexpected_input - Pattern K: needs ONLY
{DISPATCHER_YAML_STEM}.{platform}.yamlinexpected_input-- noIGameSystem_vtable.{platform}.yamlneeded - Multi-function scripts use
-AND-in the name:find-FuncA-AND-FuncB - Place the new entry near related functions (e.g.
CCSPlayer_MovementServices_*entries together)
Dependency chain example (multi-script):
# Pattern A: found via xref string, no dependencies
- name: find-FuncA
expected_output:
- FuncA.{platform}.yaml
# Pattern C: found by decompiling FuncA, needs FuncA + vtable
- name: find-FuncB
expected_output:
- FuncB.{platform}.yaml
expected_input:
- FuncA.{platform}.yaml
- SomeClass_vtable.{platform}.yaml
3b. Symbols Section
For each target symbol, add a symbol entry under the same module's symbols: list (if not already present).
# Regular function (Pattern A)
- name: {FUNC_NAME}
category: func
alias:
- {ClassName}::{MethodName} # e.g. CPlayer_MovementServices::PlayWaterStepSound
# Virtual function (Patterns B & C)
- name: {FUNC_NAME}
category: vfunc
alias:
- {ClassName}::{MethodName} # e.g. CBasePlayerPawn::OnTakeDamage
# Struct member offset (Pattern E)
- name: {STRUCT_MEMBER_NAME}
category: structmember
struct: {STRUCT_NAME}
member: {MEMBER_NAME}
alias:
- {StructName}::{MemberName}
CRITICAL -- Declare the parent struct for every struct member
For every symbol with category: structmember, verify that the same module's symbols: list already
contains its parent struct as a metadata-only declaration:
- name: {STRUCT_NAME}
category: struct
# Include platform only when the struct is platform-restricted.
platform: {platform}
- If the parent struct is missing, add the declaration immediately before its first struct member.
- If it already exists, reuse it; do not add a duplicate declaration or narrow its existing platform coverage.
- Match platform coverage: use the member's
platformwhen all members are restricted to that platform; omitplatformwhen the struct has members on both Windows and Linux. - A
category: structentry is metadata-only. Do not addsource_alias, anexpected_output, or a separate preprocessor skill for it.
Without this declaration, gamedata config validation fails with
'<StructName>' is not a declared struct in this module.
Check existing symbols before adding -- do NOT create duplicates.
Place the new symbol near related symbols (same class/subsystem).
Step 4: Handle Reference YAMLs (Patterns C, D & E only)
Pattern C, D, and E scripts reference a predecessor function's YAML at:
ida_preprocessor_scripts/references/{module}/{PREDECESSOR_FUNC}.{platform}.yaml
Check if the reference YAML already exists:
ida_preprocessor_scripts/references/{module}/{PREDECESSOR_FUNC}.linux.yamlida_preprocessor_scripts/references/{module}/{PREDECESSOR_FUNC}.windows.yaml
Always generate them using generate_reference_yaml.py:
# Windows -- always pass -platform windows explicitly
uv run generate_reference_yaml.py -func_name {PREDECESSOR_FUNC} -auto_start_mcp -binary "bin/{gamever}/{module}/{binary_name}.dll" -platform windows -debug
# Linux -- always pass -platform linux explicitly
uv run generate_reference_yaml.py -func_name {PREDECESSOR_FUNC} -auto_start_mcp -binary "bin/{gamever}/{module}/lib{module}.so" -platform linux -debug
where {gamever} can be obtained from .env -> CS2VIBE_GAMEVER.
IMPORTANT -- Always pass -platform explicitly. While -platform can theoretically be inferred from the binary extension (.dll -> windows, .so -> linux), auto-inference is unreliable and may produce the wrong platform's reference YAML. Always pass -platform windows or -platform linux explicitly.
IMPORTANT -- Run generate_reference_yaml.py sequentially, NOT in parallel. All invocations share the same IDA MCP connection. Running them in parallel will cause connection conflicts and failures. Run one command at a time, waiting for each to complete before starting the next.
YOU MUST: rename known symbols / add necessary comments in the generated reference YAMLs so the LLM can find desired symbols by comparing reference ones with raw procedure/disassembly read from new binaries. Always annotate both disasm_code and procedure fields. Format by target type:
Direct function call — rename sub_XXXX to the target function name in both fields.
Virtual function call — add offset comment:
disasm_code:call qword ptr [rax+3F0h] ; 3F0h = CBaseEntity_OnTakeDamageprocedure:(*a1 + 1008LL)... // 1008LL = 0x3F0 = CBaseEntity_OnTakeDamage
Global variable — rename qword_XXXX to the target name in both fields.
Struct member access — add comments using the (structmember, struct=X, member=Y) tag:
disasm_code:cmp dil, [rsi+0C1h] ; 0C1h = SDL_Mouse::relative_mode (structmember, struct=SDL_Mouse, member=relative_mode)procedure:a1 != Mouse->field // 0xC1 = SDL_Mouse::relative_mode (structmember, struct=SDL_Mouse, member=relative_mode)
The (structmember, struct=StructName, member=member_name) tag is required for all struct member annotations — it tells the LLM which struct and member name to report back. Annotate every access site for the target field.
IMPORTANT -- When the predecessor is a NEW function (no existing output YAMLs): If the predecessor function is brand new (discovered by another new script you're creating at the same time), its output YAMLs don't exist yet and generate_reference_yaml.py cannot resolve its address. You must use a multi-phase workflow:
- Phase 1: Create ALL scripts (vtable, xref_string, LLM_DECOMPILE) and update configs/.yaml
- Phase 2: Seed a checkout-external artifact root from tracked
bin_artifacts, remove the new outputs only there, and run the required module/skills without-force_all, with-oldgamever noneand explicit-artifactdir/-oldartifactdir. The vtable and xref-string scripts populate the new predecessor in the isolated root. - Phase 3: Now that the predecessor has output YAMLs, run
generate_reference_yaml.pyto create reference YAMLs, then annotate them. - Phase 4: Re-run the seeded affected-closure validation (the same root is fine once the reference YAMLs exist); then copy the canonical closure to tracked
bin_artifactsand run the repository artifact contract. Full force-all recomputation is delegated to the PR pipeline's selected execution (see Step 5).
IMPORTANT -- When the reference YAML already existed: generate_reference_yaml.py regenerates the file from scratch and silently overwrites any hand-written annotation comments. After running it, check the diff for each regenerated file:
git diff ida_preprocessor_scripts/references/{module}/{PREDECESSOR_FUNC}.{platform}.yaml
Look for removed lines (prefixed with - in the diff) that are annotation comments: lines beginning with ; inside disasm_code or // inside procedure. If any were dropped, restore them verbatim by copying directly from the - lines in the diff output into the correct locations in the regenerated file. Do not reconstruct comments from memory -- copy from the diff.
Step 5: Run Tests
After all creation steps are complete, validate the affected closure in a checkout-external seeded artifact root.
Seed the root from tracked bin_artifacts/<GAMEVER>/, remove only the new/changed outputs there, and run the affected
modules without -force_all (unrelated skills are skipped automatically because their outputs already exist in the
seed). Never delete or rewrite tracked expected artifacts merely to make a test run.
Because the output is very long, redirect it to a temp file and then read just the summary:
uv run ida_analyze_bin.py -gamever <GAMEVER> -configyaml configs/<GAMEVER>.yaml \
-artifactdir <checkout-external-seeded-root> -oldartifactdir bin_artifacts \
-oldgamever none -modules <AFFECTED_MODULE> -debug > /tmp/ida_test_output.txt 2>&1
tail -10 /tmp/ida_test_output.txt
Check the Summary at the end of the output:
- Failed: 0 means the affected closure is correct
- If any failures, search the full output for the failing skill name to investigate:
grep -A 5 "Failed\|Error" /tmp/ida_test_output.txt
This step is mandatory -- do not report completion without running and passing it. A local full -force_all run is
not required: the PR pipeline recomputes the affected closure itself through the trusted base-inherited-selected
execution strategy (pr-self-runner.yml -> -selected_execution manifest, falling back to -force_all only when no
manifest is published) and compares the rebuilt bytes against the PR, so a local full-force run is redundant.
After the seeded run passes, copy the computed affected/downstream closure into bin_artifacts/<GAMEVER>/ and run the
repository artifact contract.
Step 6: Run Regression Tests
Run the non-MCP unittest suite:
uv run python -c "from pathlib import Path; import sys, unittest; excluded={'test_ida_mcp_session', 'test_smoke_ida_mcp_2'}; modules=[f'tests.{path.stem}' for path in Path('tests').glob('test_*.py') if path.stem not in excluded]; result=unittest.TextTestRunner(buffer=True).run(unittest.defaultTestLoader.loadTestsFromNames(modules)); sys.exit(not result.wasSuccessful())"
This intentionally excludes the IDA MCP adapter and smoke modules (test_ida_mcp_session,
test_smoke_ida_mcp_2) to keep preprocessor work fast. Run those modules separately when changing
MCP routing or lifecycle code.
Keep 0 selected unittest failures before delivery. If any test fails, investigate and fix it before staging the task changes.
Step 7: Commit Changes to dev
After validation passes, ensure the delivery branch is dev. Never commit directly to main. If the local dev
branch exists, switch to it. Otherwise, switch to main first and create dev from main:
if git show-ref --verify --quiet refs/heads/dev; then
git switch dev
else
git switch main
git switch -c dev
fi
If any branch switch fails, stop and report the error. Review git status --short, then explicitly stage the
task-related source/config/reference files and complete bin_artifacts closure. Never use git add -A:
git add -- ida_preprocessor_scripts/find-{SKILL_NAME}.py configs/<GAMEVER>.yaml
git add -- <generated-reference-yamls>
git add -- <bin_artifacts-affected-and-downstream-closure>
git diff --cached --name-only
Include every task-related implementation file changed:
- The new preprocessor script
- configs/.yaml changes
- Any reference YAMLs generated (for Patterns C/D/E)
- Every canonical
bin_artifacts/<GAMEVER>/A/M/D/R in the computed closure
Stop if the staged-path list contains anything unrelated to this task. Commit only the staged task changes using the repository commit format:
git commit -m "feat(preprocessor): add find-{SKILL_NAME}" -m "Co-Authored-By: Codex <codex@openai.com>"
Do not call /create-pr, push the branch, or open a pull request unless the user separately requests it. Finish by
reporting the commit hash, game version, and successful ida_analyze_bin.py and non-MCP unittest results.
Checklist
Before finishing, verify:
- Preprocessor script file name matches the
namefield in configs/.yaml skill entry -
GENERATE_YAML_DESIRED_FIELDSuses correct field set for the pattern - A struct member located by
leaomitssize; a non-leamemory access includes it - configs/.yaml
expected_outputhas one entry per target - configs/.yaml
expected_inputcorrectly chains dependencies - configs/.yaml
symbolssection has entries for all targets (no duplicates) - Every
structmemberparent has one metadata-onlycategory: structdeclaration in the same module with compatible platform coverage - Pattern-specific checks pass (see the Checklist section in the chosen pattern reference file)
- Seeded-root
ida_analyze_bin.pyrun over the affected closure passes with 0 failures and does not modify checkout expected artifacts (full force-all recomputation is delegated to the PR pipeline's selected execution) - Complete canonical
bin_artifactsclosure is staged; no snapshot/gamedata/release-manifest output is staged - Non-MCP unittest command above passes with 0 failures
- The current branch is
dev(created frommainwhen it did not already exist) - Only task-related source/config/reference files and their computed artifact closure are explicitly staged and committed
-
/create-prwas not called; no push or PR was performed without a separate user request
Real-World Examples
Example: Regular function via xref string (Pattern A)
Issue says: CPlayer_MovementServices_PlayWaterStepSound is a regular function in server dll. xref_strings: "CT_Water.StepLeft". Fields needed: func_name, func_sig, func_va, func_rva, func_size.
Result: ida_preprocessor_scripts/find-CPlayer_MovementServices_PlayWaterStepSound.py with:
FUNC_XREFScontaining"CT_Water.StepLeft"GENERATE_YAML_DESIRED_FIELDSwithfunc_name, func_sig, func_va, func_rva, func_size- No
FUNC_VTABLE_RELATIONS, noLLM_DECOMPILE - configs/.yaml skill entry with
expected_output: CPlayer_MovementServices_PlayWaterStepSound.{platform}.yaml - configs/.yaml symbol entry with
category: func, aliasCPlayer_MovementServices::PlayWaterStepSound
Example: Virtual function via xref string (Pattern B)
Issue says: CSource2GameEntities_CheckTransmit is a vfunc of CSource2GameEntities in server dll. xref_strings: "CSource2GameEntities::CheckTransmit" (Windows), "./gameinterface.cpp:30" (Linux).
Result: ida_preprocessor_scripts/find-CSource2GameEntities_CheckTransmit.py with:
- Platform-specific
FUNC_XREFS_WINDOWS/FUNC_XREFS_LINUX FUNC_VTABLE_RELATIONS:("CSource2GameEntities_CheckTransmit", "CSource2GameEntities")GENERATE_YAML_DESIRED_FIELDSwith vtable fields- configs/.yaml
expected_input: CSource2GameEntities_vtable.{platform}.yaml
Example: Multiple functions from same xref (Pattern A, multi-target)
Issue says: Find both FuncA and FuncB in server. Both use xref string "SharedDebugString".
Result: ida_preprocessor_scripts/find-FuncA-AND-FuncB.py with:
- Two entries in
TARGET_FUNCTION_NAMES - Two entries in
FUNC_XREFS(each with the same or different xref strings) - Two entries in
GENERATE_YAML_DESIRED_FIELDS - configs/.yaml skill name:
find-FuncA-AND-FuncB - configs/.yaml: two
expected_outputentries, two symbol entries
Example: Derived-class vfunc via INHERIT_VFUNCS (Pattern F)
Issue says: CBaseEntity_Precache is a vfunc on CBaseEntity that overrides CEntityInstance::Precache at the same vtable slot. CEntityInstance_Precache is already found by another script.
Result: ida_preprocessor_scripts/find-CBaseEntity_Precache.py with:
INHERIT_VFUNCS: `("CBa
…(truncated)