Compare commits

...

25 Commits

Author SHA1 Message Date
sindoring 9d74d996d5 docs(M03): accept M2 placement grouper 2026-07-17 00:07:11 +04:00
sindoring f88bf9714e merge M03 M2 placement grouper 2026-07-17 00:06:19 +04:00
sindoring 7587d3bd3c docs(M03): hand off M2 placement grouper 2026-07-17 00:06:10 +04:00
sindoring 9df32d38ef refactor(M03): extract M2 placement grouper 2026-07-17 00:05:49 +04:00
sindoring 9c9020e60f coord(M03): claim M2 placement grouper 2026-07-16 23:57:35 +04:00
sindoring c34e538dcf docs(M03): accept M2 placement transform resolver 2026-07-16 23:54:41 +04:00
sindoring 396be5e69d merge M03 M2 placement transform resolver 2026-07-16 23:52:34 +04:00
sindoring 1792938567 docs(M03): hand off M2 placement transform package 2026-07-16 23:52:20 +04:00
sindoring 0a09d7bc1e refactor(M03): extract M2 placement transform resolver 2026-07-16 23:51:59 +04:00
sindoring 91f0ba8433 coord(M03): claim M2 placement transform resolver 2026-07-16 23:38:29 +04:00
sindoring 1b6c46870d docs(M03): accept M2 unique placement registry 2026-07-16 10:02:14 +04:00
sindoring 2342430439 merge M03 M2 unique placement registry 2026-07-16 10:01:34 +04:00
sindoring c41e8886a6 docs(coordination): hand off M2 unique registry 2026-07-16 10:01:23 +04:00
sindoring acb3a809bb refactor(M03): extract M2 unique placement registry 2026-07-16 10:01:09 +04:00
sindoring c13c993645 coord(M03): claim M2 unique placement registry 2026-07-16 09:54:44 +04:00
sindoring 57ae1b8f16 docs(M03): accept terrain chunk queue planner 2026-07-16 09:52:59 +04:00
sindoring 24aef13801 merge M03 terrain chunk queue planner 2026-07-16 09:52:22 +04:00
sindoring 4b90dbc1c1 docs(coordination): hand off terrain chunk queue planner 2026-07-16 09:52:18 +04:00
sindoring f800e11cd2 refactor(M03): extract terrain chunk queue planner 2026-07-16 09:52:01 +04:00
sindoring 0d2c820ea7 coord(M03): claim terrain chunk queue planner 2026-07-16 09:47:11 +04:00
sindoring b568768866 docs(M03): accept terrain chunk LOD planner 2026-07-16 09:45:09 +04:00
sindoring f36fabb8e1 merge M03 terrain chunk LOD planner 2026-07-16 09:44:31 +04:00
sindoring 9e69a8a95b docs(coordination): hand off terrain LOD planner 2026-07-16 09:44:27 +04:00
sindoring eb94cfc646 refactor(M03): extract terrain chunk LOD planner 2026-07-16 09:44:11 +04:00
sindoring aa21aafdfa coord(M03): claim terrain chunk LOD planner 2026-07-16 09:37:45 +04:00
38 changed files with 3211 additions and 257 deletions
+64
View File
@@ -950,6 +950,70 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- The ray height, 3x3 tile search and `2/5/10/20/40`-unit nearby fallback are
preserved. No terrain geometry, cache, streaming or visual behavior changed.
## 2026-07-16 Terrain Chunk LOD Planner Extraction
- `TerrainChunkLodPlanner` now owns the pure mapping from populated parsed ADT
chunk indices to desired terrain LOD 0/1/2.
- `TerrainChunkLodPolicy` snapshots the existing enabled flag, three raw chunk
radii and chunk size once per streaming-target application.
- Horizontal XZ distance to the chunk center, inclusive squared thresholds,
sparse-index omission and historical negative-radius squaring are unchanged.
- `StreamingWorldLoader` still owns tile/chunk state, queues, operation budgets,
creation/removal, Mesh/Node/RID finalization and tile-LOD fallback.
- Cache formats, quality presets and visible behavior are unchanged. The
scene-free contract is formula evidence only; asset-backed p95/p99 and visual
acceptance remain required.
## 2026-07-16 Terrain Chunk Geometry Queue Planner Extraction
- `TerrainChunkGeometryQueuePlanner` now compares current and desired chunk state
and returns fresh chunk create/remove request arrays.
- LOD mismatches and absent chunks retain the existing request shape; creates
remain sorted by horizontal distance from chunk origin to streaming focus.
- Desired tile LOD still removes every current chunk, while sparse parsed chunk
records retain the existing tile-origin distance fallback.
- `StreamingWorldLoader` still owns queue storage/drains, removal-first execution,
the shared `chunk_geometry` budget lane and every Mesh/Node/RID side effect.
- Cache formats, quality profiles and visual rules are unchanged. Asset-backed
p95/p99 and visual acceptance remain required.
## 2026-07-16 M2 Unique Placement Registry Extraction
- `M2UniquePlacementRegistry` now owns positive ADT `MDDF.uniqueId` reservations
across streamed tiles using the existing `uid:<decimal>` keys.
- Missing/zero/negative IDs remain untracked; duplicates inside one reservation
call are suppressed and another tile records one skipped key until owner release.
- Owner release still lets the loader find eligible waiting tiles and enqueue the
existing detail retry path. First ownership remains tile-processing-order based.
- `StreamingWorldLoader` still owns tile state, retry queues, grouping/build tasks,
M2 caches, MultiMesh/Node/Mesh/material/RID creation and all operation budgets.
- M2/ADT cache formats, transforms, profiles and visible rules are unchanged.
Asset-backed cross-boundary p95/p99 and visual acceptance remain required.
## 2026-07-16 M2 Placement Transform Resolver Extraction
- `M2PlacementTransformResolver` now owns the pure ADT M2 basis and local
origin-compensation formulas used by worker grouping, placeholders and direct
instance creation.
- Regular models retain `Basis.from_euler`; `ElwynnCliffRock01/02` retain their
narrow shell correction; `NewWaterfall` and `ElwynnTallWaterfall01` retain
calibrated world yaw, local fall-axis twist and tall-sheet anchor compensation.
- Path separator/case/MDX handling, constants and caller-specific final scale
clamps are unchanged. The resolver owns no Node, task, cache or RID.
- Cache formats, placement positions and visible rules are unchanged by the
extraction. Asset-backed visual recheck and general placement parity remain gaps.
## 2026-07-16 M2 Placement Grouper Extraction
- `M2PlacementGrouper` now owns pure worker-side validation, historical path
normalization and ordered tile-local transform grouping of ADT M2 placements.
- Invalid records, backslash replacement, lowercase `.mdx/.mdl` conversion,
first-seen ordering and the `0.0001` scale clamp are unchanged.
- The loader retains tasks, mutex/result queues, stale-result checks, tile/build
state, caches and all Node/MultiMesh/Mesh/material/RID finalization.
- Cache formats, batching, profiles and visible rules are unchanged. Asset-backed
traversal p95/p99, visual recheck and spatial-cell grouping remain pending.
## 2026-07-16 World Environment Snapshot Facade
- `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value,
@@ -0,0 +1,114 @@
# M03-RND-M2-PLACEMENT-GROUPER-001 — M2 placement grouper
<!-- OPENWC_CLAIM:M03-RND-M2-PLACEMENT-GROUPER-001:sindo-main-codex:2026-07-19 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-m2-placement-grouper`
- Lease expires UTC: 2026-07-19
- Integrator: M03 milestone integrator
## Outcome
Extract pure ADT M2 placement validation, path normalization and local-transform
grouping from the worker callback into a scene-free service.
## Non-goals
- Change placement ordering, coordinate conversion, transform formulas or clamps.
- Move WorkerThreadPool tasks, mutex/result queues, tile state or retry behavior.
- Move build jobs, animation/mesh loading, caches, Nodes, MultiMesh or RIDs.
- Generalize grouping policy, change batching or add a dependency.
- Claim build-12340 placement or visual parity beyond preserved formulas.
## Paths
- Exclusive: `src/render/m2/m2_placement_grouper.gd`,
`src/tools/verify_m2_placement_grouper.gd`,
`docs/modules/m2-placement-grouper.md`, this claim
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`src/tools/verify_m2_placement_transform_resolver.gd`,
`docs/modules/m2-placement-transform-resolver.md`,
`docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`,
`targets/03-renderer-facade.md`
- Generated/ignored: `.godot`, native DLL, generated ADT resources, caches and
proprietary renderer corpus
## Contracts and data
- Input: tile-local origin, MMDX name table and raw ADT M2 placement array
- Output: fresh Dictionary keyed by historically normalized relative M2 path;
each value is a fresh ordered Array of tile-local `Transform3D` values
- Invalid variants, out-of-range name IDs and empty paths are skipped
- Backslashes become slashes; lowercase `.mdx/.mdl` suffixes become `.m2`
- Transform basis/origin delegates to `M2PlacementTransformResolver`; final basis
scale retains the grouping-path clamp `max(scale, 0.0001)`
- Cache/schema/coordinate versions: unchanged
## Dependencies
- Requires: current master `c34e538`, `M2PlacementTransformResolver`
- Blocks: broader M2 worker/build decomposition
- External state: none; tests use synthetic names and placement dictionaries
## Verification
- Commands: dedicated invalid/name/path/group-order/default/transform/scale and
bounded timing verifier; loader source boundary; resolver/registry/dedupe plus
facade/internal-access/manifest/shutdown/documentation/coordination gates
- Fidelity evidence: exact loop rules and transform composition moved unchanged
- Performance budget: linear over placement count with fresh output containers;
no Node, Resource, RID, task, mutex, cache or file access
## Documentation deliverables
- Inline grouper API documentation
- New module spec with inputs/outputs, data-flow, lifecycle, ownership, failure,
performance, fidelity limitations and source map
- Transform resolver, renderer/module registry and `RENDER.md` updates
## Simplicity and naming
- Important name: `M2PlacementGrouper`
- Simplest solution: one stateless `group_placements` query
- Rejected complexity: group/result classes, strategy interfaces, callbacks,
Resources, policies or generic spatial batching framework
- Unavoidable complexity: raw ADT dictionaries remain at the parser adapter
- Measured optimization evidence: bounded synthetic grouping loop
## Status
- State: accepted
- Done: pure grouper, worker adapter, regressions and documentation verified
- Next: M2 worker/build state extraction and spatial-cell evidence
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-M2-PLACEMENT-GROUPER-001:9df32d3 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-M2-PLACEMENT-GROUPER-001:f88bf97 -->
## Handoff
- Commit: `9df32d3` (`refactor(M03): extract M2 placement grouper`)
- Results: dedicated grouper PASS (`cases=9`, `iterations=100`,
`placements=256`, `elapsed_ms=67.736`); transform resolver PASS (`cases=9`,
`iterations=10000`, `elapsed_ms=28.571`); M2 registry/dedupe, terrain
cache/LOD/queue, facade, internal-access, manifest, shutdown, scheduler,
streaming planner/focus, terrain-query and coordinate-boundary suites PASS;
editor import parsed the project with only expected missing private corpus
diagnostics; documentation PASS (`module_specs=17`, `required_files=7`);
coordination PASS with 23 pre-existing expired M00/M01 warnings; checkpoint
dry-run planned `7/7`.
- Remaining risks: no private asset-backed traversal/visual comparison;
uppercase extension preservation is historical but inconsistent with the
resolver's case-insensitive exception matching; grouping remains model-path
based rather than spatial-cell based; worker/build state and p95/p99 remain.
- Documentation updated: inline public API; new
`docs/modules/m2-placement-grouper.md`; transform resolver module spec,
renderer module registry, `docs/modules/world-renderer.md` and `RENDER.md`.
- Integration: accepted in master merge `f88bf97`; post-merge grouper
(`cases=9`, `iterations=100`, `placements=256`, `elapsed_ms=67.104`),
transform resolver, registry, internal-access, facade, manifest,
documentation and coordination gates passed.
@@ -0,0 +1,107 @@
# M03-RND-M2-PLACEMENT-TRANSFORM-001 — M2 placement transform resolver
<!-- OPENWC_CLAIM:M03-RND-M2-PLACEMENT-TRANSFORM-001:sindo-main-codex:2026-07-19 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-m2-placement-transform`
- Lease expires UTC: 2026-07-19
- Integrator: M03 milestone integrator
## Outcome
Extract pure ADT M2 placement basis/origin-offset calculation, including the
existing waterfall and Elwynn cliff-rock calibrations, into a scene-free service.
## Non-goals
- Change coordinate conversion, placement positions, rotations or scale clamps.
- Generalize model-specific corrections or add new allowlisted models.
- Move grouping/tasks, MultiMesh/Node/Mesh/material/RID creation or caches.
- Change M2 animation/material behavior, cache versions or renderer profiles.
- Claim build-12340 placement parity beyond existing fixtures.
## Paths
- Exclusive: `src/render/m2/m2_placement_transform_resolver.gd`,
`src/tools/verify_m2_placement_transform_resolver.gd`,
`docs/modules/m2-placement-transform-resolver.md`, this claim
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`,
`targets/03-renderer-facade.md`
- Generated/ignored: `.godot`, native DLL, generated ADT resources, caches and
proprietary renderer corpus
## Contracts and data
- Input: ADT-loader Godot Euler rotation, normalized/original M2 relative path,
and raw placement scale where origin compensation is requested
- Output: `Basis` and local origin-offset `Vector3`; no retained state
- Regular models use `Basis.from_euler`; two cliff rocks retain narrow YZX-style
shell correction; two waterfall sheets retain yaw/twist/anchor compensation
- Scale clamps remain caller-owned for final basis and resolver-owned at `0.0001`
only for historical waterfall anchor compensation
- Cache/schema/coordinate versions: unchanged
## Dependencies
- Requires: current master `1b6c468`
- Blocks: pure M2 grouping extraction and broader M2 service decomposition
- External state: none; tests use synthetic rotations/paths and pinned constants
## Verification
- Commands: dedicated regular/path-normalization/cliffrock/waterfall/twist-anchor/
scale and bounded timing verifier; three loader call-site/source boundaries;
M2 unique/dedupe plus facade/terrain/internal-access/manifest/shutdown gates
- Fidelity evidence: exact formulas/constants and model allowlists moved unchanged
- Performance budget: allocation-free scalar/Basis math per placement aside from
path normalization; no Node, Resource, RID, task, cache or file access
## Documentation deliverables
- Inline resolver API documentation
- New module spec with inputs/outputs, data-flow, lifecycle, ownership, failure,
performance, fidelity limitations and source map
- World renderer/module registry/`RENDER.md` updates
## Simplicity and naming
- Important name: `M2PlacementTransformResolver`
- Simplest solution: stateless service with basis and origin-offset queries
- Rejected complexity: transform strategy hierarchy, generic correction registry,
Resources, data-driven override format or callback injection
- Unavoidable complexity: four measured model-specific exceptions remain explicit
- Measured optimization evidence: bounded synthetic transform loop
## Status
- State: accepted
- Done: resolver extracted, three call sites migrated, contracts and docs verified
- Next: pure M2 grouping extraction and broader M2 service decomposition
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-M2-PLACEMENT-TRANSFORM-001:0a09d7b -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-M2-PLACEMENT-TRANSFORM-001:396be5e -->
## Handoff
- Commit: `0a09d7b` (`refactor(M03): extract M2 placement transform resolver`)
- Results: dedicated formula/source/timing contract PASS (`cases=9`,
`iterations=10000`, `elapsed_ms=28.732`); cold editor parse PASS;
documentation gate PASS (`module_specs=16`, `required_files=7`);
coordination gate PASS with 23 pre-existing expired M00/M01 warnings; related
M2 unique/dedupe, terrain cache/LOD/queue, facade, internal-access, manifest,
shutdown, scheduler, streaming focus/planner and coordinate-boundary suites
PASS; renderer checkpoint dry-run PASS `7/7`.
- Remaining risks: no private asset-backed visual recheck in this package;
general ADT M2 placement parity and asset-backed p95/p99 remain pending.
- Documentation updated: inline public API; new
`docs/modules/m2-placement-transform-resolver.md`; renderer module registry,
`docs/modules/world-renderer.md` and `RENDER.md`.
- Integration: accepted in master merge `396be5e`; post-merge resolver
(`cases=9`, `iterations=10000`, `elapsed_ms=27.409`), M2 registry,
internal-access, facade, manifest, documentation and coordination gates passed.
@@ -0,0 +1,105 @@
# M03-RND-M2-UNIQUE-REGISTRY-001 — M2 unique placement registry
<!-- OPENWC_CLAIM:M03-RND-M2-UNIQUE-REGISTRY-001:sindo-main-codex:2026-07-19 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-m2-unique-registry`
- Lease expires UTC: 2026-07-19
- Integrator: M03 milestone integrator
## Outcome
Extract cross-tile positive `MDDF.uniqueId` reservation ownership from the
streamer into a scene-free `M2UniquePlacementRegistry`.
## Non-goals
- Move tile state, candidate requeue, group/build tasks or result queues.
- Move MultiMesh/Node/Mesh/material creation, animation or resource loading.
- Change unique-key format, first-owner semantics or unkeyed placement behavior.
- Change placement transforms, detail radius, cache versions or quality profiles.
- Add dependencies or mark the broad M2 service complete.
## Paths
- Exclusive: `src/render/m2/m2_unique_placement_registry.gd`,
`src/tools/verify_m2_unique_placement_registry.gd`,
`docs/modules/m2-unique-placement-registry.md`, this claim
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`src/tools/verify_m2_unique_dedupe.gd`,
`docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`,
`targets/03-renderer-facade.md`
- Generated/ignored: `.godot`, native DLL, generated ADT resources, caches and
proprietary renderer corpus
## Contracts and data
- Input: tile key and raw ADT M2 placement dictionaries
- Reserve output: fresh filtered placements, owned unique keys and skipped keys
- Release input/output: tile key plus claimed keys; only current owner releases
- Positive IDs map to `uid:<decimal>`; zero/negative/missing IDs remain untracked
- Registry is session-memory only and clears on renderer world reset
- Queue/cache/schema/coordinate versions: unchanged
## Dependencies
- Requires: current master `57ae1b8`
- Blocks: broader M2 service extraction and dependency/performance acceptance
- External state: none; tests use synthetic placement dictionaries
## Verification
- Commands: dedicated unkeyed/duplicate/cross-tile/release/clear/detached-
diagnostic and bounded timing verifier; loader source boundary; M2 unique-id
smoke plus facade/terrain/internal-access/manifest/shutdown/scheduler gates
- Fidelity evidence: exact positive-ID key format and first-owner/retry behavior
- Performance budget: dictionary membership over placement count; no Node,
Resource, RID, task, file or persistent cache
## Documentation deliverables
- Inline registry API documentation
- New module spec with inputs/outputs, data-flow, lifecycle, sequence, ownership,
failure, recovery, performance and source map
- World renderer/module registry/`RENDER.md` updates
## Simplicity and naming
- Important name: `M2UniquePlacementRegistry`
- Simplest solution: one private key-to-owner dictionary and explicit operations
- Rejected complexity: generic identity registry, signals, callbacks, Resources,
locks or ownership graph
- Unavoidable complexity: raw placement dictionaries remain at ADT adapter boundary
- Measured optimization evidence: bounded synthetic reserve/release verifier
## Status
- State: accepted
- Done: stateful registry, loader adapters, migrated smoke, regressions and docs
- Next: asset-backed boundary traversal and further M2-service extraction later in M03
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-M2-UNIQUE-REGISTRY-001:acb3a80 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-M2-UNIQUE-REGISTRY-001:2342430 -->
## Handoff
- Commit: `acb3a80` (`refactor(M03): extract M2 unique placement registry`)
- Results: dedicated contract PASS (`cases=10`, `iterations=100`, `35.688 ms`);
historical `uid:11785` smoke PASS; terrain/facade/internal-access/manifest/
shutdown/scheduler/streaming/focus/terrain-query/coordinate regressions PASS;
internal inventory remains `42`; editor parse and documentation/coordination/
diff gates PASS; checkpoint dry-run planned `7/7`
- Remaining risks: private Azeroth corpus was absent, so the asset-backed ADT
placement probe could not open the map and is not counted as passed evidence;
first ownership remains processing-order based; M2 grouping/build/tasks/caches/
finalization and asset-backed p95/p99 remain pending
- Documentation updated: `m2-unique-placement-registry.md`, module registry,
`world-renderer.md`, `RENDER.md`, inline API documentation and data-flow,
lifecycle and sequence diagrams
- Integration: accepted in master merge `2342430`; post-merge registry, legacy
smoke, internal-access, facade and manifest gates passed
@@ -0,0 +1,104 @@
# M03-RND-TERRAIN-CHUNK-QUEUE-001 — Pure terrain chunk geometry queue planner
<!-- OPENWC_CLAIM:M03-RND-TERRAIN-CHUNK-QUEUE-001:sindo-main-codex:2026-07-19 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-terrain-chunk-queue`
- Lease expires UTC: 2026-07-19
- Integrator: M03 milestone integrator
## Outcome
Extract chunk-geometry create/remove desired-operation calculation from the
streamer into a scene-free `TerrainChunkGeometryQueuePlanner`.
## Non-goals
- Move queue storage/drains, render budgets, tile-LOD queues or async tasks.
- Move chunk Mesh/Node/RID creation/removal or terrain state ownership.
- Change removal-first execution, nearest-first create order or LOD selection.
- Change coordinate conversions, radii, cache formats or quality profiles.
- Add dependencies or mark the broad terrain service checklist complete.
## Paths
- Exclusive: `src/render/terrain/terrain_chunk_geometry_queue_planner.gd`,
`src/tools/verify_terrain_chunk_geometry_queue_planner.gd`,
`docs/modules/terrain-chunk-geometry-queue-planner.md`, this claim
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`,
`targets/03-renderer-facade.md`
- Generated/ignored: `.godot`, native DLL, generated ADT resources, caches and
proprietary renderer corpus
## Contracts and data
- Input: loader-owned tile-state read model plus immutable `GodotWorldPosition`
- Output: fresh create/remove request arrays; planner retains no queue state
- Removal: current chunks absent from desired state, or all current chunks while
tile LOD is desired
- Creation: missing or LOD-mismatched chunks, sorted by origin XZ distance
- Queue/cache/schema/coordinate versions: unchanged
## Dependencies
- Requires: integrated terrain LOD planner master `b568768`
- Blocks: broader terrain service extraction and dependency/performance acceptance
- External state: none; tests use synthetic tile/chunk dictionaries
## Verification
- Commands: dedicated removal/create/mismatch/tile-fallback/sparse/order and
bounded timing verifier; loader source boundary; facade/cache/LOD/internal-access/
manifest/shutdown/scheduler/streaming planner and repository gates
- Fidelity evidence: extracted branch/order/distance rules and loader drain order
- Performance budget: bounded linear planning plus existing create sort; no engine
resource, task, cache or persistent allocation framework
## Documentation deliverables
- Inline planner API documentation
- New module spec with inputs/outputs, data-flow, lifecycle, sequence, ownership,
failure, performance and source map
- World renderer/module registry/`RENDER.md` updates
## Simplicity and naming
- Important name: `TerrainChunkGeometryQueuePlanner`
- Simplest solution: one stateless planner returning two fresh arrays
- Rejected complexity: generic operation graph, queue class, callback strategy,
spatial index or ownership transfer
- Unavoidable complexity: raw dictionaries remain at the loader/ADT adapter boundary
- Measured optimization evidence: bounded synthetic multi-tile verifier
## Status
- State: accepted
- Done: pure planner, loader queue adoption, semantic/timing regressions and docs
- Next: asset-backed performance acceptance and further terrain-service extraction later in M03
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-TERRAIN-CHUNK-QUEUE-001:f800e11 -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-TERRAIN-CHUNK-QUEUE-001:24aef13 -->
## Handoff
- Commit: `f800e11` (`refactor(M03): extract terrain chunk queue planner`)
- Results: dedicated contract PASS (`cases=8`, `iterations=100`, `235.096 ms`);
LOD/cache/facade/internal-access/manifest/shutdown/scheduler/streaming/focus/
ground/terrain-query/coordinate regressions PASS; internal inventory remains
`42`; editor parse has no script errors; documentation/coordination/diff gates
PASS; checkpoint dry-run planned `7/7` entries with expected missing-private-
corpus diagnostics
- Remaining risks: planner adds a second linear tile-state pass; asset-backed
p95/p99 and visual acceptance remain pending; queue drains, terrain state and
Mesh/Node/RID finalization remain loader-owned
- Documentation updated: `terrain-chunk-geometry-queue-planner.md`, module
registry, `world-renderer.md`, `RENDER.md`, inline API documentation and
data-flow/lifecycle/sequence diagrams
- Integration: accepted in master merge `24aef13`; post-merge planner,
internal-access, facade and manifest gates passed
@@ -0,0 +1,103 @@
# M03-RND-TERRAIN-LOD-PLANNER-001 — Pure terrain chunk LOD planner
<!-- OPENWC_CLAIM:M03-RND-TERRAIN-LOD-PLANNER-001:sindo-main-codex:2026-07-18 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-terrain-chunk-lod-planner`
- Lease expires UTC: 2026-07-18
- Integrator: M03 milestone integrator
## Outcome
Extract chunk-level terrain LOD desired-state calculation from the streamer into
a scene-free `TerrainChunkLodPlanner` with immutable policy and typed focus input.
## Non-goals
- Move chunk/tile queues, loaded state, Mesh/Node/RID creation or budget drains.
- Change radii, chunk size, threshold inclusivity, priority sorting or tile LOD policy.
- Normalize/reorder custom radii or reinterpret negative values.
- Move ADT parsing, quality cache/tasks, materials or ground query.
- Add dependencies, persistence, workers or mark terrain extraction complete.
## Paths
- Exclusive: `src/render/terrain/terrain_chunk_lod_policy.gd`,
`src/render/terrain/terrain_chunk_lod_planner.gd`,
`src/tools/verify_terrain_chunk_lod_planner.gd`,
`docs/modules/terrain-chunk-lod-planner.md`, this claim
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`,
`targets/03-renderer-facade.md`
- Generated/ignored: `.godot`, native DLL, generated resources, caches and
proprietary renderer corpus
## Contracts and data
- Input: ADT chunk adapter records, immutable `GodotWorldPosition`, immutable
enabled/radii/chunk-size policy
- Output: detached Dictionary mapping source chunk index to LOD `0`, `1` or `2`
- Formula: horizontal distance to `origin + half chunk`; inclusive squared thresholds
- Disabled policy and empty/sparse chunks return absent entries exactly as before
- Queue/cache/schema/coordinate versions: unchanged
## Dependencies
- Requires: integrated terrain cache slice master `ea08b8e`
- Blocks: broader terrain service extraction and dependency/performance acceptance
- External state: none; tests use synthetic chunk adapter records
## Verification
- Commands: dedicated center/boundary/outside/sparse/disabled/custom-radius and
bounded timing verifier; loader source boundary; facade/cache/internal-access/
manifest/shutdown/scheduler/streaming planner and repository gates
- Fidelity evidence: extracted formulas and all seven baseline plans remain unchanged
- Performance budget: bounded linear scan over at most the ADT chunk record count;
no Node, Resource, RID, task, cache entry or allocation framework
## Documentation deliverables
- Inline API docs for policy/planner
- New module spec with inputs/outputs, data-flow, lifecycle, sequence, ownership,
failure, performance and source map
- World renderer/module registry/`RENDER.md` updates
## Simplicity and naming
- Important names: `TerrainChunkLodPolicy`, `TerrainChunkLodPlanner`
- Simplest solution: immutable scalar policy plus one pure linear planner
- Rejected complexity: generic spatial index, quadtree, callback strategy,
normalized profile model or queue ownership
- Unavoidable complexity: raw chunk dictionaries remain at the ADT adapter boundary
- Measured optimization evidence: verifier records bounded High-like planning time
## Status
- State: accepted
- Done: immutable policy, pure planner, both loader call sites, regressions and docs
- Next: asset-backed performance acceptance and further terrain-service extraction later in M03
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-TERRAIN-LOD-PLANNER-001:eb94cfc -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-TERRAIN-LOD-PLANNER-001:f36fabb -->
## Handoff
- Commit: `eb94cfc` (`refactor(M03): extract terrain chunk LOD planner`)
- Results: dedicated contract PASS (`cases=7`, `iterations=250`, `12.726 ms`);
cache/facade/internal-access/manifest/shutdown/scheduler/streaming-focus/
streaming-planner/ground/terrain-query/coordinate regressions PASS; editor parse
has no script errors; documentation and coordination gates PASS; checkpoint
dry-run planned `7/7` entries with expected missing-private-corpus diagnostics
- Remaining risks: asset-backed p95/p99 and visual acceptance remain pending;
terrain state, queue, quality-upgrade and finalization ownership remains in loader
- Documentation updated: `terrain-chunk-lod-planner.md`, module registry,
`world-renderer.md`, `RENDER.md`, inline public API documentation and data-flow,
lifecycle and sequence diagrams
- Integration: accepted in master merge `f36fabb`; post-merge planner, facade,
internal-access and manifest gates passed
+5
View File
@@ -13,6 +13,11 @@
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
| Terrain query | Implemented | [`terrain-query.md`](terrain-query.md) |
| Terrain quality Mesh cache | Implemented extraction | [`terrain-quality-mesh-cache.md`](terrain-quality-mesh-cache.md) |
| Terrain chunk LOD planner | Implemented extraction | [`terrain-chunk-lod-planner.md`](terrain-chunk-lod-planner.md) |
| Terrain chunk geometry queue planner | Implemented extraction | [`terrain-chunk-geometry-queue-planner.md`](terrain-chunk-geometry-queue-planner.md) |
| M2 unique placement registry | Implemented extraction | [`m2-unique-placement-registry.md`](m2-unique-placement-registry.md) |
| M2 placement transform resolver | Implemented | [`m2-placement-transform-resolver.md`](m2-placement-transform-resolver.md) |
| M2 placement grouper | Implemented extraction | [`m2-placement-grouper.md`](m2-placement-grouper.md) |
| Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) |
| Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) |
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) |
+191
View File
@@ -0,0 +1,191 @@
# M2 Placement Grouper
## Metadata
| Field | Value |
|---|---|
| Status | Implemented extraction |
| Target/work package | M03 / `M03-RND-M2-PLACEMENT-GROUPER-001` |
| Owners | Pure ADT M2 placement validation, normalization and transform grouping |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-placement-grouper`, 2026-07-16 |
| Profiles/capabilities | Existing static M2 worker-grouping path |
## Purpose
Convert valid raw ADT M2 placement records into ordered tile-local transforms
grouped by the historically normalized relative model path.
## Non-goals
- Reserve cross-tile unique IDs or choose placement ownership.
- Own WorkerThreadPool tasks, mutexes, result queues or tile lifecycle.
- Load models, choose animated/static rendering or build MultiMesh batches.
- Create Node, Mesh, material, Resource or RID objects.
- Change path case, transform rules, batching or visual fidelity.
## Context and boundaries
```mermaid
flowchart LR
ADT[ADT names + placements] --> Registry[M2UniquePlacementRegistry]
Registry --> Grouper[M2PlacementGrouper]
Grouper --> Resolver[M2PlacementTransformResolver]
Resolver --> Grouper
Grouper --> Groups[Path to ordered Transform3D arrays]
Groups --> Worker[Loader worker result adapter]
Worker --> Build[Loader-owned build queue]
```
Allowed dependencies are value containers, `Vector3`, `Basis`, `Transform3D`
and `M2PlacementTransformResolver`. SceneTree, Node, RenderingServer,
WorkerThreadPool, mutexes, ResourceLoader, files, gameplay, network and editor UI
are forbidden.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `group_placements(tile_origin, m2_names, m2_placements)` | Pure query | Return valid placements grouped as ordered tile-local transforms | Worker/main thread; call-local output | Invalid variants/name IDs/empty paths are skipped |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Godot-space tile origin | Loader tile state | Local-position calculation | Value copy | One call |
| Input | ADT MMDX name table | Parsed ADT tile | Path lookup | Read-only PackedStringArray | One call |
| Input | Raw placement dictionaries | Unique registry result | Validation/transforms | Read-only shallow values | One call |
| Output | Relative path to ordered `Transform3D` arrays | Grouper | Loader worker/build adapter | Fresh Dictionary and arrays | One call/build job |
Output retains first-seen group-key order through Godot Dictionary insertion
order and placement order inside each array. Inputs are not retained or returned.
## Data flow
```mermaid
flowchart TD
Call[group_placements] --> Next[Read placement]
Next --> Dict{Dictionary?}
Dict -->|no| Next
Dict -->|yes| Name{Valid name_id?}
Name -->|no| Next
Name -->|yes| Normalize[Backslash to slash; lowercase MDX/MDL to M2]
Normalize --> Empty{Path empty?}
Empty -->|yes| Next
Empty -->|no| Local[world position - tile origin]
Local --> Resolve[Resolve basis and origin offset]
Resolve --> Scale[Apply max scale 0.0001]
Scale --> Append[Append Transform3D to path group]
Append --> Next
Next -->|complete| Return[Return fresh groups]
```
## Lifecycle/state
The grouper retains no state. Every call constructs one stateless transform
resolver and fresh output containers. Reset, cancellation and shutdown require
no grouper action.
## Main sequence
```mermaid
sequenceDiagram
participant Loader as Loader worker callback
participant Grouper as M2PlacementGrouper
participant Resolver as M2PlacementTransformResolver
Loader->>Grouper: group_placements(origin, names, placements)
loop valid placement
Grouper->>Resolver: resolve basis and origin offset
Resolver-->>Grouper: value-only basis and offset
Grouper->>Grouper: append local Transform3D
end
Grouper-->>Loader: fresh path groups
Loader->>Loader: mutex-protected result enqueue
```
## Ownership, threading and resources
- The grouper owns only call-local containers and transforms.
- The loader owns tasks, mutex/result queue, tile/build state, caches, model
loading, Nodes, MultiMesh, Mesh, materials and RIDs.
- Worker use is safe because the grouper and resolver mutate no shared state.
- The caller owns returned containers; mutation cannot affect later calls.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Non-Dictionary placement | Type guard | Skip | Contract fixture | Repair parser input |
| Invalid name ID | Bounds guard | Skip | Contract fixture | Repair name table/placement |
| Empty path | Empty guard | Skip | Contract fixture | Supply valid MMDX entry |
| Missing position/rotation/scale | Defaults | Zero position/rotation and scale 1 | Contract fixture | Expected fallback |
| Zero/negative scale | Historical clamp | Basis scale uses `0.0001` | Contract fixture | Supply valid ADT scale |
| Cancellation after grouping | Loader state check | Discard stale result | Loader regressions | Re-request eligible detail |
Cancellation stays loader-owned because grouping has no task or partial state.
## Configuration and capabilities
No setting or profile branch is introduced. Preserved behavior includes
backslash replacement without lowercasing, case-sensitive lowercase `.mdx/.mdl`
conversion, first-seen ordering, tile-local subtraction and scale clamp `0.0001`.
## Persistence, cache and migration
No persistence or cache format participates. ADT and M2 cache versions are
unchanged, so no rebuild or migration is required.
## Diagnostics and observability
The service emits no logs. Its verifier records contract results, source
boundaries and bounded synthetic 100-by-256 placement timing. Loader metrics
continue to own task/build/cache observability.
## Verification
- `verify_m2_placement_grouper.gd`: invalid input, path behavior, ordering,
defaults, local origin, regular/waterfall transforms, scale clamp, fresh output,
loader/source boundary and timing.
- `verify_m2_placement_transform_resolver.gd`: three transform consumers remain
covered as two direct loader adapters plus the grouper adapter.
- Registry/dedupe, facade, internal-access, manifest, shutdown, documentation and
coordination regressions remain required.
- Fidelity evidence is exact extraction; no new asset-backed parity claim is made.
## Extension points
- A later package may extract the M2 build-job state machine behind explicit
budgets while retaining this pure grouping input.
- Spatial cells require measured culling evidence and are outside this contract.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Pure path/transform grouping | Implemented extraction | Contract/source/timing verifier | Asset-backed traversal timing pending |
| Worker/build orchestration | Remains in loader | Existing regressions | Separate stateful extraction required |
| Spatial MultiMesh cells | Partial | Current path batches | Culling-driven design/evidence pending |
## Known gaps and risks
- Raw placement dictionaries remain the ADT parser boundary.
- Uppercase extensions remain unchanged even though transform exception matching
is case-insensitive.
- Grouping is by model path, not spatial culling cell.
- Asset-backed p95/p99 and visual comparison remain unavailable.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/m2/m2_placement_grouper.gd` | Validation, normalization and grouping |
| `src/render/m2/m2_placement_transform_resolver.gd` | Basis and origin rules |
| `src/scenes/streaming/streaming_world_loader.gd` | Worker/result/build lifecycle and resources |
| `src/tools/verify_m2_placement_grouper.gd` | Contract, dependency and timing regression |
## Related decisions and references
- [`m2-placement-transform-resolver.md`](m2-placement-transform-resolver.md)
- [`m2-unique-placement-registry.md`](m2-unique-placement-registry.md)
- [`world-renderer.md`](world-renderer.md)
- [`../../RENDER.md`](../../RENDER.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
@@ -0,0 +1,187 @@
# M2 Placement Transform Resolver
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M03 / `M03-RND-M2-PLACEMENT-TRANSFORM-001` |
| Owners | Pure ADT M2 placement basis and model-specific origin compensation |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-placement-transform`, 2026-07-16 |
| Profiles/capabilities | Existing static M2 paths; four calibrated model exceptions |
## Purpose
Resolve the unscaled Godot-space orientation and optional local origin offset
used by the pure placement grouper, placeholder rendering and instance creation.
## Non-goals
- Convert WoW/server/ADT coordinates or own placement positions.
- Apply final scale clamps, build transforms, group placements or create Nodes.
- Add, generalize or configure model-specific corrections.
- Change M2 materials, animation, caches, visibility or quality profiles.
- Claim general build-12340 placement parity.
## Context and boundaries
```mermaid
flowchart LR
ADT[ADT placement rotation/path/scale] --> Resolver[M2PlacementTransformResolver]
Resolver --> Basis[Unscaled Basis]
Resolver --> Offset[Local origin offset]
Basis --> Group[M2PlacementGrouper]
Basis --> Placeholder[Placeholder MultiMesh path]
Basis --> Instance[Instance path]
Offset --> Group
Offset --> Placeholder
Offset --> Instance
Group --> Loader[StreamingWorldLoader-owned queues/build]
```
Allowed dependencies are `Vector3`, `Basis`, scalar math and path-string
normalization. Node, SceneTree, RenderingServer, ResourceLoader, tasks, caches,
files, gameplay, network and editor UI are forbidden.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `resolve_basis(rotation_radians, relative_m2_path)` | Pure query | Return unscaled regular or calibrated placement orientation | Worker/main thread; call-local result | Unknown paths use `Basis.from_euler` |
| `resolve_origin_offset(rotation_radians, relative_m2_path, scale_value)` | Pure query | Compensate the measured tall-waterfall twist anchor | Worker/main thread; call-local result | Regular/unanchored paths return zero; scale clamps to `0.0001` for compensation |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Godot Euler rotation in radians | ADT loader placement record | Resolver | Scalar copy | One call |
| Input | Relative M2 path | ADT MMDX/name table | Resolver allowlist | Caller string | One call |
| Input | Raw placement scale | ADT placement record | Offset compensation | Scalar copy | One call |
| Output | Unscaled `Basis` | Resolver | Grouper/placeholder/instance adapter | Value copy | One call |
| Output | Local `Vector3` offset | Resolver | Grouper/placeholder/instance adapter | Value copy | One call |
Callers preserve their historical basis scale minima: grouping/instance use
`0.0001`, while placeholders use `0.01`.
## Data flow
```mermaid
flowchart TD
BasisCall[resolve_basis] --> ModelName[Normalize separators/case/basename]
ModelName --> Waterfall{Waterfall sheet?}
Waterfall -->|yes| WaterfallBasis[+90 degree world yaw * Euler * local-axis twist]
Waterfall -->|no| Cliff{Elwynn cliff rock?}
Cliff -->|yes| CliffBasis[Corrected Y/BACK/RIGHT composition]
Cliff -->|no| Regular[Basis.from_euler]
OffsetCall[resolve_origin_offset] --> Anchored{Tall waterfall anchor?}
Anchored -->|no| Zero[Vector3.ZERO]
Anchored -->|yes| Compensation[(base anchor - twisted anchor) * clamped scale]
```
## Lifecycle/state
The resolver is stateless. It may be shared between the main thread and worker
calls because each result is computed solely from value inputs. Construction,
world reset and shutdown require no clear, cancellation or resource release.
## Main sequence
```mermaid
sequenceDiagram
participant Caller as Grouper or StreamingWorldLoader
participant Resolver as M2PlacementTransformResolver
participant Consumer as Grouper/placeholder/instance path
Caller->>Resolver: resolve_basis(rotation, path)
Resolver-->>Caller: unscaled Basis
Caller->>Resolver: resolve_origin_offset(rotation, path, scale)
Resolver-->>Caller: local offset
Caller->>Consumer: compose historical scaled Transform3D
```
## Ownership, threading and resources
- The resolver retains no mutable state or engine resources.
- The grouper owns worker-path final transforms; the loader owns placement records,
tasks, queues, caches and MultiMesh/Node/Mesh/material/RID creation and cleanup.
- Pure methods are safe for grouper worker use and main-thread consumers.
- Returned `Basis`/`Vector3` values have no lifetime coupling to the resolver.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Empty/unknown path | Allowlist miss | Regular Euler basis and zero offset | Contract fixture | Expected fallback |
| Mixed case/backslashes/MDX | Normalization | Same calibrated model match | Contract fixture | None required |
| Zero/negative scale for anchored offset | Numeric clamp | Use `0.0001` compensation scale | Contract fixture | Caller may supply valid ADT scale later |
| World reset/shutdown | No retained state | No action | Existing shutdown verifier | New calls remain independent |
There is no asynchronous operation or cancellation inside the resolver.
## Configuration and capabilities
The resolver introduces no settings. The preserved constants and allowlists are:
- world yaw `PI` for `ElwynnCliffRock01/02`;
- world yaw `PI/2` for `NewWaterfall` and `ElwynnTallWaterfall01`;
- measured local axes/twist signs for both waterfall sheets;
- measured tall-waterfall anchor `(-2.667799, 89.62273, 0.00129)`.
## Persistence, cache and migration
No persistence, schema or cache format participates. Existing ADT/M2 cache
versions and rebuild instructions remain unchanged.
## Diagnostics and observability
The resolver emits no logs. The contract verifier compares formulas/constants,
checks two direct loader consumers plus the grouper consumer and records bounded
synthetic timing. Visual
and asset-backed evidence remains in the renderer checkpoint workflow.
## Verification
- `verify_m2_placement_transform_resolver.gd`: regular fallback, path
normalization, both cliff rocks, both waterfalls, twist anchor, negative-scale
compensation, three consumers, source boundary and 10,000-pair timing.
- M2 unique/dedupe, terrain, facade, internal-access, manifest, shutdown,
scheduler, streaming and coordinate regressions remain required.
- Fidelity evidence is exact extraction of existing calibrated formulas. The
private asset/camera comparison that motivated them is not reproduced here.
## Extension points
- `M2PlacementGrouper` composes this resolver for the worker grouping path.
- New model corrections require separate measured fidelity evidence; this module
is not a generic override registry.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Consistent transform rules across three M2 paths | Implemented | Formula/source/timing contract | Asset-backed visual recheck pending |
| General ADT M2 placement parity | Partial | Existing checkpoint notes | Broader fixture coverage required |
## Known gaps and risks
- Four model-specific exceptions are empirical compatibility rules.
- Most placements use raw Godot Euler behavior; independent client-wide
transform validation is incomplete.
- Callers intentionally retain different final scale clamps.
- Grouping, build, cache and render ownership remain in the streamer.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/m2/m2_placement_transform_resolver.gd` | Pure basis/offset rules and calibrated allowlists |
| `src/render/m2/m2_placement_grouper.gd` | Grouping adapter and local Transform3D ownership |
| `src/scenes/streaming/streaming_world_loader.gd` | Two direct adapters and renderer resource ownership |
| `src/tools/verify_m2_placement_transform_resolver.gd` | Formula, source and performance regression |
## Related decisions and references
- [`m2-placement-grouper.md`](m2-placement-grouper.md)
- [`m2-unique-placement-registry.md`](m2-unique-placement-registry.md)
- [`world-renderer.md`](world-renderer.md)
- [`../../RENDER.md`](../../RENDER.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
@@ -0,0 +1,220 @@
# M2 Unique Placement Registry
## Metadata
| Field | Value |
|---|---|
| Status | Implemented extraction |
| Target/work package | M03 / `M03-RND-M2-UNIQUE-REGISTRY-001` |
| Owners | Cross-tile positive ADT MDDF unique-ID reservations |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-unique-registry`, 2026-07-16 |
| Profiles/capabilities | Existing static M2 placement path; session-memory only |
## Purpose
Ensure a positive ADT `MDDF.uniqueId` has at most one owning streamed tile while
allowing unkeyed placements through and returning skipped keys for loader-driven
candidate retry after the owner unloads.
## Non-goals
- Own tile state, retry queues, worker/group/build jobs or detail radius rules.
- Group placements or create MultiMesh, Mesh, Node, material or RID resources.
- Validate M2 paths, transforms, animation, materials or build-12340 fidelity.
- Persist reservations or define a generic identity/ownership framework.
- Change cache formats, quality profiles or placement visibility.
## Context and boundaries
```mermaid
flowchart LR
ADT[ADT M2 placement dictionaries] --> Loader[StreamingWorldLoader adapter]
Loader --> Registry[M2UniquePlacementRegistry]
Registry --> Accepted[Filtered placements + owned keys]
Registry --> Skipped[Skipped keys]
Accepted --> Group[Existing worker grouping/build path]
Skipped --> TileState[Loader-owned tile retry state]
Unload[Owner tile unload] --> Loader
Loader -->|release keys| Registry
Loader -->|notify released candidates| Retry[Existing detail sync queue]
```
Allowed dependencies are scalar/container values from the ADT adapter. Node,
SceneTree, RenderingServer, ResourceLoader, WorkerThreadPool, gameplay, network,
editor UI, files and persistent caches are forbidden.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `reserve(tile_key, m2_placements)` | Command/query | Reserve available positive IDs and return fresh filtered/owned/skipped arrays | Renderer owner thread/session | Invalid non-Dictionary entries are ignored; unkeyed entries pass through |
| `release(tile_key, unique_keys)` | Command | Release only keys owned by the supplied tile | Renderer owner thread/session | Unknown/non-owner/empty keys are ignored |
| `clear()` | Lifecycle command | Drop every reservation on world reset/shutdown | Renderer owner thread | Idempotent |
| `active_count()` | Query | Return current reservation count | Renderer owner thread | No failure state |
| `diagnostic_snapshot()` | Read model | Return sorted detached key/tile ownership records | Caller-owned result | Exposes no mutable registry reference |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Tile key | Streamer tile state | Registry reservation owner | Copied string | Reservation lifetime |
| Input | Raw M2 placement dictionaries | ADT parser result | Registry | Loader-owned; read only | One reserve call |
| Input | Claimed key array | Tile state | Registry release | Loader-owned; read only | One release call |
| Output | Filtered placement array | Registry | M2 grouping task adapter | Fresh shallow array; placement dictionaries remain source values | One request |
| Output | Owned `uid:<decimal>` keys | Registry | Loader tile state | Fresh strings/array | Until release |
| Output | Skipped keys | Registry | Loader candidate retry state | Fresh strings/array | Until owner release/retry |
| Output | Released keys | Registry | Loader candidate notifier | Fresh array in input order | One release |
| Output | Detached diagnostics/count | Registry | Metrics/tests | Caller-owned scalars/dictionaries | One query |
Only `unique_id > 0` enters the registry. Missing, zero and negative values have
an empty key and remain untracked, matching the prior loader behavior.
## Data flow
```mermaid
flowchart TD
Reserve[reserve tile + placements] --> Placement[Iterate placement order]
Placement --> Dict{Dictionary?}
Dict -->|no| Placement
Dict -->|yes| Positive{unique_id > 0?}
Positive -->|no| Pass[Append untracked placement]
Positive -->|yes| Owned{Key already owned?}
Owned -->|no| Claim[Set owner and append placement/key]
Owned -->|same tile + already in this call| Duplicate[Suppress duplicate]
Owned -->|same tile + first in this call| PassOwner[Append placement/key]
Owned -->|other tile| Skip[Append skipped key once]
Pass --> Placement
Claim --> Placement
Duplicate --> Placement
PassOwner --> Placement
Skip --> Placement
Release[release tile + keys] --> Owner{Tile is current owner?}
Owner -->|yes| Erase[Erase and return key]
Owner -->|no| Ignore[Leave reservation unchanged]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> Empty
Empty --> Reserved: reserve positive unowned ID
Reserved --> Reserved: same-owner reserve / competing skip / non-owner release
Reserved --> Empty: owner releases final ID
Reserved --> Empty: clear world/reset
Empty --> Empty: unkeyed reserve / unknown release / clear
Empty --> [*]
Reserved --> [*]: owner releases registry after clear
```
## Main sequence
```mermaid
sequenceDiagram
participant TileA as First streamed tile
participant Loader as StreamingWorldLoader
participant Registry as M2UniquePlacementRegistry
participant TileB as Cross-border candidate tile
TileA->>Loader: placements include positive unique ID
Loader->>Registry: reserve(tile A, placements)
Registry-->>Loader: accepted placement + owned key
TileB->>Loader: same placement ID
Loader->>Registry: reserve(tile B, placements)
Registry-->>Loader: skipped key
Loader->>Loader: retain skipped key in tile B state
TileA->>Loader: unload/remove details
Loader->>Registry: release(tile A, owned keys)
Registry-->>Loader: released key
Loader->>Loader: enqueue eligible tile B detail retry
```
## Ownership, threading and resources
- The registry owns one private key-to-owner-string dictionary.
- The loader owns tile-held owned/skipped arrays, retry discovery, task/build
lifecycle and every MultiMesh/Node/Mesh/material/RID side effect.
- Calls currently occur on the renderer main thread. The registry has no lock and
must not be mutated concurrently.
- Returned arrays and diagnostic dictionaries are fresh; source placement
dictionaries are passed through shallowly and must remain read-only.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Invalid placement variant | Type guard | Ignore entry | Contract test | Repair parser/adapter input |
| Missing/non-positive ID | Key rule | Pass placement without ownership | Smoke/contract test | Expected legacy behavior |
| Cross-tile duplicate | Owner lookup | Skip placement and return key once | Tile skipped-key state | Owner release triggers eligible candidate retry |
| Non-owner release | Owner comparison | Ignore; preserve owner | Contract test | Release from recorded tile |
| Reset/shutdown | Loader lifecycle | Clear all reservations | Shutdown regression/count | New world reserves afresh |
There is no asynchronous work or cancellation inside the registry. Loader-owned
tasks are cancelled independently before/reset around registry clearing.
## Configuration and capabilities
The registry introduces no settings. It applies to all positive IDs in the
existing static M2 placement path. Detail radius, M2 enablement, build budgets
and animation allowlists remain loader/profile configuration.
## Persistence, cache and migration
Reservations are runtime-only and are not serialized. M2 `.tscn/.glb`, animation,
mesh/material refresh and ADT cache versions remain unchanged. No rebuild or
migration is required.
## Diagnostics and observability
- `active_count()` continues to feed `m2_active_unique_ids` baseline metrics.
- Detached diagnostics sort by unique key for deterministic inspection and expose
only strings/counts.
- Contract timing covers repeated 256-placement reserve/release cycles. Asset-
backed streaming p95/p99 remains milestone acceptance work.
## Verification
- `verify_m2_unique_placement_registry.gd`: invalid/unkeyed handling, within-call
duplicate suppression, same-owner repeat, cross-tile skip, owner-checked release,
retry, idempotent clear, detached/sorted diagnostics, source boundary and timing.
- `verify_m2_unique_dedupe.gd`: existing `uid:11785` cross-tile smoke now targets
the extracted public contract rather than loader-private methods.
- Facade, terrain, internal-access, manifest, shutdown, scheduler, streaming and
coordinate regressions remain required.
- Fidelity evidence is dedupe behavior preservation only. No new build-12340
visual parity claim is made.
## Extension points
- Expose detached ownership diagnostics through facade metrics only if needed.
- Extract grouping/build orchestration as separate services with explicit budgets.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Cross-tile positive-ID ownership | Implemented extraction | Dedicated contract plus legacy smoke | Asset-backed boundary traversal timing pending |
| Candidate retry and M2 build | Remains in loader | Existing release adapter/source boundary | Broader M2 service extraction required |
## Known gaps and risks
- Placement dictionaries are shallow pass-through values at the ADT boundary.
- Same-owner reservations across separate calls intentionally pass again; the
caller's existing root/task guards prevent duplicate build requests.
- First owner follows tile processing order, matching prior behavior rather than
a canonical geometric owner rule.
- Group/build/load/animation caches and render finalization remain monolithic.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/m2/m2_unique_placement_registry.gd` | Positive-ID keying, ownership, release and diagnostics |
| `src/scenes/streaming/streaming_world_loader.gd` | Reservation adapters, tile state, candidate retry and M2 rendering |
| `src/tools/verify_m2_unique_placement_registry.gd` | Contract, lifecycle, source and timing regression |
| `src/tools/verify_m2_unique_dedupe.gd` | Historical cross-tile smoke fixture |
## Related decisions and references
- [`world-renderer.md`](world-renderer.md)
- [`../../RENDER.md`](../../RENDER.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
@@ -0,0 +1,195 @@
# Terrain Chunk Geometry Queue Planner
## Metadata
| Field | Value |
|---|---|
| Status | Implemented extraction |
| Target/work package | M03 / `M03-RND-TERRAIN-CHUNK-QUEUE-001` |
| Owners | Pure chunk geometry create/remove request calculation |
| Last verified | Worktree `work/sindo-main-codex/m03-terrain-chunk-queue`, 2026-07-16 |
| Profiles/capabilities | Existing chunk/tile LOD desired state; all profiles |
## Purpose
Compare loader-owned current chunk geometry with desired chunk/tile LOD state
and produce fresh removal and nearest-first creation request arrays.
## Non-goals
- Retain, drain or bound queues, or own per-frame operation permits.
- Create/remove Mesh, Node, material or RID resources.
- Calculate desired chunk/tile LOD, parse ADT data or schedule worker tasks.
- Change cache versions, renderer profiles or visible fidelity rules.
- Provide a generic queue/operation framework.
## Context and boundaries
```mermaid
flowchart LR
Current[Loader-owned tile/chunk state] --> Planner[TerrainChunkGeometryQueuePlanner]
Desired[Desired chunk and tile LOD state] --> Planner
Focus[GodotWorldPosition] --> Planner
Planner --> Remove[Stable-order remove requests]
Planner --> Create[Nearest-first create requests]
Remove --> Loader[StreamingWorldLoader queues]
Create --> Loader
Loader --> Budget[RenderBudgetScheduler permit]
Budget --> GPU[Existing Mesh/Node/RID side effects]
```
Allowed dependencies are typed Godot position and call-local scalar/container
values. SceneTree, RenderingServer, ResourceLoader, worker APIs, gameplay,
network, editor UI, files and persistent caches are forbidden.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `TerrainChunkGeometryQueuePlanner.plan(tile_states, focus_position)` | Pure query | Return fresh `create_requests` and `remove_requests` arrays | Synchronous; caller owns result | Null/wrong focus returns two empty arrays |
Script-identity validation uses a preload and therefore works from a cold Godot
global-class cache.
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Tile-state read model with current chunks and desired LODs | Streamer | Planner | Loader-owned; read only | One rebuild |
| Input | Desired tile LOD | Tile LOD calculation | Planner | Scalar in loader state | One rebuild |
| Input | Parsed chunk origins and tile fallback origin | ADT result/loader | Planner | Loader-owned; read only | Map session |
| Input | `GodotWorldPosition` | Streamer focus adapter | Planner | Immutable caller reference | One rebuild |
| Output | Remove `{tile, chunk}` records | Planner | Loader removal queue | Fresh caller array/dictionaries | Until drained/rebuilt |
| Output | Create `{tile, chunk, lod, dist_sq}` records | Planner | Loader creation queue | Fresh nearest-first array/dictionaries | Until drained/rebuilt |
Distance is horizontal XZ squared distance from the chunk origin, not its center.
Y is intentionally ignored. A missing/sparse parsed origin falls back to the
tile state's existing origin exactly as before extraction.
## Data flow
```mermaid
flowchart TD
Start[plan] --> FocusValid{Typed focus?}
FocusValid -->|no| Empty[Return two empty arrays]
FocusValid -->|yes| Tile[Iterate tile state insertion order]
Tile --> TileLod{Desired tile LOD active?}
TileLod -->|yes| RemoveAll[Append every current chunk removal]
TileLod -->|no| Compare[Compare current and desired chunks]
Compare --> RemoveMissing[Append current chunks absent from desired]
Compare --> CreateMismatch[Append LOD-mismatched creates]
Compare --> CreateMissing[Append absent desired creates]
CreateMismatch --> Origin[Parsed chunk origin or tile fallback]
CreateMissing --> Origin
Origin --> Distance[Attach squared XZ distance]
RemoveAll --> Next[Next tile]
RemoveMissing --> Next
Distance --> Next
Next --> Sort[Sort creates nearest-first]
Sort --> Result[Return fresh arrays]
```
## Lifecycle/state
The planner is stateless. Each loader queue rebuild supplies current read models
and receives new arrays. The loader adopts and drains those arrays until another
rebuild replaces them. Planner destruction requires no cleanup or cancellation.
## Main sequence
```mermaid
sequenceDiagram
participant Loader as StreamingWorldLoader
participant Planner as TerrainChunkGeometryQueuePlanner
participant Budget as RenderBudgetScheduler
participant Render as Existing terrain finalization
Loader->>Planner: plan(tile states, typed focus)
Planner-->>Loader: fresh create/remove arrays
Loader->>Budget: request shared chunk_geometry permit
Budget-->>Loader: permit
Loader->>Render: pop_back removal first
Loader->>Budget: request shared chunk_geometry permit
Budget-->>Loader: permit
Loader->>Render: pop_front nearest creation
```
## Ownership, threading and resources
- The planner owns only call-local arrays and dictionaries until return.
- The loader owns adopted queue storage, drain ordering, tile/chunk state and all
Mesh/Node/material/RID side effects.
- Calls and drains currently run on the renderer main thread. The planner has no
lock, task, retained resource or concurrent mutation.
- Removal and creation continue to share the existing `chunk_geometry` budget lane.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Null/wrong focus script | Identity guard | Return empty request arrays | Contract verifier | Supply typed focus and rebuild |
| Sparse/out-of-range chunk data | Origin resolver | Use tile fallback origin | Synthetic fixture | Normal ADT completion restores precise origin |
| Tile LOD becomes desired | Desired tile LOD check | Remove every current chunk; no chunk creates | Queue metrics | Existing tile-LOD create path takes over |
| Queue rebuild before drain | Loader lifecycle | Replace old desired-operation arrays | Existing queue metrics | Latest state wins |
| Shutdown/map reset | Loader lifecycle | Loader clears queues; planner has nothing to cancel | Shutdown verifier | New session replans |
## Configuration and capabilities
The planner introduces no settings. It consumes already-calculated desired LOD
state. Operation limits, profile defaults and removal-first drain order remain
loader/scheduler configuration.
## Persistence, cache and migration
No serialization, cache entry or format changes. Baked terrain, control-splat
and splat caches require no rebuild or migration.
## Diagnostics and observability
The planner emits no logs. Existing queue metrics observe the adopted arrays.
The contract verifier reports semantic cases and bounded multi-tile timing.
Asset-backed p95/p99 remains milestone acceptance work.
## Verification
- `verify_terrain_chunk_geometry_queue_planner.gd`: missing/mismatched/unchanged
chunks, tile-LOD removal, sparse origin fallback, XZ-only distance, stable
removals, nearest creates, invalid focus, loader boundary and bounded timing.
- Existing LOD planner, terrain cache, facade, internal-access, manifest,
shutdown, scheduler, streaming and coordinate regressions remain required.
- Fidelity evidence is exact branch/request/order preservation only. No new
build-12340 visual parity claim is made.
## Extension points
- Extract tile-LOD operation planning as its own bounded contract if justified.
- Add explicit typed tile-state adapters only when more than one consumer exists.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Pure chunk create/remove planning | Implemented extraction | Asset-free semantic/source/timing verifier | Asset-backed p95/p99 pending |
| Queue execution/resource ownership | Remains in loader | Drain source boundary and scheduler regression | Further terrain decomposition required |
## Known gaps and risks
- Raw tile/chunk dictionaries remain a transitional ADT adapter boundary.
- The planner performs a second linear tile-state pass beside tile-LOD planning;
bounded synthetic timing is green, but asset-backed frame metrics are pending.
- Equal-distance create ordering retains comparator behavior without a tie-breaker.
- Terrain geometry finalization and state mutation remain monolithic.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/terrain/terrain_chunk_geometry_queue_planner.gd` | Pure request calculation and nearest-first creation order |
| `src/scenes/streaming/streaming_world_loader.gd` | Queue adoption, tile-LOD planning, budgets and render side effects |
| `src/tools/verify_terrain_chunk_geometry_queue_planner.gd` | Semantic, boundary and timing regression |
## Related decisions and references
- [`terrain-chunk-lod-planner.md`](terrain-chunk-lod-planner.md)
- [`world-renderer.md`](world-renderer.md)
- [`../../RENDER.md`](../../RENDER.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
+179
View File
@@ -0,0 +1,179 @@
# Terrain Chunk LOD Planner
## Metadata
| Field | Value |
|---|---|
| Status | Implemented extraction |
| Target/work package | M03 / `M03-RND-TERRAIN-LOD-PLANNER-001` |
| Owners | Pure desired chunk-LOD calculation only |
| Last verified | Worktree `work/sindo-main-codex/m03-terrain-chunk-lod-planner`, 2026-07-16 |
| Profiles/capabilities | Existing Performance/Balanced/High/Custom radii |
## Purpose
Calculate the desired terrain LOD for each populated parsed ADT chunk from one
typed Godot-space focus and one immutable renderer-policy snapshot.
## Non-goals
- Own tile/chunk state, queues, budgets, tasks, Mesh, Node, material or RID.
- Select tile LOD, create/remove geometry or apply a completed plan.
- Normalize custom radii, change profile defaults or claim visual parity.
- Parse ADT data or convert between WoW and Godot coordinate spaces.
## Context and boundaries
```mermaid
flowchart LR
Focus[GodotWorldPosition] --> Planner[TerrainChunkLodPlanner]
Chunks[Parsed ADT chunk records] --> Planner
Config[Streamer LOD configuration] --> Policy[TerrainChunkLodPolicy]
Policy --> Planner
Planner --> Desired[chunk index to LOD 0/1/2]
Desired --> Loader[StreamingWorldLoader state adapter]
Loader --> Queue[Existing create/remove queues and budgets]
```
Allowed dependencies are the typed Godot position, immutable policy and
call-local Array/Dictionary/Vector3 values. SceneTree, RenderingServer, gameplay,
network, files, tasks and persistent caches are forbidden.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `TerrainChunkLodPolicy.new(...)` | Immutable value | Snapshot enabled flag, three raw radii and chunk size | Caller-owned; one planning refresh | Values are preserved without normalization |
| `TerrainChunkLodPlanner.plan(chunks, focus_position, policy)` | Pure query | Map populated source indices to LOD 0/1/2 | Synchronous; result caller-owned | Invalid/null typed inputs or disabled policy return empty |
The planner validates scripts by preloaded identity so it works from a cold
Godot global-class cache.
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Parsed chunk array with `origin: Vector3` | ADT load result | Planner | Loader-owned; read only | One call |
| Input | `GodotWorldPosition` | Streamer focus adapter | Planner | Immutable caller reference | One call |
| Input | `TerrainChunkLodPolicy` | Streamer exported configuration | Planner | Immutable caller reference | One call |
| Output | `{source_chunk_index: 0/1/2}` | Planner | Loader desired state | Fresh caller dictionary | One call |
The Y coordinate is intentionally ignored. Empty chunk records and chunks beyond
the third radius are absent from the result.
## Data flow
```mermaid
flowchart TD
Start[plan] --> Valid{Typed inputs and enabled?}
Valid -->|no| Empty[Return empty dictionary]
Valid -->|yes| Radii[Square raw radius times chunk size]
Radii --> Iterate[Visit source chunk indices in order]
Iterate --> Populated{Record populated?}
Populated -->|no| Iterate
Populated -->|yes| Center[origin XZ plus half chunk]
Center --> Distance[Squared horizontal distance]
Distance --> Threshold{First inclusive threshold}
Threshold -->|LOD0/1/2| Record[Record source index]
Threshold -->|outside| Iterate
Record --> Iterate
Iterate --> Done[Return desired mapping]
```
## Lifecycle/state
The planner is stateless. A policy is constructed for one refresh, read during
synchronous planning and released. The returned dictionary is then owned by the
loader state. There is no cancellation, recovery state or retained resource.
## Main sequence
```mermaid
sequenceDiagram
participant Stream as StreamingWorldLoader
participant Policy as TerrainChunkLodPolicy
participant Planner as TerrainChunkLodPlanner
Stream->>Policy: snapshot enabled/radii/chunk size
Stream->>Planner: plan(chunks, typed focus, policy)
Planner-->>Stream: fresh desired LOD dictionary
Stream->>Stream: existing tile state, queue and budget application
```
## Ownership, threading and resources
- Planner and policy own no engine resources, nodes, RIDs, tasks or caches.
- The loader retains parsed chunks, desired state and every application side effect.
- Current calls run synchronously on the renderer main thread; the pure inputs
also make isolated contract testing possible without a world scene.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Null/wrong focus or policy script | Identity guard | Empty plan | Contract verifier | Supply typed values next refresh |
| Disabled chunk LOD | Policy flag | Empty plan; existing tile-LOD fallback applies | Existing renderer state | Enable profile option |
| Empty chunk record | Record guard | Preserve sparse source index; omit record | None | Not an error |
| Custom negative radius | No normalization | Existing squared-radius behavior is retained | Regression fixture | Change caller configuration if undesired |
There is no asynchronous work or cancellation token.
## Configuration and capabilities
The policy snapshots `use_chunk_terrain_lods`, `lod0_radius_chunks`,
`lod1_radius_chunks`, `lod2_radius_chunks` and the existing `CHUNK_SIZE`. Inclusive
squared thresholds and raw custom values deliberately preserve prior behavior.
## Persistence, cache and migration
No serialization or cache format changes. Existing baked terrain, control-splat
and splat cache versions remain unchanged and require no rebuild.
## Diagnostics and observability
The service emits no logs. The contract verifier reports semantic cases and a
bounded synthetic timing. Asset-backed p95/p99 remains milestone acceptance work.
## Verification
- `verify_terrain_chunk_lod_planner.gd`: center and vertical invariance,
inclusive thresholds, sparse/outside chunks, disabled/custom-negative policy,
loader delegation and 250-by-256 bounded planning.
- Renderer cache, facade, internal-access, baseline, scheduler, target-planner,
focus, ground-query and coordinate-boundary regressions remain required.
- Fidelity evidence is formula preservation only; no new build-12340 visual
equivalence claim is made.
## Extension points
- Add measured direction/velocity only through an explicit policy/contract change.
- Keep queue prioritization and LOD application as separately bounded services.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Pure chunk desired-LOD calculation | Implemented extraction | Asset-free semantic/source-boundary contract | Asset-backed p95/p99 pending |
| Queue/state/finalization ownership | Remains in loader | Source-boundary regression | Further terrain decomposition required |
## Known gaps and risks
- Raw negative and non-monotonic custom radii retain historical squared/ordered
semantics, even though such settings may be surprising.
- The planner is linear in parsed chunk count and allocates one result dictionary.
- Terrain build, quality upgrades and geometry lifecycle remain monolithic.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/terrain/terrain_chunk_lod_policy.gd` | Immutable configuration snapshot |
| `src/render/terrain/terrain_chunk_lod_planner.gd` | Pure horizontal-distance plan |
| `src/scenes/streaming/streaming_world_loader.gd` | Composition and plan application adapter |
| `src/tools/verify_terrain_chunk_lod_planner.gd` | Contract, dependency and timing regression |
## Related decisions and references
- [`world-renderer.md`](world-renderer.md)
- [`terrain-quality-mesh-cache.md`](terrain-quality-mesh-cache.md)
- [`../../RENDER.md`](../../RENDER.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
+62 -2
View File
@@ -5,9 +5,9 @@
| Field | Value |
|---|---|
| Status | Partial |
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; `M03-RND-TERRAIN-CACHE-SERVICE-001` |
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; M03 terrain packages; M03 M2 unique/transform/grouping packages |
| Owners | Renderer workstream / milestone integrator |
| Last verified | Worktree `work/sindo-main-codex/m03-terrain-quality-cache`, 2026-07-16 |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-placement-grouper`, 2026-07-16 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose
@@ -42,6 +42,16 @@ flowchart LR
Cache[Baked terrain/M2/WMO caches] --> Loader
Loader --> TerrainCache[TerrainQualityMeshCache]
TerrainCache --> Loader
Loader --> ChunkLod[TerrainChunkLodPlanner]
ChunkLod --> Loader
Loader --> ChunkQueue[TerrainChunkGeometryQueuePlanner]
ChunkQueue --> Loader
Loader --> M2Registry[M2UniquePlacementRegistry]
M2Registry --> Loader
Loader --> M2Grouper[M2PlacementGrouper]
M2Grouper --> M2Transform[M2PlacementTransformResolver]
M2Transform --> M2Grouper
M2Transform --> Loader
Native --> Parsed[Parsed tile/model data]
Parsed --> Loader
Loader --> Scene[SceneTree nodes]
@@ -106,6 +116,11 @@ from externally reading/writing loader-private queue, task, cache and tile-state
| `runtime_stats_enabled` | Exported property | Enables periodic performance snapshot | Runtime mutable | Logging overhead only |
| `hitch_profiler_enabled` | Exported property | Enables named hitch sections | Runtime mutable | Logging overhead only |
| `TerrainQualityMeshCache.store/restore/clear` | Internal terrain service | Owns admitted full-quality revisit Mesh references and LRU | Renderer thread/map session | Rejected/missed entry leaves loader fallback unchanged |
| `TerrainChunkLodPlanner.plan` | Internal pure terrain query | Maps populated parsed chunk indices to desired LOD 0/1/2 | Synchronous per target refresh | Invalid/disabled input returns empty for existing tile-LOD fallback |
| `TerrainChunkGeometryQueuePlanner.plan` | Internal pure terrain query | Produces chunk create/remove requests from current and desired state | Synchronous per queue rebuild | Invalid focus returns empty arrays |
| `M2UniquePlacementRegistry.reserve/release/clear` | Internal M2 service | Owns positive cross-tile ADT placement-ID reservations | Renderer thread/map session | Invalid/unkeyed/non-owner inputs preserve documented fallback |
| `M2PlacementTransformResolver.resolve_basis/resolve_origin_offset` | Internal pure M2 service | Resolves regular and calibrated model-specific ADT placement transforms | Worker/main thread; stateless | Unknown paths use regular basis and zero offset |
| `M2PlacementGrouper.group_placements` | Internal pure M2 service | Validates and groups ordered tile-local placement transforms by normalized path | Worker/main thread; stateless | Invalid variants/name IDs/empty paths are skipped |
Публичным contract не считаются `StreamingWorldLoader` methods/properties,
внутренние dictionaries, queues, job records и generated node names. Scene-owned
@@ -128,6 +143,11 @@ loader configuration remains transitional composition data, not a caller API.
| Output | `StreamingTargetPlan` | `StreamingTargetPlanner` | Streamer queue/state apply | Immutable ephemeral value | One refresh |
| Internal control | Per-frame lane limits and boolean permits | Streamer configuration / `RenderBudgetScheduler` | Ordered streamer drains | Scheduler-owned counters | Main thread/frame |
| Internal cache | Full-quality terrain Mesh/source | Loader terrain upgrade / `TerrainQualityMeshCache` | Revisited tile state | Cache-retained Mesh reference | Map session until eviction/reset |
| Internal plan | Parsed chunks, typed focus and immutable chunk-LOD policy | Loader / `TerrainChunkLodPlanner` | Loader desired state | Fresh dictionary; no retained resources | One target refresh |
| Internal plan | Current/desired chunk state and typed focus | Loader / `TerrainChunkGeometryQueuePlanner` | Loader chunk queues | Fresh request arrays; no retained resources | One queue rebuild |
| Internal registry | Tile key and M2 placement unique IDs | Loader / `M2UniquePlacementRegistry` | Filtered grouping input and tile retry state | Registry-owned strings; fresh result arrays | Map session |
| Internal transform | Rotation/path/scale | Loader or grouper / `M2PlacementTransformResolver` | Group/placeholder/instance transforms | Value-only Basis/Vector3 | One placement |
| Internal grouping | Tile origin, M2 names and placements | Loader / `M2PlacementGrouper` | Loader worker result/build job | Fresh Dictionary/Transform3D arrays | One grouping task |
| Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame |
| Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach |
| Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand |
@@ -167,6 +187,14 @@ flowchart TD
Parse --> R[Result queues]
R --> TerrainCache[TerrainQualityMeshCache store]
TerrainCache --> Revisit[Revisited tile state restore]
R --> ChunkLod[TerrainChunkLodPlanner]
ChunkLod --> DesiredChunkLod[Desired chunk LOD state]
DesiredChunkLod --> ChunkQueue[TerrainChunkGeometryQueuePlanner]
ChunkQueue --> B
R --> M2Registry[M2UniquePlacementRegistry reserve]
M2Registry --> M2Grouper[M2PlacementGrouper]
M2Transform[M2PlacementTransformResolver] --> M2Grouper
M2Grouper --> M2
R --> B[RenderBudgetScheduler permits]
B --> Terrain[Terrain attach/upgrade]
B --> M2[M2 group/MultiMesh attach]
@@ -272,6 +300,20 @@ sequenceDiagram
- `TerrainQualityMeshCache` owns only full-quality revisit Mesh references and
LRU keys. The loader retains terrain tasks/results, tile state, source choice,
cache versions and every material/Node/RID finalization side effect.
- `TerrainChunkLodPlanner` is stateless and owns only call-local horizontal
distance calculations. The loader retains parsed chunks, desired state,
queues, budgets and all Mesh/Node/RID application side effects.
- `TerrainChunkGeometryQueuePlanner` is stateless and owns only call-local
create/remove request calculation and sorting. The loader adopts the fresh
arrays and retains removal-first drains, budgets and render side effects.
- `M2UniquePlacementRegistry` owns only positive unique-key to tile-key strings.
The loader retains owned/skipped tile arrays, candidate retry, grouping/build
tasks, caches and all MultiMesh/Node/Mesh/material/RID side effects.
- `M2PlacementTransformResolver` is stateless and retains no resources. The
grouper owns worker-path final transforms; direct placeholder/instance
transforms and every build/render side effect remain loader-owned.
- `M2PlacementGrouper` is stateless and owns only call-local grouped transforms.
The loader retains tasks, mutex/result queues, stale checks and build state.
- Rendered-ground query results and diagnostic snapshots are caller-owned values;
the facade never returns Mesh, Node, tile-state or queue references.
- Loaded-mesh ground sampling is renderer diagnostics, not authoritative gameplay
@@ -348,6 +390,19 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
- Unit/contract tests: streaming-focus contract/wiring, material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan.
- Pure planner contract: center/corner/catalog/clamp/editor cases plus bounded
High-like iteration timing without loading a world scene.
- Terrain chunk LOD contract: horizontal chunk-center distance, inclusive
thresholds, sparse indices, disabled/raw-custom policy, loader delegation and
bounded 250-by-256 planning without loading a world scene.
- Terrain chunk geometry queue contract: current/desired comparison, tile-LOD
takeover, sparse origin fallback, stable removals, nearest creates, loader
drain boundary and bounded multi-tile timing without a world scene.
- M2 unique placement contract: unkeyed pass-through, within-call/cross-tile
dedupe, owner release/retry, clear, detached diagnostics, loader boundary and
bounded reserve/release timing without a world scene.
- M2 placement transform contract: regular/cliffrock/waterfall formulas, path
normalization, scale compensation, all three consumers and bounded timing.
- M2 placement grouper contract: invalid/default records, historical path rules,
group order, local transforms, fresh output, source boundary and bounded timing.
- Budget scheduler contract: exact lane exhaustion, shared chunk removal/create
priority, independent lanes, frame reset, invalid limits, terminal cancellation,
dependency boundaries and bounded permit timing without loading a world scene.
@@ -377,6 +432,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
|---|---|---|---|
| ADT streaming/terrain | Partial | M00 checkpoints and current scenes | Fidelity/performance gaps remain |
| Static M2 placement | Partial | MultiMesh/dedupe probes | Full materials/animation/effects |
| M2 unique placement registry | Implemented extraction | Scene-free ownership/lifecycle/timing contract and historical `uid:11785` smoke | Group/build/tasks/finalization and asset-backed p95/p99 remain pending |
| M2 placement transform resolver | Implemented extraction | Scene-free formula/source/timing contract across three consumers | Asset-backed visual recheck and general placement parity pending |
| M2 placement grouper | Implemented extraction | Scene-free validation/order/transform/source/timing contract | Worker/build state, spatial cells and asset-backed p95/p99 remain pending |
| WMO rendering | Partial | Cached group rendering | Portals/rooms/material parity |
| Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity |
| Sky/light | Partial | DBC controller/logs | Zone transition paired validation |
@@ -387,6 +445,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Environment snapshot input | Implemented boundary / Partial fidelity | Immutable time contract, facade/controller delegation and checkpoint migration | Weather/indoor state and paired zone-transition fidelity remain |
| Entity presentation commands | Implemented boundary / Prototype visuals | Versioned typed snapshot plus present/remove lifecycle and runtime service composition | Display/equipment/animation/effects and async scale path remain |
| Terrain quality Mesh cache service | Implemented extraction | Scene-free admission/LRU/clear contract and loader delegation | Remaining terrain build/state/finalization is monolithic |
| Terrain chunk LOD planner | Implemented extraction | Scene-free formula/dependency/timing contract and loader delegation | Queue/state/finalization and asset-backed p95/p99 remain pending |
| Terrain chunk geometry queue planner | Implemented extraction | Scene-free request/order/dependency/timing contract and loader delegation | Queue drains/state/finalization and asset-backed p95/p99 remain pending |
| Pure streaming target planner | Implemented | `M03-RND-STREAMING-PLANNER-001` asset-free contract/performance regression | Queue scheduling and loaded-tile LOD application remain in streamer |
| Bounded render budget scheduler | Implemented | `M03-RND-SCHEDULER-001` asset-free permit/cancellation/performance regression | Queue storage, worker concurrency and operation execution remain in streamer/services |
| Gameplay/editor/tool internal-access boundary | Implemented | Access gate scans gameplay, EditorPlugin packages and terrain probe against actual private streamer declarations | Add future renderer diagnostic tools explicitly to the protected list |
+65
View File
@@ -0,0 +1,65 @@
class_name M2PlacementGrouper
extends RefCounted
## Stateless ADT M2 placement validation, normalization and transform grouping.
const M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT := preload(
"res://src/render/m2/m2_placement_transform_resolver.gd"
)
## Groups valid placements by normalized relative M2 path while preserving
## placement order inside each group. Returned containers are caller-owned.
func group_placements(
tile_origin: Vector3,
m2_names: PackedStringArray,
m2_placements: Array
) -> Dictionary:
var groups: Dictionary = {}
var placement_transform_resolver := (
M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
)
for placement_variant in m2_placements:
if not (placement_variant is Dictionary):
continue
var placement: Dictionary = placement_variant
var name_id: int = int(placement.get("name_id", -1))
if name_id < 0 or name_id >= m2_names.size():
continue
var normalized_relative_path := _normalize_relative_path(m2_names[name_id])
if normalized_relative_path.is_empty():
continue
var world_position: Vector3 = placement.get("pos", Vector3.ZERO)
var local_position := world_position - tile_origin
var rotation_radians: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value := float(placement.get("scale", 1.0))
var placement_basis: Basis = placement_transform_resolver.resolve_basis(
rotation_radians,
normalized_relative_path
)
var origin_offset: Vector3 = placement_transform_resolver.resolve_origin_offset(
rotation_radians,
normalized_relative_path,
scale_value
)
var local_transform := Transform3D(
placement_basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)),
local_position + origin_offset
)
if not groups.has(normalized_relative_path):
groups[normalized_relative_path] = []
(groups[normalized_relative_path] as Array).append(local_transform)
return groups
func _normalize_relative_path(relative_path: String) -> String:
var normalized_relative_path := relative_path.replace("\\", "/")
if (
normalized_relative_path.ends_with(".mdx")
or normalized_relative_path.ends_with(".mdl")
):
normalized_relative_path = normalized_relative_path.get_basename() + ".m2"
return normalized_relative_path
@@ -0,0 +1 @@
uid://dkktxa5essfn
@@ -0,0 +1,110 @@
class_name M2PlacementTransformResolver
extends RefCounted
## Stateless ADT M2 placement orientation and origin-correction rules.
const CLIFFROCK_WORLD_YAW_OFFSET := PI
const WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
## Resolves the unscaled Godot-space basis for one ADT M2 placement.
func resolve_basis(rotation_radians: Vector3, relative_m2_path: String = "") -> Basis:
if not _is_waterfall_path(relative_m2_path):
return _resolve_regular_basis(rotation_radians, relative_m2_path)
var placement_basis := Basis.from_euler(rotation_radians)
return (
Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET)
* placement_basis
* Basis(
_resolve_waterfall_local_fall_axis(relative_m2_path),
_resolve_waterfall_sheet_twist(relative_m2_path)
)
)
## Resolves local origin compensation for a twisted waterfall sheet. Regular
## models and waterfall models without a measured anchor return zero.
func resolve_origin_offset(
rotation_radians: Vector3,
relative_m2_path: String,
scale_value: float
) -> Vector3:
if not _is_waterfall_path(relative_m2_path):
return Vector3.ZERO
var twist_radians := _resolve_waterfall_sheet_twist(relative_m2_path)
if is_zero_approx(twist_radians):
return Vector3.ZERO
var anchor_local_position := _resolve_waterfall_sheet_twist_anchor(relative_m2_path)
if anchor_local_position == Vector3.ZERO:
return Vector3.ZERO
var placement_basis := (
Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET)
* Basis.from_euler(rotation_radians)
)
var twisted_basis := placement_basis * Basis(
_resolve_waterfall_local_fall_axis(relative_m2_path),
twist_radians
)
return (
placement_basis * anchor_local_position
- twisted_basis * anchor_local_position
) * maxf(scale_value, 0.0001)
func _resolve_regular_basis(rotation_radians: Vector3, relative_m2_path: String) -> Basis:
if not _is_elwynn_cliffrock_path(relative_m2_path):
return Basis.from_euler(rotation_radians)
return (
Basis(Vector3.UP, rotation_radians.y + CLIFFROCK_WORLD_YAW_OFFSET)
* Basis(Vector3.BACK, rotation_radians.x)
* Basis(Vector3.RIGHT, -rotation_radians.z)
)
func _is_waterfall_path(relative_m2_path: String) -> bool:
var model_name := _normalized_model_name(relative_m2_path)
return model_name == "newwaterfall" or model_name == "elwynntallwaterfall01"
func _is_elwynn_cliffrock_path(relative_m2_path: String) -> bool:
var model_name := _normalized_model_name(relative_m2_path)
return model_name == "elwynncliffrock01" or model_name == "elwynncliffrock02"
func _normalized_model_name(relative_m2_path: String) -> String:
return relative_m2_path.replace("\\", "/").to_lower().get_file().get_basename()
func _resolve_waterfall_local_fall_axis(relative_m2_path: String) -> Vector3:
var normalized_path := relative_m2_path.replace("\\", "/").to_lower()
if normalized_path.ends_with("newwaterfall.m2") or normalized_path.ends_with("newwaterfall.mdx"):
return Vector3(-0.138742, 0.990329, 0.0).normalized()
if (
normalized_path.ends_with("elwynntallwaterfall01.m2")
or normalized_path.ends_with("elwynntallwaterfall01.mdx")
):
return Vector3(-0.068962, 0.997619, 0.0).normalized()
return Vector3.UP
func _resolve_waterfall_sheet_twist(relative_m2_path: String) -> float:
var normalized_path := relative_m2_path.replace("\\", "/").to_lower()
if normalized_path.ends_with("newwaterfall.m2") or normalized_path.ends_with("newwaterfall.mdx"):
return PI * 0.5
if (
normalized_path.ends_with("elwynntallwaterfall01.m2")
or normalized_path.ends_with("elwynntallwaterfall01.mdx")
):
return -PI * 0.5
return 0.0
func _resolve_waterfall_sheet_twist_anchor(relative_m2_path: String) -> Vector3:
var normalized_path := relative_m2_path.replace("\\", "/").to_lower()
if (
normalized_path.ends_with("elwynntallwaterfall01.m2")
or normalized_path.ends_with("elwynntallwaterfall01.mdx")
):
return Vector3(-2.667799, 89.62273, 0.00129)
return Vector3.ZERO
@@ -0,0 +1 @@
uid://bcnmp7y8ph3qa
@@ -0,0 +1,88 @@
class_name M2UniquePlacementRegistry
extends RefCounted
## Session-memory ownership registry for positive ADT MDDF unique IDs.
var _owner_tile_by_unique_key: Dictionary = {}
## Reserves unowned positive unique IDs for one tile and returns fresh arrays.
## Unkeyed placements pass through without entering registry ownership.
func reserve(tile_key: String, m2_placements: Array) -> Dictionary:
var filtered_placements: Array = []
var unique_keys: Array = []
var skipped_unique_keys: Array = []
for placement_variant in m2_placements:
if not (placement_variant is Dictionary):
continue
var placement: Dictionary = placement_variant
var unique_key := _unique_key_for_placement(placement)
if unique_key.is_empty():
filtered_placements.append(placement)
continue
if _owner_tile_by_unique_key.has(unique_key):
var owner_tile := String(_owner_tile_by_unique_key[unique_key])
if owner_tile == tile_key and unique_keys.has(unique_key):
continue
if owner_tile != tile_key:
if not skipped_unique_keys.has(unique_key):
skipped_unique_keys.append(unique_key)
continue
_owner_tile_by_unique_key[unique_key] = tile_key
if not unique_keys.has(unique_key):
unique_keys.append(unique_key)
filtered_placements.append(placement)
return {
"placements": filtered_placements,
"unique_keys": unique_keys,
"skipped_unique_keys": skipped_unique_keys,
}
## Releases only keys currently owned by the supplied tile and returns them in
## input order so the loader can notify waiting candidate tiles.
func release(tile_key: String, unique_keys: Array) -> Array:
var released_unique_keys: Array = []
for unique_key_variant in unique_keys:
var unique_key := String(unique_key_variant)
if unique_key.is_empty() or not _owner_tile_by_unique_key.has(unique_key):
continue
if String(_owner_tile_by_unique_key[unique_key]) != tile_key:
continue
_owner_tile_by_unique_key.erase(unique_key)
released_unique_keys.append(unique_key)
return released_unique_keys
## Removes all reservations during world reset or renderer shutdown.
func clear() -> void:
_owner_tile_by_unique_key.clear()
## Returns the number of currently owned positive unique IDs.
func active_count() -> int:
return _owner_tile_by_unique_key.size()
## Returns detached, key-sorted scalar diagnostics without registry references.
func diagnostic_snapshot() -> Dictionary:
var sorted_unique_keys: Array = _owner_tile_by_unique_key.keys()
sorted_unique_keys.sort()
var owners: Array = []
for unique_key_variant in sorted_unique_keys:
var unique_key := String(unique_key_variant)
owners.append({
"unique_key": unique_key,
"tile_key": String(_owner_tile_by_unique_key[unique_key]),
})
return {
"active_count": owners.size(),
"owners": owners,
}
func _unique_key_for_placement(placement: Dictionary) -> String:
var unique_id := int(placement.get("unique_id", -1))
if unique_id <= 0:
return ""
return "uid:%d" % unique_id
@@ -0,0 +1 @@
uid://cqdvx0qnpcte1
@@ -0,0 +1,102 @@
class_name TerrainChunkGeometryQueuePlanner
extends RefCounted
## Pure desired-operation planner for terrain chunk geometry queues.
const GODOT_WORLD_POSITION_SCRIPT := preload(
"res://src/domain/coordinates/godot_world_position.gd"
)
## Returns fresh nearest-first create and stable-order remove request arrays.
## The caller retains queue storage, execution order and every render side effect.
func plan(tile_states: Dictionary, focus_position: RefCounted) -> Dictionary:
var chunk_create_requests: Array = []
var chunk_remove_requests: Array = []
if (
focus_position == null
or focus_position.get_script() != GODOT_WORLD_POSITION_SCRIPT
):
return _create_plan(chunk_create_requests, chunk_remove_requests)
var focus_x_units := float(focus_position.get("x_units"))
var focus_z_units := float(focus_position.get("z_units"))
for tile_key_variant in tile_states.keys():
var tile_key := String(tile_key_variant)
var tile_state: Dictionary = tile_states[tile_key_variant]
var current_chunks: Dictionary = tile_state["chunks"]
var desired_lods: Dictionary = tile_state["desired_lods"]
var desired_tile_lod := int(tile_state.get("desired_tile_lod", -1))
if desired_tile_lod >= 0:
for chunk_id in current_chunks.keys():
chunk_remove_requests.append({"tile": tile_key, "chunk": chunk_id})
continue
for chunk_id in current_chunks.keys():
if not desired_lods.has(chunk_id):
chunk_remove_requests.append({"tile": tile_key, "chunk": chunk_id})
continue
if int(current_chunks[chunk_id]["lod"]) != int(desired_lods[chunk_id]):
chunk_create_requests.append(_create_chunk_request(
tile_key,
chunk_id,
int(desired_lods[chunk_id]),
_resolve_chunk_origin(tile_state, int(chunk_id)),
focus_x_units,
focus_z_units
))
for chunk_id in desired_lods.keys():
if current_chunks.has(chunk_id):
continue
chunk_create_requests.append(_create_chunk_request(
tile_key,
chunk_id,
int(desired_lods[chunk_id]),
_resolve_chunk_origin(tile_state, int(chunk_id)),
focus_x_units,
focus_z_units
))
chunk_create_requests.sort_custom(func(
left_request: Dictionary,
right_request: Dictionary
) -> bool:
return float(left_request["dist_sq"]) < float(right_request["dist_sq"])
)
return _create_plan(chunk_create_requests, chunk_remove_requests)
func _resolve_chunk_origin(tile_state: Dictionary, chunk_id: int) -> Vector3:
var chunks: Array = tile_state["data"].get("chunks", [])
if chunk_id >= 0 and chunk_id < chunks.size():
var chunk: Dictionary = chunks[chunk_id]
if not chunk.is_empty():
return chunk.get("origin", Vector3.ZERO)
return tile_state.get("origin", Vector3.ZERO)
func _create_chunk_request(
tile_key: String,
chunk_id: Variant,
desired_lod: int,
chunk_origin: Vector3,
focus_x_units: float,
focus_z_units: float
) -> Dictionary:
var delta_x_units := chunk_origin.x - focus_x_units
var delta_z_units := chunk_origin.z - focus_z_units
return {
"tile": tile_key,
"chunk": chunk_id,
"lod": desired_lod,
"dist_sq": delta_x_units * delta_x_units + delta_z_units * delta_z_units,
}
func _create_plan(chunk_create_requests: Array, chunk_remove_requests: Array) -> Dictionary:
return {
"create_requests": chunk_create_requests,
"remove_requests": chunk_remove_requests,
}
@@ -0,0 +1 @@
uid://ct1ar5inci34u
@@ -0,0 +1,53 @@
class_name TerrainChunkLodPlanner
extends RefCounted
## Pure linear desired-state planner for parsed ADT chunk records.
const POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
## Maps source chunk indices to LOD 0/1/2 using horizontal distance from the
## typed focus to each chunk center. Empty/out-of-range chunks remain absent.
func plan(
chunks: Array,
focus_position: RefCounted,
policy: RefCounted
) -> Dictionary:
if (
focus_position == null
or focus_position.get_script() != GODOT_WORLD_POSITION_SCRIPT
or policy == null
or policy.get_script() != POLICY_SCRIPT
or not bool(policy.get("is_enabled"))
):
return {}
var chunk_size_units := float(policy.get("chunk_size_units"))
var lod0_radius_squared := (float(policy.get("lod0_radius_chunks")) * chunk_size_units) ** 2
var lod1_radius_squared := (float(policy.get("lod1_radius_chunks")) * chunk_size_units) ** 2
var lod2_radius_squared := (float(policy.get("lod2_radius_chunks")) * chunk_size_units) ** 2
var focus_x_units := float(focus_position.get("x_units"))
var focus_z_units := float(focus_position.get("z_units"))
var desired_lods: Dictionary = {}
for chunk_id in range(chunks.size()):
var chunk: Dictionary = chunks[chunk_id]
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var center_x_units := origin.x + chunk_size_units * 0.5
var center_z_units := origin.z + chunk_size_units * 0.5
var delta_x_units := center_x_units - focus_x_units
var delta_z_units := center_z_units - focus_z_units
var distance_squared_units := (
delta_x_units * delta_x_units + delta_z_units * delta_z_units
)
if distance_squared_units <= lod0_radius_squared:
desired_lods[chunk_id] = 0
elif distance_squared_units <= lod1_radius_squared:
desired_lods[chunk_id] = 1
elif distance_squared_units <= lod2_radius_squared:
desired_lods[chunk_id] = 2
return desired_lods
@@ -0,0 +1 @@
uid://x8simqkfctqw
@@ -0,0 +1,45 @@
class_name TerrainChunkLodPolicy
extends RefCounted
## Immutable snapshot of the streamer's chunk-terrain LOD configuration.
var is_enabled: bool:
get:
return _is_enabled
var lod0_radius_chunks: int:
get:
return _lod0_radius_chunks
var lod1_radius_chunks: int:
get:
return _lod1_radius_chunks
var lod2_radius_chunks: int:
get:
return _lod2_radius_chunks
var chunk_size_units: float:
get:
return _chunk_size_units
var _is_enabled: bool
var _lod0_radius_chunks: int
var _lod1_radius_chunks: int
var _lod2_radius_chunks: int
var _chunk_size_units: float
## Captures one renderer configuration snapshot for a deterministic plan.
func _init(
is_enabled_value: bool,
lod0_radius_chunks_value: int,
lod1_radius_chunks_value: int,
lod2_radius_chunks_value: int,
chunk_size_units_value: float
) -> void:
_is_enabled = is_enabled_value
_lod0_radius_chunks = lod0_radius_chunks_value
_lod1_radius_chunks = lod1_radius_chunks_value
_lod2_radius_chunks = lod2_radius_chunks_value
_chunk_size_units = chunk_size_units_value
@@ -0,0 +1 @@
uid://dmwi83dp52pa8
+96 -239
View File
@@ -19,6 +19,20 @@ const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_ti
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd")
const TERRAIN_QUALITY_MESH_CACHE_SCRIPT := preload("res://src/render/terrain/terrain_quality_mesh_cache.gd")
const TERRAIN_CHUNK_LOD_POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd")
const TERRAIN_CHUNK_LOD_PLANNER_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_planner.gd")
const TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT := preload(
"res://src/render/terrain/terrain_chunk_geometry_queue_planner.gd"
)
const M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT := preload(
"res://src/render/m2/m2_unique_placement_registry.gd"
)
const M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT := preload(
"res://src/render/m2/m2_placement_transform_resolver.gd"
)
const M2_PLACEMENT_GROUPER_SCRIPT := preload(
"res://src/render/m2/m2_placement_grouper.gd"
)
const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd")
const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd")
const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd")
@@ -31,8 +45,6 @@ const RENDER_GROUND_QUERY_RAY_HEIGHT_UNITS := 5000.0
const TILE_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI
const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
const QUALITY_CUSTOM := "Custom"
const QUALITY_PERFORMANCE := "Performance"
const QUALITY_BALANCED := "Balanced"
@@ -181,6 +193,10 @@ const QUALITY_HIGH := "High"
var _builder
var _streaming_target_planner := STREAMING_TARGET_PLANNER_SCRIPT.new()
var _terrain_chunk_lod_planner := TERRAIN_CHUNK_LOD_PLANNER_SCRIPT.new()
var _terrain_chunk_geometry_operation_planner := (
TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT.new()
)
var _map_dir := ""
var _abs_map_dir := ""
var _wdt_info: Dictionary = {}
@@ -198,7 +214,11 @@ var _m2_group_result_mutex := Mutex.new()
var _m2_group_result_queue: Array = []
var _m2_build_jobs: Dictionary = {}
var _m2_build_queue: Array = []
var _m2_unique_registry: Dictionary = {}
var _m2_unique_placement_registry := (
M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()
)
var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
var _m2_placement_grouper := M2_PLACEMENT_GROUPER_SCRIPT.new()
var _m2_mesh_cache: Dictionary = {}
var _m2_mesh_load_requests: Dictionary = {}
var _m2_mesh_finalize_queue: Array = []
@@ -797,6 +817,8 @@ func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionary, focus_pos: Vector3) -> void:
_last_focus_pos = focus_pos
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
var terrain_chunk_lod_policy = _create_terrain_chunk_lod_policy()
var filtered_load_queue: Array = []
for request in _tile_load_queue:
@@ -831,7 +853,7 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar
state["wanted"] = wanted
state["retained"] = retained
if wanted:
state["desired_lods"] = _compute_desired_lods(state, focus_pos)
state["desired_lods"] = _compute_desired_lods(state, typed_focus_position, terrain_chunk_lod_policy)
state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, focus_pos)
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
elif retained:
@@ -852,34 +874,29 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar
_rebuild_chunk_queues()
func _chunk_origin_for(state: Dictionary, chunk_id: int) -> Vector3:
var chunks: Array = state["data"].get("chunks", [])
if chunk_id >= 0 and chunk_id < chunks.size():
var c: Dictionary = chunks[chunk_id]
if not c.is_empty():
return c.get("origin", Vector3.ZERO)
return state.get("origin", Vector3.ZERO)
func _rebuild_chunk_queues() -> void:
_chunk_remove_queue.clear()
_chunk_create_queue.clear()
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(
_last_focus_pos.x,
_last_focus_pos.y,
_last_focus_pos.z
)
var chunk_queue_plan: Dictionary = _terrain_chunk_geometry_operation_planner.plan(
_tile_states,
typed_focus_position
)
_chunk_remove_queue = chunk_queue_plan.get("remove_requests", [])
_chunk_create_queue = chunk_queue_plan.get("create_requests", [])
_tile_lod_remove_queue.clear()
_tile_lod_create_queue.clear()
for key in _tile_states.keys():
var state: Dictionary = _tile_states[key]
var current_chunks: Dictionary = state["chunks"]
var desired_lods: Dictionary = state["desired_lods"]
var current_tile_lod: int = int(state.get("tile_lod", -1))
var desired_tile_lod: int = int(state.get("desired_tile_lod", -1))
var current_tile_lod_visible: bool = bool(state.get("tile_lod_visible", false))
var desired_tile_lod_visible: bool = bool(state.get("desired_tile_lod_visible", false))
if desired_tile_lod >= 0:
for chunk_id in current_chunks.keys():
_chunk_remove_queue.append({"tile": key, "chunk": chunk_id})
if current_tile_lod != desired_tile_lod or current_tile_lod_visible != desired_tile_lod_visible:
_tile_lod_create_queue.append({
"tile": key,
@@ -900,37 +917,7 @@ func _rebuild_chunk_queues() -> void:
if current_tile_lod >= 0 and _is_tile_chunk_set_ready(state):
_tile_lod_remove_queue.append({"tile": key})
for chunk_id in current_chunks.keys():
if not desired_lods.has(chunk_id):
_chunk_remove_queue.append({"tile": key, "chunk": chunk_id})
continue
if current_chunks[chunk_id]["lod"] != desired_lods[chunk_id]:
var lod_change_origin: Vector3 = _chunk_origin_for(state, chunk_id)
var dx2: float = lod_change_origin.x - _last_focus_pos.x
var dz2: float = lod_change_origin.z - _last_focus_pos.z
_chunk_create_queue.append({
"tile": key,
"chunk": chunk_id,
"lod": desired_lods[chunk_id],
"dist_sq": dx2 * dx2 + dz2 * dz2,
})
for chunk_id in desired_lods.keys():
if current_chunks.has(chunk_id):
continue
var chunk_origin: Vector3 = _chunk_origin_for(state, chunk_id)
var dx: float = chunk_origin.x - _last_focus_pos.x
var dz: float = chunk_origin.z - _last_focus_pos.z
_chunk_create_queue.append({
"tile": key,
"chunk": chunk_id,
"lod": desired_lods[chunk_id],
"dist_sq": dx * dx + dz * dz,
})
# Sort after fully rebuilding so nearest chunks are processed first
_chunk_create_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["dist_sq"] < b["dist_sq"])
# Sort tile LOD creates after fully rebuilding; chunk creates arrive nearest-first.
_tile_lod_create_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["dist_sq"] < b["dist_sq"])
@@ -1035,7 +1022,7 @@ func render_baseline_snapshot() -> Dictionary:
"chunk_remove": _chunk_remove_queue.size(),
},
"cache_entries": _tile_mesh_cache.size(),
"m2_active_unique_ids": _m2_unique_registry.size(),
"m2_active_unique_ids": _m2_unique_placement_registry.active_count(),
"wmo_instances": _wmo_registry.size(),
}
@@ -2492,7 +2479,16 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void:
}
# Use _last_focus_pos — already set correctly for both editor and game.
state["desired_lods"] = _compute_desired_lods(state, _last_focus_pos)
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(
_last_focus_pos.x,
_last_focus_pos.y,
_last_focus_pos.z
)
state["desired_lods"] = _compute_desired_lods(
state,
typed_focus_position,
_create_terrain_chunk_lod_policy()
)
state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, _last_focus_pos)
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
_tile_states[key] = state
@@ -2980,7 +2976,7 @@ func _clear_streamed_world() -> void:
for key in _m2_build_jobs.keys():
_cancel_m2_build_job(String(key))
_m2_build_queue.clear()
_m2_unique_registry.clear()
_m2_unique_placement_registry.clear()
_m2_mesh_load_requests.clear()
_m2_mesh_finalize_queue.clear()
_m2_runtime_rebuild_required_cache.clear()
@@ -3045,33 +3041,23 @@ func _reset_terrain_root_children() -> void:
child.free()
func _compute_desired_lods(state: Dictionary, cam_pos: Vector3) -> Dictionary:
if not use_chunk_terrain_lods:
return {}
var desired := {}
func _compute_desired_lods(
state: Dictionary,
typed_focus_position: RefCounted,
terrain_chunk_lod_policy: RefCounted
) -> Dictionary:
var chunks: Array = state["data"].get("chunks", [])
return _terrain_chunk_lod_planner.plan(chunks, typed_focus_position, terrain_chunk_lod_policy)
var lod0_sq: float = (lod0_radius_chunks * CHUNK_SIZE) ** 2
var lod1_sq: float = (lod1_radius_chunks * CHUNK_SIZE) ** 2
var lod2_sq: float = (lod2_radius_chunks * CHUNK_SIZE) ** 2
for chunk_id in range(chunks.size()):
var chunk: Dictionary = chunks[chunk_id]
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var center := origin + Vector3(CHUNK_SIZE * 0.5, 0.0, CHUNK_SIZE * 0.5)
var dx := center.x - cam_pos.x
var dz := center.z - cam_pos.z
var dist_sq := dx * dx + dz * dz
if dist_sq <= lod0_sq: desired[chunk_id] = 0
elif dist_sq <= lod1_sq: desired[chunk_id] = 1
elif dist_sq <= lod2_sq: desired[chunk_id] = 2
return desired
func _create_terrain_chunk_lod_policy() -> RefCounted:
return TERRAIN_CHUNK_LOD_POLICY_SCRIPT.new(
use_chunk_terrain_lods,
lod0_radius_chunks,
lod1_radius_chunks,
lod2_radius_chunks,
CHUNK_SIZE
)
func _runtime_load_tile_radius() -> int:
@@ -3998,86 +3984,6 @@ func _rebuild_cached_wmo_material(material: Material) -> Material:
return WMO_BUILDER_SCRIPT._build_material(mat_def, textures, extracted_dir)
func _adt_m2_placement_basis(rot: Vector3, rel_path: String = "") -> Basis:
if not _is_waterfall_m2_path(rel_path):
return _adt_m2_regular_placement_basis(rot, rel_path)
# ADTLoader stores MDDF yaw as raw_yaw - 90 degrees for historical Godot
# facing alignment. In our tile-positive ADT world, waterfall models need a
# world-space +90 degree yaw to anchor their top to the source pool. Twist
# the sheet around its own local fall axis afterwards, so the anchor stays in
# place but the alpha plane is not rendered edge-on.
var basis := Basis.from_euler(rot)
return (
Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET)
* basis
* Basis(_waterfall_local_fall_axis(rel_path), _waterfall_sheet_twist(rel_path))
)
func _adt_m2_regular_placement_basis(rot: Vector3, rel_path: String = "") -> Basis:
if not _is_elwynn_cliffrock_m2_path(rel_path):
return Basis.from_euler(rot)
# Elwynn cliff rock M2s are open-backed shell meshes. Plain Godot Euler
# points their hollow side out of the cliff at waterfall placements. Keep the
# correction model-specific so normal props retain the native ADT rotation.
return (
Basis(Vector3.UP, rot.y + ADT_CLIFFROCK_WORLD_YAW_OFFSET)
* Basis(Vector3.BACK, rot.x)
* Basis(Vector3.RIGHT, -rot.z)
)
func _adt_m2_placement_origin_offset(rot: Vector3, rel_path: String, scale_value: float) -> Vector3:
if not _is_waterfall_m2_path(rel_path):
return Vector3.ZERO
var twist := _waterfall_sheet_twist(rel_path)
if is_zero_approx(twist):
return Vector3.ZERO
var anchor := _waterfall_sheet_twist_anchor(rel_path)
if anchor == Vector3.ZERO:
return Vector3.ZERO
var base := Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET) * Basis.from_euler(rot)
var twisted := base * Basis(_waterfall_local_fall_axis(rel_path), twist)
return (base * anchor - twisted * anchor) * maxf(scale_value, 0.0001)
func _is_waterfall_m2_path(rel_path: String) -> bool:
var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename()
return name == "newwaterfall" or name == "elwynntallwaterfall01"
func _is_elwynn_cliffrock_m2_path(rel_path: String) -> bool:
var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename()
return name == "elwynncliffrock01" or name == "elwynncliffrock02"
func _waterfall_local_fall_axis(rel_path: String) -> Vector3:
var normalized := rel_path.replace("\\", "/").to_lower()
if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"):
return Vector3(-0.138742, 0.990329, 0.0).normalized()
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
return Vector3(-0.068962, 0.997619, 0.0).normalized()
return Vector3.UP
func _waterfall_sheet_twist(rel_path: String) -> float:
var normalized := rel_path.replace("\\", "/").to_lower()
if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"):
return PI * 0.5
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
return -PI * 0.5
return 0.0
func _waterfall_sheet_twist_anchor(rel_path: String) -> Vector3:
var normalized := rel_path.replace("\\", "/").to_lower()
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
return Vector3(-2.667799, 89.62273, 0.00129)
return Vector3.ZERO
func _cancel_wmo_build_job(tile_key: String) -> void:
if _wmo_build_jobs.has(tile_key):
_wmo_build_jobs.erase(tile_key)
@@ -4154,7 +4060,10 @@ func _request_tile_m2_assets(
if _m2_group_tasks.has(key) or _m2_build_jobs.has(key):
return result
var filtered := _reserve_tile_m2_placements(key, m2_placements)
var filtered: Dictionary = _m2_unique_placement_registry.reserve(
key,
m2_placements
)
var filtered_placements: Array = filtered.get("placements", [])
result["unique_keys"] = filtered.get("unique_keys", [])
result["skipped_unique_keys"] = filtered.get("skipped_unique_keys", [])
@@ -4168,46 +4077,6 @@ func _request_tile_m2_assets(
return result
func _reserve_tile_m2_placements(tile_key: String, m2_placements: Array) -> Dictionary:
var filtered_placements: Array = []
var unique_keys: Array = []
var skipped_unique_keys: Array = []
for index in m2_placements.size():
var placement_variant = m2_placements[index]
if not (placement_variant is Dictionary):
continue
var placement: Dictionary = placement_variant
var unique_key := _m2_unique_key(placement)
if unique_key.is_empty():
filtered_placements.append(placement)
continue
if _m2_unique_registry.has(unique_key):
var entry: Dictionary = _m2_unique_registry[unique_key]
var owner_tile := String(entry.get("tile", ""))
if owner_tile == tile_key and unique_keys.has(unique_key):
continue
if owner_tile != tile_key:
if not skipped_unique_keys.has(unique_key):
skipped_unique_keys.append(unique_key)
continue
_m2_unique_registry[unique_key] = {"tile": tile_key}
if not unique_keys.has(unique_key):
unique_keys.append(unique_key)
filtered_placements.append(placement)
return {
"placements": filtered_placements,
"unique_keys": unique_keys,
"skipped_unique_keys": skipped_unique_keys,
}
func _m2_unique_key(placement: Dictionary) -> String:
var uid: int = int(placement.get("unique_id", -1))
if uid <= 0:
return ""
return "uid:%d" % uid
func _release_tile_m2_unique_keys(state: Dictionary, notify_candidates: bool = true) -> void:
var tile_key := String(state.get("key", ""))
var unique_keys: Array = state.get("m2_unique_keys", [])
@@ -4216,16 +4085,7 @@ func _release_tile_m2_unique_keys(state: Dictionary, notify_candidates: bool = t
state["m2_skipped_unique_keys"] = []
return
var released_keys: Array = []
for unique_key_variant in unique_keys:
var unique_key := String(unique_key_variant)
if unique_key.is_empty() or not _m2_unique_registry.has(unique_key):
continue
var entry: Dictionary = _m2_unique_registry[unique_key]
if String(entry.get("tile", "")) != tile_key:
continue
_m2_unique_registry.erase(unique_key)
released_keys.append(unique_key)
var released_keys: Array = _m2_unique_placement_registry.release(tile_key, unique_keys)
state["m2_unique_keys"] = []
state["m2_skipped_unique_keys"] = []
@@ -4263,32 +4123,11 @@ func _group_tile_m2_task(
tile_origin: Vector3,
m2_names: PackedStringArray,
m2_placements: Array) -> void:
var groups: Dictionary = {}
for placement_variant in m2_placements:
if not (placement_variant is Dictionary):
continue
var placement: Dictionary = placement_variant
var name_id: int = int(placement.get("name_id", -1))
if name_id < 0 or name_id >= m2_names.size():
continue
var normalized: String = str(m2_names[name_id]).replace("\\", "/")
if normalized.ends_with(".mdx") or normalized.ends_with(".mdl"):
normalized = normalized.get_basename() + ".m2"
if normalized.is_empty():
continue
var world_pos: Vector3 = placement.get("pos", Vector3.ZERO)
var pos: Vector3 = world_pos - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
var basis := _adt_m2_placement_basis(rot, normalized)
var offset := _adt_m2_placement_origin_offset(rot, normalized, scale_value)
var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos + offset)
if not groups.has(normalized):
groups[normalized] = []
(groups[normalized] as Array).append(xform)
var groups: Dictionary = _m2_placement_grouper.group_placements(
tile_origin,
m2_names,
m2_placements
)
_m2_group_result_mutex.lock()
_m2_group_result_queue.append({
@@ -4971,8 +4810,18 @@ func _build_tile_m2_placeholders(tile_root: Node3D, tile_origin: Vector3, m2_nam
var local_pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
var xform_basis := _adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.01))
var xf := Transform3D(xform_basis, local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value))
var xform_basis := _m2_placement_transform_resolver.resolve_basis(
rot,
rel_path
).scaled(Vector3.ONE * maxf(scale_value, 0.01))
var xf := Transform3D(
xform_basis,
local_pos + _m2_placement_transform_resolver.resolve_origin_offset(
rot,
rel_path,
scale_value
)
)
if is_tree:
tree_transforms.append(xf)
else:
@@ -5022,8 +4871,16 @@ func _instantiate_m2(rel_path: String, placement: Dictionary, tile_origin: Vecto
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
node.transform = Transform3D(
_adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.0001)),
local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value))
_m2_placement_transform_resolver.resolve_basis(
rot,
rel_path
).scaled(Vector3.ONE * maxf(scale_value, 0.0001)),
local_pos + _m2_placement_transform_resolver.resolve_origin_offset(
rot,
rel_path,
scale_value
)
)
return node
+296
View File
@@ -0,0 +1,296 @@
extends SceneTree
## Asset-free contract, dependency and timing regression for ADT M2 grouping.
const GROUPER_SCRIPT := preload("res://src/render/m2/m2_placement_grouper.gd")
const TRANSFORM_RESOLVER_SCRIPT := preload(
"res://src/render/m2/m2_placement_transform_resolver.gd"
)
const GROUPER_PATH := "res://src/render/m2/m2_placement_grouper.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_empty_and_invalid_inputs(failures)
_verify_historical_path_normalization(failures)
_verify_group_order(failures)
_verify_tile_local_defaults(failures)
_verify_regular_transform(failures)
_verify_waterfall_transform(failures)
_verify_scale_clamp(failures)
_verify_fresh_output(failures)
_verify_source_boundaries(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("M2_PLACEMENT_GROUPER: %s" % failure)
quit(1)
return
print(
"M2_PLACEMENT_GROUPER PASS cases=9 iterations=100 placements=256 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_empty_and_invalid_inputs(failures: Array[String]) -> void:
var grouper := GROUPER_SCRIPT.new()
_expect_equal(
grouper.group_placements(Vector3.ZERO, PackedStringArray(), []).size(),
0,
"empty",
failures
)
var placements: Array = [
"invalid",
{"name_id": -1},
{"name_id": 2},
{"name_id": 0},
]
var groups: Dictionary = grouper.group_placements(
Vector3.ZERO,
PackedStringArray([""]),
placements
)
_expect_equal(groups.size(), 0, "invalid placements", failures)
func _verify_historical_path_normalization(failures: Array[String]) -> void:
var grouper := GROUPER_SCRIPT.new()
var names := PackedStringArray([
"World\\Generic\\Tree.mdx",
"World/Generic/Rock.mdl",
"World/Generic/Upper.MDX",
])
var placements: Array = [{"name_id": 0}, {"name_id": 1}, {"name_id": 2}]
var groups: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
_expect_true(groups.has("World/Generic/Tree.m2"), "backslash and mdx normalization", failures)
_expect_true(groups.has("World/Generic/Rock.m2"), "mdl normalization", failures)
_expect_true(groups.has("World/Generic/Upper.MDX"), "uppercase suffix preservation", failures)
func _verify_group_order(failures: Array[String]) -> void:
var grouper := GROUPER_SCRIPT.new()
var placements: Array = [
{"name_id": 0, "pos": Vector3(3.0, 0.0, 0.0)},
{"name_id": 1, "pos": Vector3(8.0, 0.0, 0.0)},
{"name_id": 0, "pos": Vector3(5.0, 0.0, 0.0)},
]
var groups: Dictionary = grouper.group_placements(
Vector3.ZERO,
PackedStringArray(["Tree.m2", "Rock.m2"]),
placements
)
_expect_equal(groups.size(), 2, "group count", failures)
var tree_transforms: Array = groups.get("Tree.m2", [])
_expect_equal(tree_transforms.size(), 2, "tree transform count", failures)
if tree_transforms.size() == 2:
_expect_vector_near(tree_transforms[0].origin, Vector3(3.0, 0.0, 0.0), "first tree", failures)
_expect_vector_near(tree_transforms[1].origin, Vector3(5.0, 0.0, 0.0), "second tree", failures)
func _verify_tile_local_defaults(failures: Array[String]) -> void:
var grouper := GROUPER_SCRIPT.new()
var groups: Dictionary = grouper.group_placements(
Vector3(10.0, 20.0, 30.0),
PackedStringArray(["Tree.m2"]),
[{"name_id": 0}]
)
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
_expect_vector_near(transform.origin, Vector3(-10.0, -20.0, -30.0), "default local position", failures)
_expect_basis_near(transform.basis, Basis.IDENTITY, "default basis and scale", failures)
func _verify_regular_transform(failures: Array[String]) -> void:
var grouper := GROUPER_SCRIPT.new()
var rotation := Vector3(0.2, -0.4, 0.6)
var groups: Dictionary = grouper.group_placements(
Vector3(1.0, 2.0, 3.0),
PackedStringArray(["Tree.m2"]),
[{
"name_id": 0,
"pos": Vector3(5.0, 8.0, 13.0),
"rot": rotation,
"scale": 2.0,
}]
)
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
_expect_basis_near(transform.basis, Basis.from_euler(rotation).scaled(Vector3.ONE * 2.0), "regular basis", failures)
_expect_vector_near(transform.origin, Vector3(4.0, 6.0, 10.0), "regular origin", failures)
func _verify_waterfall_transform(failures: Array[String]) -> void:
var grouper := GROUPER_SCRIPT.new()
var resolver := TRANSFORM_RESOLVER_SCRIPT.new()
var path := "World/X/ElwynnTallWaterfall01.m2"
var rotation := Vector3(0.05, -0.2, 0.15)
var scale_value := 1.5
var position := Vector3(20.0, 30.0, 40.0)
var tile_origin := Vector3(2.0, 3.0, 4.0)
var groups: Dictionary = grouper.group_placements(
tile_origin,
PackedStringArray([path]),
[{
"name_id": 0,
"pos": position,
"rot": rotation,
"scale": scale_value,
}]
)
var transform: Transform3D = (groups[path] as Array)[0]
var expected_basis: Basis = resolver.resolve_basis(rotation, path).scaled(
Vector3.ONE * scale_value
)
var expected_origin: Vector3 = (
position
- tile_origin
+ resolver.resolve_origin_offset(rotation, path, scale_value)
)
_expect_basis_near(transform.basis, expected_basis, "waterfall basis", failures)
_expect_vector_near(transform.origin, expected_origin, "waterfall origin", failures)
func _verify_scale_clamp(failures: Array[String]) -> void:
var grouper := GROUPER_SCRIPT.new()
var groups: Dictionary = grouper.group_placements(
Vector3.ZERO,
PackedStringArray(["Tree.m2"]),
[{"name_id": 0, "scale": -5.0}]
)
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
_expect_basis_near(
transform.basis,
Basis.IDENTITY.scaled(Vector3.ONE * 0.0001),
"negative scale clamp",
failures
)
func _verify_fresh_output(failures: Array[String]) -> void:
var grouper := GROUPER_SCRIPT.new()
var names := PackedStringArray(["Tree.m2"])
var placements: Array = [{"name_id": 0}]
var first: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
(first["Tree.m2"] as Array).clear()
var second: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
_expect_equal((second["Tree.m2"] as Array).size(), 1, "fresh output", failures)
func _verify_source_boundaries(failures: Array[String]) -> void:
var loader_source := _read_text(LOADER_PATH, failures)
var grouper_source := _read_text(GROUPER_PATH, failures)
var grouping_task_source := _source_between(
loader_source,
"func _group_tile_m2_task(",
"func _drain_m2_group_results()",
failures
)
_expect_equal(
loader_source.count("_m2_placement_grouper.group_placements("),
1,
"one loader grouping adapter",
failures
)
_expect_true(
not grouping_task_source.contains("for placement_variant in m2_placements"),
"loader omits grouping loop",
failures
)
_expect_equal(
grouper_source.count("placement_transform_resolver.resolve_basis("),
1,
"one grouper basis adapter",
failures
)
_expect_equal(
grouper_source.count("placement_transform_resolver.resolve_origin_offset("),
1,
"one grouper offset adapter",
failures
)
for forbidden_dependency in ["WorkerThreadPool", "Mutex", "RenderingServer", "ResourceLoader"]:
_expect_true(
not grouper_source.contains(forbidden_dependency),
"grouper omits %s" % forbidden_dependency,
failures
)
func _verify_bounded_timing(failures: Array[String]) -> float:
var grouper := GROUPER_SCRIPT.new()
var names := PackedStringArray(["Tree.m2", "Rock.mdx"])
var placements: Array = []
for placement_index in range(256):
placements.append({
"name_id": placement_index % 2,
"pos": Vector3(float(placement_index), 0.0, float(-placement_index)),
"rot": Vector3(0.1, 0.2, 0.3),
"scale": 1.0,
})
var started_microseconds := Time.get_ticks_usec()
for iteration in range(100):
var groups: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
if groups.size() != 2:
failures.append("timing iteration %d produced wrong group count" % iteration)
break
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(elapsed_milliseconds < 1000.0, "100 x 256 grouping bounded", failures)
return elapsed_milliseconds
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _source_between(
source: String,
start_marker: String,
end_marker: String,
failures: Array[String]
) -> String:
var start_offset := source.find(start_marker)
var end_offset := source.find(end_marker, start_offset + start_marker.length())
if start_offset < 0 or end_offset <= start_offset:
failures.append("cannot isolate source between %s and %s" % [start_marker, end_marker])
return ""
return source.substr(start_offset, end_offset - start_offset)
func _expect_basis_near(
actual_basis: Basis,
expected_basis: Basis,
label: String,
failures: Array[String]
) -> void:
if not actual_basis.is_equal_approx(expected_basis):
failures.append("%s basis mismatch" % label)
func _expect_vector_near(
actual_value: Vector3,
expected_value: Vector3,
label: String,
failures: Array[String]
) -> void:
if not actual_value.is_equal_approx(expected_value):
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_equal(
actual_value: int,
expected_value: int,
label: String,
failures: Array[String]
) -> void:
if actual_value != expected_value:
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://bbx57qy0lp11w
@@ -0,0 +1,233 @@
extends SceneTree
## Asset-free formula, dependency and timing regression for ADT M2 transforms.
const RESOLVER_SCRIPT := preload("res://src/render/m2/m2_placement_transform_resolver.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const GROUPER_PATH := "res://src/render/m2/m2_placement_grouper.gd"
const WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
const CLIFFROCK_WORLD_YAW_OFFSET := PI
func _initialize() -> void:
var failures: Array[String] = []
_verify_regular_basis(failures)
_verify_cliffrock_basis(failures)
_verify_new_waterfall_basis(failures)
_verify_tall_waterfall_basis_and_offset(failures)
_verify_path_normalization_and_scale_clamp(failures)
_verify_loader_boundary(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("M2_PLACEMENT_TRANSFORM_RESOLVER: %s" % failure)
quit(1)
return
print(
"M2_PLACEMENT_TRANSFORM_RESOLVER PASS cases=9 iterations=10000 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_regular_basis(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(0.2, -0.7, 1.1)
_expect_basis_near(
resolver.call("resolve_basis", rotation, "World/Generic/Tree.m2"),
Basis.from_euler(rotation),
"regular basis",
failures
)
_expect_vector_near(
resolver.call("resolve_origin_offset", rotation, "World/Generic/Tree.m2", 3.0),
Vector3.ZERO,
"regular offset",
failures
)
func _verify_cliffrock_basis(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(0.3, 0.4, -0.2)
var expected_basis := (
Basis(Vector3.UP, rotation.y + CLIFFROCK_WORLD_YAW_OFFSET)
* Basis(Vector3.BACK, rotation.x)
* Basis(Vector3.RIGHT, -rotation.z)
)
_expect_basis_near(
resolver.call("resolve_basis", rotation, "WORLD\\AZEROTH\\ElwynnCliffRock01.MDX"),
expected_basis,
"cliffrock basis",
failures
)
func _verify_new_waterfall_basis(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(-0.1, 0.8, 0.2)
var fall_axis := Vector3(-0.138742, 0.990329, 0.0).normalized()
var expected_basis := (
Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET)
* Basis.from_euler(rotation)
* Basis(fall_axis, PI * 0.5)
)
_expect_basis_near(
resolver.call("resolve_basis", rotation, "world/x/NewWaterfall.m2"),
expected_basis,
"new waterfall basis",
failures
)
_expect_vector_near(
resolver.call("resolve_origin_offset", rotation, "world/x/NewWaterfall.m2", 1.0),
Vector3.ZERO,
"new waterfall offset",
failures
)
func _verify_tall_waterfall_basis_and_offset(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(0.05, -0.2, 0.15)
var path := "world/x/ElwynnTallWaterfall01.m2"
var fall_axis := Vector3(-0.068962, 0.997619, 0.0).normalized()
var placement_basis := (
Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET) * Basis.from_euler(rotation)
)
var expected_basis := placement_basis * Basis(fall_axis, -PI * 0.5)
var anchor := Vector3(-2.667799, 89.62273, 0.00129)
var expected_offset := (placement_basis * anchor - expected_basis * anchor) * 1.5
_expect_basis_near(
resolver.call("resolve_basis", rotation, path),
expected_basis,
"tall waterfall basis",
failures
)
_expect_vector_near(
resolver.call("resolve_origin_offset", rotation, path, 1.5),
expected_offset,
"tall waterfall offset",
failures
)
func _verify_path_normalization_and_scale_clamp(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var path := "WORLD\\X\\ELWYNNTALLWATERFALL01.MDX"
var minimum_offset: Vector3 = resolver.call("resolve_origin_offset", Vector3.ZERO, path, 0.0001)
_expect_vector_near(
resolver.call("resolve_origin_offset", Vector3.ZERO, path, -9.0),
minimum_offset,
"negative scale clamp",
failures
)
_expect_basis_near(
resolver.call("resolve_basis", Vector3.ZERO, "ElwynnCliffRock02.m2"),
Basis(Vector3.UP, PI),
"second cliffrock",
failures
)
func _verify_loader_boundary(failures: Array[String]) -> void:
var source := _read_text(LOADER_PATH, failures)
var grouper_source := _read_text(GROUPER_PATH, failures)
_expect_true(
source.contains("M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()"),
"loader composes resolver",
failures
)
_expect_equal(
source.count("_m2_placement_transform_resolver.resolve_basis("),
2,
"two direct loader basis adapters",
failures
)
_expect_equal(
source.count("_m2_placement_transform_resolver.resolve_origin_offset("),
2,
"two direct loader offset adapters",
failures
)
_expect_equal(
grouper_source.count("placement_transform_resolver.resolve_basis("),
1,
"one grouped basis adapter",
failures
)
_expect_equal(
grouper_source.count("placement_transform_resolver.resolve_origin_offset("),
1,
"one grouped offset adapter",
failures
)
_expect_true(
not source.contains("func _adt_m2_placement_basis("),
"loader omits basis formula",
failures
)
_expect_true(
not source.contains("func _waterfall_sheet_twist("),
"loader omits waterfall formula",
failures
)
func _verify_bounded_timing(failures: Array[String]) -> float:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(0.1, 0.2, 0.3)
var started_microseconds := Time.get_ticks_usec()
for iteration in range(10000):
var path := (
"world/x/ElwynnTallWaterfall01.m2"
if iteration % 4 == 0
else "world/generic/Tree.m2"
)
resolver.call("resolve_basis", rotation, path)
resolver.call("resolve_origin_offset", rotation, path, 1.0)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(elapsed_milliseconds < 1000.0, "10000 transform pairs bounded", failures)
return elapsed_milliseconds
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_basis_near(
actual_basis: Basis,
expected_basis: Basis,
label: String,
failures: Array[String]
) -> void:
if not actual_basis.is_equal_approx(expected_basis):
failures.append("%s basis mismatch" % label)
func _expect_vector_near(
actual_value: Vector3,
expected_value: Vector3,
label: String,
failures: Array[String]
) -> void:
if not actual_value.is_equal_approx(expected_value):
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_equal(
actual_value: int,
expected_value: int,
label: String,
failures: Array[String]
) -> void:
if actual_value != expected_value:
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://b4a7gtg46jrdn
+10 -14
View File
@@ -1,12 +1,14 @@
extends SceneTree
const STREAMING_WORLD_LOADER := preload("res://src/scenes/streaming/streaming_world_loader.gd")
const M2_UNIQUE_PLACEMENT_REGISTRY := preload(
"res://src/render/m2/m2_unique_placement_registry.gd"
)
var _failures := 0
func _initialize() -> void:
var loader = STREAMING_WORLD_LOADER.new()
var registry: RefCounted = M2_UNIQUE_PLACEMENT_REGISTRY.new()
var placement := {
"name_id": 0,
"unique_id": 11785,
@@ -15,30 +17,24 @@ func _initialize() -> void:
"scale": 1.0,
}
var first: Dictionary = loader.call("_reserve_tile_m2_placements", "30_49", [placement])
var first: Dictionary = registry.call("reserve", "30_49", [placement])
_expect((first.get("placements", []) as Array).size() == 1, "first tile should reserve unique M2 placement")
_expect((first.get("unique_keys", []) as Array).has("uid:11785"), "first tile should own uid:11785")
_expect((first.get("skipped_unique_keys", []) as Array).is_empty(), "first tile should not skip uid:11785")
var duplicate: Dictionary = loader.call("_reserve_tile_m2_placements", "31_49", [placement])
var duplicate: Dictionary = registry.call("reserve", "31_49", [placement])
_expect((duplicate.get("placements", []) as Array).is_empty(), "second tile should skip duplicate uid:11785")
_expect((duplicate.get("skipped_unique_keys", []) as Array).has("uid:11785"), "second tile should remember skipped uid:11785")
loader.call("_release_tile_m2_unique_keys", {
"key": "30_49",
"m2_unique_keys": ["uid:11785"],
"m2_skipped_unique_keys": [],
}, false)
var after_release: Dictionary = loader.call("_reserve_tile_m2_placements", "31_49", [placement])
registry.call("release", "30_49", ["uid:11785"])
var after_release: Dictionary = registry.call("reserve", "31_49", [placement])
_expect((after_release.get("placements", []) as Array).size() == 1, "second tile should reserve uid:11785 after owner release")
_expect((after_release.get("unique_keys", []) as Array).has("uid:11785"), "second tile should own uid:11785 after release")
var same_tile_loader = STREAMING_WORLD_LOADER.new()
var same_tile_duplicate: Dictionary = same_tile_loader.call("_reserve_tile_m2_placements", "31_49", [placement, placement])
var same_tile_registry: RefCounted = M2_UNIQUE_PLACEMENT_REGISTRY.new()
var same_tile_duplicate: Dictionary = same_tile_registry.call("reserve", "31_49", [placement, placement])
_expect((same_tile_duplicate.get("placements", []) as Array).size() == 1, "same tile should keep only one copy of duplicate uid:11785")
loader.free()
same_tile_loader.free()
if _failures > 0:
push_error("M2 unique dedupe verification failed: %d issue(s)" % _failures)
quit(1)
@@ -0,0 +1,156 @@
extends SceneTree
## Asset-free ownership, lifecycle, dependency and timing regression for M2 IDs.
const REGISTRY_SCRIPT := preload("res://src/render/m2/m2_unique_placement_registry.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_unkeyed_and_invalid_placements(failures)
_verify_same_tile_and_cross_tile_deduplication(failures)
_verify_owner_checked_release_and_retry(failures)
_verify_clear_and_detached_diagnostics(failures)
_verify_loader_boundary(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("M2_UNIQUE_PLACEMENT_REGISTRY: %s" % failure)
quit(1)
return
print(
"M2_UNIQUE_PLACEMENT_REGISTRY PASS cases=10 iterations=100 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_unkeyed_and_invalid_placements(failures: Array[String]) -> void:
var registry: RefCounted = REGISTRY_SCRIPT.new()
var result: Dictionary = registry.call("reserve", "tile-a", [
{"unique_id": -1},
{"unique_id": 0},
{"name_id": 7},
"invalid",
])
_expect_equal((result["placements"] as Array).size(), 3, "unkeyed placements pass", failures)
_expect_true((result["unique_keys"] as Array).is_empty(), "unkeyed placements not owned", failures)
_expect_equal(int(registry.call("active_count")), 0, "unkeyed active count", failures)
func _verify_same_tile_and_cross_tile_deduplication(failures: Array[String]) -> void:
var registry: RefCounted = REGISTRY_SCRIPT.new()
var placement := {"unique_id": 11785, "name_id": 0}
var first: Dictionary = registry.call("reserve", "30_49", [placement, placement])
_expect_equal((first["placements"] as Array).size(), 1, "same call duplicate filtered", failures)
_expect_string_array((first["unique_keys"] as Array), ["uid:11785"], "first owner key", failures)
var same_owner_retry: Dictionary = registry.call("reserve", "30_49", [placement])
_expect_equal((same_owner_retry["placements"] as Array).size(), 1, "same owner later call passes", failures)
var competing: Dictionary = registry.call("reserve", "31_49", [placement, placement])
_expect_true((competing["placements"] as Array).is_empty(), "competing tile filtered", failures)
_expect_string_array(
competing["skipped_unique_keys"] as Array,
["uid:11785"],
"competing skip recorded once",
failures
)
func _verify_owner_checked_release_and_retry(failures: Array[String]) -> void:
var registry: RefCounted = REGISTRY_SCRIPT.new()
var placement := {"unique_id": 44}
registry.call("reserve", "owner", [placement])
_expect_true(
(registry.call("release", "other", ["uid:44"]) as Array).is_empty(),
"non-owner release ignored",
failures
)
_expect_equal(int(registry.call("active_count")), 1, "non-owner keeps reservation", failures)
var released: Array = registry.call("release", "owner", ["", "uid:44", "uid:44"])
_expect_string_array(released, ["uid:44"], "owner release order and dedupe", failures)
var retried: Dictionary = registry.call("reserve", "other", [placement])
_expect_equal((retried["placements"] as Array).size(), 1, "waiting tile reserves after release", failures)
func _verify_clear_and_detached_diagnostics(failures: Array[String]) -> void:
var registry: RefCounted = REGISTRY_SCRIPT.new()
registry.call("reserve", "tile-b", [{"unique_id": 20}])
registry.call("reserve", "tile-a", [{"unique_id": 10}])
var snapshot: Dictionary = registry.call("diagnostic_snapshot")
var owners: Array = snapshot["owners"]
_expect_equal(int(snapshot["active_count"]), 2, "diagnostic count", failures)
_expect_string_equal(String(owners[0]["unique_key"]), "uid:10", "diagnostic sorted", failures)
owners[0]["tile_key"] = "mutated"
var fresh_snapshot: Dictionary = registry.call("diagnostic_snapshot")
_expect_string_equal(
String((fresh_snapshot["owners"] as Array)[0]["tile_key"]),
"tile-a",
"diagnostics detached",
failures
)
registry.call("clear")
registry.call("clear")
_expect_equal(int(registry.call("active_count")), 0, "clear idempotent", failures)
func _verify_loader_boundary(failures: Array[String]) -> void:
var source := _read_text(LOADER_PATH, failures)
_expect_true(source.contains("M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()"), "loader composes registry", failures)
_expect_true(source.contains("_m2_unique_placement_registry.reserve("), "loader delegates reserve", failures)
_expect_true(source.contains("_m2_unique_placement_registry.release("), "loader delegates release", failures)
_expect_true(source.contains("_m2_unique_placement_registry.clear()"), "loader delegates clear", failures)
_expect_true(not source.contains("var _m2_unique_registry"), "loader omits registry dictionary", failures)
_expect_true(not source.contains("func _m2_unique_key("), "loader omits key formula", failures)
func _verify_bounded_timing(failures: Array[String]) -> float:
var placements: Array = []
for placement_index in range(256):
placements.append({"unique_id": placement_index + 1})
var registry: RefCounted = REGISTRY_SCRIPT.new()
var started_microseconds := Time.get_ticks_usec()
for _iteration in range(100):
var reservation: Dictionary = registry.call("reserve", "owner", placements)
registry.call("release", "owner", reservation["unique_keys"])
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(elapsed_milliseconds < 1000.0, "100 reserve/release cycles remain bounded", failures)
return elapsed_milliseconds
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_equal(actual_value: int, expected_value: int, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
func _expect_string_equal(
actual_value: String,
expected_value: String,
label: String,
failures: Array[String]
) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_string_array(
actual_values: Array,
expected_values: Array,
label: String,
failures: Array[String]
) -> void:
if actual_values != expected_values:
failures.append("%s expected %s, got %s" % [label, expected_values, actual_values])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://inw4fft20be0
@@ -0,0 +1,169 @@
extends SceneTree
## Asset-free semantic, dependency and timing regression for chunk queue planning.
const PLANNER_SCRIPT := preload(
"res://src/render/terrain/terrain_chunk_geometry_queue_planner.gd"
)
const POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_create_remove_and_order(failures)
_verify_tile_lod_removes_all(failures)
_verify_fallback_origin_and_vertical_invariance(failures)
_verify_invalid_focus(failures)
_verify_loader_boundary(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER: %s" % failure)
quit(1)
return
print(
"TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER PASS cases=8 iterations=100 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_create_remove_and_order(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var tile_states := {
"A": _create_tile_state(
{0: {"lod": 0}, 1: {"lod": 1}, 3: {"lod": 0}},
{0: 0, 1: 2, 2: 1},
[
{"origin": Vector3(100.0, 0.0, 0.0)},
{"origin": Vector3(30.0, 8000.0, 0.0)},
{"origin": Vector3(10.0, -8000.0, 0.0)},
]
),
"B": _create_tile_state({5: {"lod": 0}}, {}, []),
}
var plan: Dictionary = planner.call("plan", tile_states, POSITION_SCRIPT.new(0.0, 50.0, 0.0))
var creates: Array = plan["create_requests"]
var removes: Array = plan["remove_requests"]
_expect_equal(creates.size(), 2, "missing and mismatched chunks create", failures)
_expect_equal(int(creates[0]["chunk"]), 2, "nearest missing chunk first", failures)
_expect_equal(int(creates[1]["chunk"]), 1, "farther LOD mismatch second", failures)
_expect_equal(int(creates[1]["lod"]), 2, "mismatch uses desired LOD", failures)
_expect_equal(removes.size(), 2, "undesired current chunks remove", failures)
_expect_string_equal(String(removes[0]["tile"]), "A", "stable tile removal order", failures)
_expect_equal(int(removes[0]["chunk"]), 3, "first undesired chunk", failures)
_expect_equal(int(removes[1]["chunk"]), 5, "second tile undesired chunk", failures)
func _verify_tile_lod_removes_all(failures: Array[String]) -> void:
var tile_state := _create_tile_state({7: {"lod": 0}, 8: {"lod": 1}}, {7: 0}, [])
tile_state["desired_tile_lod"] = 3
var plan: Dictionary = PLANNER_SCRIPT.new().call(
"plan",
{"tile": tile_state},
POSITION_SCRIPT.new(0.0, 0.0, 0.0)
)
_expect_true((plan["create_requests"] as Array).is_empty(), "tile LOD suppresses chunk creates", failures)
_expect_equal((plan["remove_requests"] as Array).size(), 2, "tile LOD removes every chunk", failures)
func _verify_fallback_origin_and_vertical_invariance(failures: Array[String]) -> void:
var tile_state := _create_tile_state({}, {9: 1}, [])
tile_state["origin"] = Vector3(4.0, 99999.0, 3.0)
var plan: Dictionary = PLANNER_SCRIPT.new().call(
"plan",
{"fallback": tile_state},
POSITION_SCRIPT.new(0.0, -99999.0, 0.0)
)
var request: Dictionary = (plan["create_requests"] as Array)[0]
_expect_near(float(request["dist_sq"]), 25.0, "fallback origin uses horizontal distance", failures)
func _verify_invalid_focus(failures: Array[String]) -> void:
var plan: Dictionary = PLANNER_SCRIPT.new().call("plan", {}, null)
_expect_true((plan["create_requests"] as Array).is_empty(), "invalid focus create empty", failures)
_expect_true((plan["remove_requests"] as Array).is_empty(), "invalid focus remove empty", failures)
func _verify_loader_boundary(failures: Array[String]) -> void:
var source := _read_text(LOADER_PATH, failures)
_expect_true(source.contains("TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT.new()"), "loader composes planner", failures)
_expect_true(source.contains("_terrain_chunk_geometry_operation_planner.plan("), "loader delegates plan", failures)
_expect_true(not source.contains("func _chunk_origin_for("), "loader omits chunk-origin planning", failures)
_expect_true(source.contains("_remove_chunk(_chunk_remove_queue.pop_back())"), "loader retains removal-first drain", failures)
_expect_true(source.contains("_create_chunk(_chunk_create_queue.pop_front())"), "loader retains nearest-first drain", failures)
func _verify_bounded_timing(failures: Array[String]) -> float:
var tile_states: Dictionary = {}
for tile_index in range(64):
var current_chunks: Dictionary = {}
var desired_lods: Dictionary = {}
var parsed_chunks: Array = []
for chunk_index in range(16):
current_chunks[chunk_index] = {"lod": chunk_index % 3}
desired_lods[chunk_index] = (chunk_index + 1) % 3
parsed_chunks.append({
"origin": Vector3(float(tile_index * 16 + chunk_index), 0.0, float(tile_index))
})
tile_states["tile-%d" % tile_index] = _create_tile_state(
current_chunks,
desired_lods,
parsed_chunks
)
var planner: RefCounted = PLANNER_SCRIPT.new()
var focus: RefCounted = POSITION_SCRIPT.new(0.0, 0.0, 0.0)
var started_microseconds := Time.get_ticks_usec()
for _iteration in range(100):
planner.call("plan", tile_states, focus)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(elapsed_milliseconds < 1000.0, "100 multi-tile plans remain bounded", failures)
return elapsed_milliseconds
func _create_tile_state(
current_chunks: Dictionary,
desired_lods: Dictionary,
parsed_chunks: Array
) -> Dictionary:
return {
"chunks": current_chunks,
"desired_lods": desired_lods,
"desired_tile_lod": -1,
"data": {"chunks": parsed_chunks},
"origin": Vector3.ZERO,
}
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_equal(actual_value: int, expected_value: int, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if not is_equal_approx(actual_value, expected_value):
failures.append("%s expected %.3f, got %.3f" % [label, expected_value, actual_value])
func _expect_string_equal(
actual_value: String,
expected_value: String,
label: String,
failures: Array[String]
) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://dcimxvm2atvst
@@ -0,0 +1,96 @@
extends SceneTree
## Asset-free formula, dependency and bounded timing regression for chunk LOD.
const PLANNER_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_planner.gd")
const POLICY_SCRIPT := preload("res://src/render/terrain/terrain_chunk_lod_policy.gd")
const POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_formula(failures)
_verify_disabled_and_custom_policy(failures)
_verify_loader_boundary(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("TERRAIN_CHUNK_LOD_PLANNER: %s" % failure)
quit(1)
return
print("TERRAIN_CHUNK_LOD_PLANNER PASS cases=7 iterations=250 elapsed_ms=%.3f" % elapsed_milliseconds)
quit(0)
func _verify_formula(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var policy: RefCounted = POLICY_SCRIPT.new(true, 1, 2, 3, 10.0)
var focus: RefCounted = POSITION_SCRIPT.new(0.0, 9999.0, 0.0)
var chunks: Array = [
{"origin": Vector3(-5.0, 2000.0, -5.0)},
{"origin": Vector3(5.0, -3000.0, -5.0)},
{"origin": Vector3(5.001, 0.0, -5.0)},
{"origin": Vector3(15.0, 0.0, -5.0)},
{"origin": Vector3(20.0, 0.0, -5.0)},
{"origin": Vector3(25.0, 0.0, -5.0)},
{"origin": Vector3(25.001, 0.0, -5.0)},
{},
]
var desired: Dictionary = planner.call("plan", chunks, focus, policy)
_expect_equal(int(desired.get(0, -1)), 0, "center ignores vertical distance", failures)
_expect_equal(int(desired.get(1, -1)), 0, "lod0 inclusive boundary", failures)
_expect_equal(int(desired.get(2, -1)), 1, "just outside lod0", failures)
_expect_equal(int(desired.get(3, -1)), 1, "lod1 inclusive boundary", failures)
_expect_equal(int(desired.get(4, -1)), 2, "between lod1 and lod2", failures)
_expect_equal(int(desired.get(5, -1)), 2, "lod2 inclusive boundary", failures)
_expect_true(not desired.has(6) and not desired.has(7), "outside and sparse chunks absent", failures)
func _verify_disabled_and_custom_policy(failures: Array[String]) -> void:
var planner: RefCounted = PLANNER_SCRIPT.new()
var focus: RefCounted = POSITION_SCRIPT.new(0.0, 0.0, 0.0)
var chunks: Array = [{"origin": Vector3(-5.0, 0.0, -5.0)}]
_expect_true((planner.call("plan", chunks, focus, POLICY_SCRIPT.new(false, 1, 2, 3, 10.0)) as Dictionary).is_empty(), "disabled policy empty", failures)
var negative_policy: RefCounted = POLICY_SCRIPT.new(true, -1, -2, -3, 10.0)
_expect_equal(int((planner.call("plan", chunks, focus, negative_policy) as Dictionary).get(0, -1)), 0, "negative radius keeps squared semantics", failures)
func _verify_loader_boundary(failures: Array[String]) -> void:
var source := _read_text(LOADER_PATH, failures)
_expect_true(source.contains("TERRAIN_CHUNK_LOD_PLANNER_SCRIPT.new()"), "loader composes planner", failures)
_expect_true(source.contains("_terrain_chunk_lod_planner.plan(chunks, typed_focus_position, terrain_chunk_lod_policy)"), "loader delegates plan", failures)
_expect_true(not source.contains("dist_sq <= lod0_sq"), "loader omits chunk LOD formula", failures)
func _verify_bounded_timing(failures: Array[String]) -> float:
var planner: RefCounted = PLANNER_SCRIPT.new()
var policy: RefCounted = POLICY_SCRIPT.new(true, 10, 20, 40, 33.333333)
var focus: RefCounted = POSITION_SCRIPT.new(1000.0, 0.0, 1000.0)
var chunks: Array = []
for chunk_index in range(256):
chunks.append({"origin": Vector3(float(chunk_index % 16) * 33.333333, 0.0, float(chunk_index / 16) * 33.333333)})
var started_microseconds := Time.get_ticks_usec()
for _iteration in range(250):
planner.call("plan", chunks, focus, policy)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(elapsed_milliseconds < 1000.0, "250 plans remain bounded", failures)
return elapsed_milliseconds
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_equal(actual_value: int, expected_value: int, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://2oljbpoxndx3
+45 -2
View File
@@ -27,10 +27,14 @@ Runtime и Editor используют facade; planner/scheduler тестиру
## Evidence
- Date: 2026-07-16
- Revision/worktree: master merges `7ece2ab`, `7e35de7`, `80cb084`, `d6e5b53`, `c69abd6`, `606770c`, `630a0c1`;
- Revision/worktree: master merges `7ece2ab`, `7e35de7`, `80cb084`, `d6e5b53`, `c69abd6`, `606770c`, `630a0c1`, `f36fabb`, `24aef13`, `2342430`, `396be5e`, `f88bf97`;
packages `M03-RND-SCHEDULER-001`, `M03-RND-INTERNAL-ACCESS-GATE-001`,
`M03-RND-FACADE-GROUND-QUERY-001`, `M03-RND-FACADE-ENVIRONMENT-001`,
`M03-RND-FACADE-ENTITY-001`, `M03-RND-TERRAIN-CACHE-SERVICE-001`
`M03-RND-FACADE-ENTITY-001`, `M03-RND-TERRAIN-CACHE-SERVICE-001`,
`M03-RND-TERRAIN-LOD-PLANNER-001`, `M03-RND-TERRAIN-CHUNK-QUEUE-001`,
`M03-RND-M2-UNIQUE-REGISTRY-001`,
`M03-RND-M2-PLACEMENT-TRANSFORM-001`,
`M03-RND-M2-PLACEMENT-GROUPER-001`
- Commands: dedicated scheduler/planner/facade/focus/coordinate/manifest/shutdown
headless verifiers; Godot cold editor parse; coordination, documentation and diff gates.
- Results: scheduler `cases=6 iterations=20000 elapsed_ms=10.216`; planner
@@ -47,6 +51,20 @@ Runtime и Editor используют facade; planner/scheduler тестиру
`delegation=9 entity_contract=6`, while character presentation remained `9/10/3/1`.
Terrain quality cache passed `contract_cases=15 loader_calls=3`; internal-access
inventory decreased to `42` because the loader no longer owns the LRU-order field.
Terrain chunk LOD planner passed `cases=7 iterations=250 elapsed_ms=12.630`;
post-merge facade, internal-access and `7/7` manifest gates remained green.
Terrain chunk geometry queue planner passed `cases=8 iterations=100
elapsed_ms=229.324`; internal-access remained `42` and post-merge facade and
`7/7` manifest gates stayed green.
M2 unique placement registry passed `cases=10 iterations=100 elapsed_ms=35.513`;
the historical `uid:11785` cross-tile smoke, internal-access `42`, facade and
`7/7` manifest gates remained green after merge.
M2 placement transform resolver passed `cases=9 iterations=10000
elapsed_ms=27.409`; post-merge M2 registry, internal-access `42`, facade,
`7/7` manifest, documentation and coordination gates remained green.
M2 placement grouper passed `cases=9 iterations=100 placements=256
elapsed_ms=67.104`; post-merge transform resolver, registry, internal-access
`42`, facade, `7/7` manifest, documentation and coordination gates remained green.
M02 terrain-query regression
remained green (13 pre-existing expired M00 claim warnings).
- Fidelity comparison: all 16 historical operation limits and drain sites were
@@ -60,6 +78,19 @@ Runtime и Editor используют facade; planner/scheduler тестиру
baseline plans remain unchanged by the new present/remove ownership boundary.
Terrain revisit cache retains the same admitted/excluded sources, LRU touch/trim,
profile capacities and three loader adapter sites; no cache format changed.
Chunk LOD keeps horizontal XZ chunk-center distance, inclusive squared thresholds,
sparse-index omission and raw negative-radius squaring at both loader call sites.
Chunk operation planning preserves stable removals, nearest-first create sorting,
tile-LOD remove-all takeover, sparse tile-origin fallback and XZ-only distance;
loader still drains removals first through the shared `chunk_geometry` budget.
M2 positive `MDDF.uniqueId` retains `uid:<decimal>` first-owner semantics,
within-call duplicate suppression, cross-tile skip/retry and unkeyed pass-through.
M2 regular Euler, Elwynn cliff-rock shell correction, waterfall yaw/local-axis
twist, tall-waterfall anchor compensation, path normalization and caller scale
clamps are unchanged across worker grouping, placeholders and direct instances.
M2 worker grouping retains invalid-record filtering, case-sensitive lowercase
`.mdx/.mdl` conversion, first-seen group/per-group placement order, tile-local
origin subtraction and the `0.0001` scale clamp.
No new build-12340 parity claim is made; asset-backed visual/p95/p99 comparison remains pending.
- Changed files: scene-free scheduler and verifier; streamer budget composition/
permit delegation; scheduler module spec; world renderer/module registry/RENDER notes;
@@ -67,6 +98,11 @@ Runtime и Editor используют facade; planner/scheduler тестиру
immutable environment snapshot, facade/sky adapter, migrated checkpoint time input,
contract-v1 entity snapshot, renderer-owned entity presenter, runtime composition,
scene-free terrain quality Mesh cache and loader store/restore/clear adapters,
immutable terrain chunk LOD policy, pure planner and both loader plan adapters,
pure terrain chunk geometry queue planner and loader queue-adoption adapter,
stateful M2 unique placement registry, loader reserve/release/clear adapters and
migrated historical dedupe smoke, stateless M2 placement transform resolver and
three transform consumers, stateless M2 placement grouper and worker adapter,
expanded facade/internal-access/coordinate verifiers; work-package claims and this evidence.
- Remaining risks: queue storage length and worker concurrency/stale-result logic
remain streamer-owned; cancellation stops new permits but does not interrupt
@@ -74,5 +110,12 @@ Runtime и Editor используют facade; planner/scheduler тестиру
or indoor state; entity PackedScene loading is synchronous and not accepted for
network-scale hot paths; terrain cache is entry-count bounded without Mesh-byte
metrics and the remaining terrain build/tasks/state/finalization stay monolithic;
chunk LOD asset-backed p95/p99 and visual acceptance remain pending;
chunk queue planning adds a second tile-state pass whose asset-backed p95/p99
still needs acceptance, while queue drains and geometry finalization remain monolithic;
private Azeroth data was absent for the asset-backed ADT placement probe; M2
asset-backed visual transform recheck, grouping/build/tasks/caches/finalization
and boundary traversal p95/p99 remain pending; path grouping is not yet
culling-driven spatial-cell grouping;
M03 still needs further terrain/M2/WMO/liquid service extraction
and dependency/performance acceptance.