---
name: dayz-p3d-audit
description: >
  Audit and fix DayZ .p3d model files for collision, action targeting, physics, animation,
  path, and structural issues. Runs py3d-based validation against known-working reference
  patterns. Also validates config.cpp and .rvmat files for path and property issues.
  Use this skill whenever: a DayZ mod object has no collision, actions don't appear,
  the player walks through placed objects, CCTObject raycasts miss, Geometry LOD seems
  ignored, a model was exported from Blender and doesn't work in-game, textures are
  missing, animations don't play, or the user says "review the p3d", "check the model",
  "audit collision", "why no actions", "flag/object has no collision", "audit the mod",
  "check paths", "validate the model". Also trigger when debugging any placed
  Inventory_Base item. This is the GO-TO skill for P3D and mod structure debugging.
---

# DayZ P3D Audit — Complete Model & Mod Validator

Validates .p3d model files, config.cpp, model.cfg, and .rvmat materials against
DayZ engine requirements. Built from production debugging where models rendered
correctly but had zero collision, missing animations, or broken textures.

## Preflight gate

Per the L2 rule (`_shared/dayz-conventions.md`), every DayZ skill that does work gates on `/dayz-preflight` first. The audit reads `.p3d` files which can live anywhere, but the moment you point it at `P:\` paths or a built mod, an unmounted P-drive silently produces wrong results. Run `/dayz-preflight` before this skill.

## The three py3d gates cannot go red for geometry (SP-227-py3d-gates-cannot-go-red)

A clean `validate()`, a passing `save(verify=True)`, or a `diff` that reports equal is not evidence that the geometry is correct. Those checks only prove internal count consistency. When declaring a model verified, say whether vertex order was compared against debinarized vanilla or whether it was tested in-game.

1. `validate()` does not detect GLOBAL winding inversion. The winding check is relative to the Visual LOD. If every LOD is inverted (Blender Z-up to Y-up; this skill, PART 5 item 1), `validate()` returns an empty list and exit 0. If only Visual is inverted, it accuses healthy collision LODs and suggests swapping vertices on every face; following that instruction reaches the silent-broken state.
2. `save(verify=True)`: _verify_against does not compare geometry. It looks at counts, selection names and mass sum. Points `(0,0,0)` vs `(99,99,99)` still verify OK.
3. `python -m py3d diff`: py3d diff total: 0 does not prove geometric equality. The same pair reports `total: 0`, exit 0.

[DESIGN] Discriminating signal on a vanilla-scale LOD set: edge coherence (neighbors traverse the shared edge in opposite directions) and `cross(e1,e2) . declared_normal` in the same space.

Update 2026-09-07: the fork (1.7.0) ships that absolute signal as `_check_winding_absolute` → `ERR_WINDING_VS_NORMALS`, and it is calibrated against shipped MLOD (100 % on two production models; 0 % across every LOD = real global inversion). Item 1 above describes the relative check only. See "Absolute winding check is calibrated against shipped MLOD" under WINDING DIAGNOSTICS.


## SP-221 — sibling-model frame parity (before copying rotation offsets)

Two `.p3d` of the SAME family can differ 90° in authorship frame. Before copying
rotation offsets from one kit to another, verify frame parity. Compare Memory LOD
(res `1e15`) selections point-to-point between the two models. If the coordinates
correspond by an **axis permutation** — measured here `(x, y, z) → (x, -z, y)` —
the models do NOT share a frame and their config rotation offsets are **not
interchangeable**. The discriminator of "which way it mounts" is the **thin axis
of the Geometry LOD** (`1e13`): that axis must sit perpendicular to the mounting
surface, and pitch comes from there, not from the family or the model name.
**The visual LOD bbox is NOT a signal** — measured counterexample in the same
session: `gate_and.p3d` (Y≈2, Z≈1.16, pitch 90) and `switch_v2.p3d` (Y=1, Z≈2,
pitch 90) have opposite long axes and the SAME pitch. Corollary: "the two kits
are identical except this override" is a hypothesis about CONFIG; measure the
model before acting on it.

Source: 2026-08-10 LFPowerGrid (placement-batch reorientation). `switch_v2.p3d`
vs `switch_v2_remote.p3d`, memory points: `switch_axis` (-0.0407, 0.0288, -0.1472)
→ (-0.0407, 0.1472, 0.0288); `ce_center` (0.0001, 0.0366, -0.1286) → (0.0001,
0.1286, 0.0366); `boundingbox_max` (0.1833, 0.1039, -0.3665) → (0.1833, 0.3665,
0.1039); Geometry LOD 0.293/0.0911/0.4185 → 0.293/0.4185/0.0911. Refutes finding
F1 of `LFPowerGrid_dev\reviews\2026-08-07-placement-audit-grok.md` — applying the
"forgotten" pitch would have PRODUCED the symptom it was meant to cure. MLOD
parser used (memory points + extents per LOD, PowerShell, without py3d) is
reusable. See also `dayz-model-pipeline`.

## Quick Start

```bash
# Single model(s)
python <skill-path>/scripts/audit_p3d.py path/to/model.p3d [more.p3d ...]

