From 2ee647a220ba936b02236a3305e896bc9d6ecda7 Mon Sep 17 00:00:00 2001 From: sindoring Date: Thu, 16 Jul 2026 01:00:19 +0400 Subject: [PATCH] feat(M03): add environment snapshot facade --- RENDER.md | 14 ++++ docs/modules/world-renderer.md | 47 +++++++++--- .../environment/world_environment_snapshot.gd | 40 +++++++++++ .../world_environment_snapshot.gd.uid | 1 + src/render/world_render_facade.gd | 39 ++++++++++ src/scenes/sky/wow_sky_controller.gd | 17 +++++ .../streaming/eastern_kingdoms_streaming.tscn | 1 + src/scenes/streaming/kalimdor_streaming.tscn | 1 + src/tools/capture_render_checkpoints.gd | 13 ++-- .../verify_world_environment_snapshot.gd | 43 +++++++++++ .../verify_world_environment_snapshot.gd.uid | 1 + src/tools/verify_world_render_facade.gd | 71 ++++++++++++++++++- 12 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 src/render/environment/world_environment_snapshot.gd create mode 100644 src/render/environment/world_environment_snapshot.gd.uid create mode 100644 src/tools/verify_world_environment_snapshot.gd create mode 100644 src/tools/verify_world_environment_snapshot.gd.uid diff --git a/RENDER.md b/RENDER.md index da148c1..3e053c4 100644 --- a/RENDER.md +++ b/RENDER.md @@ -950,6 +950,20 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - The ray height, 3x3 tile search and `2/5/10/20/40`-unit nearby fallback are preserved. No terrain geometry, cache, streaming or visual behavior changed. +## 2026-07-16 World Environment Snapshot Facade + +- `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value, + normalized to `[0, 24)` hours; invalid non-finite input is explicit. +- `WorldRenderFacade.apply_environment_snapshot()` delegates the snapshot to the + scene-owned `WowSkyController`. The facade owns no `Environment`, sun, skybox, + shader global or DBC sampling state. +- Checkpoint capture now submits its manifest time through the facade instead of + locating `WowSkyController` and mutating `use_system_time`, `time_speed` and + `fixed_time_hours` directly. +- Applying a snapshot performs the same three fixed-clock assignments as the old + capture path. Light/Area/Skybox DBC lookup, interpolation, fog, sun and shader + behavior remain unchanged; weather and indoor environment state are follow-up work. + ## Practical Rule For Future Work If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index d35977e..3247be0 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`; `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` | +| 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`; `M03-RND-FACADE-ENVIRONMENT-001` | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-render-budget-scheduler`, 2026-07-16 | +| Last verified | Worktree `work/sindo-main-codex/m03-facade-environment`, 2026-07-16 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -44,7 +44,10 @@ flowchart LR Parsed --> Loader Loader --> Scene[SceneTree nodes] Loader --> RS[RenderingServer RIDs/MultiMesh] - Sky[DBC sky/light data] --> SkyController[WowSkyController] + EnvironmentState[World time producer] --> EnvironmentSnapshot[WorldEnvironmentSnapshot] + EnvironmentSnapshot --> Facade + Facade --> SkyController[WowSkyController] + Sky[DBC sky/light data] --> SkyController SkyController --> Scene ``` @@ -65,9 +68,10 @@ Forbidden dependencies: ## Public API -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. +Runtime, capture and probe callers use `WorldRenderFacade` for focus, immutable +environment input, metrics and rendered-ground diagnostics. `StreamingWorldLoader` +and `WowSkyController` remain internal scene implementations while a later M03 +package adds entity-visual commands. Gameplay modules and Godot `EditorPlugin` package sources are repository-gated from externally reading/writing loader-private queue, task, cache and tile-state fields. @@ -82,7 +86,10 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `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 | +| `WorldEnvironmentSnapshot.at_time_of_day` | Immutable value factory | Normalizes a finite authoritative time into `[0, 24)` hours | Any thread; caller-held value | Non-finite input creates invalid value with `environment_time_not_finite` | +| `WorldRenderFacade.apply_environment_snapshot` | Public command | Delegates exact world time to the scene-owned DBC sky controller | Main thread; snapshot remains caller-owned | Returns `false` for invalid input or missing/incompatible controller | | `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 | +| `WorldRenderFacade.world_environment_controller_path` | Internal adapter property | Resolves the existing `WowSkyController` 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 | @@ -99,6 +106,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 | `WorldEnvironmentSnapshot` | World-state/capture adapter | `WorldRenderFacade` / `WowSkyController` | Immutable caller-held value; not retained | One main-thread update | | 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 | @@ -147,6 +155,11 @@ flowchart TD B --> Liquid[Liquid attach] Loaded[Loaded terrain Mesh and Transform] --> Ground[Facade ground query] Ground --> GroundSample[RenderedGroundSample / detached diagnostics] + WorldTime[Authoritative/test time] --> EnvironmentSnapshot[WorldEnvironmentSnapshot] + EnvironmentSnapshot --> RF + RF --> SkyController[WowSkyController] + DBC[Light/Area/Skybox DBC] --> SkyController + SkyController --> World Terrain --> World[Godot world] M2 --> World WMO --> World @@ -202,6 +215,9 @@ sequenceDiagram Source->>Facade: sample_ground_height(typed position) Facade->>Stream: sample loaded render terrain Stream-->>Facade: RenderedGroundSample / diagnostic snapshot + Source->>Facade: apply_environment_snapshot(exact time) + Facade->>Render: delegate to WowSkyController + Render->>Render: freeze local clock; retain DBC sampling/apply rules Stream->>Budget: shutdown: cancel permit issuance Stream->>Worker: shutdown: wait for WorkerThreadPool tasks Stream->>Stream: shutdown: finish registered ResourceLoader requests @@ -213,7 +229,13 @@ 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. + streamer, environment controller, focus source, returned metrics, queues, + caches, nodes or RIDs. +- `WorldEnvironmentSnapshot` is an immutable caller-owned scalar value. Neither + facade nor sky controller retains it; the controller copies normalized hours + into its existing fixed-clock fields on the main thread. +- `WowSkyController` continues to own DBC sampling state and mutations of its + duplicated Godot `Environment`, sun, skybox node and world shader globals. - 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 @@ -240,6 +262,8 @@ sequenceDiagram | 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 | +| Invalid environment time | Snapshot finite-value validation | Reject update; retain prior sky clock | `environment_time_not_finite` on invalid value | Supply a finite time snapshot | +| Missing environment controller | Facade path/method validation | Reject environment update; other facade APIs remain available | One facade diagnostic until recovery | Fix scene composition path/implementation | | 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 | @@ -320,7 +344,8 @@ 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 | Facade contracts cover focus, metrics and loaded-mesh ground query | Environment and entity visuals remain | +| Stable renderer facade | Partial | Facade contracts cover focus, environment time, metrics and loaded-mesh ground query | Entity visuals remain | +| Environment snapshot input | Implemented boundary / Partial fidelity | Immutable time contract, facade/controller delegation and checkpoint migration | Weather/indoor state and paired zone-transition fidelity remain | | Pure streaming target planner | Implemented | `M03-RND-STREAMING-PLANNER-001` asset-free contract/performance regression | Queue scheduling and loaded-tile LOD application remain in streamer | | Bounded render budget scheduler | Implemented | `M03-RND-SCHEDULER-001` asset-free permit/cancellation/performance regression | Queue storage, worker concurrency and operation execution remain in streamer/services | | Gameplay/editor/tool internal-access boundary | Implemented | Access gate scans gameplay, EditorPlugin packages and terrain probe against actual private streamer declarations | Add future renderer diagnostic tools explicitly to the protected list | @@ -353,7 +378,8 @@ 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, detached metrics and rendered-ground queries | +| `src/render/world_render_facade.gd` | Stable caller boundary for typed focus/environment, detached metrics and rendered-ground queries | +| `src/render/environment/world_environment_snapshot.gd` | Immutable normalized world time input with explicit invalid state | | `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 | @@ -362,7 +388,8 @@ 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` | Focus/metrics/ground delegation, snapshot isolation and consumer wiring regression | +| `src/tools/verify_world_render_facade.gd` | Focus/environment/metrics/ground delegation, snapshot isolation and consumer wiring regression | +| `src/tools/verify_world_environment_snapshot.gd` | Cold-start environment value normalization and invalid-input regression | | `src/tools/verify_rendered_ground_sample.gd` | Cold-start renderer ground result invariant 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 | diff --git a/src/render/environment/world_environment_snapshot.gd b/src/render/environment/world_environment_snapshot.gd new file mode 100644 index 0000000..4e616f5 --- /dev/null +++ b/src/render/environment/world_environment_snapshot.gd @@ -0,0 +1,40 @@ +class_name WorldEnvironmentSnapshot +extends RefCounted + +## Immutable renderer input describing the authoritative time within one world day. +## DBC light, area, fog and skybox selection remain owned by the sky controller. + +const HOURS_PER_DAY := 24.0 +const SCRIPT_PATH := "res://src/render/environment/world_environment_snapshot.gd" + +var is_valid: bool: + get: + return _is_valid + +var time_of_day_hours: float: + get: + return _time_of_day_hours + +var failure_code: StringName: + get: + return _failure_code + +var _is_valid: bool +var _time_of_day_hours: float +var _failure_code: StringName + + +## Creates a snapshot and normalizes finite input into the half-open [0, 24) day. +## Non-finite input produces an explicit invalid value instead of renderer state. +static func at_time_of_day(time_of_day_hours_value: float) -> RefCounted: + return load(SCRIPT_PATH).new(time_of_day_hours_value) + + +func _init(time_of_day_hours_value: float) -> void: + _is_valid = is_finite(time_of_day_hours_value) + _time_of_day_hours = ( + fposmod(time_of_day_hours_value, HOURS_PER_DAY) + if _is_valid + else 0.0 + ) + _failure_code = &"" if _is_valid else &"environment_time_not_finite" diff --git a/src/render/environment/world_environment_snapshot.gd.uid b/src/render/environment/world_environment_snapshot.gd.uid new file mode 100644 index 0000000..bf31001 --- /dev/null +++ b/src/render/environment/world_environment_snapshot.gd.uid @@ -0,0 +1 @@ +uid://dass4mi3ex2fu diff --git a/src/render/world_render_facade.gd b/src/render/world_render_facade.gd index fd3f343..70c4f09 100644 --- a/src/render/world_render_facade.gd +++ b/src/render/world_render_facade.gd @@ -8,17 +8,24 @@ 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") +const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.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("..") +## Scene-owned outdoor environment implementation resolved relative to this node. +## The facade delegates snapshots but owns neither the controller nor Environment. +@export var world_environment_controller_path: NodePath = NodePath("../WowSkyController") + ## 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 _world_environment_controller: Node var _missing_loader_reported := false +var _missing_environment_controller_reported := false var _missing_focus_source_reported := false @@ -94,6 +101,21 @@ func renderer_ground_query_snapshot(godot_world_position: GodotWorldPosition) -> return (snapshot_variant as Dictionary).duplicate(true) +## Applies an immutable environment snapshot through the renderer-owned sky path. +## Returns false for an invalid value or unavailable/incompatible controller. +func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool: + if ( + world_environment_snapshot == null + or world_environment_snapshot.get_script() != WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT + or not bool(world_environment_snapshot.get("is_valid")) + ): + return false + var controller := _resolve_world_environment_controller() + if controller == null: + return false + return bool(controller.call("apply_environment_snapshot", world_environment_snapshot)) + + func _capture_streaming_focus_from_source() -> void: if streaming_focus_source_path == NodePath(): return @@ -138,3 +160,20 @@ func _resolve_streaming_world_loader() -> Node: _streaming_world_loader = candidate _missing_loader_reported = false return _streaming_world_loader + + +func _resolve_world_environment_controller() -> Node: + if is_instance_valid(_world_environment_controller): + return _world_environment_controller + var candidate := get_node_or_null(world_environment_controller_path) + if candidate == null or not candidate.has_method(&"apply_environment_snapshot"): + if not _missing_environment_controller_reported: + push_error( + "WorldRenderFacade cannot resolve a compatible environment controller at %s" + % world_environment_controller_path + ) + _missing_environment_controller_reported = true + return null + _world_environment_controller = candidate + _missing_environment_controller_reported = false + return _world_environment_controller diff --git a/src/scenes/sky/wow_sky_controller.gd b/src/scenes/sky/wow_sky_controller.gd index d844117..2b67abd 100644 --- a/src/scenes/sky/wow_sky_controller.gd +++ b/src/scenes/sky/wow_sky_controller.gd @@ -4,6 +4,7 @@ extends Node const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") 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 WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd") const LIGHT_COORD_SCALE := 36.0 const HALF_MINUTES_PER_DAY := 2880 @@ -133,6 +134,22 @@ func _process(delta: float) -> void: _update_skybox_transform() +## Accepts an authoritative time snapshot without moving DBC visual selection out +## of this controller. Existing fixed-clock behavior is preserved and frozen until +## another snapshot or explicit local clock configuration is supplied. +func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool: + if ( + world_environment_snapshot == null + or world_environment_snapshot.get_script() != WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT + or not bool(world_environment_snapshot.get("is_valid")) + ): + return false + use_system_time = false + time_speed = 0.0 + fixed_time_hours = float(world_environment_snapshot.get("time_of_day_hours")) + return true + + func _resolve_nodes() -> void: if not world_environment_path.is_empty(): _world_environment = get_node_or_null(world_environment_path) as WorldEnvironment diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index b256b31..452ba52 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -98,6 +98,7 @@ hitch_profiler_enabled = true script = ExtResource("7_render_facade") streaming_world_loader_path = NodePath("..") streaming_focus_source_path = NodePath("../ThirdPersonPlayer") +world_environment_controller_path = NodePath("../WowSkyController") [node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400) diff --git a/src/scenes/streaming/kalimdor_streaming.tscn b/src/scenes/streaming/kalimdor_streaming.tscn index fd59f92..eca6801 100644 --- a/src/scenes/streaming/kalimdor_streaming.tscn +++ b/src/scenes/streaming/kalimdor_streaming.tscn @@ -99,6 +99,7 @@ hitch_profiler_enabled = true script = ExtResource("7_render_facade") streaming_world_loader_path = NodePath("..") streaming_focus_source_path = NodePath("../ThirdPersonPlayer") +world_environment_controller_path = NodePath("../WowSkyController") [node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400) diff --git a/src/tools/capture_render_checkpoints.gd b/src/tools/capture_render_checkpoints.gd index f7e3f86..8f9e6ec 100644 --- a/src/tools/capture_render_checkpoints.gd +++ b/src/tools/capture_render_checkpoints.gd @@ -2,6 +2,7 @@ extends SceneTree const DEFAULT_MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" const M2_NATIVE_ANIMATED_BUILDER := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd") +const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd") const SHUTDOWN_DRAIN_FRAMES := 2 @@ -130,7 +131,7 @@ func _capture_async() -> void: camera.make_current() 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))) + _apply_environment_time(render_facade, float(checkpoint.get("time_hours", 13.0))) render_facade.call("refresh_streaming_focus", true) if dry_run: @@ -308,12 +309,10 @@ func _create_diagnostic_spawn_marker(checkpoint: Dictionary) -> Node3D: return marker_root -func _set_sky_time(world: Node, time_hours: float) -> void: - var sky := world.get_node_or_null("WowSkyController") - if sky != null: - sky.set("use_system_time", false) - sky.set("time_speed", 0.0) - sky.set("fixed_time_hours", time_hours) +func _apply_environment_time(render_facade: Node, time_hours: float) -> void: + var environment_snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(time_hours) + if not bool(render_facade.call("apply_environment_snapshot", environment_snapshot)): + push_error("Render checkpoint could not apply environment time %.3f" % time_hours) func _environment_metadata() -> Dictionary: diff --git a/src/tools/verify_world_environment_snapshot.gd b/src/tools/verify_world_environment_snapshot.gd new file mode 100644 index 0000000..09fccd3 --- /dev/null +++ b/src/tools/verify_world_environment_snapshot.gd @@ -0,0 +1,43 @@ +extends SceneTree + +## Cold-start contract for immutable renderer environment input. + +const SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd") + + +func _initialize() -> void: + var failures: Array[String] = [] + var afternoon: RefCounted = SNAPSHOT_SCRIPT.at_time_of_day(13.5) + _expect_true(bool(afternoon.get("is_valid")), "finite snapshot", failures) + _expect_near(float(afternoon.get("time_of_day_hours")), 13.5, "finite time", failures) + + var wrapped: RefCounted = SNAPSHOT_SCRIPT.at_time_of_day(25.25) + _expect_near(float(wrapped.get("time_of_day_hours")), 1.25, "positive day wrap", failures) + var negative: RefCounted = SNAPSHOT_SCRIPT.at_time_of_day(-1.0) + _expect_near(float(negative.get("time_of_day_hours")), 23.0, "negative day wrap", failures) + + var invalid: RefCounted = SNAPSHOT_SCRIPT.at_time_of_day(INF) + _expect_true(not bool(invalid.get("is_valid")), "non-finite invalid", failures) + _expect_true( + StringName(invalid.get("failure_code")) == &"environment_time_not_finite", + "non-finite failure code", + failures + ) + + if not failures.is_empty(): + for failure in failures: + push_error("WORLD_ENVIRONMENT_SNAPSHOT: %s" % failure) + quit(1) + return + print("WORLD_ENVIRONMENT_SNAPSHOT PASS contract=5") + quit(0) + + +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_environment_snapshot.gd.uid b/src/tools/verify_world_environment_snapshot.gd.uid new file mode 100644 index 0000000..26768c2 --- /dev/null +++ b/src/tools/verify_world_environment_snapshot.gd.uid @@ -0,0 +1 @@ +uid://e85kt58s0ca4 diff --git a/src/tools/verify_world_render_facade.gd b/src/tools/verify_world_render_facade.gd index 66571e6..b9754ef 100644 --- a/src/tools/verify_world_render_facade.gd +++ b/src/tools/verify_world_render_facade.gd @@ -6,6 +6,8 @@ const WORLD_RENDER_FACADE_SCRIPT := preload("res://src/render/world_render_facad 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 WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd") +const WOW_SKY_CONTROLLER_SCRIPT := preload("res://src/scenes/sky/wow_sky_controller.gd") const RUNTIME_SCENE_PATHS: Array[String] = [ "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn", @@ -47,9 +49,18 @@ class StreamingWorldLoaderDouble extends Node: return ground_snapshot +class WorldEnvironmentControllerDouble extends Node: + var applied_snapshot: RefCounted + + func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool: + applied_snapshot = world_environment_snapshot + return true + + func _initialize() -> void: var failures: Array[String] = [] _verify_rendered_ground_sample_contract(failures) + _verify_sky_controller_application(failures) await _verify_delegation_and_snapshot_isolation(failures) _verify_repository_wiring(failures) @@ -59,7 +70,7 @@ func _initialize() -> void: quit(1) return - print("WORLD_RENDER_FACADE PASS delegation=5 ground_contract=2 runtime_scenes=2 tools=3") + print("WORLD_RENDER_FACADE PASS delegation=6 ground_contract=2 environment_contract=7 runtime_scenes=2 tools=3") quit(0) @@ -79,6 +90,23 @@ func _verify_rendered_ground_sample_contract(failures: Array[String]) -> void: ) +func _verify_sky_controller_application(failures: Array[String]) -> void: + var controller: Node = WOW_SKY_CONTROLLER_SCRIPT.new() + controller.set("use_system_time", true) + controller.set("time_speed", 4.0) + controller.set("fixed_time_hours", 3.0) + var snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(18.75) + _expect_true( + bool(controller.call("apply_environment_snapshot", snapshot)), + "sky controller accepts snapshot", + failures + ) + _expect_true(not bool(controller.get("use_system_time")), "snapshot disables system clock", failures) + _expect_near(float(controller.get("time_speed")), 0.0, "snapshot freezes local clock", failures) + _expect_near(float(controller.get("fixed_time_hours")), 18.75, "snapshot applies exact time", failures) + controller.free() + + func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void: var test_root := Node.new() var loader := StreamingWorldLoaderDouble.new() @@ -86,12 +114,16 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void: var source := Node3D.new() source.name = "FocusSource" source.position = Vector3(10.0, 20.0, 30.0) + var environment_controller := WorldEnvironmentControllerDouble.new() + environment_controller.name = "WorldEnvironmentControllerDouble" var facade = WORLD_RENDER_FACADE_SCRIPT.new() facade.name = "WorldRenderFacade" facade.streaming_world_loader_path = NodePath("../StreamingWorldLoaderDouble") facade.streaming_focus_source_path = NodePath("../FocusSource") + facade.world_environment_controller_path = NodePath("../WorldEnvironmentControllerDouble") test_root.add_child(loader) test_root.add_child(source) + test_root.add_child(environment_controller) test_root.add_child(facade) get_root().add_child(test_root) await process_frame @@ -131,6 +163,24 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void: (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) + + var environment_snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(18.75) + _expect_true( + facade.apply_environment_snapshot(environment_snapshot), + "environment snapshot delegates", + failures + ) + _expect_true( + environment_controller.applied_snapshot == environment_snapshot, + "environment snapshot reference delegates", + failures + ) + var invalid_environment_snapshot: RefCounted = WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT.at_time_of_day(NAN) + _expect_true( + not facade.apply_environment_snapshot(invalid_environment_snapshot), + "invalid environment snapshot rejected", + failures + ) test_root.queue_free() @@ -140,6 +190,7 @@ func _verify_repository_wiring(failures: Array[String]) -> void: _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) + _expect_true(scene_source.contains('world_environment_controller_path = NodePath("../WowSkyController")'), "%s facade uses sky controller" % scene_path, failures) for tool_path in TOOL_PATHS: var tool_source := _read_text(tool_path, failures) @@ -171,6 +222,24 @@ func _verify_repository_wiring(failures: Array[String]) -> void: failures ) + var capture_source := _read_text("res://src/tools/capture_render_checkpoints.gd", failures) + _expect_true( + capture_source.contains('call("apply_environment_snapshot", environment_snapshot)'), + "capture applies environment through facade", + failures + ) + for forbidden_capture_text in [ + 'get_node_or_null("WowSkyController")', + 'sky.set("use_system_time"', + 'sky.set("time_speed"', + 'sky.set("fixed_time_hours"', + ]: + _expect_true( + not capture_source.contains(forbidden_capture_text), + "capture omits direct sky mutation %s" % forbidden_capture_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)",