"Deep audit of the Vulkan renderer — pipeline, sync, memory, shaders, ray tracing, denoiser"
Install
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-renderer Install via the SkillsCat registry.
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), neverfile:NN; confirm bygrep; drop unconfirmable claims. Backticked.extpaths 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 aBYRO_VALIDATION=1sync-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
a37fcba3crecord 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 namedcargo 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 readsource_scan::production_text(a whole-fileinclude_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. Defaultdeep.
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
- Parse
$ARGUMENTS.mkdir -p /tmp/audit/renderer. - Dedup baseline:
gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/renderer/issues.json. - Read the newest
docs/audits/AUDIT_RENDERER_*.md(its Stale skill premises first) anddocs/engine/shader-pipeline.md+memory-budget.md. Runscripts/check-shader-artifacts.sh(stale-SPIR-V gate, incl.triangle_early.frag.spv) before trusting any shader-side guard. - 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,UINT32indices,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_flagsmust equal the refit flags (VUID-03667). instance_custom_index== instance SSBO index — the load-bearing AS/SSBO contract (24-bit field;MAX_INSTANCES < 1 << 24const-asserted). Padded/unused slots must not break UPDATE mode.decide_use_updatekeys onlast_blas_addressesandlast_entity_idsmembership (plusneeds_full_rebuild,last_blas_map_gen);build_tlasitself forces BUILD wheninstance_count != built_primitive_count;invalidate_tlas_recordingforces BUILD after an unsubmitted recording;tlas_built_this_frame(reset/set lifecycle pinned, #4843) gates everyray_query_tlasconsumer. 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'sfirstInstance(#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_DISABLEis gated ondraw_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 fromscreen_scaled_reservation_bytes(per-pass enumerationrender_extent_bytes_per_pixel_x1024; formula and eviction policy:/audit-performanceDim 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_frametail on the next slot (shrink_tlas_to_fitonly recordstlas_shrink_pending);shrink_blas_scratch_to_fitruns 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 everyblas_scratch_buffersite (#4884,blas_scratch_realloc_order_tests). One-time AS submits follow the #4891 policy — destroy onNotSubmitted, leak onmay_be_in_flight()—build_blas_batchedincluded (#5201). The helper's ownvkWaitForFencesfailure arm applies the same rule to its objects (#5270,wait_failure_arm_disposes_only_on_device_loss): free the cmd buffer/fence only onERROR_DEVICE_LOST, otherwise leak and poison the reusable-fence mutex via a caught panic. The TLAS scratch peak recorded for the shrink ismax(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_residentat restore; skinned BLAS use per-mesh index buffers). rt.integritychain:integrity_snapshot()→fill_rt_integrity_stats(context/telemetry.rs) →RtIntegrityStats::verdict(tlas_emitted == tlas_eligible; the threemissing_*cause counters,non_finite_transform(#5200),lights_droppedand the two cluster counters all zero) runs per frame. Grep the METHODintegrity_snapshot, not the struct (struct-grep produced two false "no reader" findings).- Documented ceilings — quantify before filing: (a) a synchronous multi-cell
--gridburst can false-evict a not-yet-drawn BLAS via the sharedframe_counterbump ("Deferred pending a" inblas_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) indexesGpuInstance[](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 gatesceneFlags.x > 0.5precedes 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 toMAX_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 withtraceShadowGlassLegRemaining(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: #54556d64634aa/ #5565e3dfd3a31, pinned bytransmission_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_radius0.020 rad); the finalize arm draws its disk samples fromditheredSequence2(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 PCGhash2_pixel_frame, the shadow-ray disk taps useditheredSequence2; 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-facingN_biasand its sky sample follows −V (#4832/#4950); interior miss → cell ambient, not open sky — the sky cube is baked fromportal_outdoor_skyin every interior, so the interior gate is each caller'sjitter.w, notexteriorSkyRadianceOr's;MAT_FLAG_THIN_GLASSsends non-occluding panes down the zero-ray Fresnel path, occluding glass keeps Snell/RT at every RT LOD. The glass ray budget isqualityTier-gated (budgetTier);rayBudgetCountis telemetry only (negative pin onold + glassRayCost <= rayBudget.glassRayLimitinshader_constants.rs; no CPU reads it). AdaptiveRayBudget(ray_budget.rs; fedmax(main_render_ms, volumetrics_ms)byreset_ray_budget): cold start = tier 0, overload drops at once, headroom is spent only after a long stable window;--rt-test-ray-quality-tierpins 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 intopad0, reservoir stays 32 B —RESERVOIR_STRIDE); surface tag =inst.surfaceId & RESERVOIR_SURFACE_MASK. Reservoir light indices cross frames throughpreviousLightToCurrent(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 followsrestir_history_mode(dof_params.w, namedHISTORY_MODE_*and compared only againstHISTORY_MODE_PARKED/HISTORY_MODE_SCENE_STATIC− 0.5):RESTIR_HISTORY_CAP_MOVING16 /RESTIR_ALPHA_FLOOR_MOVING0.1 otherwise, aRESTIR_HISTORY_CAP_PARKED1024 /RESTIR_ALPHA_FLOOR_PARKED0.002 parked tail on mode 2 (fully static) and mode 3 (parked, light-rig geometry static under intensity flicker —restir_rig_static_last_buildfrom the intensity-blindlight_rig_geometry_key— per-lightfold_light_rig_geometry_key_forhashes combined with a commutative wrapping add, so the priority-sorted light order cannot re-key it (#53692a7223121; #5507 fixed ina851c625d);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 ridetemporal_reuse_validates_surface_depth_and_does_not_seed_neighbor_radianceand their valuesrestir_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 ina37fcba3c): the enumerated fresh stream is weightedrestirWSum *= restirMbefore any reuse combine, soWestimates 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 fragmentSHADER_READtoo (#5062). BC1 punch-through:texColor.a = 1.0unlessINSTANCE_FLAG_DIFFUSE_ALPHA(set inbuild_and_upload_instances.rsfromformat_has_alpha, which excludesBC1_RGBA). - Baked sky is read only through
exteriorSkyRadianceOr/exteriorSkyDiffuseOr(include/bindings.glsl), gated onexteriorSkyTint.w: set 1 binding 20 is PARTIALLY_BOUND, so an ungatedskyCuberead 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). RigidGpuInstance.modelis rebased on the CPU (rebase_model_matrix);triangle.fragreconstructs absolute at the top ofmain()sodFdx/dFdyconsumers see small magnitudes. Secondary-hit frames pose smooth normals/tangents from the currentbones[]palette (getHitVertexTransform,ray_hit.glsl), notmat3(inst.model).RT_ABSOLUTE_PRECISION_CEILING(2^20,cell_loader/references/mod.rs) bounds every absolute-space consumer — asin()of absolute world XZ is the recurring trap. - Depth: reversed-Z sits behind one authority (
DepthMapping/ACTIVE_DEPTH_MAPPING, GLSLinclude/depth_convention.glsl) and shipsConventionalby 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 isdepthViewSpace(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 withdepth.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
MirroredPendingGuardentry (today onlyReservoir, 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 areGuardedbyname_diverging_glsl_rust_mirrors_stay_in_lockstep, which runsassert_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 declaredNameAlias. AShaderLocalclassification must be true: a byte stride the host sizes a buffer from is a counterpart, not shader-local (regression guard:GcDrawIndirectreclassifiedGuardedagainstGC_DRAW_INDIRECT_STRIDEinde7f46578, #4956; the same commit pinsgroundcover_blade.vert'sGcCameraUBOfield-for-field as a prefix mirror ofGpuCamera, #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 onlyu32lane inGpuCamera, its.w= packed weather surface,dof_params.w= the #4942/#5369 history mode 0–3, namedHISTORY_MODE_*inshader_constants_data.rs(every_camera_ubo_mirror_describes_dof_w_as_the_history_modeis a literal-substring match per mirror for modes 0–3, all five comments carry mode 3 since #5532b27e52c71; a mode 4 needs the test and the five comments edited together),GpuInstance.surface_id@108,GpuTerrainTilelanes @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 (fourvec4, 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 throughsort_lights_by_priority_with_ids(light_history.rs, #5204) — a site sorting lights without their ids silently remaps reservoirs. GpuMaterialfields are scalarf32/u32only (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, GLSLstruct GpuMaterial(only ininclude/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 fromshader_constants_data.rs→include/shader_constants.glsl, never hand-written shader-side (build.rsonly emits the header from theSHADER_DEFINEStable, which declares each define once, #5097; the EV100 meter'sLIGHT_METER_CALIBRATION_K/SENSOR_SENSITIVITY_S/EXPOSURE_METER_S_OVER_Kare single-sourced there too, #5254 — a hand-typed8.0inexposure_meter.compis 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 literallocal_size/div_ceil(8)is the regression; theDBG_*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 fromINITIAL_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 countmin(table.len(), MAX_MATERIALS), neverinterned_count()(the dedup denominator). - Material table:
MaterialTable::intern/intern_by_hashdedup onhash_gpu_material_fields= oneFxHasher::writeoverGpuMaterial::as_bytes(#4201;DrawCommand::material_hashbuilds the struct and calls it, so lockstep is by construction —material_hash_matches_gpu_material_field_hashonly 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 inGpuInstancebelongs inGpuMaterial. 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_framewaits both slots' fences — the safety argument for every rider listed insync.rs(rider 8 isgroundcover/frame.rs'sprepare, 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_finishedis 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 incontext/resize.rs(the exposure meter and ground-cover model tier rewrite descriptors per dispatch). - AS-build input reads (vertex/index/instance) use
SHADER_READat the build stage, neverACCELERATION_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 andPRESENT_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.fragwrites 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 varianttriangle_early.frag.spv/pipeline_early, pinned byopaque_early_test_module_preserves_outputs_and_selects_execution_mode; it admits material kinds 0..=MATERIAL_KIND_MAX_LIGHTING_SHADER(16, #5057), guarded byearly_fragment_kinds_have_no_discard_or_depth_write_path) only; every other pipeline in the pass (water; ground-cover blades viadraw_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 ingbuffer.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-shotwarn!+ 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 waterCULL_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:
VulkanContextis 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 withtexture_registry); afterdevice_wait_idleonly three orderings are load-bearing:skin_slotsbeforeskin_compute;frame_upscaler::destroy_allocationsafterdestroy_device_objects;exposurebefore the allocatorArc::try_unwrap. Do not file "not reverse order"; do verify every allocator-owned object (new passes included) is destroyed beforeallocator.take()and any object holding its ownArc<Allocator>clone is drained before thetry_unwrap(else the leak-guard branch deliberately leaks the device).AllocatorResourceis removed from theWorldbeforeVulkanContext::drop()— normal exit inbyroredux/src/app_events.rs, panic-unwind inimpl 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/Droppath takes the allocator lock throughallocator::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 (
CpuToGpuvsGpuOnly); FSR's SDK memory is allocated outside gpu-allocator (seeFrameUpscaler::sdk_memory_bytes). - Deferred destroy:
pending_destroy_blas,pending_destroy_scratch(DeferredDestroyQueue<GpuBuffer>), the texture registry queue and retired instance-buffer pairs count downDEFAULT_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 resizedevice_wait_idles before freeing the old allocation. - Pool/cache policy (
check_pool_growth, LRU caps, byte-budgeted texture staging):/audit-performanceDim 3; here verifyStagingPoolreleases its allocatorArcon destroy. - Global geometry SSBO rebuild is chunked/resumable (
advance_geometry_rebuild,geometry_rebuild_needs_idle,rebuild_geometry_ssbo_atomic_fallback); compaction is two-phase — onlyapply_compaction_planmay 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), withGEOMETRY_REBUILD_IDLE_THRESHOLD_BYTESonly 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 bysame_geometrybefore substituting, skinned/morph meshes are never registered, and the shared handle's refcount covers every holder.mesh/geometry_residency.rsis 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, neverCStr::from_ptr(#5273,74ed66f0a). RT is mandatory and #4894 removed the RT-less branches (onedebug_assert!in init pins it) — a reintroducedray_query_supported == falsefallback 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_VALIDATIONopt-in in release; the full entry → instance → debug → surface → device → allocator → swapchain → … → sync init chain stays intact. - Textures:
dds.rsparses untrusted archive data — truncated headers, unsupported FourCC/DXGI formats and non-square cubemaps (#4887) mustbail!(never panic or short-read); 32-bppDDPF_RGBdecodes by its channel masks — exact RGBA/BGRA order zero-copies, anything else (X8R8G8B8, no alpha mask) takes theRgbExpandpath (#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.ddsglow maps;parse_and_expand_l8_luminance,luminance_dds_rejects_unsupported_depth_and_truncation);texture.rscheckspixel_data.len() >= total_data_sizebefore upload;mip_sizeis saturatingu64andMAX_TEXTURE_DIMENSIONrejects 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 afterrecord_pending_rgba_uploadsrecords every dirty update, never onsubmitted(#5275,7e43a1550) — the arena growth backs off forRETRY_INTERVAL_FRAMES(120) after a failed attempt unless the request shrinks, and any success clears the state (#5528, fixed ine588e5e67;growth_retries_back_off_during_a_failure_episode,growth_gate_sits_before_the_allocation). Upload orchestrators share one failure policy (#4891): destroy onNotSubmitted, leak onMaybeInFlight, withrollback_global_geometryundoing a failed append.TextureRegistry::recreate_descriptor_setsis transactional (replacement allocated before the old pool dies, #4886) and must not leak replacement samplers (#5207). Theplaceholder.rsimages (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
MaterialandMaterialTable::intern; metalness/roughness are resolvedf32; no per-frameclassify_pbr_keyword; the renderer reads resolvedEmissiveSource, 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 == falsezeroes specular and authors metalness 0.0 (#4941), never the keyword classifier's guess; a flipbook replacing aNormal/Heightindex keepsnormal_has_alphaand the parallax-alpha gate consistent with the replacement map. The Starfield CDB arm (apply_cdb_material) forwardsEffectSettingsComponent.BlendingMode"AlphaBlend" →has_alpha(soIsGlassreaches the glass classifier, #5277) and parks slot 6 (_height, a layer height-blend input) so it never arms POM (#5381,46dfdcc6c).EmissiveSettingsComponentis parked, not forwarded: physical luminance has no measured mapping toemissive_mult(#5503,4667b884b;nifal.md§4), so the regression is a re-introduced rawluminous_emittance→emissive_multforward (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(OblivionAPPLY_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 intoVertex.tangent.xyz(#786); Z-up→Y-up applies to T with N;B = sign · cross(N, T)on all three import paths. Shader side:perturbNormaldefault-on withDBG_BYPASS_NORMAL_MAPopt-out;perturbNormalGradguards zero-length tangents on both paths (Path 2's raw and post-projection length since #5199, shader_contract-pinned). "Chrome/posterized" = missing textures: runtex.missingbefore recommending a tangent fix. - Particles:
apply_emitter_overlaysis the single overlay boundary (BGEM effect-shader payload included); render assembly readsParticleEmitterpost-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.wis the progressive-accumulation decision (next_svgf_temporal_alpha; camera-static and scene-unchanged: no rigid motion or instance-set change (#4943), emptypose_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 istriangle.frag, which samples the slot written two frames earlier) modulates indirect only; alpha-blend fragments writeauxiliaryAlpha = finalAlphainto raw-indirect/albedo (α≢1 on those MRTs). Caustic accumulators: glass = 3-layerusampler2DArray(binding 5), water =usampler2D(binding 8) — don't conflate; both divide byCAUSTIC_FIXED_SCALEand 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 noTaaPipeline) 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 inGENERAL, 3×3 YCoCg clamp,alpha = max(0.1, reactive)from the FSR reactive mask (attachment 6, #4944) + octahedral-normal disocclusion (dot < 0.85);taa_failedun-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 bybloom_apply.comp(apply_to_scene) — no composite-side addition and no disable path other than the raw-view skip;BLOOM_INTENSITYfromshader_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 passgl_RayFlagsNoOpaqueEXTor it never iterates (#4945). - Volumetrics: the froxel grid is derived (
froxel_extent;VolumetricsConfig::defaultinupscaling.rs) — size the per-FIF footprint from the live image set, not quoted dimensions; dispatch covers that grid at the injectlocal_size. Inject is not one ray per froxel (budget in thevolumetrics_inject.compheader: sun + glass rays gated onsunTermLive, rim probes abovefogRayQualityTier0, up toMAX_FROXEL_LIGHTS× 2 local rays, combustion queries). Host:filter_fog_volumes_for_gridprecedes the dispatch decision; the coarse transport-occupancy mask (#4784: host zero-and-seed, dilated gate,combustion_occupancy_buffersis async.rsrider) 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);TransportFieldStatesentinels; dirty-range cluster upload (fog_cluster_write_range). Integrate multiplies transmittance; HGgclamped (−0.999, 0.999); no-emitter interiors give neutral non-NaN output while rooms with real local emitters keep dust;VOLUMETRIC_OUTPUT_CONSUMEDgates the dispatch (skipped, not dispatched-and-ignored); resize rebinds volumetric + composite descriptors. - Combustion moments:
append_combustion_surface_lightsdrains 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_UINTaccumulator cleared each frame, HOST/CLEAR→COMPUTE and COMPUTE→FRAGMENT barriers,imageAtomicAddfixed-point matching the composite divide; source selection readsinstances[].flags(INSTANCE_FLAG_CAUSTIC_SOURCE, not a hex literal); the dispatch is gated on the per-FIFgeometry_boundlatch (#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-combustionframe_lights) still invalidates the parked-camera EMA when lights/caustic sources move. Its siblinglight_rig_geometry_key(ReSTIR mode 3, #5507) lives incaustic.rstoo but is commutative and intensity-blind where the caustic key is every-float and order-sensitive — the asymmetry is deliberate. - Water:
water.fragwrites only the attachments its pipeline blends; water draws resolve to their SSBO slot viainstance_map(entity + mesh); Fresnel F0 = authored WATR fresnel clamped [0.001, 0.20] (env_translate/water.rs; default 0.02), IOR = authoredWaterMaterial::ior(default 1.33), miss → sky/backdrop with fog; two-sided via dynamicCULL_MODE; distinctGpuMaterial; the wave tuning and scroll prep live once in core (ecs/components/water.rs,WATER_WAVE_*inshader_constants_data.rs) andwater.vert, the GPU upload and the CPU crest sampler all read them (#5582); procedural-noise precision bounded for absolute-world UVs;sun_directionreachesGpuCameraeach frame;WaterCausticAccum(per-FIFR32_UINT;water.rsowns only set/layout/pool) is written byimageAtomicAddand composited as the albedo-modulated caustic term (Dim 7); water casts no opaque shadows.presentation.frag's underwater input comes fromSubmersionState(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_residentinrecord_skinned_blas_refit's collection loop, #5187) — a non-resident dispatch reads past the global vertex buffer and latcheshas_populated_outputon garbage.skin_palette.comp(bone_world × bind_inverse) dispatches beforeskin_vertices.comp; both 64-wide withdiv_ceildispatch; 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-sightbind_inversesthat never reaches the palette, is the bug class. The palette'sboneWorlddescriptor 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_BUFFERis deliberately absent (raster still skins inline intriangle.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.glslreadbones[]directly); the skin-output barrier's dst is AS_BUILD | FRAGMENT | COMPUTE. Refit matches BUILD geometry/vertex count;record_scratch_serialize_barrierdst mask includesACCELERATION_STRUCTURE_READ(a WRITE-only mask is the regression); skinned BLAS are never LRU candidates (they leave only throughdrop_skinned_blas); skinned scratch is sized formax(build, update)(#5195, Dim 1); rebuild afterSKINNED_BLAS_REFIT_THRESHOLD+ per-entitySKINNED_BLAS_REFIT_JITTER(skinned_blas_refit_limit). - Morph: delta allocation is shared per mesh (
morph_delta_cache,Weakupgrade);MorphSlotis 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 theskin_compute+accel_managerguard releases. LRU stamps and the swapchain-recreate rebase useskin_lru_stamp, never the0sentinel (#4969). - Bone-palette overflow at
MAX_TOTAL_BONESwarns once, never truncates silently; per-slot dirty upload keeps CPU dirty-state and GPU copy in agreement (/audit-performanceDim 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_BSDFonly: 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 onlydisneyDiffuseSplitcall 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 itsout vec3 transmissionRadiance(#5192): the shared traced visibility multiplies the reflection half only, the transmission half is multiplied by its own own-instance-skippingtransmissionVisibility(#5249 — #5192's unshadowed interlude re-opened #4946's through-walls leak; far-fieldshadowFademixes 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 horizonstep(0, dot(NG, L))(#5191) — pinned byshadowable_light_radiance_splits_transmission_lobes_from_traced_visibility/shadowable_light_radiance_horizon_clamps_its_front_lobes. Do not hunt for a second composition convention.dielectricF0FromIorfloorsetaat vacuum (1.0) (#4973); sheen is additive, not ÷π;distributionGGXAniso= isotropic atax == 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 directionalGpuLight(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 descendinggi_priority_scorethrough the one sharedsort_lights_by_priority_with_ids(sort_unstable_byon a caller-owned scratch, identities permuted with their lights, #5204) — also used by the post-combustion re-sort;MAX_LIGHTSoverflow drops the lowest-scoring tail and the warn must say so — that clamp is what the sort is for:pathHitRadiancescans every light with a per-hit top-K ofGI_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::remapmaps 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 atMAX_LIGHTS_PER_CLUSTER(512) andoverflowedClustersis 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 returnsFULLfor 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 sitescell_loader/references/synth_child.rs,cell_loader/spawn/mesh_instance.rs): animation decode is strict (FO4/FO76 →FLICKER|PULSE; raw0x400is Shadow-Spotlight, never a pulse; Starfield → 0 because its flag legend is unnamed — itsDAT2+12u16 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 StarfieldDAT2+56Light Type 1 →LIGHT_FLAG_SHADOW_SPOTLIGHT). Regression: a canonicalizer fabricating bits, oranimate_lights_system/flicker_intensityreading rawLightSource.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 fnincrates/fsr3-syscarries a# Safetycontract (/audit-safetyDim 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(fromexposureTex) → saturation →gradeContrast(linear stretch aboutGRADE_CONTRAST_PIVOT0.18 in exposed units, handing over belowGRADE_CONTRAST_TOE0.1 to a power-law toe) → brightness → tint → the EV-dependent chroma compress (#5154) →tonemap(compressed)(ACES | AgX byTONEMAP_OP_*); host mirrorsgrade_contrast,adaptation_chroma_compressand the operators intonemap.rs(presentation_grades_exposed_radiance_with_the_mirrored_contrastbans a rawvec3(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 in38f711a93, pinned bycontrast_keeps_shadows_positive_and_monotone_across_the_census_range); the engine docs carry the post-#5482 order since #5532b27e52c71andCLAUDE.mdsince #557051311e2ab. OneExposureResource(a 1×1R32_SFLOATslot per frame in flight) feeds FSR and presentation; it is mandatory (allocation failure aborts boot). Its slots are cleared toDEFAULT_EXPOSUREat init, so a failedexposure_meterconstruction (softNone) degrades to that constant on both sides; a per-frame param-upload failure latches the pass off and freezes the last exposure; fixed mode writesExposureTuning.fixed_exposure, and a load cover writesSTAGE_EXPOSURE_LINEAR. Auto mode clamps the metered target to [MIN_AUTO_EXPOSURE1/32,MAX_AUTO_EXPOSURE2] and then applies compensation (auto_exposure; regression guard: the envelope is7d99ba7f0's retune, which closed #5158's FNV-saloon interior overexposure after an on-device check, pinned numerically byenvelope_caps_dark_scene_lift_and_compensation_biases_around_it— no golden frame covers the look); the adaptation constant isExposureTuning's, defaulted fromDEFAULT_ADAPTATION_SECONDS(#5198). Auto metering is the boot default sincea070baaad(--no-auto-exposureopts out). BYRO_FSR_FORCE_DISPATCH_FAIL=1still exercises the native-blit fallback (renders, telemetry-reported, no hang/blank) and flushes temporal history (signal_temporal_discontinuity);record_upscale_passstays infallible (Dim 4).r.upscalerpreset 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-aais 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 withoutshaderFloat16): 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,Nonepath skips with no layout drift. Since egui 0.36 / egui-ash-renderer 0.13 (9767acd0a) an undrainedTexturesDeltadebug-asserts on drop, so every path that never renders an output (no overlay pass insubmit_egui_frame, the teardown drain ofegui_pending_output, the mid-frame-quit discard inapp_frame.rs, the debug-UIdiscard_last_output) discards it throughegui::FullOutput::drop_without_applying_deltas(no test pins those non-render sites); uploads go oneset_texturesubmit-and-wait per texture. - GPU timers: one
VkQueryPoolper FIF slot,QUERIES_PER_FRAME(56 = 28 brackets: 23 named + 5GeometryTimerPhasesub-intervals of main render); a new bracket updates the constant, theGpuTimerSnapshot_ms+_activefields 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 readMAX_FRAMES_IN_FLIGHTbehind;DeviceCapabilities::gpu_timers_supported()false (timestamps + valid bits + host query reset) →Ok(None), no unwrap in the draw path; readers honour_active.dispatches_skippedis a skin-coverage counter (skin.coverage), not a timer. - Depth capture (
depth_capture.rs, consumerdepth.stats): fence-before-read, staging destroyed on teardown. render.debugnamed views (RenderDebugMode, 16USER_MODES) are mutually exclusive with the legacyDBG_*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 viainclude/render_debug_view.glsl(#4979).request_selected_ray_probecaptures 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 sameMaterialTablepath as game content; it buildsMaterialliterals 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
- Read all
/tmp/audit/renderer/dim_*.md. - 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). - Remove cross-dimension duplicates.
Phase 4: Cleanup
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 labelrenderer; addshadersfor GLSL/SPIR-V,waterfor WATAL render-side,vulkan/pipeline/memoryas they apply).