# With config.cpp — REQUIRED for the SP-017 wheel-slot wiring check (see below)
python <skill-path>/scripts/audit_p3d.py model.p3d --config path/to/config.cpp

# model.cfg / .rvmat path checks
python <skill-path>/scripts/audit_p3d.py model.p3d --model-cfg path/to/model.cfg
python <skill-path>/scripts/audit_p3d.py model.p3d --rvmat a.rvmat b.rvmat

# Whole-mod scan (recursive; auto-uses the first config.cpp found for SP-017)
python <skill-path>/scripts/audit_p3d.py --scan-dir path/to/mod/
```

Without `--config` (or a `--scan-dir` that finds a config.cpp), the SP-017 check does NOT
run: the audit cannot verify the wheel-slot selection wiring, so a wheeled-vehicle body can
print "ALL PASSED" while its wheels will never simulate. Always pass `--config` when
auditing a vehicle body.

For full mod audit including config and materials, also check sections below manually.

## SP-017 — wheel-slot selection wiring (the silent wheel-sim gate)

The engine resolves the wheel slot→model binding via the selection named by
`config.cpp > CfgSlots > <Slot>.selection`. That selection MUST exist in the body's
**FireGeometry LOD** and contain the wheel-proxy faces. If it exists only in visual LODs,
the slot binds as inventory but the wheel never simulates: `WheelCountPresent()=0` while
`WheelCount()=N`, with NO RPT error — chassis bounces/sinks, wheels mount as items but do
not rotate, RPM climbs while speedo stays ≈0. Fix Y (confirmed in-game on LFQuad,
wheelPresent 0→4): alias the wheel-proxy face ALSO into a selection with the exact name
`CfgSlots.selection` expects — additive, preserving the original selection. Automated:
`check_wheel_slot_firegeo` in `scripts/audit_p3d.py` (runs only with `--config` or a
`--scan-dir` that finds a config.cpp). Source: Bohemia wiki `DayZ:Vehicle_Configuration`.

## Sibling skills

| Input / need | Delegate to |
|---|---|
| ODOL / binarized `.p3d` (audit_p3d.py rejects ODOL — MLOD signature required) | external ODOL→MLOD converter FIRST, then audit the MLOD output |
| Visual inspection / interactive editing of a `.p3d` | `dayz-p3d-inspector` |
| Aligning / orienting proxies | `dayz-proxy-align` |
| Assembling or generating models | `dayz-model-pipeline` |

---

## PART 1: The 13 Silent P3D Killers

These produce ZERO engine errors but break functionality completely. Full body of
each killer (root cause, detection snippet, fix, caveats) →
`references/killers-detail.md`. Index:

1. **Inverted Face Winding** (CRITICAL — most common from Blender) — Geometry LOD
   normals point INWARD; raycasts pass through. `audit_p3d.py` runs a RELATIVE
   winding check vs the Visual LOD. Fix: swap `vertices[1]`/`[2]` per inverted face.
   MANDATORY re-run whenever you generate/edit a collision LOD.
2. **Component Selection Case Sensitivity** (CRITICAL) — Geometry component MUST be
   `Component01` (uppercase C); any variation silently loses ALL collision.
3. **Missing `autocenter=0` LOD Property** (CRITICAL for Inventory_Base) — items with
   `autocenter=0` in config need it ALSO as a named property on every collision LOD,
   else collision is displaced.
4. **Missing Memory LOD or Geometry LOD** (CRITICAL) — no Memory (~1e15) → no
   animation/bounding (maybe crash); no Geometry (~1e13) → zero collision. Canon LODs:
   Geometry 1e13, Memory 1e15, LandContact 2e15, ViewGeo 6e15, FireGeo 7e15.
5. **Missing `pos center` Memory Point** — without it the engine mis-derives bounding
   center for tall/asymmetric objects, breaking the action-targeting pre-filter.
6. **Missing Animation Selections & Axes** — model.cfg anim needs a Visual-LOD selection
   + a 2-point Memory axis. Caveat: on vehicles the axis↔selection binding lives in
   `model.cfg`, so the NAME heuristic false-positives on a valid decoupled rig (LL-027).
7. **Missing `box_placing_min` / `box_placing_max` Memory Points** — hologram placement
   fallback; fires only for items without a proper Geometry LOD / broken `GetCollisionBox()`.
8. **Incomplete Component01 Coverage** — `Component01` must include ALL verts AND faces
   with weight=1, or collision is partial.
9. **Non-Watertight Collision Mesh** — open Geometry mesh (boundary edges/holes) →
   raycasts pass through gaps.
10. **Missing Surface/Material Assignment on Collision LODs** (CRITICAL) — every collision
    face needs a penetration `.rvmat`→`.bisurf` material, else bullets pass / no footstep /
    action cursor may miss. Persists through binarization.
11. **Wheel Proxy `.p3d` Memory LOD has only `ce_center`** (CRITICAL for wheeled vehicles) —
    vanilla ships 5 mem-points (`ce_center`, `ce_radius`, `boundingbox_min/max`, `invview`);
    missing them → `contact=0` every frame, bounce, speedo diverges to ±inf.
12. **Wheel-vertical-placement** (CRITICAL for wheeled vehicles) — tire bottom below model
    origin / chassis floor below wheel-center line → belly too low, `contact=0` at rest.
    Fix raises wheels AND chassis floor together (ride-height triple).
13. **Vehicle Geometry built as ONE monolithic component** (CRITICAL for wheeled vehicles) —
    a working car's Geometry LOD is many closed convex components each with mass +
    `autocenter=0`; a monolith → low inertia. NOTE the confirmed spawn-launch root cause was
    a spurious `#Mass#` on a non-Geometry LOD (see "Vehicle satellite checks"), NOT the
    monolith — keep multi-component as best-practice, diagnose mass first.

