Scripting Subsystem Audit

VerifiedSafe

Deep audit of scripting subsystem including .pex decompiler, Papyrus parser, AST-to-ECS recognizer chain, and runtime. Focus on correctness and bug detection.

Sby Skills Guide Bot
TestingAdvanced
207/26/2026
Claude Code
#audit#scripting#decompiler#parser#game-development

Recommended for

Our review

This skill performs a deep audit of a game's scripting subsystem, including the decompiler, parser, runtime, and engine integration.

Strengths

  • Covers multiple crates and the full Papyrus pipeline chain.
  • Targets high-bug-density areas such as the decompiler.
  • Leverages prior audit passes to avoid redundancy.
  • Uses an orchestrator to run dimensions concurrently.

Limitations

  • Requires deep familiarity with the codebase.
  • Can be time-consuming when auditing all dimensions.
  • Results depend on the auditor's expertise.
When to use it

Use to verify correctness of the Papyrus scripting pipeline after changes or before a release.

When not to use it

Not suitable for quick checks or when automated testing suffices.

Security analysis

Safe
Quality score90/100

The skill only contains instructions for auditing a codebase; it does not instruct the AI to execute any potentially harmful operations. It is purely informational and advisory.

No concerns found

Examples

Deep audit of decompiler
Audit the scripting subsystem with focus on the pex decompiler dimension and depth deep.
Full deep audit
Run a full deep audit of the scripting subsystem covering all dimensions (pex, papyrus, scripting, engine wiring).
Shallow audit of runtime
Audit the scripting runtime (events, timers, conditions) with depth shallow, focusing only on correctness invariants.

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}). 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.rsbase_record_script_instance accessor (VMAD retained on ACTI/CONT/NPC/CREA base records).
  • crates/plugin/src/esm/records/script_instance.rsScriptInstanceData / ScriptInstance (decoded VMAD).
  • byroredux/src/asset_provider/script.rsbuild_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 line 139 as stale — verify it still reads correctly before reporting any doc-rot here.

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 count is n as usize with Vec::with_capacity(n as usize). Confirm a hostile but in-i32-range huge n (e.g. i32::MAX) can't pre-allocate gigabytes before take fails — with_capacity(2^31) is a reachable DoS even though the reads will EOF. (Same hazard on every with_capacity(count) fed by an attacker-controlled u16/u32: read_string_table, read_objects, read_instructions, read_typed_names, read_struct_infos. The u16 ones cap at 65535 = benign; the u32 user-flags / var-arg / object-size are the ones to scrutinize.)
  • 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 statement that consumes its result via count_constant_id; 0 → skip, 1 → inline (and restart at i=0), >1 → ExpressionRebuildFailed. This is the AST-correctness core. Verify: (a) the count is over the immediately next statement only (scope[i+1]) — 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; val(0)/id(1)/id(2) for CallStatic). 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)reconstructlower_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 ENDfail()).
  • The deliberate skip in control_flow.rs: when last is itself conditional, the block is "left unmerged, advance by one" — this is the || short-circuit case the boolean pre-pass is supposed to have already collapsed. Confirm that with the boolean pass running first this branch is unreachable for well-formed input; if a script reaches it (boolean pass declined to collapse), the CF pass silently drops a guard — that's a wrong-AST hazard (a guarded effect becomes unguarded). Assess whether such a script errors, declines, or silently mis-decompiles.
  • 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) StructCreateNew 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 BinaryOp::precedence): Or=1, And=2, comparisons=3, Add/Sub/StrCat=4, Mul/Div/Mod=5; unary=6, cast=7, postfix=8. 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 * ca + (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). 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 (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 rumble.
  • 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): a flat-sequence model — Stmt::ExprStmt(e) → classify_effect(&e.node, &env)? and _ => return None for ANY control flow / valued return / var-decl. An assignment binds a quest-ref local (quest_expr_ref(...)?) or declines. Verify no statement type is silently accepted-as-noop except the explicit Stmt::Return(None).
  • Hole binding: QuestRef::{OwningQuest, SelfRef, Property(name)} must FULLY resolve. OwningQuest needs ctx.owning_quest (decline if Nonedeclines_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 Errlog::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 14 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 holds three resource locks (QuestStageFragments read + QuestStageState mut + QuestObjectiveState mut) across its whole dispatch loop.
  • 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 three 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 QuestStageFragments/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, QuestStageAdvanced, 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 && !occupant_inside), updates occupant_inside each frame, fires again on re-entry. 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 — occupant_inside seeded true) 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_instanceNone → return), .pex not in archive (extract_pexNone, trace-log, continue), parse/decompile fail (translate_pexNone, 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, returns the first script_instance.as_ref(). Verify the record types covered match the VMAD-bearing set (a scripted base type not in the chain → its scripts never 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).
  • .pex resolution (asset_provider.rs): extract_pex normalizes a VMAD script name → scripts\<name>.pex (backslash, lowercase, scripts\ prefix). --scripts-bsa is repeatable, first-archive-hit-wins (mod layering). Verify the path normalization matches the on-disk archive convention (a wrong prefix / separator → every .pex miss → zero scripts attach silently) and that the repeatable-flag layering order is mod-over-vanilla (later --scripts-bsa archives should override earlier — confirm the iteration order vs. the first-hit-wins semantics actually achieve that).
  • 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
Related skills