feat(M01): add explicit streaming focus
Work-Package: M01-RND-STREAMING-FOCUS-001 Agent: sindo-main-codex
This commit is contained in:
@@ -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.
|
- 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.
|
- 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Status | Partial |
|
| 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 |
|
| 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 |
|
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -26,7 +26,11 @@
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
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]
|
Assets[Extracted WDT/ADT/M2/WMO/BLP] --> Native[Native loaders]
|
||||||
Cache[Baked terrain/M2/WMO caches] --> Loader
|
Cache[Baked terrain/M2/WMO caches] --> Loader
|
||||||
Native --> Parsed[Parsed tile/model data]
|
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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| 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 | 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 |
|
| 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 |
|
| Output | Desired tile/detail operations | Streaming planner inside loader | Finalize queues | Loader-owned | Cross-frame |
|
||||||
@@ -90,7 +100,12 @@ Side effects:
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
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
|
WDT[WDT tile catalog] --> T
|
||||||
T --> Q[Tile load queue]
|
T --> Q[Tile load queue]
|
||||||
Q --> P[Worker parse/cache load]
|
Q --> P[Worker parse/cache load]
|
||||||
@@ -113,13 +128,14 @@ flowchart TD
|
|||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
[*] --> Initializing
|
[*] --> Initializing
|
||||||
Initializing --> Ready: WDT/catalog/config ready
|
Initializing --> WaitingForFocus: WDT/catalog/config ready
|
||||||
Initializing --> Degraded: missing loaders/data/cache
|
Initializing --> Degraded: missing loaders/data/cache
|
||||||
Ready --> Streaming: focus update
|
WaitingForFocus --> Streaming: valid StreamingFocus
|
||||||
Streaming --> Streaming: load/finalize/evict ticks
|
Streaming --> Streaming: load/finalize/evict ticks
|
||||||
|
Streaming --> WaitingForFocus: world reset without replacement focus
|
||||||
Streaming --> SwitchingMap: map/focus reset
|
Streaming --> SwitchingMap: map/focus reset
|
||||||
SwitchingMap --> Initializing
|
SwitchingMap --> Initializing
|
||||||
Ready --> ShuttingDown
|
WaitingForFocus --> ShuttingDown
|
||||||
Degraded --> ShuttingDown
|
Degraded --> ShuttingDown
|
||||||
Streaming --> ShuttingDown
|
Streaming --> ShuttingDown
|
||||||
ShuttingDown --> [*]
|
ShuttingDown --> [*]
|
||||||
@@ -129,12 +145,14 @@ stateDiagram-v2
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Focus as Camera/Player/Editor
|
participant Source as Player/Editor/Capture source
|
||||||
|
participant Focus as StreamingFocus adapter
|
||||||
participant Stream as StreamingWorldLoader
|
participant Stream as StreamingWorldLoader
|
||||||
participant Worker as Worker task
|
participant Worker as Worker task
|
||||||
participant Budget as Main-thread budget
|
participant Budget as Main-thread budget
|
||||||
participant Render as SceneTree/RenderingServer
|
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->>Stream: desired tiles and priorities
|
||||||
Stream->>Worker: parse/load tile and detail data
|
Stream->>Worker: parse/load tile and detail data
|
||||||
Worker-->>Stream: immutable result
|
Worker-->>Stream: immutable result
|
||||||
@@ -150,6 +168,8 @@ sequenceDiagram
|
|||||||
## Ownership, threading and resources
|
## Ownership, threading and resources
|
||||||
|
|
||||||
- `StreamingWorldLoader` владеет tile states, queues, task registries, active world instances и render caches session scope.
|
- `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.
|
- Worker tasks не должны менять SceneTree и shared Resource concurrently.
|
||||||
- Parsed/grouped results передаются обратно через guarded result queues.
|
- Parsed/grouped results передаются обратно через guarded result queues.
|
||||||
- Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets.
|
- Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets.
|
||||||
@@ -163,6 +183,8 @@ sequenceDiagram
|
|||||||
| Failure | Detection | Behavior | Diagnostic | Recovery |
|
| Failure | Detection | Behavior | Diagnostic | Recovery |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Missing WDT/ADT | File/catalog check | Tile/map unavailable | Loader warning/error | Fix extraction/profile, reload |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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.
|
- Renderer reads caches under `data/cache`; caches are not committed.
|
||||||
- Format changes require `FORMAT_VERSION`/required-version bump and rebuild instructions.
|
- Format changes require `FORMAT_VERSION`/required-version bump and rebuild instructions.
|
||||||
- Atomic cache publication and unified manifest are target-state work from FND/M03.
|
- 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
|
## 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.
|
- 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.
|
- 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.
|
- 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
|
## Extension points
|
||||||
|
|
||||||
- Target `WorldRenderFacade` for runtime/editor/capture callers.
|
- 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.
|
- Extracted planner, budget scheduler, terrain, M2, WMO, liquid, sky and character services.
|
||||||
- Separate Blizzlike/Enhanced material and quality profiles.
|
- 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 |
|
| Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity |
|
||||||
| Sky/light | Partial | DBC controller/logs | Zone transition paired validation |
|
| Sky/light | Partial | DBC controller/logs | Zone transition paired validation |
|
||||||
| Character presentation | Prototype | Character creator/player experiment | Full customization/equipment states |
|
| 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 |
|
| Stable renderer facade | Planned | M03 | Current caller uses monolithic Node API |
|
||||||
|
|
||||||
## Known gaps and risks
|
## Known gaps and risks
|
||||||
|
|
||||||
- Monolithic streamer mixes planning, jobs, caches and presentation.
|
- 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.
|
- Original-client paired fidelity evidence incomplete.
|
||||||
- Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC.
|
- Первый 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.
|
- 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 |
|
| Path | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/scenes/streaming/streaming_world_loader.gd` | Current runtime orchestration, queues, caches and attachment |
|
| `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/adt_builder.gd` | Terrain/material/liquid construction |
|
||||||
| `addons/mpq_extractor/loaders/m2_builder.gd` | Static M2 mesh/material 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 |
|
| `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Native animated M2 experiment |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ehv78jvge0q2
|
||||||
@@ -53,6 +53,7 @@ fog_depth_end = 5200.0
|
|||||||
|
|
||||||
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
|
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
|
||||||
script = ExtResource("1_stream")
|
script = ExtResource("1_stream")
|
||||||
|
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
|
||||||
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
|
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
|
||||||
quality_preset = "High"
|
quality_preset = "High"
|
||||||
update_interval = 0.1
|
update_interval = 0.1
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ fog_depth_end = 5200.0
|
|||||||
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
|
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
|
||||||
script = ExtResource("1_sisqv")
|
script = ExtResource("1_sisqv")
|
||||||
map_name = "Kalimdor"
|
map_name = "Kalimdor"
|
||||||
|
streaming_focus_source_path = NodePath("ThirdPersonPlayer")
|
||||||
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
|
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
|
||||||
quality_preset = "High"
|
quality_preset = "High"
|
||||||
update_interval = 0.1
|
update_interval = 0.1
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@tool
|
@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
|
extends Node3D
|
||||||
|
|
||||||
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
|
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_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_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 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_BAKED_TILE_FORMAT_VERSION := 5
|
||||||
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
|
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
|
||||||
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
|
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 extracted_dir: String = "res://data/extracted"
|
||||||
@export var map_name: String = "Azeroth"
|
@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 var camera_path: NodePath
|
||||||
@export_enum("Custom", "Performance", "Balanced", "High") var quality_preset: String = QUALITY_CUSTOM
|
@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 _tile_max := Vector2i(0, 0)
|
||||||
var _camera_initialized := false
|
var _camera_initialized := false
|
||||||
var _editor_signature := ""
|
var _editor_signature := ""
|
||||||
|
var _streaming_focus: StreamingFocus
|
||||||
|
var _missing_focus_source_reported := false
|
||||||
var _last_focus_pos := Vector3.ZERO
|
var _last_focus_pos := Vector3.ZERO
|
||||||
var _dbg_chunks_created := 0
|
var _dbg_chunks_created := 0
|
||||||
var _dbg_chunks_null := 0
|
var _dbg_chunks_null := 0
|
||||||
@@ -293,6 +300,8 @@ func _ready() -> void:
|
|||||||
_tick_editor_streaming()
|
_tick_editor_streaming()
|
||||||
elif auto_position_camera:
|
elif auto_position_camera:
|
||||||
_position_camera_over_world()
|
_position_camera_over_world()
|
||||||
|
if not Engine.is_editor_hint():
|
||||||
|
_capture_streaming_focus_from_source()
|
||||||
|
|
||||||
set_process(true)
|
set_process(true)
|
||||||
call_deferred("_refresh_streaming_targets_after_ready")
|
call_deferred("_refresh_streaming_targets_after_ready")
|
||||||
@@ -364,7 +373,7 @@ func _process(delta: float) -> void:
|
|||||||
if Engine.is_editor_hint():
|
if Engine.is_editor_hint():
|
||||||
_tick_editor_streaming()
|
_tick_editor_streaming()
|
||||||
else:
|
else:
|
||||||
_refresh_streaming_targets(false)
|
refresh_streaming_focus(false)
|
||||||
did_refresh = true
|
did_refresh = true
|
||||||
_profile_section(timings, "refresh", section_start, profile_enabled)
|
_profile_section(timings, "refresh", section_start, profile_enabled)
|
||||||
_log_hitch_profile(profile_start, timings, did_refresh, 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)
|
var focus_world := _tile_center_to_world(editor_preview_center_x, editor_preview_center_y)
|
||||||
if editor_follow_view_camera:
|
if editor_follow_view_camera:
|
||||||
var editor_camera := _get_stream_camera()
|
var editor_camera := _get_editor_view_camera()
|
||||||
if editor_camera:
|
if editor_camera:
|
||||||
focus_world += editor_camera.global_position
|
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:
|
func _scan_available_tiles() -> void:
|
||||||
@@ -681,24 +691,28 @@ func _load_tiles_from_directory() -> void:
|
|||||||
_tile_max.y = max(_tile_max.y, ty)
|
_tile_max.y = max(_tile_max.y, ty)
|
||||||
|
|
||||||
|
|
||||||
func _refresh_streaming_targets(force: bool) -> void:
|
## Replaces the current streaming point without requiring a Node or Camera3D.
|
||||||
var camera := _get_stream_camera()
|
func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
|
||||||
if camera == null:
|
_streaming_focus = streaming_focus
|
||||||
return
|
|
||||||
|
|
||||||
|
## 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
|
_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:
|
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
|
_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:
|
func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
|
||||||
if not force and not _should_refresh_focus(focus_pos):
|
if not force and not _should_refresh_focus(focus_pos):
|
||||||
return
|
return
|
||||||
@@ -3048,32 +3062,46 @@ func _is_tile_queued(key: String) -> bool:
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
func _get_stream_camera() -> Camera3D:
|
func _capture_streaming_focus_from_source() -> void:
|
||||||
if Engine.is_editor_hint():
|
if streaming_focus_source_path == NodePath():
|
||||||
# The 3D editor viewport camera lives in EditorInterface, not get_viewport().
|
return
|
||||||
# get_viewport().get_camera_3d() from a @tool script returns whichever Camera3D
|
var focus_source := get_node_or_null(streaming_focus_source_path) as Node3D
|
||||||
# belongs to the edited scene (often null), so editor-view movement is invisible
|
if focus_source == null:
|
||||||
# unless we reach into EditorInterface.
|
if not _missing_focus_source_reported:
|
||||||
var vp := EditorInterface.get_editor_viewport_3d(0)
|
push_warning("Streaming focus source is missing or is not Node3D: %s" % streaming_focus_source_path)
|
||||||
if vp != null:
|
_missing_focus_source_reported = true
|
||||||
var cam := vp.get_camera_3d()
|
return
|
||||||
if cam:
|
_missing_focus_source_reported = false
|
||||||
return cam
|
_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()
|
func _set_streaming_focus_from_vector3(world_position: Vector3) -> void:
|
||||||
if viewport_camera:
|
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(
|
||||||
return viewport_camera
|
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:
|
func _can_use_baked_tile_cache() -> bool:
|
||||||
@@ -5365,7 +5393,7 @@ func _position_camera_over_world() -> void:
|
|||||||
if _camera_initialized:
|
if _camera_initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
var camera := _get_stream_camera()
|
var camera := _get_auto_position_camera()
|
||||||
if camera == null:
|
if camera == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func _capture_async() -> void:
|
|||||||
camera.far = 50000.0
|
camera.far = 50000.0
|
||||||
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
|
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
|
||||||
(world as Node3D).add_child(camera)
|
(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("debug_streaming", true)
|
||||||
world.set("runtime_stats_enabled", true)
|
world.set("runtime_stats_enabled", true)
|
||||||
get_root().add_child(world)
|
get_root().add_child(world)
|
||||||
@@ -126,8 +126,8 @@ func _capture_async() -> void:
|
|||||||
if player != null:
|
if player != null:
|
||||||
player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0])))
|
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)))
|
_set_sky_time(world, float(checkpoint.get("time_hours", 13.0)))
|
||||||
if world.has_method("_refresh_streaming_targets_at"):
|
if world.has_method("refresh_streaming_focus"):
|
||||||
world.call("_refresh_streaming_targets_at", camera.global_position, true)
|
world.call("refresh_streaming_focus", true)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [
|
print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func _run_async() -> void:
|
|||||||
camera.name = "OccluderProbeCamera"
|
camera.name = "OccluderProbeCamera"
|
||||||
camera.current = true
|
camera.current = true
|
||||||
world.add_child(camera)
|
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)
|
world.set("debug_streaming", false)
|
||||||
get_root().add_child(world)
|
get_root().add_child(world)
|
||||||
await process_frame
|
await process_frame
|
||||||
@@ -47,8 +47,8 @@ func _run_async() -> void:
|
|||||||
var camera_position := _vector3(checkpoint.get("camera", []))
|
var camera_position := _vector3(checkpoint.get("camera", []))
|
||||||
var target_position := _vector3(checkpoint.get("target", []))
|
var target_position := _vector3(checkpoint.get("target", []))
|
||||||
camera.global_position = camera_position
|
camera.global_position = camera_position
|
||||||
if world.has_method("_refresh_streaming_targets_at"):
|
if world.has_method("refresh_streaming_focus"):
|
||||||
world.call("_refresh_streaming_targets_at", camera_position, true)
|
world.call("refresh_streaming_focus", true)
|
||||||
await create_timer(maxf(0.1, wait_seconds)).timeout
|
await create_timer(maxf(0.1, wait_seconds)).timeout
|
||||||
var geometry_nodes: Array[Node3D] = []
|
var geometry_nodes: Array[Node3D] = []
|
||||||
_collect_geometry_nodes(world, geometry_nodes)
|
_collect_geometry_nodes(world, geometry_nodes)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func _run_async() -> void:
|
|||||||
var camera := Camera3D.new()
|
var camera := Camera3D.new()
|
||||||
camera.current = true
|
camera.current = true
|
||||||
world.add_child(camera)
|
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)
|
world.set("debug_streaming", false)
|
||||||
get_root().add_child(world)
|
get_root().add_child(world)
|
||||||
await process_frame
|
await process_frame
|
||||||
@@ -53,8 +53,8 @@ func _run_async() -> void:
|
|||||||
continue
|
continue
|
||||||
var camera_position := _vector3(checkpoint.get("camera", []))
|
var camera_position := _vector3(checkpoint.get("camera", []))
|
||||||
camera.global_position = camera_position
|
camera.global_position = camera_position
|
||||||
if world.has_method("_refresh_streaming_targets_at"):
|
if world.has_method("refresh_streaming_focus"):
|
||||||
world.call("_refresh_streaming_targets_at", camera_position, true)
|
world.call("refresh_streaming_focus", true)
|
||||||
await create_timer(maxf(0.1, wait_seconds)).timeout
|
await create_timer(maxf(0.1, wait_seconds)).timeout
|
||||||
var terrain_sample := _sample_terrain(world, camera_position)
|
var terrain_sample := _sample_terrain(world, camera_position)
|
||||||
terrain_sample["name"] = checkpoint.get("name", "checkpoint")
|
terrain_sample["name"] = checkpoint.get("name", "checkpoint")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b7ayw5a0bn1lq
|
||||||
Reference in New Issue
Block a user