Part of #839 — [Audit][Round 2] A++ umbrella.
Phase: P1 · Finding: R2-2 · Depends on: nothing (unblocks R2-1)
Problem
LODMesh and LODChunk are treated as open structs by both lod_manager and lod_renderer. A field change to either propagates to two consumers — classic Law-of-Demeter / shotgun-surgery smell. This blocks clean extraction boundaries in R2-1.
Evidence
Both consumers reach into LODMesh bare fields:
lod_renderer.zig:246, 248, 355, 425, 429, 592, 677-686 — reads buffer_handle, vertex_count, opaque_vertex_count, water_vertex_offset, water_vertex_count, vertex_offset, pooled, ready
lod_manager.zig:1303, 1588-1589, 2515-2517, 2525, 2548-2553 — reads/writes ready, vertex_count, capacity, pending_vertices, opaque_vertex_count
Both consumers reach into LODChunk bare fields:
lod_renderer.zig:360, 503-507, 666-670 — reads state, ready_children, transition_frames_remaining, lod_level
lod_manager.zig — reads/writes state extensively across the worker pipeline
Manager hands raw &meshes and ®ions HashMap pointers to the renderer — lod_manager.zig:1685. The renderer iterates these maps directly (lod_renderer.zig:347), exposing internal storage layout.
SchedulerContext (lod_scheduler.SchedulerContext) is a 14-field struct carrying raw pointers into the manager's regions, gen_queues, mutex, next_job_token, plus a callback coverage_ptr. The decoupling is structurally present but semantically hollow — "manager exposes its entire interior for one call."
Fix
Add behavior methods to LODMesh and LODChunk; collapse open-field access. Target methods (initial sketch — adjust during implementation):
LODMesh
pub fn drawRange(self: *LODMesh, layer: RenderLayer) ?MeshDrawRange
pub fn byteSize(self: *const LODMesh) usize
pub fn markUploaded(self: *LODMesh, vertex_count: u32, opaque_count: u32) void
pub fn isReady(self: *const LODMesh) bool
pub fn acquireWriteLock(self: *LODMesh) void // wraps existing sync.Mutex
LODChunk
pub fn isRenderable(self: *const LODChunk) bool
pub fn transitionFadeProgress(self: *const LODChunk) f32
pub fn isCoveredByFinerLOD(self: *const LODChunk, config: CoverageConfig) bool
pub fn beginTransition(self: *LODChunk, target_level: LODLevel) void
pub fn lodLevel(self: *const LODChunk) LODLevel
LODMeshStorage iterator
Replace &meshes HashMap pointer exposure with: pub fn iterateRenderable(self: *LODMeshStorage, layer: RenderLayer) RenderableIter. The renderer asks for an iterator of (mesh_ref, draw_range) tuples instead of indexing the map.
Slim down SchedulerContext
Convert the 14-field struct to 3 fields: regions_view: RegionsView, queue_handle: QueueHandle, coverage: *const CoverageQuery. The view types are read-only facades.
Verification
nix develop --command zig build test (includes shader validation)
- Headless screenshot baseline before/after (
-Dskip-present, low/medium presets) — must be bit-exact
nix develop --command zig build test -- --test-filter "LODMesh" and --test-filter "LODChunk"
grep -rE "\.(vertex_count|buffer_handle|opaque_vertex_count|ready_children|transition_frames_remaining)" modules/world-lod/ should return only the new method bodies, no callers
Constraints
- Behavior-preserving. Render output unchanged.
- Small reviewable PR targeting
dev; conventional commit (refactor(world-lod): encapsulate LODMesh/LODChunk).
- Per-method PRs are acceptable (one PR per struct is also fine if small).
- Keep worker-thread safety: encapsulation must not introduce new shared-mutable-state races. Existing per-mesh
sync.Mutex moves into the methods.
Notes
- This is the prerequisite for clean R2-1 extraction boundaries. Land first.
- The duplicated coverage logic (
areAllChunksLoaded vs isCoveredByChunks) is addressed separately in R2-1's bonus section.
Tracking: #839
Part of #839 — [Audit][Round 2] A++ umbrella.
Phase: P1 · Finding: R2-2 · Depends on: nothing (unblocks R2-1)
Problem
LODMeshandLODChunkare treated as open structs by bothlod_managerandlod_renderer. A field change to either propagates to two consumers — classic Law-of-Demeter / shotgun-surgery smell. This blocks clean extraction boundaries in R2-1.Evidence
Both consumers reach into
LODMeshbare fields:lod_renderer.zig:246, 248, 355, 425, 429, 592, 677-686— readsbuffer_handle,vertex_count,opaque_vertex_count,water_vertex_offset,water_vertex_count,vertex_offset,pooled,readylod_manager.zig:1303, 1588-1589, 2515-2517, 2525, 2548-2553— reads/writesready,vertex_count,capacity,pending_vertices,opaque_vertex_countBoth consumers reach into
LODChunkbare fields:lod_renderer.zig:360, 503-507, 666-670— readsstate,ready_children,transition_frames_remaining,lod_levellod_manager.zig— reads/writesstateextensively across the worker pipelineManager hands raw
&meshesand®ionsHashMap pointers to the renderer —lod_manager.zig:1685. The renderer iterates these maps directly (lod_renderer.zig:347), exposing internal storage layout.SchedulerContext(lod_scheduler.SchedulerContext) is a 14-field struct carrying raw pointers into the manager'sregions,gen_queues,mutex,next_job_token, plus a callbackcoverage_ptr. The decoupling is structurally present but semantically hollow — "manager exposes its entire interior for one call."Fix
Add behavior methods to
LODMeshandLODChunk; collapse open-field access. Target methods (initial sketch — adjust during implementation):LODMeshLODChunkLODMeshStorageiteratorReplace
&meshesHashMap pointer exposure with:pub fn iterateRenderable(self: *LODMeshStorage, layer: RenderLayer) RenderableIter. The renderer asks for an iterator of(mesh_ref, draw_range)tuples instead of indexing the map.Slim down
SchedulerContextConvert the 14-field struct to 3 fields:
regions_view: RegionsView,queue_handle: QueueHandle,coverage: *const CoverageQuery. The view types are read-only facades.Verification
nix develop --command zig build test(includes shader validation)-Dskip-present, low/medium presets) — must be bit-exactnix develop --command zig build test -- --test-filter "LODMesh"and--test-filter "LODChunk"grep -rE "\.(vertex_count|buffer_handle|opaque_vertex_count|ready_children|transition_frames_remaining)" modules/world-lod/should return only the new method bodies, no callersConstraints
dev; conventional commit (refactor(world-lod): encapsulate LODMesh/LODChunk).sync.Mutexmoves into the methods.Notes
areAllChunksLoadedvsisCoveredByChunks) is addressed separately in R2-1's bonus section.Tracking: #839