"Deep audit of the M30/M47 scripting runtime — AST→ECS recognizer chain (decline-on-unmodeled), fragment/quest-stage dispatch, ECS event/timer/condition/trigger systems, cell-loader script attach, scene/package/cinematic playback, legacy ObScript quest execution (M47.3), extender-compat provider layer. Compiler frontends (.pex decompiler, .psc parser) are /audit-papyrus"
Instal·la
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-scripting Instal·la des del registre de SkillsCat.
Scripting Runtime Audit (M30 / M47.0–M47.3)
Read .claude/commands/_audit-common.md and .claude/commands/_audit-severity.md for shared protocol.
Audit crates/scripting/ and its engine-side wiring: the AST→ECS recognizer chain whose load-bearing
invariant is decline-on-any-unmodeled-term, fragment / quest-stage dispatch, the ECS event runtime, the
cell-loader attach path, scene/package/cinematic playback, and the two legacy/compat execution layers
(compiled ObScript, Papyrus provider calls). A partial or approximate lowering is worse than none: an
inert unrecognized script is safe; a wrongly-lowered one corrupts game state with no fallback (the scripting
analogue of NIFAL's no-fabrication rule).
Split: the .pex reader/decompiler (crates/pex) and the .psc lexer/parser (crates/papyrus) are/audit-papyrus (untrusted-input bounds, decompiler soundness, depth caps). crates/hkx parsing →/audit-parsers (this skill keeps only cinematic playback of decoded clips, Dim 5). crates/sdk →/audit-tooling (this skill keeps the provider/extension seam, Dim 7). AI-package procedure runtimes andsystems/combat_ai.rs → /audit-gameplay; scheduler/lock shape in general → /audit-ecs,/audit-concurrency.
Architecture: Orchestrator; each dimension is a Task agent (max 3 concurrent).
Scope
crates/scripting/src/ (~51k LOC): translate/ (mod, source, archetype, compose, effects, tables,recognizers/{quest_stage_gate,rumble,two_state_activator}); fragment.rs + fragment/{effects,populate, state,systems}.rs; quest_stages.rs, globals.rs, vm_state.rs, events.rs, cleanup.rs, timer.rs,recurring_update.rs, condition.rs, trigger.rs, player_control.rs, equipment.rs, registry.rs;scene.rs + scene/{playback,quest_alias}.rs, package.rs, dialogue.rs, cinematic.rs, combat.rs
(FactionRelations, AiCombatState), magic.rs (SpellCatalog/SpellList/RaceSpells, #4415), story_manager.rs (#5366 SM dispatch: SmTree, StoryEvent, story_manager_dispatch_system), load_order.rs
(LoadOrderIdentity, slot↔PluginId bridge); obscript{,_runtime,_vm,_quests}.rs; papyrus_provider/,compatibility.rs; papyrus_demo/ (reference scripts/test fixtures; ScriptRegistry is the pre-SkyrimSCRI→SCPT extension point — boot no longer seeds it). Engine side: byroredux/src/cell_loader/references/ {attach,synth_child}.rs, cell_loader/{spawn,exterior,unload}.rs, asset_provider/script.rs,systems/{cinematic,npc_dialogue,forcegreet,story_events}.rs, commands/quest.rs (sm.event, dialogue.forcegreet), boot/schedule/, plus crates/plugin/src/esm/records/{index, script_instance}.rs (VMAD retention; decode is /audit-esm).
Ground truth: docs/engine/scripting.md, m47-0-design.md, m47-2-design.md (recognizer + "no opcode
semantics guessed"), m47-2-recognizer-scaling.md (corpus: handler vs fragment populations, decline-the-tail),m47-3-quest-alias-design.md ("Remaining subsystem boundary" lists the bounded alias follow-ups: Created
Object fills, true LCTN, reference collections, unloaded-world search, injected
packages/spells/keywords overlays — real gaps, don't re-file as discoveries), crate docstrings
(translate/mod.rs, fragment.rs, cleanup.rs marker house rules).
Known-open — cite, don't re-file (verified 2026-10-12 with gh issue view): none owned here. #4113/#4115
are open but live in crates/papyrus/crates/pex (/audit-papyrus). Closed: #3817 (cinematic retention reversible, 63bf3347f), #4415 (magic runtime incl. the racial pair, 9813af435), #5366 (Story Manager, Phases 0-4 all shipped), #5367 (dialogue
trees: greeting/line lifetime/FO3-FNV force-greet 14cff35ae, Skyrim force-greet 287214103) and its #5397
Random-stack follow-up (0436bea5c), #4334 (once-only persistence, ReferenceScriptState), #4190, #4116.
Not gaps: the M47.1 condition resolvers and fragment lowerer are implemented and live-verified; MoveTo
default-materialized shapes lower (#3487); the QUST VMAD property table is wired.
Instruments: crates/scripting/examples/{fragment_coverage,mq101_conformance,extender_preflight, quest_fragment_populate,dump_stage_fragment_effects}.rs (need game data; fragment_coverage counts fragments throughEffect::is_placeholder primitives separately), docs/smoke-tests/m47-triggers.sh (spawn+attach gate on
Skyrim data via --scripts-bsa), tests/pex_recognize_e2e.rs (#[ignore]d, needs Skyrim SE data).
Parameters / Extra Fields / Severity
--focus default all 7 · --depth shallow (API contracts + decline/bounds invariants) | deep (per-frame
ECS lifecycle, lock traces). Finding fields: Dimension: Recognizer Chain | Fragment Dispatch & Locks |
Event Runtime | Attach & Reference Identity | Scene/Package/Cinematic | Legacy ObScript | Provider/Extender Layer ·
Untrusted-Input: Yes | No (Yes on any path decoding mod-supplied bytes — .pex derivatives, SCDA,
provider manifests).
Escalations over _audit-severity.md: recognizer/effect emits on an unmodeled term instead of declining,
or a wrong AST is accepted → HIGH; ECS lock held across a second resource/component mutation (deadlock) →
HIGH; transient marker not drained / drained out of stage order (re-fires every frame, or a frame late) →
HIGH; panic/OOB/unbounded alloc or recursion on mod-supplied bytes → HIGH; feature-matrix.md/comment rot → LOW.
Phase 1: Setup
mkdir -p /tmp/audit/scripting; dedup gh issue list --repo matiaszanolli/ByroRedux --limit 300 --json number,title,state,labels > /tmp/audit/scripting/issues.json;
read the newest docs/audits/AUDIT_SCRIPTING_*.md (older reports use the pre-split 8-dimension numbering:
old 5→Dim 1/2, old 6→Dims 2/3, old 7→Dim 4, old 8→Dim 5) and diff against it; read translate/mod.rs,fragment.rs, cleanup.rs docstrings and m47-2-design.md §"Risks & mitigations" to separate designed
declines from defects. Delta-first: git log --since=<last report> --format='%h %cs %s' -- crates/scripting byroredux/src/cell_loader/references byroredux/src/asset_provider/script.rs byroredux/src/systems/cinematic.rs byroredux/src/systems/npc_dialogue.rs byroredux/src/systems/forcegreet.rs byroredux/src/systems/story_events.rs.
Run cargo test -p byroredux-scripting and cargo test -p byroredux --bin byroredux boot::schedule.
Phase 2: Dimensions
Dimension 1: Recognizer Chain & Effect Lowering (decline-on-unmodeled — highest yield)
Paths: crates/scripting/src/translate/**, crates/scripting/src/papyrus_provider/{catalog,lower_call,lower_program}.rs (seam)
First step: cargo test -p byroredux-scripting -- translate::; git log --since=<last report> -- crates/scripting/src/translate/effects.rs
Guard (E2): every_effect_primitive_bounds_its_argument_count (effects.rs — every prim_* bounds args,
so an over-arity call from a modded .pex declines) and unrecognized_script_is_a_silent_miss; aim at what
they cannot see:
- Decline enforcement:
classify_guard_atom(atom, ..)?propagatesNoneon any atom noGUARD_PRIMITIVES
entry claims (noif let Somethat drops aNone);split_anddeliberately does not split||, so a
disjunction declines rather than lowering half. Flat-sequence effect model:Stmt::ExprStmt → classify_effect(..)?; locals bind only viabind_local(quest / object / player / plain, else decline).
Exactly two control-flow exceptions:Stmt::Whileonly vialower_3d_loaded_wait(OR-tree of!<actor>.Is3DLoaded()+ exactly one positiveUtility.Wait), andStmt::Ifonly asEffect::Conditional
(noelseif; every atom an exactGetStageDone == 0.0|1.0; branches lowered against clonedScopes,
and a branch that re-binds a local declared before theIfdeclines —Scope::rebound_by, #5632ed6998c51;
noWait/WaitForActors3DLoadedinside —has_latent). BareReturnlowers only as the body's final
statement at depth 0 (#5631), and an assignment to a bare identifier that is no declared local (a script
variable / property write) declines (#5638,rebinding_a_pre_branch_local_inside_a_branch_declines,assignment_to_a_script_variable_declines). Widening any of these is a decline-invariant
regression.lower_statementsrecursion is capped (MAX_CONDITIONAL_DEPTH=MAX_STMT_DEPTH, the smaller
upstream cap). - Lower only what is modeled, name it from the declared signature. For each primitive compare against the
engine class's real Papyrus signature (decompile the vanilla.pex— e.g.faction.pex,game.pex):
parameters must carry their declared names (#4322SetInChargenflags were invented; #4318SetEnemyloweredabSelfIsNeutralToOther/abOtherIsNeutralToSelfthen discarded them, so "mutually
neutral" was recorded as hostility). A primitive that lowers an argument and drops it, or accepts a
non-literal for a default-only parameter, is a finding; the pattern is "accept only the literal-default form,
decline the rest" (MoveTo: ≤MOVE_TO_MAX_ARGS=6 with offsets0.0, rotation flags at defaults;AddItem: literalabSilent). Post-2026-09-19 primitives to hold to this:StopCombat,AddSpell(accepts
and drops a literalabVerbose),RemoveSpell(#4414/#4415),AddRaceSpells/RemoveRaceSpells(unqualified argless call onto the player; apply re-applies/clears exactly the stampedRaceSpellsset, a scriptedAddSpellsurvives the clear,9813af435; the player's own stamp isattach_to_player's, #5413212a71dba— before it both were silent no-ops on the only actor they target),SetUnconscious(prim_set_unconscious,[abUnconscious = true], player receiver declines; #50173c08cfe50). Also held to it since 2026-10-10:Locktakes the compiled two-parameter shape (abLock, literalabAsOwnerparsed and not applied; a
non-literal or third argument declines, #56532ce808553),SetObjectiveDisplayedparsesabForcethroughbool_arg(#56529777c41a2),Enable/DisablePlayerControlsfill absent arguments from their own
default tables (ENABLE_PLAYER_CONTROLS_DEFAULTvsDISABLE_PLAYER_CONTROLS_DEFAULT, #56515e2fc3c8e), andSetMotionTypeinteger literals lower only where every game agrees (just1= Dynamic; Skyrim/FO4 constants
differ, the namedMotion_*property read still lowers; #563392a1782fe,literal_motion_types_lower_only_where_every_game_agrees). - Receiver/hole binding never defaults to form-id 0:
QuestRef::{OwningQuest, SelfRef, Property}must
fully resolve (OwningQuestneedsctx.owning_questand lowers only fromSelf.GetOwningQuest()— an
alias property'sGetOwningQuest()names another quest, #5634221befd61;SelfRefon a REFR declines);ObjectRefhas no
bare-receiver case (receiver_objectrejectsself; object locals resolve throughscope.object_locals, alias-bound viaSceneActorBindings); a receiver the runtime would drive must
not be the player (StartCombat, #4323).PlayerRef(0x14) resolves inside the sharedresolve_entity_by_global_form_id(#4694) — a local 0x14 special-case elsewhere is drift.Disable/Enableclassify the receiver through the same
alias-awarereceiver_objectas their siblings (an alias-bound marker must not decline alone). - Placeholders are coverage with a hole:
Effect::is_placeholder(ShowRaceMenu,RequestSave,SetHudCartMode— state nothing reads) must be reported separately by coverage harnesses (#4328/#4372);
any new effect whose runtime arm only records a value needs listing there and a pin
(only_the_counter_only_primitives_are_placeholders). - Chain order:
RECOGNIZERS= per-script first (two_state_activator,rumble), generic second
(quest_stage_gate); first match wins, all-None= silent miss; a new generic recognizer must not shadow a
per-script one.quest_stage_gatedeclines when the condition quest and theSetStagetarget disagree, and arms only the
handler in effect in the script's boot state (theAuto State's handler, else script scope; handlers
only in non-auto states are ignored — #5639e1b7e31e7,advance_handler_must_be_in_the_boot_state).rumbleextracts literal property values only. Fidelity gate:recognizes_da10_and_reproduces_hand_builder
(.pscside) andda10_pex_reproduces_hand_builder_byte_for_byte(.pexside,#[ignore]d, Skyrim SE
data) — the.pschalf alone never touches the decompiler; run both. - Provider catalog is consulted after the canonical table (
classify_effect_with_providers: primitives
first, #3934 — an installed alias must never change how vanilla lowers); lowering must only accept
provider barriers the dispatcher can serve (servable_catalog()gates oncallback.is_some();every_lowering_seam_reads_the_servable_cataloginasset_provider/script.rs). translate_pexclean-Noneon bad bytes and on panic:catching_panicswraps the whole
parse→decompile→analyze→lower sequence (a_decompile_panic_is_a_silent_none); it cannot recover OOM/stack
overflow (those need the bounds/audit-papyruschecks).CanonicalEvent::from_papyrusis
case-insensitive;Unknownmeans "no consumer", never a wildcard.
Output:/tmp/audit/scripting/dim_1.md
Dimension 2: Fragment Dispatch, Quest Stages & Lock Nesting
Paths: crates/scripting/src/{fragment.rs,fragment/**,quest_stages.rs,globals.rs}, byroredux/src/boot/schedule/
First step: cargo test -p byroredux-scripting -- fragment:: quest_stages::; cargo test -p byroredux --bin byroredux boot::schedule
Guards: boot/schedule/mod.rs — scene_and_fragment_dispatch_chain_stays_in_dependency_order,fragment_activation_order_tests, every_parallel_system_declares_everything_it_acquires,papyrus_provider_system_…/legacy_obscript_load_order_system_declares_everything_it_acquires,build_scheduler_reports_zero_access_conflicts; and fragment/tests.rs::{nested_lock_contract_documents_ apply_effect_itself, the_nested_lock_residual_list_names_every_type_apply_effect_acquires} (source scans overfragment::SOURCES: apply_effect's doc block must name every type its per-family helpers acquire).
- Nested-lock safety is exclusive scheduling.
apply_effect(now delegating toapply_{global,inventory,placement,scene,lock,player_control,vehicle_cinematic,ai_combat}_effect+apply_quest_scoped_effect, #4340) runs under oneresource_2_mut::<QuestStageState, QuestObjectiveState>()
taken inapply_fragment_guard_free;QuestStageFragmentsis cloned before any quest lock (no read→write
nesting). Safe only because every quest-resource system isadd_exclusive— movingquest_fragment_dispatch_system,scene_fragment_dispatch_system, or any sibling to the parallel lane, or
adding a nested component/resource lock, needs the ABBA analysis re-derived (apply_effect's doc block is
the running inventory — re-read it, do not trust a count). Reverse-order acquirers (Inventory/Transform/Globalsfirst, quest resources second) on any path are the finding. apply_effectsis an explicit cursor stack (#3935) — no per-Conditionalrecursion or program clone; an
unresolvable guardQuestRefdeclines the wholeConditional(neither branch; #3785,apply_effects_declines_conditional_with_unresolvable_guard), with awarn!.- Cascade:
MAX_CASCADE = 64bounds only fragment-emitted (is_cascade)SetStages, not authored
ingress; FIFOVecDeque; overflow warns; a no-op re-set (previous == new) does not cascade. - Objective transitions (#5153):
QuestObjectiveStatemutators push a boundedQuestObjectiveEventonly on
false→true flips (objective_mutators_emit_transitions_only); the queue is serde-skipped presentation
(drained by the app frame into announcements) — a repeat/un-flip that emits is the regression. - Quest journal:
QuestStageStatekeeps a sequenced event journal (QUEST_EVENT_RETENTION= 16 384) with
three fixed subscribers (SCENE_QUEST_EVENT_SUBSCRIBER,FRAGMENT_QUEST_EVENT_SUBSCRIBER,TERMINAL_QUEST_EVENT_SUBSCRIBER), each polling its own cursor
(missed_events > 0⇒ the subscriber must resync from canonical state).set_stagekeepsstages_done
history (GetStageDone(37)stays true after 40). The journal is authoritative;QuestStageAdvancedBatchis a
compatibility mirror written throughpush_quest_stage_advances(oneSparseSetStorageslot: a bareinsertdrops same-frame producers, #3277). A consumer that reads both must dedup the mirror as a multiset
(asquest_fragment_dispatch_systemdoes — otherwise a double advance) and must not claim (poll) the
journal while it has nothing to consume (#3012); a new consumer picks a subscriber constant, not a private cursor. - Populate:
populate_quest_fragments_from_pexmerges all of a stage'sFragment_Neffect chains in VMAD
order and replaces the installed chain (re-population must not duplicate);QuestStageFragments::vmad
is registered soProperty-targeted effects resolve — a decline with a populated VMAD is a regression.SceneFragmentsis one binding per(scene, event). - Lock & enable ledgers (
ReferenceLockState,ReferenceEnableState, FormID-keyed, saved): a scriptedSetLocked/SetLockLevelrecords the component's full outcome (level + key), never a partial delta that
outranks the authored XLOC (#4329); alias-bound receivers come back to a FormID viaentity_global_form_id;
non-resident references still record where the ledger can hold them (#4330;Enable/Disabledo). Third
siblingReferenceScriptState(#4334, save v26): fire-once triggers park their script there and the
recognizer's spawn closure consults it so a reload does not re-arm. A "simplification" that keys any of the
three ledgers by entity is the regression. Effect::StartCombatalso removes the actor'sAmbientEngagement(moved intocrates/scripting/src/combat.rs
besideAiCombatState) in its own guard scope, so a scripted fight is never auto-dropped by the ambient
disengage (#5046fef67f3c3);Effect::SetUnconsciouswritesActorControlState(mutually exclusive with
restrained) and ends the actor's combat — both acquisitions belong inapply_effect's lock inventory.Effect::AddSpell/RemoveSpellapply throughmagic::{add_spell,remove_spell}(SpellList+ permanentActorValuesmodifier, add/undo symmetric —add_and_remove_spell_apply_and_undo_permanent_modifiers); both
types are nested under the quest lock, so they belong inapply_effect's lock inventory.Effect::SetGlobalValuewritesGlobals(saved,resolve_property_form_id— a GLOB is never alias-bound).QuestAliasReadinessGate: advances only whenis_running,stage < only_below_stage, and!get_stage_done(target)(idempotent); one gate per quest (upsert).
Output:/tmp/audit/scripting/dim_2.md
Dimension 3: Event Runtime — Markers, Timers, Conditions, Triggers
Paths: crates/scripting/src/{events,cleanup,timer,recurring_update,condition,trigger,vm_state,player_control}.rs, crates/scripting/src/papyrus_demo/quest_advance.rs
First step: cargo test -p byroredux-scripting -- cleanup:: condition:: trigger:: recurring_update:: quest_advance
Guards: cleanup.rs::every_drained_marker_is_a_documented_pattern_a_marker, cleanup_removes_all_event_types;boot/schedule/mod.rs::{transient_cleanup_is_the_last_late_exclusive, activation_flush_is_scheduled_before_every_activate_event_consumer, extension_event_adapters_run_before_transient_cleanup}.
- Marker lifetime — two sanctioned patterns (module doc of
cleanup.rsis authoritative): A = listed inevent_cleanup_systemfor markers with no single owner (last system inStage::Late); B = drained
unconditionally at the head of the one owning consumer (no early return before the drain, or the marker
strands and re-fires forever). A marker in neither is the bug — cross-check everyworld.insertof a
marker type against the drain lists, and check each Pattern-B owner's first statements. Multi-producer
markers must merge, not overwrite (SparseSetStorage= one slot per entity):QuestStageAdvancedBatch
(push_quest_stage_advances),HitEvent(two producers; same-frame overwrite fixed in #4324 — re-verify
after any new producer),ActivateEvent(Update-stage consumer order pinned by the flush test, which now
listscontainer_loot_systemandmg07_on_activate_dispatch(#4712/#4116); the Late-stagenpc_dialogueselection system is a further consumer — it must stay beforeevent_cleanup_system). - Two-phase lock discipline:
timer_tick_system,trigger_detection_system,recurring_update_tick_systemcollect under onequery_mut,drop(), then acquire the marker-insertquery_mut; two component-mut locks at once forces the TypeId-sorted contract (deadlock vector). - CTDA OR-precedence (
condition.rs::evaluate): consecutiveor_nextconditions form a block that binds
tighter than the AND chain (A AND B OR C AND D=A AND (B OR C) AND D); empty list →true; guardsor_precedence_quirk_*,and_chain_short_circuits_on_first_false. Function catalog per the module-doc
table (indices verified against TES5Edit;CATALOGis 24 since #5380395d2d831added 56GetQuestRunning,
74GetGlobalValue, 77GetRandomPercent— the last ridesStoryManagerRng, a documented 50.0 with none
installed; the module-doc table and a row-count pin track the catalog, #56482ed7f8ec3.GetQuestRunning
readsis_runningalone — a stopped/completed/failed quest is not running, #5636aac92c6ca; the plugin-sideparam1_is_form_idload-order remap mirrors the catalog by index, with rows for 56/71/74 and an exhaustive
mirror test, #5637722c06a76; M47.2 grewCATALOGto 34 with 629GetVMQuestVariableand the location
family 181/359/360/562/563/565/566/567/605 — those readConditionContext(owning quest's aliases, a
location-alias fill'slocation_subject) throughevaluate_contextual(reads_context), andparam2_is_form_idremaps 605/181's second parameter; 180/610 stay unmapped (Oblivion/FNV collisions); fn 0 is deliberately unmapped (xEdit:GetWantBlockingon TES5/FO4, no index 0 on
TES4/FO3/FNV — the short-livedGetButtonPressedmapping was removed by #5431;GetButtonPressedexists
only as an ObScript script function inobscript_quests));GetIsIDcompares the Run-On's base object
(SceneAliasCandidate::base_form_id), not the placed ref (#50411816bbc14,get_is_id_matches_run_on_base_object_not_placed_reference);GetActorValuereads throughCharacterRuleset::actor_value(the shared derived+modifier composer, #5042 —/audit-characterDim 4); unknown functions return the documented safe default — a
display/selection default only: a consumer that acts on a pass must decline on anyUnknownterm first
(Story Manager nodes, #5380; the ambient force-greet install'spackage_conditions_fully_modeled, #5376), and
a new intrusive consumer that trusts the fail-open pass is the finding;RunOn
resolution declines (condition fails) on an unresolvable target rather than defaulting to the subject; a
wrong sentinel flips a condition.crates/plugin/src/consumables.rsborrows fn-586 semantics by profile
(/audit-characterDim 1). - Triggers (
trigger_detection_system): edge-triggered (outside→inside);occupant_inside: Option<bool>
withNone= never checked (first tick never fires for the player — a player loaded inside a volume must
not fire);containsmath (Spherehalf_extents.xradius; OBB viarotation.inverse());OnTriggerEnterEvent.triggerers: Vec<EntityId>— no consumer may read[0]only
(preserves_all_actors_entering_one_volume_in_the_same_frame); actors tracked inTriggerOccupancyState
keyed(trigger, actor)and pruned each frame (retain(observed)); only tethered horses useintersects_sphere(TETHERED_HORSE_TRIGGER_RADIUS);became_ready_insideis a level condition that stops
once the target stage is done.actor_quest_trigger_is_in_sequence(gate) andscene_trigger_actor_approach_system_inner(router, Dim 5) answer "which BaseForm trigger stage is next"
separately — verify they agree via the sharedbase_form_advance_is_eligible/scene_phase_awaited_stage
(#4333) on cross-quest phase waits and centerless triggers. quest_advance_systemunifiesActivateEvent+OnTriggerEnterEventonQuestAdvanceOnActivatewith a
three-wayActivatorGate(Any/PlayerOnly/BaseForm), evaluated per(entity, triggerer)pair;
nothing may deliver both signals to one entity in one frame.disable_reference_after_advance(fromdisableWhenDone) records intoReferenceEnableState; both fire-once variants park inReferenceScriptState
(fire_once_advances_park_the_reference_script_state, #4334).recurring_update_tick_system: no fire on the registering frame/zero dt; once per interval; overshoot fires
once;UnregisterForUpdatein a handler terminates cleanly.QuestAliasReadinessGatetiming: afterquest_alias_refresh_system, beforescene_playback_system.
Output:/tmp/audit/scripting/dim_3.md
Dimension 4: Engine Attach Path & Reference Identity
Paths: byroredux/src/cell_loader/references/{attach,synth_child,mod}.rs, byroredux/src/cell_loader/{spawn,exterior,unload}.rs, byroredux/src/asset_provider/script.rs, crates/plugin/src/esm/records/index.rs
First step: cargo test -p byroredux --bin byroredux -- cell_loader::references reference_enable_gate; grep -rn 'spawn_logical_quest_reference' byroredux/src
Untrusted-Input: Yes — .pex bytes come from possibly-modded archives.
- Silent-miss everywhere: no
--scripts-bsa, VMAD absent,.pexmissing (extract_pex→None), decode/
decompile failure, recognizer miss — every branch iscontinue/return, neverunwrap/expect.--scripts-bsais repeatable, first-listed-archive-hit wins (the inverse of mod-manager priority:
list overrides first);pex_archive_pathnormalizes toscripts\<lowercase>.pex(a wrong prefix/separator =
every.pexmisses, zero attach, no error). - Script sources:
base_record_script_instance(VMAD on ACTI/CONT/NPC/CREA, item family, statics family
incl. STAT/MSTT/FURN/DOOR/LIGH/…, TERM; pins per family) and legacybase_record_script(SCRI incl. the
statics family); REFR's own VMAD is merged bydedup_vmad_scripts(names differing only by case collapse
to one attach). Verifying coverage against only the first few accessor arms re-derives a closed gap. - Every identity-only actor/reference stub must attach scripts. All
spawn_logical_quest_reference
callers outsidesynth_child.rs— the persistent-worldspace loop incell_loader/exterior.rsandmaterialize_scene_actor_alias_stubsinasset_provider/script.rs— go throughspawn_logical_quest_reference_with_scripts(attaches once perreference_form_id, #4112/#4331; a source
pin rejects the bare spelling). Residual (documented in the helper): a stub followed by the home cell
streaming in leaves scripts on both — stub/real reconciliation is unbuilt and untracked (the helper doc calls it
#2664's half, but #2664 closed on the stamping/Transformhalf; no open issue owns it). New spawn paths
that call the bare spawner are the recurrence. - Canonical identity stamping (
stamp_quest_reference:FormIdComponent+SceneAliasCandidate+mark_scene_actor_bindings_dirty) is applied at every synthetic REFR path, gated on the primary synth child
(synth_idx == 0) so a SCOL/PKIN fan-out registers one candidate per authored alias; the no-mesh fallback
still spawns aTransform/GlobalTransformentity. - Enable/lock gates consult the FormID ledgers before content is built:
reference_is_disabledis asked
once per REFR ahead of every branch inload_references_budgeted(actor jobs, LIGH-only/fxlight placements,
invisible triggers — #4326/#4327),placement_is_disabledinspawn_placed_instancesafter the root but
before mesh/collider/light,scripted_lock_overrideat theLockedstamp. A disabled actor/trigger keeps
identity + scripts but no body/volume. Regression = a spawn path that never reaches a gate, or the gate
moved render-side (AnimatedVisibilityis honored in static but not skinned rendering). Guard:cell_loader/reference_enable_gate_tests.rs. - XPRM →
TriggerVolume(trigger_volume_from_primitive): bounds are z-up half-extents (do not
divide by 2), permute[x, z, y]with.abs(), REFR scale baked into a world-space volume, sphere radius inhalf_extents.x, shape1 → Box,3 → Sphere, elseNone
(trigger_volume_from_{box,sphere}_primitive_*); invisible (MODL-less) trigger REFRs compose world space once. - The
M47.2 scripts:cell-load summary counters (recognized,trigger volumes) must be wired orm47-triggers.shis vacuous; exterior stub loops report attach counts.commands/quest.rs
(thequest.*andscene.*console commands, incl.quest.effects) stays read-only over&Worldexcept the explicit start/stage
commands.
Output:/tmp/audit/scripting/dim_4.md
Dimension 5: Scene / Package / Dialogue / Cinematic Playback
Paths: crates/scripting/src/{scene.rs,scene/**,package.rs,dialogue.rs,cinematic.rs,story_manager.rs}, byroredux/src/systems/{cinematic,npc_dialogue,forcegreet,story_events}.rs, byroredux/src/cell_loader/unload.rs, byroredux/src/asset_provider/animation.rs
First step: cargo test -p byroredux-scripting -- scene:: package:: dialogue:: cinematic:: story_manager::; cargo test -p byroredux --bin byroredux -- cinematic strip_ active_tether npc_dialogue forcegreet story_dispatch_and_forcegreet
The M47.2 MQ101 cart sequence is the first scripted sequence that drives animation, not just ECS state.
- Scene alias substrate:
SceneActorBindings(quest, alias) → EntityIdis whatresolve_object,RunOn::QuestAlias, andSceneShowCommandall resolve through (one entry point — a debug view must not use
a different path); an unfilled alias returnsNone, never fabricates an entity;apply_alias_injections/QuestAliasInjectionState(permanent inventory-grant ledger, saved) must not
double-grant after load. Refresh pre-filters must be exact supersets:refresh_scene_actor_bindings
visits per-fillCandidateIndexbuckets and skips/filters condition-only fills viasubject_requirement
(ConditionFunction::reads_run_on_entityandis_deterministicmust stay exhaustive — a stochastic
function likeGetRandomPercentis never folded into a once-for-everyone value, #5645;run_on_identity
is shared with the evaluator) — a bucket or requirement that excludes a candidate the full fill test orevaluatewould admit silently
unbinds an alias (subject_requirement_never_excludes_a_passing_subject,condition_only_identity_fill_binds_what_the_full_scan_would). - NPC dialogue selection (
systems/npc_dialogue.rs, Late exclusive): consumes the player'sActivateEvent
on an NPC thatrunning_quests_binding_entitynames (owned DIALs viaDialRecord::quest_refs— QSTI and,
for Skyrim, QNAM). Candidates are Player-category topics with an INFO that passesselect_first_infofor
this NPC (actor subject, player target); a qualifying Blocking DLBR entry opens the conversation and its
INFO's TCLT links replace the list; otherwise the list is the Top-Level starting topics, and mid-branch
children are reached only through links (#5037/#50457ab87c0fb; FO3/FNV branch-less topics without the DIAL
Top-level flag are link-only, #5224305363122). It stampsNpcDialogueTopicon exactly one NPC (the
surface keys onDialogueSurfaceState::npc, #5038); both entry points gate onplayer_can_actand the sharednpc_refuses_dialogue(dead /AiCombatState/ unconscious), also applied by the interaction Talk arm
(#5043,1d31ced2b). UI re-selection goes throughselect_topic_by_form_idand must re-check live
ownership. Spoken-line fragments (#5152,c59600138):DialogueInfoFragments(TIF_ INFO VMAD, rebuilt
from plugin data, unsaved) dispatches the line's OnBegin at selection and the outgoing line's OnEnd on
selection change / close viaapply_spoken_info_fragment— the same guard-free executor + journal polling
as the quest/scene dispatchers (the_spoken_lines_fragments_advance_the_stage); every native leave path
(Close, Escape, the Inventory key) funnels throughApp::leave_dialogue_page→end_open_conversation, so
the OnEnd dispatches on all of them (#540914b0d33f2). Greetings and line
lifetime (#536714cff35ae):open_conversationfalls back to the master's generic greeting topic
(GREETINGOblivion/FO3/FNV,DialogueGenericHelloSkyrim — resolved once per load order ontoEsmIndex::generic_greeting_form, the map scan kept only as the hand-built-index fallback, #5426411d0eeaf) when no owned quest topic qualifies;select_info
collects every passing candidate — the first wins, and a Random first INFO rolls within its positional
Random stack (ended by the next passing non-Random INFO or a Random End, #53970436bea5c) through the seededDialogueRandomState; position is the merged INFO chain (override INFOs fold by FormID after their PNAM
predecessor, #5375168b81322— the merge itself is/audit-esm). Spoken Say-Once INFOs are disqualified
per actor through the savedDialogueSpokenInfoFormsledger —(speaker reference FormID, INFO)pairs since
FORMAT_MAJOR 38 (Skyrim ENAM/legacy-DATA flags now decode; #5300f18224b02; re-selecting the same INFO ends
the outgoing line first); a spoken Goodbye closes on
the presentation estimate. FO3/FNV
INFOs honour their own owning quest (QSTI, #5271dbc07e8f0). Force-greet
(systems/forcegreet.rs,ForceGreetDirectivebridge): FO3/FNV Dialogue procedure (PKDDtopic onPackRecord::dialogue_topic) and the SkyrimForceGreetprocedure-tree leaf (#5367287214103; a concrete package's leaf and topic are
sought on its own tree first, then on its PKCU template — 334 of 339 Skyrim.esm force-greet packs are template
instances, #551172e863d80) walk the NPC to the player and open the topic through the same selection path
without activation. The walk goes throughnavmesh_path::cached_walk_stepand honours KCC-blocked: a mover
held against an obstacle forLOCOMOTION_STUCK_REPICK_SECSgives up the approach and opens in place (#54526316719ed), and the step is gated on the sharedai_locomotion_enabled()kill switch (the in-radius open
stays live and the directive stays pending, #5430bd366e300). Two installers: thedialogue.forcegreetconsole door (any Dialogue package, unconditionally — the drivable one, smokedt1/dt2)
and FO3/FNV ambient package selection, which installs only from a fully-modeled, player-targeted (PTDT 0x14 or
none) Conversation (PackRecord::dialogue_type0) — #53764e58f241d,AmbientBehavior::from_packageinbyroredux/src/npc_spawn/ai_package.rs(/audit-gameplay), pinned bydialogue_ambient_install_declines_unmodeled_offtarget_sayto;
the Skyrim tree dialect has no ambient installer. A failed open consumes the directive (no per-frame retry);forcegreet_systemholds every directive whileplayer_can_actis false and holds one while the player talks
to another NPC (consumed if already talking to the greeter) — #539216544d4c9,dead_player_holds_the_forcegreet_directive,open_conversation_holds_or_consumes_the_forcegreet_directive.
Walk steps writeTransform(notGlobalTransform) andPhysicsWorldis held for the step alone (#5373/#5371f0c683ef1). Every opened conversation raises anAHELstory event;raise_hello_story_eventresolves the
location before taking theStoryEventguard and the open sites scope theirLoadedCellIndexread to a clone
(#53721c15a2270— theLoadedCellIndex → StoryEvent → LoadedCellIndexcycle). Thenpc_dialogue_selection
Access row declares the cross-file voice and AHEL hops, and the declaration scan listsdialogue_voice.rs,story_events.rsand the condition evaluator (evaluate_function, 18 storages/resources incl. theStoryManagerRngwrite) explicitly (#54149d8fd4cc9, #5649197a9e245— the same-file callee walk cannot
follow a hop into another file). A replaced or closed line cuts its voice (stop_line_voice→AudioWorld::stop_sounds_for; #543240b77828b). Talk-candidate filtering (populate_candidates,byroredux/src/interaction.rs)
takes each alias-resource guard alone and builds bulk sets per storage (running_quest_bound_entities;
#50252a1a7a362closed two ABBA cycles, #510983d52707e) — a guard nest reintroduced there reddens the
lock-order lane. The egui response page itself is/audit-tooling. - Story Manager (
story_manager.rs,docs/engine/story-manager.md; #5366 Phases 0-4, live gatedocs/smoke-tests/sm1-story-manager.sh):install_story_managerfolds the parsedSMBN/SMEN/SMQNmap into anSmTree.SNAMnames the
previous sibling (xEdit): the head is theSNAM == 0(or dangling) member and the fold follows the inverse
edges, authored top-down (#5385846e4a6dd,snam_previous_semantics_walk_head_first; every member of a
sibling group is placed — groups replay in FormID order, not one SNAM chain, #540576a927f8a; the earlier same-parent
check held in both directions, so a direction pin must be asymmetric); a sibling cycle must still terminate
(sibling_cycle_builds_and_dispatch_terminates).story_manager_dispatch_system(exclusive, Update;StoryClocksynced fromGameTimeResfirst) drains theStoryEventmarker (Pattern B) and walks the event mnemonic'sSMENroot: a node whose CTDA set holds any
uncatalogued function declines before evaluation — neither starts nor consumes, the walk continues to its
siblings (#5380395d2d831,unknown_condition_function_declines_the_node; the Unknown→0.0 default once
passed== 0gates vacuously) — otherwise node CTDA runs through the shared M47.1
evaluator withRunOn::EventDataresolved over the positional R1/R2/L1/L2 slots, a passing quest node starts
through the canonicalQuestStageStatelifecycle (never a private start path),FromEventaliases fill from
the slots viaStoryEventAliasFillconsumed byrefresh_scene_actor_bindings, which probes slot liveness
(despawned id, orDeadwithout ALLOW_DEAD →StoryManagerEventUnavailable, never a stale bind; #54191dfdeb4f9,from_event_alias_skips_stale_and_dead_slot_entities).DNAMpolicies: random parents
shuffle (StoryManagerRng, whose write guard is taken for the draw alone —GetRandomPercentfn 77 re-enters
it duringevaluate, #5628faa00de2a), a non-sharing quest node consumes the event only when its own pool
queued a start candidate (a passing node with an exhausted pool no longer swallows the event, #540476a927f8a), pools round-robin withRNAMreset windows (hours, decoded through xEdit's 1/24 scale, #5386) inStoryManagerNodeState. Saved SM
resources:StoryManagerNodeStateandStoryEventAliasFill(the CLOC location cursor is session-local and reset on every save load, #5435557ead21d; #539403e51dfad, column added at FORMAT_MAJOR 35 — the R1/R2
slots persist as reference FormIds and re-resolve among resident candidates, sessionEntityIds serde-skipped;story_event_alias_fill_survives_save_load_as_reference_forms);SmTree/cursors/clock/RNG are
not-saved-by-design, so a quickload cannot re-fire spent radiants. Update ordering (CLOC producer → dispatcher →quest_alias_refresh_system, ambient installer →forcegreet_system) is pinned bystory_dispatch_and_forcegreet_update_ordering_is_pinned(byroredux/src/scheduler_access_tests.rs, #5415).
Producers:KILL(raise_kill_story_event, called from all four live death producers — combat, NPC and
player drowning, SDK actor-value batch — with the session LCTN as L1; corpse restores deliberately do not
raise it, #5434c85d50356; the per-frame CLOC key derivation early-outs on an emptySmTree, #5447),CLOC(systems/story_events.rs, LCTN-granularity key),AHEL(every opened conversation),sm.eventconsole;SCPT/crime/crafting families have no producer, andMNAM/XNAM(Num quests to run / Max concurrent, named
#5420) are unconsumed — one quest starts per node (documented gaps instory-manager.md§8, not findings). - Marker patterns for this domain (Pattern B, drained at consumer head):
Scene{Start,Stop}Request,SceneActionCompletionBatch,DialoguePresentationEventBatch,DialogueLineCompletionBatch,ScenePackage*Batch,EvaluatePackageRequest,MotionTypeChangeRequest(tail-drains exactly what it
snapshotted). Pattern-ASceneEventBatch/SceneFragmentInvocationBatchfeedscene_fragment_dispatch_system(Dim 2 order guard). SCEN packageActivateleaf ordering vsActivateEvent
consumers is pinned by the schedule tests. - Playback lifecycle:
idle_clip_playback_systemstarts a scoped player once per serial and drains the
request (idle_request_starts_scoped_animation_player_once_per_serial);cinematic_root_motion_systemapplies
then drains the delta (cart_exit_root_motion_moves_and_orients_actor_then_drains_delta— an undrained
delta launches the actor);cinematic_animation_event_system/behavior_completion_eventsignore unknown
annotations safely and a missing completion event cannot deadlock a quest stage;vehicle_attachment_system/scripted_motion_type_systemflip motion type with the sameKeyframed
disciplinenpc_spawnuses (physics half →/audit-physics);cinematic_horse_route_systemdrives the
tethered cart along the horse's XLKR marker chain fromPackageTargetRegistry(markers are not render
entities). Clip conversion (convert_hkx_clip: unmatched bones reported not dropped; Z-up→Y-up exactly
once; deterministic candidate order inidle_animation_candidates) — decode itself is/audit-parsers. - Router vs gate:
scene_trigger_actor_approach_system_inner(registered viamake_scene_trigger_actor_approach_system, persistent scratch) routes an offscreen actor to the lowest-stage
reachableBaseFormtrigger; the gate intrigger.rsdecides whether it may fire. They must share one
eligibility predicate (#4333) and agree on cross-quest phase waits and centerless triggers. Lock order:
theSceneRegistryread guard is held across the scan and must not invert the canonical scene→quest order
(drop before quest resources). Cinematic systems takeTransformbeforeActorCinematicState/HorseTetherState(#4546,docs/engine/ecs.mdcanonical chain;BYRO_LOCK_ORDER_CHECK=1is the gate). - Cinematic retention (
cinematic_retained_entitiescomputed once per unload batch;strip_retained_cell_root(world, victims, retained)per cell, scoped toretained ∩ victims): walk is
transitive overChildren(active_tether_retains_horse_cart_rider_and_hierarchy); stripping never
orphans an entity from indexes that expect aCellRoot; the retention set's lifetime is bounded since
#381763bf3347f:cinematic_horse_route_systemreleases the tether at the XLKR chain's terminal marker, and
released entities that lost theirCellRootto a mid-tether unload queue onCinematicReAdoption(unsaved,
session-local ids) for the streaming step (retry_cinematic_readoption) to stamp onto the loaded exterior cell
root containing them — un-rooted entities are despawn-immune, so a non-draining list is residency leak. Only
parentless members queue and a root's adoption stamps its wholeChildrensubtree (a localTransformis
not a world position, #5384faf8e5682); the player's subtree is never queued or cell-owned, andpurge_cinematic_retention_state(world, ctx)releases the still-pending roots' wholeChildrenclosure
(minus the player rig) throughrelease_entitiesat session replacement (#5379203be9ed4; the closure and
the render-side release, #549099cd9fd6c).release_finished_tethersclearsvehicle, soExitCart's exit
heading falls back to the rider's own rotation — equal only becausevehicle_attachment_systemlast wrotevehicle.rotation * vehicle_local_rotation(released_rider_exit_heading_matches_the_attached_heading, #5461).
Pins:systems/cinematic.rs(the #3817 lifecycle pair plusparented_subtree_rides_the_roots_adoption,player_rider_is_never_queued_or_cell_adopted) andcell_loader/unload.rs
(pending_readoption_victims_are_the_whole_subtree_closure,pending_readoption_victims_spare_the_player_rig); a release path
that stripsvehicle/cart_seatwould break the awaited-event exit. - Player
CinematicPresentationState/PlayerControlStateflags are saved; a flag a consumer never reads is
a placeholder (Dim 1) —disable_savingis read bySaveCommand;hud_cart_modehad no reader at #4372.
Output:/tmp/audit/scripting/dim_5.md
Dimension 6: Legacy ObScript Execution (Oblivion/FO3/FNV; M47.3 quest scripts)
Paths: crates/scripting/src/{obscript,obscript_runtime,obscript_vm,obscript_quests}.rs, byroredux/src/cell_loader/references/attach.rs (attach_scpt_script, obscript_dialect_for), byroredux/src/asset_provider/script.rs (install_quest_scripts call)
First step: cargo test -p byroredux-scripting -- obscript; cargo test -p byroredux --bin byroredux -- attach::tests; git log -p --since=<last report> -- crates/scripting/src/obscript_vm.rs
Untrusted-Input: Yes — SCDA bytecode comes from mod-supplied plugins. Distinct from the (unbuilt) .psc-side
SCTX frontend (ScriptSource::Obscript placeholder).
obscript_vm.rs(pure interpreter) bounds discipline: statement framing[op][len][payload],0x1cexplicit-caller escapes, implicit-caller statement calls (line opcode = command id), RPN expression
tokens,[u16 count]typed arguments,If/ElseIf/Elsenesting withReturn-inside-arm early exit,
1-based SLSD local / SCRO+SCRV reference resolution — compiled reference indices are 1-based into the
interleaved SCRO+SCRV list (ScriptRef;SCHR.num_refscounts both; a reference-variable slot resolves to
nothing, an unresolved explicit caller is declined, #5629), and a tag-rSet <ref>.<remote var>is declined
and counted (CMD_DECLINED), never stored as a ref assignment (#5630,aac6b25b3). Argument payload reads
are checked against the statement's argument region, not just the script buffer (#5655291af4528). Everyread_u16/checked_add/span end must be bounds-checked
and malformed input must yieldBlockOutcome::Malformed, never panic (truncated_bytecode_reports_malformed;installed_legacy_masters_have_structurally_valid_scdais the real-data check,#[ignore]d). Recursion is
capped byMAX_OBSCRIPT_VM_NESTING= 32 on bothexec_if_chain↔run_arm_bodyand nestedXargument
decode (decode_args_at_depth); guardsexcessive_nested_if_chains_are_malformed,excessive_nested_x_arguments_are_rejected— a new recursive path that skips the depth parameter is the
regression. The operand stack ineval_expr/apply_operatormust not grow unbounded from a lying token count.- Command IDs are empirical (aligned SCTX↔compiled stream on vanilla
Oblivion.esm; 2 349/2 393 scripts
decode):SetStage0x1039,GetStage0x103a,GetStageDone0x103b,StartQuest/StopQuest0x1036/0x1037,GetQuestRunning0x1038,Message/MessageBox,GetSecondsPassed,GetButtonPressed(−1). Anything else
is a traced no-op returning 0, counted per quest inObScriptQuestTimers.unknown_commands, never faked
— a new command that
returns a guessed value is a finding (feedback_no_guessing); dialect tables (xNVSE vs xOBSE opcode
numbering,ObscriptDialect) stay separate because the same number names unrelated commands. obscript_quest_tick_system(exclusiveStage::Update, vanillafQuestProcessInterval5 s cadence):
runs only running quests' GameMode block throughQuestCommandHost, which lowers onto the sameQuestStageState/Globalsand emits the same stage events as the fragment path (so Dim 2's journal and
cascade rules apply). Timers/effect ring (ObScriptQuestTimers,ObScriptEffectLog, cap 64) are unsaved by
design — cadence restarts on load and script locals are re-executed fresh per run (documented M47.3
residual, not silent save loss);StartQuest/StopQuestattach/detach the script loop consistently.
Phase-2 gaps (object-script blocks, Message UI, per-quest quest-delay time, actor-state functions) are
documented in ROADMAP M47.3 — not new findings. The 255-script corpus gate inobscript_quests.rs
(#[ignore]d) now resolves Oblivion.esm viatest_paths::oblivion_esm(), panics underBYROREDUX_REQUIRE_GAME_DATA=1instead of returning green, and runs in.github/workflows/real-data-gates.yml
(#50881377d3463).- Legacy load-order/extender path:
attach_scpt_scriptresolves SCPT viaScriptRegistry(logs an error
when the resource is absent); dialect selection is by profile row, pinned byobscript_dialect_follows_the_profile_not_the_game_kind; source-less FNV/FO3 bytecode is decoded
structurally (decode_extender_calls, only registered xNVSE/xOBSE opcodes) and FO3 must not apply the
xNVSE table (source_less_fo3_scpt_does_not_apply_xnvse_opcode_table). Arguments/bytes obey the SDK caps
(MAX_SCRIPT_CALL_BYTES,MAX_SCRIPT_STRING_BYTES,MAX_LEGACY_OBSCRIPT_NESTING).
Output:/tmp/audit/scripting/dim_6.md
Dimension 7: Provider / Extender-Compatibility Layer (SKSE, JContainers, StorageUtil, ModEvent seam)
Paths: crates/scripting/src/{papyrus_provider/**,compatibility.rs}, crates/scripting/tests/extender_compat_e2e.rs, byroredux/src/extensions/, crates/pex/src/call_sites.rs (consumer)
First step: cargo test -p byroredux-scripting -- papyrus_provider compatibility; cargo test -p byroredux --bin byroredux -- boot::schedule::system_access_declaration_tests
This layer (~7.5k LOC scripting-side: papyrus_provider/ + compatibility.rs, incl. tests) was a reported coverage gap through 2026-09-14 (only its seams were
audited). Its SDK half (crates/sdk: StorageUtil/JContainers verbs, typed script values, limits, manifests)
is /audit-tooling; audit here the runtime that consumes it.
- Untrusted-input: preflight/lowering run on
.pex-derived data and provider manifests.compatibility
caps its script table (MAX_COMPATIBILITY_SCRIPTS,truncated()must surface); lowering recursion is capped
(MAX_PROVIDER_HANDLER_NESTING= 32 — anelseifchain counts its clause index into the depth so lowering and
the per-dispatchvalidate_provider_statementsagree, #5656;MAX_PROVIDER_FRAGMENT_BARRIERS= 64 —
cap-before-descend); a literalUtility.Waitis capped atMAX_PROVIDER_WAIT_SECONDS(one game day) at
lowering so a mod cannot park immortal continuations in the bounded 4 096-slot queue, #565705d8d5864;execute_statementsruns on an explicit cursor stack, not a recursion per executedIf(#5635afa5186a1,
20 000 sequentialIfs on a 256 KiB stack);papyrus_provider_systemgroups ModEvent registrations by entity
once per frame and only when events are pending (#564034a47ba81); value sizes
obeyMAX_SCRIPT_ARRAY_ELEMENTS/MAX_SCRIPT_CALL_BYTES/MAX_PENDING_PAPYRUS_MOD_EVENTS.execute.rshasunreachable!arms guarded by "validated …" invariants — each must be truly established by lowering.
Thecatching_panicsnet (#3948) wraps the translate/lower path; check whether runtime execution inpapyrus_provider_systemhas any equivalent (none found 2026-10-05: nocatch_unwindunderpapyrus_provider/). - Decline-on-unmodeled at the seam: a provider call that cannot be typed declines the whole fragment/
handler (lower_provider_callerrors →None), it never lowers a prefix and silently drops the tail; aPapyrusProviderRuntime::default()with a non-empty catalog butcallback: Noneis not servable (Dim 1). - Continuation persistence:
PapyrusProviderContinuationQueueandFragmentExecutionQueueare saved
resources; continuations must stay principal-owned and typed (no raw closures or process-local handles).
Locals holdingScriptValue::Entity(EntityRef)are session-scoped (world_generation) — they are dropped
after load bydrop_entity_bound_continuations(#4139;/audit-saveDim 5 owns the ordering); any new
saved shape here is aFORMAT_MAJORquestion (/audit-saveDim 2). - Lock/access:
papyrus_provider_systemandlegacy_obscript_load_order_systemdeclare what they acquire
(the schedule tests scan bodies to 3 call hops);extension_*adapters run beforeevent_cleanup_system
(extension_event_adapters_run_before_transient_cleanup). Any new SDK-state exposure that nests a resource
lock againstQuestStageState/Inventory/etc. is a Dim 2-class finding. - Provider resolvers/callbacks (
set_papyrus_provider_{runtime,entity_resolver,form_resolver, mod_event_publisher},set_extension_script_function_invoker,set_legacy_obscript_content_catalog) are
installed by boot/extension setup; an uninstalled resolver must degrade to a decline, not a panic. - Census/instrument:
crates/scripting/examples/extender_preflight.rs; docs:feature-matrix.md/ROADMAP M47.2
rows must acknowledge this layer (SCR-ORCH-2026-09-06-01).
Output:/tmp/audit/scripting/dim_7.md
Phase 3: Merge
Combine /tmp/audit/scripting/dim_*.md into docs/audits/AUDIT_SCRIPTING_<TODAY>.md: Executive Summary
(shipped vs deferred; findings by severity; untrusted-input verdict for the runtime-side decoders —SCDA, provider manifests — must be NO panic/OOB/OOM/overflow; frontends' verdict lives in the newestAUDIT_PAPYRUS_*) · Decline-Invariant Audit (every recognizer/composer/effect decline point ×
verified-conservative vs leaks-a-partial-lowering) · Runtime Lifecycle Matrix (marker drain coverage;
two-phase lock-drop per system; cascade bound; edge-trigger seed; CTDA OR-precedence; journal subscribers) ·
Findings (CRITICAL first, deduplicated) · Future-Phase Readiness (invariants pinned for ObScript
phase 2, alias follow-ups). Dedup ownership: marker-drain coverage = Dim 3 (pointers from others);translate_pex clean-None = Dim 1 (pointer from Dim 4); half-extent convention = Dim 4; cinematic router/
gate agreement = Dim 5 (pointer from Dim 3).
Phase 4: Cleanup
rm -rf /tmp/audit/scripting; tell the user the report is ready; suggest/audit-publish docs/audits/AUDIT_SCRIPTING_<TODAY>.md (domain label scripting; add quests for
QUST/alias findings and the matching game:* for one title's scripts).