From 7815385b3b593393b64ded382ebb5165598abc99 Mon Sep 17 00:00:00 2001 From: sindoring Date: Mon, 13 Jul 2026 15:41:16 +0400 Subject: [PATCH] feat(M01): add explicit streaming focus Work-Package: M01-RND-STREAMING-FOCUS-001 Agent: sindo-main-codex --- RENDER.md | 13 +++ docs/modules/world-renderer.md | 57 +++++++--- src/domain/streaming/streaming_focus.gd | 17 +++ src/domain/streaming/streaming_focus.gd.uid | 1 + .../streaming/eastern_kingdoms_streaming.tscn | 1 + src/scenes/streaming/kalimdor_streaming.tscn | 1 + .../streaming/streaming_world_loader.gd | 104 +++++++++++------- src/tools/capture_render_checkpoints.gd | 6 +- src/tools/probe_render_camera_occluders.gd | 6 +- src/tools/probe_render_terrain_height.gd | 6 +- src/tools/verify_streaming_focus.gd | 101 +++++++++++++++++ src/tools/verify_streaming_focus.gd.uid | 1 + 12 files changed, 253 insertions(+), 61 deletions(-) create mode 100644 src/domain/streaming/streaming_focus.gd create mode 100644 src/domain/streaming/streaming_focus.gd.uid create mode 100644 src/tools/verify_streaming_focus.gd create mode 100644 src/tools/verify_streaming_focus.gd.uid diff --git a/RENDER.md b/RENDER.md index fc31a78..a4e0cdc 100644 --- a/RENDER.md +++ b/RENDER.md @@ -830,6 +830,19 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - A focused 8-second GUI checkpoint and a seven-checkpoint single-pass GUI run finish without Node, renderer resource or RID leak diagnostics. A timing-dependent anonymous zero-reference `RefCounted` ObjectDB warning can remain at engine teardown; it is tracked separately and is not suppressed. - This is shutdown-only ownership behavior and does not alter checkpoint rendering, cache formats or the `Blizzlike335` compatibility profile. +## 2026-07-13 Explicit Streaming Focus + +- `StreamingWorldLoader` consumes an immutable `StreamingFocus` containing a + typed `GodotWorldPosition`; runtime streaming no longer discovers the active + `Camera3D` from the viewport or child nodes. +- Eastern Kingdoms and Kalimdor runtime scenes explicitly use the player as the + streaming source. Checkpoint capture and terrain/occluder probes explicitly + use their dedicated camera through the same public refresh path. +- The editor viewport camera remains behind a dedicated editor-only adapter. + `camera_path` is retained solely for optional automatic overview positioning. +- Streaming radii, LOD decisions, cache formats and renderer quality profiles + are unchanged by this migration. + ## 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 fe26d89..153d7b9 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 active; декомпозиция M01–M03 | +| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; декомпозиция M02–M03 | | Owners | Renderer workstream / milestone integrator | -| Last verified | M00 visual-diff worktree, 2026-07-11 | +| Last verified | Worktree `work/sindo-main-codex/m01-streaming-focus`, 2026-07-13 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -26,7 +26,11 @@ ```mermaid flowchart LR - Focus[Camera/player/editor focus] --> Loader[StreamingWorldLoader] + Player[Player or spectator Node3D] --> Adapter[Explicit source adapter] + Editor[Editor viewport adapter] --> Focus[StreamingFocus] + Capture[Capture tool] --> Adapter + Adapter --> Focus + Focus --> Loader[StreamingWorldLoader] Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders] Cache[Baked terrain/M2/WMO caches] --> Loader Native --> Parsed[Parsed tile/model data] @@ -60,7 +64,11 @@ Forbidden dependencies: |---|---|---|---|---| | `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 | -| `camera_path` | Exported property | Current streaming focus compatibility path | Main thread, scene lifetime | Missing camera prevents normal focus update | +| `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 | +| `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 | @@ -71,7 +79,9 @@ Forbidden dependencies: | Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | |---|---|---|---|---|---| -| Input | Map/focus/configuration | Scene/app/editor | `StreamingWorldLoader` | Caller config, copied/read | Main thread/session | +| 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 | 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 | | Output | Desired tile/detail operations | Streaming planner inside loader | Finalize queues | Loader-owned | Cross-frame | @@ -90,7 +100,12 @@ Side effects: ```mermaid flowchart TD - F[Streaming focus] --> T[Compute desired ADT tiles] + P[Player/spectator Node3D] --> A[Explicit scene adapter] + C[Capture camera] --> A + E[Editor viewport] --> EA[Editor adapter] + A --> F[StreamingFocus] + EA --> F + F --> T[Compute desired ADT tiles] WDT[WDT tile catalog] --> T T --> Q[Tile load queue] Q --> P[Worker parse/cache load] @@ -113,13 +128,14 @@ flowchart TD ```mermaid stateDiagram-v2 [*] --> Initializing - Initializing --> Ready: WDT/catalog/config ready + Initializing --> WaitingForFocus: WDT/catalog/config ready Initializing --> Degraded: missing loaders/data/cache - Ready --> Streaming: focus update + WaitingForFocus --> Streaming: valid StreamingFocus Streaming --> Streaming: load/finalize/evict ticks + Streaming --> WaitingForFocus: world reset without replacement focus Streaming --> SwitchingMap: map/focus reset SwitchingMap --> Initializing - Ready --> ShuttingDown + WaitingForFocus --> ShuttingDown Degraded --> ShuttingDown Streaming --> ShuttingDown ShuttingDown --> [*] @@ -129,12 +145,14 @@ stateDiagram-v2 ```mermaid sequenceDiagram - participant Focus as Camera/Player/Editor + participant Source as Player/Editor/Capture source + participant Focus as StreamingFocus adapter participant Stream as StreamingWorldLoader participant Worker as Worker task participant Budget as Main-thread budget participant Render as SceneTree/RenderingServer - Focus->>Stream: focus position changed + Source->>Focus: sample explicit Godot position + Focus->>Stream: set/refresh typed focus Stream->>Stream: desired tiles and priorities Stream->>Worker: parse/load tile and detail data Worker-->>Stream: immutable result @@ -150,6 +168,8 @@ sequenceDiagram ## Ownership, threading and resources - `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope. +- 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. - Parsed/grouped results передаются обратно через guarded result queues. - Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets. @@ -163,6 +183,8 @@ sequenceDiagram | Failure | Detection | Behavior | Diagnostic | Recovery | |---|---|---|---|---| | 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` | +| 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 | | Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path | @@ -184,6 +206,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m ## Persistence, cache and migration +- `StreamingFocus` is runtime-only and is not serialized. Scene migration adds + `streaming_focus_source_path`; existing `camera_path` remains valid only for + optional automatic overview positioning. - Renderer reads caches under `data/cache`; caches are not committed. - Format changes require `FORMAT_VERSION`/required-version bump and rebuild instructions. - Atomic cache publication and unified manifest are target-state work from FND/M03. @@ -198,7 +223,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m ## Verification -- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan. +- Unit/contract tests: streaming-focus contract/wiring, material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan. - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints. - Fidelity evidence: семь локальных build 12340 reference JPG покрывают terrain/ADT/M2/WMO/liquid/sky и реальный `GryphonRoost01`; automated cold/warm metrics имеют `compared=14`, `missing=0`. Sky использует приблизительно paired camera, а animated-M2 остаётся asset-paired/placement-unpaired из-за synthetic probe. - Performance budgets: M00 report records cold/warm p95 and max hitch; no final acceptance threshold yet. @@ -207,7 +232,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m ## Extension points - Target `WorldRenderFacade` for runtime/editor/capture callers. -- Target `StreamingFocus` independent of Camera3D. +- Extend `StreamingFocus` with measured direction/velocity needs when the pure planner is extracted. - Extracted planner, budget scheduler, terrain, M2, WMO, liquid, sky and character services. - Separate Blizzlike/Enhanced material and quality profiles. @@ -221,12 +246,14 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity | | Sky/light | Partial | DBC controller/logs | Zone transition paired validation | | 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 | | Stable renderer facade | Planned | M03 | Current caller uses monolithic Node API | ## Known gaps and risks - Monolithic streamer mixes planning, jobs, caches and presentation. -- Direct camera path remains until M01/M03. +- `camera_path` remains only for optional automatic overview positioning; it no + longer drives runtime streaming or discovers the active viewport camera. - Original-client paired fidelity evidence incomplete. - Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC. - Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units. @@ -247,6 +274,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | Path | Responsibility | |---|---| | `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 | | `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/domain/streaming/streaming_focus.gd b/src/domain/streaming/streaming_focus.gd new file mode 100644 index 0000000..f895b30 --- /dev/null +++ b/src/domain/streaming/streaming_focus.gd @@ -0,0 +1,17 @@ +class_name StreamingFocus +extends RefCounted + +## Immutable world-streaming point independent of cameras and the scene tree. +## Renderer adapters may obtain this position from a player, spectator, editor +## viewport or capture tool, but streaming consumers receive only this value. + +var world_position: GodotWorldPosition: + get: + return _world_position + +var _world_position: GodotWorldPosition + + +## Creates a focus at an explicit position in the Godot renderer basis. +func _init(world_position_value: GodotWorldPosition) -> void: + _world_position = world_position_value diff --git a/src/domain/streaming/streaming_focus.gd.uid b/src/domain/streaming/streaming_focus.gd.uid new file mode 100644 index 0000000..0b8ac5a --- /dev/null +++ b/src/domain/streaming/streaming_focus.gd.uid @@ -0,0 +1 @@ +uid://ehv78jvge0q2 diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index 78f214b..814b3df 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -53,6 +53,7 @@ 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 diff --git a/src/scenes/streaming/kalimdor_streaming.tscn b/src/scenes/streaming/kalimdor_streaming.tscn index e37635e..2480e73 100644 --- a/src/scenes/streaming/kalimdor_streaming.tscn +++ b/src/scenes/streaming/kalimdor_streaming.tscn @@ -54,6 +54,7 @@ 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 diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 6cdd77a..2dbe1c6 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -1,5 +1,5 @@ @tool -## Streams Azeroth terrain around the active camera with chunk-based LODs. +## Streams Azeroth terrain around an explicit [StreamingFocus] with chunk-based LODs. extends Node3D const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd") @@ -12,6 +12,8 @@ const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_buil const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd") const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") +const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd") +const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 @@ -29,6 +31,9 @@ const QUALITY_HIGH := "High" @export var extracted_dir: String = "res://data/extracted" @export var map_name: String = "Azeroth" +## Optional scene adapter source. Any Node3D may provide the runtime focus. +@export var streaming_focus_source_path: NodePath +## Camera used only by the optional automatic overview positioning feature. @export var camera_path: NodePath @export_enum("Custom", "Performance", "Balanced", "High") var quality_preset: String = QUALITY_CUSTOM @@ -213,6 +218,8 @@ var _tile_min := Vector2i(63, 63) var _tile_max := Vector2i(0, 0) var _camera_initialized := false var _editor_signature := "" +var _streaming_focus: StreamingFocus +var _missing_focus_source_reported := false var _last_focus_pos := Vector3.ZERO var _dbg_chunks_created := 0 var _dbg_chunks_null := 0 @@ -293,6 +300,8 @@ func _ready() -> void: _tick_editor_streaming() elif auto_position_camera: _position_camera_over_world() + if not Engine.is_editor_hint(): + _capture_streaming_focus_from_source() set_process(true) call_deferred("_refresh_streaming_targets_after_ready") @@ -364,7 +373,7 @@ func _process(delta: float) -> void: if Engine.is_editor_hint(): _tick_editor_streaming() else: - _refresh_streaming_targets(false) + refresh_streaming_focus(false) did_refresh = true _profile_section(timings, "refresh", section_start, profile_enabled) _log_hitch_profile(profile_start, timings, did_refresh, profile_enabled) @@ -590,10 +599,11 @@ func _tick_editor_streaming() -> void: var focus_world := _tile_center_to_world(editor_preview_center_x, editor_preview_center_y) if editor_follow_view_camera: - var editor_camera := _get_stream_camera() + var editor_camera := _get_editor_view_camera() if editor_camera: focus_world += editor_camera.global_position - _refresh_editor_streaming_targets_at(focus_world, false) + _set_streaming_focus_from_vector3(focus_world) + _refresh_editor_streaming_targets_at(_streaming_focus_to_vector3(), false) func _scan_available_tiles() -> void: @@ -681,24 +691,28 @@ func _load_tiles_from_directory() -> void: _tile_max.y = max(_tile_max.y, ty) -func _refresh_streaming_targets(force: bool) -> void: - var camera := _get_stream_camera() - if camera == null: - return +## Replaces the current streaming point without requiring a Node or Camera3D. +func set_streaming_focus(streaming_focus: StreamingFocus) -> void: + _streaming_focus = streaming_focus + + +## Samples the configured scene source, then refreshes streaming from the typed focus. +## Returns false when no valid focus is available; existing streamed content is retained. +func refresh_streaming_focus(force: bool = false) -> bool: + _capture_streaming_focus_from_source() + if _streaming_focus == null or _streaming_focus.world_position == null: + return false _terrain_root.position = Vector3.ZERO - _refresh_streaming_targets_at(camera.global_position, force) + _refresh_streaming_targets_at(_streaming_focus_to_vector3(), force) + return true func _refresh_streaming_targets_after_ready() -> void: - var camera := _get_stream_camera() - if camera == null: - return - _terrain_root.position = Vector3.ZERO _has_refresh_focus = false - _refresh_streaming_targets_at(camera.global_position, false) + refresh_streaming_focus(false) -## Core streaming update — works for both game (camera pos) and editor (preview center pos). +## Core streaming update for runtime and editor focus positions. func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> void: if not force and not _should_refresh_focus(focus_pos): return @@ -3048,32 +3062,46 @@ func _is_tile_queued(key: String) -> bool: return false -func _get_stream_camera() -> Camera3D: - if Engine.is_editor_hint(): - # The 3D editor viewport camera lives in EditorInterface, not get_viewport(). - # get_viewport().get_camera_3d() from a @tool script returns whichever Camera3D - # belongs to the edited scene (often null), so editor-view movement is invisible - # unless we reach into EditorInterface. - var vp := EditorInterface.get_editor_viewport_3d(0) - if vp != null: - var cam := vp.get_camera_3d() - if cam: - return cam +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("Streaming focus source is missing or is not Node3D: %s" % streaming_focus_source_path) + _missing_focus_source_reported = true + return + _missing_focus_source_reported = false + _set_streaming_focus_from_vector3(focus_source.global_position) - if camera_path != NodePath(): - var from_path := get_node_or_null(camera_path) - if from_path is Camera3D: - return from_path - var viewport_camera := get_viewport().get_camera_3d() - if viewport_camera: - return viewport_camera +func _set_streaming_focus_from_vector3(world_position: Vector3) -> void: + var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new( + world_position.x, + world_position.y, + world_position.z + ) + set_streaming_focus(STREAMING_FOCUS_SCRIPT.new(typed_world_position)) - for child in get_children(): - if child is Camera3D and child.current: - return child - return null +func _streaming_focus_to_vector3() -> Vector3: + var world_position: GodotWorldPosition = _streaming_focus.world_position + return Vector3(world_position.x_units, world_position.y_units, world_position.z_units) + + +func _get_editor_view_camera() -> Camera3D: + # EditorInterface owns this camera. This method is the explicit editor adapter; + # runtime streaming never discovers a viewport camera. + var editor_viewport := EditorInterface.get_editor_viewport_3d(0) + if editor_viewport == null: + return null + return editor_viewport.get_camera_3d() + + +func _get_auto_position_camera() -> Camera3D: + if camera_path == NodePath(): + return null + return get_node_or_null(camera_path) as Camera3D func _can_use_baked_tile_cache() -> bool: @@ -5365,7 +5393,7 @@ func _position_camera_over_world() -> void: if _camera_initialized: return - var camera := _get_stream_camera() + var camera := _get_auto_position_camera() if camera == null: return diff --git a/src/tools/capture_render_checkpoints.gd b/src/tools/capture_render_checkpoints.gd index b786077..2f1bc6e 100644 --- a/src/tools/capture_render_checkpoints.gd +++ b/src/tools/capture_render_checkpoints.gd @@ -64,7 +64,7 @@ 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("camera_path", NodePath("CheckpointCamera")) + world.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 +126,8 @@ 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_targets_at"): - world.call("_refresh_streaming_targets_at", camera.global_position, true) + if world.has_method("refresh_streaming_focus"): + world.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" % [ diff --git a/src/tools/probe_render_camera_occluders.gd b/src/tools/probe_render_camera_occluders.gd index 8261ac5..b3a9022 100644 --- a/src/tools/probe_render_camera_occluders.gd +++ b/src/tools/probe_render_camera_occluders.gd @@ -28,7 +28,7 @@ func _run_async() -> void: camera.name = "OccluderProbeCamera" camera.current = true world.add_child(camera) - world.set("camera_path", NodePath(camera.name)) + world.set("streaming_focus_source_path", NodePath(camera.name)) world.set("debug_streaming", false) get_root().add_child(world) await process_frame @@ -47,8 +47,8 @@ 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_targets_at"): - world.call("_refresh_streaming_targets_at", camera_position, true) + if world.has_method("refresh_streaming_focus"): + world.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 6a0502c..7b39599 100644 --- a/src/tools/probe_render_terrain_height.gd +++ b/src/tools/probe_render_terrain_height.gd @@ -36,7 +36,7 @@ func _run_async() -> void: var camera := Camera3D.new() camera.current = true world.add_child(camera) - world.set("camera_path", NodePath(camera.name)) + world.set("streaming_focus_source_path", NodePath(camera.name)) world.set("debug_streaming", false) get_root().add_child(world) await process_frame @@ -53,8 +53,8 @@ func _run_async() -> void: continue var camera_position := _vector3(checkpoint.get("camera", [])) camera.global_position = camera_position - if world.has_method("_refresh_streaming_targets_at"): - world.call("_refresh_streaming_targets_at", camera_position, true) + if world.has_method("refresh_streaming_focus"): + world.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 new file mode 100644 index 0000000..c450bdd --- /dev/null +++ b/src/tools/verify_streaming_focus.gd @@ -0,0 +1,101 @@ +extends SceneTree + +## Headless M01 contract and wiring regression for camera-independent streaming. + +const StreamingFocusScript = preload("res://src/domain/streaming/streaming_focus.gd") +const GodotWorldPositionScript = preload("res://src/domain/coordinates/godot_world_position.gd") + +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" +const RUNTIME_SCENE_PATHS: Array[String] = [ + "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn", + "res://src/scenes/streaming/kalimdor_streaming.tscn", +] +const CAPTURE_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", +] + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_scene_free_focus_value(failures) + _verify_loader_boundary(failures) + _verify_runtime_scene_wiring(failures) + _verify_capture_tool_wiring(failures) + + if not failures.is_empty(): + for failure in failures: + push_error("STREAMING_FOCUS: %s" % failure) + quit(1) + return + + print("STREAMING_FOCUS PASS contract=1 runtime_scenes=2 capture_tools=3") + quit(0) + + +func _verify_scene_free_focus_value(failures: Array[String]) -> void: + var position = GodotWorldPositionScript.new(17199.159666667, 83.5312, 26016.616666667) + var focus = StreamingFocusScript.new(position) + _expect_true(focus.world_position == position, "focus retains typed position", failures) + _expect_near(focus.world_position.x_units, 17199.159666667, "focus X", failures) + _expect_near(focus.world_position.y_units, 83.5312, "focus Y", failures) + _expect_near(focus.world_position.z_units, 26016.616666667, "focus Z", failures) + + var focus_source := _read_text("res://src/domain/streaming/streaming_focus.gd", failures) + _expect_true(not focus_source.contains("extends Node"), "focus does not inherit Node", failures) + _expect_true(not focus_source.contains("extends Resource"), "focus does not inherit Resource", failures) + _expect_true(not focus_source.contains("Vector3"), "focus does not expose Vector3", failures) + + +func _verify_loader_boundary(failures: Array[String]) -> void: + var loader_source := _read_text(LOADER_PATH, failures) + for required_text in [ + "@export var streaming_focus_source_path: NodePath", + "func set_streaming_focus(streaming_focus: StreamingFocus) -> void:", + "func refresh_streaming_focus(force: bool = false) -> bool:", + ]: + _expect_true(loader_source.contains(required_text), "loader contains %s" % required_text, failures) + + for forbidden_text in [ + "get_viewport().get_camera_3d()", + "child is Camera3D and child.current", + "func _get_stream_camera()", + ]: + _expect_true(not loader_source.contains(forbidden_text), "loader omits %s" % forbidden_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")'), + "%s uses player focus" % scene_path, + failures + ) + + +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(not tool_source.contains('world.call("_refresh_streaming_targets_at"'), "%s avoids private refresh" % 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 absf(actual_value - expected_value) > 0.000001: + failures.append("%s expected %.9f, got %.9f" % [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_streaming_focus.gd.uid b/src/tools/verify_streaming_focus.gd.uid new file mode 100644 index 0000000..de63871 --- /dev/null +++ b/src/tools/verify_streaming_focus.gd.uid @@ -0,0 +1 @@ +uid://b7ayw5a0bn1lq