Scripting Subsystem Audit

VerifiedSafe

Deep audit of the M30/M47 scripting crates and engine wiring for correctness across the compiled Papyrus pipeline, including decompiler, parser, AST→ECS recognizer chain, and ECS runtime.

Sby Skills Guide Bot
DevelopmentAdvanced
007/30/2026
Claude Code
#audit#scripting#game-development#papyrus#decompiler

Recommended for

Our review

Performs a deep audit of the Papyrus scripting subsystem, covering the .pex decompiler, .psc parser, AST→ECS recognizer chain, ECS runtime, and cell-loader attachment path.

Strengths

  • Systematic analysis across the full compiled-Papyrus pipeline
  • Weighted dimensions focusing on the decompiler (highest bug-density area)
  • Relies on a corpus decompile smoke harness and .psc-vs-.pex fidelity gate

Limitations

  • Requires familiarity with up to six prior audit passes documented separately
  • Does not cover engine-specific details outside the listed crates and wiring
  • Severity assessment depends on an external scale not included here
When to use it

Ideal for a focused, in-depth code review of Papyrus script components after modifications or before a major integration.

When not to use it

Not suitable for high-level or exploratory reviews, as the audit is structured into very specific dimensions requiring detailed domain knowledge.

Security analysis

Safe
Quality score85/100

This skill is a static audit instruction; it describes a code review task without any executable commands or data exfiltration. It poses no execution risk.

No concerns found

Examples

Full audit of scripting subsystem
Audit the scripting subsystem with --focus all --depth deep
Decompiler-focused deep audit
Audit the scripting subsystem with --focus decompiler --depth deep
Recognizer chain shallow check
Audit the scripting subsystem with --focus recognizer --depth shallow

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