Merge work package M03-RND-STREAMING-PLANNER-001

Work-Package: M03-RND-STREAMING-PLANNER-001
Agent: sindo-main-codex
Tests: planner/facade/focus/coordinate/manifest/shutdown contracts, dry-run and repository gates
Fidelity: preserves target sets, boundary prefetch, queue priority, caches and visible renderer behavior
This commit is contained in:
2026-07-16 00:08:32 +04:00
16 changed files with 908 additions and 83 deletions
+14
View File
@@ -889,6 +889,20 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
visibility, materials or animation. Environment, entity visuals and ground visibility, materials or animation. Environment, entity visuals and ground
query remain explicit follow-up facade contracts in M03. query remain explicit follow-up facade contracts in M03.
## 2026-07-15 StreamingTargetPlanner Extraction
- Runtime and editor wanted/retained ADT set calculation now lives in the
scene-free `StreamingTargetPlanner`.
- `StreamingTargetPolicy` preserves existing chunk/tile radii, warm/prewarm/
retain margins and the clamped boundary-prefetch threshold.
- `StreamingTargetPlan` freezes its focus/wanted/retained result; the streamer
still owns available paths, load-queue distance sorting, loaded-tile LOD/detail
state, async work, caches, nodes and RIDs.
- Asset-free fixtures cover center, edge/corner prefetch, catalog filtering,
clamp and editor behavior plus bounded High-like iteration timing.
- Cache versions, quality profiles, queue priority and visible rendering rules
are unchanged by the extraction.
## Practical Rule For Future Work ## Practical Rule For Future Work
If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it.
@@ -0,0 +1,118 @@
# M03-RND-STREAMING-PLANNER-001 — Pure streaming target planner
<!-- OPENWC_CLAIM:M03-RND-STREAMING-PLANNER-001:sindo-main-codex:2026-07-17 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-streaming-target-planner`
- Lease expires UTC: 2026-07-17
- Integrator: M03 milestone integrator
## Outcome
Extract runtime/editor ADT wanted/retained-set calculation and boundary prefetch
selection from `StreamingWorldLoader` into a scene-free, deterministic
`StreamingTargetPlanner` with typed focus input and asset-free contract tests.
## Non-goals
- Change load/retain radii, boundary thresholds, queue priority or LOD policy.
- Move queue mutation, async jobs, cache ownership or GPU finalization.
- Extract `RenderBudgetScheduler` or asset services in this package.
- Change cache formats, renderer profiles or target milestone status.
## Paths
- Exclusive: `src/render/streaming/streaming_target_planner.gd`,
`src/render/streaming/streaming_target_policy.gd`,
`src/render/streaming/streaming_target_plan.gd`,
`src/tools/verify_streaming_target_planner.gd`,
`docs/modules/streaming-target-planner.md`, this claim
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`src/tools/verify_coordinate_conversion_boundaries.gd`,
`docs/modules/world-renderer.md`, `docs/modules/coordinate-mapping.md`,
`docs/modules/README.md`, `RENDER.md`
- Generated/ignored: `.godot`, native DLL, generated cache resources and
proprietary renderer corpus
## Contracts and data
- Input: immutable `GodotWorldPosition`, available-tile key set and immutable
`StreamingTargetPolicy`
- Output: immutable `StreamingTargetPlan` with focus tile plus detached
wanted/retained tile-key sets
- Tile-key representation remains the streamer's existing `<x>_<y>` internal key
- Schema/cache/coordinate versions: unchanged
- Consumers: `StreamingWorldLoader` runtime/editor refresh paths
## Dependencies
- Requires: M01 typed coordinates/focus and integrated facade package `a100ced`
- Blocks: budget scheduler extraction and planner performance regression gate
- External state: none; tests use synthetic tile catalogs
## Verification
- Commands: dedicated planner verifier, facade/focus/coordinate regressions,
renderer manifest/dry-run and repository gates
- Fixtures: center, edge, corner, unavailable-tile, clamped-radius and editor cases
- Fidelity evidence: expected tile sets are captured from current formulas; full
renderer dry-run retains all seven checkpoint plans
- Performance budget: planner work remains bounded by square radii and creates no
Node, Resource, task, cache or RID
## Documentation deliverables
- Inline API docs for planner/policy/plan
- New module specification with input/output table and data-flow diagram
- Updated world-renderer data-flow/sequence/ownership/source map
- Updated module registry and renderer notes
## Simplicity and naming
- Important names: `StreamingTargetPlanner`, `StreamingTargetPolicy`,
`StreamingTargetPlan`
- Simplest approach: one pure calculator plus two immutable value objects
- Rejected complexity: generic graph planner, priority framework, service
container, job ownership or duplicate available-tile catalog
- Unavoidable complexity: boundary-prefetch cross product must preserve corner
behavior and existing string tile keys
- Measured optimization evidence: no new optimization; exact extraction
## Status
- State: ready-for-review
- Done: extracted immutable policy/plan and pure planner, migrated runtime/editor
target refresh, added behavior/dependency/performance coverage and module docs
- Next: M03 integrator reviews and merges `e887c3b`
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-STREAMING-PLANNER-001:e887c3b -->
## Handoff
- Commit: `e887c3b`
- Results: runtime/editor wanted and retained ADT sets plus boundary-prefetch
centers are calculated by scene-free `StreamingTargetPlanner`; streamer keeps
queue ordering, LOD/detail state, jobs, caches, nodes and RIDs.
- Verification: planner `cases=5 iterations=250 average_ms=1.765`; facade
`delegation=3 runtime_scenes=2 tools=3`; focus `contract=1 runtime_scenes=2
capture_tools=3`; coordinate boundary `files=109 consumers=6`; renderer
manifest `checkpoints=7 coverage=7 caches=7`; shutdown ownership passed;
documentation, coordination and diff gates passed.
- Fidelity: center/corner/catalog/clamp/editor fixtures preserve the extracted
formulas; seven-checkpoint dry-run retained all plans. No radius, threshold,
load priority, cache version, placement, material, animation or visibility
rule changed. No build-12340 parity claim is added.
- External/local inputs: ignored native DLL and generated ADT resource scripts
were copied from master for worktree smoke only. Proprietary ADT and character
assets were unavailable, so the dry-run emitted expected degraded-data diagnostics.
- Remaining risks: exact p95/p99 impact still needs an asset-backed baseline;
pathological custom radii are not benchmarked; tile-key strings remain the
existing internal catalog representation; queue/LOD application remains monolithic.
- Documentation updated: new planner module spec with API/input/output/data-flow/
sequence/ownership/recovery/performance/source-map sections; world renderer,
coordinate mapping, module registry and renderer implementation notes updated.
+1
View File
@@ -15,6 +15,7 @@
| Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.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) | | Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) |
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) |
| Streaming target planner | Implemented | [`streaming-target-planner.md`](streaming-target-planner.md) |
| Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) | | Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) |
| Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) | | Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
| UI/Lua/audio | Prototype/Planned | [`../../targets/roadmap/05-ui-lua-audio.md`](../../targets/roadmap/05-ui-lua-audio.md) | | UI/Lua/audio | Prototype/Planned | [`../../targets/roadmap/05-ui-lua-audio.md`](../../targets/roadmap/05-ui-lua-audio.md) |
+4
View File
@@ -116,9 +116,12 @@ flowchart LR
Godot -->|typed direct adapter| Tile Godot -->|typed direct adapter| Tile
Godot -->|typed direct adapter| Local Godot -->|typed direct adapter| Local
Godot --> TerrainQuery[AdtTerrainQuery consumer] Godot --> TerrainQuery[AdtTerrainQuery consumer]
Godot --> TargetPlanner[StreamingTargetPlanner consumer]
TerrainQuery --> Tile TerrainQuery --> Tile
TerrainQuery --> Local TerrainQuery --> Local
TerrainQuery --> Chunk TerrainQuery --> Chunk
TargetPlanner --> Tile
TargetPlanner --> Local
Tile -->|tile plus local| Godot Tile -->|tile plus local| Godot
ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw] ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw] CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw]
@@ -249,6 +252,7 @@ raw three-number arrays are not an accepted new persistence contract.
| `src/tools/verify_coordinate_golden_fixtures.gd` | Cross-source fixture schema and mapper validation | | `src/tools/verify_coordinate_golden_fixtures.gd` | Cross-source fixture schema and mapper validation |
| `src/tools/verify_coordinate_conversion_boundaries.gd` | Repository-wide manual conversion and required-consumer gate | | `src/tools/verify_coordinate_conversion_boundaries.gd` | Repository-wide manual conversion and required-consumer gate |
| `src/gameplay/terrain/adt_terrain_query.gd` | Typed Godot-position consumer for ADT tile/local/chunk lookup | | `src/gameplay/terrain/adt_terrain_query.gd` | Typed Godot-position consumer for ADT tile/local/chunk lookup |
| `src/render/streaming/streaming_target_planner.gd` | Typed Godot-position consumer for ADT focus tile and boundary-local lookup |
| `src/tools/server_spawn_render_manifest.json` | Pinned server-spawn camera, player, marker and expected ADT ownership | | `src/tools/server_spawn_render_manifest.json` | Pinned server-spawn camera, player, marker and expected ADT ownership |
| `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer manifest contract verifier | | `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer manifest contract verifier |
| `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision | | `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision |
+239
View File
@@ -0,0 +1,239 @@
# Streaming Target Planner
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | `M03-RND-STREAMING-PLANNER-001` |
| Owners | Renderer workstream / M03 integrator |
| Last verified | `e887c3b`, 2026-07-15 |
| Profiles/capabilities | Runtime and Editor preview ADT target selection; profile values supplied by caller |
## Purpose
Calculate which available ADT tiles are wanted and retained for one typed focus
without accessing the SceneTree, worker tasks, renderer queues, caches or GPU
resources. Runtime planning preserves prewarm, hysteresis and boundary-prefetch
behavior; editor planning preserves the square preview radius.
## Non-goals
- Mutate load/finalize/eviction queues.
- Prioritize queued tile paths by world distance.
- Calculate chunk or tile LOD levels after a tile is loaded.
- Own the available WDT tile catalog or cache paths.
- Create, cancel or await jobs.
- Implement M2, WMO, liquid or terrain attachment.
## Context and boundaries
```mermaid
flowchart LR
Focus[GodotWorldPosition] --> Planner[StreamingTargetPlanner]
Policy[StreamingTargetPolicy] --> Planner
Catalog[Available tile keys from WDT/directory] --> Planner
Planner --> Plan[StreamingTargetPlan]
Plan --> Streamer[StreamingWorldLoader queue/state apply]
Streamer --> Queue[Tile load queue]
```
Allowed dependencies:
- typed coordinate values and `CoordinateMapper`;
- immutable planner policy/result contracts;
- read-only available-tile key membership.
Forbidden dependencies:
- `Node`, SceneTree and viewport/camera discovery;
- `WorkerThreadPool`, `ResourceLoader` and filesystem access;
- `RenderingServer`, meshes, materials, nodes and RIDs;
- gameplay, network, UI, editor UI or server adapters;
- streamer queues, tile states and cache dictionaries.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `StreamingTargetPlanner.plan_runtime` | Pure method | Builds wanted/retained sets with edge/corner prefetch | Any thread if caller does not mutate catalog concurrently; call-scoped | Empty catalog produces empty sets |
| `StreamingTargetPlanner.plan_editor` | Pure method | Builds identical editor wanted/retained square sets | Same as runtime planning | Negative radius clamps to zero |
| `StreamingTargetPolicy` | Immutable value | Captures radius margins and boundary threshold for one plan | Caller-owned, shareable | Values retain scene input; effective margins/threshold are clamped |
| `visible_tile_radius` | Pure query | Resolves chunk-radius versus tile-radius visibility | Any thread/value lifetime | No failure |
| `load_tile_radius` | Pure query | Adds non-negative prewarm margin | Any thread/value lifetime | No failure |
| `retained_tile_radius` | Pure query | Adds non-negative hysteresis margin | Any thread/value lifetime | No failure |
| `StreamingTargetPlan` | Immutable value | Holds focus tile and read-only wanted/retained key sets | Caller-owned plan lifetime | No failure |
| `wanted_tile_keys` | Read-only query | Returns immutable wanted set | Plan lifetime | Mutation is rejected by Godot read-only Dictionary |
| `retained_tile_keys` | Read-only query | Returns immutable retained set | Plan lifetime | Mutation is rejected by Godot read-only Dictionary |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `GodotWorldPosition` | `WorldRenderFacade`/streamer focus adapter | Planner | Immutable caller value | One plan |
| Input | Available `<tile_x>_<tile_y>` keys | `StreamingWorldLoader` WDT/directory catalog | Planner membership checks | Loader-owned, read-only during call | Map session |
| Input | `StreamingTargetPolicy` | Streamer configuration snapshot | Planner | Immutable caller value | One or more plans |
| Input | Editor preview radius | Editor scene configuration | Planner | Scalar copy | One plan |
| Output | `StreamingTargetPlan.focus_tile` | Planner/`CoordinateMapper` | Streamer diagnostics | Immutable typed coordinate | Plan lifetime |
| Output | Wanted tile-key set | Planner | Streamer queue/state apply | Plan-owned read-only Dictionary | Plan lifetime |
| Output | Retained tile-key set | Planner | Streamer hysteresis/state apply | Plan-owned read-only Dictionary | Plan lifetime |
Side effects:
- none;
- no input mutation;
- no logging, I/O, task submission, cache access, SceneTree or GPU work.
## Data flow
```mermaid
flowchart TD
Position[GodotWorldPosition] --> Tile[CoordinateMapper.godot_to_adt_tile]
Position --> Local[CoordinateMapper.godot_to_adt_tile_local]
Policy[StreamingTargetPolicy] --> Radii[load and retained radii]
Policy --> Threshold[clamped boundary threshold]
Tile --> Centers[base prefetch center]
Local --> Centers
Threshold --> Centers
Centers --> Wanted[available keys within load radius]
Centers --> Retained[available keys within retained radius]
Catalog[Available tile keys] --> Wanted
Catalog --> Retained
Wanted --> Plan[StreamingTargetPlan]
Retained --> Plan
Tile --> Plan
```
## Lifecycle/state
The planner is stateless. A call creates local center/set dictionaries, freezes
the result dictionaries in `StreamingTargetPlan`, returns the plan and retains
no reference to inputs or output. There is no retry, cancellation or shutdown
state machine.
## Main sequence
```mermaid
sequenceDiagram
participant Stream as StreamingWorldLoader
participant Planner as StreamingTargetPlanner
participant Mapper as CoordinateMapper
participant Plan as StreamingTargetPlan
Stream->>Planner: plan_runtime(catalog, typed focus, policy)
Planner->>Mapper: focus tile and tile-local position
Mapper-->>Planner: typed coordinates
Planner->>Planner: prefetch centers and available key sets
Planner->>Plan: freeze focus/wanted/retained result
Plan-->>Stream: immutable target plan
Stream->>Stream: apply queues, LOD and detail state
```
## Ownership, threading and resources
- `StreamingWorldLoader` owns the available catalog and must not mutate it during a planning call.
- `StreamingTargetPolicy` and typed focus remain caller-owned immutable values.
- The planner owns temporary center/set values only until plan construction.
- `StreamingTargetPlan` owns its read-only result dictionaries.
- The planner creates no Node, Resource, RID, thread, mutex or task.
- Current runtime calls occur on the main thread; scene-free contracts permit a
future worker call only after catalog publication/ownership is explicit.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Empty/unavailable catalog | No membership matches | Valid plan with empty sets | Streamer retains existing missing-data diagnostics | Load a valid WDT/directory catalog and replan |
| Focus outside catalog/grid | Typed coordinate does not match keys | Valid focus tile with filtered empty/partial sets | Caller diagnostic if required | Supply valid focus/map or catalog |
| Negative prewarm/retain margin | Policy effective-radius query | Margin clamps to zero | Contract test | Correct configuration if negative value was unintended |
| Boundary threshold outside range | Policy query | Clamp to `0.0..0.49` | Contract test | Correct configuration or accept safe clamp |
| Plan no longer current | New focus/config/catalog revision | Caller discards ephemeral plan | Streamer refresh diagnostics | Replan from current inputs |
Cancellation is not applicable: planning is synchronous, bounded and owns no
external work. A returned stale plan is simply not retained by the planner.
## Configuration and capabilities
| Setting/capability | Default source | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| `lod2_radius_chunks` | Streamer scene/profile | Renderer | Yes at composition/debug boundary | Converts chunk coverage to minimum ADT radius |
| `lod2_tile_radius` | Streamer scene/profile | Renderer | Yes | Minimum visible ADT radius |
| `warm_tile_margin` | Streamer scene/profile | Renderer | Yes | Extends chunk-derived visible radius |
| `prewarm_tile_margin` | Streamer scene/profile | Renderer | Yes | Extends wanted/load radius |
| `retain_tile_margin` | Streamer scene/profile | Renderer | Yes | Extends retained hysteresis radius |
| `boundary_prefetch_threshold` | Streamer scene/profile | Renderer | Yes | Adds adjacent/corner prefetch centers near tile boundaries |
| Editor preview radius | Editor scene | Authoring preview | Yes | Square wanted/retained preview set |
The planner does not select `Blizzlike335` or `Enhanced`; it applies the values
already selected by renderer composition.
## Persistence, cache and migration
- Planner contracts are runtime-only and are not serialized.
- Existing tile keys and cache paths are unchanged.
- No cache/resource/schema version bump or migration is required.
## Diagnostics and observability
- Logs remain emitted by `StreamingWorldLoader` after applying a plan.
- `StreamingTargetPlan` exposes focus/wanted/retained data for deterministic tests.
- Dedicated verifier reports case count, iteration count, total time and average plan time.
- No correlation ID is required for synchronous ephemeral plans.
## Verification
- Unit/contract: `src/tools/verify_streaming_target_planner.gd` covers center
radii, edge/corner cross product, catalog filtering, clamps, editor behavior,
input/result ownership and scene-free dependencies.
- Integration: streamer source gate confirms both runtime/editor delegation and
removal of `_predictive_focus_tiles` from the monolith.
- Fidelity evidence: expected sets encode the pre-extraction formulas; renderer
checkpoint dry-run must retain all seven checkpoint plans.
- Performance budget: 250 synthetic High-like corner plans average at most
`4.0 ms` each on the headless regression runner; measured result is recorded
in test output.
- Manual diagnostics: unchanged `StreamingWorld` focus/wanted/queued log.
## Extension points
- Direction/velocity/frustum priority can extend policy/input only with fixtures
and a compatibility-safe default reproducing the current position-only plan.
- Multi-focus planning may union additional typed centers without giving the
planner ownership of queues or consumers.
- Queue ordering remains a separate scheduler/application responsibility.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Runtime wanted/retained sets | Implemented | Center/corner/catalog fixtures | Add paired traversal performance evidence |
| Boundary prefetch centers | Implemented | Edge/corner cross-product fixture | Direction/velocity prediction not implemented |
| Editor preview set | Implemented | Negative/radius-one fixtures | Editor viewport integration remains existing adapter |
| Scene-free deterministic execution | Implemented | Dependency/source gate and 250-iteration run | Worker execution is unnecessary today |
| Queue priority/LOD | Not in module | Preserved in streamer | Separate extraction packages |
## Known gaps and risks
- Tile-key sets preserve the streamer's internal string representation; a typed
catalog migration is deferred until it can remove, rather than duplicate, that source of truth.
- Planner time scales with the union of square radii/prefetch centers. The
current bounded regression covers High-like corner input but not pathological custom radii.
- Exact p95/p99 frame impact still requires renderer baseline runs with local assets.
- Direction, velocity and camera frustum do not influence target selection yet.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/streaming/streaming_target_planner.gd` | Pure runtime/editor set calculation and boundary centers |
| `src/render/streaming/streaming_target_policy.gd` | Immutable radius/threshold snapshot and effective clamps |
| `src/render/streaming/streaming_target_plan.gd` | Immutable typed focus and read-only result sets |
| `src/scenes/streaming/streaming_world_loader.gd` | Planner composition and plan application to queues/state |
| `src/tools/verify_streaming_target_planner.gd` | Asset-free behavior/dependency/performance regression |
## Related decisions and references
- [`world-renderer.md`](world-renderer.md)
- [`../../targets/03-renderer-facade.md`](../../targets/03-renderer-facade.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
- [`../GODOT_BEST_PRACTICES.md`](../GODOT_BEST_PRACTICES.md)
- [`../CODING_STANDARD.md`](../CODING_STANDARD.md)
+23 -6
View File
@@ -34,6 +34,9 @@ flowchart LR
Adapter --> Focus Adapter --> Focus
Focus --> Facade[WorldRenderFacade] Focus --> Facade[WorldRenderFacade]
Facade --> Loader[StreamingWorldLoader internal] Facade --> Loader[StreamingWorldLoader internal]
Loader --> Planner[StreamingTargetPlanner]
Planner --> TargetPlan[StreamingTargetPlan]
TargetPlan --> Loader
Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders] Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders]
Cache[Baked terrain/M2/WMO caches] --> Loader Cache[Baked terrain/M2/WMO caches] --> Loader
Native --> Parsed[Parsed tile/model data] Native --> Parsed[Parsed tile/model data]
@@ -94,7 +97,8 @@ loader configuration remains transitional composition data, not a caller API.
| Input | WDT/ADT/M2/WMO/BLP bytes | Extracted asset repository | Native loaders/builders | Loader result owns parsed data | Worker or controlled load | | Input | WDT/ADT/M2/WMO/BLP bytes | Extracted asset repository | Native loaders/builders | Loader result owns parsed data | Worker or controlled load |
| Input | Baked resources | Cache build tools | Streamer/builders | Shared immutable resource/cache entry | Session/cache lifetime | | Input | Baked resources | Cache build tools | Streamer/builders | Shared immutable resource/cache entry | Session/cache lifetime |
| Test input | Server-spawn render manifest | Coordinate fixture/verifier | Checkpoint capture tool | Repository-owned diagnostic data | Test-process lifetime | | Test input | Server-spawn render manifest | Coordinate fixture/verifier | Checkpoint capture tool | Repository-owned diagnostic data | Test-process lifetime |
| Output | Desired tile/detail operations | Streaming planner inside loader | Finalize queues | Loader-owned | Cross-frame | | Output | `StreamingTargetPlan` | `StreamingTargetPlanner` | Streamer queue/state apply | Immutable ephemeral value | One refresh |
| 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 | 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 | | Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand |
| Output | Logs/diagnostics | Hitch/stat instrumentation | Logs/baseline tools | Immutable records | Periodic/frame | | Output | Logs/diagnostics | Hitch/stat instrumentation | Logs/baseline tools | Immutable records | Periodic/frame |
@@ -121,8 +125,9 @@ flowchart TD
A --> F[StreamingFocus] A --> F[StreamingFocus]
EA --> F EA --> F
F --> RF[WorldRenderFacade] F --> RF[WorldRenderFacade]
RF --> T[StreamingWorldLoader target refresh] RF --> Planner[StreamingTargetPlanner]
WDT[WDT tile catalog] --> T Planner --> T[StreamingTargetPlan apply]
WDT[WDT tile catalog] --> Planner
T --> Q[Tile load queue] T --> Q[Tile load queue]
Q --> Parse[Worker parse/cache load] Q --> Parse[Worker parse/cache load]
Parse --> R[Result queues] Parse --> R[Result queues]
@@ -136,8 +141,8 @@ flowchart TD
WMO --> World WMO --> World
Liquid --> World Liquid --> World
Marker --> World Marker --> World
F --> E[Eviction/retention decisions] F --> Eviction[Eviction/retention decisions]
E --> World Eviction --> World
``` ```
## Lifecycle/state ## Lifecycle/state
@@ -166,13 +171,16 @@ sequenceDiagram
participant Focus as StreamingFocus adapter participant Focus as StreamingFocus adapter
participant Facade as WorldRenderFacade participant Facade as WorldRenderFacade
participant Stream as StreamingWorldLoader internal participant Stream as StreamingWorldLoader internal
participant Planner as StreamingTargetPlanner
participant Worker as Worker task participant Worker as Worker task
participant Budget as Main-thread budget participant Budget as Main-thread budget
participant Render as SceneTree/RenderingServer participant Render as SceneTree/RenderingServer
Source->>Focus: sample explicit Godot position Source->>Focus: sample explicit Godot position
Focus->>Facade: set/refresh typed focus Focus->>Facade: set/refresh typed focus
Facade->>Stream: delegate focus and refresh Facade->>Stream: delegate focus and refresh
Stream->>Stream: desired tiles and priorities Stream->>Planner: typed focus, catalog and target policy
Planner-->>Stream: immutable wanted/retained plan
Stream->>Stream: queue priority and loaded-tile LOD/detail state
Stream->>Worker: parse/load tile and detail data Stream->>Worker: parse/load tile and detail data
Worker-->>Stream: immutable result Worker-->>Stream: immutable result
Stream->>Budget: enqueue finalize operations Stream->>Budget: enqueue finalize operations
@@ -189,6 +197,8 @@ sequenceDiagram
- `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope. - `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope.
- `WorldRenderFacade` owns only scene-relative adapter paths. It does not own the - `WorldRenderFacade` owns only scene-relative adapter paths. It does not own the
streamer, focus source, returned metrics, queues, caches, nodes or RIDs. streamer, focus source, returned metrics, queues, caches, nodes or RIDs.
- `StreamingTargetPlanner` is stateless and owns only call-local calculations;
its immutable plan is consumed synchronously by the streamer.
- Focus producers own the immutable `StreamingFocus` reference; the loader retains - Focus producers own the immutable `StreamingFocus` reference; the loader retains
the latest reference and converts it to `Vector3` only inside the render boundary. the latest reference and converts it to `Vector3` only inside the render boundary.
- Worker tasks не должны менять SceneTree и shared Resource concurrently. - Worker tasks не должны менять SceneTree и shared Resource concurrently.
@@ -249,6 +259,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Verification ## Verification
- 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. - 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.
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00 - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00
cold/warm checkpoints and one dedicated mapped server-spawn checkpoint. cold/warm checkpoints and one dedicated mapped server-spawn checkpoint.
- Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe. - Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe.
@@ -275,6 +287,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Camera-independent streaming focus | Implemented | Typed contract, two player-focused runtime scenes and three explicitly camera-focused diagnostic tools | Multi-focus/direction/velocity belong to planner extraction | | Camera-independent streaming focus | Implemented | Typed contract, two player-focused runtime scenes and three explicitly camera-focused diagnostic tools | Multi-focus/direction/velocity belong to planner extraction |
| Golden server-spawn visualization | Implemented | Pinned AzerothCore fixture, validated renderer manifest and diagnostic origin-marker capture at ADT `(32,48)`/MCNK `(3,12)` | No TrinityCore row or original-client visual-parity claim | | Golden server-spawn visualization | Implemented | Pinned AzerothCore fixture, validated renderer manifest and diagnostic origin-marker capture at ADT `(32,48)`/MCNK `(3,12)` | No TrinityCore row or original-client visual-parity claim |
| Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain | | Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain |
| 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 |
## Known gaps and risks ## Known gaps and risks
@@ -301,10 +314,14 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Path | Responsibility | | Path | Responsibility |
|---|---| |---|---|
| `src/render/world_render_facade.gd` | Stable caller boundary for typed focus and detached renderer metrics | | `src/render/world_render_facade.gd` | Stable caller boundary for typed focus and detached renderer metrics |
| `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation |
| `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy |
| `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets |
| `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment | | `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment |
| `src/domain/streaming/streaming_focus.gd` | Immutable camera-independent streaming position contract | | `src/domain/streaming/streaming_focus.gd` | Immutable camera-independent streaming position contract |
| `src/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool wiring regression | | `src/tools/verify_streaming_focus.gd` | Headless contract, dependency and runtime/tool wiring regression |
| `src/tools/verify_world_render_facade.gd` | Facade delegation, snapshot isolation and consumer wiring regression | | `src/tools/verify_world_render_facade.gd` | Facade delegation, snapshot isolation and consumer wiring regression |
| `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression |
| `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction | | `addons/mpq_extractor/loaders/adt_builder.gd` | Terrain/material/liquid construction |
| `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction | | `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material construction |
| `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment | | `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment |
@@ -0,0 +1,37 @@
class_name StreamingTargetPlan
extends RefCounted
## Immutable result of one runtime or editor target-planning operation.
## Tile-key sets use the streamer's existing `<tile_x>_<tile_y>` representation.
var focus_tile: AdtTileCoordinate:
get:
return _focus_tile
var _focus_tile: AdtTileCoordinate
var _wanted_tile_keys: Dictionary
var _retained_tile_keys: Dictionary
## Takes ownership of planner-created sets and freezes them. The planner creates
## these dictionaries independently from its catalog input, so no copy is needed.
func _init(
focus_tile_value: AdtTileCoordinate,
wanted_tile_keys_value: Dictionary,
retained_tile_keys_value: Dictionary
) -> void:
_focus_tile = focus_tile_value
_wanted_tile_keys = wanted_tile_keys_value
_retained_tile_keys = retained_tile_keys_value
_wanted_tile_keys.make_read_only()
_retained_tile_keys.make_read_only()
## Returns the immutable wanted tile-key set.
func wanted_tile_keys() -> Dictionary:
return _wanted_tile_keys
## Returns the immutable retained tile-key set.
func retained_tile_keys() -> Dictionary:
return _retained_tile_keys
@@ -0,0 +1 @@
uid://fq1ax8tdju2q
@@ -0,0 +1,104 @@
class_name StreamingTargetPlanner
extends RefCounted
## Scene-free deterministic calculator for runtime/editor ADT target sets.
## It creates no tasks or renderer resources and never mutates its inputs.
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const TARGET_PLAN_SCRIPT := preload("res://src/render/streaming/streaming_target_plan.gd")
## Builds runtime wanted and retained sets, including the existing edge/corner
## prefetch centers. `available_tile_paths_by_key` is read only for membership.
func plan_runtime(
available_tile_paths_by_key: Dictionary,
focus_position: GodotWorldPosition,
policy: StreamingTargetPolicy
) -> StreamingTargetPlan:
var focus_tile = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(focus_position)
var prefetch_centers := _prefetch_centers(focus_position, focus_tile, policy)
var wanted_tile_keys := _available_keys_within_radius(
available_tile_paths_by_key,
prefetch_centers,
policy.load_tile_radius()
)
var retained_tile_keys := _available_keys_within_radius(
available_tile_paths_by_key,
prefetch_centers,
policy.retained_tile_radius()
)
return TARGET_PLAN_SCRIPT.new(focus_tile, wanted_tile_keys, retained_tile_keys)
## Builds the editor square preview set. Editor wanted and retained sets are
## identical and a negative radius is clamped to the focus tile.
func plan_editor(
available_tile_paths_by_key: Dictionary,
focus_position: GodotWorldPosition,
editor_preview_tile_radius: int
) -> StreamingTargetPlan:
var focus_tile = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(focus_position)
var centers: Array[AdtTileCoordinate] = [focus_tile]
var wanted_tile_keys := _available_keys_within_radius(
available_tile_paths_by_key,
centers,
maxi(0, editor_preview_tile_radius)
)
return TARGET_PLAN_SCRIPT.new(focus_tile, wanted_tile_keys, wanted_tile_keys)
func _prefetch_centers(
focus_position: GodotWorldPosition,
focus_tile: AdtTileCoordinate,
policy: StreamingTargetPolicy
) -> Array[AdtTileCoordinate]:
var centers: Array[AdtTileCoordinate] = [focus_tile]
var threshold := policy.clamped_boundary_prefetch_threshold()
if threshold <= 0.0:
return centers
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(focus_position)
var local_x_fraction: float = tile_local_position.east_yards / COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
var local_z_fraction: float = tile_local_position.south_yards / COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
var tile_x_offsets: Array[int] = [0]
var tile_y_offsets: Array[int] = [0]
if local_x_fraction <= threshold:
tile_x_offsets.append(-1)
elif local_x_fraction >= 1.0 - threshold:
tile_x_offsets.append(1)
if local_z_fraction <= threshold:
tile_y_offsets.append(-1)
elif local_z_fraction >= 1.0 - threshold:
tile_y_offsets.append(1)
for tile_x_offset in tile_x_offsets:
for tile_y_offset in tile_y_offsets:
if tile_x_offset == 0 and tile_y_offset == 0:
continue
centers.append(ADT_TILE_COORDINATE_SCRIPT.new(
focus_tile.tile_x + tile_x_offset,
focus_tile.tile_y + tile_y_offset
))
return centers
func _available_keys_within_radius(
available_tile_paths_by_key: Dictionary,
centers: Array[AdtTileCoordinate],
tile_radius: int
) -> Dictionary:
var available_tile_keys: Dictionary = {}
for center in centers:
for tile_y in range(center.tile_y - tile_radius, center.tile_y + tile_radius + 1):
for tile_x in range(center.tile_x - tile_radius, center.tile_x + tile_radius + 1):
var tile_key := _tile_key(tile_x, tile_y)
if available_tile_paths_by_key.has(tile_key):
available_tile_keys[tile_key] = true
return available_tile_keys
func _tile_key(tile_x: int, tile_y: int) -> String:
return "%d_%d" % [tile_x, tile_y]
@@ -0,0 +1 @@
uid://crqjbgoe3f6ea
@@ -0,0 +1,108 @@
class_name StreamingTargetPolicy
extends RefCounted
## Immutable runtime tile-radius and boundary-prefetch policy consumed by
## [StreamingTargetPlanner]. Values preserve scene configuration exactly;
## effective radii and thresholds apply the existing renderer clamps.
const CHUNKS_PER_ADT_TILE := 16
var lod2_radius_chunks: int:
get:
return _lod2_radius_chunks
var lod2_tile_radius: int:
get:
return _lod2_tile_radius
var warm_tile_margin: int:
get:
return _warm_tile_margin
var prewarm_tile_margin: int:
get:
return _prewarm_tile_margin
var retain_tile_margin: int:
get:
return _retain_tile_margin
var boundary_prefetch_threshold: float:
get:
return _boundary_prefetch_threshold
var _lod2_radius_chunks: int
var _lod2_tile_radius: int
var _warm_tile_margin: int
var _prewarm_tile_margin: int
var _retain_tile_margin: int
var _boundary_prefetch_threshold: float
## Captures one renderer configuration snapshot for a deterministic plan.
func _init(
lod2_radius_chunks_value: int,
lod2_tile_radius_value: int,
warm_tile_margin_value: int,
prewarm_tile_margin_value: int,
retain_tile_margin_value: int,
boundary_prefetch_threshold_value: float
) -> void:
_lod2_radius_chunks = lod2_radius_chunks_value
_lod2_tile_radius = lod2_tile_radius_value
_warm_tile_margin = warm_tile_margin_value
_prewarm_tile_margin = prewarm_tile_margin_value
_retain_tile_margin = retain_tile_margin_value
_boundary_prefetch_threshold = boundary_prefetch_threshold_value
## Returns the current visible ADT radius derived from chunk and tile settings.
func visible_tile_radius() -> int:
return calculate_visible_tile_radius(_lod2_radius_chunks, _lod2_tile_radius, _warm_tile_margin)
## Returns the visible radius plus the non-negative prewarm margin.
func load_tile_radius() -> int:
return calculate_load_tile_radius(
_lod2_radius_chunks,
_lod2_tile_radius,
_warm_tile_margin,
_prewarm_tile_margin
)
## Returns the load radius plus the non-negative hysteresis margin.
func retained_tile_radius() -> int:
return load_tile_radius() + maxi(0, _retain_tile_margin)
## Returns the edge fraction accepted by the existing prefetch policy.
func clamped_boundary_prefetch_threshold() -> float:
return clampf(_boundary_prefetch_threshold, 0.0, 0.49)
## Calculates the visible radius without allocating a policy. The streamer uses
## this hot-path form while finalizing tiles between planning refreshes.
static func calculate_visible_tile_radius(
lod2_radius_chunks_value: int,
lod2_tile_radius_value: int,
warm_tile_margin_value: int
) -> int:
var chunk_tile_radius := int(ceil(
float(lod2_radius_chunks_value) / float(CHUNKS_PER_ADT_TILE)
)) + warm_tile_margin_value
return maxi(chunk_tile_radius, lod2_tile_radius_value)
## Calculates the load radius without allocating a policy.
static func calculate_load_tile_radius(
lod2_radius_chunks_value: int,
lod2_tile_radius_value: int,
warm_tile_margin_value: int,
prewarm_tile_margin_value: int
) -> int:
return calculate_visible_tile_radius(
lod2_radius_chunks_value,
lod2_tile_radius_value,
warm_tile_margin_value
) + maxi(0, prewarm_tile_margin_value)
@@ -0,0 +1 @@
uid://cfrad5wr668w5
+42 -75
View File
@@ -17,6 +17,8 @@ const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd") const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd") const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd") const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.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 REQUIRED_BAKED_TILE_FORMAT_VERSION := 5 const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
@@ -174,6 +176,7 @@ const QUALITY_HIGH := "High"
@export var hitch_profiler_threshold_ms: float = 20.0 @export var hitch_profiler_threshold_ms: float = 20.0
var _builder var _builder
var _streaming_target_planner := STREAMING_TARGET_PLANNER_SCRIPT.new()
var _map_dir := "" var _map_dir := ""
var _abs_map_dir := "" var _abs_map_dir := ""
var _wdt_info: Dictionary = {} var _wdt_info: Dictionary = {}
@@ -720,57 +723,47 @@ func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> vo
if not force and not _should_refresh_focus(focus_pos): if not force and not _should_refresh_focus(focus_pos):
return return
var focus_tile := _world_to_tile(focus_pos) var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
var total_tile_radius: int = maxi(0, editor_preview_tile_radius) var target_plan = _streaming_target_planner.plan_editor(
_available_tiles,
typed_focus_position,
editor_preview_tile_radius
)
var wanted_tiles: Dictionary = target_plan.wanted_tile_keys()
var wanted_tiles: Dictionary = {} _apply_streaming_target(wanted_tiles, target_plan.retained_tile_keys(), focus_pos)
for ty in range(focus_tile.y - total_tile_radius, focus_tile.y + total_tile_radius + 1):
for tx in range(focus_tile.x - total_tile_radius, focus_tile.x + total_tile_radius + 1):
var key := _tile_key(tx, ty)
if _available_tiles.has(key):
wanted_tiles[key] = true
_apply_streaming_target(wanted_tiles, wanted_tiles, focus_pos)
_note_refresh_focus(focus_pos) _note_refresh_focus(focus_pos)
if force or debug_streaming: if force or debug_streaming:
print("StreamingWorld(editor): focus_tile=%s wanted=%d queued=%d" % [ print("StreamingWorld(editor): focus_tile=%s wanted=%d queued=%d" % [
focus_tile, wanted_tiles.size(), _tile_load_queue.size()]) Vector2i(target_plan.focus_tile.tile_x, target_plan.focus_tile.tile_y),
wanted_tiles.size(),
_tile_load_queue.size(),
])
func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void: func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
if not force and not _should_refresh_focus(focus_pos): if not force and not _should_refresh_focus(focus_pos):
return return
var focus_tile := _world_to_tile(focus_pos) var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
var target_plan = _streaming_target_planner.plan_runtime(
# Chunk-LOD radius → tile radius (ceiling), plus margin for pre-warming. _available_tiles,
var focus_tiles := _predictive_focus_tiles(focus_pos) typed_focus_position,
var total_tile_radius: int = _runtime_load_tile_radius() _streaming_target_policy()
var retain_tile_radius: int = _runtime_retain_tile_radius() )
var wanted_tiles: Dictionary = target_plan.wanted_tile_keys()
var retained_tiles: Dictionary = {} var retained_tiles: Dictionary = target_plan.retained_tile_keys()
for center in focus_tiles:
for ty in range(center.y - retain_tile_radius, center.y + retain_tile_radius + 1):
for tx in range(center.x - retain_tile_radius, center.x + retain_tile_radius + 1):
var retained_key := _tile_key(tx, ty)
if _available_tiles.has(retained_key):
retained_tiles[retained_key] = true
var wanted_tiles: Dictionary = {}
for center in focus_tiles:
for ty in range(center.y - total_tile_radius, center.y + total_tile_radius + 1):
for tx in range(center.x - total_tile_radius, center.x + total_tile_radius + 1):
var key := _tile_key(tx, ty)
if _available_tiles.has(key):
wanted_tiles[key] = true
_apply_streaming_target(wanted_tiles, retained_tiles, focus_pos) _apply_streaming_target(wanted_tiles, retained_tiles, focus_pos)
_note_refresh_focus(focus_pos) _note_refresh_focus(focus_pos)
if force or debug_streaming: if force or debug_streaming:
print("StreamingWorld: focus_tile=%s wanted=%d queued=%d" % [ print("StreamingWorld: focus_tile=%s wanted=%d queued=%d" % [
focus_tile, wanted_tiles.size(), _tile_load_queue.size()]) Vector2i(target_plan.focus_tile.tile_x, target_plan.focus_tile.tile_y),
wanted_tiles.size(),
_tile_load_queue.size(),
])
@@ -2891,50 +2884,24 @@ func _compute_desired_lods(state: Dictionary, cam_pos: Vector3) -> Dictionary:
return desired return desired
func _runtime_visible_tile_radius() -> int:
var chunk_tile_radius: int = int(ceil(float(lod2_radius_chunks) / 16.0)) + warm_tile_margin
return maxi(chunk_tile_radius, lod2_tile_radius)
func _runtime_load_tile_radius() -> int: func _runtime_load_tile_radius() -> int:
return _runtime_visible_tile_radius() + maxi(0, prewarm_tile_margin) return STREAMING_TARGET_POLICY_SCRIPT.calculate_load_tile_radius(
lod2_radius_chunks,
lod2_tile_radius,
warm_tile_margin,
prewarm_tile_margin
)
func _runtime_retain_tile_radius() -> int: func _streaming_target_policy() -> StreamingTargetPolicy:
return _runtime_load_tile_radius() + maxi(0, retain_tile_margin) return STREAMING_TARGET_POLICY_SCRIPT.new(
lod2_radius_chunks,
lod2_tile_radius,
func _predictive_focus_tiles(focus_pos: Vector3) -> Array[Vector2i]: warm_tile_margin,
var base := _world_to_tile(focus_pos) prewarm_tile_margin,
var result: Array[Vector2i] = [base] retain_tile_margin,
var threshold: float = clampf(boundary_prefetch_threshold, 0.0, 0.49) boundary_prefetch_threshold
if threshold <= 0.0: )
return result
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_focus_position)
var local_x: float = tile_local_position.east_yards / TILE_SIZE
var local_z: float = tile_local_position.south_yards / TILE_SIZE
var x_offsets: Array[int] = [0]
var z_offsets: Array[int] = [0]
if local_x <= threshold:
x_offsets.append(-1)
elif local_x >= 1.0 - threshold:
x_offsets.append(1)
if local_z <= threshold:
z_offsets.append(-1)
elif local_z >= 1.0 - threshold:
z_offsets.append(1)
for ox in x_offsets:
for oz in z_offsets:
if ox == 0 and oz == 0:
continue
result.append(Vector2i(base.x + ox, base.y + oz))
return result
func _compute_desired_tile_lod(state: Dictionary, cam_pos: Vector3) -> int: func _compute_desired_tile_lod(state: Dictionary, cam_pos: Vector3) -> int:
@@ -19,7 +19,8 @@ const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
] ]
const REQUIRED_MAPPER_CALLS := { const REQUIRED_MAPPER_CALLS := {
"res://src/scenes/sky/wow_sky_controller.gd": ["godot_to_canonical", "godot_to_adt_tile", "godot_to_adt_tile_local"], "res://src/scenes/sky/wow_sky_controller.gd": ["godot_to_canonical", "godot_to_adt_tile", "godot_to_adt_tile_local"],
"res://src/scenes/streaming/streaming_world_loader.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_godot"], "res://src/scenes/streaming/streaming_world_loader.gd": ["godot_to_adt_tile", "adt_tile_local_to_godot"],
"res://src/render/streaming/streaming_target_planner.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local"],
"res://src/scenes/player/third_person_wow_controller.gd": ["adt_tile_local_to_godot"], "res://src/scenes/player/third_person_wow_controller.gd": ["adt_tile_local_to_godot"],
"res://src/gameplay/terrain/adt_terrain_query.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_chunk"], "res://src/gameplay/terrain/adt_terrain_query.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_chunk"],
"res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"], "res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"],
@@ -60,7 +61,7 @@ func _initialize() -> void:
quit(1) quit(1)
return return
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=5 classifier_cases=6" % source_paths.size()) print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=6 classifier_cases=6" % source_paths.size())
quit(0) quit(0)
@@ -0,0 +1,211 @@
extends SceneTree
## Asset-free M03 regression for pure runtime/editor ADT target planning.
const PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd")
const POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const PLANNER_PATH := "res://src/render/streaming/streaming_target_planner.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const PERFORMANCE_ITERATIONS := 250
const MAXIMUM_AVERAGE_PLAN_MILLISECONDS := 4.0
func _initialize() -> void:
var failures: Array[String] = []
var planner = PLANNER_SCRIPT.new()
_verify_center_radii_and_isolation(planner, failures)
_verify_corner_prefetch_and_catalog_filter(planner, failures)
_verify_policy_clamps(planner, failures)
_verify_editor_plan(planner, failures)
_verify_scene_free_extraction(failures)
var elapsed_milliseconds := _measure_planner_iterations(planner, failures)
if not failures.is_empty():
for failure in failures:
push_error("STREAMING_TARGET_PLANNER: %s" % failure)
quit(1)
return
print("STREAMING_TARGET_PLANNER PASS cases=5 iterations=%d elapsed_ms=%.3f average_ms=%.3f" % [
PERFORMANCE_ITERATIONS,
elapsed_milliseconds,
elapsed_milliseconds / float(PERFORMANCE_ITERATIONS),
])
quit(0)
func _verify_center_radii_and_isolation(planner, failures: Array[String]) -> void:
var available_tiles := _tile_catalog(28, 36, 44, 52)
var focus_position = _tile_position(32, 48, 0.5, 0.5)
var policy = POLICY_SCRIPT.new(0, 1, 0, 1, 1, 0.35)
var plan = planner.plan_runtime(available_tiles, focus_position, policy)
var wanted_tile_keys: Dictionary = plan.wanted_tile_keys()
var retained_tile_keys: Dictionary = plan.retained_tile_keys()
_expect_equal(plan.focus_tile.tile_x, 32, "center focus tile X", failures)
_expect_equal(plan.focus_tile.tile_y, 48, "center focus tile Y", failures)
_expect_equal(wanted_tile_keys.size(), 25, "center wanted 5x5", failures)
_expect_equal(retained_tile_keys.size(), 49, "center retained 7x7", failures)
_expect_true(wanted_tile_keys.has("30_46"), "wanted includes north-west radius edge", failures)
_expect_true(wanted_tile_keys.has("34_50"), "wanted includes south-east radius edge", failures)
_expect_true(retained_tile_keys.has("29_45"), "retained includes hysteresis edge", failures)
_expect_true(wanted_tile_keys.is_read_only(), "wanted result is immutable", failures)
_expect_true(retained_tile_keys.is_read_only(), "retained result is immutable", failures)
_expect_equal(available_tiles.size(), 81, "available catalog remains unchanged", failures)
func _verify_corner_prefetch_and_catalog_filter(planner, failures: Array[String]) -> void:
var available_tiles := {
"32_48": "tile-a",
"33_48": "tile-b",
"32_49": "tile-c",
"33_49": "tile-d",
"31_48": "outside-prefetch",
}
var focus_position = _tile_position(32, 48, 0.99, 0.99)
var policy = POLICY_SCRIPT.new(0, 0, 0, 0, 0, 0.35)
var plan = planner.plan_runtime(available_tiles, focus_position, policy)
_expect_set_equal(
plan.wanted_tile_keys(),
["32_48", "33_48", "32_49", "33_49"],
"corner prefetch cross product",
failures
)
_expect_set_equal(
plan.retained_tile_keys(),
["32_48", "33_48", "32_49", "33_49"],
"corner retained cross product",
failures
)
func _verify_policy_clamps(planner, failures: Array[String]) -> void:
var policy = POLICY_SCRIPT.new(16, 2, -5, -3, -4, 0.9)
_expect_equal(policy.visible_tile_radius(), 2, "visible radius keeps tile minimum", failures)
_expect_equal(policy.load_tile_radius(), 2, "negative prewarm clamps to zero", failures)
_expect_equal(policy.retained_tile_radius(), 2, "negative retain clamps to zero", failures)
_expect_near(policy.clamped_boundary_prefetch_threshold(), 0.49, "threshold upper clamp", failures)
var plan = planner.plan_runtime(
_tile_catalog(30, 34, 46, 50),
_tile_position(32, 48, 0.5, 0.5),
policy
)
_expect_equal(plan.wanted_tile_keys().size(), 25, "clamped policy wanted radius", failures)
_expect_equal(plan.retained_tile_keys().size(), 25, "clamped policy retained radius", failures)
func _verify_editor_plan(planner, failures: Array[String]) -> void:
var available_tiles := _tile_catalog(31, 33, 47, 49)
var focus_position = _tile_position(32, 48, 0.5, 0.5)
var negative_radius_plan = planner.plan_editor(available_tiles, focus_position, -4)
_expect_set_equal(
negative_radius_plan.wanted_tile_keys(),
["32_48"],
"editor negative radius clamps to focus",
failures
)
_expect_set_equal(
negative_radius_plan.retained_tile_keys(),
["32_48"],
"editor retained matches wanted",
failures
)
var radius_one_plan = planner.plan_editor(available_tiles, focus_position, 1)
_expect_equal(radius_one_plan.wanted_tile_keys().size(), 9, "editor radius one 3x3", failures)
_expect_set_equal(
radius_one_plan.retained_tile_keys(),
radius_one_plan.wanted_tile_keys().keys(),
"editor radius retained matches wanted",
failures
)
func _verify_scene_free_extraction(failures: Array[String]) -> void:
var planner_source := _read_text(PLANNER_PATH, failures)
_expect_true(planner_source.contains("extends RefCounted"), "planner is RefCounted", failures)
_expect_true(not planner_source.contains("extends Node"), "planner does not inherit Node", failures)
_expect_true(not planner_source.contains("Vector3"), "planner keeps typed position boundary", failures)
for forbidden_text in ["WorkerThreadPool", "RenderingServer", "ResourceLoader", "_tile_load_queue"]:
_expect_true(not planner_source.contains(forbidden_text), "planner omits %s" % forbidden_text, failures)
var loader_source := _read_text(LOADER_PATH, failures)
_expect_true(loader_source.contains("_streaming_target_planner.plan_runtime"), "loader delegates runtime planning", failures)
_expect_true(loader_source.contains("_streaming_target_planner.plan_editor"), "loader delegates editor planning", failures)
_expect_true(not loader_source.contains("func _predictive_focus_tiles"), "loader no longer owns prefetch planner", failures)
func _measure_planner_iterations(planner, failures: Array[String]) -> float:
var available_tiles := _tile_catalog(24, 40, 40, 56)
var focus_position = _tile_position(32, 48, 0.99, 0.99)
var policy = POLICY_SCRIPT.new(40, 5, 1, 3, 4, 0.42)
var started_microseconds := Time.get_ticks_usec()
for _iteration in PERFORMANCE_ITERATIONS:
planner.plan_runtime(available_tiles, focus_position, policy)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
var average_plan_milliseconds := elapsed_milliseconds / float(PERFORMANCE_ITERATIONS)
_expect_true(
average_plan_milliseconds <= MAXIMUM_AVERAGE_PLAN_MILLISECONDS,
"average plan stays within %.1f ms (actual %.3f)" % [
MAXIMUM_AVERAGE_PLAN_MILLISECONDS,
average_plan_milliseconds,
],
failures
)
return elapsed_milliseconds
func _tile_catalog(minimum_tile_x: int, maximum_tile_x: int, minimum_tile_y: int, maximum_tile_y: int) -> Dictionary:
var available_tiles: Dictionary = {}
for tile_y in range(minimum_tile_y, maximum_tile_y + 1):
for tile_x in range(minimum_tile_x, maximum_tile_x + 1):
available_tiles["%d_%d" % [tile_x, tile_y]] = "synthetic"
return available_tiles
func _tile_position(tile_x: int, tile_y: int, east_fraction: float, south_fraction: float) -> GodotWorldPosition:
var tile_coordinate = ADT_TILE_COORDINATE_SCRIPT.new(tile_x, tile_y)
var tile_local_position = ADT_TILE_LOCAL_POSITION_SCRIPT.new(
east_fraction * COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS,
0.0,
south_fraction * COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
)
return COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(tile_coordinate, tile_local_position)
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_set_equal(actual_set: Dictionary, expected_keys: Array, label: String, failures: Array[String]) -> void:
var actual_keys := actual_set.keys()
actual_keys.sort()
var sorted_expected_keys := expected_keys.duplicate()
sorted_expected_keys.sort()
if actual_keys != sorted_expected_keys:
failures.append("%s expected %s, got %s" % [label, sorted_expected_keys, actual_keys])
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_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://b5unt23vi8krk