Skip to content

[R2-1][P1] Decompose LODManager God-object (2,715 LOC → ~500) #840

Description

@MichaelFisher1997

Part of #839 — [Audit][Round 2] A++ umbrella.
Phase: P1 · Finding: R2-1 · Risk: HIGH

Problem

LODManager is the worst God object in the repo. 2,715 LOC, 2,207 impl LOC, 89 functions, and 10–12 distinct concerns merged into a single struct. Round 1 split lod_mesh.zig (#785) and the leaves (lod_geometry, lod_ingest, lod_scheduler, lod_cache, lod_store, lod_streaming_coordinator) are exemplary — but the orchestrator itself absorbed the residual complexity.

This issue supersedes any informal follow-up to #785 and is the single highest-leverage refactor in Round 2.

Evidence

Struct fields group into ≥10 concernsmodules/world-lod/src/lod_manager.zig:183-289:

# Concern Fields LOC impact
1 Region storage regions[LODLevel.count]RegionMap ~300
2 Mesh storage meshes[LODLevel.count]MeshMap ~200
3 Job system + worker pool gen_queues, lod_gen_pool, next_job_token ~400
4 Upload pipeline upload_queues ~150
5 Cache/persistence cache_dir_path, store_mutex, 12+ private fns ~250
6 Chunk-derived ingestion pending_ingestions, ingestion_mutex, 9 methods ~250
7 Memory governor memory_used_bytes, radius_shrink_chunks ~100
8 Mesh deletion queue deletion_queue, deletion_timer ~80
9 Transition bookkeeping transition_queue ~120
10 Threading — 3 locks + 3 atomics mutex (RwLock), store_mutex, ingestion_mutex, stop_flag, player_cx/cz smeared

update() orchestrates 14 subsystems in 110 lineslod_manager.zig:812-922. Textbook "feature envy over its own subsystems": mesh deletion sweep → NaN safety → player position publish → queue reprioritization → throttling → coverage unload → cache recenter → scheduling (×N levels) → generation dispatch → state transitions → uploads → stats logging → memory budget → distance unload → ingestion drain → edit flush → store flush → transition decay.

Locking is smeared: 87 acquisitions of self.mutex across ~25 functions including updateStats, queueLODRegions, processQueuedGenerations, processStateTransitions, processUploadsWithBudget, unloadDistantForLevel, enforceMemoryBudget, decayTransitionFrames, markRegionRenderable, recordCacheHit, recordCacheMiss, applyIngestionToRegions, cacheEnabled, logLegacyCacheNotice, cacheDirPathSnapshot. The inline comment at line 276 — "never held while acquiring mutex (avoids cross-lock deadlocks)" — is a clear admission that locking discipline has outgrown ad-hoc reasoning.

processLODJob worker callback spans 6 subsystems in ~150 lineslod_manager.zig:2055-2203: state lookup/validation, stale-job detection, token check, pinning, lock drop, simplified-data allocation, heightmap generation via generator.generateHeightmapOnly, abort-flag polling, mesh building via buildMeshForChunk, state transition.

Duplicated coverage logicLODManager.areAllChunksLoaded (lod_manager.zig:1741-1766) and LODRenderer.isCoveredByChunks (lod_renderer.zig:520-576) both compute "what chunks does this LOD region touch and are they loaded?" with subtly different policies, and both define the same CHUNK_COVERAGE_PADDING = 1 constant.

Fix

Extract 5 subsystems (priority order), each landing as one focused PR against this issue. After all 5 land, LODManager becomes a thin orchestrator (~400–500 LOC).

Extraction 1 — LODCacheStore

Owns: cache_dir_path, logged_legacy_cache_notice, store_mutex, and all of cacheKey / legacyCacheFilePath / readStorePayload / writeStorePayload / deleteStorePayload / deleteStoreContainer / loadCachedSourceData / saveCachedSourceData / recordCacheHit / recordCacheMiss / enableCache / flushDirtyStores / logLegacyCacheNotice / cacheDirPathSnapshot / cacheEnabled.
Public interface: enable(path), load(key) ?Data, save(key, data), flush(regions_iter), validateHeader() bool.
Removes ~12 private fns + 3 fields + ~250 LOC.

Extraction 2 — LODIngestionQueue

Owns: pending_ingestions, edit_dirty, ingestion_mutex, chunk_resolver, edit_cooldown, ingestion_drain_per_frame.
Public interface: ingestChunk(cx, cz, chunk, prov), markEdited(cx, cz), requestDeferred(cx, cz, prov), drain(resolver, regions), flushEdits(resolver, regions), tick().
Removes 9 methods + 6 fields + ~250 LOC + 1 mutex + the cross-lock ordering rule. Highest-impact single extraction.

Extraction 3 — LODMeshDisposalQueue

Owns: deletion_queue, deletion_timer, queueMeshDeletion, processMeshDeletions. Manager just calls disposal.queue(mesh) and disposal.tick(dt, MAX_SWEEP).
Removes 2 fields + 2 methods. Kills the "manager knows how to wait for GPU idle" smell.

Extraction 4 — LODMemoryGovernor

Owns: memory_used_bytes, radius_shrink_chunks, enforceMemoryBudget, regionMemoryBytes.
Public interface: track(bytes), enforce(regions, meshes, player_pos) Decision, currentPressure() Pressure.
Removes 2 fields + 2 methods + ~100 LOC. Pushes eviction policy out of the orchestrator.

Extraction 5 — LODJobDispatcher

Owns: gen_queues, lod_gen_pool, next_job_token, processLODJob, processQueuedGenerations, processStateTransitions, processUploads, requeueUpload.
Public interface: tick(ctx), pushGeneration, pushMeshing, pushUpload. Manager holds an opaque handle.
Removes ~5 fields + 6 methods + ~400 LOC. Biggest LOC win, most delicate — locking must move with it.

Final — collapse update() to ~30 lines

After extractions 1–5, update() becomes a thin delegating loop: cacheStore.tick(); ingestion.tick(); disposal.tick(dt); memoryGov.track(currentBytes); dispatcher.tick(ctx); decayTransitions();. Target: ~30 LOC.

Bonus — extract LODCoverageQuery

Single source of truth for "are all chunks under this region loaded?" — eliminates the duplicated CHUNK_COVERAGE_PADDING constant and the two divergent implementations between manager (1741-1766) and renderer (520-576).

Verification

  • nix develop --command zig build test (includes shader validation)
  • Required: headless screenshot baseline before extraction 1 and after each extraction (-Dskip-present, low/medium/high presets) — visual parity must be bit-exact
  • nix develop --command zig build test -- --test-filter "LOD" after each extraction
  • 508 LOC of tests in lod_manager.zig must continue to pass; tests move with their code into the new modules
  • nix develop --command zig build -Doptimize=ReleaseFast (perf check — extraction must not regress frame times)

Constraints

  • One extraction per PR — do not bundle. Each PR is independently revertable.
  • Preserve current behavior bit-for-bit (visual + perf). Extraction is purely structural.
  • Locking moves with the data: when extracting LODIngestionQueue, the ingestion_mutex must move into the new struct; do not leave the manager reaching across to it.
  • Keep worker-thread RHI isolation intact — extracted schedulers/dispatchers must not introduce RHI calls on workers.
  • Conventional commits: refactor(world-lod): extract LODCacheStore from LODManager etc.

Notes

  • Issue Refactor lod_manager.zig - Separate LOD Logic from GPU Operations #246 (decoupling GPU ops via LODGPUBridge/LODRenderInterface) is done and cleanLODManager does not import engine-rhi. The remaining debt is data-structure encapsulation and pulling 4 subsystems out.
  • Depends on R2-2 (LODMesh/LODChunk encapsulation) for clean extraction boundaries — consider landing R2-2 first.
  • Zero TODO/FIXME across world-lod — the rot is structural, not attention-driven.

Tracking: #839

Metadata

Metadata

Assignees

No one assigned

    Labels

    automated-auditIssues found by automated opencode audit scansbugSomething isn't workingenhancementNew feature or requesthotfixquestionFurther information is requestedworld

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions