rnd(M03): introduce renderer facade seam

Work-Package: M03-RND-FACADE-001
Agent: sindo-main-codex
Tests: facade/focus/coordinate/manifest contracts, renderer dry-run, coordination and documentation gates
Fidelity: focus targets, budgets, queues, caches and visible renderer behavior remain unchanged
This commit is contained in:
2026-07-15 23:48:53 +04:00
parent a629bedccf
commit 3d528e3bbf
12 changed files with 335 additions and 37 deletions
+17 -1
View File
@@ -25,7 +25,8 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g
## Основные файлы
- `src/scenes/streaming/streaming_world_loader.gd` - главный runtime streamer мира.
- `src/render/world_render_facade.gd` - стабильная граница runtime/tools для focus и metrics.
- `src/scenes/streaming/streaming_world_loader.gd` - внутренний runtime streamer мира.
- `src/scenes/streaming/eastern_kingdoms_streaming.tscn` - текущая сцена для проверки Azeroth/Eastern Kingdoms.
- `addons/mpq_extractor/loaders/adt_builder.gd` - сборка ADT terrain, control splat материалов и MH2O liquids.
- `addons/mpq_extractor/loaders/m2_builder.gd` - сборка статического M2 mesh/material.
@@ -873,6 +874,21 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- This closes a coordinate/renderer integration criterion only. It does not
claim TrinityCore row equivalence or visual parity with the original client.
## 2026-07-15 WorldRenderFacade First Seam
- Runtime streaming scenes, checkpoint capture and renderer probes now submit
typed focus through `WorldRenderFacade`; those callers no longer call the
monolithic streamer's focus methods or configure its focus source directly.
- Baseline capture reads queue/cache/activity counts through
`renderer_metrics_snapshot()`. The facade returns a deep copy, so diagnostics
cannot mutate renderer-owned state.
- `StreamingWorldLoader` remains the internal implementation and still owns all
queues, tasks, caches, nodes and RIDs. The facade adds no parallel state or
replacement scheduler.
- This package does not alter targets, budgets, cache versions, placement,
visibility, materials or animation. Environment, entity visuals and ground
query remain explicit follow-up facade contracts in M03.
## 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.
+37 -18
View File
@@ -5,9 +5,9 @@
| Field | Value |
|---|---|
| Status | Partial |
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; декомпозиция M02–M03 |
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; `M03-RND-FACADE-001` |
| Owners | Renderer workstream / milestone integrator |
| Last verified | Worktree `work/sindo-main-codex/m01-server-spawn-renderer`, 2026-07-13 |
| Last verified | Worktree `work/sindo-main-codex/m03-renderer-facade`, 2026-07-15 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose
@@ -32,7 +32,8 @@ flowchart LR
SpawnFixture[Pinned server spawn fixture] --> SpawnManifest[Spawn render manifest]
SpawnManifest --> Capture
Adapter --> Focus
Focus --> Loader[StreamingWorldLoader]
Focus --> Facade[WorldRenderFacade]
Facade --> Loader[StreamingWorldLoader internal]
Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders]
Cache[Baked terrain/M2/WMO caches] --> Loader
Native --> Parsed[Parsed tile/model data]
@@ -60,36 +61,43 @@ Forbidden dependencies:
## Public API
Текущая система ещё не имеет стабильного facade. Фактический integration surface — `StreamingWorldLoader` Node3D и exported properties. M03 должен заменить этот surface на `WorldRenderFacade`.
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.
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `map_name` | Exported property | WDT/map directory name | Set before world load | Missing WDT produces diagnostic/empty world |
| `extracted_dir` | Exported property | Root of legally extracted client data | Session lifetime | Missing files reported per loader path |
| `StreamingFocus` | Immutable value | Typed Godot-basis position without Node/Camera dependency | Any thread; caller-owned reference | Null position is a caller contract violation |
| `streaming_focus_source_path` | Exported adapter property | Samples any explicit `Node3D` source into `StreamingFocus` | Main thread, scene lifetime | Missing/wrong node warns once and retains prior focus |
| `set_streaming_focus` | Public method | Accepts focus from app/editor/tool composition | Main thread/session | Null focus is ignored by refresh until replaced |
| `refresh_streaming_focus` | Public method | Samples configured source and applies refresh thresholds | Main thread/session | Returns `false` when no valid focus exists |
| `WorldRenderFacade.streaming_focus_source_path` | Exported adapter property | Samples any explicit `Node3D` source into `StreamingFocus` | Main thread, scene lifetime | Missing/wrong node warns once and retains prior focus |
| `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.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 |
| `runtime_stats_enabled` | Exported property | Enables periodic performance snapshot | Runtime mutable | Logging overhead only |
| `hitch_profiler_enabled` | Exported property | Enables named hitch sections | Runtime mutable | Logging overhead only |
Публичным contract не считаются внутренние dictionaries, queues, job records и generated node names.
Публичным contract не считаются `StreamingWorldLoader` methods/properties,
внутренние dictionaries, queues, job records и generated node names. Scene-owned
loader configuration remains transitional composition data, not a caller API.
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `StreamingFocus` | Player/spectator/editor/capture adapter | `StreamingWorldLoader` | Immutable caller value | Main thread/session |
| Input | Focus-source `NodePath` | Runtime/test scene composition | Loader source adapter | Scene-owned node, sampled position | Main thread/scene lifetime |
| 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 | 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 |
| 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 | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach |
| Output | Metrics/diagnostics | Hitch/stat instrumentation | Logs/baseline tools | Immutable records | Periodic/frame |
| 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 |
| Test output | Diagnostic spawn marker and cold/warm PNGs | Checkpoint capture tool | Human/integrator review | SceneTree marker; `user://` files | One capture process |
Side effects:
@@ -104,7 +112,7 @@ Side effects:
```mermaid
flowchart TD
P[Player/spectator Node3D] --> A[Explicit scene adapter]
PlayerSource[Player/spectator Node3D] --> A[Explicit scene adapter]
C[Capture camera] --> A
SF[Pinned server spawn] --> SM[Validated spawn manifest]
SM --> C
@@ -112,11 +120,12 @@ flowchart TD
E[Editor viewport] --> EA[Editor adapter]
A --> F[StreamingFocus]
EA --> F
F --> T[Compute desired ADT tiles]
F --> RF[WorldRenderFacade]
RF --> T[StreamingWorldLoader target refresh]
WDT[WDT tile catalog] --> T
T --> Q[Tile load queue]
Q --> P[Worker parse/cache load]
P --> R[Result queues]
Q --> Parse[Worker parse/cache load]
Parse --> R[Result queues]
R --> B[Per-frame budget scheduler]
B --> Terrain[Terrain attach/upgrade]
B --> M2[M2 group/MultiMesh attach]
@@ -155,12 +164,14 @@ stateDiagram-v2
sequenceDiagram
participant Source as Player/Editor/Capture source
participant Focus as StreamingFocus adapter
participant Stream as StreamingWorldLoader
participant Facade as WorldRenderFacade
participant Stream as StreamingWorldLoader internal
participant Worker as Worker task
participant Budget as Main-thread budget
participant Render as SceneTree/RenderingServer
Source->>Focus: sample explicit Godot position
Focus->>Stream: set/refresh typed focus
Focus->>Facade: set/refresh typed focus
Facade->>Stream: delegate focus and refresh
Stream->>Stream: desired tiles and priorities
Stream->>Worker: parse/load tile and detail data
Worker-->>Stream: immutable result
@@ -176,6 +187,8 @@ sequenceDiagram
## Ownership, threading and resources
- `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.
- Focus producers own the immutable `StreamingFocus` reference; the loader retains
the latest reference and converts it to `Vector3` only inside the render boundary.
- Worker tasks не должны менять SceneTree и shared Resource concurrently.
@@ -192,6 +205,8 @@ sequenceDiagram
|---|---|---|---|---|
| Missing WDT/ADT | File/catalog check | Tile/map unavailable | Loader warning/error | Fix extraction/profile, reload |
| Missing focus source | Explicit NodePath resolution | Retain current streamed content and prior valid focus | One warning until source recovers | Fix composition path or call `set_streaming_focus` |
| Missing renderer implementation | Facade path/method validation | Focus/metrics call fails without mutation | One facade diagnostic until recovery | Fix scene composition path/implementation |
| Invalid metrics result | Facade return-type validation | Return empty caller snapshot | Facade error | Restore `Dictionary` implementation contract |
| No focus supplied | Refresh contract | No target recomputation; queues continue draining | `refresh_streaming_focus()` returns `false` | Supply player/editor/capture focus |
| 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 |
@@ -225,6 +240,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Diagnostics and observability
- Logs: `HITCH`, `PERF`, `TERRAIN_QUALITY`, `SKY_LIGHT`, `SKYBOX_MODEL`.
- API snapshot: `WorldRenderFacade.renderer_metrics_snapshot()` returns detached
queue/cache/activity counts without exposing mutable renderer state.
- Metrics: queue depths, active tiles/assets, named finalize times, frame percentiles and max hitch.
- Debug views: render checkpoint scenes/captures; future streaming/LOD/portal overlays.
- Correlation IDs: currently mostly tile/path keys; target architecture adds session/job IDs.
@@ -257,7 +274,7 @@ 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 | Planned | M03 | Current caller uses monolithic Node API |
| Stable renderer facade | Partial | `M03-RND-FACADE-001` contract/runtime/tool regressions | Focus and metrics migrated; environment/entity visuals/ground query remain |
## Known gaps and risks
@@ -283,9 +300,11 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| Path | Responsibility |
|---|---|
| `src/render/world_render_facade.gd` | Stable caller boundary for typed focus and detached renderer metrics |
| `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 |
| `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 |
+110
View File
@@ -0,0 +1,110 @@
@tool
class_name WorldRenderFacade
extends Node
## Stable main-thread boundary between runtime/tool callers and the monolithic
## streaming renderer. The facade owns no queues, caches, nodes or RIDs.
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")
## Internal renderer implementation resolved relative to this node.
## The referenced node remains owned by its scene parent.
@export var streaming_world_loader_path: NodePath = NodePath("..")
## Optional explicit Node3D source sampled in Godot world coordinates.
## Runtime scenes use the player; capture and probe tools replace it with their camera.
@export var streaming_focus_source_path: NodePath
var _streaming_world_loader: Node
var _missing_loader_reported := false
var _missing_focus_source_reported := false
func _ready() -> void:
_resolve_streaming_world_loader()
set_process(streaming_focus_source_path != NodePath())
if streaming_focus_source_path != NodePath():
# Child nodes become ready before their parent StreamingWorldLoader. Only
# publish the initial focus here; the loader applies it after initialization.
_capture_streaming_focus_from_source()
func _process(_delta_seconds: float) -> void:
_capture_streaming_focus_from_source()
## Replaces the renderer focus. The immutable value remains caller-owned; the
## internal streamer retains the reference for its next throttled refresh.
func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
var loader := _resolve_streaming_world_loader()
if loader == null:
return
loader.call("set_streaming_focus", streaming_focus)
## Samples the configured source and requests target refresh from the internal
## streamer. Returns false when either dependency or a valid focus is unavailable.
func refresh_streaming_focus(force: bool = false) -> bool:
_capture_streaming_focus_from_source()
var loader := _resolve_streaming_world_loader()
if loader == null:
return false
return bool(loader.call("refresh_streaming_focus", force))
## Returns a detached read-only metrics snapshot for diagnostics and baselines.
## Mutating the returned Dictionary cannot mutate renderer-owned state.
func renderer_metrics_snapshot() -> Dictionary:
var loader := _resolve_streaming_world_loader()
if loader == null:
return {}
var metrics_variant = loader.call("render_baseline_snapshot")
if not (metrics_variant is Dictionary):
push_error("WorldRenderFacade received a non-Dictionary renderer metrics snapshot.")
return {}
return (metrics_variant as Dictionary).duplicate(true)
func _capture_streaming_focus_from_source() -> void:
if streaming_focus_source_path == NodePath():
return
var focus_source := get_node_or_null(streaming_focus_source_path) as Node3D
if focus_source == null:
if not _missing_focus_source_reported:
push_warning("WorldRenderFacade focus source is missing or is not Node3D: %s" % streaming_focus_source_path)
_missing_focus_source_reported = true
return
_missing_focus_source_reported = false
var godot_position: Vector3 = focus_source.global_position
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(
godot_position.x,
godot_position.y,
godot_position.z
)
set_streaming_focus(STREAMING_FOCUS_SCRIPT.new(typed_world_position))
func _resolve_streaming_world_loader() -> Node:
if is_instance_valid(_streaming_world_loader):
return _streaming_world_loader
var candidate := get_node_or_null(streaming_world_loader_path)
if candidate == null:
if not _missing_loader_reported:
push_error("WorldRenderFacade cannot resolve StreamingWorldLoader at %s" % streaming_world_loader_path)
_missing_loader_reported = true
return null
for required_method in [
&"set_streaming_focus",
&"refresh_streaming_focus",
&"render_baseline_snapshot",
]:
if not candidate.has_method(required_method):
if not _missing_loader_reported:
push_error("WorldRenderFacade renderer implementation lacks %s" % required_method)
_missing_loader_reported = true
return null
_streaming_world_loader = candidate
_missing_loader_reported = false
return _streaming_world_loader
+1
View File
@@ -0,0 +1 @@
uid://sxn3ai2ic18r
@@ -6,6 +6,7 @@
[ext_resource type="Script" uid="uid://cq60al4uw0mgo" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
[ext_resource type="Script" uid="uid://bhqbo1ftrt3a3" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" uid="uid://c2jnn0e3mmymc" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[ext_resource type="Script" path="res://src/render/world_render_facade.gd" id="7_render_facade"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
@@ -56,7 +57,6 @@ fog_depth_end = 5200.0
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_stream")
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High"
update_interval = 0.1
@@ -94,6 +94,11 @@ wmo_visibility_range = 3600.0
debug_streaming = true
hitch_profiler_enabled = true
[node name="WorldRenderFacade" type="Node" parent="."]
script = ExtResource("7_render_facade")
streaming_world_loader_path = NodePath("..")
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
script = ExtResource("2_player")
+6 -1
View File
@@ -6,6 +6,7 @@
[ext_resource type="Script" path="res://src/scenes/player/third_person_camera_rig.gd" id="4_camera_rig"]
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
[ext_resource type="Script" path="res://src/render/world_render_facade.gd" id="7_render_facade"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
@@ -57,7 +58,6 @@ fog_depth_end = 5200.0
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_sisqv")
map_name = "Kalimdor"
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High"
update_interval = 0.1
@@ -95,6 +95,11 @@ wmo_visibility_range = 3600.0
debug_streaming = true
hitch_profiler_enabled = true
[node name="WorldRenderFacade" type="Node" parent="."]
script = ExtResource("7_render_facade")
streaming_world_loader_path = NodePath("..")
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
script = ExtResource("2_puy8r")
+11 -7
View File
@@ -64,7 +64,12 @@ func _capture_async() -> void:
camera.far = 50000.0
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
(world as Node3D).add_child(camera)
world.set("streaming_focus_source_path", NodePath("CheckpointCamera"))
var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("Streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../CheckpointCamera"))
world.set("debug_streaming", true)
world.set("runtime_stats_enabled", true)
get_root().add_child(world)
@@ -126,8 +131,7 @@ func _capture_async() -> void:
if player != null:
player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0])))
_set_sky_time(world, float(checkpoint.get("time_hours", 13.0)))
if world.has_method("refresh_streaming_focus"):
world.call("refresh_streaming_focus", true)
render_facade.call("refresh_streaming_focus", true)
if dry_run:
print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [
@@ -148,7 +152,7 @@ func _capture_async() -> void:
var load_started := Time.get_ticks_usec()
await create_timer(maxf(wait_seconds, 0.0)).timeout
var load_time_ms := float(Time.get_ticks_usec() - load_started) / 1000.0
var metrics := await _measure_frames(maxf(measure_seconds, 0.1), world)
var metrics := await _measure_frames(maxf(measure_seconds, 0.1), render_facade)
await RenderingServer.frame_post_draw
var image := get_root().get_texture().get_image()
@@ -189,7 +193,7 @@ func _capture_async() -> void:
quit(0)
func _measure_frames(seconds: float, world: Node) -> Dictionary:
func _measure_frames(seconds: float, render_facade: Node) -> Dictionary:
var frame_times: Array[float] = []
var deadline := Time.get_ticks_usec() + int(seconds * 1000000.0)
var previous := Time.get_ticks_usec()
@@ -200,8 +204,8 @@ func _measure_frames(seconds: float, world: Node) -> Dictionary:
previous = now
frame_times.sort()
var snapshot := {}
if world.has_method("render_baseline_snapshot"):
snapshot = world.call("render_baseline_snapshot")
if render_facade.has_method("renderer_metrics_snapshot"):
snapshot = render_facade.call("renderer_metrics_snapshot")
return {
"frames": frame_times.size(),
"frame_ms_p50": _percentile(frame_times, 0.50),
+7 -3
View File
@@ -28,7 +28,12 @@ func _run_async() -> void:
camera.name = "OccluderProbeCamera"
camera.current = true
world.add_child(camera)
world.set("streaming_focus_source_path", NodePath(camera.name))
var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("CAMERA_OCCLUDER_PROBE: streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../%s" % camera.name))
world.set("debug_streaming", false)
get_root().add_child(world)
await process_frame
@@ -47,8 +52,7 @@ func _run_async() -> void:
var camera_position := _vector3(checkpoint.get("camera", []))
var target_position := _vector3(checkpoint.get("target", []))
camera.global_position = camera_position
if world.has_method("refresh_streaming_focus"):
world.call("refresh_streaming_focus", true)
render_facade.call("refresh_streaming_focus", true)
await create_timer(maxf(0.1, wait_seconds)).timeout
var geometry_nodes: Array[Node3D] = []
_collect_geometry_nodes(world, geometry_nodes)
+7 -3
View File
@@ -37,7 +37,12 @@ func _run_async() -> void:
var camera := Camera3D.new()
camera.current = true
world.add_child(camera)
world.set("streaming_focus_source_path", NodePath(camera.name))
var render_facade := world.get_node_or_null("WorldRenderFacade")
if render_facade == null:
push_error("TERRAIN_HEIGHT_PROBE: streaming scene has no WorldRenderFacade")
quit(1)
return
render_facade.set("streaming_focus_source_path", NodePath("../%s" % camera.name))
world.set("debug_streaming", false)
get_root().add_child(world)
await process_frame
@@ -54,8 +59,7 @@ func _run_async() -> void:
continue
var camera_position := _vector3(checkpoint.get("camera", []))
camera.global_position = camera_position
if world.has_method("refresh_streaming_focus"):
world.call("refresh_streaming_focus", true)
render_facade.call("refresh_streaming_focus", true)
await create_timer(maxf(0.1, wait_seconds)).timeout
var terrain_sample := _sample_terrain(world, camera_position)
terrain_sample["name"] = checkpoint.get("name", "checkpoint")
+15 -3
View File
@@ -6,6 +6,7 @@ const StreamingFocusScript = preload("res://src/domain/streaming/streaming_focus
const GodotWorldPositionScript = preload("res://src/domain/coordinates/godot_world_position.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const FACADE_PATH := "res://src/render/world_render_facade.gd"
const RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
@@ -21,6 +22,7 @@ func _initialize() -> void:
var failures: Array[String] = []
_verify_scene_free_focus_value(failures)
_verify_loader_boundary(failures)
_verify_facade_boundary(failures)
_verify_runtime_scene_wiring(failures)
_verify_capture_tool_wiring(failures)
@@ -65,11 +67,21 @@ func _verify_loader_boundary(failures: Array[String]) -> void:
_expect_true(not loader_source.contains(forbidden_text), "loader omits %s" % forbidden_text, failures)
func _verify_facade_boundary(failures: Array[String]) -> void:
var facade_source := _read_text(FACADE_PATH, failures)
for required_text in [
"class_name WorldRenderFacade",
"func set_streaming_focus(streaming_focus: StreamingFocus) -> void:",
"func refresh_streaming_focus(force: bool = false) -> bool:",
]:
_expect_true(facade_source.contains(required_text), "facade contains %s" % required_text, failures)
func _verify_runtime_scene_wiring(failures: Array[String]) -> void:
for scene_path in RUNTIME_SCENE_PATHS:
var scene_source := _read_text(scene_path, failures)
_expect_true(
scene_source.contains('streaming_focus_source_path = NodePath("ThirdPersonPlayer")'),
scene_source.contains('streaming_focus_source_path = NodePath("../ThirdPersonPlayer")'),
"%s uses player focus" % scene_path,
failures
)
@@ -78,8 +90,8 @@ func _verify_runtime_scene_wiring(failures: Array[String]) -> void:
func _verify_capture_tool_wiring(failures: Array[String]) -> void:
for tool_path in CAPTURE_TOOL_PATHS:
var tool_source := _read_text(tool_path, failures)
_expect_true(tool_source.contains('world.set("streaming_focus_source_path"'), "%s sets explicit focus source" % tool_path, failures)
_expect_true(tool_source.contains('world.call("refresh_streaming_focus", true)'), "%s uses public refresh" % tool_path, failures)
_expect_true(tool_source.contains('render_facade.set("streaming_focus_source_path"'), "%s sets explicit focus source" % tool_path, failures)
_expect_true(tool_source.contains('render_facade.call("refresh_streaming_focus", true)'), "%s uses facade refresh" % tool_path, failures)
_expect_true(not tool_source.contains('world.call("_refresh_streaming_targets_at"'), "%s avoids private refresh" % tool_path, failures)
+117
View File
@@ -0,0 +1,117 @@
extends SceneTree
## Headless contract and wiring regression for the first M03 renderer facade seam.
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 RUNTIME_SCENE_PATHS: Array[String] = [
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
"res://src/scenes/streaming/kalimdor_streaming.tscn",
]
const TOOL_PATHS: Array[String] = [
"res://src/tools/capture_render_checkpoints.gd",
"res://src/tools/probe_render_camera_occluders.gd",
"res://src/tools/probe_render_terrain_height.gd",
]
class StreamingWorldLoaderDouble extends Node:
var current_focus: StreamingFocus
var refresh_count := 0
var metrics := {"tiles": 2, "queues": {"tile": 1}}
func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
current_focus = streaming_focus
func refresh_streaming_focus(_force: bool = false) -> bool:
refresh_count += 1
return current_focus != null
func render_baseline_snapshot() -> Dictionary:
return metrics
func _initialize() -> void:
var failures: Array[String] = []
await _verify_delegation_and_snapshot_isolation(failures)
_verify_repository_wiring(failures)
if not failures.is_empty():
for failure in failures:
push_error("WORLD_RENDER_FACADE: %s" % failure)
quit(1)
return
print("WORLD_RENDER_FACADE PASS delegation=3 runtime_scenes=2 tools=3")
quit(0)
func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void:
var test_root := Node.new()
var loader := StreamingWorldLoaderDouble.new()
loader.name = "StreamingWorldLoaderDouble"
var source := Node3D.new()
source.name = "FocusSource"
source.position = Vector3(10.0, 20.0, 30.0)
var facade = WORLD_RENDER_FACADE_SCRIPT.new()
facade.name = "WorldRenderFacade"
facade.streaming_world_loader_path = NodePath("../StreamingWorldLoaderDouble")
facade.streaming_focus_source_path = NodePath("../FocusSource")
test_root.add_child(loader)
test_root.add_child(source)
test_root.add_child(facade)
get_root().add_child(test_root)
await process_frame
_expect_true(loader.current_focus != null, "ready captures explicit source", failures)
if loader.current_focus != null:
_expect_near(loader.current_focus.world_position.x_units, 10.0, "source X", failures)
_expect_near(loader.current_focus.world_position.y_units, 20.0, "source Y", failures)
_expect_near(loader.current_focus.world_position.z_units, 30.0, "source Z", failures)
var typed_focus = STREAMING_FOCUS_SCRIPT.new(GODOT_WORLD_POSITION_SCRIPT.new(40.0, 50.0, 60.0))
facade.streaming_focus_source_path = NodePath()
facade.set_streaming_focus(typed_focus)
_expect_true(facade.refresh_streaming_focus(true), "typed focus refresh delegates", failures)
_expect_true(loader.current_focus == typed_focus, "typed focus reference delegates", failures)
var snapshot: Dictionary = facade.renderer_metrics_snapshot()
snapshot["tiles"] = 999
(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)
test_root.queue_free()
func _verify_repository_wiring(failures: Array[String]) -> void:
for scene_path in RUNTIME_SCENE_PATHS:
var scene_source := _read_text(scene_path, failures)
_expect_true(scene_source.contains('path="res://src/render/world_render_facade.gd"'), "%s loads facade" % scene_path, failures)
_expect_true(scene_source.contains('[node name="WorldRenderFacade" type="Node" parent="."]'), "%s owns facade node" % scene_path, failures)
_expect_true(scene_source.contains('streaming_focus_source_path = NodePath("../ThirdPersonPlayer")'), "%s facade uses player focus" % scene_path, failures)
for tool_path in TOOL_PATHS:
var tool_source := _read_text(tool_path, failures)
_expect_true(tool_source.contains('get_node_or_null("WorldRenderFacade")'), "%s resolves facade" % tool_path, failures)
_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)
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_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://baoiewiesd02u