matiaszanolli

Renderer Audit

"Deep audit of the Vulkan renderer — pipeline, sync, memory, shaders, ray tracing, denoiser"

matiaszanolli 28 3 Updated 1w ago
GitHub

Install

npx skillscat add matiaszanolli/byroredux/claude-commands-audit-renderer

Install via the SkillsCat registry.

SKILL.md

Renderer Audit

Read .claude/commands/_audit-common.md and .claude/commands/_audit-severity.md for shared protocol (layout, Key Reference Docs, methodology, dedup, context rules, finding format; the RT/SSBO/GPU-struct/denoiser severity floors used below).

Audit the Vulkan renderer's GPU shading and sync correctness: ray tracing, SSBO/UBO indexing, GPU-struct layout, synchronization, GPU memory, the deferred-lighting/resolve chain (G-buffer, SVGF, composite, TAA, bloom, FSR, presentation) and the per-feature passes.

Architecture: Orchestrator. Each dimension runs as a Task agent (max 3 concurrent).

Do NOT restate GPU-struct layouts, descriptor bindings, G-buffer formats or submission order here — docs/engine/shader-pipeline.md is the code-verified reference; docs/engine/memory-budget.md owns VRAM/RAM ceilings, LRU thresholds, deferred-destroy depth. Audit against them; a divergence is a finding (or the doc is stale — say which).

Scope boundary. Ground cover, sky-cube bake/prefilter/SH, cloud march, weather/sun, terrain splat/LOD and water translation belong to /audit-exterior. This skill keeps the renderer-wide rules those passes obey (struct lockstep, barriers, teardown, descriptor lifecycle, shading-side consumption) — apply Dims 3/4/5 to their resources; do not audit their content.

Verification discipline (No-Guessing)

  • Anchor findings on symbols (fn/struct/const/test names), never file:NN; confirm by grep; drop unconfirmable claims. Backticked .ext paths must resolve now (_audit-validate.sh).
  • Recast resolved issues as regression guards ("verify X still holds").
  • No speculative Vulkan changes: never propose render-pass/pipeline/barrier edits whose failure is invisible to cargo test; frame as needs RenderDoc (or a BYRO_VALIDATION=1 sync-validation run) and stop at the observation.
  • Never hard-code FPS/ms; cite ROADMAP.md's live Bench-of-record and the open items it names (R6a-regress-22, re-stated by #5128 as a camera move plus a small residual; R6a-stale-25 — the a37fcba3c record predates every frame-path change since #5154, so no HEAD frame-time claim is current until it is re-run).
  • Guard-first: for each Guard: line, run the named cargo test -p byroredux-renderer --lib <filter> (bin-crate guards: -p byroredux --bin byroredux; core: --features inspect), confirm the tests exist, are not #[ignore]d and not vacuous, then spend the budget on what the guard cannot see. Source-shape tests prove a string is present, not that the GPU behaves; a scan must read source_scan::production_text (a whole-file include_str! scan is satisfied by the test's own literals).

Parameters

  • --focus <dimensions>: comma-separated numbers (e.g. 1,3,7). Default all. --depth shallow|deep: patterns only vs traced invariants. Default deep.

Extra Per-Finding Fields

  • Dimension: AS Correctness | Ray Queries | GPU-Struct Layout | Material Table | Sync/Barriers | Pipeline/RenderPass | Memory/Lifecycle | NIFAL Material | Tangent-Space | Denoiser/Composite | TAA | Bloom | Volumetrics | Caustics | Water | Skinning | Camera-Relative Precision | Disney BSDF | Soft Shadows | Sky/Weather | Light Animation | FSR/Presentation | Debug/Telemetry | Cornell Harness

Phase 1: Setup

  1. Parse $ARGUMENTS. mkdir -p /tmp/audit/renderer.
  2. Dedup baseline: gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/renderer/issues.json.
  3. Read the newest docs/audits/AUDIT_RENDERER_*.md (its Stale skill premises first) and docs/engine/shader-pipeline.md + memory-budget.md. Run scripts/check-shader-artifacts.sh (stale-SPIR-V gate, incl. triangle_early.frag.spv) before trusting any shader-side guard.
  4. Delta scoping: run each dimension's First step: with --since=<last report date>; dimensions whose Paths are untouched get a guard run + skim, not a deep trace.

Phase 2: Dimensions

Ordered by risk: AS/SSBO indexing and struct layout corrupt every frame silently; sync/memory compound; shading is mostly visual.

CRITICAL tier — silent whole-frame corruption

Dimension 1: Acceleration structures (BLAS/TLAS)

