# Create Preprocessor Scripts

> Create a new find-XXXX preprocessor Python script from scratch (no existing SKILL.md), add configs/<GAMEVER>.yaml skill and symbol entries. Covers xref-string-based and LLM_DECOMPILE-based discovery patterns. Use when a GitHub issue or user instruction specifies a new function to find.

- Skill: `hlnd2t/create-preprocessor-scripts` (Agent Skill, multi-file: 14 files)
- Install (CLI): `npx skillmds@latest add hlnd2t/create-preprocessor-scripts`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hlnd2t/create-preprocessor-scripts/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: HLND2T (https://skillmd.com/u/hlnd2t)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/hlnd2t/create-preprocessor-scripts

---


# 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.md` needs conversion (for that, use `convert-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:

1. **Is the target a function, vfunc, or struct member offset?**
   - Has `xref_strings` + category `func` -> **Pattern A**
   - Has `xref_strings` + category `vfunc` -> **Pattern B**
   - Has `xref_gvs` (vtable VA from a vtable YAML) + category `func` -> **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) + category `vfunc` -> **Pattern B** with dynamic FUNC_XREFS
   - Has `xref_funcs` (known callee function name) + category `func` -> **Pattern A** (static FUNC_XREFS; see "xref_funcs: finding callers of a known function" note)
   - Has `xref_funcs` (known callee function name) + category `vfunc` -> **Pattern B** (static FUNC_XREFS)
   - Has predecessor function + category `vfunc` -> **Pattern C** (`vfunc_sig` is ALWAYS required in `GENERATE_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_index` needed) -> 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
   - 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's `jmp [reg+disp]` instruction -> **Pattern L** (preferred, reusable helper) or **Pattern I** (bespoke `py_eval` walk; use only if you need the old-gamever reuse fast path or a custom operand filter)
   - Target is an `IGameSystem` vfunc visible as the callback argument to `IGameSystem_DispatchCall(...)` in a known predecessor's decompile -> **Pattern J**
   - Target is an `IGameSystem` abstract vfunc (slot-only output: `func_name, vtable_name, vfunc_offset, vfunc_index`; no `func_sig`) dispatched by a known `IGameSystem_Loop*AllSystems` function 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 no `func_sig`/`vfunc_sig` is feasible (a `jmp [reg+disp8]` for offset `<= 0x7F` is 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 the `vfunc_index`)
   - Target `X` was found by a single Pattern A/B finder, but a helper that used to be inlined into `X` **de-inlined** on some build (the anchor string/call left `X`, so `X.{platform}.yaml` stopped being produced and the fail-fast run aborts the module) -> **Pattern M** (split into a helper + `X-noinline` + `X-inlined` fallback chain)

2. **Do xref strings differ between Windows and Linux?** If yes, use platform-specific `FUNC_XREFS_WINDOWS` / `FUNC_XREFS_LINUX` variant.

3. **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](references/pattern-A.md)
- [Pattern B -- Virtual function via xref strings](references/pattern-B.md)
- [Pattern C -- Virtual function via LLM_DECOMPILE](references/pattern-C.md)
- [Pattern D -- Regular function via LLM_DECOMPILE](references/pattern-D.md)
- [Pattern E -- Struct member offset via LLM_DECOMPILE](references/pattern-E.md)
- [Pattern F -- Virtual function via INHERIT_VFUNCS](references/pattern-F.md) (standard + slot-only variant)
- [Pattern G -- ConCommand handler function](references/pattern-G.md)
- [Pattern H -- Secondary (ordinal) vtable](references/pattern-H.md)
- [Pattern I -- Interface vfunc offset via thunk walk](references/pattern-I.md)
- [Pattern J -- IGameSystem vfunc via dispatch scan](references/pattern-J.md)
- [Pattern L -- Interface vfunc slot via indirect vcall scan (reusable)](references/pattern-L.md)
- [Pattern M -- Inline/noinline fallback chain (de-inlined helper)](references/pattern-M.md)

### 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.

```python
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:
1. Reads `vtable_va` from `{VtableClass}_vtable.{platform}.yaml` in `new_binary_dir`
2. Builds `func_xrefs` dynamically with the VA in `xref_gvs`
3. Passes the dynamic list to `preprocess_common_skill`

```python
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/<GAMEVER>.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:

```python
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.

```python
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/<GAMEVER>.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):

```yaml
        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.

```python
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/<GAMEVER>.yaml (no `expected_input` for 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/<GAMEVER>.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:**

```yaml
      - 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}.yaml` per target function in the script
- `expected_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}.yaml` in `expected_input`
- Pattern K: needs ONLY `{DISPATCHER_YAML_STEM}.{platform}.yaml` in `expected_input` -- no `IGameSystem_vtable.{platform}.yaml` needed
- 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):

```yaml
      # 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).

```yaml
      # 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:

```yaml
      - 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 `platform` when all members are restricted to that platform;
  omit `platform` when the struct has members on both Windows and Linux.
- A `category: struct` entry is metadata-only. Do not add `source_alias`, an `expected_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.yaml`
- `ida_preprocessor_scripts/references/{module}/{PREDECESSOR_FUNC}.windows.yaml`

**Always** generate them using `generate_reference_yaml.py`:

```bash
# 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_OnTakeDamage`
- `procedure`: `(*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**:

1. **Phase 1:** Create ALL scripts (vtable, xref_string, LLM_DECOMPILE) and update configs/<GAMEVER>.yaml
2. **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 none` and explicit `-artifactdir`/`-oldartifactdir`. The vtable and xref-string scripts populate the new predecessor in the isolated root.
3. **Phase 3:** Now that the predecessor has output YAMLs, run `generate_reference_yaml.py` to create reference YAMLs, then annotate them.
4. **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_artifacts` and 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:

```bash
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:

```bash
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:
  ```bash
  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:

```bash
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`:

```bash
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`:

```bash
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/<GAMEVER>.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:

```bash
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 `name` field in configs/<GAMEVER>.yaml skill entry
- [ ] `GENERATE_YAML_DESIRED_FIELDS` uses correct field set for the pattern
- [ ] A struct member located by `lea` omits `size`; a non-`lea` memory access includes it
- [ ] configs/<GAMEVER>.yaml `expected_output` has one entry per target
- [ ] configs/<GAMEVER>.yaml `expected_input` correctly chains dependencies
- [ ] configs/<GAMEVER>.yaml `symbols` section has entries for all targets (no duplicates)
- [ ] Every `structmember` parent has one metadata-only `category: struct` declaration 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.py` run 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_artifacts` closure 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 from `main` when it did not already exist)
- [ ] Only task-related source/config/reference files and their computed artifact closure are explicitly staged and committed
- [ ] `/create-pr` was 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_XREFS` containing `"CT_Water.StepLeft"`
- `GENERATE_YAML_DESIRED_FIELDS` with `func_name, func_sig, func_va, func_rva, func_size`
- No `FUNC_VTABLE_RELATIONS`, no `LLM_DECOMPILE`
- configs/<GAMEVER>.yaml skill entry with `expected_output: CPlayer_MovementServices_PlayWaterStepSound.{platform}.yaml`
- configs/<GAMEVER>.yaml symbol entry with `category: func`, alias `CPlayer_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_FIELDS` with vtable fields
- configs/<GAMEVER>.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/<GAMEVER>.yaml skill name: `find-FuncA-AND-FuncB`
- configs/<GAMEVER>.yaml: two `expected_output` entries, 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)
