Our review
Performs a deep audit of the M30/M47 scripting subsystem, including the .pex decompiler, .psc parser, AST→ECS recognizer chain, ECS runtime, and cell-loader attach path.
Strengths
- Comprehensive coverage of the entire compiled Papyrus pipeline, from bytecode to ECS runtime.
- Prioritizes high bug-density areas, especially the five-phase decompiler.
- Leverages existing audit passes to avoid duplication and focus efforts.
- Orchestrated architecture with parallel task agents for efficient analysis.
Limitations
- Requires deep prior knowledge of the codebase and previous audit passes.
- Can be time-consuming and resource-intensive (16k LOC, 22 crates).
- Depends on common audit reference files (does not duplicate them but must read them).
Use for a thorough code review of the scripting subsystem before major integration or release.
Do not use for quick lookups or specific bug fixes without a general audit context.
Security analysis
SafeThe skill is a deeply scoped code-audit instruction file. It contains no executable shell commands, network calls, file modifications, or obfuscated payloads. All referenced paths and tools are for static analysis and documentation review; no untrusted input is executed.
No concerns found
Examples
Run a deep audit of the scripting subsystem with focus on all dimensions and deep depth. Read the most recent audit pass first.Audit the scripting crates focusing on the decompiler and engine-side wiring with shallow depth.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/—.pexreader + 5-phase decompiler. Files:crates/pex/src/opcode.rs,crates/pex/src/reader.rs,crates/pex/src/model.rs,crates/pex/src/lib.rs, andcrates/pex/src/decompile/(mod,cfg,lift,control_flow,boolean,lower,node,event_names).crates/papyrus/src/—.psclexer (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, andcrates/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 ofmod.rs, #1877;mod.rsre-exports them and keeps their call sites) —attach_vmad_scripts/attach_script_for_refrcallbyroredux_scripting::translate_pex; thetrigger_volume_from_primitivebuilder spawns invisibleTriggerVolumeREFRs fromXPRMprimitives.crates/plugin/src/esm/records/index.rs—base_record_script_instanceaccessor (VMAD retained on ACTI/CONT/NPC/CREA base records).crates/plugin/src/esm/records/script_instance.rs—ScriptInstanceData/ScriptInstance(decoded VMAD).byroredux/src/asset_provider/script.rs—build_script_providerparses the repeatable--scripts-bsaflag;extract_pexresolves a VMAD script name to.pexbytes.
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.pscparser + AST.docs/engine/m47-0-design.md— event-hooks runtime (the attach chain M47.2 extends).docs/engine/m47-2-design.md— the.pexdecompiler + recognizer-chain spec, the.psc-vs-.pexfidelity 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 theAddItem/MoveToobject- 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-boundProperty-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— runsbyroredux_pex::parse+decompile::decompile_scriptover every.pexin 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 /Erras 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.pexcorpus extractor.docs/smoke-tests/m47-triggers.sh— engine-side spawn+attach gate on real Skyrim data (--scripts-bsa, theM47.2 scripts:cell-load summary line).
Future-phase gaps (do NOT flag as missing unless scope says so):
- Obscript /
SCTXfrontend (Oblivion/FO3/FNV) —ScriptSource::Obscriptis 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-verifiedAUDIT_SCRIPTING_2026-07-16.mdDimension 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'sQF_.pexfrom--scripts-bsa, decompiles, and registers intoQuestStageFragmentsat cell load;quest_fragment_dispatch_systemconsumesQuestStageAdvancedand applies. Verified against realSkyrim.esm: 845 scripted quests → 5,108 stage bindings → 742 fragments fully lowered and registered. Do not re-flag this as unwired. - QUST
VMADproperty-table wiring (2026-07-21, same-session fix):QuestRef::Property/the newObjectRef::Property(see Dim 5) used to always decline at dispatch becausequest_fragment_dispatch_systempassedvmad: Noneunconditionally —parse_quest_fragmentsdecoded the QUST's own VMAD scripts-section internally (to find the fragment-section offset) and then discarded it. Fixed:QustRecord.script_instancenow retains it,QuestStageFragments::insert_vmad/vmad()store it per-quest, andresolve_quest/resolve_object(Dim 6) receive the real VMAD. Verified live: 969 Skyrim quests now have a resolvable property table. AProperty-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_coveragefound zero hits in bothSkyrim - Misc.bsaandFallout4 - Misc.ba2): real content overwhelmingly binds the object receiver via an alias accessor (ObjectReference k = SomeAlias.GetActorRef()) rather than a bareObjectReference Property, andbind_localalready 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 indocs/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 againstSkyrim.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, notpex/papyrus/scripting), but directly explains whyQuestRef::Property/ObjectRef::Propertystill decline on an alias-bound VMAD entry (alias != -1inPropertyValue::Object) even after the VMAD-wiring fix above — that's M47.3 Phase 2, not built. Seedocs/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 coverquest.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/.pscbytes — 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
- Parse
$ARGUMENTSfor--focus,--depth. mkdir -p /tmp/audit/scripting- Fetch dedup baseline:
gh issue list --repo matiaszanolli/ByroRedux --limit 300 --json number,title,state,labels > /tmp/audit/scripting/issues.json - Read the most recent
docs/audits/AUDIT_SCRIPTING_*.mdreport (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 livecrates/scripting/code before flagging any condition-resolver gap, don't assume the stub-era finding still applies. - 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 throughtake(checked_add+<= data.len()filter →UnexpectedEof). Verify NO read path bypasses it (a directself.data[...]slice, atry_into().unwrap()on a short slice). Untrusted-Input: every finding here is Yes.- The
OpCode::from_u8transmute.crates/pex/src/opcode.rsdoesunsafe { transmute::<u8, OpCode>(byte) }guarded bybyte >= MAX_OPCODE → None. This is memory-safety-critical: confirm (a)MAX_OPCODE == 51exactly 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>. Thediscriminants_match_on_disk_order+from_u8_round_trips_and_rejects_oobtests pin this — verify they actually cover every discriminant, not just spot values. arg_countdrives operand consumption.read_instructionsreads exactlyop.arg_count()fixed operands + (ifhas_varargs) aValue::Integer(n >= 0)count thennoperands. TheOPCODEStable is the contract. Cross-check every row against the UESP Papyrus Assembly spec / ChampollionOPCODES(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 isn as usizewithVec::with_capacity(n as usize). Confirm a hostile but in-i32-range hugen(e.g.i32::MAX) can't pre-allocate gigabytes beforetakefails —with_capacity(2^31)is a reachable DoS even though the reads will EOF. (Same hazard on everywith_capacity(count)fed by an attacker-controlledu16/u32:read_string_table,read_objects,read_instructions,read_typed_names,read_struct_infos. Theu16ones cap at 65535 = benign; theu32user-flags / var-arg / object-size are the ones to scrutinize.)string_indexrange check: au16index is.get(idx).cloned()→BadStringIndexon miss. Verify NO field reads a rawu16and indexesself.strings[idx]directly (panic on OOB).value()type tag: only 0..=5 accepted (BadValueTypeotherwise). Confirm the six arms matchValueTypeand thatValue::Integer(self.u32()? as i32)sign-reinterprets (not truncates) — Papyrus ints are signed.- Endianness / dialect detection: magic LE (
0xFA57C0DE) vs BE (0xDEC057FA) setsendian;script_typederives 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 honorsself.endianand that the provisionalEndian::Littleinnew()can't leak into a read beforeread_headersets it. - Skyrim-vs-FO4+ field gating:
is_skyrim()skipsconst_flag,struct_infos, property-group / struct-order debug tables; Starfield-onlyguards. A misgated field shifts the whole stream. Verifyread_objectsreads fields in the exact FileReader order and thatskip_property_groups/skip_struct_ordersconsume 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
Pexescapes:lib.rsclaims the reader "never returns a half-builtPex". Confirmread_binaryis all-or-Err(noOkwith a truncatedobjectsVec 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 viaPexWriter::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_targetvalidates0 <= ip+offset <= count(inclusive — the exit anchor is one past last).build_cfgerrors on a non-integer offset (BadJumpOffset) or OOB (JumpOutOfRange). Verify the inclusive bound is correct (a jump tocountlands on the synthetic exit block, not OOB) and thatcondition_namerejects 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].atis alwaysip+1or a jump target ≥ 1, soat-1can't underflow — confirmsplitis never called withat == 0(the initial full block starts at 0 and the exit anchor pre-exists, soip+1for the final instruction maps to the anchor without a split). Asplit(0)is an underflow panic. jmpfvsjmptedge polarity:jmpfjumps when FALSE, so true-edge = fall-through (ip+1), false-edge = target;jmptis mirrored. This is the load-bearing CFG semantic — a flipped polarity inverts everyIf. Theforward_jmpf_builds_an_if_diamond+backward_jmpt_builds_a_loop_edgetests pin it; verify the(on_true, on_false)tuple inbuild_cfgmatches.- Copy-propagation soundness (
rebuild_expression): a non-final (temp-producing) node is folded into the single following statement that consumes its result viacount_constant_id; 0 → skip, 1 → inline (and restart ati=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_varasymmetry is intact (is_finaltreats any::tempprefix as non-final incl._var-suffixed;is_temp_varexcludes_var) — the file documents this as a deliberate Champollion port, so a "cleanup" that unifies them is a regression; (c) thereplace_constant_idslot.take()substitutes exactly once (thedebug_assert!(slot.is_none())only fires in debug — a release build with a1-match that slipped past
count_constant_idwould silently drop the producer). create_nodeopcode→node map: each opcode maps to aNodeKind. Spot-check the precedence values passed (they're cosmetic for AST lowering but the file carries them); theCastheuristic (lift.rs): a cast is downgraded to aCopywhen source isNone, or when both sides are same-typed identifiers (or src is::nonevar). Verify the same-type test usestype_ofon both and the::nonevarcase-insensitive exception — a wrong downgrade turns a real type-narrowing cast into an identity copy (recognizer reads the wrong type).CallStatic/CallMethod/CallParentoperand 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_ofon a literal: operands that must be identifiers (id(n)) error withExpectedIdentifieron a literal. Verify the lift neverunwrap()sas_identifier()outside a checked branch (the Cast arm doessrc.as_identifier().unwrap()— confirm it's guarded by the precedingmatches!(src, Value::Identifier(_))short-circuit).- Bodyless / native functions:
build_cfgreturnsentry == ENDfor zero instructions;lift_functionyields 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_bodyruns cfg → lift →rebuild_boolean_operators(before) →reconstruct→lower_body. The boolean pass MUST precede control-flow reconstruction (it collapses&&/||short-circuit chains into one conditional so the CF pass sees a clean diamond). Verify the order; a swap leaves||chains as the "unmerged conditionallast" case incontrol_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 whenbefore == current. Verify the while/if/if-else discriminants against the edge invariants and thatbefore_exitreturns the block containingexit-1(the degenerateexit == 0returnsEND→fail()). - The deliberate skip in
control_flow.rs: whenlastis 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_operandchecksresult == 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. combineprecedence + assign preservation:&&= prec 7,||= prec 8; an enclosingAssignis rebuilt around the combined op. Verify the operand unwrap intake_operand(std::mem::replace(value, Constant(None))) leaves no danglingNonein the tree.- AST lowering totality (
lower.rs):lower_expr/lower_stmtmust be total (no panic on anyNodeKind). 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)istype-test →Cast(no ASTis); (c)StructCreate→Newwith size 0; (d)lower_binary_opdefault 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 anEventiff (on-prefixed ANDis_event_name) OR::remote_-prefixed.EVENT_NAMESis a sorted lowercase union (Skyrim+FO4+Starfield) binary-searched byis_event_name. Verify the list stays sorted (thelist_is_sorted_for_binary_searchtest guards it) — an unsorted entry makesbinary_searchmiss it, demoting a real event handler to a plain function (recognizers that look forOnActivateas anEventwould 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_scriptassembly: synthetic::-prefixed variables dropped; auto-state functions → script-scope items, named states →Stateitems; property getter/setter bodies decompiled viabuild_named_function. Verify the auto-state match usesstate.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.pexand counts panics/Erras failures. The README/docs claim 26640/26641 — confirm the harness'sdecompile_scriptcall 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::rebuildandboolean.rs::BoolPass::rebuildthread adepthparam capped atMAX_REBUILD_DEPTH = 1024, erroringDecompileError::RecursionLimitrather than overflowing the stack (control-flow: pre-existing #1729; boolean: #1815/ SCR-D2-01, fixed by7fdb694b). Verify both still cap — a "cleanup" that drops the boolean-pass thread regresses #1815. Regression guards: therebuild_rejects_excessive_recursion_depthtest exists in bothcontrol_flow.rsandboolean.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_bpincrementsexpr_depthat entry, returnsExpressionTooDeepat 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 throughparse_expr_bp(no directparse_expr_bp_innerrecursion that bypasses the gate); (c) the statement parser (stmt.rs) has its own guard:stmt_depth/MAX_STMT_DEPTH = 256(#1712) mirrorsexpr_depthand caps nestedIf/Whileblock 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.rsBinaryOp::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'sop_prec <= min_bp → break(the<=, not<). Verifya - b - c→(a-b)-c(test_left_associativity) anda + b * c→a + (b*c). Note: Papyrus's runtime CTDA OR-precedence quirk (Bethesda's inverted AND/OR) is a condition evaluation concern (Dim 6) — the.pscsource operators here are standard. - Line-continuation preprocessing (
lexer.rs::preprocess): a\immediately before\n/\r\n/ lone\ris elided (2 / 3 / 2 bytes) and recorded inOffsetMapfor span remap; any other\passes through. Verify the\r-only ("Mac classic") branch and that theOffsetMapbyte 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 theIdentregex (priority = 1). Verify a keyword-shaped identifier (e.g. a variable literally namedstate) is handled per Papyrus rules — logos keyword tokens win over the lower-priorityIdentregex, sostatealways lexes as the keyword. Flag if that breaks any legal vanilla identifier (Papyrus reserves these, so it's likely correct — confirm against the grammar indocs/engine/papyrus-parser.mdrather than assuming). - Error recovery:
parse_scriptreturnsOk((Script, Vec<ParseError>))for partial success (collects per-item errors,skip_to_next_line, continues) andErronly for fatal failures (missingScriptName, lex error).parse_exprbails on first error. Verifyskip_to_next_linealways 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 checkresult.1.is_empty(). - Integer/literal parsing: hex (
test_hex_literal), negative ints, floats — verify nounwrap()onstr::parsethat 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.rsRECOGNIZERS): per-script recognizers FIRST (rumble), generic families SECOND (quest_stage_gate), so a bespoke script isn't swallowed by a family match.translate_scriptisRECOGNIZERS.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 shadowrumble. - Guard decline enforcement (
compose.rs+quest_stage_gate.rs): the load-bearing decline isclassify_guard_atom(atom, player_param)?inside the per-atom loop inclassify_if_condition— the?propagatesNonethe instant an atom isn't claimed byGUARD_PRIMITIVES. Verify (a) the loop does NOT skip / ignore an unmatched atom (noif let Some(..) = ... { }that silently drops aNone); (b)split_anddeliberately 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 anIf a || bcondition declines rather than lowering only theahalf. - Effect decline enforcement (
effects.rs::lower_fragment): a flat-sequence model —Stmt::ExprStmt(e) → classify_effect(&e.node, &env)?and_ => return Nonefor 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 explicitStmt::Return(None). - Hole binding:
QuestRef::{OwningQuest, SelfRef, Property(name)}must FULLY resolve.OwningQuestneedsctx.owning_quest(decline ifNone—declines_when_owning_quest_unavailable);Property(name)needs the VMADscript_instanceto carry that property as a form-id (decline if unbound —declines_when_quest_property_unbound);SelfRefon a REFR is declined (quest scripts attach to a quest, not a REFR). Verify each binding failure declines, never defaults to form-id 0. ObjectRefhole binding (2026-07-21, object-targeting effects): unlikeQuestRef,ObjectRefhas no unambiguous bare-receiver case at all — noSelf/GetOwningQuest()equivalent, since the fragment script alwaysextends Questand is never itself theObjectReference/Actorbeing acted on.receiver_object(effects.rs) must: (a) explicitly reject a bareSelfidentifier (does NOT rely on no VMAD property ever being named "self" — verify the explicitkey == "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 viascope.quest_locals/scope.decl_locals, not silently resolve. At dispatch time (fragment.rs),resolve_property_form_idmust decline (not guess) when the VMADPropertyValue::Object'salias != -1— an alias-bound property needs the (unbuilt) quest-alias-fill subsystem, and resolving it from the rawform_idnext to a live alias index would risk a wrong-object application. Thenresolve_objectcomposes that withresolve_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/MoveToconservative-shape declines:AddItem's optional 3rd arg (abSilent) is accepted only as a literal (bool_arg'sNoneon a present-but-non-literal value must decline the whole primitive, mirroringSetObjectiveDisplayed's existing discipline) and a 4th+ arg declines outright;MoveToaccepts 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_gatecross-check: when the condition's quest and theSetStagetarget's quest disagree, the recognizer declines (don't advance the wrong quest). Verifyrecognizes_da10_and_reproduces_hand_builder(.psc-side,quest_stage_gate.rs) andda10_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 againstda10_main_door(...)— together they are the full.psc-vs-.pexfidelity gate for this recognizer (the.psc-side test alone never touchesdecompile_script).rumbleper-script recognizer: matches script namedefaultRumbleOnActivate(case-insensitive) and extracts 5 auto-property float/bool initial values with.pscdefaults; 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 thatUnknowncallers treat it as "no consumer", never as a wildcard match.translate_pexclean-Noneon bad bytes AND on panic:byroredux_pex::parse/decompile_scriptErr→log::debug+return None(never a panic escaping into the cell loader). Guards:translate_pex_on_empty_bytes_is_a_clean_none,translate_pex_on_garbage_bytes_is_a_clean_none,translate_pex_on_truncated_after_magic_is_a_clean_none. Adecompile_scriptpanic is also caught viacatch_unwind(crates/scripting/src/translate/mod.rs, #1816/SCR-D5-NEW-02) and degraded to the sameNone— verify the wrap is still present, not removed by a future refactor (no corpus.pexor 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 14quest_stage_gate.rstests 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, andrecurring_update_tick_systemeach Phase-1 hold aquery_mut::<T>(), collect aVecof entities to act on,drop()the lock, then Phase-2 acquire a differentquery_mutto insert markers. Verify the explicitdrop()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_systemholds three resource locks (QuestStageFragmentsread +QuestStageStatemut +QuestObjectiveStatemut) across its whole dispatch loop. - NEW nested-lock surface (2026-07-21,
AddItem/MoveTo) — verify, don't assume:apply_effectnow ALSO takesworld: &Worldand, for the two object-targeting variants, acquires a component lock (world.query_mut::<Inventory>()forAddItem;world.get::<GlobalTransform>()thenworld.query_mut::<Transform>()forMoveTo) while the three resource locks above are still held (they're bound in the outer scope for the wholewhile 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 acquireInventory/Transformfirst and then try to acquireQuestStageFragments/QuestStageState/QuestObjectiveState— the reverse order — on a path the scheduler could run concurrently with this one; (b) does the engine's scheduler ever runquest_fragment_dispatch_systemconcurrently with anything else that touches these same resources/components (checksys.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 byevent_cleanup_systemexactly once per frame. Verifyevent_cleanup_systemdrains EVERY marker type the runtime emits (cross-check thecleanup.rsdrain list against everyworld.insertof 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.cleanupmust 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) emitsQuestStageAdvanced;quest_fragment_dispatch_systemconsumes it and may re-emit (cascade). Verify the cascade is bounded byMAX_CASCADE = 64with a WARN on overflow (an unboundedSetStage→fragment→SetStageloop hangs the frame) and that only genuine transitions cascade (a no-op re-set of the same stage is skipped — thefragment.rscascade guard). - CTDA OR-precedence (
condition.rs::evaluate): Bethesda's inverted precedence — consecutiveor_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 whileconditions[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 hasor_next == false) and the empty-list →truecontract. 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/HasPerkreturn 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 thatRunOnresolution declines (condition fails) on an unresolvable target rather than defaulting to subject. - Edge-triggered trigger detection (
trigger.rs):trigger_detection_systemfiresOnTriggerEnterEventONLY on the outside→inside transition (inside && !occupant_inside), updatesoccupant_insideeach 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_insideseeded true) and thecontainsmath: Sphere =(p-center).length_squared() <= r*rwithhalf_extents.xas radius; Box (OBB) =rotation.inverse() * (p-center)then per-axislocal.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_stageupdatescurrent_stageAND inserts intostages_done(history retained across advances —GetStageDone(37)stays true after advancing to 40);set_stagereturns the previous current; backward set is allowed;resetclears 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 freshRecurringUpdatedoes 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);UnregisterForUpdateinside 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 hardcodedpapyrus_demo::register_spawnersfor a vanilla REFR (the demos should be test fixtures only —m47-2-design.md§"Engine integration" says the hardcoded registration is retired). Flag a surviving hardcoded-attach call site as a tech-debt / correctness mismatch. Output:/tmp/audit/scripting/dim_6.md
Dimension 7: Engine Attach Path & Trigger-Volume Wiring (engine-side)
Entry points: byroredux/src/cell_loader/references/attach.rs (attach_vmad_scripts,
attach_script_for_refr, trigger_volume_from_primitive, the invisible-trigger
REFR spawn path — split out of references/mod.rs under #1877, which now only
re-exports them and keeps their call sites); crates/plugin/src/esm/records/index.rs
(base_record_script_instance); crates/plugin/src/esm/records/script_instance.rs
(ScriptInstanceData, ScriptInstance); byroredux/src/asset_provider/script.rs
(build_script_provider, extract_pex, the --scripts-bsa parse);
crates/scripting/src/papyrus_demo/quest_advance.rs (quest_advance_system,
QuestAdvanceOnActivate).
Why this dimension: the decompiler + recognizer chain (Dims 1–5) are the
producer of canonical components; the cell-loader attach path is the only live
driver that feeds them real VMAD + .pex from game data. None of the crate
dimensions covers it.
Checklist:
- Silent-miss everywhere (graceful degradation): the attach path must NEVER
panic on missing data — no
--scripts-bsa(early out), VMAD absent (base_record_script_instance→None→ return),.pexnot in archive (extract_pex→None, trace-log, continue), parse/decompile fail (translate_pex→None, debug-log), recognizer miss (trace-log). Verify every branch is acontinue/return false, not anunwrap/expect. Untrusted-Input: Yes (the.pexbytes 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 firstscript_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 bybase_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). .pexresolution (asset_provider.rs):extract_pexnormalizes a VMAD script name →scripts\<name>.pex(backslash, lowercase,scripts\prefix).--scripts-bsais repeatable, first-archive-hit-wins (mod layering). Verify the path normalization matches the on-disk archive convention (a wrong prefix / separator → every.pexmiss → zero scripts attach silently) and that the repeatable-flag layering order is mod-over-vanilla (later--scripts-bsaarchives should override earlier — confirm the iteration order vs. the first-hit-wins semantics actually achieve that).- XPRM →
TriggerVolumehalf-extent convention (trigger_volume_from_primitive): XPRMboundsare Bethesda z-up HALF-extents (CK Primitive convention, consistent withbhkBoxShapehalf-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 REFRscaleis baked in (world-space volume); (d) sphere usesbounds[0]as radius intohalf_extents.x; (e) shape dispatch1 → 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), sotrigger_detection_systemcan test against the post-propagation playerGlobalTransformwithout per-frame composition. quest_advance_systemunifies OnActivate + OnTriggerEnter: bothActivateEvent(doors/levers,activatorfield) andOnTriggerEnterEvent(trigger volumes,triggererfield) converge on oneQuestAdvanceOnActivatecomponent 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 perQuestAdvanceOnActivate(ConditionContext::for_subject+evaluate_condition_list) and theActivatorGate::PlayerOnlyis 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 gatedocs/smoke-tests/m47-triggers.shkeys on theN REFRs recognized, M trigger volumes spawnedline. Verify the counters are wired (recognized++ on atranslate_pexSome, 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
- Read all
/tmp/audit/scripting/dim_*.mdfiles. - Combine into
docs/audits/AUDIT_SCRIPTING_<TODAY>.mdwith structure:- Executive Summary — what shipped (M30.2
.pscparser; M47.0 event hooks; M47.1 condition eval; M47.2.pexreader + 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 + theAddItem/MoveToobject-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 — theProperty-resolution decline on an alias-bound VMAD entry). Findings count by severity. Untrusted-input robustness verdict (can a hostile/corrupt.pexor.pscpanic, 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-.pexfidelity-gate verdict (dorecognizes_da10_and_reproduces_ hand_builderANDda10_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 incontrol_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.
- Executive Summary — what shipped (M30.2
- Remove cross-dimension duplicates: marker-drain coverage is owned by Dim 6
(pointers from Dims 1–5 if they emit markers); the
translate_pexclean-Nonecontract is owned by Dim 5 (pointer from Dim 7); the half-extent convention is owned by Dim 7.
Phase 4: Cleanup
rm -rf /tmp/audit/scripting- Inform user the report is ready.
- Suggest:
/audit-publish docs/audits/AUDIT_SCRIPTING_<TODAY>.md
Next.js App Router Expert
Development
A skill that turns Claude into a Next.js App Router expert.
README Generator
Development
Creates professional and comprehensive README.md files for your projects.
API Documentation Writer
Development
Generates comprehensive API documentation in OpenAPI/Swagger format.