From b697a896e81aaab102fa0405394bc14f04c14c43 Mon Sep 17 00:00:00 2001 From: sindoring Date: Thu, 16 Jul 2026 00:49:28 +0400 Subject: [PATCH] rnd(M03): add rendered ground query facade --- RENDER.md | 20 ++- .../claims/M03-RND-FACADE-GROUND-QUERY-001.md | 2 +- docs/modules/terrain-query.md | 14 +- docs/modules/world-renderer.md | 45 +++-- src/render/terrain/rendered_ground_sample.gd | 50 ++++++ .../terrain/rendered_ground_sample.gd.uid | 1 + src/render/world_render_facade.gd | 30 ++++ .../streaming/streaming_world_loader.gd | 169 ++++++++++++++++++ src/tools/probe_render_terrain_height.gd | 143 +-------------- ...verify_coordinate_conversion_boundaries.gd | 3 +- src/tools/verify_renderer_internal_access.gd | 8 +- src/tools/verify_world_render_facade.gd | 89 ++++++++- 12 files changed, 416 insertions(+), 158 deletions(-) create mode 100644 src/render/terrain/rendered_ground_sample.gd create mode 100644 src/render/terrain/rendered_ground_sample.gd.uid diff --git a/RENDER.md b/RENDER.md index a8be31a..e30142a 100644 --- a/RENDER.md +++ b/RENDER.md @@ -929,11 +929,25 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' matches those ownership categories. - Scene composition may still reference the loader script and the facade's implementation path; those are composition details, not mutable queue access. -- Renderer diagnostic probes are outside this gameplay/editor checklist. - `probe_render_terrain_height.gd` remains a documented temporary exception until - readiness and ground-query facade contracts replace its internal inspection. +- Renderer diagnostic probes are added explicitly as their facade contracts land. - This is a source-only boundary check and changes no renderer behavior or fidelity. +## 2026-07-16 Rendered Ground Query Facade + +- `WorldRenderFacade.sample_ground_height()` accepts `GodotWorldPosition` and + returns renderer-owned immutable `RenderedGroundSample` from already loaded + quality/tile-LOD render meshes, avoiding a render-to-gameplay dependency. +- `renderer_ground_query_snapshot()` preserves the terrain probe's tile readiness, + queue position, mesh bounds and nearby seam-fallback diagnostics as a detached + Dictionary; callers receive no queue, tile-state, Mesh or Node reference. +- `probe_render_terrain_height.gd` now uses this facade snapshot and no longer reads + `_tile_states`, `_tile_load_queue`, `_tile_loading_tasks` or `_available_tiles`. +- Gameplay continues using the independently composed M02 `TerrainQuery`/ + `AdtTerrainQuery`. Loaded render meshes are diagnostic and are not authoritative + movement collision. +- 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. + ## 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. diff --git a/coordination/claims/M03-RND-FACADE-GROUND-QUERY-001.md b/coordination/claims/M03-RND-FACADE-GROUND-QUERY-001.md index 26d3607..b0dffa7 100644 --- a/coordination/claims/M03-RND-FACADE-GROUND-QUERY-001.md +++ b/coordination/claims/M03-RND-FACADE-GROUND-QUERY-001.md @@ -40,7 +40,7 @@ terrain-height probe away from direct streamer state/queue access. ## Contracts and data - Input: immutable `GodotWorldPosition` in Godot world units -- Output: immutable `TerrainGroundSample` plus optional detached diagnostic Dictionary +- Output: renderer-owned immutable `RenderedGroundSample` plus optional detached diagnostic Dictionary - Sampling source: loaded quality or tile-LOD Mesh and its live tile transform - M02 ADT query cache, renderer cache versions and coordinate contracts: unchanged diff --git a/docs/modules/terrain-query.md b/docs/modules/terrain-query.md index e53485a..29fcdb7 100644 --- a/docs/modules/terrain-query.md +++ b/docs/modules/terrain-query.md @@ -5,10 +5,10 @@ | Field | Value | |---|---| | Status | Implemented | -| Target/work package | M02 / M02-GMP-TERRAIN-QUERY-001 | +| Target/work package | M02 / M02-GMP-TERRAIN-QUERY-001; M03 renderer consumer `M03-RND-FACADE-GROUND-QUERY-001` | | Owners | Gameplay terrain-query contract; ADT adapter owns parsed tile cache | | Last verified | `a45d521`, 2026-07-14 | -| Profiles/capabilities | Current sandbox ADT ground height; renderer/physics backend planned | +| Profiles/capabilities | Current sandbox ADT ground height; loaded-render-mesh diagnostic query; authoritative physics backend planned | ## Purpose @@ -47,6 +47,8 @@ Allowed dependencies: - Existing native `ADTLoader` at the ADT adapter boundary. - Dynamic dictionaries only inside the native-format adapter. - Player scene composition may inject any `TerrainQuery` implementation. +- Renderer diagnostics reuse `GodotWorldPosition` through `WorldRenderFacade` + but return renderer-owned `RenderedGroundSample`, preserving the render/gameplay boundary. Forbidden dependencies: @@ -65,6 +67,7 @@ Forbidden dependencies: | `AdtTerrainQuery.new(extracted_directory, map_directory_name, loader_override)` | Adapter constructor | Configures one map and optional test/data-source loader | Query lifetime | Production validates native loader and file presence lazily | | `AdtTerrainQuery.sample_ground_height(position)` | Adapter method | Loads/caches tile and interpolates ADT outer heights | Main thread; cache lives with query | Returns stable failure code | | `ThirdPersonWowController.set_terrain_query(query)` | Composition method | Replaces terrain backend independently of movement/presentation | Call before scene ready or on main thread | Null is rejected and current query retained | +| `WorldRenderFacade.sample_ground_height(position)` | Separate renderer diagnostic API | Samples already loaded terrain Mesh and returns `RenderedGroundSample` without exposing streamer state | Main thread; immutable sample | Stable `render_terrain_*` unavailable code; not injected into player by default | ## Inputs and outputs @@ -206,6 +209,9 @@ Existing ADT/native/cache format versions remain unchanged. ## Extension points - Streaming renderer or physics backend may implement `TerrainQuery` without changing player/movement. +- The current facade intentionally uses a separate renderer-owned result and is + not a `TerrainQuery` implementation. A future adapter must explicitly translate + readiness/results and define gameplay authority before composition. - Future result may grow separate slope, surface, liquid or collision contracts; incompatible additions require a versioned contract rather than Dictionary fields. - Test/authoring data sources may use constructor loader override. @@ -218,7 +224,8 @@ Existing ADT/native/cache format versions remain unchanged. | Typed replaceable ground-height query | Implemented | Contract and injected player regression | Integrate target checklist after review | | Existing ADT height interpolation | Implemented | Synthetic fractional-grid fixture | Compare terrain contact with build 12340 | | Per-tile success/failure cache | Implemented | Loader call-count regression | Add explicit invalidation only with authoring/hot reload | -| Renderer/streaming cache backend | Planned | Boundary permits replacement | M03 renderer facade/public terrain backend | +| Renderer loaded-mesh diagnostic backend | Implemented | M03 facade typed sample and detached snapshot contract | Not composed into gameplay; triangle Mesh ray is diagnostic only | +| Authoritative renderer/physics backend | Planned | Boundary permits replacement | Define holes/slopes/collision/readiness semantics before gameplay composition | | Holes/slopes/collision | Planned | Outside height-only contract | Later movement/physics package | | Liquid/swim query | Planned | Outside contract | M09/M12 world gameplay | @@ -240,6 +247,7 @@ Existing ADT/native/cache format versions remain unchanged. | `src/gameplay/terrain/terrain_query.gd` | Replaceable typed query boundary | | `src/gameplay/terrain/adt_terrain_query.gd` | Native ADT adapter, cache, chunk selection and interpolation | | `src/scenes/player/third_person_wow_controller.gd` | Query composition and existing ground-snap consumer | +| `src/render/world_render_facade.gd` | Read-only loaded-render-mesh diagnostic consumer of typed position/sample contracts | | `src/tools/verify_terrain_query.gd` | Synthetic contract/cache/failure/injection regression | | `src/tests/scenes/player_input_regression.tscn` | Asset-free player composition fixture | diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index a95cc57..88c13d7 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -5,7 +5,7 @@ | Field | Value | |---|---| | Status | Partial | -| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-RND-FACADE-001`; `M03-RND-STREAMING-PLANNER-001`; `M03-RND-SCHEDULER-001` | +| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-RND-FACADE-001`; `M03-RND-STREAMING-PLANNER-001`; `M03-RND-SCHEDULER-001`; `M03-RND-INTERNAL-ACCESS-GATE-001`; `M03-RND-FACADE-GROUND-QUERY-001` | | Owners | Renderer workstream / milestone integrator | | Last verified | Worktree `work/sindo-main-codex/m03-render-budget-scheduler`, 2026-07-16 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | @@ -65,9 +65,9 @@ Forbidden dependencies: ## Public API -Runtime, capture and probe callers use `WorldRenderFacade` for focus and metrics. -`StreamingWorldLoader` remains the internal scene implementation while later M03 -packages add environment, entity-visual and ground-query contracts. +Runtime, capture and probe callers use `WorldRenderFacade` for focus, metrics and +rendered-ground diagnostics. `StreamingWorldLoader` remains the internal scene +implementation while later M03 packages add environment and entity-visual contracts. Gameplay modules and Godot `EditorPlugin` package sources are repository-gated from externally reading/writing loader-private queue, task, cache and tile-state fields. @@ -80,6 +80,8 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `WorldRenderFacade.set_streaming_focus` | Public method | Accepts focus from app/editor/tool composition | Main thread/session | Missing internal renderer is diagnosed; no state is duplicated | | `WorldRenderFacade.refresh_streaming_focus` | Public method | Samples configured source and delegates existing refresh thresholds | Main thread/session | Returns `false` when dependency/focus is unavailable | | `WorldRenderFacade.renderer_metrics_snapshot` | Public method | Returns a deep-detached queue/cache/activity snapshot | Main thread/caller-owned result | Missing/invalid implementation result returns empty snapshot with diagnostic | +| `WorldRenderFacade.sample_ground_height` | Public read-only query | Samples already loaded render terrain at a typed Godot world position | Main thread; immutable caller-held result | Stable unavailable code for missing mesh/intersection/facade; not gameplay authority | +| `WorldRenderFacade.renderer_ground_query_snapshot` | Public diagnostic query | Returns ground height plus detached tile/readiness context without exposing mutable state | Main thread; caller-owned deep copy | Missing/invalid renderer returns empty snapshot with diagnostic | | `WorldRenderFacade.streaming_world_loader_path` | Internal adapter property | Resolves the existing implementation without exposing it to consumers | Main thread, scene lifetime | Missing method/path produces one diagnostic until recovery | | `camera_path` | Exported property | Camera used only by optional automatic overview positioning | Main thread, scene lifetime | Missing camera skips automatic positioning | | `quality_preset` | Exported property | Applies renderer budgets/radii | Before/at runtime depending property | Invalid combination should be diagnosed | @@ -96,6 +98,7 @@ loader configuration remains transitional composition data, not a caller API. |---|---|---|---|---|---| | Input | `StreamingFocus` | Player/spectator/editor/capture adapter | `WorldRenderFacade` | Immutable caller value | Main thread/session | | Input | Focus-source `NodePath` | Runtime/test scene composition | `WorldRenderFacade` source adapter | Scene-owned node, sampled position | Main thread/scene lifetime | +| Input | Ground-query `GodotWorldPosition` | Renderer probe/future adapter | `WorldRenderFacade` | Immutable caller-held value | One main-thread query | | Input | Map/configuration | Scene/app/editor | `StreamingWorldLoader` | Caller config, copied/read | Main thread/session | | 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 | @@ -105,6 +108,8 @@ loader configuration remains transitional composition data, not a caller API. | 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 | +| Output | `RenderedGroundSample` | `WorldRenderFacade`/loaded terrain | Renderer diagnostics | Immutable caller-held value | One query | +| Output | Detached rendered-ground snapshot | `WorldRenderFacade` | Terrain-height probe | Caller-owned deep copy | One query | | Output | Logs/diagnostics | Hitch/stat instrumentation | Logs/baseline tools | Immutable records | Periodic/frame | | Test output | Diagnostic spawn marker and cold/warm PNGs | Checkpoint capture tool | Human/integrator review | SceneTree marker; `user://` files | One capture process | @@ -140,6 +145,8 @@ flowchart TD B --> M2[M2 group/MultiMesh attach] B --> WMO[WMO instance/group attach] B --> Liquid[Liquid attach] + Loaded[Loaded terrain Mesh and Transform] --> Ground[Facade ground query] + Ground --> GroundSample[RenderedGroundSample / detached diagnostics] Terrain --> World[Godot world] M2 --> World WMO --> World @@ -192,6 +199,9 @@ sequenceDiagram Budget-->>Stream: true while lane remains bounded Stream->>Render: attach permitted terrain/M2/WMO/liquid work Stream->>Render: evict outside retention range + Source->>Facade: sample_ground_height(typed position) + Facade->>Stream: sample loaded render terrain + Stream-->>Facade: RenderedGroundSample / diagnostic snapshot Stream->>Budget: shutdown: cancel permit issuance Stream->>Worker: shutdown: wait for WorkerThreadPool tasks Stream->>Stream: shutdown: finish registered ResourceLoader requests @@ -204,6 +214,10 @@ sequenceDiagram - `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 streamer, focus source, returned metrics, queues, caches, nodes or RIDs. +- 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 + collision. M02 `TerrainQuery` composition and ADT cache ownership remain unchanged. - `StreamingTargetPlanner` is stateless and owns only call-local calculations; its immutable plan is consumed synchronously by the streamer. - `RenderBudgetScheduler` owns only per-frame lane counters and a terminal @@ -230,6 +244,7 @@ sequenceDiagram | Cache version mismatch | Required format constant | Reject stale cache/fallback | Version diagnostic | Rebuild matching cache | | Worker failure | Task result/empty data | Skip/degrade affected asset | Queue/task warning | Retry/rebuild source | | Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path | +| Render terrain unavailable | Facade ground query | Immutable unavailable sample | Stable `render_terrain_*` failure code and optional snapshot | Wait for streaming or use gameplay-owned query backend | | D3D12 descriptor exhaustion | Rendering backend error | Render failure/fallback backend | Godot error + baseline report | Dedup resources/fix settings | | Teleport/map change | Focus/session transition | Old jobs become stale | Target/session generation | Cancel/drop stale results | | Shutdown leak | Godot leak/RID diagnostics | Resource retained | Verbose shutdown report + cache shutdown verifier | Drain requests, preserve tree ownership, then free detached prototypes and clear resource caches | @@ -276,6 +291,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - Internal-access contract: derives the current streamer's private queue/task/ cache/state field inventory and rejects external member/reflection access from gameplay or EditorPlugin package code. +- Ground-query facade contract: available/unavailable typed result delegation, + detached nested diagnostics and terrain-probe source migration without private access. - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes, seven M00 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. @@ -301,10 +318,11 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | Character presentation | Prototype | Character creator/player experiment | Full customization/equipment states | | 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 | -| 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 | Facade contracts cover focus, metrics and loaded-mesh ground query | Environment and entity visuals 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 | | 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 internal-access boundary | Implemented | `M03-RND-INTERNAL-ACCESS-GATE-001` scans actual private streamer declarations against gameplay and EditorPlugin sources | Renderer diagnostic probes remain a scoped exception pending ground-query/readiness facade contracts | +| 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 | +| Rendered terrain ground query | Implemented | `M03-RND-FACADE-GROUND-QUERY-001` typed result/snapshot isolation and probe migration | Diagnostic Mesh ray only; not movement/collision authority | ## Known gaps and risks @@ -325,15 +343,16 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - D3D12 descriptor issues remain. Explicit shutdown ownership removes the GUI capture's Node/resource/RID leaks in focused and seven-checkpoint runs; a timing-dependent ObjectDB warning for anonymous zero-reference `RefCounted` objects remains as a separate engine-teardown diagnostic. - M2/WMO/material/particle/ribbon/portal parity incomplete. - Public API is mostly exported configuration rather than stable contracts. -- `probe_render_terrain_height.gd` remains a renderer-only diagnostic exception - that reads tile readiness/terrain internals; it is excluded from the gameplay/ - EditorPlugin gate until equivalent ground-query and readiness facade contracts exist. +- Rendered-ground sampling generates a triangle mesh on demand and retains the + historical nearby seam fallback. It is appropriate for diagnostics, not a + per-physics-frame gameplay query or a claim of exact terrain collision parity. ## Source map | 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, detached metrics and rendered-ground queries | +| `src/render/terrain/rendered_ground_sample.gd` | Immutable renderer-owned available/unavailable ground query result | | `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 | @@ -341,10 +360,10 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `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/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` | Focus/metrics/ground delegation, snapshot isolation and consumer wiring regression | | `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression | | `src/tools/verify_render_budget_scheduler.gd` | Scheduler bounds, shared-lane priority, cancellation and timing regression | -| `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin boundary gate derived from private streamer fields | +| `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin/registered renderer-tool boundary gate derived from private streamer fields | | `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_native_animated_builder.gd` | Native animated M2 experiment | @@ -360,7 +379,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer checkpoint contract verification | | `tools/sweep_render_checkpoint_camera_pose.ps1` | Offline yaw/pitch capture grid and paired-error ranking | | `src/tools/verify_render_coordinate_calibration.gd` | Build 12340 camera-coordinate golden point round-trip diagnostic | -| `src/tools/probe_render_terrain_height.gd` | Offline active-mesh terrain height and camera-clearance report | +| `src/tools/probe_render_terrain_height.gd` | Offline facade-backed active-mesh terrain height and camera-clearance report | | `src/tools/probe_render_camera_occluders.gd` | Scene-tree placement containment and camera-to-target AABB intersection report | ## Related decisions and references diff --git a/src/render/terrain/rendered_ground_sample.gd b/src/render/terrain/rendered_ground_sample.gd new file mode 100644 index 0000000..73fd409 --- /dev/null +++ b/src/render/terrain/rendered_ground_sample.gd @@ -0,0 +1,50 @@ +class_name RenderedGroundSample +extends RefCounted + +## Immutable result of sampling currently loaded render terrain in Godot world units. +## This diagnostic value is renderer-owned and is not authoritative gameplay collision. + +var is_available: bool: + get: + return _is_available + +var height_units: float: + get: + return _height_units + +var failure_code: StringName: + get: + return _failure_code + +var _is_available: bool +var _height_units: float +var _failure_code: StringName + + +## Creates an available finite rendered-ground sample in Godot world units. +static func available(height_units_value: float) -> RenderedGroundSample: + return RenderedGroundSample.new(true, height_units_value, &"") + + +## Creates an unavailable rendered-ground sample with a stable failure code. +static func unavailable(failure_code_value: StringName) -> RenderedGroundSample: + return RenderedGroundSample.new(false, 0.0, failure_code_value) + + +func _init( + is_available_value: bool, + height_units_value: float, + failure_code_value: StringName +) -> void: + _is_available = is_available_value and is_finite(height_units_value) + _height_units = height_units_value if _is_available else 0.0 + if _is_available: + _failure_code = &"" + elif not is_finite(height_units_value): + _failure_code = &"render_terrain_height_not_finite" + else: + _failure_code = ( + failure_code_value + if not failure_code_value.is_empty() + else &"render_terrain_unavailable" + ) diff --git a/src/render/terrain/rendered_ground_sample.gd.uid b/src/render/terrain/rendered_ground_sample.gd.uid new file mode 100644 index 0000000..4673661 --- /dev/null +++ b/src/render/terrain/rendered_ground_sample.gd.uid @@ -0,0 +1 @@ +uid://c744ldnnn87a8 diff --git a/src/render/world_render_facade.gd b/src/render/world_render_facade.gd index a0f9307..fd3f343 100644 --- a/src/render/world_render_facade.gd +++ b/src/render/world_render_facade.gd @@ -7,6 +7,7 @@ extends Node const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd") +const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd") ## Internal renderer implementation resolved relative to this node. ## The referenced node remains owned by its scene parent. @@ -66,6 +67,33 @@ func renderer_metrics_snapshot() -> Dictionary: return (metrics_variant as Dictionary).duplicate(true) +## Samples the currently loaded render terrain at a typed Godot world position. +## This read-only renderer view is diagnostic and does not replace authoritative +## gameplay collision or the independently composed [TerrainQuery]. +func sample_ground_height(godot_world_position: GodotWorldPosition) -> RefCounted: + var loader := _resolve_streaming_world_loader() + if loader == null: + return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_facade_unavailable") + var sample_variant = loader.call("sample_rendered_ground_height", godot_world_position) + if sample_variant is RefCounted and sample_variant.get_script() == RENDERED_GROUND_SAMPLE_SCRIPT: + return sample_variant + push_error("WorldRenderFacade received an invalid rendered ground sample.") + return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_ground_sample_invalid") + + +## Returns a detached diagnostic snapshot for one rendered-ground query. +## Mutating the result cannot mutate streamer queues, tile state or resources. +func renderer_ground_query_snapshot(godot_world_position: GodotWorldPosition) -> Dictionary: + var loader := _resolve_streaming_world_loader() + if loader == null: + return {} + var snapshot_variant = loader.call("render_ground_query_snapshot", godot_world_position) + if not (snapshot_variant is Dictionary): + push_error("WorldRenderFacade received a non-Dictionary ground query snapshot.") + return {} + return (snapshot_variant as Dictionary).duplicate(true) + + func _capture_streaming_focus_from_source() -> void: if streaming_focus_source_path == NodePath(): return @@ -99,6 +127,8 @@ func _resolve_streaming_world_loader() -> Node: &"set_streaming_focus", &"refresh_streaming_focus", &"render_baseline_snapshot", + &"sample_rendered_ground_height", + &"render_ground_query_snapshot", ]: if not candidate.has_method(required_method): if not _missing_loader_reported: diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 5ff35db..086049c 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -17,6 +17,7 @@ const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot 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 RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.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") @@ -25,6 +26,7 @@ const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 const M2_MATERIAL_REFRESH_VERSION := 2 const WMO_MATERIAL_REFRESH_VERSION := 10 +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 @@ -1038,6 +1040,173 @@ func render_baseline_snapshot() -> Dictionary: } +## Samples already loaded render terrain at one typed Godot world position. +## This diagnostic rendering view is not authoritative gameplay collision. +func sample_rendered_ground_height(godot_world_position: GodotWorldPosition) -> RefCounted: + var ground_query_snapshot := render_ground_query_snapshot(godot_world_position) + if ground_query_snapshot.has("terrain_height"): + return RENDERED_GROUND_SAMPLE_SCRIPT.available(float(ground_query_snapshot["terrain_height"])) + var query_status := StringName(ground_query_snapshot.get("status", "render_terrain_unavailable")) + match query_status: + &"mesh_not_ready": + return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_terrain_mesh_not_ready") + &"no_intersection": + return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_terrain_no_intersection") + _: + return RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_terrain_unavailable") + + +## Returns a detached diagnostic read model for the loaded terrain around one +## typed position. Queue/cache ownership and mutable tile state stay internal. +func render_ground_query_snapshot(godot_world_position: GodotWorldPosition) -> Dictionary: + var world_position := Vector3( + godot_world_position.x_units, + godot_world_position.y_units, + godot_world_position.z_units + ) + var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(godot_world_position) + var highest_terrain_height := -INF + var intersected_tile_key := "" + var ready_mesh_count := 0 + for tile_y in range(tile_coordinate.tile_y - 1, tile_coordinate.tile_y + 2): + for tile_x in range(tile_coordinate.tile_x - 1, tile_coordinate.tile_x + 2): + var tile_key := _tile_key(tile_x, tile_y) + if not _tile_states.has(tile_key): + continue + var terrain_state: Dictionary = _tile_states[tile_key] + var terrain_height_variant = _intersect_rendered_terrain_state(terrain_state, world_position) + if terrain_height_variant == null: + continue + ready_mesh_count += 1 + var terrain_height := float(terrain_height_variant) + if terrain_height > highest_terrain_height: + highest_terrain_height = terrain_height + intersected_tile_key = tile_key + + if is_finite(highest_terrain_height): + return { + "status": "sampled", + "tile": intersected_tile_key, + "terrain_height": highest_terrain_height, + } + + var focus_tile_key := _tile_key(tile_coordinate.tile_x, tile_coordinate.tile_y) + var missing_snapshot := { + "status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready", + "tile": focus_tile_key, + } + missing_snapshot.merge( + _render_ground_tile_diagnostic(focus_tile_key, world_position), + true + ) + if bool(missing_snapshot.get("quality_mesh_present", false)) or bool( + missing_snapshot.get("tile_lod_mesh_present", false)): + missing_snapshot["status"] = "no_intersection" + missing_snapshot.merge(_nearest_rendered_terrain_sample(world_position), true) + if missing_snapshot.get("nearest_sample_distance", null) != null: + missing_snapshot["status"] = "sampled_nearby" + missing_snapshot["terrain_height"] = float(missing_snapshot["nearest_sample_height"]) + return missing_snapshot + + +func _intersect_rendered_terrain_state(terrain_state: Dictionary, world_position: Vector3): + var terrain_mesh: Mesh = terrain_state.get("quality_terrain_mesh", null) + if terrain_mesh == null: + terrain_mesh = terrain_state.get("tile_lod_mesh", null) + if terrain_mesh == null: + return null + var triangle_mesh := terrain_mesh.generate_triangle_mesh() + if triangle_mesh == null: + return null + var tile_root := terrain_state.get("root", null) as Node3D + if tile_root == null or not is_instance_valid(tile_root): + return null + var inverse_transform := tile_root.global_transform.affine_inverse() + var local_ray_origin := inverse_transform * Vector3( + world_position.x, + RENDER_GROUND_QUERY_RAY_HEIGHT_UNITS, + world_position.z + ) + var local_ray_direction := inverse_transform.basis * Vector3.DOWN + var intersection: Dictionary = triangle_mesh.intersect_ray(local_ray_origin, local_ray_direction) + if intersection.is_empty(): + return null + var local_hit: Vector3 = intersection.get("position", Vector3.ZERO) + return (tile_root.global_transform * local_hit).y + + +func _render_ground_tile_diagnostic(tile_key: String, world_position: Vector3) -> Dictionary: + var queued_index := -1 + for request_index in _tile_load_queue.size(): + var tile_request: Dictionary = _tile_load_queue[request_index] + if String(tile_request.get("key", "")) == tile_key: + queued_index = request_index + break + var diagnostic_snapshot := { + "available": _available_tiles.has(tile_key), + "queued_index": queued_index, + "loading": _tile_loading_tasks.has(tile_key), + "state_present": _tile_states.has(tile_key), + "load_queue_size": _tile_load_queue.size(), + } + if not _tile_states.has(tile_key): + return diagnostic_snapshot + + var terrain_state: Dictionary = _tile_states[tile_key] + var quality_mesh: Mesh = terrain_state.get("quality_terrain_mesh", null) + var tile_lod_mesh: Mesh = terrain_state.get("tile_lod_mesh", null) + diagnostic_snapshot["quality_mesh_present"] = quality_mesh != null + diagnostic_snapshot["tile_lod_mesh_present"] = tile_lod_mesh != null + diagnostic_snapshot["quality_source"] = String(terrain_state.get("quality_terrain_source", "")) + diagnostic_snapshot["tile_lod"] = int(terrain_state.get("tile_lod", -1)) + var tile_root := terrain_state.get("root", null) as Node3D + if tile_root != null and is_instance_valid(tile_root): + var local_position := tile_root.global_transform.affine_inverse() * world_position + diagnostic_snapshot["tile_root_position"] = _render_ground_vector3_array(tile_root.global_position) + diagnostic_snapshot["probe_local_position"] = _render_ground_vector3_array(local_position) + var diagnostic_mesh := quality_mesh if quality_mesh != null else tile_lod_mesh + if diagnostic_mesh != null: + var mesh_bounds := diagnostic_mesh.get_aabb() + diagnostic_snapshot["mesh_aabb_position"] = _render_ground_vector3_array(mesh_bounds.position) + diagnostic_snapshot["mesh_aabb_size"] = _render_ground_vector3_array(mesh_bounds.size) + return diagnostic_snapshot + + +func _nearest_rendered_terrain_sample(world_position: Vector3) -> Dictionary: + for radius_units in [2.0, 5.0, 10.0, 20.0, 40.0]: + for offset in [ + Vector2(radius_units, 0.0), + Vector2(-radius_units, 0.0), + Vector2(0.0, radius_units), + Vector2(0.0, -radius_units), + ]: + var sample_position := world_position + Vector3(offset.x, 0.0, offset.y) + var typed_sample_position = GODOT_WORLD_POSITION_SCRIPT.new( + sample_position.x, + sample_position.y, + sample_position.z + ) + var sample_tile = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_sample_position) + var sample_tile_key := _tile_key(sample_tile.tile_x, sample_tile.tile_y) + if not _tile_states.has(sample_tile_key): + continue + var terrain_height_variant = _intersect_rendered_terrain_state( + _tile_states[sample_tile_key], + sample_position + ) + if terrain_height_variant != null: + return { + "nearest_sample_distance": radius_units, + "nearest_sample_height": float(terrain_height_variant), + "nearest_sample_tile": sample_tile_key, + } + return {"nearest_sample_distance": null} + + +func _render_ground_vector3_array(vector: Vector3) -> Array[float]: + return [vector.x, vector.y, vector.z] + + func _tick_runtime_stats(delta: float) -> void: if not runtime_stats_enabled or Engine.is_editor_hint(): return diff --git a/src/tools/probe_render_terrain_height.gd b/src/tools/probe_render_terrain_height.gd index dc5b0b5..42a528e 100644 --- a/src/tools/probe_render_terrain_height.gd +++ b/src/tools/probe_render_terrain_height.gd @@ -5,9 +5,7 @@ extends SceneTree ## [--wait 3.0] [--output user://render_terrain_height/report.json] const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" -const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd") const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") -const RAY_HEIGHT := 5000.0 func _initialize() -> void: @@ -61,7 +59,15 @@ func _run_async() -> void: camera.global_position = camera_position render_facade.call("refresh_streaming_focus", true) await create_timer(maxf(0.1, wait_seconds)).timeout - var terrain_sample := _sample_terrain(world, camera_position) + var typed_camera_position = GODOT_WORLD_POSITION_SCRIPT.new( + camera_position.x, + camera_position.y, + camera_position.z + ) + var terrain_sample: Dictionary = render_facade.call( + "renderer_ground_query_snapshot", + typed_camera_position + ) terrain_sample["name"] = checkpoint.get("name", "checkpoint") terrain_sample["camera_y"] = camera_position.y if terrain_sample.has("terrain_height"): @@ -94,137 +100,6 @@ func _run_async() -> void: quit(1 if failed else 0) -func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary: - var tile_coordinate := _adt_tile_vector2i(world_position) - var tile_states: Dictionary = world.get("_tile_states") - var highest_terrain_height := -INF - var intersected_tile_key := "" - var ready_mesh_count := 0 - for tile_y in range(tile_coordinate.y - 1, tile_coordinate.y + 2): - for tile_x in range(tile_coordinate.x - 1, tile_coordinate.x + 2): - var tile_key := "%d_%d" % [tile_x, tile_y] - if not tile_states.has(tile_key): - continue - var state: Dictionary = tile_states[tile_key] - var terrain_height_variant = _intersect_terrain_state(state, world_position) - if terrain_height_variant == null: - continue - ready_mesh_count += 1 - var terrain_height := float(terrain_height_variant) - if terrain_height > highest_terrain_height: - highest_terrain_height = terrain_height - intersected_tile_key = tile_key - if not is_finite(highest_terrain_height): - var missing_result := { - "status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready", - "tile": "%d_%d" % [tile_coordinate.x, tile_coordinate.y], - } - missing_result.merge(_tile_runtime_diagnostic(world, String(missing_result.tile), world_position), true) - if bool(missing_result.get("quality_mesh_present", false)) or bool(missing_result.get("tile_lod_mesh_present", false)): - missing_result["status"] = "no_intersection" - missing_result.merge(_nearest_terrain_sample(world, world_position), true) - if missing_result.get("nearest_sample_distance", null) != null: - missing_result["status"] = "sampled_nearby" - missing_result["terrain_height"] = float(missing_result.nearest_sample_height) - return missing_result - return { - "status": "sampled", - "tile": intersected_tile_key, - "terrain_height": highest_terrain_height, - } - - -func _intersect_terrain_state(state: Dictionary, world_position: Vector3): - var terrain_mesh: Mesh = state.get("quality_terrain_mesh", null) - if terrain_mesh == null: - terrain_mesh = state.get("tile_lod_mesh", null) - if terrain_mesh == null: - return null - var triangle_mesh := terrain_mesh.generate_triangle_mesh() - if triangle_mesh == null: - return null - var tile_root := state.get("root", null) as Node3D - if tile_root == null: - return null - var inverse_transform := tile_root.global_transform.affine_inverse() - var local_ray_origin := inverse_transform * Vector3(world_position.x, RAY_HEIGHT, world_position.z) - var local_ray_direction := inverse_transform.basis * Vector3.DOWN - var intersection: Dictionary = triangle_mesh.intersect_ray(local_ray_origin, local_ray_direction) - if intersection.is_empty(): - return null - var local_hit: Vector3 = intersection.get("position", Vector3.ZERO) - var world_hit := tile_root.global_transform * local_hit - return world_hit.y - - -func _tile_runtime_diagnostic(world: Node3D, tile_key: String, world_position: Vector3) -> Dictionary: - var available_tiles: Dictionary = world.get("_available_tiles") - var loading_tasks: Dictionary = world.get("_tile_loading_tasks") - var tile_states: Dictionary = world.get("_tile_states") - var load_queue: Array = world.get("_tile_load_queue") - var queued_index := -1 - for index in load_queue.size(): - var request: Dictionary = load_queue[index] - if String(request.get("key", "")) == tile_key: - queued_index = index - break - var diagnostic := { - "available": available_tiles.has(tile_key), - "queued_index": queued_index, - "loading": loading_tasks.has(tile_key), - "state_present": tile_states.has(tile_key), - "load_queue_size": load_queue.size(), - } - if tile_states.has(tile_key): - var state: Dictionary = tile_states[tile_key] - var quality_mesh: Mesh = state.get("quality_terrain_mesh", null) - var tile_lod_mesh: Mesh = state.get("tile_lod_mesh", null) - diagnostic["quality_mesh_present"] = quality_mesh != null - diagnostic["tile_lod_mesh_present"] = tile_lod_mesh != null - diagnostic["quality_source"] = String(state.get("quality_terrain_source", "")) - diagnostic["tile_lod"] = int(state.get("tile_lod", -1)) - var tile_root := state.get("root", null) as Node3D - if tile_root != null: - var local_position := tile_root.global_transform.affine_inverse() * world_position - diagnostic["tile_root_position"] = _vector3_array(tile_root.global_position) - diagnostic["probe_local_position"] = _vector3_array(local_position) - var diagnostic_mesh := quality_mesh if quality_mesh != null else tile_lod_mesh - if diagnostic_mesh != null: - var mesh_aabb := diagnostic_mesh.get_aabb() - diagnostic["mesh_aabb_position"] = _vector3_array(mesh_aabb.position) - diagnostic["mesh_aabb_size"] = _vector3_array(mesh_aabb.size) - return diagnostic - - -func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictionary: - var tile_states: Dictionary = world.get("_tile_states") - for radius in [2.0, 5.0, 10.0, 20.0, 40.0]: - for offset in [Vector2(radius, 0.0), Vector2(-radius, 0.0), Vector2(0.0, radius), Vector2(0.0, -radius)]: - var sample_position := world_position + Vector3(offset.x, 0.0, offset.y) - var sample_tile := _adt_tile_vector2i(sample_position) - var sample_key := "%d_%d" % [sample_tile.x, sample_tile.y] - if not tile_states.has(sample_key): - continue - var height_variant = _intersect_terrain_state(tile_states[sample_key], sample_position) - if height_variant != null: - return { - "nearest_sample_distance": radius, - "nearest_sample_height": float(height_variant), - "nearest_sample_tile": sample_key, - } - return {"nearest_sample_distance": null} - - -func _adt_tile_vector2i(world_position: Vector3) -> Vector2i: - var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z) - var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position) - return Vector2i(tile_coordinate.tile_x, tile_coordinate.tile_y) - - -func _vector3_array(value: Vector3) -> Array[float]: - return [value.x, value.y, value.z] - - func _vector3(value_variant) -> Vector3: if not (value_variant is Array) or value_variant.size() != 3: return Vector3.ZERO diff --git a/src/tools/verify_coordinate_conversion_boundaries.gd b/src/tools/verify_coordinate_conversion_boundaries.gd index dd2f096..ef4692d 100644 --- a/src/tools/verify_coordinate_conversion_boundaries.gd +++ b/src/tools/verify_coordinate_conversion_boundaries.gd @@ -23,7 +23,6 @@ const REQUIRED_MAPPER_CALLS := { "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/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"], } @@ -61,7 +60,7 @@ func _initialize() -> void: quit(1) return - print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=6 classifier_cases=6" % source_paths.size()) + print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=5 classifier_cases=6" % source_paths.size()) quit(0) diff --git a/src/tools/verify_renderer_internal_access.gd b/src/tools/verify_renderer_internal_access.gd index 88b6e34..3a9dd36 100644 --- a/src/tools/verify_renderer_internal_access.gd +++ b/src/tools/verify_renderer_internal_access.gd @@ -5,6 +5,9 @@ extends SceneTree const STREAMING_WORLD_LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" const GAMEPLAY_ROOT := "res://src/gameplay" const SOURCE_ROOTS: Array[String] = ["res://src", "res://addons"] +const RENDERER_DIAGNOSTIC_PATHS: Array[String] = [ + "res://src/tools/probe_render_terrain_height.gd", +] func _initialize() -> void: @@ -20,6 +23,8 @@ func _initialize() -> void: consumer_paths_by_path[gameplay_path] = true for editor_source_path in editor_source_paths: consumer_paths_by_path[editor_source_path] = true + for renderer_diagnostic_path in RENDERER_DIAGNOSTIC_PATHS: + consumer_paths_by_path[renderer_diagnostic_path] = true for consumer_path_variant in consumer_paths_by_path: var consumer_path := String(consumer_path_variant) @@ -33,10 +38,11 @@ func _initialize() -> void: quit(1) return - print("RENDERER_INTERNAL_ACCESS PASS private_symbols=%d gameplay=%d editor_sources=%d" % [ + print("RENDERER_INTERNAL_ACCESS PASS private_symbols=%d gameplay=%d editor_sources=%d renderer_tools=%d" % [ private_renderer_symbols.size(), gameplay_paths.size(), editor_source_paths.size(), + RENDERER_DIAGNOSTIC_PATHS.size(), ]) quit(0) diff --git a/src/tools/verify_world_render_facade.gd b/src/tools/verify_world_render_facade.gd index dea34c4..66571e6 100644 --- a/src/tools/verify_world_render_facade.gd +++ b/src/tools/verify_world_render_facade.gd @@ -5,6 +5,7 @@ extends SceneTree const WORLD_RENDER_FACADE_SCRIPT := preload("res://src/render/world_render_facade.gd") const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd") +const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd") const RUNTIME_SCENE_PATHS: Array[String] = [ "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn", @@ -15,12 +16,19 @@ const TOOL_PATHS: Array[String] = [ "res://src/tools/probe_render_camera_occluders.gd", "res://src/tools/probe_render_terrain_height.gd", ] +const STREAMING_WORLD_LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" class StreamingWorldLoaderDouble extends Node: var current_focus: StreamingFocus var refresh_count := 0 var metrics := {"tiles": 2, "queues": {"tile": 1}} + var ground_sample: RefCounted = RENDERED_GROUND_SAMPLE_SCRIPT.available(42.25) + var ground_snapshot := { + "status": "sampled", + "terrain_height": 42.25, + "diagnostic": {"state_present": true}, + } func set_streaming_focus(streaming_focus: StreamingFocus) -> void: current_focus = streaming_focus @@ -32,9 +40,16 @@ class StreamingWorldLoaderDouble extends Node: func render_baseline_snapshot() -> Dictionary: return metrics + func sample_rendered_ground_height(_godot_world_position: GodotWorldPosition) -> RefCounted: + return ground_sample + + func render_ground_query_snapshot(_godot_world_position: GodotWorldPosition) -> Dictionary: + return ground_snapshot + func _initialize() -> void: var failures: Array[String] = [] + _verify_rendered_ground_sample_contract(failures) await _verify_delegation_and_snapshot_isolation(failures) _verify_repository_wiring(failures) @@ -44,10 +59,26 @@ func _initialize() -> void: quit(1) return - print("WORLD_RENDER_FACADE PASS delegation=3 runtime_scenes=2 tools=3") + print("WORLD_RENDER_FACADE PASS delegation=5 ground_contract=2 runtime_scenes=2 tools=3") quit(0) +func _verify_rendered_ground_sample_contract(failures: Array[String]) -> void: + var non_finite_sample = RENDERED_GROUND_SAMPLE_SCRIPT.available(INF) + _expect_true(not non_finite_sample.is_available, "non-finite rendered ground is unavailable", failures) + _expect_true( + non_finite_sample.failure_code == &"render_terrain_height_not_finite", + "non-finite rendered ground failure code", + failures + ) + var default_unavailable_sample = RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"") + _expect_true( + default_unavailable_sample.failure_code == &"render_terrain_unavailable", + "empty rendered ground failure code normalizes", + failures + ) + + func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void: var test_root := Node.new() var loader := StreamingWorldLoaderDouble.new() @@ -82,6 +113,24 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void: (snapshot["queues"] as Dictionary)["tile"] = 999 _expect_true(int(loader.metrics.tiles) == 2, "top-level metrics are detached", failures) _expect_true(int(loader.metrics.queues.tile) == 1, "nested metrics are detached", failures) + + var query_position = GODOT_WORLD_POSITION_SCRIPT.new(100.0, 200.0, 300.0) + var ground_sample: RefCounted = facade.sample_ground_height(query_position) + _expect_true(ground_sample.is_available, "rendered ground sample delegates", failures) + _expect_near(ground_sample.height_units, 42.25, "rendered ground height", failures) + loader.ground_sample = RENDERED_GROUND_SAMPLE_SCRIPT.unavailable(&"render_terrain_mesh_not_ready") + var unavailable_ground_sample: RefCounted = facade.sample_ground_height(query_position) + _expect_true(not unavailable_ground_sample.is_available, "unavailable ground sample delegates", failures) + _expect_true( + unavailable_ground_sample.failure_code == &"render_terrain_mesh_not_ready", + "ground failure code delegates", + failures + ) + var ground_snapshot: Dictionary = facade.renderer_ground_query_snapshot(query_position) + ground_snapshot["terrain_height"] = 999.0 + (ground_snapshot["diagnostic"] as Dictionary)["state_present"] = false + _expect_near(float(loader.ground_snapshot.terrain_height), 42.25, "ground snapshot top-level detached", failures) + _expect_true(bool(loader.ground_snapshot.diagnostic.state_present), "ground snapshot nested detached", failures) test_root.queue_free() @@ -98,6 +147,44 @@ func _verify_repository_wiring(failures: Array[String]) -> void: _expect_true(not tool_source.contains('world.call("refresh_streaming_focus"'), "%s avoids direct streamer refresh" % tool_path, failures) _expect_true(not tool_source.contains('world.set("streaming_focus_source_path"'), "%s avoids direct streamer focus config" % tool_path, failures) + var terrain_probe_source := _read_text("res://src/tools/probe_render_terrain_height.gd", failures) + _expect_true( + terrain_probe_source.contains('"renderer_ground_query_snapshot"'), + "terrain probe delegates ground diagnostics", + failures + ) + _expect_true( + not terrain_probe_source.contains('world.get("_'), + "terrain probe avoids private streamer state", + failures + ) + for forbidden_probe_text in [ + "_tile_states", + "_tile_load_queue", + "_tile_loading_tasks", + "_available_tiles", + "generate_triangle_mesh", + ]: + _expect_true( + not terrain_probe_source.contains(forbidden_probe_text), + "terrain probe omits %s" % forbidden_probe_text, + failures + ) + + var loader_source := _read_text(STREAMING_WORLD_LOADER_PATH, failures) + for required_loader_text in [ + "func sample_rendered_ground_height(godot_world_position: GodotWorldPosition)", + "func render_ground_query_snapshot(godot_world_position: GodotWorldPosition)", + "RENDER_GROUND_QUERY_RAY_HEIGHT_UNITS := 5000.0", + "for radius_units in [2.0, 5.0, 10.0, 20.0, 40.0]", + "generate_triangle_mesh()", + ]: + _expect_true( + loader_source.contains(required_loader_text), + "streamer ground query retains %s" % required_loader_text, + failures + ) + func _read_text(path: String, failures: Array[String]) -> String: var file := FileAccess.open(path, FileAccess.READ)