"Audit the SpeedTree (.spt) TLV walker (crates/spt), the placeholder-billboard import, and the TREE→billboard/wind wiring for Oblivion / FO3 / FNV"
Instal·la
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-speedtree Instal·la des del registre de SkillsCat.
SpeedTree Subsystem Audit
Audit crates/spt (~2.8k LOC in src/; .spt is pre-Skyrim only — Skyrim+ trees are NIFs rooted
at BSTreeNode, /audit-nif). It does two things: (1) walks the .spt parameter stream as
tag-length-value data, and (2) emits a placeholder billboard ImportedScene so TREE cells
render something instead of failing or going treeless.
Settled facts (verify against code, then treat as premises):
.sptis a procedural tree definition (parameters, BezierSpline curves, texture names) —
there is no geometry tail to decode (#3808, 2026-09-07; the largest of 159 corpus files is
8,793 B). Pastparser::TAG_MAX(13,999) the stream continues as more parameter TLV in the
14000–22000 tag bands; the earlier0x4E25/0x4E21"geometry-tail markers" were arithmetic
slips (0 of 159 files). Layout notes:crates/spt/docs/format-notes.md.- The walker stops on the true TLV boundary in 159/159 corpus files (#4122, fixed 2026-09-24;
was 86/159): three dictionary entries were mis-sized —10002stride 1→32,10003stride
8→32 (u32count + count × 8 f32),130137→4 B. Guard: opt-inwalker_stops_on_true_tlv_boundary(every non-EOF stop word in the 14000–23000 tail band andparser::best_resync_shift== 0; the same fn drivesspt_tail --culprit). Extract/parse drops
are counted per archive and asserted to zero, so a fatalparse_spterror fails the gate instead
of shrinking its denominator (fixed in b0faf598a, #5139). The precondition
for raisingTAG_MAX/ dictionarying the 14000–22000 bands is now met but not acted on; any
consumer reading pasttail_offsetis still new territory. - SNAM/CNAM are parsed but deliberately not consumed:
SpeedTreeWinduses the neutral(1, 0)
pair because TREE.CNAM's layout is unpinned (#3190).
Not here (/audit-exterior): distant tree/object LOD, generating branch/leaf geometry or
sourcing real tree meshes — an open design question in docs/engine/exal-trees.md §3/§10.
Architecture: Single-pass — small enough to run all dimensions inline.
Read .claude/commands/_audit-common.md and .claude/commands/_audit-severity.md for shared protocol.
Scope
Crate: crates/spt/src/ — parser.rs, tag.rs, version.rs, stream.rs, scene.rs,import/mod.rs, feature-gated recon/mod.rs; public surface in lib.rs (parse_spt,import_spt_scene, SptImportParams, SptScene, detect_variant, dispatch_tag).
Wiring cross-cuts:
byroredux/src/cell_loader/references/synth_child.rs(.sptdispatch) →references/import.rs(parse_and_import_spt,resolve_spt_model_path,resolve_tree_icon_path);cell_loader/nif_import_registry.rs(spt_cache_key,CachedNifImport.speedtree_wind);cell_loader/spawn/mesh_instance.rs(attachesBillboard
frommesh.billboard_modeandSpeedTreeWind, routes throughtranslate_material);byroredux/src/streaming/pre_parse.rs(skips.sptin prefetch viais_spt_model_path, a byte-level
suffix test — a&strbyte-index slice there panicked on a trailing U+FFFD, fixed in c83e4837a, #5137).byroredux/src/scene/nif_loader.rs— the--treeloose route: a parallel path callingimport_spt_scenewithSptImportParams::default()(no TREE metadata).crates/plugin/src/esm/records/tree.rs(parse_tree→TreeRecord: OBND/ICON/MODB/SNAM/CNAM/
BNAM/PFIG;has_speedtree_binary);byroredux/src/systems/billboard.rs(rotation + shared-wind
bend);crates/core/src/ecs/components/billboard.rs(SpeedTreeWind).
Acceptance: ≥ 95% unknown-tag-clean per game in crates/spt/tests/parse_real_spt.rs
(parse_rate_{fnv,fo3,oblivion}_spt; #[ignore], env BYROREDUX_{FNV,FO3,OBL}_DATA; 133 vanilla
files, Oblivion 113) plus walker_stops_on_true_tlv_boundary (159 incl. Shivering Isles); un-decoded trees render a billboard, never an Err out of the cell loader.
Parameters (from $ARGUMENTS)
--focus <dimensions>: comma-separated numbers. Default: all 6.--depth shallow|deep:shallow= walker contract + wiring from source;deep= also run the
corpus harness. Defaultdeep.
Extra Per-Finding Fields
- Dimension: Walker Byte-Accounting | Placeholder Fallback | TREE→Billboard Wiring | Per-Game Variants | Tag Dictionary | NIFAL Material Translation
Phase 1: Setup
mkdir -p /tmp/audit/speedtree.gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels --search "speedtree OR spt OR TREE" > /tmp/audit/speedtree/issues.json.- Read the most recent
docs/audits/AUDIT_SPEEDTREE_*.md(sort by date). Findings it lists as
closed are regression guards —gh issue viewbefore treating any list as current. cargo test -p byroredux-spt(default lane). CI also compiles the feature-gated recon
examples and unit tests (cargo check -p byroredux-spt --features recon --examples,cargo test -p byroredux-spt --features recon --lib).deeponly — corpus:BYROREDUX_FNV_DATA=… cargo test -p byroredux-spt --release --test parse_real_spt -- --ignored --nocapture
(and_FO3_DATA/_OBL_DATA); per-file dumps via the recon examples
(cargo run -p byroredux-spt --features recon --example spt_walk; alsospt_tail,spt_tagmap, …).
Phase 2: Dimensions
Ordered by risk.
Dimension 1: Walker Byte-Accounting
Paths: crates/spt/src/{parser,stream}.rs
First step: cargo test -p byroredux-spt then diff read_payload sizes against tag.rs
Highest risk: most tags have no length framing — the walker advances by the payload kind's
fixed size, so one wrong size desyncs everything after it.
Guards: parser.rs unit tests (tag_13005_* family incl. tag_13005_at_eof_does_not_panic,empty_candidate_is_not_a_plausible_curve_string), tests/parse_synthetic_spt.rs
(generator_output_matches_pinned_bytes, parser_decodes_every_dispatch_arm_against_pinned_fixture),tests/parse_real_spt.rs (opt-in; incl. the #4122 boundary gate).
Checklist:
- Each
SptTagKindadvances exactly its size:U8=1,U32=4,Vec3=12,FixedBytes(n)=n,String=4+len,ArrayBytes{stride}=4+count×stride,Bare=0. Cross-checkread_payload
againsttag.rs. MaybeStringElseBare(tag 13005): peek decides Bare vs String; re-syncs on both arms, an EOF
peek can't panic, and a zero-length candidate is Bare (#3531:Iterator::allis vacuously
true on an empty slice;Barediscards nothing, a mis-read leading0shifts everything).- Stops cleanly at
is_eof()(reached_eof) and at an out-of-range/Unknowntag (recordstail_offset+unknown_tags); never reads past EOF. read_string_lpandArrayBytescap at 64 KiB on the byte count (count × stride), not
justcount, and returnErrrather than OOM.parse_spthas five fatalErrconditions (its docstring lists them: magic mismatch,
mid-payload underflow, string cap, array cap, unrecognized context-sensitive kind) in two kinds —
underflow raisesUnexpectedEof(SptStream::read_bytes), the other fourInvalidData(the
docstring said all-InvalidDatauntil 86cd60621, #5361); all discard the wholeSptScene.
In-range-but-unknown tags are non-fatal — the contract the placeholder relies on. A new fatal
path is HIGH (it kills the cell-loader fallback).- Little-endian, unconditional (every
.sptis__IdvSpt_02_); flag any host-endian read. - A walk stop whose word is outside the tail band, or a non-zero resync shift, is a mis-sized
dictionary entry — pair the shift with the last decoded tag (spt_tail --culprit). The shift
only sees mis-sizing mod 4 (that is how10003hid); the stop-word check catches the rest.
Output:/tmp/audit/speedtree/dim_1.md
Dimension 2: Placeholder Fallback Correctness
Paths: crates/spt/src/import/mod.rs, byroredux/src/systems/billboard.rs
First step: cargo test -p byroredux-spt placeholder
Guards: import/mod.rs tests: placeholder_normals_point_negative_z_for_billboard_arc,placeholder_index_winding_produces_negative_z_geometric_normal, placeholder_uses_obnd_bounds_when_present,non_finite_bnam_falls_through_instead_of_producing_a_nan_quad, leaf_texture_override_wins_over_spt_tag,empty_texture_leaves_path_unset_for_renderer_placeholder.
Checklist:
import_spt_scenealways returns a one-node / one-meshImportedScene; the cell loader only
getsNonewhenparse_sptreturnsErr, and that path must log + skip the REFR without
aborting the cell.- Leaf-texture precedence: TREE.ICON override →
.spttag 4003 (first wins) → unset (renderer
placeholder). - Size precedence in
compute_billboard_size: OBND → BNAM → MODB → 256×512 default, each
throughclamp_billboard_extent(returnsOption, so a non-finite field falls through to the
next tier; a baref32::clampis NaN-transparent and lets a NaN BNAM reach the quad, bounds and
BLAS build, #3529). OBND beats BNAM on purpose (BNAM clamps tall trees). Measured: vanilla
Oblivion has BNAM on 142/142 TREE records and no OBND, so the MODB tier is reached by 0 vanilla
records; whether Oblivion should size from BNAM or MODB is an unanswered format question with
no tracking issue (#3740 closed 2026-08-31 for its comment half only; still unanswered as of the
2026-10-05 audit) — a finding needs a source, not a re-file. - Winding: front-face normal is
-Z, indices[0, 3, 2, 2, 1, 0], because the billboard system
rotates viaQuat::from_rotation_arc(-Z, look_dir);bs_boundZ-up→Y-up swap useszup_to_yup_poswith half-extents(hx, hz, hy). - Billboard mode
BsRotateAboutUpincompute_billboard_rotationis a world-up yaw lock (no
local frame available) — confirm it never drifts into pitch, which would tilt every tree.
Output:/tmp/audit/speedtree/dim_2.md
Dimension 3: TREE → Billboard Wiring (highest report yield)
Paths: byroredux/src/cell_loader/references/{synth_child,import}.rs, byroredux/src/cell_loader/{nif_import_registry,spawn/mesh_instance}.rs, byroredux/src/systems/billboard.rs, crates/plugin/src/esm/records/tree.rs
First step: cargo test -p byroredux parse_and_import_spt and, with data, cargo test -p byroredux vanilla_tree -- --ignored
Guards: references/import_tests.rs — vanilla_tree_models_all_resolve andvanilla_tree_icons_all_resolve (env-gated corpus gates), the pure-closure resolver tests
(tree_model_resolves_leading_separator_bare_names_under_the_measured_directory,tree_icon_resolves_bare_filenames_under_the_measured_directory),parse_and_import_spt_surfaces_billboard_mode_on_mesh, malformed_spt_still_produces_placeholder;systems/water.rs pins the billboard system's SpeedTreeWind query shape by source text.
Live end-to-end gate: docs/smoke-tests/m-trees.sh [fnv|fo3|obl|all] (per-game entity + Billboard
floors; byro-dbg's (no entities) reads as a real zero so a broken .spt route is not blamed on
the attach, 811499b65, #5360; exits 0 on SKIP without data — run and reported via /audit-runtime).
Checklist:
- The
.sptroute fires when the TREE base's MODL ends in.spt; the TREE record comes fromrecord_index.trees; mixed.nif+.sptREFRs coexist. - Model path: every vanilla
.sptMODL is a leading-separator bare filename (154/154) and the
binaries live under a top-leveltrees\folder, outsidemeshes\.resolve_spt_model_pathprobes
verbatim →meshes\-rooted → bare name underSPT_CANDIDATE_DIRSvia exact-key lookup
(has_mesh_exact/extract_mesh_exact), for.sptonly; the streaming prefetch skips.spt
(a miss there would write a negative registry entry that masks the sync-side resolve). Pre-fix
0/154 records resolved. Hoistingtrees\prefixing into the shared normaliser is the regression. - Icon path: every vanilla
TREE.ICONis a bare filename;resolve_tree_icon_pathprobes
verbatim, thentextures\trees\leaves\(93/93 measured), then…\billboards\; a miss warns
naming the ICON. Same scoping rule — never in the sharednormalize_texture_path. - Cache key is per-(model path, TREE record) via
spt_cache_key(records sharing one.spt
carry different ICON/OBND/BNAM); NIF keying unchanged. parse_and_import_sptreturns the sameCachedNifImportshape as any model with synthetic
defaults the spawn path must not mis-read as NIF-rooted (bsx_flags = 0,root_flags = 0,attach_light_offset = None).- Billboard attach is at mesh level:
import_spt_scenesetsmesh.billboard_mode = Some(BILLBOARD_MODE_BS_ROTATE_ABOUT_UP)and the functional insert isspawn/mesh_instance.rs'sif let Some(raw) = mesh.billboard_mode(without it the quad spawns
static).placement_root_billboardis a dead seam for SpeedTree (no producer sets it;spawn.rsconsumer is unreachable) — target findings at the mesh-level insert. - Wind: placeholders carry
SpeedTreeWind::new(1.0, 0.0)(neutral; cached asCachedNifImport.speedtree_wind, attached inmesh_instance.rsand both loose-route sites innif_loader.rs).apply_speedtree_windinbillboard.rsbends the canopy from the sharedWindField: gust is clamped to a finite non-negative value before use (#3194), response/stiffness
clamped, keyed off theSpeedTreeWindmarker rather than one billboard enum.WindField.directionis engine-xz "blows toward" (#4729): the bend axis tips the crown
downwind (same sense as the grass beneath it) and the gust wave travels with the wind —
pinned inspeedtree_billboard_bends_with_shared_weather_wind/reversing_wind_reverses_mean_lean. The billboard
system's scheduler access declaresreads::<SpeedTreeWind>()(boot/schedule/late.rs).SpeedTreeWindis rebuilt on import, not saved (save_io/registry_completeness_tests.rs).
Do not project TREE.CNAM into it (unpinned, #3190). TreeRecordcapture is lossless for what the importer reads (OBND→bounds, ICON→leaf_texture,
MODB→bound_radius, BNAM→billboard_size); CNAM is 8 × f32 on all three games (#3751) — flag
mis-parses, not the unconsumed SNAM/CNAM.- Cell unload despawns the placeholders; no leaked BLAS entries for the quad.
Output:/tmp/audit/speedtree/dim_3.md
Dimension 4: Per-Game Variants & Route Divergence
Paths: crates/spt/src/version.rs, byroredux/src/scene/nif_loader.rs
First step: grep -rn 'detect_variant' byroredux/src crates/spt/src
Checklist:
detect_variantrecognises any__IdvSpt_02_-prefixed file but cannot separate Oblivion (4.x)
from FO3/FNV (5.x) at the magic level — it defaults toV5Fnv. It is a logged sanity check
today; a consumer that branches on it is a real bug (the placeholder path is
variant-agnostic). Guards:detect_variant_recognises_idvspt_magic,detect_variant_unknown_for_non_speedtree_inputs.MAGIC_HEADis the exact 20 bytes (u32 1000,u32 12,"__IdvSpt_02_"); a flip or short input rejects.- Route divergence: the cell route threads TREE metadata; the
--treeroute uses defaults
(256×512, no ICON override). Both must callparse_spt+import_spt_scene; flag drift in
that call, and any sizing bug the default route would mask for the cell route. - Oblivion vs FO3/FNV share one walker (same magic, same dictionary); the undictionaried
14000–22000 bands recur across ~151/159 files — a claim they are identical across games is unverified.
Output:/tmp/audit/speedtree/dim_4.md
Dimension 5: Tag Dictionary
Paths: crates/spt/src/tag.rs, crates/spt/docs/format-notes.md
First step: cargo test -p byroredux-spt tag::
Lower risk, but a wrong size is the Dim 1 desync trigger — spot-check.
Guards: tag.rs tests (fixed_byte_payload_tags, string_payload_tags,unknown_for_out_of_dictionary_tags, …).
dispatch_tagmaps ~120 tags conservatively: anything absent →Unknown→ the walker stops.
Dictionary size is not a gap. Sample fixed sizes against theformat-notes.mdtables (8003/8005/
8009 = 52 B, 13008 = 11 B, 13013 = 4 B, ArrayBytes 10002 / 10003 stride 32 — the #4122
byte-verified sizes); a size contradicting the observed histogram is MEDIUM. 12002 (16 B) /
12003 (20 B) were unobserved before #4122 and now decode cleanly in the corpus tail
(format-notes.md2026-09-24; thetag.rs/scene.rs/stream.rsprose was brought in line
in 567d7e064, #5138) — flag only if a real sample contradicts them, or if doc prose drifts back
to the pre-#4122 desync state.- Confounder tags (
4096,5376— string-length values inside the tag band) must stayUnknown. - A tag at ≥ 1% corpus frequency still
Unknownneeds aformat-notes.mdrationale (LOW).
Output:/tmp/audit/speedtree/dim_5.md
Dimension 6: NIFAL Material Translation for Placeholders
Paths: crates/spt/src/import/mod.rs (placeholder_billboard_mesh), byroredux/src/material_translate.rs
First step: cargo test -p byroredux-spt placeholder_billboard_sets_foliage_pbr_overrides
The placeholder flows through the single NIFAL boundary; single-boundary / no-fabrication
findings belong to /audit-nifal, not here.
- Both routes (
scene/nif_loader.rs,spawn/mesh_instance.rs) reachtranslate_material; no
parallel "spt material" path. - Import-side non-PBR defaults hold:
is_pbr/from_bgsm/external_material_resolvedallfalse(viaDefault— the last is the glass-promotion provenance, #4283), explicit foliage
overridesmetalness_override: Some(0.0)/roughness_override: Some(0.85)(aNone
re-opens the keyword-classifier substring collision — Boxwood→wood, Elderberry→glass);emissive_source: EmissiveSource::None; two-sided alpha-test cutout (alpha_test, threshold
0.5, func 6 GREATEREQUAL,has_alpha: false) survives translation with the silhouette
preserved. SpeedTree never resolves a BGSM/BGEM.
Output:/tmp/audit/speedtree/dim_6.md
Phase 3: Output
Write findings to docs/audits/AUDIT_SPEEDTREE_<TODAY>.md in the base finding format. Suggest/audit-publish (labels speedtree + terrain-exterior; add game:* when specific to one
title's .spt corpus).