## PART 2: Config.cpp Validation

### Baked-in P:\ Drive Paths (CRITICAL)

Absolute paths like `P:\DZ\gear\consumables\data\rag_co.paa` only exist on the
developer's machine. These MUST be converted to game-relative paths:

```cpp
// WRONG — breaks on any other machine:
hiddenSelectionsTextures[] = {"P:\DZ\gear\consumables\data\rag_co.paa"};

// CORRECT — works everywhere:
hiddenSelectionsTextures[] = {"DZ\gear\consumables\data\rag_co.paa"};
```

**Where to check**: `hiddenSelectionsTextures[]`, `hiddenSelectionsMaterials[]`,
and any texture/material path in config.cpp.

**Exception**: Paths starting with `P:\` are valid ONLY during development on a
workbench with P: drive mounted. They must be stripped for distribution/PBO packing.

### Required Properties for Placed Objects

```cpp
class MyPlacedObject: Inventory_Base
{
    autocenter = 0;        // MANDATORY — prevents visual mesh burial
    model = "\ModName\data\model.p3d";  // Backslash prefix = addon root
    // For kits (handheld items): do NOT set autocenter=0
};
```

### AnimationSources Must Match model.cfg

If model.cfg defines animation `flag_mast` with `source = "flag_mast"`, config.cpp
MUST have a matching AnimationSources entry:

```cpp
class AnimationSources
{
    class flag_mast
    {
        source = "user";    // "user" = script-controlled
        animPeriod = 0.5;
        initPhase = 1;      // 0=up, 1=down for vanilla flag convention
    };
};
```

### hiddenSelections Must Match P3D

Every entry in `hiddenSelections[]` MUST have a matching named selection in the
Visual LOD of the P3D. Missing selections silently fail (no texture swap occurs).

---

## PART 3: Material (.rvmat) Validation

### P:\ Drive Paths in .rvmat Files

Same rule as config.cpp — `P:\` paths break on distribution:

```
// WRONG:
texture="P:\dz\gear\camping\data\flag_generic_nohq.paa";

