---
description: "Deep audit of the M30/M47 scripting domain — .pex decompiler (Champollion port), .psc Papyrus parser, AST→ECS recognizer chain, ECS scripting runtime, and the cell-loader attach path"
argument-hint: "--focus <dimensions> --depth shallow|deep"
---

# Scripting Subsystem Audit (M30 / M47.0 / M47.1 / M47.2)

Audit the three scripting crates plus their engine-side wiring for
correctness across the full compiled-Papyrus pipeline: untrusted `.pex`
bytecode decode (`crates/pex/`), the 5-phase decompiler that lifts that
bytecode back to the shared Papyrus AST, the `.psc` source parser
(`crates/papyrus/`), the AST→ECS recognizer chain whose load-bearing
invariant is *decline-on-any-unmodeled-term* (`crates/scripting/src/translate/`),
the ECS scripting runtime (events / timers / conditions / triggers / quest
stages), and the cell-loader REFR-attach path that resolves a scripted REFR's
VMAD-named `.pex` and runs the recognizer chain.

This domain (~16k LOC) has six prior audit passes in `docs/audits/AUDIT_SCRIPTING_*.md`
— read the most recent one first (Phase 1 below). The
decompiler is the **highest bug-density area**: it parses untrusted bytecode
and runs five tree-rewriting passes, so dimensions are weighted toward it
(three of seven). Its correctness story rests on a corpus-decompile
smoke harness and the `.psc`-vs-`.pex` fidelity gate — point findings there,
not at speculation.

**Architecture**: Orchestrator. Each dimension runs as a Task agent (max 3 concurrent).

See `.claude/commands/_audit-common.md` for project layout, methodology,
deduplication, context rules, and finding format. See
`.claude/commands/_audit-severity.md` for the severity scale. Do NOT duplicate
those here.

## Scope

**Crates** (22-crate sanity check in `_audit-common.md`; `pex` is the newest owned by this audit):
- `crates/pex/src/` — `.pex` reader + 5-phase decompiler. Files: `crates/pex/src/opcode.rs`,
  `crates/pex/src/reader.rs`, `crates/pex/src/model.rs`, `crates/pex/src/lib.rs`,
  and `crates/pex/src/decompile/` (`mod`, `cfg`, `lift`, `control_flow`, `boolean`,
  `lower`, `node`, `event_names`).
- `crates/papyrus/src/` — `.psc` lexer (logos) + Pratt parser → AST. Files:
  `crates/papyrus/src/token.rs`, `crates/papyrus/src/lexer.rs`, `crates/papyrus/src/ast.rs`,
  `crates/papyrus/src/span.rs`, `crates/papyrus/src/error.rs`, `crates/papyrus/src/lib.rs`,
  and `crates/papyrus/src/parser/` (`mod`, `expr`, `stmt`, `script`).
- `crates/scripting/src/` — ECS-native runtime + recognizer chain. Runtime:
  `crates/scripting/src/events.rs`, `crates/scripting/src/timer.rs`,
  `crates/scripting/src/cleanup.rs`, `crates/scripting/src/condition.rs`,
  `crates/scripting/src/trigger.rs`, `crates/scripting/src/quest_stages.rs`,
  `crates/scripting/src/fragment.rs`, `crates/scripting/src/recurring_update.rs`,
  `crates/scripting/src/registry.rs`, `crates/scripting/src/lib.rs`. Recognizer
  chain: `crates/scripting/src/translate/` (`mod`, `source`, `archetype`, `compose`,
  `effects`, `tables`, `recognizers/{mod, quest_stage_gate, rumble,
  two_state_activator}` — `two_state_activator` landed after this file's last
  refresh; it recognizes `default2StateActivator` the same per-script way
  `rumble` does). Reference scripts: `crates/scripting/src/papyrus_demo/`.

