"Deep audit of CHARAL — the per-game character ruleset → canonical ActorValues/Level/Perks layer: derived-stat formulas, leveling models, skill/attribute rosters, regen + affliction + reputation systems, population boundary"
Install
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-character Install via the SkillsCat registry.
Character / CHARAL Audit
Audit crates/core/src/character/ — the per-game character ruleset translation tier. CHARAL
translates rules, not data: each game's attribute / skill / perk / leveling model resolves into one
canonical representation over ActorValues, so the gameplay runtime never branches on the source game./audit-ecs checks the shape of its components; nothing else checks its numbers. A wrong constant
changes gameplay silently — no crash, no validation error, no failing test unless someone wrote one.
Architecture: Orchestrator; each dimension runs as a Task agent (max 3 concurrent). Read.claude/commands/_audit-common.md (layout, methodology, dedup, finding format) and.claude/commands/_audit-severity.md first; do not duplicate them here.
Scope
crates/core/src/character/(14 modules,mod_docstring_indexes_every_sub_modulepins the count):ruleset.rs(CharacterRuleset, the per-gameResourceseam),profile.rs(CharacterRulesProfile—
the single per-game policy row:NpcStatModel,creature_stats,body_condition_base,RulesetBuilder),derived.rs(DerivedStatFormula/DerivedInput/DerivedOutput/DerivedScope/RoundMode),leveling.rs,attribute.rs/skill.rs,fallout.rs/tes.rs/skyrim.rs(per-family
ruleset builders),regen.rs,affliction.rs/resistance.rs,reputation.rs,components.rs.
Substrate:crates/core/src/ecs/components/{actor_values,perk_list,faction_ranks}.rs.- CHARAL-adjacent (not submodules):
crates/core/src/combat.rs(Oblivion damage math),crates/core/src/stealth.rs(FO3/FNV sneak detection) — consumer-less or thin today; verify anyway. - Population boundary:
byroredux/src/npc_spawn.rs(build_character_ruleset, the only ruleset
construction site) +npc_spawn/resumable/,npc_spawn/ai_package.rs,crates/plugin/src/esm/records/ actor_value_derive.rs,crates/plugin/src/esm/records/actor/mod.rs(effective_actor_level),crates/plugin/src/equip.rs(resolve_inherited_*),byroredux/src/cell_loader/references/,byroredux/src/inventory.rs(attach_to_playerstamps the player'sActorValues/ActorVitals/CharacterLevel/Background/SpellList/RaceSpellsand readsCharacterRulesProfile::vital_pools),byroredux/src/combat.rs(melee_damage_charal_bonus),byroredux/src/commands/{actor_value,condition}.rs. - Handoff: gameplay writers of
ActorValues(consumables, restoration, combat —byroredux/src/ {inventory,combat}.rs,systems/restoration.rs) belong to/audit-gameplay; CHARAL owns only whether
they respect the layer contract (Dim 4). Save schema ofActorValues/Perks→/audit-save.
Ground truth — read the captures BEFORE the Rust constants (reading code first turns verification
into confirmation): docs/engine/charal.md, then charal-{fnv-fo3,fo4,oblivion,skyrim,fo76,starfield}- ruleset.md — the authority for every constant; a coefficient no capture supports is a finding
(feedback_no_guessing). Memory: actor_value_population, class_system, tes_character_rules,
vats_system, perk_system.
Known-open — do NOT re-file (confirm still true):
- FNV/FO3 tag-skill per-level formula undocumented, deferred; CLAS SPECIAL lives in
ATTR, notDATA. - FO3/FNV AP (and Crit/Melee/Unarmed) NPC scope is unsourced: those rows ship
.player_only()as the
documented conservative choice (#2937, closed as documented), disclosed in place and pinned. - VATS runtime (AP pool/regen, time-pause, limb health, hit roll) absent; only AP formulas exist.
- Regen:
PoolRegenConfighas zero production insertion sites (pool_regen_tick_systemis
registered and early-returns on every game);affliction_tick_systemhas no scheduler registration; no
runtime level-up system (level_cap()has no consumers). Verify still true; a change is a wiring
event (Dim 5 doc sweep). - Oblivion
RulesetBuilder::Noneis deliberate (no AVIF pre-FO3 → no resolver); pinned byoblivion_still_has_no_runtime_ruleset_and_that_is_deliberate. FO76/Starfield: captures, no builders. - Open (verified 2026-10-12) — cite, do not re-file: #4137 (six
template_flagsbits with no consumer),
#4232 (effective_actor_levelreturns 0 verbatim). #4415 (magic runtime) is closed (9813af435). The 2026-09-19 report's
#4452-#4457/#4459-#4463 are all closed — regression checks now. Verify withgh issue viewbefore citing.
Parameters
--focus <dims> (default all 5) · --game fnv|fo3|fo4|oblivion|skyrim (restrict constant checks) ·--depth shallow|deep (shallow = structure + doctrine; deep = every coefficient vs its capture; default deep).
Extra Per-Finding Fields
- Dimension: Ruleset Seam | Derived Formulas | Progression & Pools | Population Boundary | Coverage & Doctrine
- Game: family the finding concerns (or
all). Source: the capture-document line the correct value
comes from — required for any numeric finding; a numeric finding with no source is not reportable.
Phase 1: Setup
mkdir -p /tmp/audit/character; dedup baseline:gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/character/issues.json.- Read the newest
docs/audits/AUDIT_CHARACTER_*.md; its fixed findings are regression checks. cargo test -p byroredux-core character(record counts). Load the capture documents before any Rust constant.
Phase 2: Dimensions
Delta-first: for each dimension run git log --since=<last report date> --format='%h %cs %s' -- <Paths>;
skim dimensions whose Paths did not change (constants only move when a capture or formula commit lands).
Dimension 1: Ruleset Seam & Doctrine
Paths: crates/core/src/character/{ruleset,profile,mod}.rs, byroredux/src/npc_spawn.rs
First step: grep -rn 'GameKind\|\.game ==\|game_kind' crates/core/src/character byroredux/src/npc_spawn* crates/plugin/src/esm/records/actor_value_derive.rs
- Doctrine: the per-game seam is data in the tables, never a branch in a consumer. Report every
game ==/GameKind/ master-name test in CHARAL population or consumers (weight =/audit-nifal's
render-time per-game branch). Precedent: FO3/FNV body-condition seeding was a rawGameKindgate until
it becameCharacterRulesProfile.body_condition_base(#4447). - Profile-as-oracle:
CharacterRulesProfileis the only FO3-vs-FNV discriminator (both areGameKind::Fallout3NV); non-character consumers (cell_loader/references/attach.rs::obscript_dialect_for,crates/plugin/src/consumables.rsfn-586 gate) borrow it. The duty is documented and pinned (#4448) —
verify each consumer still has a test proving the discriminator is the profile row, and that a new
borrower adds one. - Single sink:
build_character_rulesetis the only construction site; no second site builds a
ruleset or writes derived stats straight intoActorValues. - Derived table is a flat
Vec<(u32, DerivedStatFormula)>scanned linearly (N ≈ 3–10): report growth to
dozens as a documented trade-off unless on a hot path. Output keys are remapped global AVIF FormIDs
(/audit-esmDim 3), not plugin-local ids. attributes/skillsmembership is engine-supplied, the FormIDs they resolve to are authored: a
hardcoded FormID in a roster, or an engine count derived from parsed data, is a finding. Guard:ROSTER_CASES+assert_rosters_resolve(crates/plugin/tests/parse_real_esm.rs,#[ignore]d
real-master corpus gate) — a new roster/derived output without aROSTER_CASESentry is untested by
construction.- Profile rows must be sourced: a row for an unwired family may not claim a wire format without a
capture line (FO76/Starfield were reset toNpcStatModel::None, #4453); the same holds forvital_pools
(per-profile roster since #4679 — a consumer-sideGameKindpool list is the regression); the "blocked, not forgotten" comment pattern
(Oblivion arm) is the template; Starfield's pool roster is("O2", "Oxygen"), the AVIF EDID its master
authors (#50449bac2d87b— an unresolvable"O2"EDID once silently dropped the bar). Pins:fallout_profiles_keep_roster_health_and_ruleset_in_lockstep,skyrim_profile_builds_a_ruleset_and_actually_calls_gmst.
Output:/tmp/audit/character/dim_1.md
Dimension 2: Derived-Stat Formulas
Paths: crates/core/src/character/{derived,fallout,tes,skyrim,resistance}.rs, crates/core/src/{combat,stealth}.rs
First step: cargo test -p byroredux-core character::derived character::fallout character::tes character::skyrim
- Every coefficient vs its capture (bias,
c_a,c_b, cross term, cap,RoundMode), citing the
document line. FO4 Healthfloor(77.5 + 4.5·END + 2.5·L + 0.5·L·END)is the only cross-term row; a
non-zero cross term elsewhere is a red flag. Rounding modes are per-source (FO4 sourced; FO3/FNV shipNone, exact on their integer domain) — "obviously round/floor" is the guess this audit catches (#4451). - Sentinels: uncapped cap is
f32::INFINITY(no table may rely on cap-0 = uncapped);DerivedInput0= unused,u32::MAX= level — no path may build a real FormID equal to either. - Scope contract:
DerivedScope(player-only vs actor-general) is what contains the #2937
deferral. A row with no scope annotation in its capture is unsourced (#4450: documented + pinned);
every consumer that evaluates for an arbitrary entity must check scope (crates/scripting/src/ condition.rsand, since #4452,melee_damage_charal_bonusdo).PlayerOnlyrows are evaluated for the
player only (stamped #4674, refreshed per frame since #5039) — verify no NPC path reaches them. FO3/FNV
shared-row scopes cite the GECK Stats Tab — NPC page (#52403826c2792,fo3_fnv_geck_npc_page_scopes_the_shared_derived_rows); Melee Damage is annotated CONFLICTED and kept
actor-general deliberately — do not re-file the flip without the settle bar the capture names. - Chaining: SPECIAL + skills must be in
ActorValuesbefore dependents evaluate (order is the
mechanism, no dependency graph); an unpopulated input reads a documented default, not accidental 0. evalstays allocation-free, no game branch;DerivedStatFormulaCopy+ 36 B
(formula_is_thirty_six_bytes_and_copy).- Siblings:
combat.rs(modified_skillLuck 0.4,oblivion_weapon_damage_multiplier,oblivion_hand_to_hand_damagecross-term) vscharal-oblivion-ruleset.md"Complete Damage Formula";stealth.rs(detection_score/classify) vscharal-fnv-fo3-ruleset.md"Sneak Detection". Perspective
traps: UESP's "Opponent…" names are attacker-relative; the code reads the wearer's own skill (#4449).
Verify regardless of consumer-less status.
Output:/tmp/audit/character/dim_2.md
Dimension 3: Progression, Pools, Afflictions & Reputation
Paths: crates/core/src/character/{leveling,regen,affliction,reputation,components,skyrim,tes}.rs
First step: cargo test -p byroredux-core character::leveling character::regen character::affliction character::reputation
- Leveling: three models (Fallout
XpCurve, classic-TESSkillUse, SkyrimSkillXp) are enum data
variants with one consumer-side match;level_cap()==0= uncapped identically in all three (no consumer
exists yet — the off-by-one duty is future). Skyrimwith_gmstrequests exactly["fXPLevelUpBase","fXPLevelUpMult"];xp_per_skill_rankis a fixed engine coefficient by design
(do not re-flag). Oblivion attribute-bonus banding + 10-major-skill-ups; Skyrim pool base 100, +10/level
(SKYRIM_POOL_BASE,pool_pick_gain);Perks::try_set_rankrejects out-of-range rather than clamping. - Regen tick: fixed-60 Hz (
POOL_REGEN_DT), backlog clamped (MAX_REGEN_SUBSTEPS), zero-dt cannot
spin; the scheduler access declaration matches the body (/audit-concurrencyDim 4 owns the rule).
Absence ofPoolRegenConfiginsertion is known-open — verify the gap, not a comment describing it. - Affliction = diff-and-reapply (remove previous band's penalty before applying the new one — a
missing removal compounds); bands half-open and consistent;band_forvsband_by_keytie-break (#4456). - Resistance/reputation:
damage_multiplierclamps[0, cap], never negative (no healing);STANDING_GRIDaxes[infamy][fame](transpose = plausible wrong standing); karma/affinity clamp both
ends; per-factionFactionRepThresholdsrows match the capture row-for-row. - Every constant here needs a Source; regen rates and band thresholds are exactly the numbers typed
from memory.
Output:/tmp/audit/character/dim_3.md
Dimension 4: Population Boundary (parse → ruleset → actor) — highest yield
Paths: byroredux/src/npc_spawn*, crates/plugin/src/esm/records/{actor_value_derive,actor/mod}.rs, crates/plugin/src/equip.rs, byroredux/src/inventory.rs, byroredux/src/cell_loader/references/
First step: cargo test -p byroredux --bin byroredux resolve_inherited_call_sites_are_enumerated_and_pinned; git log --since=<last report> --format='%h %cs %s' -- <Paths>
- One derivation, one implementation (the class that keeps recurring):
effective_actor_levellives
once inactor/mod.rsand is.max(0)on the non-multiplier branch — a second copy or a.max(1)(also
inexamples/) is the regression (#3171/#4095);pc_level_mult_actors_resolve_to_calc_min_not_the_raw_ multipliercalls it through the plugin crate. TPLT template inheritance: FO4 authors a per-flagTPTA
Template Actors array (NpcRecord::template_actors: [u32; 13], 0 = use theTPLTdefault) andwalk_inherited_recordspicks the flag's own entry when non-zero (#549899c22a8db; FO4-only data, no game
branch — pinned bycrates/plugin/src/equip.rstests). Since #4457 the population boundary resolves
every category once throughResolvedNpc::resolve(crates/plugin/src/equip.rs) and consumers read the
resolved type; the guard pins productionresolve_inherited_*calls per file (two left, both pre-spawn
race boundaries:cell_loader/references/mod.rsandplayer_body.rs, the latter joined in #5047c20dcdb04, which also moved the player's level/Background/outfit reads onto the Use-Stats/Use-Traits/
Use-Inventory terminals) — a new direct call or a rawnpc.<field>read is the recurrence
(fix-by-addition made it worse in #4086). Verify the enumerated count moved only deliberately, and that new reads honortemplate_flagsrather than overwriting inherited values (six bits still have no consumer: #4137). - Writers vs stamps (silent no-op class): for every writer that gates on a component
(consume_item/restoration_system/drowning gate on playerActorValues+ActorVitals), find a
production insert of that component on the entity class the writer targets; unit tests hand-insert and
hide the gap (#4458: fixed byattach_to_player; recurred as #5413212a71dba— the player body path skipsstamp_spell_list, soRaceSpellswas absent and the scriptedAddRaceSpells/RemoveRaceSpellspair no-oped
untilattach_to_playerstamped it fromresolve_racial_spells). The player now carriesCharacterLevel/Background
from the resolved Player record (#4678);CharacterLevelis still unsaved, so the first XP writer tripsvalidate_progression_state(/audit-save). The player'sFactionRanksis re-derived from the record
(Use-Factions terminal) on load, not saved (#5058be3cd9468) — a scripted rank edit is lost on load by
design, exactly as for a respawned NPC. - Magic modifiers (#4415, closed):
byroredux_scripting::magic::apply_constant_modifiersadds constant-spell
amounts to anActorValuespermanent modifier at spawn and onAddSpell/RemoveSpell. Check it never
writes base, that add/remove are symmetric, and that no other writer overwritespermanent_mod
(set_permanentreplaces the whole constant-spell sum — the pre-#5412SetBasereroute did exactly that).
Composition iscurrent = base + set_override + permanent + temporary − damage. Derived-value composition (#5042c9254beb8):CharacterRuleset::actor_valueis the one composer — an authored carried base (ActorValue::base_authored,
set only byset_base) wins; otherwise an actor-generalAbsoluterow supplies the base and the entry's
modifier + damage layers compose on top (a modifier-only placeholder entry once read as the bare modifier:
Finesse CritChance 5).GetActorValue(crates/scripting/src/condition.rs) andmelee_damage_charal_bonusmust both call it; a consumer readingderived_valueor the raw entry alone is
the regression. Player pools (#5039): the player'sPlayerOnlyHealth/AP base is a cache of the
formula, refreshed byCharacterRuleset::refresh_player_only_bases(allocation-free) at stamping, afterattach_to_player's constant spells, and every frame byplayer_derived_stats_system
(byroredux/src/systems/character.rs, Update) — the one documented exception to computed-on-demand
(docs/engine/charal.md§6); a refresh must preserve the damage,set_overrideand modifier layers. build_character_rulesetreturnsNonefor Oblivion/FO76/Starfield; every caller must treatNoneas
"no CHARAL for this game", never "use the default ruleset" (Fallout formulas on a TES actor).resolve=index.actor_value_form_id(editor_id): an unresolved EditorID skips its formula
(push_derivedresolve-or-skip), never registers one keyed on0.- Ordering: base attributes + skills written before dependent derived stats; FO3/FNV class auto-calc
skill = 2 + 2×SPECIAL + ceil(Luck/2)implemented, tag-skill part still absent not guessed.
FO3/FNV NPC Health is the NPC curve, not the player row (#5238c71eeed80):NpcHealthCurveinNpcStatModel::ClassAutoCalc=NPC_DATABase Health +fAVDNPCHealthEnduranceMult·(END + offset) +fAVDNPCHealthLevelMult·(L +level_anchor), measured 5 / 5 / −1 / −1.with_gmstoverlays the Endurance
offset and the two mults only: the level anchor is an engine-hardcoded −1 (the player formula'sLevel − 1;
no level-offset GMST exists in either exe), so a retuned Endurance offset moves only the Endurance term
(#53405ed49ed5c,the_level_anchor_is_engine_hardcoded_and_never_overlaid). Pinsfo3_fnv_npc_health_curve_reproduces_the_vanilla_samples— all 15 infobox samples since #5342274ba13c5, which
also restored the player Health row beside the NPC row in the capture — and the#[ignore]dfo3_fnv_named_npc_health_matches_the_authored_curve. The player-curve constants (bias 90/95, END·20)
on an NPC path are the regression. Non-auto-calc NPCs (ACBS auto-calculate bit clear, ~40% of the FO3/FNV
census) seed their authoredNPC_DATASPECIAL (NpcRecord::data_attributes) instead of the classATTR,
falling back to the class for aDATA-less shell; skills still derive from the SPECIAL, the DNAM per-skill
half is unparsed (#53384957536ba). - Skyrim
derive_skyrim_actor_values: Health/Magicka/Stamina resolve independently (race base +
signed ACBS offset, own AVIF;RaceRecord.starting_*areOption<f32>) — a missing pool suppresses
only itself. The capture's class/level term is deferred and must be disclosed in place (#4454). - One ceiling composition:
ActorValue::undamaged()(base + set_override + permanent + temporary) is the
single ceiling;current()isundamaged() − damage. HUD bar fractions, debug vitals and the SDK projection
read through it (the SDK foldsset_overrideinto base —ActorValueStatehas no override slot); a reader that
re-sums layers by hand is the regression (#5508627149b62,undamaged_includes_every_layer_but_damage). setav/modavwrite the base component, not a derived output the next tick recomputes — except the
player'sPlayerOnlyderived pools:setav/SDKSetBasethere route throughCharacterRuleset::is_player_derived_poolinto theset_overridelayer (ActorValues::set_override, composed
total = formula base + value + any constant-spell modifier), becauserefresh_player_only_baseswould revert a
base write in one tick (#52392464a52d7; re-layered by #5412a614eb273— the first destination,set_permanent, erased carried ability bonuses and made their removal subtract twice;set_override_survives_the_player_refresh_and_keeps_permanent_mod). Sourcing percharal.md§6: the Oblivion
CS SetActorValue note (FO3/FNV inherit it unverified) plus the FO4 CKSetValue"modifiers are left intact".
Non-player targets and actor-general stats keepset_base.
Output:/tmp/audit/character/dim_4.md
Dimension 5: Coverage, Documentation & Doctrine Drift — second-highest yield
Paths: docs/engine/charal*.md, crates/core/src/character/mod.rs docstring, docs/feature-matrix.md, ROADMAP.md
First step: grep -rn 'not yet reachable\|oblivion_pool_regen_config\|when a live .CharacterRuleset' crates byroredux docs ROADMAP.md
- Coverage matrix (publish even with zero findings): family × {ruleset implemented, wired, derived
stats, leveling model, regen wired, affliction wired}. Re-derive wiring fromprofile.rsarms + workspace
greps, never from prose. Today: FO3/FNV/FO4/Skyrim wired, Oblivion blocked, FO76/Starfield absent; regen
and affliction inert everywhere; "leveling model" is a model, not a runtime. - Wiring events rot prose: after any commit that wires/unwires a builder or resource, grep for the old
claim in themod.rsdocstring, the capture Status columns (code ahead of capture is doc rot too),docs/engine/charal.md,docs/feature-matrix.md,ROADMAP.md, and code comments describing an
intended insertion site.mod_docstring_indexes_every_sub_modulechecks module names only. - Cite symbols, not line ranges, in docs (
CharacterRulesProfile::OBLIVION, notprofile.rs:82-87). - Every capture document has an implementation or an explicit "not implemented" note; a capture with
neither is silent scope loss. Vocabulary staystranslate/canonical/resolve(NIFAL/EXAL/PHYSAL/WATAL).
Output:/tmp/audit/character/dim_5.md
Phase 3: Merge
Combine /tmp/audit/character/dim_*.md into docs/audits/AUDIT_CHARACTER_<TODAY>.md: Executive
Summary (severity counts; which families' constants were verified against captures) · Constant
Verification Table (formula × code × document × verdict; a row with no document value is UNSOURCED and
itself a finding) · Coverage Matrix (Dim 5) · Findings by severity, deduplicated · Known-Open
Register (restate the items above; confirm none re-filed). Cross-audit routing: component shape →/audit-ecs; AVIF/CLAS/NPC_ parsing → /audit-esm Dim 4; CTDA evaluation → /audit-scripting;
scheduler access → /audit-concurrency Dim 4; gameplay writers → /audit-gameplay.
Phase 4: Cleanup
rm -rf /tmp/audit/character; tell the user the report is ready; suggest/audit-publish docs/audits/AUDIT_CHARACTER_<TODAY>.md (domain label character; add the matchinggame:* — CHARAL findings are almost always one title's ruleset).