// CORRECT (vanilla reference):
texture="dz\gear\camping\data\flag_generic_nohq.paa";
```

### Required Texture Stages

Standard DayZ .rvmat needs at minimum:
- Stage 0: Diffuse color texture (`_co.paa`)
- Stage 1: Normal map (`_nohq.paa`)
- Stage 2: Specular/detail map (`_smdi.paa`)

Missing stages produce engine warnings but don't crash.

---

## PART 4: Diagnostic Decision Tree

```
1. Can you SEE the object in-game?
   NO  → Check model path in config.cpp (backslash prefix, case)
   YES ↓

2. Is the object buried/floating?
   BURIED → Missing autocenter=0 in config.cpp AND/OR LOD property
   FLOATING → autocenter=0 on a kit class (only placed objects need it)
   CORRECT ↓

3. Does floating text (item name) appear near it?
   NO  → Entity not spawned. Check CreateObjectEx, server logs
   YES ↓

4. Do debug Print() in ActionCondition appear in script log?
   YES → ActionCondition rejecting. Read prints to find which check fails
   NO  ↓ (P3D Geometry LOD issue — engine can't raycast)

5. Run audit_p3d.py and check:
   a. Face winding outward?      → If inward: swap verts[1]/verts[2]
   b. Component01 uppercase C?   → If wrong case: rename
   c. autocenter=0 LOD property? → If missing: add
   d. pos center in Memory?      → If missing: add at (0,0,0)
   e. Component01 covers all?    → If partial: extend selection
   f. Mesh watertight?           → If open: close gaps
   g. Geometry LOD exists?       → If missing: create one

6. Animations not playing?
   → Check flag_mast selection in Visual LOD
   → Check flag_mast_axis (2 points) in Memory LOD
   → Check AnimationSources in config.cpp matches model.cfg

7. Textures missing/white?
   → Check P:\ paths in config.cpp hiddenSelectionsTextures
   → Check P:\ paths in .rvmat files
   → Verify .paa files exist at referenced paths
```

---

## PART 5: Common Blender Export Pitfalls

1. **Z-up → Y-up flips collision winding but not visual** — always verify Geometry LOD
   normals independently from Visual LOD
2. **Blender Geometry LOD may inherit `class=house`** from Object Builder templates
3. **py3d read-write cycles** preserve validity but change file size (~800 bytes per
   property change). This is normal.
4. **Addon Builder binarizes MLOD → ODOL** — mesh defects persist into builds, but the
   winding/cull SIGN does not: on the measured path it inverts in every pair. Audit the
   published ODOL with a predicate calibrated on ODOL, never one ported from the MLOD
   (LL-273, "MLOD to ODOL is a winding-sign boundary", below)
5. **Named selections are case-sensitive** in MLOD format. `Component01` ≠ `component01`
6. **Memory LOD must have zero faces** — only single-vertex points. Faces in Memory LOD
   may confuse the engine.
7. **Animation axis points must be in the SAME named selection** — both points of
   `flag_mast_axis` must be in one selection, not split across two.

---

## PART 6: Script-Side Gotchas

These aren't P3D issues but commonly co-occur during debugging:

- `IsTakeable()` MUST return `true` for `ActionManagerClient` to include the entity
  in the action targeting pipeline. Use `CanPutInCargo()=false` +
  `CanPutIntoHands()=false` + `RemoveAction(ActionTakeItem)` for non-pickup items.
- `SetFullyRaised()` in group creation → flags start at progress=1.0 → only
  `LowerFlag` appears initially, not `RaiseFlag` (checks `< 1.0`).
- SyncVar timing vs RPC cache timing can deadlock ActionConditions.
- `autocenter=0` on FlagKit (handheld) causes it to float when dropped — only set
  on placed objects, not kits.

---



---

## WINDING DIAGNOSTICS — Deep Methodology

Deep winding validation methodology (how NOT to verify — centroid/right-handed heuristics
that false-positive on DayZ left-handed models; Check A winding-vs-averaged-normal, Check B
edge-pair topology, Check C vs-vanilla; minority-group isolation per welded component and
the coupled fix — flip vertex order AND negate the stored normals unless the pipeline
recalculates them afterwards; full-sphere back-dominance battery for inverted faces with NO
topological minority, judging residue in visible pixels, never face counts; known lessons
learned incl. `flip_winding.py`
idempotency and Crate_Wooden mixed winding tolerated in render) →
`references/winding-diagnostics.md`. Complements killer #1.

### MLOD to ODOL is a winding-sign boundary (LL-273)

The measured SUB_BRZ path paired source MLOD and published ODOL triangles by centroid and found opposite orientation in 12,784 of 12,784 pairs. For that Binarize path, a cull/winding predicate calibrated on MLOD must invert its sign when auditing the published ODOL; for a different toolchain or build, recalibrate the boundary instead of assuming the sign.

Qualify the release gate with a known MLOD/ODOL pair and prove it separates a healthy build from an intentionally broken one. An identical zero count across materially different inputs is evidence of an instrument failure, not a clean artifact — measured `0 of 2367` on three distinct builds; with the sign inverted, ODOL and MLOD agreed cell by cell (637 → 0 and 636 → 0). Verify the published ODOL, but never port the MLOD predicate by memory.

### Absolute winding check is calibrated against shipped MLOD (added 2026-09-07)

py3d fork 1.7.0 implements the absolute signal: `_pct_normal_agreement(lod)` = % of faces with `dot(cross(v1−v0, v2−v0), declared_normal_v0) > 0`, both vectors in raw MLOD space; `_check_winding_absolute` raises `ERR_WINDING_VS_NORMALS` (CRITICAL) near 0 % and `WARN_WINDING_NORMAL_MISMATCH` when mixed. Calibration measured 2026-09-07 with that same function on the visual LOD:

| MLOD | Status | Agreement |
|---|---|---|
| `P:\LFPowerGrid\data\solarpanel\lf_solarpanel.p3d` (2,944 faces) | in production, seen in game | 100.0 % |
| `P:\LFPowerGrid\data\kits\lf_kit_box.p3d` (12 faces) | in production | 100.0 % |
| SUB_BRZ co-driver door MLOD (double-sided glass twins with negated normals) | verified in game | 26 % |
| Two third-party Blender exports (LFSecure door and room, 11 and 12 LODs) | untested | 0 % in every visual and collision LOD |

Reading rules that follow from the table:

1. Healthy single-sided MLOD reads ≈ 100 %, so **0 % across every LOD is a real global inversion** (the Z-up → Y-up handedness signature), not a convention of the format. The relative check (item 1 of "The three py3d gates") cannot see a global inversion; this one can.
2. A **mixed** percentage on a double-sided model is the twins voting, not an inversion: isolate the minority group per welded component (`references/winding-diagnostics.md`) instead of flipping everything.
3. Measure in **raw MLOD coordinates**. A frame that already flips Z (an OBJ export, `parse_obj` helpers) inverts the sign; one session concluded "healthy = cross opposite normal" from such a frame and doubted a true CRITICAL for a round.
4. Fix with `face.vertices.reverse()` on every face of every LOD (visual, shadow, Geometry/ViewGeo/FireGeo), never by swapping `vertices[1]`/`[2]` (a quad becomes a crossed face); keep the stored normals; re-run the audit expecting ≈ 100 % and no `WINDING_MIXED`; then confirm in the engine (outside render + raycast) before building on top.

---

## Vehicle satellite checks (mass / wheel clearance / crew / vertex ceiling)

Vehicle-specific extensions of the killers, full detail →
`references/vehicle-killers.md`:

- **`#Mass#` must live only in the Geometry LOD** (2026-06-02) — a stray `#Mass#` tagg on a
  non-Geometry LOD makes binarize bake THAT LOD's mass → `CoM=(0,0,0)`, spawn below ground,
  PhysX ejection. MANUAL check (not automated in `audit_p3d.py`); set `point.mass = None`
  (not `0.0`) on non-Geometry LODs. This is the confirmed spawn-launch root cause behind
  killer #13.
- **Wheel-well clearance vs wheel RADIUS, not HUB** (SP-024) — measure chassis-to-wheel-center
  vs the effective wheel radius (cylinder), not the small hub box; `< radius` → PhysX
  self-penetration ejection.
- **Crew check (get-in / co-driver)** (2026-06-05) — Check A: `seat_driver`/`seat_codriver`
  must each live in their own ViewGeo component; Check B: crew proxies must be canonical
  (not 90/45/45 isosceles) or the player sits sideways.
- **Vertex-ceiling flag counts face-indices, not resolved vertices** — FALSE POSITIVE; the
  DX9 16-bit ceiling is on resolved unique vertices (point×normal×uv), not `faces×3`.
  Patched 2026-07-06 (`check_lod0_vertex_budget`).

---

## SP-051 — UV audit step (added 2026-07-06)

UV audit (beyond out-of-range): run
`<dayz-projects>\LFQuad_dev\tools\uv_audit.py`
(verified present 2026-07-06) — checks: zero-uv%, NaN, bounds vs ODOL int16 quantization
(range ≤ ~32 on 2048 tex — min/max over the WHOLE LOD), degenerate faces, mirrored islands
(signed UV area — breaks _nohq), Monte-Carlo overlap per group and cross-group, island count
(union-find) + texel density (px/m; healthy reference ≈ 292 px/m LFQuad wheels; 27 px/m =
measured cause of bake artifacts). Gotchas: whitelist `proxy:*` faces (degenerate by design),
exclude full-frame tris (UV area > 0.2) from cross-group overlap, classic raster misses
subpixel islands. Full symptom→fix catalog:
`<vault>\AI\20_Knowledge\uv-mapping-dayz.md`.


---

## Resolution LOD count -- performance check (added 2026-07-14)

WARN (performance, not a correctness killer): flag any model that ships with only ONE
resolution LOD. With a single LOD the engine loads the full mesh at any distance (no
distance-based decimation), inflating client/server load; it is a documented cause of
stutter and random disconnects when many such models are near the player. Vanilla
reference: the plate carrier ships ~5 resolution LODs.

- Heuristic: `resolution_LOD_count >= 2` for any non-trivial visible model; a genuinely
  low-poly prop (a few hundred faces) may legitimately keep one.
- Manual for now (not automated in `audit_p3d.py`): open in Object Builder / py3d and count
  Resolution LODs; or compare face counts across LODs (identical counts = the ladder is a
  copy, not a decimation -- see dayz-model-pipeline `lods-and-geometry.md`).
- Fix: author decimated LODs (user-gated per the dayz-model-pipeline decimation gate).

Source: community report (YouTube Oqz8-FNQypI, 2026) + BI LOD wiki recommendation of at
least one resolution LOD. Related BI cap: 30+ total LODs can crash the binarizer.

## Reglas promovidas del corpus de lecciones (added 2026-07-27)

Promovidas desde `AI/20_Knowledge/lessons-learned.md` para que lleguen por trigger en vez
de depender de que alguien recuerde buscarlas. Cada regla cita su `LL-NNN` de origen;
la entrada completa vive allí. No quites la cita: el índice detecta la promoción por ella.

- **LL-068** — Audita por separado descomposición convexa y reparto de masa: reagrupar los mismos puntos no cambia CoM ni inercia. No conviertas CoM/Izz en gates duros si proceden de masas uniformes reconstruidas o son geométricamente inalcanzables.
- **LL-092** — Construye el crew proxy como triángulo escaleno con frame inequívoco y calibra +Y/+Z contra el submodelo referenciado. Coloca el vértice ancla a la altura real del asiento; no copies vértices de otro tipo de proxy.
- **LL-364** — Antes de citar «vanilla vale N» para un campo, pregunta POR QUÉ CANAL llegó ese N. Si el material se distribuye en formato derivado (ODOL) y el número lo produjo un conversor, la pregunta correcta no es cuánto vale sino si el campo sobrevive a la conversión: `odol_to_mlod.py` escribe `face.flags = 0` a pelo, así que cualquier «vanilla usa flags=N» medido por ahí es el instrumento contestándose. Medido 2026-08-24 por round-trip: `binarize.exe` DESCARTA el campo `flags` por cara del MLOD — tres MLOD que difieren solo en él binarizan a un modelo byte a byte idéntico, mientras dos controles positivos (un punto movido 1 cm, la textura vaciada en esas mismas caras) sí cambian la salida. Y un campo vecino del formato derivado que lleva el mismo bit (`Section.special`, por sección) NO es el mismo campo.


## Vehicle get-in action contract gate (SP-086, added 2026-08-31)

For every vehicle that declares `class Crew`, run a `contract_gate` over the complete action
graph rather than checking each file independently:

- identify the Memory LOD by resolution near `1e15` and require `actionSel`, `proxyPos`,
  `getInPos`, and `getInDir` endpoints to resolve in the `.p3d`;
- close those names through model.cfg `sections[]` and `SkeletonBones[]`, then through the
  matching config.cpp `class Crew` fields;
- require each get-in position/direction pair to be within 1 m of its intended proxy;
- close config.cpp `AnimationSources` to model.cfg `Animations`.

Report every broken edge with both endpoint files and names. Keep `actionSel in sections[]`
as a calibration warning, not a hard failure, until it has passed a representative vanilla
vehicle control. A project-local gate passed one known-good artifact and rejected one known
bad artifact, but that single pair does not settle the membership rule.

## Resolve every face index (SP-142, added 2026-08-31)

Counts, selection names, proxy centroids, and material pairs live in a different space from
face indices. A freshly assembled or surgically edited `.p3d` must have a gate that
dereferences every face corner's `point_index` and `normal_index` against the live pools.
Fail on the first out-of-range value and name the LOD, face, corner, index, and pool length.
Do not treat matching cardinalities as an equivalent check.

Run `binarize` as the authoritative serialization check, with the previous known-good model
as a control in the same run. This separates a product failure from a broken test bench and
makes pre-existing log noise comparable. A binarize pass proves that this index/serialization
gate passed; it still does not replace the in-game spawn gate in SP-216.

## SP-359 — The audit does not check the resolved-vertex budget

A `.p3d` can pass this entire audit with 0 CRITICAL and still be rejected by the engine when it
loads the MLOD with `Too many vertices`, which surfaces in-game as
`PHYSICS (E): Won't simulate, it has no geometry` even when the Geometry LOD is perfect.

Add to the audit: a count of `(point, normal, uv)` triples per LOD, and a warning past 90% of the
measured ceiling for that model.

The ceiling is per-model, not a constant — `RESOLVED_LIMIT = 65535` is a false friend. Measured
cliff on the HH-60G: 46.133 triples load, 46.134 do not (25 in-game verdicts, zero false).

The authoritative gate is `binarize.exe`, not this count, and it has three states: PASS /
CAPACITY_FAIL / OTHER_FAIL. CAPACITY_FAIL means do not go in-game to "fix" Geometry; OTHER_FAIL
means do not touch geometry at all. Evidence and the measured cliff: SP-122 in
`dayz-vehicles/SKILL.md` and `dayz-vehicles/references/binarize-vertex-budget.md`.
