Our review
This skill performs a deep audit of a game's scripting subsystem, including the decompiler, parser, runtime, and engine integration.
Strengths
- Covers multiple crates and the full Papyrus pipeline chain.
- Targets high-bug-density areas such as the decompiler.
- Leverages prior audit passes to avoid redundancy.
- Uses an orchestrator to run dimensions concurrently.
Limitations
- Requires deep familiarity with the codebase.
- Can be time-consuming when auditing all dimensions.
- Results depend on the auditor's expertise.
Use to verify correctness of the Papyrus scripting pipeline after changes or before a release.
Not suitable for quick checks or when automated testing suffices.
Security analysis
SafeThe skill only contains instructions for auditing a codebase; it does not instruct the AI to execute any potentially harmful operations. It is purely informational and advisory.
No concerns found
Examples
Audit the scripting subsystem with focus on the pex decompiler dimension and depth deep.Run a full deep audit of the scripting subsystem covering all dimensions (pex, papyrus, scripting, engine wiring).Audit the scripting runtime (events, timers, conditions) with depth shallow, focusing only on correctness invariants.description: "Deep audit of the M30/M47 scripting domain — .pex decompiler (Champollion port), .psc Papyrus parser, AST→ECS recognizer chain, ECS scripting runtime, and the cell-loader attach path" argument-hint: "--focus <dimensions> --depth shallow|deep"
Scripting Subsystem Audit (M30 / M47.0 / M47.1 / M47.2)
Audit the three scripting crates plus their engine-side wiring for
correctness across the full compiled-Papyrus pipeline: untrusted .pex
bytecode decode (crates/pex/), the 5-phase decompiler that lifts that
bytecode back to the shared Papyrus AST, the .psc source parser
(crates/papyrus/), the AST→ECS recognizer chain whose load-bearing
invariant is decline-on-any-unmodeled-term (crates/scripting/src/translate/),
the ECS scripting runtime (events / timers / conditions / triggers / quest
stages), and the cell-loader REFR-attach path that resolves a scripted REFR's
VMAD-named .pex and runs the recognizer chain.
This domain (~16k LOC) has six prior audit passes in docs/audits/AUDIT_SCRIPTING_*.md
— read the most recent one first (Phase 1 below). The
decompiler is the highest bug-density area: it parses untrusted bytecode
and runs five tree-rewriting passes, so dimensions are weighted toward it
(three of seven). Its correctness story rests on a corpus-decompile
smoke harness and the .psc-vs-.pex fidelity gate — point findings there,
not at speculation.
Architecture: Orchestrator. Each dimension runs as a Task agent (max 3 concurrent).
See .claude/commands/_audit-common.md for project layout, methodology,
deduplication, context rules, and finding format. See
.claude/commands/_audit-severity.md for the severity scale. Do NOT duplicate
those here.
Scope
Crates (22-crate sanity check in _audit-common.md; pex is the newest owned by this audit):
crates/pex/src/—.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
TDD Red-Green-Refactor
Testing
Skill that guides Claude through the complete TDD cycle.
Web Accessibility Audit
Testing
Performs a comprehensive web accessibility audit following WCAG standards.
UAT Test Case Generator
Testing
Generates structured and comprehensive user acceptance test cases.