Paths: crates/renderer/src/vulkan/acceleration/, context/resources.rs (build_blas_batched, restore_missing_static_blas_for_draws), context/skinned_blas_refit.rs, context/dispatch_skin_and_cluster.rs (TLAS build site), context/begin_frame_recording.rs (grow_instance_ssbos → build_instance_map capped by instance_map_cap), context/build_and_upload_instances.rs (the SSBO writer; clamp_batches_to_instance_capacity, #4726), scene_buffer/constants.rs, byroredux/src/app_step.rs (step_static_blas_restore), byroredux/src/loading_screen.rs (while a load cover is up it decides the whole TLAS membership and drops BLAS).
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/acceleration crates/renderer/src/vulkan/context/resources.rs crates/renderer/src/vulkan/context/begin_frame_recording.rs
Floor: wrong geometry/address = CRITICAL; freeing an AS an in-flight frame still reads = CRITICAL; missing build→read barrier = HIGH.
Guard: cargo test -p byroredux-renderer --lib acceleration (acceleration/tests/: barriers, deferred-destroy countdown, residency, admission, telemetry, decide_use_update membership) + build_tlas_is_preceded_by_an_unconditional_as_write_to_as_read_barrier (dispatch_skin_and_cluster.rs; pins order and gate, not the dst access mask) + static_blas_recovery_covers_both_sources_and_protects_current_draws (resources.rs) + bin-crate static_blas_recovery_runs_between_frames_not_in_the_render_driver (app_step.rs).
Checklist (what the guards cannot see):

  • BLAS geometry R32G32B32_SFLOAT @ 0, UINT32 indices, OPAQUE. Flags (acceleration/constants.rs): STATIC_BLAS_FLAGS (FAST_TRACE|ALLOW_COMPACTION), SKINNED_BLAS_FLAGS (FAST_BUILD|ALLOW_UPDATE — deliberate, measured), UPDATABLE_AS_FLAGS (TLAS); BlasEntry.built_flags must equal the refit flags (VUID-03667).
  • instance_custom_index == instance SSBO index — the load-bearing AS/SSBO contract (24-bit field; MAX_INSTANCES < 1 << 24 const-asserted). Padded/unused slots must not break UPDATE mode. decide_use_update keys on last_blas_addresses and last_entity_ids membership (plus needs_full_rebuild, last_blas_map_gen); build_tlas itself forces BUILD when instance_count != built_primitive_count; invalidate_tlas_recording forces BUILD after an unsubmitted recording; tlas_built_this_frame (reset/set lifecycle pinned, #4843) gates every ray_query_tlas consumer. The TLAS instance map is capped at the slot's real capacity (instance_map_cap, #4833); scene draw batches (clamp_batches_to_instance_capacity, #4726) and the UI overlay's firstInstance (#4722) re-clamp after the grow resolves. Non-finite transforms are dropped from the TLAS (#4633) and counted (non_finite_transform).
  • Transform column-major mat4 → 3×4 row-major; TRIANGLE_FACING_CULL_DISABLE is gated on draw_cmd.two_sided — keep the gate (same two-sided intent as the raster PipelineKey); no shader ray requests facing culling today, so the bit changes nothing (#4580) — a future facing-cull ray flag must revisit it.
  • Instance mask (shadow_mask_for_instance, mask_divert_cause, predicates.rs): every actor/glass/effect instance lands in exactly one category; refractive glass (incl. multi-layer-parallax refractors) and effect kinds stay outside the opaque shadow cull mask; is_legacy_fx_card (blended ∧ (kind 102 ∨ dst_blend == 0)) routes legacy FX cards to EFFECT (#4576/#4834).
  • BLAS budget = blas_budget_for_heap(heap, reserved), the reservation from screen_scaled_reservation_bytes (per-pass enumeration render_extent_bytes_per_pixel_x1024; formula and eviction policy: /audit-performance Dim 3): verify it bills every render-extent pass, the output-extent upscaler images and FSR SDK bytes (FrameUpscaler::sdk_memory_bytes), and is re-derived on resize after the upscaler is recreated (context/resize.rs).
  • Shrink wiring: the two TLAS shrinks run at the draw_frame tail on the next slot (shrink_tlas_to_fit only records tlas_shrink_pending); shrink_blas_scratch_to_fit runs at cell unload (cell_loader/unload.rs) and resize; constants match memory-budget.md.
  • Scratch sizing and failure paths: every AS scratch (skinned shared scratch, its shrink peak and refit assert, the TLAS per-frame scratch) is sized max(buildScratchSize, updateScratchSize) — the spec does not order them (#5195, BlasEntry::update_scratch_size); a replacement scratch is allocated before the old one retires at every blas_scratch_buffer site (#4884, blas_scratch_realloc_order_tests). One-time AS submits follow the #4891 policy — destroy on NotSubmitted, leak on may_be_in_flight() — build_blas_batched included (#5201). The helper's own vkWaitForFences failure arm applies the same rule to its objects (#5270, wait_failure_arm_disposes_only_on_device_loss): free the cmd buffer/fence only on ERROR_DEVICE_LOST, otherwise leak and poison the reusable-fence mutex via a caught panic. The TLAS scratch peak recorded for the shrink is max(build, update) too (#5250).
  • Geometry-SSBO compaction/rebuild (Dim 5) must not invalidate BLAS (static BLAS self-contained; global-only LOD sources gated by is_geometry_resident at restore; skinned BLAS use per-mesh index buffers).
  • rt.integrity chain: integrity_snapshot() → fill_rt_integrity_stats (context/telemetry.rs) → RtIntegrityStats::verdict (tlas_emitted == tlas_eligible; the three missing_* cause counters, non_finite_transform (#5200), lights_dropped and the two cluster counters all zero) runs per frame. Grep the METHOD integrity_snapshot, not the struct (struct-grep produced two false "no reader" findings).
  • Documented ceilings — quantify before filing: (a) a synchronous multi-cell --grid burst can false-evict a not-yet-drawn BLAS via the shared frame_counter bump ("Deferred pending a" in blas_static.rs; unreachable while static BLAS bytes ≤ budget on the 12 GB dev card); (b) all skinned builds/refits share one scratch and serialize.
    Output: /tmp/audit/renderer/dim_1.md

Dimension 2: Ray-query shading & coordinate spaces

Paths: crates/renderer/shaders/{triangle.frag,triangle.vert,water.frag}, shaders/include/{raytrace,ray_hit,ray_origin,lighting,shadow_common,shadow_transport,mesh_id,math_common}.glsl, scene_buffer/{ray_budget,light_history}.rs, byroredux/src/render/camera.rs, crates/core/src/ecs/components/camera.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/shaders, then cargo test -p byroredux-renderer shader_contract.
Floor: SSBO index mismatch = CRITICAL; self-intersection / wrong tMin = HIGH; a path mixing the two coordinate conventions = HIGH.
Guard: cargo test -p byroredux-renderer shader_contract (scene_buffer/shader_contract_tests.rs — GLSL source-shape: scale-aware ray origins, render-origin-relative varying + skinned rebase, bindless-index masking, shared raster/ray alpha chain, Gram-Schmidt zero guards, stable-surface-ID reservoirs, GI zero-budget floor, the window-portal orientation pin, and the depth family, e.g. depth_view_space_decodes_true_depth_from_inv_view_proj_alone), triangle_frag_keeps_glass_identity_and_ior_across_rt_lods (shader_constants.rs), light_history::tests.
Checklist:

  • instance_custom_index (never gl_InstanceID) indexes GpuInstance[] (Dim 1 contract); materials[instance.material_id] and the vertex/index SSBOs (set 1 bindings 8/9) use the offsets the Rust upload writes. RT gate sceneFlags.x > 0.5 precedes every fragment-shader ray query (push-constant-gated compute passes excepted).
  • Shadow rays: the direct-light visibility origin is offset toward the light side of the triangle plane (offsetRayOriginForDirection(fragWorldPos, geometricNormal, L), #5018, direct_shadow_rays_orient_their_origin_toward_the_light) — so the front lobes need the geometric-horizon clamp (#5191) and the transmission lobes are kept out of the shared traced visibility (#5192) and take their own shadow ray, traceShadowTransmittanceSkippingInstance (shadow_transport.glsl, #5249: walks past the receiver's own instance up to MAX_TRANSMISSION_SELF_SKIPS, defers to the alpha/glass-aware trace on the first foreign hit, exhausted budget reports lit; Dim 10). Every lit-returning exit of the walk finishes with traceShadowGlassLegRemaining (the glass-only leg of the remaining segment) and the foreign-hit handoff carries the full mask incl. VISIBILITY_LAYER_GLASS, because the walk itself sees only opaque layers and a pane between receiver and light is invisible to it (regression guards: #5455 6d64634aa / #5565 e3dfd3a31, pinned by transmission_skip_walk_hands_the_full_mask_to_the_foreign_leg — a source-shape pin, so judge the glass handoff by reading the GLSL); TerminateOnFirstHit, point/spot disk vs directional-cone jitter (sun_angular_radius 0.020 rad); the finalize arm draws its disk samples from ditheredSequence2 (math_common.glsl: per-pixel R2 sequence rotated by a blue-IGN mask, 4d991bad7, #5369 residual; shadow_ray_disk_samples_are_dithered_low_discrepancy). Reflection: reflect(viewDir, N) sign, roughness/metalness gate, barycentric UV from the vertex SSBO. GI: cosine hemisphere, distance cutoff, miss → sky/ambient without NaN/inf. Noise is deterministic per pixel per frame so TAA/FSR converge: ReSTIR and GI streams use the integer PCG hash2_pixel_frame, the shadow-ray disk taps use ditheredSequence2; interleaved-gradient noise survives only in the glass roughness scatter, inside that dither's mask and in the compiled-out legacy-WRS arm (64-frame wrap, #4951).
  • Glass/IOR: Frisvad basis (not cross(N, up)); window-portal demote breaks coincident-glass loops; the portal gate/ray/origin orient from the viewer-facing N_bias and its sky sample follows −V (#4832/#4950); interior miss → cell ambient, not open sky — the sky cube is baked from portal_outdoor_sky in every interior, so the interior gate is each caller's jitter.w, not exteriorSkyRadianceOr's; MAT_FLAG_THIN_GLASS sends non-occluding panes down the zero-ray Fresnel path, occluding glass keeps Snell/RT at every RT LOD. The glass ray budget is qualityTier-gated (budgetTier); rayBudgetCount is telemetry only (negative pin on old + glassRayCost <= rayBudget.glassRayLimit in shader_constants.rs; no CPU reads it).
  • AdaptiveRayBudget (ray_budget.rs; fed max(main_render_ms, volumetrics_ms) by reset_ray_budget): cold start = tier 0, overload drops at once, headroom is spent only after a long stable window; --rt-test-ray-quality-tier pins it (cold_start_uses_the_watchdog_safe_quality_floor).
  • ReSTIR-DI: neighbour reservoirs are rejected on the 25° cone of the geometric normal (SPATIAL_NORMAL_COS; octEncoded into pad0, reservoir stays 32 B — RESERVOIR_STRIDE); surface tag = inst.surfaceId & RESERVOIR_SURFACE_MASK. Reservoir light indices cross frames through previousLightToCurrent (Dim 3 light header). The direct-light EMA (prevAccum) accumulates the shadow ratio, not shaded colour (#4942), and lags on mixed-visibility clusters by design (mixed_visibility_clusters_keep_a_documented_ratio_lag, #5020). Its depth follows restir_history_mode (dof_params.w, named HISTORY_MODE_* and compared only against HISTORY_MODE_PARKED/HISTORY_MODE_SCENE_STATIC − 0.5): RESTIR_HISTORY_CAP_MOVING 16 / RESTIR_ALPHA_FLOOR_MOVING 0.1 otherwise, a RESTIR_HISTORY_CAP_PARKED 1024 / RESTIR_ALPHA_FLOOR_PARKED 0.002 parked tail on mode 2 (fully static) and mode 3 (parked, light-rig geometry static under intensity flicker — restir_rig_static_last_build from the intensity-blind light_rig_geometry_key — per-light fold_light_rig_geometry_key_for hashes combined with a commutative wrapping add, so the priority-sorted light order cannot re-key it (#5369 2a7223121; #5507 fixed in a851c625d); light_rig_geometry_key_ignores_intensity_but_not_geometry, light_rig_geometry_key_is_independent_of_light_order, flicker_only_changes_keep_the_deep_ema_mode; the cap/floor names ride temporal_reuse_validates_surface_depth_and_does_not_seed_neighbor_radiance and their values restir_direct_light_ema_constants_come_from_the_generated_header, #5617). The lag fixtures read the live parked α (mixed_visibility_clusters_keep_a_documented_ratio_lag, ~346-frame switch-off half-life, #5567); cornell_parked_penumbra_converges_at_one_shadow_ray (byroredux/tests/cornell_rt_oracle.rs) is #[ignore]d (needs an RT device). Regression guard (#4940, fixed in a37fcba3c): the enumerated fresh stream is weighted restirWSum *= restirM before any reuse combine, so W estimates the sum of shadow-casting lights, not their mean (fresh_enumeration_is_weighted_by_candidate_count, fresh_estimator_expectation_is_the_sum_of_light_radiance, restir.rs). The reservoir clear is published to fragment SHADER_READ too (#5062). BC1 punch-through: texColor.a = 1.0 unless INSTANCE_FLAG_DIFFUSE_ALPHA (set in build_and_upload_instances.rs from format_has_alpha, which excludes BC1_RGBA).
  • Baked sky is read only through exteriorSkyRadianceOr / exteriorSkyDiffuseOr (include/bindings.glsl), gated on exteriorSkyTint.w: set 1 binding 20 is PARTIALLY_BOUND, so an ungated skyCube read is UB. Every exterior sky-escape site (GI miss, metal reflection, glass reflect/refract miss, water reflection miss) must use the helper (every_sky_cube_consumer_gates_on_the_ready_flag, owned by /audit-exterior).
  • Coordinate spaces (#1495/#1496): raster is render-origin-relative; RT is absolute (TLAS, ray origins, lighting, fog: worldPos_rel + render_origin). Rigid GpuInstance.model is rebased on the CPU (rebase_model_matrix); triangle.frag reconstructs absolute at the top of main() so dFdx/dFdy consumers see small magnitudes. Secondary-hit frames pose smooth normals/tangents from the current bones[] palette (getHitVertexTransform, ray_hit.glsl), not mat3(inst.model). RT_ABSOLUTE_PRECISION_CEILING (2^20, cell_loader/references/mod.rs) bounds every absolute-space consumer — a sin() of absolute world XZ is the recurring trap.
  • Depth: reversed-Z sits behind one authority (DepthMapping / ACTIVE_DEPTH_MAPPING, GLSL include/depth_convention.glsl) and ships Conventional by design (the flip is an operator RenderDoc job) — do not file "should use reversed-Z", do file any site restating the convention by hand (the one decode helper is depthViewSpace(invViewProj, ndcXY, z); it reads no fog lanes, #4831). NEAR_PLANE_BU_SCALE (5.0) applies only to BU-scale game content (Camera::for_content_scale); measure with depth.stats.
    Output: /tmp/audit/renderer/dim_2.md

Dimension 3: GPU-struct layout & material table

Paths: scene_buffer/{gpu_types,constants,upload,buffers}.rs, crates/renderer/src/vulkan/material.rs, shader_constants_data.rs, shaders/include/{bindings,shader_constants}.glsl, the standalone GLSL mirrors (grep struct GpuInstance / struct GpuLight / CameraUBO: today GpuInstance in triangle.vert, ui.vert, water.vert, caustic_splat.comp, groundcover_models.comp; GpuLight in cluster_cull.comp, caustic_splat.comp, volumetrics_inject.comp; plus the GcCameraUBO prefix in groundcover_blade.vert), byroredux/src/render/{mod,static_meshes}.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/shaders/include/bindings.glsl crates/renderer/src/vulkan/scene_buffer crates/renderer/src/vulkan/material.rs, then cargo test -p byroredux-renderer -- scene_buffer material.
Floor: #[repr(C)] struct drifting from its shader mirror = HIGH (silent per-instance/per-material corruption).
Guard: sizes gpu_instance_is_160_bytes_std430_compatible, gpu_camera_is_368_bytes, gpu_light_is_64_bytes, gpu_terrain_tile_is_176_bytes (scene_buffer/gpu_instance_layout_tests.rs), gpu_material_size_is_432_bytes + gpu_material_field_offsets_match_shader_contract (material_tests.rs); mirrors every_shader_struct_is_classified (shader_contract_tests.rs: every GLSL struct must be Guarded / MirroredPendingGuard / ShaderLocal) + the per-struct lockstep tests it names; hash_gpu_material_fields_covers_every_gpu_material_field; UBO sizes every_remaining_uniform_block_size_matches_its_host_struct (reflect.rs), composite_and_volumetrics_uniforms_match_rust_field_order (composite.rs); light header history_header_has_exact_std430_offsets (light_history.rs). The gpu_material_size_claims module (no_file_states_a_stale_gpu_material_size, material_tests.rs) scans .claude/commands/ for stale sizes of every size-pinned struct (GpuMaterial, GpuLight, GpuTerrainTile, GpuInstance, GpuCamera, #5203) — a size written here must equal the live one.
Checklist (blind spots):

  • Every MirroredPendingGuard entry (today only Reservoir, whose Rust side is a byte-stride constant) is an unguarded Rust↔GLSL contract — diff by hand when either side changed. The ground-cover and bench mirrors are Guarded by name_diverging_glsl_rust_mirrors_stay_in_lockstep, which runs assert_glsl_matches_rust (count, name/order, type; padding compared like any field — both sides spell pads out) — new mirrors go through that table, and a Rust field the GLSL names differently needs a declared NameAlias. A ShaderLocal classification must be true: a byte stride the host sizes a buffer from is a counterpart, not shader-local (regression guard: GcDrawIndirect reclassified Guarded against GC_DRAW_INDIRECT_STRIDE in de7f46578, #4956; the same commit pins groundcover_blade.vert's GcCameraUBO field-for-field as a prefix mirror of GpuCamera, #4957).
  • Layout tests miss a semantic change to an existing lane (GpuCamera.exterior_sky_tint.w = sky-bake ready flag, sky_tint.w = sun angular radius, render_debug = the only u32 lane in GpuCamera, its .w = packed weather surface, dof_params.w = the #4942/#5369 history mode 0–3, named HISTORY_MODE_* in shader_constants_data.rs (every_camera_ubo_mirror_describes_dof_w_as_the_history_mode is a literal-substring match per mirror for modes 0–3, all five comments carry mode 3 since #5532 b27e52c71; a mode 4 needs the test and the five comments edited together), GpuInstance.surface_id @108, GpuTerrainTile lanes @160/@164): confirm every reader, the rustdoc and shader-pipeline.md carry the new meaning.
  • Light SSBO (set 1 binding 0 and the cluster/caustic/volumetrics sets): count, 3 pads, previousLightToCurrent[MAX_LIGHTS + 1], GpuLight[] — a 4112 B header before 64 B lights (four vec4, no identity lane since #5055); the dirty hash (hash_light_upload) covers count, lights and remap. ReSTIR identities are CPU-only: FrameInputs.light_ids ([[u32; 4]], parallel to the lights) permuted only through sort_lights_by_priority_with_ids (light_history.rs, #5204) — a site sorting lights without their ids silently remaps reservoirs.
  • GpuMaterial fields are scalar f32/u32 only (never [f32;3]), no padding, so byte identity has no uninitialised bytes; crate::vulkan::buffer::byte_view (NoUninit) is the one sanctioned view. A new field needs the Rust struct, GLSL struct GpuMaterial (only in include/bindings.glsl), an offset assertion and its producer (DrawCommand/Material → to_gpu_material).
  • Flag constants (INSTANCE_FLAG_*, MATERIAL_KIND_*, MAT_FLAG_*, DBG_*/DBG_BITS, RENDER_LAYER_*, FOG_VOLUME_SHAPE_*, TONEMAP_OP_*) come from shader_constants_data.rs → include/shader_constants.glsl, never hand-written shader-side (build.rs only emits the header from the SHADER_DEFINES table, which declares each define once, #5097; the EV100 meter's LIGHT_METER_CALIBRATION_K / SENSOR_SENSITIVITY_S / EXPOSURE_METER_S_OVER_K are single-sourced there too, #5254 — a hand-typed 8.0 in exposure_meter.comp is the regression); compute workgroup widths, reduction strides and SH count (EXPOSURE_METER_WORKGROUP, SKY_IRRADIANCE_WORKGROUP, SKY_SH_COEFFICIENTS) and the ReSTIR EMA caps/floors/HISTORY_MODE_* are named here too (#5620, #5617) — a literal local_size/div_ceil(8) is the regression; the DBG_* mask already reaches bit 31 (DBG_VIZ_SELECTED_LIGHT), so the next flag needs a wider lane.
  • Capacity: MAX_INSTANCES = 0x40000, MAX_MATERIALS = 16384, MAX_INDIRECT_DRAWS = MAX_INSTANCES, MAX_LIGHTS = 1023 (const-asserted vs the ReSTIR light mask). Instance SSBOs grow on demand from INITIAL_INSTANCE_CAPACITY (SceneBuffers::ensure_instance_capacity, after the frame fence wait): a grow rewrites scene-set bindings 4 and 18 in place, and every other persistent descriptor naming the instance buffer must be rebound — today only the caustic set (CausticPipeline::rebind_instance_buffer); a new consumer needs the same. The grow runs twice per frame (before the TLAS map; again with the model-tier tail after skin/cluster recording) — sound only while nothing before it binds the scene set (nothing_before_the_final_grow_binds_the_scene_set). Uploads clamp to the slot capacity with a one-shot warn; over-cap material intern returns id 0 + warn!; the material upload uses the unique count min(table.len(), MAX_MATERIALS), never interned_count() (the dedup denominator).
  • Material table: MaterialTable::intern/intern_by_hash dedup on hash_gpu_material_fields = one FxHasher::write over GpuMaterial::as_bytes (#4201; DrawCommand::material_hash builds the struct and calls it, so lockstep is by construction — material_hash_matches_gpu_material_field_hash only guards against a second walk). Dedup-ratio telemetry (ctx.scratch) must not drop even when correctness holds; N copies of a material render byte-identically; no per-instance field in GpuInstance belongs in GpuMaterial. Particle colour fade is quantized (quantize_fade, render/particles.rs) so it cannot defeat dedup.
  • Neutral-value consumption (multiplicative roles detail, tint, dark): per-role colour-space handling and shader arithmetic are only correct together and no test ties them — a blank/neutral vanilla map must be the identity (Material.detail_neutral → GpuMaterial; tint multiply gated on a real alpha channel), not a darkening.
    Output: /tmp/audit/renderer/dim_3.md

HIGH tier — sync, memory, translation, resolve chain

Dimension 4: Synchronization, command recording & pipeline state

Paths: context/{draw,frame_params,sync_and_acquire_frame,begin_frame_recording,geometry_pass,post_passes,resize,helpers}.rs (frame_params.rs, #5087, holds the pure parameter assembly split out of draw.rs: build_composite_params, taa_jitter, FrameInputs, group_state, needs_two_sided_blend_split, should_use_indirect_draws), vulkan/{sync,pipeline,descriptors,gbuffer,reflect,egui_pass}.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/context crates/renderer/src/vulkan/sync.rs crates/renderer/src/vulkan/pipeline.rs. A BYRO_VALIDATION=1 bench needs a free Vulkan device (never launch beside the user's instance); otherwise list the item under Needs-RenderDoc.
Floor: G-buffer format mismatch (shader output vs attachment) = HIGH. Flag every invisible-failure item needs RenderDoc.
Frame shape (order is in shader-pipeline.md; verify against code): draw_frame = sync_and_acquire_frame → begin_frame_recording (dynamic-RGBA copies, reservoir clear, first instance grow + TLAS map) → assemble_camera_and_lights → dispatch_skin_and_cluster (skin, skinned refit, AS barrier, TLAS build, cluster cull, ground-cover scatter, dev record_groundcover_bench) → build_and_upload_instances (uploads, second grow + clamp_batches_to_instance_capacity, sky-cube bake) → record_groundcover_models → record_geometry_pass → probe FRAGMENT→HOST barrier → copy_depth_to_history (conditional) → depth_capture_record_copy → record_post_passes (svgf → caustic → volumetrics → ssao → composite → bloom → exposure meter → taa → upscale → presentation) → egui → screenshot → #4602 device→host flush edge → end_command_buffer. The post-pass helpers are deliberately infallible (()): a ? there skips queue_submit after temporal latches were set (#2146/#2503). shader_pipeline_documents_every_record_pass_helper covers post_passes.rs; every_frame_recorder_is_documented (#4958) covers every record_*/begin_frame call in draw.rs and its phase files.
Guard: taa_resolves_the_post_bloom_scene_tap, record_bloom_pass_skips_raw_correctness_views_before_dispatch (post_passes.rs); dependency_chain_tests (egui_pass.rs); capture_ordering_tests + depth_format_guard_tests (depth_capture.rs); frames_in_flight_contract_names_every_dependent_resource (sync.rs); skin_dispatch_ran_rollback_scope_tests (app_frame.rs); splits_when_glass_and_z_write_false / does_not_split_two_sided_blended_particles (frame_params.rs — needs_two_sided_blend_split = is_blend && two_sided && order_dependent_glass; a re-added z_write term or dropped glass term is the regression); scene_descriptor_reflection_tests (scene_buffer/mod.rs).
Checklist:

  • Per-frame fence waited before command-buffer reuse (MAX_FRAMES_IN_FLIGHT == 2, const-asserted; the single shared depth image relies on it). sync_and_acquire_frame waits both slots' fences — the safety argument for every rider listed in sync.rs (rider 8 is groundcover/frame.rs's prepare, pinned since #5265); the throughput half of the all-slots wait is tracked as #5365 (#4606 stays closed on its doc half); a per-slot wait without migrating each rider is the regression. render_finished is per swapchain image, not per frame. Swapchain recreate waits in-flight work, re-creates every size-derived resource and rewrites every descriptor naming it — a new size-derived resource must be in context/resize.rs (the exposure meter and ground-cover model tier rewrite descriptors per dispatch).
  • AS-build input reads (vertex/index/instance) use SHADER_READ at the build stage, never ACCELERATION_STRUCTURE_READ_KHR (that is for AS structure reads).
  • Hand-offs: render-pass final layouts → SVGF/TAA/SSAO/composite; caustic-accum atomics → SHADER_READ; egui and presentation carry their own EXTERNAL dependencies (presentation owns the swapchain attachment and PRESENT_SRC_KHR; the UI quad draws inside it — /audit-ui). Every cross-pass or cross-submission read, new passes included (ground cover, sky bake, cloud noise), needs its own barrier.
  • G-buffer: triangle.frag writes eight colour attachments (0–7: HDR, normal, motion, mesh_id, raw_indirect, albedo, FSR reactive, FSR transparency) + depth as 8 — true of that shader (and its early-depth variant triangle_early.frag.spv / pipeline_early, pinned by opaque_early_test_module_preserves_outputs_and_selects_execution_mode; it admits material kinds 0..=MATERIAL_KIND_MAX_LIGHTING_SHADER (16, #5057), guarded by early_fragment_kinds_have_no_discard_or_depth_write_path) only; every other pipeline in the pass (water; ground-cover blades via draw_color_write_masks) must write each attachment its blend/write mask enables or mask it off. The ground-cover model tier draws through the late opaque triangle pipeline. Formats are in gbuffer.rs; audit the match.
  • Mesh-ID: R32_UINT, bit 31 = alpha-blend/no-history; opaque bits 0–30 = stable surface ID, blended = live instance index + 1 (caustic lookup needs live order); overrun is a one-shot warn! + clamp.
  • Every pipeline that builds its own descriptor-set layout must run validate_set_layout (reflect.rs) against its shaders (water, presentation, ground cover do; check new ones).
  • Vertex input matches crates/renderer/src/vertex.rs; push-constant ranges match GLSL; dynamic viewport/scissor (and water CULL_MODE) set each frame.
  • Pipeline cache: header validated against the device (validate_pipeline_cache_header; mismatch → warn + empty cache, never a crash); persisted at teardown and, when lazy blend-variant creation grows it, mid-session (save_pipeline_cache_if_grown, context/helpers.rs).
    Output: /tmp/audit/renderer/dim_4.md

Dimension 5: GPU memory, resource lifecycle & teardown

Paths: vulkan/{buffer,image,allocator,texture,dds,placeholder,device,instance,swapchain}.rs, crates/renderer/src/deferred_destroy.rs, scene_buffer/, acceleration/memory.rs, context/{teardown,init,resize}.rs, crates/renderer/src/mesh/geometry_ssbo.rs, crates/renderer/src/texture_registry/, byroredux/src/app_events.rs, byroredux/src/loading_screen.rs (cover stage owns GPU handles), byroredux/src/cell_loader/unload.rs (release_entities).
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/context/teardown.rs crates/renderer/src/vulkan/buffer.rs crates/renderer/src/mesh; every resource owner added since needs a memory-budget.md ledger row (a pass with none is a finding).
Floor: per-frame leak = HIGH; use-after-free of in-flight GPU memory = CRITICAL.
Guard: geometry-SSBO rebuild planning_compaction_does_not_publish_offsets, in_place_compaction_moves_each_survivor_with_its_bytes, out_of_order_layout_falls_back_to_the_allocating_copy (mesh/geometry_ssbo.rs), chunked_rebuild_consults_the_idle_threshold_before_duplicating, live_budget_gates_duplication_at_the_approaching_oom_line (mesh.rs); skin_slot_drain_sits_outside_the_skin_compute_guard (teardown.rs). staging_guard_coverage_tests (buffer.rs) and no_file_outside_this_module_rolls_its_own_image_chain (image.rs) carry allow-lists — their exemptions are not coverage. device_names_read_through_bounded_accessors (device.rs, #5273); parse_uncompressed_a8r8g8b8_zero_copies_as_bgra, parse_uncompressed_x8r8g8b8_expands_channels_and_forces_opaque, reject_32bpp_with_empty_rgb_masks (dds.rs, #5378); a_submitted_skip_frame_does_not_rearm_the_episode_warn (dynamic_rgba.rs, #5275).
Checklist:

  • VulkanContext is decomposed into sub-structs (PostChain — every post pipeline, TemporalHistory, ScratchBuffers, SwapchainTargets, OverlayResources, FrameSync) plus directly-owned subsystems. Teardown (context/teardown.rs) is not reverse-creation order (it starts with texture_registry); after device_wait_idle only three orderings are load-bearing: skin_slots before skin_compute; frame_upscaler::destroy_allocations after destroy_device_objects; exposure before the allocator Arc::try_unwrap. Do not file "not reverse order"; do verify every allocator-owned object (new passes included) is destroyed before allocator.take() and any object holding its own Arc<Allocator> clone is drained before the try_unwrap (else the leak-guard branch deliberately leaks the device).
  • AllocatorResource is removed from the World before VulkanContext::drop() — normal exit in byroredux/src/app_events.rs, panic-unwind in impl Drop for App (byroredux/src/main.rs). GpuImage (image.rs) is the one create→allocate→bind→view helper; a hand-rolled image chain regresses it. Every free/destroy/Drop path takes the allocator lock through allocator::lock_recovering (#4599; its source-scan allow-list covers allocator locks only — a raw .lock().unwrap() on a teardown path is the regression).
  • gpu-allocator memory-type correctness (CpuToGpu vs GpuOnly); FSR's SDK memory is allocated outside gpu-allocator (see FrameUpscaler::sdk_memory_bytes).
  • Deferred destroy: pending_destroy_blas, pending_destroy_scratch (DeferredDestroyQueue<GpuBuffer>), the texture registry queue and retired instance-buffer pairs count down DEFAULT_COUNTDOWN (= MAX_FRAMES_IN_FLIGHT) ticks after the fence wait and drain at shutdown; an immediate destroy at an eviction/unload/grow site is the regression. build_skinned_blas_batched_on_cmd's immediate grow-destroy is sound only because of the both-slots fence wait (Dim 4), not a slot-local one. TLAS resize device_wait_idles before freeing the old allocation.
  • Pool/cache policy (check_pool_growth, LRU caps, byte-budgeted texture staging): /audit-performance Dim 3; here verify StagingPool releases its allocator Arc on destroy.
  • Global geometry SSBO rebuild is chunked/resumable (advance_geometry_rebuild, geometry_rebuild_needs_idle, rebuild_geometry_ssbo_atomic_fallback); compaction is two-phase — only apply_compaction_plan may publish offsets (an early publish points every draw at the wrong geometry); a reinstated single-frame rebuild is the regression. Duplication is gated at 80 % of the live DEVICE_LOCAL budget (approaching_oom_line), with GEOMETRY_REBUILD_IDLE_THRESHOLD_BYTES only when no budget reading exists; CPU pools compact in place when survivors are ascending and disjoint, else the allocating copy (one-shot warn).
  • Exact-content geometry sharing (mesh/geometry_sharing.rs): a fingerprint hit is confirmed by same_geometry before substituting, skinned/morph meshes are never registered, and the shared handle's refcount covers every holder. mesh/geometry_residency.rs is a census that must never change admission.
  • Every screen/output-extent-scaled resource is billed in screen_scaled_reservation_bytes (Dim 1) and ledgered in memory-budget.md.
  • Device init (device.rs, instance.rs, context/init.rs): selection requires ray-query + AS + deferred-host-ops and probes every force-enabled feature bit (missing_required_features: shaderInt64, synchronization2, descriptor indexing, textureCompressionBC, … — #4895) and prefers hardware devices; device/extension names are read through ash's bounded *_as_c_str() accessors, never CStr::from_ptr (#5273, 74ed66f0a). RT is mandatory and #4894 removed the RT-less branches (one debug_assert! in init pins it) — a reintroduced ray_query_supported == false fallback is the regression; optional caps (timestamps + host-query-reset → GPU timers, fill_mode_non_solid → wireframe, shaderFloat16 → FSR permutation) degrade explicitly; validation layers on in debug, BYRO_VALIDATION opt-in in release; the full entry → instance → debug → surface → device → allocator → swapchain → … → sync init chain stays intact.
  • Textures: dds.rs parses untrusted archive data — truncated headers, unsupported FourCC/DXGI formats and non-square cubemaps (#4887) must bail! (never panic or short-read); 32-bpp DDPF_RGB decodes by its channel masks — exact RGBA/BGRA order zero-copies, anything else (X8R8G8B8, no alpha mask) takes the RgbExpand path (#5378, cb6a5bf0b); DDPF_LUMINANCE (8-bpp L8, 16-bpp L8A8 / L16) CPU-expands with the luminance mask on R, G and B (#5402; 470 Oblivion *_g.dds glow maps; parse_and_expand_l8_luminance, luminance_dds_rejects_unsupported_depth_and_truncation); texture.rs checks pixel_data.len() >= total_data_size before upload; mip_size is saturating u64 and MAX_TEXTURE_DIMENSION rejects absurd extents (#4511); a failure after image creation must unwind every sibling before recording copies. Upload staging uses the byte-budgeted pool; same-size RGBA updates (texture_registry/dynamic_rgba.rs) copy through a per-slot arena in the frame command buffer, acknowledged only by a successful submit; a staging failure skips that overlay frame (#4889), and its warn-once flag re-arms only after record_pending_rgba_uploads records every dirty update, never on submitted (#5275, 7e43a1550) — the arena growth backs off for RETRY_INTERVAL_FRAMES (120) after a failed attempt unless the request shrinks, and any success clears the state (#5528, fixed in e588e5e67; growth_retries_back_off_during_a_failure_episode, growth_gate_sits_before_the_allocation). Upload orchestrators share one failure policy (#4891): destroy on NotSubmitted, leak on MaybeInFlight, with rollback_global_geometry undoing a failed append. TextureRegistry::recreate_descriptor_sets is transactional (replacement allocated before the old pool dies, #4886) and must not leak replacement samplers (#5207). The placeholder.rs images (white AO, caustic storage sink) are allocator-backed and destroyed before the allocator.
    Output: /tmp/audit/renderer/dim_5.md

Dimension 6: Material translation consumption & tangent space

Paths: byroredux/src/material_translate.rs, crates/core/src/ecs/components/material.rs, byroredux/src/asset_provider/material/, byroredux/src/systems/particle.rs, byroredux/src/render/particles.rs, crates/nif/src/import/mesh/tangent.rs, shaders/include/{material_sampling,ray_hit,normal_transform}.glsl.
First step: git log --since=… --format='%h %s' -- byroredux/src/material_translate.rs crates/core/src/ecs/components/material.rs byroredux/src/asset_provider/material byroredux/src/systems/particle.rs byroredux/src/render/particles.rs crates/nif/src/import/mesh/tangent.rs crates/renderer/shaders/include/material_sampling.glsl crates/renderer/shaders/include/ray_hit.glsl crates/renderer/shaders/include/normal_transform.glsl.
Floor: wrong/divergent Material out of translate_material = HIGH (one boundary, all-game blast radius). The boundary itself is /audit-nifal Dim 1 (single boundary, PBR resolved once, emissive/glass-once); this dimension audits what the render path does with it and does not repeat that checklist.
Guard: every_exterior_spawner_inserts_a_boundary_material (material_translate.rs; recursive over SPAWNER_ROOTS; the test's exemptions — Cornell, scene.rs demo primitives, the LOD-water mesh re-insert (#5243), save restore_world, ground cover — and docs/engine/nifal.md §3's list (which adds mesh→participating-medium fog cards, #5102) must agree); core resolve_pbr_is_idempotent, glass_behavior_preserves_authored_map_overlay (--features inspect); the vanilla-data check cross_game_translation_completeness is #[ignore]d — run it explicitly (cargo test -p byroredux-nif --test translation_completeness -- --ignored cross_game_translation_completeness, ~2 s).
Checklist:

  • No per-game branch between Material and MaterialTable::intern; metalness/roughness are resolved f32; no per-frame classify_pbr_keyword; the renderer reads resolved EmissiveSource, not raw per-game properties.
  • Material-swap (MSWP) and BGSM/BGEM merge paths carry every non-texture scalar of the target material, not just textures; a BGSM with specular_enabled == false zeroes specular and authors metalness 0.0 (#4941), never the keyword classifier's guess; a flipbook replacing a Normal/Height index keeps normal_has_alpha and the parallax-alpha gate consistent with the replacement map. The Starfield CDB arm (apply_cdb_material) forwards EffectSettingsComponent.BlendingMode "AlphaBlend" → has_alpha (so IsGlass reaches the glass classifier, #5277) and parks slot 6 (_height, a layer height-blend input) so it never arms POM (#5381, 46dfdcc6c). EmissiveSettingsComponent is parked, not forwarded: physical luminance has no measured mapping to emissive_mult (#5503, 4667b884b; nifal.md §4), so the regression is a re-introduced raw luminous_emittance → emissive_mult forward (cdb_emissive_settings_are_parked_not_forwarded_raw, merge.rs).
  • Texture-role selection feeding the boundary (FaceGen tint by material_kind, detail/tint neutrals): check against real vanilla data, not only code.
  • Parallax height rides bit 31 of parallaxMapIndex (Oblivion APPLY_HILIGHT2): every shader reader masks it and both POM marchers (raster and secondary-ray, ray_hit.glsl) honour it (parallax_alpha_height_bit_is_honoured_by_both_marchers, bindless_index_bits_are_masked_at_every_textures_subscript; the latter misses an index copied through a local).
  • Tangent space (import side: /audit-nif): Bethesda "tangent" is ∂P/∂V, so the decoder reads the bitangent half into Vertex.tangent.xyz (#786); Z-up→Y-up applies to T with N; B = sign · cross(N, T) on all three import paths. Shader side: perturbNormal default-on with DBG_BYPASS_NORMAL_MAP opt-out; perturbNormalGrad guards zero-length tangents on both paths (Path 2's raw and post-projection length since #5199, shader_contract-pinned). "Chrome/posterized" = missing textures: run tex.missing before recommending a tangent fix.
  • Particles: apply_emitter_overlays is the single overlay boundary (BGEM effect-shader payload included); render assembly reads ParticleEmitter post-overlay.
    Output: /tmp/audit/renderer/dim_6.md

Dimension 7: Denoise & resolve chain — SVGF, composite, TAA, bloom

Paths: vulkan/{svgf,composite,taa,bloom,ssao}.rs (composite.rs prepare_sky_aperture), shaders/{svgf_temporal,svgf_atrous,taa,ssao,bloom_downsample,bloom_upsample,bloom_apply}.comp, shaders/composite.frag, context/post_passes.rs, context/assemble_camera_and_lights.rs (jitter), context/build_and_upload_instances.rs (params.w decision, SVGF/TAA param uploads), context/frame_params.rs (build_composite_params, taa_jitter).
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/{svgf,composite,taa,bloom}.rs crates/renderer/shaders.
Floor: SVGF using wrong motion vectors = HIGH; ghosting / wrong resolve order = MEDIUM.
Guard: taa_comp_keeps_history_bounded_and_rejects_unstable_surfaces (taa.rs); a_taa_failure_signals_a_temporal_discontinuity, an_svgf_parameter_upload_failure_latches_the_pass (post_passes.rs); bloom_dispatches_after_composite_and_applies_itself_downstream, downsample_taps_match_the_documented_footprint, draw_frame_does_not_re_upload_bloom_params_every_frame (bloom); taa_and_fsr_negate_jitter_y_the_same_way (frame_params.rs); svgf_temporal_alpha_is_fed_the_combined_camera_and_light_rig_signal, temporal_pass_sanitizes_non_finite_samples_on_every_history_path (svgf.rs); aperture_screen_bounds_preserve_brute_force_ray_hits (composite.rs).
Checklist:

  • SVGF: history ping-pong; motion-vector reprojection; mesh-ID disocclusion (opaque = stable surface ID, blended = live index); firefly clamp also on the no-history path; no non-finite value enters history — the current sample is zeroed at the top of main() and the nearest-tap fallback applies the bilinear loop's #903 rejection (#5211, e6e888f68); params.w is the progressive-accumulation decision (next_svgf_temporal_alpha; camera-static and scene-unchanged: no rigid motion or instance-set change (#4943), empty pose_dirty, unchanged every-float caustic light key — not #5369's intensity-blind ReSTIR rig key, Dim 2); one temporal + ATROUS_ITERATIONS (3) à-trous dispatches under one timer bracket; dispatch covers the image exactly.
  • Direct light is already EMA-accumulated in triangle.frag (prevAccum) before TAA/FSR — judge ghosting against both filters.
  • Composite emits linear HDR (no tone map/exposure) into the offscreen scene image: direct + SVGF-denoised indirect·albedo + caustics; fog/volumetric transmittance applies to the fully reassembled term (combined * vol.a + vol.rgb, then * transmittance + aerial), never direct alone; SSAO (ssao.comp, 16 samples/pixel; its sole reader is triangle.frag, which samples the slot written two frames earlier) modulates indirect only; alpha-blend fragments write auxiliaryAlpha = finalAlpha into raw-indirect/albedo (α≢1 on those MRTs). Caustic accumulators: glass = 3-layer usampler2DArray (binding 5), water = usampler2D (binding 8) — don't conflate; both divide by CAUSTIC_FIXED_SCALE and enter as a third, albedo-modulated, firefly-capped term (combined = direct + indirect * albedo + caustic), never through the denoiser.
  • TAA exists only under --upscaler taa (FSR mode has no TaaPipeline) and resolves composite's post-bloom scene image — the tap FSR consumes (#3572); the upscale stage then blits TAA's output. Halton(2,3) jitter (taa_jitter), un-jittered projection kept for motion vectors, per-FIF history in GENERAL, 3×3 YCoCg clamp, alpha = max(0.1, reactive) from the FSR reactive mask (attachment 6, #4944) + octahedral-normal disocclusion (dot < 0.85); taa_failed un-jitters (assemble_camera_and_lights). Raw correctness views bypass TAA and bloom, and leaving one signals a temporal discontinuity (#4966).
  • Bloom: BLOOM_MIP_COUNT (5) down / 4 up mips, B10G11R11_UFLOAT, downsample = 2×2 box, soft-knee bright pass on the scene-seeded level only (BLOOM_THRESHOLD/BLOOM_KNEE), applied in place onto composite's scene image by bloom_apply.comp (apply_to_scene) — no composite-side addition and no disable path other than the raw-view skip; BLOOM_INTENSITY from shader_constants_data.rs. Order: composite → bloom → exposure meter → TAA/upscale.
    Output: /tmp/audit/renderer/dim_7.md

Dimension 8: Participating media & water shading — volumetrics, caustics, water

Paths: vulkan/{volumetrics,caustic,water,water_caustic}.rs, vulkan/volumetrics/, shaders/{volumetrics_inject,volumetrics_integrate,caustic_splat}.comp, shaders/{water.vert,water.frag}, shaders/include/{froxel_slices,medium_transport,caustic_kernel}.glsl, byroredux/src/render/{water,fog_volumes}.rs, byroredux/src/env_translate/water.rs, context/assemble_camera_and_lights.rs (combustion drain).
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/volumetrics* crates/renderer/src/vulkan/water* crates/renderer/src/vulkan/caustic.rs.
Guard: water_fragment_uses_shared_material_aware_ray_hits, water_reflection_and_refraction_keep_distinct_two_sided_semantics, gpu_water_params_rust_and_glsl_copies_stay_in_lockstep (shader_contract_tests.rs); record_volumetrics_pass_routes_skip_clears_through_the_shared_latch (post_passes.rs); composite_and_volumetrics_uniforms_match_rust_field_order (composite.rs); architectural_glass_portal_checks_every_candidate_layer, transport_occupancy_gate_is_wired_into_the_inject_pass, v_buffer_history_rejects_non_finite_and_the_store_clamps_to_fp16, every_skipped_frame_drops_the_temporal_history_not_just_the_first (volumetrics.rs, source-shape); volumetric_sun_uses_portal_lane_only_inside (post_passes.rs); water/presentation set layouts are reflection-validated (Dim 4).
Checklist:

  • Every BLAS geometry is OPAQUE, so any ray-query candidate loop must pass gl_RayFlagsNoOpaqueEXT or it never iterates (#4945).
  • Volumetrics: the froxel grid is derived (froxel_extent; VolumetricsConfig::default in upscaling.rs) — size the per-FIF footprint from the live image set, not quoted dimensions; dispatch covers that grid at the inject local_size. Inject is not one ray per froxel (budget in the volumetrics_inject.comp header: sun + glass rays gated on sunTermLive, rim probes above fogRayQualityTier 0, up to MAX_FROXEL_LIGHTS × 2 local rays, combustion queries). Host: filter_fog_volumes_for_grid precedes the dispatch decision; the coarse transport-occupancy mask (#4784: host zero-and-seed, dilated gate, combustion_occupancy_buffers is a sync.rs rider) skips empty slots; the raw V-buffer history rejects non-finite values and the store clamps to finite fp16 (#4782); every skipped frame drops temporal history (#4780); TransportFieldState sentinels; dirty-range cluster upload (fog_cluster_write_range). Integrate multiplies transmittance; HG g clamped (−0.999, 0.999); no-emitter interiors give neutral non-NaN output while rooms with real local emitters keep dust; VOLUMETRIC_OUTPUT_CONSUMED gates the dispatch (skipped, not dispatched-and-ignored); resize rebinds volumetric + composite descriptors.
  • Combustion moments: append_combustion_surface_lights drains the previous use of the frame slot's fixed-point moment buffer (one frame of latency by design; caller must have waited that slot's fence), resets it for this frame's injector and appends bounded inverse-square lights; the oversized-reach canary (COMBUSTION_REACH_CANARY_CULL_RADIUS_BU) guards derived reach.
  • Caustic splat: per-FIF R32_UINT accumulator cleared each frame, HOST/CLEAR→COMPUTE and COMPUTE→FRAGMENT barriers, imageAtomicAdd fixed-point matching the composite divide; source selection reads instances[].flags (INSTANCE_FLAG_CAUSTIC_SOURCE, not a hex literal); the dispatch is gated on the per-FIF geometry_bound latch (#5064, caustic_dispatch_is_gated_on_geometry_bindings_written), which must also close when the geometry globals die (clear_geometry_bound, #5188); a skipped frame resets its parked slot (clear_for_skip → reset_parked_slot); prev_caustic_scene_key (folded from the merged post-combustion frame_lights) still invalidates the parked-camera EMA when lights/caustic sources move. Its sibling light_rig_geometry_key (ReSTIR mode 3, #5507) lives in caustic.rs too but is commutative and intensity-blind where the caustic key is every-float and order-sensitive — the asymmetry is deliberate.
  • Water: water.frag writes only the attachments its pipeline blends; water draws resolve to their SSBO slot via instance_map (entity + mesh); Fresnel F0 = authored WATR fresnel clamped [0.001, 0.20] (env_translate/water.rs; default 0.02), IOR = authored WaterMaterial::ior (default 1.33), miss → sky/backdrop with fog; two-sided via dynamic CULL_MODE; distinct GpuMaterial; the wave tuning and scroll prep live once in core (ecs/components/water.rs, WATER_WAVE_* in shader_constants_data.rs) and water.vert, the GPU upload and the CPU crest sampler all read them (#5582); procedural-noise precision bounded for absolute-world UVs; sun_direction reaches GpuCamera each frame; WaterCausticAccum (per-FIF R32_UINT; water.rs owns only set/layout/pool) is written by imageAtomicAdd and composited as the albedo-modulated caustic term (Dim 7); water casts no opaque shadows. presentation.frag's underwater input comes from SubmersionState (producer: /audit-physics / /audit-exterior).
    Output: /tmp/audit/renderer/dim_8.md

Dimension 9: GPU skinning & morph compute + BLAS refit

Paths: vulkan/{skin_compute,morph_compute}.rs, shaders/{skin_palette,skin_vertices}.comp, acceleration/blas_skinned.rs, context/{skinned_blas_refit,dispatch_skin_and_cluster}.rs, byroredux/src/render/skinned.rs, crates/core/src/ecs/resources/skin_slot_pool.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/skin_compute.rs crates/renderer/src/vulkan/morph_compute.rs crates/renderer/src/vulkan/context/skinned_blas_refit.rs.
Guard: renderer push_constants_size_is_32_bytes (SkinPushConstants, 6 fields); the VERTEX_STRIDE_FLOATS (26 = 104 B) assert against size_of::<Vertex>(); cargo test -p byroredux-renderer --lib palette (dirty plan, publish barrier, descriptor range/key, plan scratch); bind_inverse_upload_failed_is_reset_alongside_skin_dispatch_ran (context/draw.rs); release_shared_destroys_on_last_strong_ref_despite_a_weak (morph_compute.rs). Bin crate: bone_palette_overflow_tests, skin_dispatch_ran_rollback_scope_tests (app_frame.rs).
Checklist:

  • The skin chain (dispatch, slot/BLAS setup, refit) skips draws whose geometry is not yet resident (is_geometry_resident in record_skinned_blas_refit's collection loop, #5187) — a non-resident dispatch reads past the global vertex buffer and latches has_populated_output on garbage. skin_palette.comp (bone_world × bind_inverse) dispatches before skin_vertices.comp; both 64-wide with div_ceil dispatch; the palette dispatch covers only dirty slot runs (plan_palette_dispatch: sparse runs, or the dense range when that does no less work) — a changed slot outside the plan, or a first-sight bind_inverses that never reaches the palette, is the bug class. The palette's boneWorld descriptor range is the whole buffer and its descriptor cache keys on ranges as well as handles (#4829).
  • Skinned output usage = STORAGE_BUFFER | SHADER_DEVICE_ADDRESS | ACCELERATION_STRUCTURE_BUILD_INPUT_READ_ONLY_KHR; VERTEX_BUFFER is deliberately absent (raster still skins inline in triangle.vert; re-add only with a raster bind path). Device address cached, never re-queried per frame.
  • Two publish edges: the palette barrier's dst is COMPUTE | VERTEX | FRAGMENT (raster and ray_hit.glsl read bones[] directly); the skin-output barrier's dst is AS_BUILD | FRAGMENT | COMPUTE. Refit matches BUILD geometry/vertex count; record_scratch_serialize_barrier dst mask includes ACCELERATION_STRUCTURE_READ (a WRITE-only mask is the regression); skinned BLAS are never LRU candidates (they leave only through drop_skinned_blas); skinned scratch is sized for max(build, update) (#5195, Dim 1); rebuild after SKINNED_BLAS_REFIT_THRESHOLD + per-entity SKINNED_BLAS_REFIT_JITTER (skinned_blas_refit_limit).
  • Morph: delta allocation is shared per mesh (morph_delta_cache, Weak upgrade); MorphSlot is created once at spawn (try_spawn_morph_slot) and never recreated, so a live entity's slot must never be reaped — its LRU is stamped from entity liveness (refresh_morph_slot_lru); eviction drains after the skin_compute + accel_manager guard releases. LRU stamps and the swapchain-recreate rebase use skin_lru_stamp, never the 0 sentinel (#4969).
  • Bone-palette overflow at MAX_TOTAL_BONES warns once, never truncates silently; per-slot dirty upload keeps CPU dirty-state and GPU copy in agreement (/audit-performance Dim 4).
    Output: /tmp/audit/renderer/dim_9.md

MEDIUM tier — lighting, presentation, tooling

Dimension 10: Lighting — BSDF, light translation/collection, sky consumption

Paths: shaders/include/{pbr,lighting,clusters}.glsl, shaders/cluster_cull.comp, vulkan/compute.rs (ClusterCullPipeline), crates/renderer/src/vulkan/material.rs (Disney presets), byroredux/src/render/{lights,sky}.rs, crates/core/src/lighting.rs, byroredux/src/systems/light_anim.rs, context/assemble_camera_and_lights.rs, scene_buffer/{light_history,upload}.rs.
First step: git log --since=… --format='%h %s' -- crates/renderer/shaders/include/pbr.glsl crates/renderer/shaders/include/lighting.glsl byroredux/src/render/lights.rs crates/core/src/lighting.rs byroredux/src/systems/light_anim.rs.
Floor: wrong per-game light flag decode = MEDIUM (visual only); a wrong BSDF convention = MEDIUM–HIGH (whole-scene brightness).
Guard: cargo test -p byroredux-renderer shader_contract (BSDF/light source-shape: sheen weighting, DALC irradiance→radiance, anisotropic-GGX clamps, canonical source radius/attenuation; the "no unshadowed fill" pins ban only named fill terms and an XCLL bypass); directional_source_contract_tests (render/lights.rs); shadow_spotlight_bit_never_leaks_into_animation_on_any_game, fallout3nv_zero_projection_flags_remain_zero_diagnostics (light_anim.rs); upload_lights_warns_on_overflow_like_upload_instances (light_overflow_tests.rs); light_history::tests.
Checklist:

  • Disney lobe gate is MAT_FLAG_PBR_BSDF only: FNV/FO3/Oblivion set it on zero materials; FO4/FO76/Starfield BGSM is canonical (a BGSM falling back to Lambert is the regression). shadowableLightRadiance (lighting.glsl) is the only disneyDiffuseSplit call site — every directional/point/spot source arrives through it and scales the whole lobe by π ((diffuse + sheen) * PI * (1 - metalness)); translucency and back-light lobes live inside it (#4946) but leave through its out vec3 transmissionRadiance (#5192): the shared traced visibility multiplies the reflection half only, the transmission half is multiplied by its own own-instance-skipping transmissionVisibility (#5249 — #5192's unshadowed interlude re-opened #4946's through-walls leak; far-field shadowFade mixes it toward lit like the reflection half), unshadowed quantities (Lo, restirUnshadowedSum, pHat, reuse selection) recombine both, and the front lobes clamp on the viewer-oriented geometric horizon step(0, dot(NG, L)) (#5191) — pinned by shadowable_light_radiance_splits_transmission_lobes_from_traced_visibility / shadowable_light_radiance_horizon_clamps_its_front_lobes. Do not hunt for a second composition convention. dielectricF0FromIor floors eta at vacuum (1.0) (#4973); sheen is additive, not ÷π; distributionGGXAniso = isotropic at ax == ay; translucency/model-space-normal/soft-rim-back flags fire independently.
  • Sun soft shadow: the ReSTIR finalize traces rayBudget.directShadowSamples (1..MAX_DIRECT_SHADOW_SAMPLES) cone taps per pixel per frame, deterministic per pixel per frame, non-black disocclusion fallback. Interior XCLL is an ordinary directional GpuLight (type 2.0, VisibilityMask::FULL) through the same shadow path.
  • Light collection (collect_lights): the leading directional prefix (the scene key at slot 0) is pinned; the suffix after it is sorted by descending gi_priority_score through the one shared sort_lights_by_priority_with_ids (sort_unstable_by on a caller-owned scratch, identities permuted with their lights, #5204) — also used by the post-combustion re-sort; MAX_LIGHTS overflow drops the lowest-scoring tail and the warn must say so — that clamp is what the sort is for: pathHitRadiance scans every light with a per-hit top-K of GI_HIT_LIGHT_CAP (8), and froxel/cluster lists are unordered. ReSTIR light identity is CPU-only since #5055 (light_ids, parallel to the lights: authored [entity_id, 1, 0, 0], scene key [0, 2, 0, 0], else [0; 4] = fresh only); LightHistory::remap maps an identity duplicated on either side to INVALID, never to another lamp. NIF-authored lamp lights consume the canonical LIGH falloff lane (#4938).
  • Cluster cull (cluster_cull.comp): 16×9×24 clusters, one 32-thread workgroup each; lists clamp at MAX_LIGHTS_PER_CLUSTER (512) and overflowedClusters is the capacity oracle — silently dropped near-camera lights are a lighting bug, not a stats line.
  • Shadow-visibility policy is one value: VisibilityMask::for_legacy_local_light() (crates/core/src/lighting.rs) takes no argument and returns FULL for every legacy light in every game (Emitter::default() uses it too, #4972); projection/shadow flags are diagnostics only. A producer hand-picking any other mask is the regression.
  • Light canonical translation (canonical_light_animation_flags, canonical_light_shadow_flags; attach sites cell_loader/references/synth_child.rs, cell_loader/spawn/mesh_instance.rs): animation decode is strict (FO4/FO76 → FLICKER|PULSE; raw 0x400 is Shadow-Spotlight, never a pulse; Starfield → 0 because its flag legend is unnamed — its DAT2+12 u16 is populated, so "no Flags field" is a false premise); shadow bits are pass-through diagnostics (authored bits returned unchanged, nothing synthesised — FO3/FNV zero-authoring stays zero, NIF-direct lights carry zero raw flags; the one mapping is Starfield DAT2+56 Light Type 1 → LIGHT_FLAG_SHADOW_SPOTLIGHT). Regression: a canonicalizer fabricating bits, or animate_lights_system/flicker_intensity reading raw LightSource.flags.
  • Sky consumption (content: /audit-exterior): sky, fog and volumetric transmittance apply to the combined term pre-tone-map (Dim 7); a disabled-WTHR fallback is neutral (no NaN, no black).
    Output: /tmp/audit/renderer/dim_10.md

Dimension 11: FSR 3.1 upscaler, exposure & presentation

Paths: crates/fsr3-sys/src/lib.rs, vulkan/{frame_upscaler,upscaling,presentation,exposure,exposure_meter}.rs, crates/renderer/src/tonemap.rs, shaders/{presentation.frag,exposure_meter.comp}, context/post_passes.rs (record_upscale_pass, record_exposure_meter_pass, record_presentation_pass), byroredux/src/main.rs (install_universal_settings, record_active_upscaler), scripts/generate-fsr3-vulkan-shaders.sh; docs docs/engine/fsr3-upscaler-integration-plan.md, fsr3-troubleshooting.md.
First step: git log --since=… --format='%h %s' -- crates/fsr3-sys crates/renderer/src/vulkan/frame_upscaler.rs crates/renderer/src/vulkan/upscaling.rs crates/renderer/src/vulkan/presentation.rs crates/renderer/shaders/presentation.frag. A BYRO_VALIDATION=1 bench needs a free Vulkan device (never launch beside the user's instance); otherwise list the item under Needs-RenderDoc.
Floor: FSR Quality is the engine default — a layout/barrier error here is CRITICAL (default render path); a wrong reactive/T&C mask is MEDIUM. The effective upscaler is CLI --upscaler/--fsr-quality > persisted render.upscaler (BYROREDUX_SETTINGS_PATH) > Quality, with record_active_upscaler the one writer (#4974/#4975): validation runs pass --upscaler or a fresh settings path.
Guard: cargo test -p byroredux-renderer --lib filtered exposure|tonemap|upscal|post_passes; cargo test -p byroredux-fsr3-sys (ABI + SDK contract); bin renderer_config_defaults_to_fsr_quality (nothing covers the settings-merged default).
Checklist:

  • FFI: every unsafe fn in crates/fsr3-sys carries a # Safety contract (/audit-safety Dim 1); a new FFI struct joins the C↔Rust layout probe (byro_fsr3_abi_layout) and the C++ static asserts.
  • FSR needs specific layouts on colour, depth, motion vectors, reactive mask, T&C mask and output; layout errors are invisible to cargo test — any frame-tail barrier/layout edit needs a validation-layer run, not reasoning.
  • Order: composite → bloom → exposure meter → (TAA only in --upscaler taa) → record_upscale_pass → record_presentation_pass. Presentation owns exposure, grade + tone map at output resolution from the upscaled linear-HDR image; nothing upstream tone-maps. Order since #5482 (3bcf6c8e8): exposed = scene.rgb * exposure (from exposureTex) → saturation → gradeContrast (linear stretch about GRADE_CONTRAST_PIVOT 0.18 in exposed units, handing over below GRADE_CONTRAST_TOE 0.1 to a power-law toe) → brightness → tint → the EV-dependent chroma compress (#5154) → tonemap(compressed) (ACES | AgX by TONEMAP_OP_*); host mirrors grade_contrast, adaptation_chroma_compress and the operators in tonemap.rs (presentation_grades_exposed_radiance_with_the_mirrored_contrast bans a raw vec3(0.18) pivot). The toe weights on the stretched value (stretched / GRADE_CONTRAST_TOE), so it stays positive for every contrast ≥ 0 and monotone across the censused range (regression guard: #5506, fixed in 38f711a93, pinned by contrast_keeps_shadows_positive_and_monotone_across_the_census_range); the engine docs carry the post-#5482 order since #5532 b27e52c71 and CLAUDE.md since #5570 51311e2ab. One ExposureResource (a 1×1 R32_SFLOAT slot per frame in flight) feeds FSR and presentation; it is mandatory (allocation failure aborts boot). Its slots are cleared to DEFAULT_EXPOSURE at init, so a failed exposure_meter construction (soft None) degrades to that constant on both sides; a per-frame param-upload failure latches the pass off and freezes the last exposure; fixed mode writes ExposureTuning.fixed_exposure, and a load cover writes STAGE_EXPOSURE_LINEAR. Auto mode clamps the metered target to [MIN_AUTO_EXPOSURE 1/32, MAX_AUTO_EXPOSURE 2] and then applies compensation (auto_exposure; regression guard: the envelope is 7d99ba7f0's retune, which closed #5158's FNV-saloon interior overexposure after an on-device check, pinned numerically by envelope_caps_dark_scene_lift_and_compensation_biases_around_it — no golden frame covers the look); the adaptation constant is ExposureTuning's, defaulted from DEFAULT_ADAPTATION_SECONDS (#5198). Auto metering is the boot default since a070baaad (--no-auto-exposure opts out).
  • BYRO_FSR_FORCE_DISPATCH_FAIL=1 still exercises the native-blit fallback (renders, telemetry-reported, no hang/blank) and flushes temporal history (signal_temporal_discontinuity); record_upscale_pass stays infallible (Dim 4).
  • r.upscaler preset switch: resources re-created at the new render resolution, no mid-frame swap, BLAS budget + volumetric grid re-derived (Dim 1).
  • One jitter and one motion-vector source shared by FSR and TAA; every raster pipeline feeding FSR applies the projection jitter. Reactive/T&C writers follow the plan's mask contract (0.9 clamp, opaque geometry off, materials drive marking); under --upscaler taa, TAA samples the reactive mask (#4944) while the T&C mask is still written though unsampled (documented cost).
  • native-aa is expected to be a net loss (full-output reconstruction) — never report its cost as a perf bug. The FP32 SDK permutation is unexercised (needs a GPU without shaderFloat16): carried scope, mark "untested". Bench-harness byte-stability: /audit-performance.
    Output: /tmp/audit/renderer/dim_11.md

Dimension 12: Debug overlay, GPU telemetry & Cornell harness

Paths: vulkan/{egui_pass,gpu_timers,render_debug}.rs, context/{depth_capture,screenshot,render_debug,telemetry,sync_and_acquire_frame}.rs, shaders/include/render_debug_view.glsl, crates/debug-ui/ (panels/commands: /audit-tooling), byroredux/src/cornell.rs + byroredux/src/cornell/ (builders, labs, oracle), byroredux/src/commands/scene.rs (mat.*), byroredux/src/systems/{debug,metrics}.rs, byroredux/src/main.rs (BENCH_GPU_KEYS).
First step: git log --since=… --format='%h %s' -- crates/renderer/src/vulkan/gpu_timers.rs crates/renderer/src/vulkan/render_debug.rs crates/renderer/src/vulkan/context/telemetry.rs byroredux/src/cornell.rs byroredux/src/cornell.
Guard: cargo test -p byroredux-renderer --lib gpu_timers, every_post_pass_is_gpu_timer_bracketed_or_deliberately_not, composite_frag_spv_debug_mode_guard_matches_render_debug_mode_max, legacy_visualization_bits_are_disabled_in_named_debug_modes; bin bench_gpu_key tests (main.rs), mat_set_tests.
Checklist:

  • egui: loadOp = LOAD, initialLayout = PRESENT_SRC_KHR, own incoming dependency (Dim 4), framebuffers recreated on resize, Option<EguiPass> destroyed before device teardown, None path skips with no layout drift. Since egui 0.36 / egui-ash-renderer 0.13 (9767acd0a) an undrained TexturesDelta debug-asserts on drop, so every path that never renders an output (no overlay pass in submit_egui_frame, the teardown drain of egui_pending_output, the mid-frame-quit discard in app_frame.rs, the debug-UI discard_last_output) discards it through egui::FullOutput::drop_without_applying_deltas (no test pins those non-render sites); uploads go one set_texture submit-and-wait per texture.
  • GPU timers: one VkQueryPool per FIF slot, QUERIES_PER_FRAME (56 = 28 brackets: 23 named + 5 GeometryTimerPhase sub-intervals of main render); a new bracket updates the constant, the GpuTimerSnapshot _ms + _active fields and every reader together (BENCH_GPU_KEYS, gpu_breakdown, metrics_sample_system, fill_skin_coverage_stats → SkinCoverageStats, the bench format string; doc-count rot recurs). Child brackets (geometry phases in main render, volumetrics inject/integrate in volumetrics) are never summed with their parent (#4977); results read MAX_FRAMES_IN_FLIGHT behind; DeviceCapabilities::gpu_timers_supported() false (timestamps + valid bits + host query reset) → Ok(None), no unwrap in the draw path; readers honour _active. dispatches_skipped is a skin-coverage counter (skin.coverage), not a timer.
  • Depth capture (depth_capture.rs, consumer depth.stats): fence-before-read, staging destroyed on teardown.
  • render.debug named views (RenderDebugMode, 16 USER_MODES) are mutually exclusive with the legacy DBG_* categorical bits, and correctness views must reach the screen untouched: every post pass carries a raw-output gate (render_debug_requires_raw_output) — in Rust for TAA, exposure meter, bloom and upscale, in the shader for composite and presentation; a new post pass needs the same gate. Water and ground-cover blades honour structured views via include/render_debug_view.glsl (#4979). request_selected_ray_probe captures one bounded selected-light visibility ray (entity resolved via the map snapshotted at arm time, #4978).
  • Cornell harness (--cornell): self-contained RT reference scene (no game data) that must still build a valid TLAS and render; mat.* commands round-trip through the same MaterialTable path as game content; it builds Material literals by documented exemption. Metalness-vs-lighting and glass-stipple/IGN jitter on opaque glass are open observations, not harness bugs.
    Output: /tmp/audit/renderer/dim_12.md

Phase 3: Merge

  1. Read all /tmp/audit/renderer/dim_*.md.
  2. Combine into docs/audits/AUDIT_RENDERER_<TODAY>.md: Executive Summary · RT Pipeline Assessment (AS, SSBO indexing, ray-query safety, denoiser) · GPU-Struct & Memory Assessment · Findings (by severity, CRITICAL first, deduplicated) · Prioritized Fix Order (correctness → safety → optimization) · Needs-RenderDoc · Stale skill premises (for the next sync).
  3. Remove cross-dimension duplicates.

Phase 4: Cleanup

  1. rm -rf /tmp/audit/renderer — skip when launched by /audit-suite, which reconciles the report against the scratch files. 2. Tell the user the report is ready. 3. Suggest /audit-publish docs/audits/AUDIT_RENDERER_<TODAY>.md (domain label renderer; add shaders for GLSL/SPIR-V, water for WATAL render-side, vulkan/pipeline/memory as they apply).

Categories