diff --git a/RENDER.md b/RENDER.md index e57fe40..7f157a3 100644 --- a/RENDER.md +++ b/RENDER.md @@ -1491,6 +1491,18 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' queues and permits remain loader-owned. Synthetic traversal timing is not private-asset visual, leak, GPU or p95/p99 evidence. +## 2026-08-01 WMO Scene Instance Factory Extraction + +- `WmoSceneInstanceFactory` now owns cached PackedScene instantiation/currentness + validation and live-prototype duplication with shared basename/placement rules. +- Cached validation still precedes placement; live duplicates still skip the + scene-cache validator. Accepted descendant Resources retain exact identity. +- Invalid non-Node3D cached roots are now freed synchronously, closing an + error-path lifetime leak that normal scene-cache admission already prevents. +- Source lookup, ResourceLoader, runtime preparation, attachment, registry, + queues and permits remain loader-owned. Synthetic factory timing is not + private-asset visual, leak/GPU or p95/p99 evidence. + ## 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/coordination/claims/M03-RND-WMO-SCENE-INSTANCE-FACTORY-001.md b/coordination/claims/M03-RND-WMO-SCENE-INSTANCE-FACTORY-001.md new file mode 100644 index 0000000..1749255 --- /dev/null +++ b/coordination/claims/M03-RND-WMO-SCENE-INSTANCE-FACTORY-001.md @@ -0,0 +1,82 @@ +# M03-RND-WMO-SCENE-INSTANCE-FACTORY-001 + + + +## Owner + +- Agent ID: `sindo-main-codex` +- Target: M03 Renderer Facade and Safe Extraction +- Branch: `work/sindo-main-codex/m03-wmo-scene-instance-factory` +- Worktree: `C:\Users\sindo\open-wc-worktrees\m03-wmo-scene-instance-factory` + +## Outcome + +Move WMO cached-scene instantiation/currentness validation and live-prototype +duplication with shared name/placement application into one main-thread factory. + +## Non-goals + +- Change cache lookup/admission, ResourceLoader or prototype construction. +- Change runtime Mesh/material/occluder/shadow preparation. +- Change attachment, placement registry, queues, permits or lifetime. +- Change scene cache version rules or placement formulas. + +## Paths + +- Exclusive: `src/render/wmo/wmo_scene_instance_factory.gd`, + `src/tools/verify_wmo_scene_instance_factory.gd`, + `docs/modules/wmo-scene-instance-factory.md`, this claim +- Shared: loader, adjacent WMO verifier/spec, renderer registry/specification, + `RENDER.md`, M03 Evidence + +## Contracts and data + +- Null scenes/prototypes and non-Node3D instantiation/duplication return null. +- Cached instances failing the injected currentness validator are synchronously freed. +- Accepted instances retain exact descendant Resource identities. +- Both paths apply `relative_path.get_file().get_basename()` and the exact + placement-resolver Transform3D. +- The factory returns detached roots and retains no Node or Resource. + +## Dependencies + +- Requires: accepted WMO placement resolver and runtime scene preparer +- Blocks: remaining loader-owned WMO cache/prototype orchestration cleanup + +## Verification + +- Synthetic cached/live type, validation, freeing, exact identity, name, + transform, dependency/order, ownership, source and bounded-timing contracts; + adjacent WMO/renderer gates and checkpoint dry-run. + +## Documentation deliverables + +- Inline API docs; module API/I/O/ownership; data-flow/sequence/dependency + diagrams; adjacent renderer docs and M03 Evidence. + +## Status + +- State: ready +- Done: implementation, verification and documentation +- Next: integrator review and merge +- Blocked by: + +## Handoff + +- Commit: `7e97b19` (`render: extract WMO scene instance factory`) +- Results: factory PASS `cases=41 iterations=1000 elapsed_ms=5.297`; + placement resolver dependency PASS `cases=10 iterations=20000 + elapsed_ms=28.740`; suite `65/66` with no unexpected failures and only the + proprietary ADT placement probe unavailable; editor parse had zero script + diagnostics; checkpoint dry-run retained `7/7`; documentation passed with + `52` module specifications; coordination passed with `77` historical warnings. +- Fidelity: valid cached/live cache-validation distinction, basename, exact + Transform3D and descendant Resource identity are unchanged. Invalid non-Node3D + cached roots are now freed synchronously, fixing an error-path leak without + changing valid visible output. +- Remaining risks: instantiation/duplication remains synchronous; no private WMO + portal/room, visual, long-traversal, leak/GPU or p95/p99 evidence. +- Documentation: new full factory module specification; placement resolver + consumers/sequence, renderer registry/source map and `RENDER.md` updated. + + diff --git a/docs/modules/README.md b/docs/modules/README.md index 918a793..8b14a27 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -52,6 +52,7 @@ | WMO runtime Mesh finalizer | Implemented extraction | [`wmo-runtime-mesh-finalizer.md`](wmo-runtime-mesh-finalizer.md) | | WMO render group materializer | Implemented extraction | [`wmo-render-group-materializer.md`](wmo-render-group-materializer.md) | | WMO runtime scene preparer | Implemented extraction | [`wmo-runtime-scene-preparer.md`](wmo-runtime-scene-preparer.md) | +| WMO scene instance factory | Implemented extraction | [`wmo-scene-instance-factory.md`](wmo-scene-instance-factory.md) | | Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) | | Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | diff --git a/docs/modules/wmo-placement-resolver.md b/docs/modules/wmo-placement-resolver.md index 9d2c341..39e0d6a 100644 --- a/docs/modules/wmo-placement-resolver.md +++ b/docs/modules/wmo-placement-resolver.md @@ -7,7 +7,7 @@ | Status | Implemented | | Target/work package | M03 / `M03-RND-WMO-PLACEMENT-RESOLVER-001` | | Owners | Pure WMO cache-key, placement-identity and world-transform rules | -| Last verified | Worktree `work/sindo-main-codex/m03-wmo-placement-resolver`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-scene-instance-factory`, 2026-08-01 | | Profiles/capabilities | Existing ADT/WDT WMO placement paths | ## Purpose @@ -31,14 +31,17 @@ live-prototype instance paths. flowchart LR Parsed[ADT/WDT WMO placement] --> Loader[StreamingWorldLoader adapter] Loader --> Resolver[WmoPlacementResolver] + Loader --> Factory[WmoSceneInstanceFactory] + Factory --> Resolver Resolver --> CacheKey[Normalized cache key] Resolver --> Identity[Registry unique key] Resolver --> Transform[World Transform3D] CacheKey --> Cache[Loader WMO caches/requests] Identity --> Registry[WmoPlacementRegistry] Transform --> RenderRoot[Lightweight render root] - Transform --> Scene[Cached scene instance] - Transform --> Live[Live prototype instance] + Transform --> Factory + Factory --> Scene[Cached scene instance] + Factory --> Live[Live prototype instance] ``` Allowed dependencies are Dictionary/String values and Godot `Vector3`, `Basis` @@ -62,7 +65,7 @@ WorkerThreadPool, mutexes, files, gameplay, network and editor UI are forbidden. | Input | Tile key and placement index | Loader build job | Synthetic identity fallback | Copied scalar/String | Registry entry lifetime | | Output | Normalized relative path | Resolver | Render/scene cache and load-request maps | New String value | Request/cache lookup | | Output | `uid:*` or `tile:*:*` key | Resolver | `WmoPlacementRegistry` and loader ref arrays | New String value | Until unregister/reset | -| Output | World `Transform3D` | Resolver | Three WMO instance adapters | Value copy | Instance lifetime after assignment | +| Output | World `Transform3D` | Resolver | Lightweight render-root adapter and cached/live instance factory | Value copy | Instance lifetime after assignment | The resolver retains no source Dictionary, output or engine resource. @@ -91,6 +94,7 @@ and shutdown require no resolver operation. ```mermaid sequenceDiagram participant Loader as StreamingWorldLoader + participant Factory as WmoSceneInstanceFactory participant Resolver as WmoPlacementResolver participant Registry as WmoPlacementRegistry participant Instance as Render/cached/live instance @@ -98,9 +102,15 @@ sequenceDiagram Resolver-->>Loader: cache key Loader->>Resolver: resolve_unique_key(placement, tile, index) Resolver-->>Registry: identity adopted by loader - Loader->>Resolver: resolve_world_transform(placement) - Resolver-->>Loader: value Transform3D - Loader->>Instance: assign transform and attach/build + alt lightweight render root + Loader->>Resolver: resolve_world_transform(placement) + Resolver-->>Loader: value Transform3D + else cached/live instance + Loader->>Factory: create with placement + Factory->>Resolver: resolve_world_transform(placement) + Resolver-->>Factory: value Transform3D + end + Loader->>Instance: attach/build prepared instance ``` ## Ownership, threading and resources @@ -109,7 +119,9 @@ sequenceDiagram - `WmoPlacementRegistry` owns placement-key reference sets. The loader owns its key-to-Node map, cache/load-request state, jobs/queues, resource fallback and cancellation. -- The loader and builders own every Node/Mesh/MultiMesh/material/RID lifecycle. +- `WmoSceneInstanceFactory` owns detached cached/live candidate roots until + rejection or transfer; the loader/builders own attachment and remaining + Node/Mesh/MultiMesh/material/RID lifecycle. - Pure calls are thread-safe; current consumers execute on the main thread. ## Errors, cancellation and recovery diff --git a/docs/modules/wmo-scene-instance-factory.md b/docs/modules/wmo-scene-instance-factory.md new file mode 100644 index 0000000..7b32426 --- /dev/null +++ b/docs/modules/wmo-scene-instance-factory.md @@ -0,0 +1,219 @@ +# WMO Scene Instance Factory + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target | M03 Renderer Facade and Safe Extraction | +| Work package | `M03-RND-WMO-SCENE-INSTANCE-FACTORY-001` | +| Owner | Render | +| Last verified | 2026-08-01 | + +## Purpose + +`WmoSceneInstanceFactory` creates detached WMO `Node3D` roots from validated +cached `PackedScene` resources or live-built prototypes. It owns cache-currentness +validation, basename assignment and canonical placement-resolver delegation. + +## Non-goals + +- look up/load/cache PackedScenes or build live WMO prototypes; +- apply Mesh/material/occluder/shadow runtime preparation; +- attach nodes, assign Editor ownership or manage placement references; +- own queues, permits, cache versions or world teardown; +- define WMO placement formulas or scene-cache currentness rules. + +## Context and boundaries + +The loader selects cached versus live sources. The factory creates a detached +instance and applies identity/placement. `WmoRuntimeScenePreparer` then applies +path-specific presentation policy before the loader attaches/registers the root. + +```mermaid +flowchart LR + Cache[WMO PackedScene cache] --> Loader[StreamingWorldLoader] + Prototype[Live WMO prototype cache/build] --> Loader + Loader --> Factory[WmoSceneInstanceFactory] + Validator[WMOBuilder scene-cache validator] --> Factory + Resolver[WmoPlacementResolver] --> Factory + Factory --> Detached[Detached WMO Node3D] + Detached --> Preparer[WmoRuntimeScenePreparer] + Preparer --> Loader + Loader --> Scene[Attachment and placement registry] +``` + +## Public API + +| Symbol | Role | Thread/lifetime | Failure behavior | +|---|---|---|---| +| `is_cached_node_current(node)` | Delegate one Node to the injected cache validator | Renderer main thread; no retention | Null/missing validator returns false | +| `instantiate_cached_scene(relative_path, scene, placement)` | Instantiate, type-check, validate, name and place a cached scene | Renderer main thread; detached result caller-owned | Invalid input/root/stale/dependency returns null; created rejected roots freed | +| `duplicate_live_prototype(relative_path, prototype, placement)` | Duplicate, name and place a live prototype | Renderer main thread; detached result caller-owned | Null/missing resolver/unexpected duplicate returns null | + +The cached path validates before placement. The live path deliberately skips the +scene-cache validator. Both paths use `get_file().get_basename()` and the exact +`WmoPlacementResolver.resolve_world_transform` result. + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Cached `PackedScene` or live prototype `Node3D` | Loader cache/build adapters | Factory | Borrowed Resource/Node | One main-thread call | +| Input | Relative WMO path and placement Dictionary | Loader placement job | Factory | Borrowed values | One call | +| Internal | Candidate root | PackedScene instantiate/prototype duplicate | Validator/factory | Factory-owned until accepted | One call | +| Output | Detached named/placed `Node3D` | Factory | Runtime scene preparer/loader | Ownership transfers to caller | Until attachment/release | +| Output | Currentness bool | Validator via factory | Loader cache admission | Value | Immediate | + +Side effects are limited to scene instantiation/duplication, candidate name and +transform mutation, and synchronous free of rejected candidates. No attachment, +filesystem, ResourceLoader, worker, RID, queue, cache or Editor-owner mutation. + +## Data flow + +```mermaid +flowchart LR + Source[PackedScene or live prototype] --> Create{Cached or live?} + Create -->|cached| Instantiate[PackedScene.instantiate] + Create -->|live| Duplicate[prototype.duplicate] + Instantiate --> Type{Node3D?} + Duplicate --> Type + Type -->|no| Free[Free created candidate and return null] + Type -->|yes cached| Current{Cache current?} + Type -->|yes live| Identity[Apply basename] + Current -->|no| Free + Current -->|yes| Identity + Identity --> Resolve[WmoPlacementResolver] + Resolve --> Return[Return detached Node3D] +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant Factory as WmoSceneInstanceFactory + participant Validator as WMOBuilder + participant Resolver as WmoPlacementResolver + alt cached source + Loader->>Factory: instantiate_cached_scene(path, scene, placement) + Factory->>Factory: instantiate and require Node3D + Factory->>Validator: is_scene_cache_current(root) + Validator-->>Factory: current/stale + else live source + Loader->>Factory: duplicate_live_prototype(path, prototype, placement) + Factory->>Factory: duplicate and require Node3D + end + Factory->>Factory: assign basename + Factory->>Resolver: resolve_world_transform(placement) + Resolver-->>Factory: exact Transform3D + Factory-->>Loader: detached Node3D or null +``` + +## Dependency diagram + +```mermaid +flowchart TB + Loader[StreamingWorldLoader] --> Factory[WmoSceneInstanceFactory] + Factory --> Validator[Injected WMO scene-cache validator] + Factory --> Resolver[Injected WmoPlacementResolver] + Factory --> Engine[PackedScene / Node3D / Transform3D] + Factory -. no dependency .-> Preparation[WmoRuntimeScenePreparer] + Factory -. no dependency .-> IO[ResourceLoader / FileAccess] + Factory -. no dependency .-> Queue[WMO queues / scheduler] +``` + +## Ownership, threading and resources + +- Calls are renderer-main-thread only because PackedScene/Node APIs mutate. +- The source scene/prototype remains caller/cache-owned. +- The factory owns a newly created root until rejection or successful return. +- Successful return transfers detached-root ownership to the caller. +- Descendant Mesh/Material Resources retain engine duplicate/instantiate identity. +- The factory retains only injected stateless dependencies, never Nodes/Resources. + +## Errors, cancellation and recovery + +| Failure/state | Detection | Behavior | Recovery | +|---|---|---|---| +| Null scene/prototype | Guard | Return null without allocation | Correct caller source | +| Missing validator | Currentness guard | Cached candidate rejected/freed | Fix composition | +| Missing resolver | Guard before creation | Return null without allocation | Fix composition | +| Non-Node3D root | Runtime type check | Free candidate and return null | Rebuild invalid cache/source | +| Stale cached root | Injected validator | Free candidate; skip placement | Rebuild cache/current metadata | +| Placement cancellation | Loader lifecycle | Detached/attached result released by caller | Existing retry path | +| Shutdown | No retained candidates | Nothing to drain | Existing loader teardown | + +The non-Node3D cached rejection now frees the created invalid root synchronously. +Normal admitted caches already enforce Node3D through the scene finalizer, so this +closes an error-path lifetime leak without changing valid rendered output. + +## Configuration and capabilities + +No new settings. Cache-currentness rules belong to the injected WMOBuilder +boundary; placement formulas belong to `WmoPlacementResolver`. + +## Persistence, cache and migration + +No format/version change and no rebake. The factory reads no files and writes no +metadata. Existing cache validator version policy remains authoritative. + +## Diagnostics and observability + +The factory emits no logs or metrics. Loader cache/placement metrics and +synthetic rejection contracts remain the diagnostic surfaces. + +## Verification + +- `verify_wmo_scene_instance_factory.gd` covers cached validation-before-placement, + exact accepted root/descendant Resource identity, stale-root free, non-Node3D + rejection, live validator suppression, detached ownership, dependencies, + basename/Transform3D application, source boundaries and 1,000 duplicates. +- Adjacent scene finalizer, placement resolver, runtime preparer, shutdown and + checkpoint regressions protect lifecycle and visible output. +- Fidelity evidence is behavior-preserving extraction for valid inputs. The + invalid non-Node3D free is a lifetime fix, not a visual 3.3.5a change. + +The synthetic budget requires 1,000 simple live duplicates in under one second. +Asset-backed CPU/GPU p95/p99 and long-traversal evidence remain pending. + +## Extension points + +- Asset-backed cached/live instances can compare placement and lifetime without + changing the factory API. +- New source kinds should be separate explicit methods only when their validation + and identity semantics differ materially. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Cached WMO instantiation | Implemented extraction | Type/currentness/name/placement/lifetime contract | Serialized asset-backed cache corpus pending | +| Live prototype duplication | Implemented extraction | Identity/name/placement/validator-suppression contract | Asset-backed traversal/leak evidence pending | +| Runtime preparation | Separate implemented service | Runtime scene preparer regression | Visual/GPU p95/p99 pending | +| Attachment/registry lifetime | Loader-owned | Existing WMO placement/shutdown regressions | Further orchestration extraction pending | + +## Known gaps and risks + +- Scene instantiation/duplication remains synchronous main-thread work. +- No private WMO corpus, portal/room behavior, long traversal, leak/GPU timing or + paired original-client capture is included. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/wmo/wmo_scene_instance_factory.gd` | Cached/live creation, validation, identity and placement | +| `src/render/wmo/wmo_placement_resolver.gd` | Canonical WMO placement Transform3D | +| `src/render/wmo/wmo_runtime_scene_preparer.gd` | Post-factory cached/live render preparation | +| `src/scenes/streaming/streaming_world_loader.gd` | Source selection, cache/prototype lookup, attachment and lifetime | +| `src/tools/verify_wmo_scene_instance_factory.gd` | Synthetic type/identity/lifetime/boundary/timing regression | + +## Related decisions and references + +- [`wmo-placement-resolver.md`](wmo-placement-resolver.md) +- [`wmo-scene-resource-finalizer.md`](wmo-scene-resource-finalizer.md) +- [`wmo-runtime-scene-preparer.md`](wmo-runtime-scene-preparer.md) +- [`world-renderer.md`](world-renderer.md) +- [`../../RENDER.md`](../../RENDER.md) +- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md) diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 5c258ad..b68f4f1 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -7,7 +7,7 @@ | Status | Partial | | Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; M03 terrain packages; M03 M2 packages; M03 WMO placement package | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-wmo-runtime-scene-preparer`, 2026-08-01 | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-scene-instance-factory`, 2026-08-01 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -74,6 +74,8 @@ flowchart LR WmoGroupMaterializer --> Scene Loader --> WmoScenePreparer[WmoRuntimeScenePreparer] WmoScenePreparer --> Scene + Loader --> WmoInstanceFactory[WmoSceneInstanceFactory] + WmoInstanceFactory --> WmoScenePreparer Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -170,6 +172,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `WmoRenderBuildQueue` / `WmoRenderBuildJob` | Internal WMO pending-state service | Owns typed root/resource/cursor jobs and FIFO placement keys | Renderer main thread; map session | Invalid enqueue rejected; duplicate/stale behavior preserved | | `WmoRenderGroupMaterializer.materialize_mesh_group/materialize_multimesh_group` | Internal WMO scene-materialization service | Creates, configures and attaches one indexed lightweight render group | Renderer main thread; stateless after each call | Invalid parent/resource/index returns null without attachment | | `WmoRuntimeScenePreparer.prepare_cached_instance/prepare_live_instance` | Internal WMO subtree-preparation service | Preserves cached/live Mesh-finalization distinction, direct occluder policy and recursive shadow enabling | Renderer main thread; stateless after each call | Null/freed root returns false | +| `WmoSceneInstanceFactory.instantiate_cached_scene/duplicate_live_prototype` | Internal WMO instance-creation service | Validates/creates detached cached/live roots and applies shared name/placement | Renderer main thread; stateless after each call | Invalid/stale/dependency failure returns null and frees created rejects | | `WmoRenderResourceCacheState` | Internal WMO cache-state service | Owns validated Resources, negative entries and pending cache paths | Renderer main thread; map/cache session | Invalid/occupied request and unknown completion are rejected | | `WmoRenderResourceFinalizer` | Internal WMO terminal-I/O service | Polls lightweight render requests, validates script/format and publishes Resource/missing outcomes | Renderer main thread; stateless across calls | Non-terminal retained; failed/null/wrong/stale complete missing | | `WmoSceneResourceCacheState` | Internal WMO cache-state service | Owns validated PackedScenes, negative entries and pending `.tscn` paths | Renderer main thread; map/cache session | Direct missing and terminal request transitions remain distinct | @@ -214,6 +217,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal WMO pending build | Placement key, Node3D root, WMO Resource and cursors | Loader / `WmoRenderBuildQueue` | Loader drain and step planner adapter | Queue-owned job and strong references | Until cancel/clear/replacement | | Internal WMO group materialization | Parent root, exact Mesh/MultiMesh, indexed metadata and render settings | Loader / `WmoRenderGroupMaterializer` | Attached geometry node | Parent owns node and exact Resource reference | One main-thread group operation | | Internal WMO subtree preparation | Cached/live root, extracted directory and render policies | Loader / `WmoRuntimeScenePreparer` | Borrowed subtree and runtime Mesh finalizer | Loader/placement owns subtree; preparer retains nothing | One main-thread instance preparation | +| Internal WMO instance creation | Cached PackedScene or live prototype, path and placement | Loader / `WmoSceneInstanceFactory` | Runtime scene preparer and attachment adapter | Factory owns candidate until detached-root transfer | One main-thread creation | | Internal WMO render cache | Normalized path, cache path and validated Resource | Loader / `WmoRenderResourceCacheState` | Loader lookup, ResourceLoader poll and build queue | State-owned Resource/path references; detached request snapshots | Until transient/full clear | | Internal WMO scene cache | Normalized path, `.tscn` path and validated PackedScene | Loader / `WmoSceneResourceCacheState` | Loader lookup, request poll and scene instantiation | State-owned PackedScene/path references; detached request snapshots | Until transient/full clear | | Internal ADT water load | Tile key, ADT path, task ID and parsed Dictionary | Loader/worker / `AdtWaterLoadPipelineState` | Loader task start, budgeted drain and finalization | State-owned records; mutex result mailbox | Request through result completion/reset | @@ -425,6 +429,9 @@ sequenceDiagram creation, render settings and attachment without retaining engine objects. `WmoRuntimeScenePreparer` owns cached-only Mesh traversal/finalization plus the shared direct-Occluders and recursive shadow policies for cached/live roots. + `WmoSceneInstanceFactory` owns cached/live detached-root creation, cache + validation, basename and placement application; loader retains source lookup, + runtime preparation, attachment and lifetime. `WmoRenderResourceCacheState` owns validated render Resources, negative entries and pending cache paths; `WmoRenderResourceFinalizer` owns its terminal ResourceLoader polling and script/format validation. `WmoSceneResourceCacheState` @@ -576,6 +583,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - WMO runtime scene preparer contract: cached/live finalizer distinction, exact Mesh traversal order, direct occluder removal, recursive shadow policy, ownership boundaries and bounded main-thread timing. +- WMO scene instance factory contract: cached validation-before-placement, + stale/type rejection lifetime, live validator suppression, exact descendant + Resource identity, naming/placement and bounded main-thread timing. - WMO render Resource cache contract: invalid/duplicate request rejection, validated/missing terminal transitions, transient/full reset, detached sorted diagnostics, loader-owned version validation and bounded timing. @@ -638,6 +648,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | WMO render build queue | Implemented extraction | Typed lifecycle/order/ownership/source/timing contract | Asset-backed traversal/leak evidence pending | | WMO render group materializer | Implemented extraction | Synthetic Resource/name/transform/render/attachment/source/timing contract | Asset-backed visual/leak/GPU p95/p99 pending | | WMO runtime scene preparer | Implemented extraction | Synthetic cached/live traversal/occluder/shadow/source/timing contract | Asset-backed visual/leak/GPU p95/p99 pending | +| WMO scene instance factory | Implemented extraction | Synthetic cached/live type/identity/lifetime/name/placement/source/timing contract | Serialized/asset-backed traversal/leak evidence pending | | WMO render Resource cache state | Implemented extraction | Scene-free lifecycle/exclusivity/source/timing plus shutdown contract | Asset-backed traversal/leak evidence pending | | WMO render Resource finalizer | Implemented extraction | Status/order/script/format/adoption/source/timing contract | Serialized/asset-backed corrupt-cache and leak evidence pending | | WMO scene Resource cache state | Implemented extraction | Scene-free lifecycle/direct-missing/source/timing plus shutdown contract | Asset-backed traversal/leak evidence pending | @@ -722,6 +733,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/render/wmo/wmo_runtime_mesh_finalizer.gd` | Cached WMO runtime refresh admission, surface iteration and material reconstruction | | `src/render/wmo/wmo_render_group_materializer.gd` | Indexed lightweight WMO geometry-node creation, render setup and attachment | | `src/render/wmo/wmo_runtime_scene_preparer.gd` | Cached/live WMO subtree Mesh traversal and render policy | +| `src/render/wmo/wmo_scene_instance_factory.gd` | Cached/live detached-root creation, validation, identity and placement | | `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 | | `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets | @@ -761,6 +773,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_wmo_runtime_mesh_finalizer.gd` | WMO Mesh identity/version/material-definition/boundary/timing regression | | `src/tools/verify_wmo_render_group_materializer.gd` | WMO render-group Resource/name/transform/render/attachment/boundary/timing regression | | `src/tools/verify_wmo_runtime_scene_preparer.gd` | WMO cached/live traversal/occluder/shadow/boundary/timing regression | +| `src/tools/verify_wmo_scene_instance_factory.gd` | WMO cached/live type/identity/lifetime/name/placement/boundary/timing 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 | | `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin/registered renderer-tool boundary gate derived from private streamer fields | diff --git a/src/render/wmo/wmo_scene_instance_factory.gd b/src/render/wmo/wmo_scene_instance_factory.gd new file mode 100644 index 0000000..fa5f6b9 --- /dev/null +++ b/src/render/wmo/wmo_scene_instance_factory.gd @@ -0,0 +1,73 @@ +class_name WmoSceneInstanceFactory +extends RefCounted + +## Creates detached cached/live WMO Node3D instances with the existing cache +## validation, naming and world-placement rules. Runtime preparation is separate. + +var _scene_cache_validator: Object +var _placement_resolver: Object + + +func _init(scene_cache_validator: Object, placement_resolver: Object) -> void: + _scene_cache_validator = scene_cache_validator + _placement_resolver = placement_resolver + + +## Returns whether [param node] satisfies the injected WMO scene-cache contract. +## Null nodes or missing validators return false without mutation. +func is_cached_node_current(node: Node) -> bool: + if node == null or _scene_cache_validator == null: + return false + return bool(_scene_cache_validator.call("is_scene_cache_current", node)) + + +## Instantiates, validates, names and places a cached WMO PackedScene. Rejected +## instantiated roots are freed synchronously. The accepted detached Node3D is +## caller-owned and retains its exact descendant Resource identities. +func instantiate_cached_scene( + relative_path: String, + scene: PackedScene, + placement: Dictionary) -> Node3D: + if scene == null or _placement_resolver == null: + return null + var instantiated_root := scene.instantiate() + if not (instantiated_root is Node3D): + if instantiated_root != null: + instantiated_root.free() + return null + var instance := instantiated_root as Node3D + if not is_cached_node_current(instance): + instance.free() + return null + _apply_identity_and_placement(instance, relative_path, placement) + return instance + + +## Duplicates, names and places a live-built WMO prototype. The detached result +## is caller-owned. Null inputs, missing placement composition or an unexpected +## non-Node3D duplicate return null; rejected duplicates are freed synchronously. +func duplicate_live_prototype( + relative_path: String, + prototype: Node3D, + placement: Dictionary) -> Node3D: + if prototype == null or _placement_resolver == null: + return null + var duplicated_root := prototype.duplicate() + if not (duplicated_root is Node3D): + if duplicated_root != null: + duplicated_root.free() + return null + var instance := duplicated_root as Node3D + _apply_identity_and_placement(instance, relative_path, placement) + return instance + + +func _apply_identity_and_placement( + instance: Node3D, + relative_path: String, + placement: Dictionary) -> void: + instance.name = relative_path.get_file().get_basename() + instance.transform = _placement_resolver.call( + "resolve_world_transform", + placement + ) as Transform3D diff --git a/src/render/wmo/wmo_scene_instance_factory.gd.uid b/src/render/wmo/wmo_scene_instance_factory.gd.uid new file mode 100644 index 0000000..cbca3e0 --- /dev/null +++ b/src/render/wmo/wmo_scene_instance_factory.gd.uid @@ -0,0 +1 @@ +uid://c13w66d7uaf2n diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index ecadaa0..91b8c81 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -42,6 +42,9 @@ const WMO_RENDER_GROUP_MATERIALIZER_SCRIPT := preload( const WMO_RUNTIME_SCENE_PREPARER_SCRIPT := preload( "res://src/render/wmo/wmo_runtime_scene_preparer.gd" ) +const WMO_SCENE_INSTANCE_FACTORY_SCRIPT := preload( + "res://src/render/wmo/wmo_scene_instance_factory.gd" +) const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_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") @@ -393,6 +396,10 @@ var _wmo_runtime_scene_preparer := WMO_RUNTIME_SCENE_PREPARER_SCRIPT.new( ) var _wmo_missing_cache: Dictionary = {} var _wmo_placement_resolver := WMO_PLACEMENT_RESOLVER_SCRIPT.new() +var _wmo_scene_instance_factory := WMO_SCENE_INSTANCE_FACTORY_SCRIPT.new( + WMO_BUILDER_SCRIPT, + _wmo_placement_resolver +) var _world_wmo_root: Node3D var _wmo_placement_registry := WMO_PLACEMENT_REGISTRY_SCRIPT.new() var _wmo_render_build_step_planner := WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.new() @@ -3692,7 +3699,13 @@ func _process_wmo_build_jobs() -> void: ) refs.append(unique_key) else: - var scene_instance := _instantiate_wmo_scene(rel_path, scene, placement) + var scene_instance := ( + _wmo_scene_instance_factory.instantiate_cached_scene( + rel_path, + scene, + placement + ) + ) if scene_instance != null: _prepare_runtime_wmo_instance(scene_instance) _world_wmo_root.add_child(scene_instance) @@ -3920,26 +3933,6 @@ func _get_resource_file_size(path: String) -> int: return int(length) -func _is_wmo_node_cache_current(node: Node) -> bool: - if node == null: - return false - return bool(WMO_BUILDER_SCRIPT.is_scene_cache_current(node)) - - -func _instantiate_wmo_scene(rel_path: String, scene: PackedScene, placement: Dictionary) -> Node3D: - if scene == null: - return null - var instance := scene.instantiate() as Node3D - if instance == null: - return null - if not _is_wmo_node_cache_current(instance): - instance.free() - return null - instance.name = rel_path.get_file().get_basename() - instance.transform = _wmo_placement_resolver.resolve_world_transform(placement) - return instance - - func _prepare_runtime_wmo_instance(instance: Node3D) -> void: _wmo_runtime_scene_preparer.prepare_cached_instance( instance, @@ -4632,11 +4625,15 @@ func _instantiate_wmo_world(rel_path: String, placement: Dictionary) -> Node3D: if prototype == null: return null - var instance := prototype.duplicate() - instance.name = rel_path.get_file().get_basename() # WMOs are parented to _terrain_root, whose position absorbs the editor - # offset — use world-space placement.pos directly (no tile_origin subtraction). - instance.transform = _wmo_placement_resolver.resolve_world_transform(placement) + # offset — the factory applies world-space placement.pos directly. + var instance := _wmo_scene_instance_factory.duplicate_live_prototype( + rel_path, + prototype, + placement + ) + if instance == null: + return null _wmo_runtime_scene_preparer.prepare_live_instance( instance, enable_occlusion_culling, @@ -4736,7 +4733,7 @@ func _get_or_load_wmo_prototype(rel_path: String) -> Node3D: var resource: Resource = load(cache_path) if resource is PackedScene: var node = (resource as PackedScene).instantiate() - if node is Node3D and _is_wmo_node_cache_current(node): + if node is Node3D and _wmo_scene_instance_factory.is_cached_node_current(node): _wmo_prototype_cache[normalized_rel] = node as Node3D return node as Node3D if node is Node: diff --git a/src/tools/verify_wmo_placement_resolver.gd b/src/tools/verify_wmo_placement_resolver.gd index 699efeb..fa8f562 100644 --- a/src/tools/verify_wmo_placement_resolver.gd +++ b/src/tools/verify_wmo_placement_resolver.gd @@ -4,6 +4,7 @@ extends SceneTree const RESOLVER_SCRIPT := preload("res://src/render/wmo/wmo_placement_resolver.gd") const RESOLVER_PATH := "res://src/render/wmo/wmo_placement_resolver.gd" +const INSTANCE_FACTORY_PATH := "res://src/render/wmo/wmo_scene_instance_factory.gd" const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" @@ -25,7 +26,7 @@ func _initialize() -> void: quit(1) return print( - "WMO_PLACEMENT_RESOLVER PASS cases=9 iterations=20000 elapsed_ms=%.3f" + "WMO_PLACEMENT_RESOLVER PASS cases=10 iterations=20000 elapsed_ms=%.3f" % elapsed_milliseconds ) quit(0) @@ -138,6 +139,7 @@ func _verify_node_property_equivalence(failures: Array[String]) -> void: func _verify_source_boundaries(failures: Array[String]) -> void: var loader_source := _read_text(LOADER_PATH, failures) var resolver_source := _read_text(RESOLVER_PATH, failures) + var instance_factory_source := _read_text(INSTANCE_FACTORY_PATH, failures) _expect_true( loader_source.contains("WMO_PLACEMENT_RESOLVER_SCRIPT.new()"), "loader composes resolver", @@ -157,8 +159,13 @@ func _verify_source_boundaries(failures: Array[String]) -> void: ) _expect_equal_int( loader_source.count("_wmo_placement_resolver.resolve_world_transform("), - 3, - "three transform adapters", + 1, + "one direct lightweight render-root transform adapter", + failures + ) + _expect_true( + instance_factory_source.contains('"resolve_world_transform"'), + "cached/live instance factory delegates transform resolution", failures ) _expect_true( diff --git a/src/tools/verify_wmo_scene_instance_factory.gd b/src/tools/verify_wmo_scene_instance_factory.gd new file mode 100644 index 0000000..1a357ed --- /dev/null +++ b/src/tools/verify_wmo_scene_instance_factory.gd @@ -0,0 +1,294 @@ +extends SceneTree + +## Asset-free WMO cached/live instance validation, identity, placement, +## lifetime, source-boundary and bounded-timing regression. + +const FACTORY_SCRIPT := preload("res://src/render/wmo/wmo_scene_instance_factory.gd") +const FACTORY_PATH := "res://src/render/wmo/wmo_scene_instance_factory.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +class FakeSceneCacheValidator extends RefCounted: + var is_current := true + var inspected_nodes: Array[Node] = [] + var events: Array[String] + + func _init(shared_events: Array[String] = []) -> void: + events = shared_events + + func is_scene_cache_current(node: Node) -> bool: + inspected_nodes.append(node) + events.append("validate") + return is_current + + +class FakePlacementResolver extends RefCounted: + var resolved_transform := Transform3D(Basis.IDENTITY, Vector3(4.0, 5.0, 6.0)) + var placements: Array[Dictionary] = [] + var events: Array[String] + + func _init(shared_events: Array[String] = []) -> void: + events = shared_events + + func resolve_world_transform(placement: Dictionary) -> Transform3D: + placements.append(placement.duplicate(true)) + events.append("resolve") + return resolved_transform + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_cached_scene_contract(failures) + _verify_stale_and_wrong_root_rejection(failures) + _verify_live_prototype_contract(failures) + _verify_invalid_composition(failures) + _verify_source_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("WMO_SCENE_INSTANCE_FACTORY: %s" % failure) + quit(1) + return + print( + "WMO_SCENE_INSTANCE_FACTORY PASS cases=41 iterations=1000 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_cached_scene_contract(failures: Array[String]) -> void: + var events: Array[String] = [] + var validator := FakeSceneCacheValidator.new(events) + var resolver := FakePlacementResolver.new(events) + var factory := FACTORY_SCRIPT.new(validator, resolver) + var fixture := _create_packed_wmo_scene() + var scene: PackedScene = fixture["scene"] + var source_mesh: Mesh = fixture["mesh"] + var placement := {"pos": Vector3(1.0, 2.0, 3.0), "rotation": Vector3.ZERO} + var instance: Node3D = factory.instantiate_cached_scene( + "World/Wmo/Stormwind/Keep.wmo", + scene, + placement + ) + _expect_true(instance != null, "current cached scene accepted", failures) + _expect_true(instance.get_parent() == null, "cached result remains detached", failures) + _expect_string_equal(instance.name, "Keep", "cached basename applied", failures) + _expect_true(instance.transform == resolver.resolved_transform, "cached transform applied", failures) + _expect_equal(validator.inspected_nodes.size(), 1, "cached root validated once", failures) + _expect_same(validator.inspected_nodes[0], instance, "accepted exact root validated", failures) + _expect_true(resolver.placements == [placement], "cached placement forwarded", failures) + _expect_true(events == ["validate", "resolve"], "validate precedes placement", failures) + var mesh_child := instance.get_node_or_null("Group") as MeshInstance3D + _expect_true(mesh_child != null, "cached Mesh child retained", failures) + _expect_same(mesh_child.mesh, source_mesh, "cached descendant Mesh identity retained", failures) + _expect_true(factory.is_cached_node_current(instance), "public currentness delegates", failures) + instance.free() + + +func _verify_stale_and_wrong_root_rejection(failures: Array[String]) -> void: + var validator := FakeSceneCacheValidator.new() + validator.is_current = false + var resolver := FakePlacementResolver.new() + var factory := FACTORY_SCRIPT.new(validator, resolver) + var fixture := _create_packed_wmo_scene() + _expect_true( + factory.instantiate_cached_scene("world/stale.wmo", fixture["scene"], {}) == null, + "stale cached scene rejected", + failures + ) + _expect_equal(validator.inspected_nodes.size(), 1, "stale root validated once", failures) + _expect_false( + is_instance_valid(validator.inspected_nodes[0]), + "stale instantiated root freed", + failures + ) + _expect_equal(resolver.placements.size(), 0, "stale root skips placement", failures) + + var control_root := Control.new() + var wrong_root_scene := PackedScene.new() + _expect_equal(wrong_root_scene.pack(control_root), OK, "wrong-root fixture packed", failures) + control_root.free() + validator.is_current = true + _expect_true( + factory.instantiate_cached_scene("world/control.wmo", wrong_root_scene, {}) == null, + "non-Node3D cached root rejected", + failures + ) + _expect_equal(validator.inspected_nodes.size(), 1, "wrong root skips validator", failures) + + +func _verify_live_prototype_contract(failures: Array[String]) -> void: + var validator := FakeSceneCacheValidator.new() + var resolver := FakePlacementResolver.new() + var factory := FACTORY_SCRIPT.new(validator, resolver) + var prototype := Node3D.new() + prototype.name = "Prototype" + var source_mesh := ArrayMesh.new() + var mesh_child := MeshInstance3D.new() + mesh_child.name = "Group" + mesh_child.mesh = source_mesh + prototype.add_child(mesh_child) + var placement := {"pos": Vector3(7.0, 8.0, 9.0)} + var instance: Node3D = factory.duplicate_live_prototype( + "World/Live/Abbey.wmo", + prototype, + placement + ) + _expect_true(instance != null, "live prototype duplicated", failures) + _expect_false(is_same(instance, prototype), "live root identity isolated", failures) + _expect_true(instance.get_parent() == null, "live result remains detached", failures) + _expect_string_equal(instance.name, "Abbey", "live basename applied", failures) + _expect_true(instance.transform == resolver.resolved_transform, "live transform applied", failures) + _expect_same( + (instance.get_node("Group") as MeshInstance3D).mesh, + source_mesh, + "live descendant Mesh identity retained", + failures + ) + _expect_equal(validator.inspected_nodes.size(), 0, "live path skips cache validator", failures) + _expect_true(resolver.placements == [placement], "live placement forwarded", failures) + instance.free() + prototype.free() + + +func _verify_invalid_composition(failures: Array[String]) -> void: + var validator := FakeSceneCacheValidator.new() + var resolver := FakePlacementResolver.new() + var factory := FACTORY_SCRIPT.new(validator, resolver) + _expect_false(factory.is_cached_node_current(null), "null currentness rejected", failures) + _expect_true( + factory.instantiate_cached_scene("world/a.wmo", null, {}) == null, + "null PackedScene rejected", + failures + ) + _expect_true( + factory.duplicate_live_prototype("world/a.wmo", null, {}) == null, + "null prototype rejected", + failures + ) + var fixture := _create_packed_wmo_scene() + var missing_validator_factory := FACTORY_SCRIPT.new(null, resolver) + _expect_true( + missing_validator_factory.instantiate_cached_scene( + "world/a.wmo", fixture["scene"], {} + ) == null, + "missing validator rejects cached instance", + failures + ) + var missing_resolver_factory := FACTORY_SCRIPT.new(validator, null) + _expect_true( + missing_resolver_factory.instantiate_cached_scene( + "world/a.wmo", fixture["scene"], {} + ) == null, + "missing resolver rejects cached instance before allocation", + failures + ) + var prototype := Node3D.new() + _expect_true( + missing_resolver_factory.duplicate_live_prototype("world/a.wmo", prototype, {}) == null, + "missing resolver rejects live duplicate", + failures + ) + prototype.free() + + +func _verify_source_boundaries(failures: Array[String]) -> void: + var factory_source := FileAccess.get_file_as_string(FACTORY_PATH) + var loader_source := FileAccess.get_file_as_string(LOADER_PATH) + _expect_true( + loader_source.contains("WMO_SCENE_INSTANCE_FACTORY_SCRIPT.new("), + "loader composes scene instance factory", + failures + ) + _expect_true( + loader_source.contains("_wmo_scene_instance_factory.instantiate_cached_scene("), + "loader delegates cached instantiation", + failures + ) + _expect_true( + loader_source.contains("_wmo_scene_instance_factory.duplicate_live_prototype("), + "loader delegates live duplication", + failures + ) + for released_token in [ + "func _is_wmo_node_cache_current(", + "func _instantiate_wmo_scene(", + ]: + _expect_false(loader_source.contains(released_token), "loader releases %s" % released_token, failures) + for owned_token in [ + "scene.instantiate()", + "prototype.duplicate()", + "is_scene_cache_current", + "relative_path.get_file().get_basename()", + "resolve_world_transform", + ]: + _expect_true(factory_source.contains(owned_token), "factory owns %s" % owned_token, failures) + for forbidden_dependency in [ + "ResourceLoader.", + "FileAccess.", + "WorkerThreadPool.", + "RenderingServer.", + "add_child(", + ".owner =", + "WmoRuntimeScenePreparer", + "_wmo_render_build_queue", + ]: + _expect_false(factory_source.contains(forbidden_dependency), "factory omits %s" % forbidden_dependency, failures) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var validator := FakeSceneCacheValidator.new() + var resolver := FakePlacementResolver.new() + var factory := FACTORY_SCRIPT.new(validator, resolver) + var prototype := Node3D.new() + var started_microseconds := Time.get_ticks_usec() + for iteration in 1000: + var instance: Node3D = factory.duplicate_live_prototype( + "world/timing.wmo", prototype, {} + ) + instance.free() + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_equal(resolver.placements.size(), 1000, "timing duplicates complete", failures) + _expect_true(elapsed_milliseconds < 1000.0, "1000 duplicates under one second", failures) + prototype.free() + return elapsed_milliseconds + + +func _create_packed_wmo_scene() -> Dictionary: + var source_root := Node3D.new() + var mesh := ArrayMesh.new() + var mesh_child := MeshInstance3D.new() + mesh_child.name = "Group" + mesh_child.mesh = mesh + source_root.add_child(mesh_child) + mesh_child.owner = source_root + var scene := PackedScene.new() + var pack_error := scene.pack(source_root) + if pack_error != OK: + push_error("WMO_SCENE_INSTANCE_FACTORY: cannot pack fixture") + source_root.free() + return {"scene": scene, "mesh": mesh} + + +func _expect_true(condition: bool, label: String, failures: Array[String]) -> void: + if not condition: + failures.append(label) + + +func _expect_false(condition: bool, label: String, failures: Array[String]) -> void: + _expect_true(not condition, label, failures) + + +func _expect_equal(actual: int, expected: int, label: String, failures: Array[String]) -> void: + if actual != expected: + failures.append("%s expected=%d actual=%d" % [label, expected, actual]) + + +func _expect_string_equal(actual: String, expected: String, label: String, failures: Array[String]) -> void: + if actual != expected: + failures.append("%s expected=%s actual=%s" % [label, expected, actual]) + + +func _expect_same(actual: Variant, expected: Variant, label: String, failures: Array[String]) -> void: + if not is_same(actual, expected): + failures.append(label) diff --git a/src/tools/verify_wmo_scene_instance_factory.gd.uid b/src/tools/verify_wmo_scene_instance_factory.gd.uid new file mode 100644 index 0000000..e5b0910 --- /dev/null +++ b/src/tools/verify_wmo_scene_instance_factory.gd.uid @@ -0,0 +1 @@ +uid://cae6gor0iqjv4