**Engine-side wiring** (Dimension 7 — outside the crates):
- `byroredux/src/cell_loader/references/attach.rs` (split out of `mod.rs`,
  #1877; `mod.rs` re-exports them and keeps their call sites) —
  `attach_vmad_scripts` / `attach_script_for_refr` call
  `byroredux_scripting::translate_pex`; the `trigger_volume_from_primitive`
  builder spawns invisible `TriggerVolume` REFRs from `XPRM` primitives.
- `crates/plugin/src/esm/records/index.rs` — `base_record_script_instance`
  accessor (VMAD retained on ACTI/CONT/NPC/CREA base records, plus the item
  family per #2189 — see Dim 7).
- `crates/plugin/src/esm/records/script_instance.rs` — `ScriptInstanceData` /
  `ScriptInstance` (decoded VMAD).
- `byroredux/src/asset_provider/script.rs` — `build_script_provider` parses the
  repeatable `--scripts-bsa` flag; `extract_pex` resolves a VMAD script name
  to `.pex` bytes.

**Ground truth — read these before auditing**:
- `docs/engine/scripting.md` — the 50KB authoritative model (ECS-native VM
  replacement, recognizer-chain design, 136-event ECS mapping).
- `docs/engine/papyrus-parser.md` — M30 `.psc` parser + AST.
- `docs/engine/m47-0-design.md` — event-hooks runtime (the attach chain M47.2 extends).
- `docs/engine/m47-2-design.md` — the `.pex` decompiler + recognizer-chain spec,
  the `.psc`-vs-`.pex` fidelity gate, "no opcode semantics guessed" rule.
- `docs/engine/m47-2-recognizer-scaling.md` — corpus characterization
  (26,641 `.pex`; handler vs fragment populations; decline-the-tail thesis);
  its "Shipped (2026-07-21)" section documents the `AddItem`/`MoveTo` object-
  targeting effects and the real-corpus ~0%-yield finding — read before
  flagging anything about those two effects.
- `docs/engine/m47-3-quest-alias-design.md` — QUST alias (`ALST`/`ALLS`)
  decode + the future alias-fill runtime. Out of this skill's crate scope,
  but explains the *current* alias-bound `Property`-resolution decline (see
  Future-phase gaps) — read before flagging that decline as a bug.
- The crate module docstrings: `crates/pex/src/lib.rs`,
  `crates/pex/src/decompile/mod.rs`, `crates/scripting/src/translate/mod.rs`,
  `crates/scripting/src/fragment.rs`.

**Doc-rot check**: `docs/feature-matrix.md:139` was already corrected (independent
of this skill) to reflect the shipped `.pex` recognizer slice; only line ~175
("What Doesn't Work Yet") still lists the *full* transpiler as deferred, which
remains accurate (the recognizer chain is a targeted slice, not a general
transpiler). Do not re-flag that "shipped recognizer slice" framing on line 139
as stale. **Do** flag its embedded phase-order parenthetical, though —
`"CFG→lift→control-flow→lower→short-circuit"` (booleans LAST) contradicts the
actual pipeline (Dim 3: the short-circuit boolean pass runs BEFORE control-flow
reconstruction, confirmed in `decompile_body`/`lower.rs`) — that parenthetical
was apparently never updated when the phase order was pinned down, independent
of the "shipped slice" fix around it.

**Corpus / fidelity instruments (point findings here, do not re-derive)**:
- `crates/pex/examples/pex_corpus_smoke.rs` — runs `byroredux_pex::parse` +
  `decompile::decompile_script` over every `.pex` in real game archives; the
  **source of the 99.996% (26640/26641) zero-panic decompile claim**. Verify the
  claim by re-reading the harness's success/failure tally logic — confirm it
  actually counts a decompile *panic* / `Err` as a failure (a harness that
  swallows panics would inflate the rate).
- `crates/pex/examples/pex_corpus_shapes.rs` + `docs/r5/corpus-shape-survey.txt` —
  the structural-fingerprint coverage instrument behind the recognizer-scaling doc.
- `crates/bsa/examples/r5_extract_pex_ba2.rs` — the `.pex` corpus extractor.
- `docs/smoke-tests/m47-triggers.sh` — engine-side spawn+attach gate on real
  Skyrim data (`--scripts-bsa`, the `M47.2 scripts:` cell-load summary line).

**Future-phase gaps (do NOT flag as missing unless scope says so)**:
- Obscript / `SCTX` frontend (Oblivion/FO3/FNV) — `ScriptSource::Obscript` is a
  typed placeholder; the SCTX parser is M47.2 Phase 5, not built.
- M47.1 condition resolvers (`GetActorValue`/`GetDistance`/`GetFactionRank`/`GetIsID`/
  `HasPerk`, the Global comparand, and the 6 stub branches from #1316) are
  **no longer stubs** — all 13 catalog functions are fully implemented with
  correct Bethesda safe-default sentinels (#1663–#1668, #1316, all closed
  2026-06-29→07-04; re-verified `AUDIT_SCRIPTING_2026-07-16.md` Dimension 6,
  27 passing unit tests). Re-verification against a live headless cell with
  real CTDA data (not just unit tests) remains outstanding — a gap, not a stub.
- The fragment lowerer (b2) is **no longer a "may be partial" gap — it is
  fully wired and live-data-verified** (2026-07-21). `populate_quest_fragments`
  (`byroredux/src/asset_provider/script.rs`) resolves each scripted quest's
  `QF_` `.pex` from `--scripts-bsa`, decompiles, and registers into
  `QuestStageFragments` at cell load; `quest_fragment_dispatch_system`
  consumes `QuestStageAdvanced` and applies. Verified against real
  `Skyrim.esm`: 845 scripted quests → 5,108 stage bindings → 742 fragments
  fully lowered and registered. Do not re-flag this as unwired.
- **QUST `VMAD` property-table wiring (2026-07-21, same-session fix)**:
  `QuestRef::Property`/the new `ObjectRef::Property` (see Dim 5) used to
  always decline at dispatch because `quest_fragment_dispatch_system` passed
  `vmad: None` unconditionally — `parse_quest_fragments` decoded the QUST's
  own VMAD scripts-section internally (to find the fragment-section offset)
  and then discarded it. Fixed: `QustRecord.script_instance` now retains it,
  `QuestStageFragments::insert_vmad`/`vmad()` store it per-quest, and
  `resolve_quest`/`resolve_object` (Dim 6) receive the real VMAD. Verified
  live: 969 Skyrim quests now have a resolvable property table. A
  `Property`-targeted effect that still declines with a *populated* VMAD on
  hand is a real regression; declining with no VMAD registered (no
  `--scripts-bsa`, or the quest's VMAD carried no scripts section) is correct.
- **Object-targeting fragment effects (`AddItem`/`MoveTo`, 2026-07-21)** — see
  Dim 5/6 for the mechanism. Real, tested, dispatch-wired — but *live-corpus
  measured at ~0% real yield* today (`fragment_coverage` found zero hits in
  both `Skyrim - Misc.bsa` and `Fallout4 - Misc.ba2`): real content
  overwhelmingly binds the object receiver via an alias accessor
  (`ObjectReference k = SomeAlias.GetActorRef()`) rather than a bare
  `ObjectReference Property`, and `bind_local` already declines that whole
  fragment regardless (a side-effecting call, #1907's discipline) — not a
  bug in the new effects, a real dormant-until-alias-resolution state.
  Documented in `docs/engine/m47-2-recognizer-scaling.md` §"Shipped
  (2026-07-21)". Do not flag the low yield as a defect; do flag it if the
  *mechanism itself* (lowering or dispatch-time resolution) regresses.
- **QUST alias decode (M47.3 Phase 0, 2026-07-21, `crates/plugin`)** —
  `QustRecord.aliases: Vec<QuestAlias>` (`ALST`/`ALLS`/fill-types/`FNAM`/
  injected data/`ALFI` "Force Into Alias") is now decoded, live-verified
  against `Skyrim.esm`/`Fallout4.esm` (`crates/plugin/examples/
  qust_alias_survey.rs` + `qust_alias_rawdump.rs`). This is **pure parser
  data with zero fill-and-apply runtime** — out of this skill's crate scope
  (`crates/plugin`, not `pex`/`papyrus`/`scripting`), but directly explains
  why `QuestRef::Property`/`ObjectRef::Property` still decline on an
  *alias-bound* VMAD entry (`alias != -1` in `PropertyValue::Object`) even
  after the VMAD-wiring fix above — that's M47.3 Phase 2, not built. See
  `docs/engine/m47-3-quest-alias-design.md`. Do not flag the alias-bound
  decline as a bug, and do not expand this skill's dimensions to cover
  `quest.rs`'s alias parser — it belongs to a future ESM/quest-focused audit.

## Parameters (from $ARGUMENTS)

- `--focus <dimensions>`: Comma-separated dimension numbers (e.g., `1,2,3`). Default: all 7.
- `--depth shallow|deep`: `shallow` = check API contracts + the decline/bounds
  invariants; `deep` = trace each decompiler pass's tree rewrite + the per-frame
  ECS lifecycle. Default: `deep`.

## Extra Per-Finding Fields

- **Dimension**: PEX Reader & Opcode Decode | Decompiler CFG & Lift | Decompiler Control-Flow / Boolean / Lower | Papyrus Lexer & Pratt Parser | Recognizer-Chain Soundness | Scripting Runtime Systems | Engine Attach & Trigger Wiring
- **Untrusted-Input**: Yes | No (set Yes for any finding on a path that consumes raw `.pex` / `.psc` bytes — these escalate by the special rules below)

## Severity Notes for This Domain

Apply `_audit-severity.md` as written. Domain-specific escalations:

| Condition | Minimum Severity |
|-----------|-----------------|
| Panic / OOB index / unbounded alloc reachable from untrusted `.pex` or `.psc` bytes | HIGH (CRITICAL if it's memory-unsafe — see the `transmute` in `crates/pex/src/opcode.rs`) |
| Decompiler emits a **wrong** AST that a recognizer then matches (false-positive lowering → wrong ECS behavior on vanilla content) | HIGH (silent, all-game blast radius; same class as a wrong NIFAL `Material`) |
| Recognizer emits a component on an **unmodeled** condition/term instead of declining | HIGH (the load-bearing invariant; a quest advancing on the wrong predicate is silent game-logic corruption) |
| Copy-propagation / boolean-collapse soundness bug (folds a temp into the wrong consumer, mis-attributes an `&&`/`||` operand) | HIGH (corrupts the AST the recognizer reads) |
| Stack overflow via unbounded recursion in the parser or a decompiler tree walk | HIGH |
| ECS lock held across a second resource/component mutation (deadlock vector) | HIGH |
| Transient marker not drained / drained out of stage order (re-fires every frame, or fires a frame late) | HIGH |
| `feature-matrix.md` doc-rot, stale comments | LOW |

The decline-on-unmodeled invariant is the scripting analogue of NIFAL's
single-boundary rule: **a partial / approximate lowering is worse than no
lowering**, because an inert unrecognized script is safe but a wrongly-lowered
one corrupts game state with no fallback to mask it.

## Phase 1: Setup

1. Parse `$ARGUMENTS` for `--focus`, `--depth`.
2. `mkdir -p /tmp/audit/scripting`
3. Fetch dedup baseline: `gh issue list --repo matiaszanolli/ByroRedux --limit 300 --json number,title,state,labels > /tmp/audit/scripting/issues.json`
4. **Read the most recent `docs/audits/AUDIT_SCRIPTING_*.md` report** (sort by
   date — do not hardcode a filename here, it rots every cycle). Diff direction
   against it rather than re-litigating settled findings. In particular, the
   M47.1 condition-resolver stubs (#1663–#1668, #1316) that earlier reports
   tracked as open are now CLOSED and fully implemented — verify against the
   live `crates/scripting/` code before flagging any condition-resolver gap,
   don't assume the stub-era finding still applies.
5. Read the three crate module docstrings + `docs/engine/m47-2-design.md` §"Frontends
   in detail" and §"Risks & mitigations" to confirm what is *designed* to decline /
   defer vs. what is a real defect, before reporting any "missing handling" finding.

## Phase 2: Launch Dimension Agents

Ordered by bug risk: untrusted decode + the five decompiler passes first
(Dims 1–3), then the source parser, the recognizer invariant, the runtime
lifecycle, and the engine wiring.

### Dimension 1: `.pex` Reader & Opcode Decode (untrusted input)
**Entry points**: `crates/pex/src/reader.rs` (`Reader`, `read_binary`, `read_header`,
`read_string_table`, `read_debug_info`, `skip_property_groups`, `skip_struct_orders`,
`read_objects`, `read_struct_infos`, `read_variables`, `read_guards`, `read_properties`,
`read_states`, `read_named_functions`, `read_function`, `read_typed_names`,
`read_instructions`, `value`, `string`, `string_index`, `take`); `crates/pex/src/opcode.rs`
(`OpCode`, `from_u8`, `MAX_OPCODE`, the `OPCODES` table); `crates/pex/src/model.rs`
(`Pex`, `Object`, `Function`, `Instruction`, `Value`, `ScriptType`); `crates/pex/src/lib.rs`
(`parse`, `PexError`).
**Checklist**:
- **`take(n)` is the single bounds gate.** Every primitive read funnels through
  `take` (`checked_add` + `<= data.len()` filter → `UnexpectedEof`). Verify NO
  read path bypasses it (a direct `self.data[...]` slice, a `try_into().unwrap()`
  on a short slice). Untrusted-Input: every finding here is Yes.
- **The `OpCode::from_u8` `transmute`.** `crates/pex/src/opcode.rs` does
  `unsafe { transmute::<u8, OpCode>(byte) }` guarded by `byte >= MAX_OPCODE → None`.
  This is memory-safety-critical: confirm (a) `MAX_OPCODE == 51` exactly matches
  the last discriminant (`TryLockGuards = 50`), (b) the enum is `#[repr(u8)]` with
  *contiguous* discriminants 0..=50 (a gap would make a valid-range byte transmute
  to an invalid variant = UB), (c) the guard is `>=` not `>`. The
  `discriminants_match_on_disk_order` + `from_u8_round_trips_and_rejects_oob` tests
  pin this — verify they actually cover every discriminant, not just spot values.
- **`arg_count` drives operand consumption.** `read_instructions` reads exactly
  `op.arg_count()` fixed operands + (if `has_varargs`) a `Value::Integer(n >= 0)`
  count then `n` operands. The `OPCODES` table is the contract. Cross-check every
  row against the UESP Papyrus Assembly spec / Champollion `OPCODES` (the file
  claims a verbatim port) — a wrong arg count desyncs the entire instruction
  stream silently (subsequent opcodes read garbage operands). Spot-check the
  var-arg opcodes (`callmethod`/`callparent`/`callstatic`/`lock_guards`/
  `unlock_guards`/`try_lock_guards`) and the high-arity ones
  (`array_findstruct` = 5, `array_getallmatchingstructs` = 6).
- **`BadVarArgCount`**: a negative or non-integer var-arg count is rejected.
  The var-arg element count `n` (`Value::Integer`, attacker-controlled up to
  `i32::MAX`) does **not** feed a `Vec::with_capacity(n)` — `read_instructions`
  grows the `var_args` vec geometrically via a plain `Vec::new()` + per-element
  `push` loop instead (#1710/SCR-D1-01), specifically because a
  `with_capacity(n)` there would pre-allocate tens of GB before the first
  out-of-range element read could hit `take`'s EOF guard. Verify this is still
  true — a "cleanup" that reintroduces `Vec::with_capacity(n as usize)` here
  reopens #1710. Regression guard: `hostile_vararg_count_errors_instead_of_ooming`
  (`crates/pex/src/reader.rs`). Every OTHER `with_capacity(count)` in the reader
  (`read_string_table`, `read_objects`, `read_instructions`'s own
  instruction-count vec, `read_typed_names`, `read_struct_infos`, …) is fed by a
  `u16` count capped at 65535 — benign. The `u32` `user_flags` / object-size
  fields are plain data (bitflags / a size hint), never used as a `Vec`
  capacity — not a hazard.
- **`string_index` range check**: a `u16` index is `.get(idx).cloned()` →
  `BadStringIndex` on miss. Verify NO field reads a raw `u16` and indexes
  `self.strings[idx]` directly (panic on OOB).
- **`value()` type tag**: only 0..=5 accepted (`BadValueType` otherwise). Confirm
  the six arms match `ValueType` and that `Value::Integer(self.u32()? as i32)`
  sign-reinterprets (not truncates) — Papyrus ints are signed.
- **Endianness / dialect detection**: magic LE (`0xFA57C0DE`) vs BE
  (`0xDEC057FA`) sets `endian`; `script_type` derives from endian + `game_id`
  (4→Starfield, 3→FO76, else FO4; BE→Skyrim). `u32_opt(true)` reads the magic LE
  before endian is known — verify every *other* multi-byte read honors `self.endian`
  and that the provisional `Endian::Little` in `new()` can't leak into a read
  before `read_header` sets it.
- **Skyrim-vs-FO4+ field gating**: `is_skyrim()` skips `const_flag`, `struct_infos`,
  property-group / struct-order debug tables; Starfield-only `guards`. A misgated
  field shifts the whole stream. Verify `read_objects` reads fields in the exact
  FileReader order and that `skip_property_groups`/`skip_struct_orders` consume the
  same bytes the (FO4+) writer emits (the doc says "consume-and-discard to stay
  aligned" — a wrong skip count corrupts every following object).
- **No partial `Pex` escapes**: `lib.rs` claims the reader "never returns a
  half-built `Pex`". Confirm `read_binary` is all-or-`Err` (no `Ok` with a
  truncated `objects` Vec on a mid-object EOF).
- Regression guards: `parses_a_handbuilt_fo4_pex`, `parses_a_handbuilt_skyrim_be_pex`,
  `parses_a_handbuilt_starfield_pex_with_guards`, `rejects_bad_magic`,
  `rejects_truncation` (`crates/pex/src/lib.rs`); `metadata_matches_champollion`
  (`crates/pex/src/opcode.rs`). FO4/LE, Skyrim/BE, and Starfield-guards dialects
  all round-trip via `PexWriter::new_be()` + the two new tests (#1728) — the
  prior MEDIUM coverage gap (hand-built writer only exercising FO4/LE) is closed;
  a future writer regression that drops the BE or guards path re-opens it.
**Output**: `/tmp/audit/scripting/dim_1.md`

### Dimension 2: Decompiler — CFG Construction & Opcode→Node Lift (highest bug density)
**Entry points**: `crates/pex/src/decompile/cfg.rs` (`build_cfg`, `CodeBlock`,
`Cfg`, `split`, `split_block`, `find_block_for_instruction`, `checked_target`,
`condition_name`, `END`); `crates/pex/src/decompile/lift.rs` (`lift_function`,
`create_node`, `check_assign`, `rebuild_expression`, `count_constant_id`,
`replace_constant_id`, `build_var_types`); `crates/pex/src/decompile/node.rs`
(`Node`, `NodeKind`, `is_final`, `is_temp_var`, `child_nodes`, `child_nodes_mut`,
`SYNTH_IP`).
**Checklist**:
- **Jump-target bounds**: `checked_target` validates `0 <= ip+offset <= count`
  (inclusive — the exit anchor is one past last). `build_cfg` errors on a
  non-integer offset (`BadJumpOffset`) or OOB (`JumpOutOfRange`). Verify the
  inclusive bound is correct (a jump to `count` lands on the synthetic exit block,
  not OOB) and that `condition_name` rejects non-{ident,bool,int} conditions
  (`BadJumpCondition`). Untrusted-Input: Yes.
- **Block-split arithmetic**: `CodeBlock::split(at)` truncates to `[begin, at-1]`
  and emits tail `[at, end]`. `at` is always `ip+1` or a jump target ≥ 1, so
  `at-1` can't underflow — confirm `split` is never called with `at == 0` (the
  initial full block starts at 0 and the exit anchor pre-exists, so `ip+1` for the
  final instruction maps to the anchor without a split). A `split(0)` is an
  underflow panic.
- **`jmpf` vs `jmpt` edge polarity**: `jmpf` jumps when FALSE, so true-edge =
  fall-through (`ip+1`), false-edge = target; `jmpt` is mirrored. This is the
  load-bearing CFG semantic — a flipped polarity inverts every `If`. The
  `forward_jmpf_builds_an_if_diamond` + `backward_jmpt_builds_a_loop_edge` tests
  pin it; verify the `(on_true, on_false)` tuple in `build_cfg` matches.
- **Copy-propagation soundness (`rebuild_expression`)**: a non-final
  (temp-producing) node is folded into the *single* following live statement
  that consumes its result via `count_constant_id`; **0 → advance, 1 → inline
  and resume at the live predecessor of the fold target, >1 →
  `ExpressionRebuildFailed`**. This is the AST-correctness core. Post-#2024
  the pass runs over an explicit doubly-linked live-index chain, NOT a
  restart-at-`i=0` rescan of the whole `Vec` — the old restart-at-0 behavior
  (Champollion's `it = scope->begin()`) was itself the O(n²) bug #2024 fixed
  (confirmed by a 20k/40k/80k-fold-pair benchmark); a "cleanup" that
  reintroduces it, or reintroduces `Vec::remove`'s O(n) shift, reopens #2024.
  Regression guard: `rebuild_expression_is_linear_up_to_the_wire_format_ceiling`
  (`crates/pex/src/decompile/lift.rs`). Verify: (a) the count is over the
  *immediately next live* statement only (the `next[i]` link — Champollion's
  single-consumer model); folding into a non-adjacent consumer would reorder
  side effects; (b) `is_final` / `is_temp_var`
  asymmetry is intact (`is_final` treats any `::temp` prefix as non-final incl.
  `_var`-suffixed; `is_temp_var` excludes `_var`) — the file documents this as a
  deliberate Champollion port, so a "cleanup" that unifies them is a regression;
  (c) the `replace_constant_id` `slot.take()` substitutes exactly once (the
  `debug_assert!(slot.is_none())` only fires in debug — a release build with a
  >1-match that slipped past `count_constant_id` would silently drop the producer).
- **`create_node` opcode→node map**: each opcode maps to a `NodeKind`. Spot-check
  the precedence values passed (they're cosmetic for AST lowering but the file
  carries them); the **`Cast` heuristic** (lift.rs): a cast is downgraded to a
  `Copy` when source is `None`, or when both sides are same-typed identifiers
  (or src is `::nonevar`). Verify the same-type test uses `type_of` on *both* and
  the `::nonevar` case-insensitive exception — a wrong downgrade turns a real
  type-narrowing cast into an identity copy (recognizer reads the wrong type).
- **`CallStatic`/`CallMethod`/`CallParent` operand order**: result, object, method
  name are pulled from specific arg indices (`id(2)`/`val(1)`/`id(0)` for
  CallMethod; `id(2)`/`val(0)`/`id(1)` for CallStatic — result/object/method in
  that order, matching the CallMethod convention; note the actual `Node::call_method`
  call site writes them `val(0), id(1)` positionally, i.e. object-then-method, so
  don't misread the source's left-to-right argument order as the result/object/
  method order this bullet uses). A swapped index mis-names the called function —
  fatal for a recognizer that keys on the method name (`SetStage`, `GetStageDone`).
  Cross-check against the UESP opcode operand order.
- **`id_of` on a literal**: operands that must be identifiers (`id(n)`) error with
  `ExpectedIdentifier` on a literal. Verify the lift never `unwrap()`s
  `as_identifier()` outside a checked branch (the Cast arm does
  `src.as_identifier().unwrap()` — confirm it's guarded by the preceding
  `matches!(src, Value::Identifier(_))` short-circuit).
- **Bodyless / native functions**: `build_cfg` returns `entry == END` for zero
  instructions; `lift_function` yields empty scopes. Verify a native/abstract
  function (no body) decompiles to an empty body, not a panic.
- **`Vec::with_capacity(op.arg_count())`** in lift — bounded (≤ 6), benign.
- Regression guards: `temp_folds_into_its_single_consumer`,
  `chained_temps_fold_into_one_expression`, `call_with_inlined_argument`,
  `property_set_lowers_to_assign_of_property_access`,
  `cast_between_different_types_is_a_cast_not_a_copy`,
  `double_use_of_a_temp_is_an_error` (`crates/pex/src/decompile/lift.rs`);
  `bodyless_function_yields_empty_cfg`, `straight_line_is_one_block_plus_exit`,
  `forward_jmpf_builds_an_if_diamond`, `backward_jmpt_builds_a_loop_edge`,
  `jump_out_of_range_is_an_error`, `non_integer_jump_offset_is_an_error`
  (`crates/pex/src/decompile/cfg.rs`).
**Output**: `/tmp/audit/scripting/dim_2.md`

### Dimension 3: Decompiler — Control-Flow, Short-Circuit Booleans & AST Lowering
**Entry points**: `crates/pex/src/decompile/control_flow.rs` (`reconstruct`,
`Reconstructor`, `rebuild`, `before_exit`, `take_scope`);
`crates/pex/src/decompile/boolean.rs` (`rebuild_boolean_operators`, `BoolPass`,
`collapse`, `last_result`, `take_operand`, `combine`, `BoolOp`);
`crates/pex/src/decompile/lower.rs` (`decompile_script`, `decompile_body`,
`lower_expr`, `lower_stmt`, `lower_body`, `build_handler`, `lower_property`,
`lower_type`, `lower_binary_op`); `crates/pex/src/decompile/event_names.rs`
(`is_event_name`, `EVENT_NAMES`); pipeline order in
`crates/pex/src/decompile/mod.rs`.
**Checklist**:
- **Pass order is load-bearing**: `decompile_body` runs cfg → lift →
  **`rebuild_boolean_operators` (before)** → `reconstruct` → `lower_body`. The
  boolean pass MUST precede control-flow reconstruction (it collapses `&&`/`||`
  short-circuit chains into one conditional so the CF pass sees a clean diamond).
  Verify the order; a swap leaves `||` chains as the "unmerged conditional `last`"
  case in `control_flow.rs` (which the file documents it *skips* — see below).
- **Control-flow shape classification (`rebuild`)**: reads structure off block
  edges — While (body tail jumps back to the condition: `last.next == current`),
  simple If (`last.next == exit`), If/Else (else). The **jmpt inversion** negates
  the condition and swaps edges when `before == current`. Verify the
  while/if/if-else discriminants against the edge invariants and that
  `before_exit` returns the block containing `exit-1` (the degenerate `exit == 0`
  returns `END` → `fail()`).
- **The deliberate skip in `control_flow.rs`**: when `last` is *itself*
  conditional, the block hits the `||` short-circuit case the boolean pre-pass
  is supposed to have already collapsed. This branch **fails closed**
  (`ControlFlowFailed`, SCR-D3-01/#1732) rather than silently advancing past
  the block and dropping its lifted statements — the pre-#1732 "advance by
  one, drop the guard" behavior this bullet used to describe was itself the
  wrong-AST hazard #1732 fixed. `decompile_script`'s `Err` then makes
  `translate_pex` degrade to a clean `None` decline (§Dim 5), so a script that
  reaches this branch declines rather than mis-decompiling. Verify the
  fail-closed `Err` is still there (a "fix" that resumes silently dropping the
  block reopens #1732) and that well-formed input never reaches this branch
  because the boolean pass ran first. Regression guard:
  `conditional_predecessor_fails_closed` (`crates/pex/src/decompile/control_flow.rs`).
- **Boolean collapse soundness (`boolean.rs`)**: `&&` = true edge falls through
  (`block.on_true() == block.end + 1`), `||` = false edge falls through. The
  operand block must *recompute the same condition variable* (`take_operand`
  checks `result == cond`). The file documents **two deliberate departures from
  Champollion**: (1) NO debug-line guard (it relies on the structural signal
  alone — Champollion uses per-instruction source lines to reject cross-line
  merges); (2) a termination guard (only re-process on a real merge). Audit both:
  for (1), reason about whether a non-`&&`/`||` block that *happens* to recompute
  a same-named temp on its fall-through edge could be falsely collapsed (a
  false-positive merge fabricates a boolean operator that wasn't in the source —
  wrong AST). The file says this is "validated against the corpus decompile rate +
  the R5 fidelity gate" — point the finding at those instruments, not speculation.
  For (2), confirm the re-process loop strictly shrinks the graph (merges a
  non-exit rejoin) so it terminates — an infinite loop here hangs the decompiler.
- **`combine` precedence + assign preservation**: `&&` = prec 7, `||` = prec 8;
  an enclosing `Assign` is rebuilt around the combined op. Verify the operand
  unwrap in `take_operand` (`std::mem::replace(value, Constant(None))`) leaves no
  dangling `None` in the tree.
- **AST lowering totality (`lower.rs`)**: `lower_expr` / `lower_stmt` must be
  total (no panic on any `NodeKind`). Note the *intentional* lossy lowerings —
  flag them only if a recognizer keys on the lost info:
  (a) statement-shaped nodes appearing as sub-expressions → `Expr::NoneLit`
  (should be unreachable; if reachable it's a lift bug);
  (b) `is` type-test → `Cast` (no AST `is`);
  (c) `StructCreate` → `New` with size 0;
  (d) `lower_binary_op` default arm → `BinaryOp::Eq` (a comment says "shouldn't
  reach here" — a real unknown op silently becomes `==`, which would corrupt a
  condition; verify only the modeled op strings reach it).
- **Event-vs-function classification (`build_handler`)**: a name is an `Event`
  iff (`on`-prefixed AND `is_event_name`) OR `::remote_`-prefixed. `EVENT_NAMES`
  is a sorted lowercase union (Skyrim+FO4+Starfield) binary-searched by
  `is_event_name`. Verify the list stays sorted (the `list_is_sorted_for_binary_search`
  test guards it) — an unsorted entry makes `binary_search` miss it, demoting a
  real event handler to a plain function (recognizers that look for `OnActivate`
  as an `Event` would miss it). A *missing* engine event in the union is the same
  bug; spot-check that high-frequency events from the recognizer-scaling doc
  (`onactivate`, `onload`, `ontriggerenter`, `onhit`, `ontimer`, `oninit`,
  `onupdate`) are all present.
- **`decompile_script` assembly**: synthetic `::`-prefixed variables dropped;
  auto-state functions → script-scope items, named states → `State` items;
  property getter/setter bodies decompiled via `build_named_function`. Verify the
  auto-state match uses `state.name == object.auto_state_name` (a Skyrim
  empty-string auto-state vs FO4 named auto-state both handled).
- **The 99.996% claim**: this dimension owns verifying the corpus-smoke harness
  (`crates/pex/examples/pex_corpus_smoke.rs`) actually decompiles (not just
  parses) every `.pex` and counts panics/`Err` as failures. The README/docs claim
  26640/26641 — confirm the harness's `decompile_script` call is inside the
  success/failure tally and that a panic isn't caught-and-counted-as-success.
- **Recursion-depth caps**: both `control_flow.rs::Reconstructor::rebuild` and
  `boolean.rs::BoolPass::rebuild` thread a `depth` param capped at
  `MAX_REBUILD_DEPTH = 1024`, erroring `DecompileError::RecursionLimit` rather
  than overflowing the stack (control-flow: pre-existing #1729; boolean: #1815/
  SCR-D2-01, fixed by `7fdb694b`). Verify both still cap — a "cleanup" that drops
  the boolean-pass thread regresses #1815. Regression guards: the
  `rebuild_rejects_excessive_recursion_depth` test exists in **both**
  `control_flow.rs` and `boolean.rs` (same name, distinct files/tests).
- Regression guards: `simple_if_reconstructs`, `if_else_reconstructs_both_branches`,
  `while_loop_reconstructs`, `nested_and_becomes_nested_ifs`,
  `straight_line_has_no_control_flow_nodes` (`crates/pex/src/decompile/control_flow.rs`);
  `and_collapses_to_a_single_if_with_an_and_condition`,
  `or_collapses_to_a_single_if_with_an_or_condition`,
  `plain_if_is_untouched_by_the_boolean_pass`,
  `straight_line_with_a_call_is_unchanged` (`crates/pex/src/decompile/boolean.rs`);
  `an_on_activate_function_lowers_to_an_event`, `a_plain_function_stays_a_function`,
  `an_if_with_a_call_lowers_to_an_if_statement`, `auto_property_lowers_with_auto_flag`
  (`crates/pex/src/decompile/lower.rs`); `list_is_sorted_for_binary_search`,
  `known_events_match_case_insensitively` (`crates/pex/src/decompile/event_names.rs`).
**Output**: `/tmp/audit/scripting/dim_3.md`

### Dimension 4: Papyrus `.psc` Lexer & Pratt Parser (untrusted input)
**Entry points**: `crates/papyrus/src/lib.rs` (`parse_script`, `parse_expr`);
`crates/papyrus/src/token.rs` (logos `Token`, `ignore(ascii_case)` keyword
attrs, the `Ident` regex); `crates/papyrus/src/lexer.rs` (`preprocess`,
`OffsetMap`); `crates/papyrus/src/parser/expr.rs` (`parse_expr_bp`,
`parse_expr_bp_inner`, `MAX_EXPR_DEPTH`, `PREC_*`); `crates/papyrus/src/parser/mod.rs`
(`expr_depth`); `crates/papyrus/src/parser/stmt.rs`; `crates/papyrus/src/parser/script.rs`
(`skip_to_next_line`, item recovery); `crates/papyrus/src/ast.rs`
(`BinaryOp::precedence`); `crates/papyrus/src/error.rs` (`ExpressionTooDeep`).
**Checklist**:
- **Recursion-depth cap (`MAX_EXPR_DEPTH = 256`, #1270 / SAFE-DIM3-NEW-02)**:
  `parse_expr_bp` increments `expr_depth` at entry, returns
  `ExpressionTooDeep` at the cap, decrements at exit. This is the stack-overflow
  guard against pathological `((((…))))`. Verify (a) the increment/decrement is
  balanced on every return path including the error path (a missed decrement
  would falsely cap legitimate sibling expressions); (b) ALL recursive expression
  entry funnels through `parse_expr_bp` (no direct `parse_expr_bp_inner` recursion
  that bypasses the gate); (c) the *statement* parser (`stmt.rs`) has its own
  guard: `stmt_depth`/`MAX_STMT_DEPTH = 256` (#1712) mirrors `expr_depth` and
  caps nested `If`/`While` block recursion — verify it still resets between
  top-level calls and rejects pathological nesting (guards:
  `stmt_depth_cap_rejects_pathological_nested_if`,
  `stmt_depth_cap_rejects_pathological_nested_while`,
  `stmt_depth_cap_accepts_legitimate_nesting`,
  `stmt_depth_resets_between_top_level_calls`). Untrusted-Input: Yes.
- **Operator precedence + associativity**: `ast.rs`'s `BinaryOp::precedence`
  gives Or=1, And=2, comparisons=3, Add/Sub/StrCat=4, Mul/Div/Mod=5; `ast.rs`'s
  separate `UnaryOp::precedence` gives unary=6; cast=7/postfix=8 are the
  `PREC_CAST`/`PREC_POSTFIX` consts in `parser/expr.rs`, not `ast.rs`. Left-
  associativity hinges on the Pratt loop's `op_prec <= min_bp →
  break` (the `<=`, not `<`). Verify `a - b - c` → `(a-b)-c`
  (`test_left_associativity`) and `a + b * c` → `a + (b*c)`. Note: Papyrus's
  *runtime CTDA* OR-precedence quirk (Bethesda's inverted AND/OR) is a *condition
  evaluation* concern (Dim 6) — the `.psc` source operators here are standard.
- **Line-continuation preprocessing (`lexer.rs::preprocess`)**: a `\` immediately
  before `\n` / `\r\n` / lone `\r` is elided (2 / 3 / 2 bytes) and recorded in
  `OffsetMap` for span remap; any other `\` passes through. Verify the `\r`-only
  ("Mac classic") branch and that the `OffsetMap` byte counts (2/3/2) exactly
  match the elided bytes — a wrong count drifts every subsequent error span. Edge:
  a trailing `\` at EOF (no following newline) — confirm it's emitted, not
  swallowed (an OOB peek).
- **Case-insensitive keywords**: every keyword `#[token(..., ignore(ascii_case))]`;
  identifiers preserve case via the `Ident` regex (`priority = 1`). Verify a
  keyword-shaped identifier (e.g. a variable literally named `state`) is handled
  per Papyrus rules — logos keyword tokens win over the lower-priority `Ident`
  regex, so `state` always lexes as the keyword. Flag if that breaks any legal
  vanilla identifier (Papyrus reserves these, so it's likely correct — confirm
  against the grammar in `docs/engine/papyrus-parser.md` rather than assuming).
- **Error recovery**: `parse_script` returns `Ok((Script, Vec<ParseError>))` for
  partial success (collects per-item errors, `skip_to_next_line`, continues) and
  `Err` only for fatal failures (missing `ScriptName`, lex error). `parse_expr`
  bails on first error. Verify `skip_to_next_line` always makes progress (consumes
  ≥1 token) — a recovery point that doesn't advance is an infinite loop on a
  malformed item. Confirm callers that need strict-fail check `result.1.is_empty()`.
- **Integer/literal parsing**: hex (`test_hex_literal`), negative ints, floats —
  verify no `unwrap()` on `str::parse` that a malformed-but-lexable literal could
  panic on (lexer should reject before parse, but confirm the seam).
- Regression guards (sample — there are ~56): `depth_cap_rejects_pathological_parens`,
  `depth_cap_accepts_legitimate_nesting`, `depth_resets_between_top_level_calls`
  (`crates/papyrus/src/parser/expr.rs`); `test_left_associativity`,
  `test_precedence_mul_over_add`, `test_precedence_and_over_or`,
  `test_cast_precedence` (`crates/papyrus/src/parser/expr.rs`);
  `test_preprocess_line_continuation`, `test_preprocess_crlf_continuation`,
  `test_lex_case_insensitive_keywords` (`crates/papyrus/src/lexer.rs`);
  `parse_full_rumble_on_activate_translation` (`crates/papyrus/src/parser/script.rs`).
**Output**: `/tmp/audit/scripting/dim_4.md`

### Dimension 5: Recognizer-Chain Soundness (decline-on-unmodeled — the load-bearing invariant)
**Entry points**: `crates/scripting/src/translate/mod.rs` (`translate_script`,
`translate_pex`, `RECOGNIZERS`); `crates/scripting/src/translate/archetype.rs`
(`RecognizeCtx`, `Recognized`, `SpawnFn`, `Recognizer`);
`crates/scripting/src/translate/source.rs` (`ScriptSource`);
`crates/scripting/src/translate/compose.rs` (`split_and`, `classify_guard_atom`,
`GuardPrimitive`, `GUARD_PRIMITIVES`, `GuardMatch`, `quest_via`, `QuestRef`,
`ObjectRef`, `prim_player_gate`, `prim_stage_done`);
`crates/scripting/src/translate/effects.rs`
(`lower_fragment`, `classify_effect`, `EffectPrimitive`, `EFFECT_PRIMITIVES`,
`Effect` incl. the `AddItem`/`MoveTo` object-targeting variants (2026-07-21),
`receiver_object`, `prim_add_item`, `prim_move_to`);
`crates/scripting/src/translate/tables.rs` (`CanonicalEvent::from_papyrus`);
`crates/scripting/src/translate/recognizers/quest_stage_gate.rs` (`recognize`,
`extract_stage_gate`, `classify_if_condition`);
`crates/scripting/src/translate/recognizers/rumble.rs` (`recognize`);
`crates/scripting/src/translate/recognizers/two_state_activator.rs` (`recognize`
— per-script, matches `default2StateActivator`, added after this file's last
refresh; not otherwise covered by this dimension's checklist below).
**Checklist**:
- **The invariant**: a recognizer MUST return `None` (decline) on ANY unmodeled
  condition atom, effect statement, or unbindable hole — never emit a component
  built from a partial / approximated match. A false-positive lowering silently
  corrupts game logic (quest advances on the wrong predicate) with no fallback.
  This is the scripting analogue of NIFAL's no-fabrication rule.
- **Chain ordering (`mod.rs` `RECOGNIZERS`)**: per-script recognizers FIRST
  (`two_state_activator`, then `rumble`), generic families SECOND
  (`quest_stage_gate`), so a bespoke script isn't swallowed by a family match.
  `translate_script` is `RECOGNIZERS.iter().find_map(...)` — first match wins,
  all-`None` → silent miss. Verify the order matches the design (per-script
  before generic) and that adding a future generic recognizer can't shadow
  either per-script recognizer.
- **Guard decline enforcement (`compose.rs` + `quest_stage_gate.rs`)**: the
  load-bearing decline is `classify_guard_atom(atom, player_param)?` inside the
  per-atom loop in `classify_if_condition` — the `?` propagates `None` the instant
  an atom isn't claimed by `GUARD_PRIMITIVES`. Verify (a) the loop does NOT skip /
  ignore an unmatched atom (no `if let Some(..) = ... { }` that silently drops a
  `None`); (b) **`split_and` deliberately does NOT split `||`** — a disjunction is
  left as one atom no primitive matches, forcing a decline. This is intentional
  conservatism (the file documents it). Confirm an `If a || b` condition declines
  rather than lowering only the `a` half.
- **Effect decline enforcement (`effects.rs::lower_fragment`)**: mostly a
  flat-sequence model — `Stmt::ExprStmt(e) → classify_effect(&e.node, &scope)?`;
  `Stmt::VarDecl`/`Stmt::Assign` bind a local via `bind_local` (quest / object /
  player / side-effect-free-plain, or decline — the "ANY var-decl declines"
  framing this bullet used to state is no longer accurate, since a local that
  `bind_local` can classify is recorded, not declined); `Stmt::Return(None)` is
  the explicit no-op terminator; and — the one narrowed exception to "ANY
  control flow declines" — `Stmt::While` is accepted **only** through
  `lower_3d_loaded_wait` (MQ101 cart-cinematic work, post-2026-07-21), which
  requires the condition to be an OR-tree of `!<actor>.Is3DLoaded()` leaves and
  the loop body to be exactly one positive `Utility.Wait(..)` call; any other
  `While`/`If`/valued-`Return` still hits `_ => return None`. Verify the
  `lower_3d_loaded_wait`/`collect_not_3d_loaded_actors` shape check is still
  exactly that narrow (an OR-of-NOT-Is3DLoaded, single positive `Wait` body)
  and hasn't silently widened to accept other loop shapes — that would be a
  decline-invariant regression. `effects.rs` has grown substantially since this
  dimension was last refreshed (many more `Effect`/`EFFECT_PRIMITIVES` variants
  beyond `AddItem`/`MoveTo` — scene/player-control/vehicle/cinematic effects for
  the MQ101 cart sequence); this checklist does not enumerate them individually,
  so treat the decline invariant above as the load-bearing thing to re-verify
  rather than assuming the older, smaller primitive table.
- **Hole binding**: `QuestRef::{OwningQuest, SelfRef, Property(name)}` must FULLY
  resolve. `OwningQuest` needs `ctx.owning_quest` (decline if `None` —
  `declines_when_owning_quest_unavailable`); `Property(name)` needs the VMAD
  `script_instance` to carry that property as a form-id (decline if unbound —
  `declines_when_quest_property_unbound`); `SelfRef` on a REFR is declined
  (quest scripts attach to a quest, not a REFR). Verify each binding failure
  declines, never defaults to form-id 0.
- **`ObjectRef` hole binding (2026-07-21, object-targeting effects)**: unlike
  `QuestRef`, `ObjectRef` has **no unambiguous bare-receiver case at all** —
  no `Self`/`GetOwningQuest()` equivalent, since the fragment script always
  `extends Quest` and is never itself the `ObjectReference`/`Actor` being
  acted on. `receiver_object` (`effects.rs`) must: (a) explicitly reject a
  bare `Self` identifier (does NOT rely on no VMAD property ever being named
  "self" — verify the explicit `key == "self"` guard is still there); (b)
  decline any local-variable receiver, including a side-effect-free ident
  copy (`ObjectReference k = SomeProperty; k.AddItem(...)`) — this increment
  deliberately doesn't trace a local back to the property it aliases, so a
  local receiver must decline via `scope.quest_locals`/`scope.decl_locals`,
  not silently resolve. At *dispatch* time (`fragment.rs`),
  `resolve_property_form_id` must decline (not guess) when the VMAD
  `PropertyValue::Object`'s `alias != -1` — an alias-bound property needs
  the (unbuilt) quest-alias-fill subsystem, and resolving it from the raw
  `form_id` next to a live alias index would risk a wrong-object
  application. Then `resolve_object` composes that with
  `resolve_entity_by_global_form_id` (the same M42.5–8/M47.1 resolver) —
  verify no path bypasses either hop. Guards: `add_item_declines_on_local_receiver`,
  `declines_on_unmodeled_effect`, `dispatch_add_item_via_registered_vmad`,
  `dispatch_move_to_via_registered_vmad` (`crates/scripting/src/fragment/tests.rs`).
- **`AddItem`/`MoveTo` conservative-shape declines**: `AddItem`'s optional
  3rd arg (`abSilent`) is accepted only as a literal (`bool_arg`'s `None` on
  a present-but-non-literal value must decline the whole primitive, mirroring
  `SetObjectiveDisplayed`'s existing discipline) and a 4th+ arg declines
  outright; `MoveTo` accepts *only* the 2-arg shape (receiver + destination)
  — any offset/match-rotation argument declines rather than silently
  dropping it and misplacing the object. Guards:
  `add_item_declines_with_non_literal_silent_arg`,
  `move_to_declines_with_offset_args` (`crates/scripting/src/translate/effects.rs`).
- **`quest_stage_gate` cross-check**: when the condition's quest and the
  `SetStage` target's quest disagree, the recognizer declines (don't advance the
  wrong quest). Verify `recognizes_da10_and_reproduces_hand_builder` (`.psc`-side,
  `quest_stage_gate.rs`) and `da10_pex_reproduces_hand_builder_byte_for_byte`
  (`.pex`-side, `crates/scripting/tests/pex_recognize_e2e.rs`, `#[ignore]`-gated
  on Skyrim SE game data, #1740) both assert byte-equality against
  `da10_main_door(...)` — together they are the full `.psc`-vs-`.pex` fidelity
  gate for this recognizer (the `.psc`-side test alone never touches
  `decompile_script`).
- **`rumble` per-script recognizer**: matches script name `defaultRumbleOnActivate`
  (case-insensitive) and extracts 5 auto-property float/bool initial values with
  `.psc` defaults; declines a non-literal property value and a different script
  name. Verify the literal-only extraction (a property initialized by an
  expression must decline, not coerce).
- **`CanonicalEvent::from_papyrus`** (`tables.rs`): a fixed lowercase-keyed
  catalog; unknown → `CanonicalEvent::Unknown` (a safe long-tail bucket, not an
  error). Verify the case-insensitive match and that `Unknown` callers treat it as
  "no consumer", never as a wildcard match.
- **`translate_pex` clean-`None` on bad bytes AND on panic**: `byroredux_pex::parse` /
  `decompile_script` `Err` → `log::debug` + `return None` (never a panic
  escaping into the cell loader). Guards: `translate_pex_on_empty_bytes_is_a_clean_none`,
  `translate_pex_on_garbage_bytes_is_a_clean_none`,
  `translate_pex_on_truncated_after_magic_is_a_clean_none`. A `decompile_script`
  **panic** is also caught via `catch_unwind` (`crates/scripting/src/translate/mod.rs`,
  #1816/SCR-D5-NEW-02) and degraded to the same `None` — verify the wrap is
  still present, not removed by a future refactor (no corpus `.pex` or
  characterized input currently triggers it — this is a safety net, not an
  active-bug regression test).
- Regression guards: `unrecognized_script_is_a_silent_miss` (`crates/scripting/src/translate/mod.rs`);
  `split_and_flattens_conjunction_keeps_disjunction_whole`, `unmodeled_atom_declines`,
  `stage_done_primitive_binds_holes`, `player_gate_primitive_matches_both_orders`
  (`crates/scripting/src/translate/compose.rs`); `declines_on_unmodeled_effect`,
  `declines_on_control_flow`, `empty_fragment_is_understood_as_noop`
  (`crates/scripting/src/translate/effects.rs`); the 20 `quest_stage_gate.rs`
  tests incl. `declines_unmodeled_condition_term`, `declines_handler_without_set_stage`,
  `declines_when_quest_property_unbound`, `declines_unconditional_with_extra_statements`
  (`crates/scripting/src/translate/recognizers/quest_stage_gate.rs`);
  `recognizes_rumble_and_extracts_psc_defaults`, `declines_a_different_script`
  (`crates/scripting/src/translate/recognizers/rumble.rs`);
  `canonical_event_unknown_for_long_tail` (`crates/scripting/src/translate/tables.rs`).
**Output**: `/tmp/audit/scripting/dim_5.md`

### Dimension 6: Scripting Runtime Systems — Lifecycle, Stage & Lock Ordering
**Entry points**: `crates/scripting/src/lib.rs` (`register`);
`crates/scripting/src/events.rs` (the marker structs);
`crates/scripting/src/timer.rs` (`timer_tick_system`, `ScriptTimer`);
`crates/scripting/src/cleanup.rs` (`event_cleanup_system`);
`crates/scripting/src/condition.rs` (`evaluate`, `evaluate_condition`,
`evaluate_function`, `ConditionFunction`, `ConditionContext`);
`crates/scripting/src/trigger.rs` (`trigger_detection_system`, `TriggerVolume`,
`TriggerShape`, `contains`); `crates/scripting/src/quest_stages.rs`
(`QuestStageState`, `QuestObjectiveState`, `set_stage`, `get_stage_done`);
`crates/scripting/src/fragment.rs` (`quest_fragment_dispatch_system`,
`QuestStageFragments` incl. `insert_vmad`/`vmad` (2026-07-21), `apply_effects`,
`apply_effect`, `apply_quest_scoped_effect`, `resolve_quest_logged`,
`resolve_property_form_id`, `resolve_object`, `MAX_CASCADE`);
`crates/scripting/src/recurring_update.rs` (`recurring_update_tick_system`,
`RecurringUpdate`, `OnUpdateEvent`); `crates/scripting/src/registry.rs`
(`ScriptRegistry`).
**Checklist**:
- **Two-phase lock-drop discipline**: `timer_tick_system`,
  `trigger_detection_system`, and `recurring_update_tick_system` each Phase-1
  hold a `query_mut::<T>()`, collect a `Vec` of entities to act on, **`drop()` the
  lock**, then Phase-2 acquire a *different* `query_mut` to insert markers. Verify
  the explicit `drop()` precedes the second acquisition in every one — holding two
  component-mut locks at once forces the TypeId-sorted-acquisition contract and is
  a deadlock vector. `quest_fragment_dispatch_system` clones `QuestStageFragments`
  (`world.resource::<QuestStageFragments>().clone()`) *before* taking the mutable
  resources — the source's own comment says this is deliberate, "to avoid a
  read→write nested resource-lock order" — so only two *resource* locks
  (`QuestStageState` mut + `QuestObjectiveState` mut, via `resource_2_mut`) are
  held across the dispatch loop, not three. Verify the clone-before-lock
  ordering is still there; reintroducing a live `QuestStageFragments` read guard
  held alongside the two mutable ones would reopen the read→write nesting the
  comment says was deliberately avoided.
- **NEW nested-lock surface (2026-07-21, `AddItem`/`MoveTo`) — verify, don't
  assume**: `apply_effect` now ALSO takes `world: &World` and, for the two
  object-targeting variants, acquires a *component* lock
  (`world.query_mut::<Inventory>()` for `AddItem`; `world.get::<GlobalTransform>()`
  then `world.query_mut::<Transform>()` for `MoveTo`) **while the two resource
  locks above are still held** (they're bound in the outer scope for the whole
  `while let Some((quest, stage)) = queue.pop()` loop). This is a real change
  to the lock-nesting shape this dimension previously described as
  "resource-locks-only, no component lock held across them" — that framing is
  now stale. Investigate rather than assume safe: (a) does any *other* code
  path acquire `Inventory`/`Transform` first and then try to acquire
  `QuestStageState`/`QuestObjectiveState` — the reverse order — on a path the
  scheduler could run concurrently with this one; (b)
  does the engine's scheduler ever run `quest_fragment_dispatch_system`
  concurrently with anything else that touches these same resources/components
  (check `sys.accesses` / the scheduler's declared-access report for this
  system — Dimension 6's own §"ECS lock held across a second resource/component
  mutation" severity row already rates this class HIGH if it's a real
  deadlock vector, not merely theoretical).
- **Marker single-frame semantics**: all transient markers
  (`ActivateEvent`, `HitEvent`, `TimerExpired`, `AnimationTextKeyEvents`,
  `OnUpdateEvent`, `OnTriggerEnterEvent`, `OnCellLoadEvent`, `OnEquipEvent`,
  `QuestStageAdvancedBatch` — the actual drained `Component`; the bare
  `QuestStageAdvanced` struct it wraps carries no `Component` impl and is never
  inserted directly — and the rumble/camera/UI command markers) MUST be drained
  by `event_cleanup_system` exactly once per frame. Verify `event_cleanup_system`
  drains EVERY marker type the runtime emits (cross-check the
  `cleanup.rs` drain list against every `world.insert` of a marker across the
  crate) — an undrained marker re-fires its consumer every frame; a marker
  emitted by a system that runs *after* cleanup lags a frame. `cleanup` must be
  the LAST scripting system in the schedule. Guards: `cleanup_removes_all_event_types`,
  `cleanup_preserves_non_event_components`.
- **Producer→consumer cross-stage ordering**: `quest_advance_system` (Dim 7)
  emits `QuestStageAdvanced`; `quest_fragment_dispatch_system` consumes it and may
  re-emit (cascade). Verify the cascade is bounded by `MAX_CASCADE = 64` with a
  WARN on overflow (an unbounded `SetStage`→fragment→`SetStage` loop hangs the
  frame) and that only *genuine* transitions cascade (a no-op re-set of the same
  stage is skipped — the `fragment.rs` cascade guard).
- **CTDA OR-precedence (`condition.rs::evaluate`)**: Bethesda's **inverted**
  precedence — consecutive `or_next`-flagged conditions form an OR block that
  binds *tighter* than the surrounding AND chain (`A AND B OR C AND D` =
  `A AND (B OR C) AND D`). The block scan walks while `conditions[i].or_next`,
  OR-combines the block with `.any()`, AND-combines blocks with early-return on a
  false block. Verify the block-boundary logic (the last condition of a block has
  `or_next == false`) and the empty-list → `true` contract. Guards:
  `or_precedence_quirk_a_and_b_or_c_and_d_groups_b_or_c`,
  `or_precedence_quirk_swap_test_a_true`, `and_chain_short_circuits_on_first_false`,
  `or_block_returns_true_when_any_member_true`, `empty_condition_list_returns_true`.
- **Condition stubs are KNOWN (#1663–#1668, #1316)**: `GetActorValue`/`GetDistance`/
  `GetFactionRank`/`GetIsID`/`HasPerk` return documented safe-defaults (the
  Bethesda "unknown-function safe-default" / "not in faction" = -1.0 sentinels).
  Do NOT re-file these. DO verify the *safe-default values* are correct (a wrong
  sentinel flips a condition) and that `RunOn` resolution declines (condition
  fails) on an unresolvable target rather than defaulting to subject.
- **Edge-triggered trigger detection (`trigger.rs`)**: `trigger_detection_system`
  fires `OnTriggerEnterEvent` ONLY on the outside→inside transition
  (`inside && !was_inside`), updates `occupant_inside` each frame, fires
  again on re-entry. `occupant_inside` is `Option<bool>`, not a bare `bool` —
  `None` means "never checked" and the seed contract is enforced by *skipping*
  the enter check entirely on that first tick (SCR-D6-NEW-02/#1817), not by
  seeding a synthetic `Some(true)`/"was inside" default: a fresh volume writes
  `Some(inside)` without ever comparing against the `None`. The event lands on
  the **volume entity** with the triggerer in the marker field. Verify the seed
  contract (a player loaded already inside a volume must NOT spuriously fire on
  frame 1 — the `None` branch never pushes to `entered`) and
  the `contains` math: Sphere = `(p-center).length_squared() <= r*r` with
  `half_extents.x` as radius; Box (OBB) = `rotation.inverse() * (p-center)` then
  per-axis `local.abs() <= half_extents`. Guards: `edge_triggered_not_level_triggered`,
  `re_entry_fires_again`, `sphere_contains_by_radius`, `obb_rotation_is_respected`,
  `aabb_contains_interior_and_rejects_exterior`.
- **Quest stage history (`quest_stages.rs`)**: `set_stage` updates `current_stage`
  AND inserts into `stages_done` (history retained across advances —
  `GetStageDone(37)` stays true after advancing to 40); `set_stage` returns the
  previous current; backward set is allowed; `reset` clears one quest only. Guards:
  `get_stage_done_retains_history_across_advances`,
  `set_stage_on_already_done_stage_remains_idempotent`, `reset_leaves_other_quests_intact`.
- **`recurring_update_tick_system`**: a fresh `RecurringUpdate` does NOT fire on
  the registering frame / zero dt; fires once per interval; re-arms after fire; a
  long-frame dt overshoot fires once (not a burst); `UnregisterForUpdate` inside a
  handler terminates cleanly. Guards: `fresh_subscription_does_not_fire_on_zero_dt`,
  `dt_overshoot_fires_only_once_per_tick`, `subscription_re_arms_after_fire`,
  `unregister_inside_handler_terminates_cleanly` (`crates/scripting/src/recurring_update/tests.rs`).
- **`ScriptRegistry`** (M47.0 static path, being retired in favor of the dynamic
  attach): case-SENSITIVE editor-id keys, re-register replaces. Verify no live
  call path still depends on the hardcoded `papyrus_demo::register_spawners` for a
  vanilla REFR (the demos should be test fixtures only — `m47-2-design.md` §"Engine
  integration" says the hardcoded registration is retired). Flag a surviving
  hardcoded-attach call site as a tech-debt / correctness mismatch.
**Output**: `/tmp/audit/scripting/dim_6.md`

### Dimension 7: Engine Attach Path & Trigger-Volume Wiring (engine-side)
**Entry points**: `byroredux/src/cell_loader/references/attach.rs` (`attach_vmad_scripts`,
`attach_script_for_refr`, `trigger_volume_from_primitive`, the invisible-trigger
REFR spawn path — split out of `references/mod.rs` under #1877, which now only
re-exports them and keeps their call sites); `crates/plugin/src/esm/records/index.rs`
(`base_record_script_instance`); `crates/plugin/src/esm/records/script_instance.rs`
(`ScriptInstanceData`, `ScriptInstance`); `byroredux/src/asset_provider/script.rs`
(`build_script_provider`, `extract_pex`, the `--scripts-bsa` parse);
`crates/scripting/src/papyrus_demo/quest_advance.rs` (`quest_advance_system`,
`QuestAdvanceOnActivate`).
**Why this dimension**: the decompiler + recognizer chain (Dims 1–5) are the
*producer* of canonical components; the cell-loader attach path is the only live
*driver* that feeds them real VMAD + `.pex` from game data. None of the crate
dimensions covers it.
**Checklist**:
- **Silent-miss everywhere (graceful degradation)**: the attach path must NEVER
  panic on missing data — no `--scripts-bsa` (early out), VMAD absent
  (`base_record_script_instance` → `None` → return), `.pex` not in archive
  (`extract_pex` → `None`, trace-log, continue), parse/decompile fail
  (`translate_pex` → `None`, debug-log), recognizer miss (trace-log). Verify every
  branch is a `continue`/`return false`, not an `unwrap`/`expect`. Untrusted-Input:
  Yes (the `.pex` bytes come from a possibly-modded archive).
- **VMAD retention + accessor (`index.rs::base_record_script_instance`)**: checks
  ACTI/CONT/NPC/CREA base records in order, then (#2189) the item family —
  WEAP/ARMO/AMMO/MISC/KEYM/ALCH/INGR/BOOK/NOTE, via `self.items.get(base_form_id)
  .and_then(|r| r.common.script_instance.as_ref())` — returning the first hit.
  Verify the record types covered match the VMAD-bearing set (a scripted base
  type not in the chain → its scripts never attach) — the item family was
  absent until `CommonItemFields` gained a decoded `script_instance` (#2189;
  before that every scripted item silently declined to attach). Confirm the
  accessor is keyed by `base_form_id` (the REFR's base, not the REFR's own form
  id) and that a REFR's *own* VMAD (Skyrim+ supports per-REFR scripts) is also
  resolved — flag if only base-record VMAD is consulted (per-REFR override
  scripts would be dropped). Guard: `base_record_script_instance_resolves_an_item_records_vmad`.
- **`.pex` resolution (`asset_provider/script.rs`)**: `extract_pex` normalizes a
  VMAD script name → `scripts\<name>.pex` (backslash, lowercase, `scripts\`
  prefix). `--scripts-bsa` is **repeatable, first-listed-archive-hit-wins**
  (searched in flag order, first hit returned) — this is the documented
  *inverse* of typical mod-manager load order (there, later = higher
  priority): list override/mod archives *before* the vanilla one on the
  command line (#1743/SCR-D7-03). Verify the path normalization matches the
  on-disk archive convention (a wrong prefix/separator → every `.pex` miss →
  zero scripts attach silently) and that the iteration order actually
  implements first-listed-wins, not the reverse.
- **XPRM → `TriggerVolume` half-extent convention** (`trigger_volume_from_primitive`):
  XPRM `bounds` are Bethesda **z-up HALF-extents** (CK Primitive convention,
  consistent with `bhkBoxShape` half-extents) — the code must NOT divide by 2.
  Verify (a) no `/ 2.0`; (b) the z-up→y-up permute is `[x, z, y]` (bounds[0],
  bounds[2], bounds[1]) with `.abs()` (extents are magnitudes); (c) the REFR
  `scale` is baked in (world-space volume); (d) sphere uses `bounds[0]` as radius
  into `half_extents.x`; (e) shape dispatch `1 → Box`, `3 → Sphere`, other →
  `None` (line/portal/plane are non-containment). A wrong half/full or a wrong
  permute makes every trigger box the wrong size/shape → quests fire at the wrong
  position or never. Guards: `trigger_volume_from_box_primitive_permutes_and_scales`,
  `trigger_volume_from_sphere_primitive_uses_radius`.
- **Invisible (MODL-less) trigger REFR spawn**: a scripted trigger REFR with no
  mesh spawns an entity with `Transform`/`GlobalTransform`/`TriggerVolume` (no
  render component) and attaches its script. Verify the volume is built in
  **world space** (REFR position + rotation + scale composed once at load), so
  `trigger_detection_system` can test against the post-propagation player
  `GlobalTransform` without per-frame composition.
- **`quest_advance_system` unifies OnActivate + OnTriggerEnter**: both
  `ActivateEvent` (doors/levers, `activator` field) and `OnTriggerEnterEvent`
  (trigger volumes, `triggerer` field) converge on one `QuestAdvanceOnActivate`
  component via a combined `(entity, triggerer)` collect. The design relies on a
  given entity receiving only one signal (doors have no volume, volumes have no
  use interaction) — verify nothing can deliver both to one entity in one frame
  (double-advance). Confirm condition gating runs per `QuestAdvanceOnActivate`
  (`ConditionContext::for_subject` + `evaluate_condition_list`) and the
  `ActivatorGate::PlayerOnly` is honored. Guards: `trigger_enter_advances_quest`,
  `trigger_enter_respects_player_only_gate`,
  `activate_and_trigger_in_same_frame_both_advance`
  (`crates/scripting/src/papyrus_demo/quest_advance/tests.rs`).
- **The `M47.2 scripts:` cell-load summary**: the smoke gate
  `docs/smoke-tests/m47-triggers.sh` keys on the `N REFRs recognized, M trigger
  volumes spawned` line. Verify the counters are wired (recognized++ on a
  `translate_pex` Some, trigger_volumes++ on a volume spawn) so the smoke harness
  has a real signal — a counter that never increments makes the gate vacuous.
**Output**: `/tmp/audit/scripting/dim_7.md`

## Phase 3: Merge

1. Read all `/tmp/audit/scripting/dim_*.md` files.
2. Combine into `docs/audits/AUDIT_SCRIPTING_<TODAY>.md` with structure:
   - **Executive Summary** — what shipped (M30.2 `.psc` parser; M47.0 event
     hooks; M47.1 condition eval; M47.2 `.pex` reader + 5-phase decompiler +
     recognizer chain + dynamic attach path + XPRM trigger volumes + the
     fragment-lowerer wired-and-live-verified dispatch + the QUST VMAD
     property-table fix + the `AddItem`/`MoveTo` object-targeting effects, all
     2026-07-21) vs. deferred (Obscript/SCTX Phase 5; the M47.1 condition
     resolvers' live-cell re-verification; M47.3 quest-alias-fill — the
     `Property`-resolution decline on an alias-bound VMAD entry). Findings
     count by severity. **Untrusted-input
     robustness verdict** (can a hostile/corrupt `.pex` or `.psc` panic, OOB, or
     OOM the cell loader — MUST be NO). **The 99.996% decompile-rate claim
     verdict** (is the corpus-smoke harness measuring what it claims). **The
     `.psc`-vs-`.pex` fidelity-gate verdict** (do `recognizes_da10_and_reproduces_
     hand_builder` AND `da10_pex_reproduces_hand_builder_byte_for_byte` (#1740)
     both actually pin byte-equality).
   - **Decompiler Soundness Matrix** — per pass (reader / cfg / lift+copy-prop /
     boolean / control-flow / lower): bounds-safe? terminates? total (no panic)?
     fidelity-tested? — with the two documented Champollion departures (no
     debug-line guard in `boolean.rs`; the deliberate `||`-skip in `control_flow.rs`)
     adjudicated as benign-or-bug.
   - **Decline-Invariant Audit** — every recognizer/composer/effect decline point
     × verified-conservative vs. leaks-a-partial-lowering.
   - **Runtime Lifecycle Invariant Matrix** — marker drain coverage; two-phase
     lock-drop per system; cascade bound; edge-trigger seed; CTDA OR-precedence.
   - **Findings** — grouped by severity (CRITICAL first), deduplicated.
   - **Future-Phase Readiness** — which invariants this audit pinned for Obscript
     (Phase 5), the fragment lowerer (b2), and the condition-resolver issues.
3. Remove cross-dimension duplicates: marker-drain coverage is owned by Dim 6
   (pointers from Dims 1–5 if they emit markers); the `translate_pex` clean-`None`
   contract is owned by Dim 5 (pointer from Dim 7); the half-extent convention is
   owned by Dim 7.

## Phase 4: Cleanup

1. `rm -rf /tmp/audit/scripting`
2. Inform user the report is ready.
3. Suggest: `/audit-publish docs/audits/AUDIT_SCRIPTING_<TODAY>.md`
