diff --git a/RENDER.md b/RENDER.md index 02e6d56..e71fbb1 100644 --- a/RENDER.md +++ b/RENDER.md @@ -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. diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index e8cf719..247ea3d 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -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 | diff --git a/src/render/world_render_facade.gd b/src/render/world_render_facade.gd new file mode 100644 index 0000000..a0f9307 --- /dev/null +++ b/src/render/world_render_facade.gd @@ -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 diff --git a/src/render/world_render_facade.gd.uid b/src/render/world_render_facade.gd.uid new file mode 100644 index 0000000..11cb87a --- /dev/null +++ b/src/render/world_render_facade.gd.uid @@ -0,0 +1 @@ +uid://sxn3ai2ic18r diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index a5da80f..b256b31 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -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") diff --git a/src/scenes/streaming/kalimdor_streaming.tscn b/src/scenes/streaming/kalimdor_streaming.tscn index 3a7f685..fd59f92 100644 --- a/src/scenes/streaming/kalimdor_streaming.tscn +++ b/src/scenes/streaming/kalimdor_streaming.tscn @@ -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") diff --git a/src/tools/capture_render_checkpoints.gd b/src/tools/capture_render_checkpoints.gd index 4dae0bc..f7e3f86 100644 --- a/src/tools/capture_render_checkpoints.gd +++ b/src/tools/capture_render_checkpoints.gd @@ -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), diff --git a/src/tools/probe_render_camera_occluders.gd b/src/tools/probe_render_camera_occluders.gd index b3a9022..fc9709c 100644 --- a/src/tools/probe_render_camera_occluders.gd +++ b/src/tools/probe_render_camera_occluders.gd @@ -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) diff --git a/src/tools/probe_render_terrain_height.gd b/src/tools/probe_render_terrain_height.gd index d596a74..dc5b0b5 100644 --- a/src/tools/probe_render_terrain_height.gd +++ b/src/tools/probe_render_terrain_height.gd @@ -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") diff --git a/src/tools/verify_streaming_focus.gd b/src/tools/verify_streaming_focus.gd index c450bdd..525c091 100644 --- a/src/tools/verify_streaming_focus.gd +++ b/src/tools/verify_streaming_focus.gd @@ -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) diff --git a/src/tools/verify_world_render_facade.gd b/src/tools/verify_world_render_facade.gd new file mode 100644 index 0000000..dea34c4 --- /dev/null +++ b/src/tools/verify_world_render_facade.gd @@ -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) diff --git a/src/tools/verify_world_render_facade.gd.uid b/src/tools/verify_world_render_facade.gd.uid new file mode 100644 index 0000000..0ae7355 --- /dev/null +++ b/src/tools/verify_world_render_facade.gd.uid @@ -0,0 +1 @@ +uid://baoiewiesd02u