"Deep audit of the Papyrus compiler frontends — .pex reader + 5-phase decompiler (Champollion port, crates/pex) and the .psc lexer/Pratt parser (crates/papyrus): untrusted-input bounds, decompiler soundness, depth caps"
Instal·la
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-papyrus Instal·la des del registre de SkillsCat.
Papyrus Frontend Audit (crates/pex + crates/papyrus)
Read .claude/commands/_audit-common.md and .claude/commands/_audit-severity.md for shared protocol.
Audit the two frontends that turn Papyrus bytes into the shared byroredux_papyrus::ast::Script:
untrusted .pex bytecode → decode (crates/pex) → 5-phase decompiler, and .psc source → lexer + Pratt
parser (crates/papyrus). Both feed the recognizer chain in /audit-scripting (which ownstranslate_pex's clean-None contract and the panic net); this skill owns everything up to the AST.
Highest bug-density area of the scripting domain (untrusted input + five tree-rewriting passes).
Findings here are silent wrong-AST (a recognizer then matches it → wrong ECS behavior on vanilla
content) or process aborts (OOM / stack overflow — catch_unwind cannot recover either, so a panic net
is no substitute for a bound).
Architecture: Orchestrator; each dimension is a Task agent (max 3 concurrent).
Scope
crates/pex/src/:reader.rs,opcode.rs,model.rs,lib.rs(parse,PexError,string_byte_budget),call_sites.rs(Pex::call_sites, the extender-preflight scan — consumed by/audit-scriptingDim 7),decompile/{mod,cfg,lift,boolean,control_flow,lower,node,event_names}.rs.crates/papyrus/src/:token.rs,lexer.rs,ast.rs,span.rs,error.rs,lib.rs,parser/{mod,expr,stmt,script}.rs.- Instruments:
crates/pex/examples/pex_corpus_smoke.rs(decompiles every.pexin real archives; the
source of the decompile-rate claim),pex_corpus_shapes.rs+docs/r5/corpus-shape-survey.txt,crates/bsa/examples/r5_extract_pex_ba2.rs(corpus extractor). - Ground truth: crate docstrings (
crates/pex/src/lib.rs,decompile/mod.rs),docs/engine/{papyrus-parser, m47-2-design}.md("no opcode semantics guessed"). Reference decompiler: Champollion (OPCODES,PscCoder::writeStates); UESP Papyrus Assembly spec for operand order. - Not here: hkx (
/audit-parsers), ESM VMAD decode (/audit-esm), recognizers/runtime (/audit-scripting).
Parameters / Extra Fields / Severity
--focus default all 4 · --depth shallow (bounds + depth-cap contracts) | deep (trace each pass's tree
rewrite). Finding fields: Dimension: PEX Reader | Decompiler CFG & Lift | Decompiler Boolean/Control-Flow/
Lower | Papyrus Lexer & Parser · Untrusted-Input: Yes | No (Yes for any path consuming raw .pex/.psc).
Escalations over _audit-severity.md: panic / OOB / unbounded alloc or recursion reachable from untrusted
bytes → HIGH (CRITICAL if memory-unsafe — the transmute in opcode.rs); decompiler emits a wrong AST
that a recognizer accepts, or copy-propagation / boolean-collapse folds into the wrong consumer → HIGH
(silent, all-game blast radius); doc rot → LOW.
Phase 1: Setup
mkdir -p /tmp/audit/papyrus; dedup gh issue list --repo matiaszanolli/ByroRedux --limit 300 --json number,title,state,labels > /tmp/audit/papyrus/issues.json;
read the newest docs/audits/AUDIT_SCRIPTING_*.md (Dims 1-4 findings; pre-split reports use the old
8-dimension numbering) and docs/engine/m47-2-design.md §"Frontends in detail"/"Risks & mitigations" to
separate designed declines from defects. Run cargo test -p byroredux-pex -p byroredux-papyrus.
Delta-first: git log --since=<last report> --format='%h %cs %s' -- crates/pex crates/papyrus — these crates
often have zero commits between passes; then re-verify the guards below rather than re-reading source.
Phase 2: Dimensions
Dimension 1: .pex Reader & Opcode Decode (untrusted input — every finding is Untrusted-Input: Yes)
Paths: crates/pex/src/{reader,opcode,model,lib,call_sites}.rs
First step: cargo test -p byroredux-pex (then re-read any guard named below that did not run)
take(n)is the single bounds gate (checked_add+<= len→UnexpectedEof). No read path may
indexself.data[..]ortry_into().unwrap()a short slice. Guard:every_prefix_of_every_wire_valid_ sample_is_rejected(exhaustive-prefix over five builders: Skyrim LE/BE, extender-dependent BE, Starfield
guards, and #4474'sbuild_sample_full_coverage— FO4 debug-info skips, getter/setter bodies, non-emptystruct_infos,Value::Float). A new reader branch no sample reaches is the gap to look for.- Allocation is bounded by something other than a wire count: var-arg count
n(up toi32::MAX) must
not feedVec::with_capacity(grow bypush;hostile_vararg_count_errors_instead_of_ooming); otherwith_capacity(count)sites areu16-capped;u32flags/sizes are never capacities. String copies are
charged againststring_byte_budget=max(64 × file size, 16 MiB)in bothreader.rsandcall_sites.rs(ScanBudget; #4317 — a ~330 KB wire-valid file once exhausted 8 GB and aborted). Any new
owned copy of a table string (a new model field, a new scan) must be charged; guardsstring_copies_past_the_budget_are_refused,call_site_copies_are_bounded_by_the_string_budget. OpCode::from_u8transmute(unsafe, memory-safety-critical):MAX_OPCODE == 51equals last
discriminant + 1, enum#[repr(u8)]with contiguous0..=50, guard is>=; a compile-timeconst _: () = assert!(OpCode::TryLockGuards as u8 == MAX_OPCODE - 1)backs it (#4475). Pins:discriminants_match_on_disk_order,from_u8_round_trips_and_rejects_oob,metadata_matches_champollion{,_full_table}(verify they cover every discriminant, not spot values).arg_counttable drives operand consumption: a wrong row desyncs the whole stream silently. Spot-check
var-arg opcodes (callmethod/callparent/callstatic/guard ops) and high-arity rows
(array_findstruct5,array_getallmatchingstructs6) against Champollion/UESP.string_index→.get(idx)/BadStringIndex(neverself.strings[idx]);value()accepts tags 0..=5,Integersign-reinterprets; endianness from magic (0xFA57C0DELE /0xDEC057FABE) with the provisional
LE innew()never leaking into a multibyte read; Skyrim-vs-FO4+ field gating (is_skyrim():const_flag,struct_infos, property-group/struct-order tables; Starfield-onlyguards) reads in FileReader order and
the consume-and-discard skips match the writer's byte counts.read_binaryis all-or-Err(no partialPex);call_sitesstops with a diagnostic when its budget is
exhausted rather than truncating silently.
Output:/tmp/audit/papyrus/dim_1.md
Dimension 2: Decompiler — CFG & Opcode→Node Lift (copy-propagation is the AST-correctness core)
Paths: crates/pex/src/decompile/{cfg,lift,node}.rs
First step: cargo test -p byroredux-pex -- decompile::cfg decompile::lift
- Jump targets:
checked_targetaccepts0 <= ip+offset <= count(inclusive —countis the exit
anchor); non-integer offset →BadJumpOffset, OOB →JumpOutOfRange, non-{ident,bool,int} condition →BadJumpCondition.CodeBlock::split(at)is never called withat == 0(underflow). jmpf/jmptpolarity:jmpfjumps when FALSE (true-edge = fall-through); a flip inverts everyIf
(forward_jmpf_builds_an_if_diamond,forward_jmpf_with_unconditional_backedge_builds_a_loop, andbackward_jmpt_builds_a_loop_edge— a trueJmpTpin since #4476; it was a misnamedJmpFtest before).rebuild_expression: a temp-producing node folds into the single immediately-next live consumer
(count_constant_id: 0 → advance, 1 → inline, >1 →ExpressionRebuildFailed); folding into a non-adjacent
consumer reorders side effects. It runs over a linked live-index chain, not restart-at-0/Vec::remove
(O(n²), #2024 —rebuild_expression_is_linear_up_to_the_wire_format_ceiling).is_finalvsis_temp_var
asymmetry (::tempprefix,_varsuffix) is a deliberate Champollion port — unifying them is a regression.replace_constant_id'sdebug_assert!(slot.is_none())is debug-only: a release >1-match would silently
drop the producer.- Depth is memoized on
Node(Node::depth,recompute_depth) and checked at every fold againstMAX_EXPR_DEPTH(= the papyrus parser constant), so repeated re-folds by the boolean/control-flow passes
cannot reset the ledger (the SIGABRT class); guardsa_temp_chain_past_the_depth_cap_is_declined_not_aborted
/..._inside_the_depth_cap_still_folds. Any new in-place tree mutation must callrecompute_depth. create_node:Castdowngrades toCopyonly when source isNone, same-typed identifiers, or::nonevar(case-insensitive) — a wrong downgrade turns a narrowing cast into an identity copy. Operand
order forCallMethod/CallStatic/CallParent(result/object/method indices) is what recognizers key
on (SetStage,GetStageDone); cross-check UESP.id_ofon a literal →ExpectedIdentifier, neverunwrap()outside a checked branch. Bodyless/native functions decompile to an empty body.
Output:/tmp/audit/papyrus/dim_2.md
Dimension 3: Decompiler — Boolean, Control-Flow, Lower & the Corpus Instruments
Paths: crates/pex/src/decompile/{boolean,control_flow,lower,event_names,mod}.rs, crates/pex/examples/pex_corpus_smoke.rs
First step: cargo test -p byroredux-pex -- decompile::; with game data: cargo run --release -p byroredux-pex --example pex_corpus_smoke -- <Skyrim - Misc.bsa> <Fallout4 - Misc.ba2>
- Pass order is load-bearing:
lower::decompile_bodyruns cfg → lift →rebuild_boolean_operators→reconstruct→lower_body; the boolean pass precedes control-flow so&&/||chains collapse first. control_flow.rs: shape classification (While:last.next == current; If:last.next == exit; If/Else;
jmpt inversion negates + swaps edges). A conditional-predecessorlast(||shape the boolean pass should
have collapsed) fails closed (ControlFlowFailed, #1732;conditional_predecessor_fails_closed) —
a fix that resumes past the block drops statements. The module doc's "advanced past" wording is stale
(#4115 open, verified 2026-10-12).- Three bookkept Champollion departures —
boolean.rs's two, pluslower.rs'sis→ object-typedCast
(#4477,an_is_opcode_lowers_to_an_object_typed_cast; recognizer-irrelevant only while no consumer keys on it).
For theboolean.rstwo — adjudicate benign-or-bug, evidence = corpus rate +
fidelity gate, not speculation: (1) no debug-line guard (a same-named temp on a fall-through edge could be
falsely collapsed into a fabricated&&/||); (2) termination guard (only re-process on a real merge —
infinite loop hangs the decompiler).&&true edge falls through,||false edge; operand must recompute
the same condition variable; prec&&=7/||=8;take_operandleaves no danglingNone. - Recursion:
MAX_REBUILD_DEPTH = 1024incontrol_flow.rs(boolean.rsderives it — not restated);
both rebuilds returnRecursionLimit(rebuild_rejects_excessive_recursion_depthexists in both files).
It andMAX_EXPR_DEPTH(256) compose additively (~1,280) and independent edits erode the margin
(#4113 open, verified 2026-10-12) — check any change to either constant. lower.rstotality:lower_expr/lower_stmtnever panic on anyNodeKind; intentional lossy lowerings
matter only if a recognizer keys on the lost info: statement-shaped node as sub-expression →NoneLit
(should be unreachable),is→Cast,StructCreate→Newsize 0,lower_binary_opdefault arm →Eq(a real unknown op silently becomes==).- Script assembly: synthetic
::variables dropped; the object'sconditional/hidden/defaultuser
flags (bit indices named by the file's own user-flag table,has_user_flag— shift guarded byflag_index < 32on untrusted bytes) lower into the sameScriptFlagsthe.pscheader parses, and a
variable'sconditionalintois_conditional(#5398ce90b481c,object_user_flags_lower_into_script_flags);
script scope = the empty-named state's functions;
every named state → aStateitem; exactly one (the first case-insensitiveis_auto_statematch againstauto_state_name) isis_auto(#4473,case_colliding_state_names_mark_exactly_one_auto) — keying scope on the auto match inverted 983 vanilla scripts (#4319;named_auto_state_stays_an_auto_state_and_the_empty_state_is_script_scope). Event iff (on-prefixed ANDis_event_name) OR::remote_-prefixed;EVENT_NAMESis a sorted lowercase union binary-searched
(list_is_sorted_for_binary_search); a missing engine event demotes a handler to a function. The list is a
documented superset of Champollion's frozenEventNames.hpp: 12 real engine events it omits
(OnAttach,OnStoryClearLocation, ten Starfield) are hand-added in sorted position from a base-class
corpus census (#44710611cd116;the_census_engine_events_classify_as_events, which also keeps vanilla
typos likeoncelldetatchout) — an addition needs the same census evidence, not a guess. - The decompile-rate claim measures robustness, not fidelity. Verify
pex_corpus_smokecountsErrand
panics as failures and tallies afterdecompile_script. Its shape check must not share an
implementation rule with the code under test — the #3017 check anddecompile_scriptshared the wrong
auto-state rule and both agreed on 983 mis-assembled scripts. Fidelity evidence is the.psc-vs-.pex
gate (owned by/audit-scriptingDim 1:recognizes_da10_and_reproduces_hand_builder+da10_pex_reproduces_hand_builder_byte_for_byte), not the corpus rate.
Output:/tmp/audit/papyrus/dim_3.md
Dimension 4: .psc Lexer & Pratt Parser (untrusted: .psc files and console/debug text)
Paths: crates/papyrus/src/{token,lexer,ast,error,lib}.rs, crates/papyrus/src/parser/{mod,expr,stmt,script}.rs
First step: cargo test -p byroredux-papyrus -- depth chain the_two_parser_depth_caps_stay_equal
- Depth caps (stack overflow and AST
Droprecursion):MAX_EXPR_DEPTH = 256(expr.rs) andMAX_STMT_DEPTH = 256(stmt.rs), bothpub, equality pinned bythe_two_parser_depth_caps_stay_equal;
downstreamMAX_CONDITIONAL_DEPTHandlift.rs's cap alias them. The Pratt loop chargesexpr_depth
for each node a postfix/binary chain wraps (#4320 — the cap once counted only recursive calls, soa.a.a…of ~0.4 MB overflowed on Drop) and restores on every exit including error paths
(long_postfix_and_binary_chains_hit_the_depth_cap,realistic_chains_still_parse_and_release_their_depth,depth_resets_between_top_level_calls). All recursive expression entry funnels throughparse_expr_bp. - Newline is a terminator in the Pratt loop: the loop uses
peek_raw(), not the newline-skippingpeek()(#4321 — a line opening with(was glued to the previous statement, a silent wrong AST). Explicit\continuations are joined bylexer::preprocess; an operator ending a line still continues. Verify
every otherpeek()on a statement boundary: #4472 (six sites, viacheck_raw) and #4763 (seven more —Extends, variable/function/group flags, property and local initializers) moved construct-continuation
decisions to the raw stream, each pinned by an*_on_the_next_line_*test inscript.rsplussingle_line_forms_of_the_4763_sites_still_parse. Remainingpeek()/check()sites should sit behind an
explicitskip_newlines()or a bracket-bounded list.expect()/expect_ident()still skip newlines, so
a keyword's **mandatory operand** goes throughexpect_same_line/expect_raw/expect_ident_raw
(parser/mod.rs; #5021,3b7ffa670): declaration names, theExtendstarget, the(after a function
name,StateafterAuto, the If/ElseIf/While condition start, andNew's type and[— #5331fd5aa4ead,parse_new_expr— pinned bya_mandatory_operand_on_the_next_line_is_an_error,auto_on_its_own_line_does_not_make_an_auto_state,extends_with_its_target_on_the_next_line_sets_no_parent,single_line_forms_of_the_5021_sites_still_parse.
Only closing keywords may keep the newline-skippingexpect; a new declaration keyword that uses it is the
regression. - Statement termination (#5322
a5c3f2428):expect_eol(parser/mod.rs) used to accept ANY token after a
statement, so a tail the Pratt loop stopped at re-parsed as a new statement (If f is Actorbecame a
truthiness test plus a fabricatedVarDecl) with zero errors. It now records a recoveredUnexpectedTokenfor
anything but Newline/EOF/DocComment and skips to line end viaskip_to_line_end, which leaves block terminators
(EndIf,Else*,End*) unconsumed so a gluedx = 1 EndIfstill closes its block
(glued_statement_tail_is_an_error_and_not_a_statement);parse_expr(debug console, offline preflight) ends inrequire_input_end(parse_expr_rejects_trailing_tokens). FO4isis a keyword with anExpr::Istype-test
node at cast precedence (test_is_type_test); downstream exhaustive matches treat it as transparent to the operand
(the.pexside still lowersOpCode::Isto an object-typedCast, so the two frontends do NOT yet share one shape). - Precedence/associativity:
BinaryOp::precedenceOr=1, And=2, comparisons=3, Add/Sub/StrCat=4,
Mul/Div/Mod=5, unary=6, cast=7/postfix=8 (PREC_*inexpr.rs); left-assoc hinges onop_prec <= min_bp → break. (Bethesda's inverted CTDA OR/AND precedence is a condition-evaluation concern in/audit-scriptingDim 3 —.pscoperators are standard.) preprocess:\+\n/\r\n/lone\relided (2/3/2 bytes) with exactOffsetMapcounts (a wrong count
drifts every later span); a trailing\at EOF is emitted, not swallowed. Keywords areignore(case)(logos 0.16 dropped ascii_case,15a6b1d2d— Unicode folding, which differs only for the few
non-ASCII letters that fold onto ASCII, e.g. U+017F, U+212A, invalid Papyrus either way) and win over theIdentregex. CK flags matched contextually stay out of the lexer:Default(script header) andCollapsed(group) are identifiers checked in their flag position (#5398ce90b481c,default_and_collapsed_remain_identifiers) — expressions accept onlyToken::Ident, so promoting either to a
keyword breaks every variable of that name. Lone CR is a line terminator and CRLF oneNewline
(bare_cr_is_a_newline_and_crlf_is_one_newline, #4479 — a CR-only file once lexed to zero newlines); a;
line comment likewise stops at the first\ror\n(line_comment_ends_at_a_bare_cr, #5019a75c7dc33—
it once searched only\n, swallowing a CR-only file to EOF with zero errors);0xand exponent shapes route throughMalformedNumberto aLexError, not an adjacent token pair.- Recovery:
parse_scriptreturnsOk((Script, Vec<ParseError>))for partial success andErronly for
fatal;skip_to_next_linealways consumes ≥1 token (no infinite loop); callers needing strict-fail checkresult.1.is_empty(). Nounwrap()onstr::parseof a malformed-but-lexable literal.
Output:/tmp/audit/papyrus/dim_4.md
Phase 3: Merge
Combine /tmp/audit/papyrus/dim_*.md into docs/audits/AUDIT_PAPYRUS_<TODAY>.md: Executive Summary
(findings by severity; untrusted-input robustness verdict — can a hostile .pex/.psc panic, OOB, OOM,
or stack-overflow the cell loader / console — must be NO; decompile-rate verdict — re-measured, and what
it does and does not prove) · Decompiler Soundness Matrix (reader / cfg / lift+copy-prop / boolean /
control-flow / lower / .psc parser × bounds-safe, terminates, total, fidelity-tested; the three Champollion
departures adjudicated) · Findings (severity order, deduplicated). Cross-audit: recognizer/runtime →/audit-scripting; .pex bytes reaching the extender preflight → /audit-scripting Dim 7.
Phase 4: Cleanup
rm -rf /tmp/audit/papyrus; tell the user the report is ready; suggest/audit-publish docs/audits/AUDIT_PAPYRUS_<TODAY>.md (domain label scripting).