From c83929428dcb5bedaa1852ca0b3988aa288c4689 Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 17 Jul 2026 01:19:19 +0400 Subject: [PATCH] refactor(M03): extract WMO scene resource cache state --- RENDER.md | 15 + docs/modules/README.md | 1 + .../wmo-render-resource-cache-state.md | 7 +- .../modules/wmo-scene-resource-cache-state.md | 212 ++++++++++ docs/modules/world-renderer.md | 17 +- .../wmo/wmo_scene_resource_cache_state.gd | 134 +++++++ .../wmo/wmo_scene_resource_cache_state.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 58 +-- .../verify_render_runtime_cache_shutdown.gd | 14 +- .../verify_wmo_scene_resource_cache_state.gd | 363 ++++++++++++++++++ ...rify_wmo_scene_resource_cache_state.gd.uid | 1 + 11 files changed, 783 insertions(+), 40 deletions(-) create mode 100644 docs/modules/wmo-scene-resource-cache-state.md create mode 100644 src/render/wmo/wmo_scene_resource_cache_state.gd create mode 100644 src/render/wmo/wmo_scene_resource_cache_state.gd.uid create mode 100644 src/tools/verify_wmo_scene_resource_cache_state.gd create mode 100644 src/tools/verify_wmo_scene_resource_cache_state.gd.uid diff --git a/RENDER.md b/RENDER.md index 25da9a7..ade4372 100644 --- a/RENDER.md +++ b/RENDER.md @@ -32,6 +32,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `src/render/wmo/wmo_render_build_step_planner.gd` - mesh-first lightweight WMO group operation and cursor planning without Nodes or Resources. - `src/render/wmo/wmo_render_build_queue.gd` / `wmo_render_build_job.gd` - typed pending group jobs, FIFO placement keys and strong root/resource references without engine destruction. - `src/render/wmo/wmo_render_resource_cache_state.gd` - validated lightweight WMO render Resources, negative cache and pending cache paths without ResourceLoader I/O. +- `src/render/wmo/wmo_scene_resource_cache_state.gd` - validated cached-WMO PackedScenes, negative cache and pending `.tscn` paths without file/I/O/Node ownership. - `src/scenes/streaming/eastern_kingdoms_streaming.tscn` - текущая сцена для проверки Azeroth/Eastern Kingdoms. - `addons/mpq_extractor/loaders/adt_builder.gd` - сборка ADT terrain, control splat материалов и MH2O liquids. - `addons/mpq_extractor/loaders/m2_builder.gd` - сборка статического M2 mesh/material. @@ -1062,6 +1063,20 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Asset-backed corrupt-cache, traversal/leak p95/p99 and paired fidelity evidence remain pending. +## 2026-07-17 WMO Scene Resource Cache State Extraction + +- `WmoSceneResourceCacheState` now owns validated cached-WMO PackedScenes, + negative entries and normalized-path to pending-`.tscn` records. +- `StreamingWorldLoader` still checks file existence and + `wmo_max_runtime_scene_mb`, calls `ResourceLoader`, instantiates a validation + probe, checks WMOBuilder cache metadata and frees the probe before adoption. +- Missing files, oversize scenes, request errors, load failures and stale scenes + retain their prior negative-cache and live-prototype fallback behavior. +- Map reset clears pending/negative state while retaining accepted scenes; final + shutdown releases all scene state. Formats, profiles and visuals are unchanged. +- Asset-backed oversize/stale fixtures, traversal/leak p95/p99 and paired fidelity + evidence remain pending. + ## 2026-07-16 World Environment Snapshot Facade - `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value, diff --git a/docs/modules/README.md b/docs/modules/README.md index 87d9c2e..26bacad 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -24,6 +24,7 @@ | WMO render build step planner | Implemented extraction | [`wmo-render-build-step-planner.md`](wmo-render-build-step-planner.md) | | WMO render build queue | Implemented extraction | [`wmo-render-build-queue.md`](wmo-render-build-queue.md) | | WMO render Resource cache state | Implemented extraction | [`wmo-render-resource-cache-state.md`](wmo-render-resource-cache-state.md) | +| WMO scene Resource cache state | Implemented extraction | [`wmo-scene-resource-cache-state.md`](wmo-scene-resource-cache-state.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-render-resource-cache-state.md b/docs/modules/wmo-render-resource-cache-state.md index 801828c..717a9b4 100644 --- a/docs/modules/wmo-render-resource-cache-state.md +++ b/docs/modules/wmo-render-resource-cache-state.md @@ -179,8 +179,8 @@ states; Resource references and cache file paths are not exposed. No logs emit. ## Extension points -- Cached WMO scene request state can use a separate typed module because its - size limit, PackedScene validation and missing-file behavior differ. +- Cached WMO scene request state is a separate typed module because its size + limit, PackedScene validation and missing-file behavior differ. - A later loader I/O adapter may consume the detached request snapshot while preserving this state contract and cache-version boundary. @@ -190,7 +190,7 @@ states; Resource references and cache file paths are not exposed. No logs emit. |---|---|---|---| | Lightweight WMO render Resource state | Implemented extraction | Lifecycle/source/timing and shutdown verifiers | Asset-backed traversal/leak evidence pending | | Cache script/version validation | Preserved in loader | Source boundary and WMO regressions | Dedicated corrupt-cache fixture could follow | -| Cached WMO scene state | Partial/loader-owned | Existing runtime behavior | Separate extraction | +| Cached WMO scene state | Implemented extraction | Scene-cache lifecycle/source/timing verifier | Asset-backed traversal/leak evidence pending | | WMO materialization | Partial/loader-owned | Queue/planner regressions | Further safe extraction | ## Known gaps and risks @@ -213,6 +213,7 @@ states; Resource references and cache file paths are not exposed. No logs emit. ## Related decisions and references - [`wmo-render-build-queue.md`](wmo-render-build-queue.md) +- [`wmo-scene-resource-cache-state.md`](wmo-scene-resource-cache-state.md) - [`wmo-placement-resolver.md`](wmo-placement-resolver.md) - [`world-renderer.md`](world-renderer.md) - [`../../RENDER.md`](../../RENDER.md) diff --git a/docs/modules/wmo-scene-resource-cache-state.md b/docs/modules/wmo-scene-resource-cache-state.md new file mode 100644 index 0000000..3decbce --- /dev/null +++ b/docs/modules/wmo-scene-resource-cache-state.md @@ -0,0 +1,212 @@ +# WMO Scene Resource Cache State + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | M03 / `M03-RND-WMO-SCENE-RESOURCE-CACHE-001` | +| Owners | Validated cached-WMO PackedScenes, negative cache and pending request paths | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-scene-resource-cache`, 2026-07-17 | +| Profiles/capabilities | Cached WMO `.tscn` fallback; profile-independent state | + +## Purpose + +Own mutually exclusive cached, missing and pending states for normalized WMO +scene paths. The state accepts only caller-validated `PackedScene` references and +records `.tscn` paths for threaded requests whose I/O, size limit and cache +metadata validation remain in `StreamingWorldLoader`. + +## Non-goals + +- Normalize WMO paths, build `.tscn` paths or measure files. +- Call ResourceLoader/FileAccess, instantiate scenes or validate cache metadata. +- Own live prototypes, placement/build jobs, instantiated Nodes or RIDs. +- Change missing, oversize, request-error, retry or fallback behavior. +- Persist, migrate or build WMO caches. + +## Context and boundaries + +```mermaid +flowchart LR + Lookup[Normalized WMO scene lookup] --> Loader[StreamingWorldLoader] + Loader --> State[WmoSceneResourceCacheState] + Loader --> Size[File existence and size limit] + Size --> ResourceLoader[Godot ResourceLoader] + ResourceLoader --> Loader + Loader --> Probe[Instantiate, metadata/version check, free] + Probe -->|accepted PackedScene or missing| State + Loader --> Live[Live-prototype fallback] +``` + +The module retains validated PackedScenes and copied paths only. ResourceLoader, +FileAccess, WMOBuilder, Node, RID, worker, gameplay, network and editor concerns +remain outside it. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `scene_for(path)` / `contains_scene(path)` | Query | Borrow/test validated PackedScene | Renderer main thread; until full clear | Empty/unknown returns null/false | +| `is_missing(path)` / `has_request(path)` | Query | Test negative/pending state | Renderer main thread | Empty returns false | +| `remember_request(path, cache_path)` | Command/query | Record loader-started request | Until terminal/reset | Invalid/occupied returns false | +| `mark_missing(path)` | Command/query | Record absent/oversize/request-start failure without pending state | Until transient/full clear | Invalid/occupied returns false | +| `request_paths_snapshot()` | Query | Copy pending normalized/cache-path mapping | Poll/shutdown drain | Detached Dictionary | +| `complete_request_with_scene(path, scene)` | Command/query | Remove pending and adopt validated PackedScene | Terminal poll | Unknown/null returns false | +| `complete_request_as_missing(path)` | Command/query | Remove pending and adopt negative state | Terminal poll | Unknown returns false | +| `clear_transient_state()` | Command | Clear pending/missing and retain scenes | Map reset/request drain | Idempotent | +| `clear_all()` | Command | Release scenes and transient state | Final runtime-cache release | Idempotent | +| `pending_request_count()` | Query | Preserve renderer queue metric | Diagnostics | None | +| `diagnostic_snapshot()` | Query | Return sorted detached scalar keys | Tests/diagnostics | No PackedScene exposure | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Normalized WMO path | Placement resolver through loader | State | Copied String | Cache lifetime | +| Input | `.tscn` path after accepted request start | Loader | Pending state | Copied String | Until terminal/reset | +| Input | Metadata/version-validated PackedScene | Loader probe adapter | Scene cache | Strong reference | Until full clear | +| Input | Missing decision | Loader file/size/request/load/validation adapters | Negative cache | Boolean membership | Until transient/full clear | +| Output | Borrowed PackedScene | State | Loader scene instantiation | State retains reference | Lookup/instantiate call | +| Output | Detached request snapshot | State | Loader polling/shutdown | Caller-owned Dictionary | One drain pass | + +## Data flow + +```mermaid +flowchart TD + Lookup --> Known{Cached, missing or pending?} + Known -->|cached| Return[Return PackedScene] + Known -->|missing/pending| Null[Return null] + Known -->|absent| Check[Loader checks file and size] + Check -->|missing/oversize| Mark[mark_missing] + Check -->|allowed| Request[Start threaded request] + Request -->|error| Mark + Request -->|accepted| Remember[remember_request] + Remember --> Poll[Loader polls snapshot] + Poll -->|failure| CompleteMissing[complete as missing] + Poll -->|loaded| Validate[Instantiate and validate cache metadata] + Validate -->|valid| CompleteScene[complete with scene] + Validate -->|invalid| CompleteMissing +``` + +## Lifecycle/state + +Each normalized path is `Absent`, `Pending`, `Cached` or `Missing`. Direct file, +size and request-start rejection moves `Absent` directly to `Missing`; terminal +loads move `Pending` to `Cached` or `Missing`. Transient clear retains `Cached`, +while full clear returns every path to `Absent`. + +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Pending: accepted request + Absent --> Missing: absent, oversize or request error + Pending --> Cached: validated PackedScene + Pending --> Missing: load or validation failure + Pending --> Absent: transient/full clear + Missing --> Absent: transient/full clear + Cached --> Absent: full clear +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant State as WmoSceneResourceCacheState + participant RL as ResourceLoader + Loader->>State: cached/missing/pending queries + Loader->>Loader: exists and wmo_max_runtime_scene_mb check + Loader->>RL: load_threaded_request(.tscn) + Loader->>State: remember_request(normalized, path) + loop renderer tick + Loader->>State: request_paths_snapshot() + Loader->>RL: load_threaded_get_status(path) + end + Loader->>RL: load_threaded_get(path) + Loader->>Loader: instantiate, validate WMO metadata, free probe + Loader->>State: complete with scene or as missing +``` + +## Ownership, threading and resources + +- State owns three Dictionaries and strong references to accepted PackedScenes. +- Loader owns paths, file measurement, requests, validation and all Node lifetime. +- All mutation is serialized on the renderer main thread; no mutex is required. +- Detached request snapshots allow terminal removal while polling. +- Scene instantiation borrows the PackedScene and does not transfer cache ownership. + +## Errors, cancellation and recovery + +| Failure/state | Detection | Behavior | Recovery | +|---|---|---|---| +| Missing `.tscn` | Loader ResourceLoader existence check | Direct missing state | Transient reset permits later retry | +| Oversize `.tscn` | Loader byte limit | Missing plus unchanged debug log | Raise limit/rebuild, then reset | +| Request-start/load failure | Loader error/status | Direct or terminal missing | Reset and retry later | +| Wrong type/stale metadata | Loader PackedScene/probe validation | Terminal missing; probe freed | Rebuild cache and reset | +| Shutdown while pending | Loader drains snapshot | Clear transient, then full cache release | New loader starts absent | + +## Configuration and capabilities + +No setting is added. `wmo_cache_dir`, `wmo_max_runtime_scene_mb`, request timing, +quality profiles and WMO build budgets stay loader-owned. + +## Persistence, cache and migration + +The module is runtime-only. Existing WMOBuilder cache metadata/version checks +remain unchanged and execute before state adoption. No cache format, migration or +rebuild is introduced. + +## Diagnostics and observability + +Pending count preserves the existing `wmobuild` metric contribution. The snapshot +exposes sorted scene/missing/request keys only. Existing oversize logging remains +in the loader; the state emits no logs. + +## Verification + +- `verify_wmo_scene_resource_cache_state.gd`: invalid input, direct missing, + exclusivity, request completion, partial/full reset, diagnostics, boundaries + and bounded timing. +- `verify_render_runtime_cache_shutdown.gd`: loader full release of scenes, + negative and pending state. +- Adjacent WMO/renderer regressions verify unchanged lookup/fallback behavior. + This extraction makes no visual or build-12340 parity claim. + +## Extension points + +- A later I/O adapter may consume request snapshots while retaining this contract. +- Scene instantiation/material preparation can be extracted separately because it + owns Nodes and main-thread side effects rather than cache state. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Cached WMO PackedScene state | Implemented extraction | Lifecycle/source/timing and shutdown verifiers | Asset-backed traversal/leak evidence pending | +| Size and cache metadata validation | Preserved in loader | Source boundary and WMO regressions | Oversize/stale asset fixtures could follow | +| ResourceLoader I/O and live fallback | Partial/loader-owned | Existing runtime behavior | Separate extraction if justified | + +## Known gaps and risks + +- PackedScenes remain strongly referenced until final shutdown cache release. +- Negative caching suppresses retries until transient reset, matching prior behavior. +- No proprietary WMO corpus, stale/oversize asset fixture, leak run, traversal + p95/p99 or paired original-client capture is included. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/wmo/wmo_scene_resource_cache_state.gd` | Scene/missing/request state and resets | +| `src/scenes/streaming/streaming_world_loader.gd` | Paths, file limit, requests, validation, fallback and Node lifetime | +| `src/tools/verify_wmo_scene_resource_cache_state.gd` | State, boundary and timing regression | +| `src/tools/verify_render_runtime_cache_shutdown.gd` | Loader final cache ownership regression | + +## Related decisions and references + +- [`wmo-render-resource-cache-state.md`](wmo-render-resource-cache-state.md) +- [`wmo-render-build-queue.md`](wmo-render-build-queue.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 e055ac2..84164d6 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-render-resource-cache`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-scene-resource-cache`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -137,6 +137,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `WmoRenderBuildStepPlanner.plan_step` | Internal pure WMO service | Selects one mesh-first lightweight render-group operation and next cursors | Main/any thread; stateless | Raw integer comparisons are preserved without clamping | | `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 | | `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 | +| `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 | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -170,6 +171,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal WMO build step | Mesh/MultiMesh counts and job cursors | Loader / `WmoRenderBuildStepPlanner` | Loader materialization/cursor adapter | Fresh scalar Dictionary | One group operation | | 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 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 | | Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame | | Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach | | Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand | @@ -350,9 +352,10 @@ sequenceDiagram and cursor plan. `WmoRenderBuildQueue` owns typed pending jobs, FIFO keys and strong root/resource references without freeing engine objects. `WmoRenderResourceCacheState` owns validated render Resources, negative entries - and pending cache paths. The loader retains ResourceLoader I/O, script/version - validation, cached-scene/live fallback, materialization, permits, validity - reactions and every Node lifecycle action. + and pending cache paths. `WmoSceneResourceCacheState` similarly owns validated + PackedScenes, negative entries and pending `.tscn` paths. The loader retains + ResourceLoader/FileAccess I/O, size and cache-version validation, live fallback, + materialization, permits, validity reactions and every Node lifecycle action. - Rendered-ground query results and diagnostic snapshots are caller-owned values; the facade never returns Mesh, Node, tile-state or queue references. - Loaded-mesh ground sampling is renderer diagnostics, not authoritative gameplay @@ -455,6 +458,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - 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. +- WMO scene Resource cache contract: direct missing sources, pending terminal + transitions, transient/full reset, detached diagnostics, loader-owned size/ + metadata validation and bounded timing. - Budget scheduler contract: exact lane exhaustion, shared chunk removal/create priority, independent lanes, frame reset, invalid limits, terminal cancellation, dependency boundaries and bounded permit timing without loading a world scene. @@ -492,7 +498,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | WMO placement registry | Implemented extraction | Scene-free ownership/lifecycle/source/timing contract | Build/resource state and asset-backed cross-tile corpus pending | | WMO render build step planner | Implemented extraction | Scene-free order/cursor/source/timing contract | Asset-backed traversal p95/p99 pending | | WMO render build queue | Implemented extraction | Typed lifecycle/order/ownership/source/timing contract | Materialization and asset-backed traversal/leak evidence pending | -| WMO render Resource cache state | Implemented extraction | Scene-free lifecycle/exclusivity/source/timing plus shutdown contract | Cached-scene/I/O extraction and asset-backed traversal/leak evidence pending | +| WMO render Resource cache state | Implemented extraction | Scene-free lifecycle/exclusivity/source/timing plus shutdown contract | ResourceLoader I/O and asset-backed traversal/leak evidence pending | +| WMO scene Resource cache state | Implemented extraction | Scene-free lifecycle/direct-missing/source/timing plus shutdown contract | ResourceLoader/live-fallback extraction and asset-backed traversal/leak evidence pending | | WMO rendering | Partial | Cached group rendering | Portals/rooms/material parity | | Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity | | Sky/light | Partial | DBC controller/logs | Zone transition paired validation | diff --git a/src/render/wmo/wmo_scene_resource_cache_state.gd b/src/render/wmo/wmo_scene_resource_cache_state.gd new file mode 100644 index 0000000..cebae35 --- /dev/null +++ b/src/render/wmo/wmo_scene_resource_cache_state.gd @@ -0,0 +1,134 @@ +class_name WmoSceneResourceCacheState +extends RefCounted + +## Owns validated cached-WMO PackedScenes and their load state. File limits, +## ResourceLoader I/O and scene-cache validation remain caller responsibilities. + +var _scenes_by_normalized_path: Dictionary = {} +var _missing_normalized_paths: Dictionary = {} +var _request_cache_paths_by_normalized_path: Dictionary = {} + + +## Returns the validated PackedScene for a normalized WMO path, or null. +func scene_for(normalized_relative_path: String) -> PackedScene: + if normalized_relative_path.is_empty(): + return null + return _scenes_by_normalized_path.get(normalized_relative_path) as PackedScene + + +## Returns whether a validated PackedScene is cached for the normalized path. +func contains_scene(normalized_relative_path: String) -> bool: + return ( + not normalized_relative_path.is_empty() + and _scenes_by_normalized_path.has(normalized_relative_path) + ) + + +## Returns whether the normalized path is in the negative cache. +func is_missing(normalized_relative_path: String) -> bool: + return ( + not normalized_relative_path.is_empty() + and _missing_normalized_paths.has(normalized_relative_path) + ) + + +## Returns whether the normalized path has a pending threaded load request. +func has_request(normalized_relative_path: String) -> bool: + return ( + not normalized_relative_path.is_empty() + and _request_cache_paths_by_normalized_path.has(normalized_relative_path) + ) + + +## Records one loader-started request. Occupied or empty paths are rejected. +func remember_request(normalized_relative_path: String, cache_path: String) -> bool: + if normalized_relative_path.is_empty() or cache_path.is_empty(): + return false + if _has_any_state(normalized_relative_path): + return false + _request_cache_paths_by_normalized_path[normalized_relative_path] = cache_path + return true + + +## Records an absent, oversize or request-start failure without requiring a +## pending request. Occupied and empty paths are rejected. +func mark_missing(normalized_relative_path: String) -> bool: + if normalized_relative_path.is_empty() or _has_any_state(normalized_relative_path): + return false + _missing_normalized_paths[normalized_relative_path] = true + return true + + +## Returns a detached normalized-path to cache-path snapshot for loader polling. +func request_paths_snapshot() -> Dictionary: + return _request_cache_paths_by_normalized_path.duplicate() + + +## Completes a pending request with a caller-validated PackedScene. +func complete_request_with_scene( + normalized_relative_path: String, + validated_scene: PackedScene +) -> bool: + if ( + normalized_relative_path.is_empty() + or validated_scene == null + or not _request_cache_paths_by_normalized_path.has(normalized_relative_path) + ): + return false + _request_cache_paths_by_normalized_path.erase(normalized_relative_path) + _missing_normalized_paths.erase(normalized_relative_path) + _scenes_by_normalized_path[normalized_relative_path] = validated_scene + return true + + +## Completes a pending request as missing after load or validation failure. +func complete_request_as_missing(normalized_relative_path: String) -> bool: + if ( + normalized_relative_path.is_empty() + or not _request_cache_paths_by_normalized_path.has(normalized_relative_path) + ): + return false + _request_cache_paths_by_normalized_path.erase(normalized_relative_path) + _scenes_by_normalized_path.erase(normalized_relative_path) + _missing_normalized_paths[normalized_relative_path] = true + return true + + +## Clears pending and negative state while retaining validated PackedScenes. +func clear_transient_state() -> void: + _request_cache_paths_by_normalized_path.clear() + _missing_normalized_paths.clear() + + +## Releases every cached scene and transient entry. Safe to call repeatedly. +func clear_all() -> void: + _scenes_by_normalized_path.clear() + clear_transient_state() + + +## Returns the number of pending threaded load requests. +func pending_request_count() -> int: + return _request_cache_paths_by_normalized_path.size() + + +## Returns detached, sorted scalar keys without exposing PackedScene references. +func diagnostic_snapshot() -> Dictionary: + var scene_paths: Array = _scenes_by_normalized_path.keys() + var missing_paths: Array = _missing_normalized_paths.keys() + var request_paths: Array = _request_cache_paths_by_normalized_path.keys() + scene_paths.sort() + missing_paths.sort() + request_paths.sort() + return { + "scene_paths": scene_paths.duplicate(), + "missing_paths": missing_paths.duplicate(), + "request_paths": request_paths.duplicate(), + } + + +func _has_any_state(normalized_relative_path: String) -> bool: + return ( + _scenes_by_normalized_path.has(normalized_relative_path) + or _missing_normalized_paths.has(normalized_relative_path) + or _request_cache_paths_by_normalized_path.has(normalized_relative_path) + ) diff --git a/src/render/wmo/wmo_scene_resource_cache_state.gd.uid b/src/render/wmo/wmo_scene_resource_cache_state.gd.uid new file mode 100644 index 0000000..a647c1d --- /dev/null +++ b/src/render/wmo/wmo_scene_resource_cache_state.gd.uid @@ -0,0 +1 @@ +uid://b4geepupdscu7 diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index eff46b4..ed3393e 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -24,6 +24,9 @@ const WMO_RENDER_BUILD_QUEUE_SCRIPT := preload( const WMO_RENDER_RESOURCE_CACHE_STATE_SCRIPT := preload( "res://src/render/wmo/wmo_render_resource_cache_state.gd" ) +const WMO_SCENE_RESOURCE_CACHE_STATE_SCRIPT := preload( + "res://src/render/wmo/wmo_scene_resource_cache_state.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_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") @@ -285,9 +288,7 @@ var _terrain_quality_mesh_cache: RefCounted = TERRAIN_QUALITY_MESH_CACHE_SCRIPT. var _wmo_prototype_cache: Dictionary = {} var _wmo_render_resource_cache_state := WMO_RENDER_RESOURCE_CACHE_STATE_SCRIPT.new() var _wmo_render_build_queue := WMO_RENDER_BUILD_QUEUE_SCRIPT.new() -var _wmo_scene_resource_cache: Dictionary = {} -var _wmo_scene_cache_missing: Dictionary = {} -var _wmo_scene_load_requests: Dictionary = {} +var _wmo_scene_resource_cache_state := WMO_SCENE_RESOURCE_CACHE_STATE_SCRIPT.new() var _wmo_missing_cache: Dictionary = {} var _wmo_placement_resolver := WMO_PLACEMENT_RESOLVER_SCRIPT.new() var _m2_scene_cache: Dictionary = {} @@ -469,8 +470,7 @@ func _release_runtime_caches_for_shutdown() -> void: _m2_static_animation_cache.clear() _m2_missing_cache.clear() _wmo_render_resource_cache_state.clear_all() - _wmo_scene_resource_cache.clear() - _wmo_scene_cache_missing.clear() + _wmo_scene_resource_cache_state.clear_all() _wmo_missing_cache.clear() _shared_tex_cache.clear() @@ -1007,7 +1007,7 @@ func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bo _m2_build_queue.size(), ( _wmo_build_queue.size() - + _wmo_scene_load_requests.size() + + _wmo_scene_resource_cache_state.pending_request_count() + _wmo_render_resource_cache_state.pending_request_count() ), _wmo_render_build_queue.pending_count(), @@ -1038,7 +1038,7 @@ func render_baseline_snapshot() -> Dictionary: "m2_build": _m2_build_queue.size(), "wmo_build": ( _wmo_build_queue.size() - + _wmo_scene_load_requests.size() + + _wmo_scene_resource_cache_state.pending_request_count() + _wmo_render_resource_cache_state.pending_request_count() ), "wmo_groups": _wmo_render_build_queue.pending_count(), @@ -1324,7 +1324,7 @@ func _tick_runtime_stats(delta: float) -> void: _m2_build_queue.size(), ( _wmo_build_queue.size() - + _wmo_scene_load_requests.size() + + _wmo_scene_resource_cache_state.pending_request_count() + _wmo_render_resource_cache_state.pending_request_count() ), _wmo_render_build_queue.pending_count(), @@ -2359,14 +2359,15 @@ func _wait_for_tile_tasks() -> void: _m2_animation_load_requests.clear() _m2_animation_finalize_queue.clear() - for path_variant in _wmo_scene_load_requests.values(): + var wmo_scene_request_paths := _wmo_scene_resource_cache_state.request_paths_snapshot() + for path_variant in wmo_scene_request_paths.values(): var path := String(path_variant) if path.is_empty(): continue var status := ResourceLoader.load_threaded_get_status(path) if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED: ResourceLoader.load_threaded_get(path) - _wmo_scene_load_requests.clear() + _wmo_scene_resource_cache_state.clear_transient_state() var wmo_render_request_paths := ( _wmo_render_resource_cache_state.request_paths_snapshot() @@ -2379,7 +2380,6 @@ func _wait_for_tile_tasks() -> void: if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED: ResourceLoader.load_threaded_get(path) _wmo_render_resource_cache_state.clear_transient_state() - _wmo_scene_cache_missing.clear() _wmo_render_build_queue.clear() for pending in _terrain_upgrade_tasks.values(): @@ -3015,9 +3015,8 @@ func _clear_streamed_world() -> void: _m2_animation_finalize_queue.clear() _wmo_build_jobs.clear() _wmo_build_queue.clear() - _wmo_scene_load_requests.clear() + _wmo_scene_resource_cache_state.clear_transient_state() _wmo_render_resource_cache_state.clear_transient_state() - _wmo_scene_cache_missing.clear() _wmo_render_build_queue.clear() _tile_result_mutex.lock() @@ -3607,7 +3606,7 @@ func _process_wmo_build_jobs() -> void: else: var scene := _get_wmo_scene_or_request(rel_path) if scene == null: - if _wmo_scene_load_requests.has(normalized_rel): + if _wmo_scene_resource_cache_state.has_request(normalized_rel): advance_job = false else: var live_instance := _instantiate_wmo_world(rel_path, placement) @@ -3662,21 +3661,24 @@ func _process_wmo_build_jobs() -> void: func _drain_wmo_scene_loads() -> void: - for normalized_rel_variant in _wmo_scene_load_requests.keys(): + var request_paths := _wmo_scene_resource_cache_state.request_paths_snapshot() + for normalized_rel_variant in request_paths.keys(): var normalized_rel := String(normalized_rel_variant) - var path := String(_wmo_scene_load_requests[normalized_rel]) + var path := String(request_paths[normalized_rel]) var status := ResourceLoader.load_threaded_get_status(path) if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED: continue - _wmo_scene_load_requests.erase(normalized_rel) if status != ResourceLoader.THREAD_LOAD_LOADED: - _wmo_scene_cache_missing[normalized_rel] = true + _wmo_scene_resource_cache_state.complete_request_as_missing(normalized_rel) continue var resource: Resource = ResourceLoader.load_threaded_get(path) if resource is PackedScene and _is_wmo_scene_cache_current(resource as PackedScene): - _wmo_scene_resource_cache[normalized_rel] = resource as PackedScene + _wmo_scene_resource_cache_state.complete_request_with_scene( + normalized_rel, + resource as PackedScene + ) else: - _wmo_scene_cache_missing[normalized_rel] = true + _wmo_scene_resource_cache_state.complete_request_as_missing(normalized_rel) func _drain_wmo_render_loads() -> void: @@ -3733,23 +3735,23 @@ func _get_wmo_scene_or_request(rel_path: String) -> PackedScene: var normalized_rel := _wmo_placement_resolver.normalize_relative_path(rel_path) if normalized_rel.is_empty(): return null - if _wmo_scene_resource_cache.has(normalized_rel): - return _wmo_scene_resource_cache[normalized_rel] - if _wmo_scene_cache_missing.has(normalized_rel): + if _wmo_scene_resource_cache_state.contains_scene(normalized_rel): + return _wmo_scene_resource_cache_state.scene_for(normalized_rel) + if _wmo_scene_resource_cache_state.is_missing(normalized_rel): return null - if _wmo_scene_load_requests.has(normalized_rel): + if _wmo_scene_resource_cache_state.has_request(normalized_rel): return null var cache_path := wmo_cache_dir.path_join(normalized_rel.get_basename() + ".tscn") if not ResourceLoader.exists(cache_path): - _wmo_scene_cache_missing[normalized_rel] = true + _wmo_scene_resource_cache_state.mark_missing(normalized_rel) return null if wmo_max_runtime_scene_mb > 0.0: var size_bytes := _get_resource_file_size(cache_path) var max_bytes := int(wmo_max_runtime_scene_mb * 1024.0 * 1024.0) if size_bytes > max_bytes: - _wmo_scene_cache_missing[normalized_rel] = true + _wmo_scene_resource_cache_state.mark_missing(normalized_rel) if debug_streaming: print("StreamingWorld: skipping large WMO %.2f MB: %s" % [ float(size_bytes) / (1024.0 * 1024.0), @@ -3763,9 +3765,9 @@ func _get_wmo_scene_or_request(rel_path: String) -> PackedScene: false, ResourceLoader.CACHE_MODE_REUSE) if err == OK or err == ERR_BUSY: - _wmo_scene_load_requests[normalized_rel] = cache_path + _wmo_scene_resource_cache_state.remember_request(normalized_rel, cache_path) else: - _wmo_scene_cache_missing[normalized_rel] = true + _wmo_scene_resource_cache_state.mark_missing(normalized_rel) return null diff --git a/src/tools/verify_render_runtime_cache_shutdown.gd b/src/tools/verify_render_runtime_cache_shutdown.gd index e76259b..bb21967 100644 --- a/src/tools/verify_render_runtime_cache_shutdown.gd +++ b/src/tools/verify_render_runtime_cache_shutdown.gd @@ -43,8 +43,10 @@ func _verify_runtime_cache_ownership() -> void: wmo_render_cache_state.call("complete_request_with_resource", "render", Resource.new()) wmo_render_cache_state.call("remember_request", "missing", "res://missing.res") wmo_render_cache_state.call("complete_request_as_missing", "missing") - loader.set("_wmo_scene_resource_cache", {"scene": PackedScene.new()}) - loader.set("_wmo_scene_cache_missing", {"missing": true}) + var wmo_scene_cache_state: RefCounted = loader.get("_wmo_scene_resource_cache_state") + wmo_scene_cache_state.call("remember_request", "scene", "res://scene.tscn") + wmo_scene_cache_state.call("complete_request_with_scene", "scene", PackedScene.new()) + wmo_scene_cache_state.call("mark_missing", "missing") loader.set("_wmo_missing_cache", {"missing": true}) loader.set("_shared_tex_cache", {"texture": ImageTexture.new()}) @@ -60,8 +62,6 @@ func _verify_runtime_cache_ownership() -> void: "_m2_mesh_cache", "_m2_static_animation_cache", "_m2_missing_cache", - "_wmo_scene_resource_cache", - "_wmo_scene_cache_missing", "_wmo_missing_cache", "_shared_tex_cache", ]: @@ -80,6 +80,12 @@ func _verify_runtime_cache_ownership() -> void: (wmo_render_snapshot["request_paths"] as Array).is_empty(), "WMO render requests should be empty after shutdown" ) + var wmo_scene_snapshot: Dictionary = wmo_scene_cache_state.call("diagnostic_snapshot") + for state_key in ["scene_paths", "missing_paths", "request_paths"]: + _expect( + (wmo_scene_snapshot[state_key] as Array).is_empty(), + "WMO scene %s should be empty after shutdown" % state_key + ) loader.free() diff --git a/src/tools/verify_wmo_scene_resource_cache_state.gd b/src/tools/verify_wmo_scene_resource_cache_state.gd new file mode 100644 index 0000000..dffec63 --- /dev/null +++ b/src/tools/verify_wmo_scene_resource_cache_state.gd @@ -0,0 +1,363 @@ +extends SceneTree + +## Asset-free state, ownership, dependency and timing regression for cached WMO scenes. + +const CACHE_STATE_SCRIPT := preload( + "res://src/render/wmo/wmo_scene_resource_cache_state.gd" +) +const CACHE_STATE_PATH := "res://src/render/wmo/wmo_scene_resource_cache_state.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_invalid_and_request_state(failures) + _verify_direct_missing_and_exclusivity(failures) + _verify_validated_scene_completion(failures) + _verify_failed_request_completion(failures) + _verify_transient_and_full_clear(failures) + _verify_detached_sorted_diagnostics(failures) + _verify_ownership_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("WMO_SCENE_RESOURCE_CACHE_STATE: %s" % failure) + quit(1) + return + print( + "WMO_SCENE_RESOURCE_CACHE_STATE PASS cases=11 iterations=100 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_invalid_and_request_state(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + _expect_false( + cache_state.call("remember_request", "", "res://cache/a.tscn"), + "empty normalized path rejected", + failures + ) + _expect_false( + cache_state.call("remember_request", "world/a.wmo", ""), + "empty cache path rejected", + failures + ) + _expect_true( + cache_state.call("remember_request", "world/a.wmo", "res://cache/a.tscn"), + "valid request accepted", + failures + ) + _expect_true( + cache_state.call("has_request", "world/a.wmo"), + "pending request observable", + failures + ) + _expect_equal( + int(cache_state.call("pending_request_count")), + 1, + "pending request count", + failures + ) + _expect_false( + cache_state.call("remember_request", "world/a.wmo", "res://cache/other.tscn"), + "duplicate request rejected", + failures + ) + var request_snapshot: Dictionary = cache_state.call("request_paths_snapshot") + _expect_string_equal( + String(request_snapshot.get("world/a.wmo", "")), + "res://cache/a.tscn", + "request cache path preserved", + failures + ) + request_snapshot.clear() + _expect_true( + cache_state.call("has_request", "world/a.wmo"), + "request snapshot detached", + failures + ) + + +func _verify_direct_missing_and_exclusivity(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + _expect_false(cache_state.call("mark_missing", ""), "empty missing rejected", failures) + _expect_true( + cache_state.call("mark_missing", "world/missing.wmo"), + "direct missing accepted", + failures + ) + _expect_true( + cache_state.call("is_missing", "world/missing.wmo"), + "direct missing observable", + failures + ) + _expect_false( + cache_state.call("mark_missing", "world/missing.wmo"), + "duplicate missing rejected", + failures + ) + _expect_false( + cache_state.call( + "remember_request", + "world/missing.wmo", + "res://cache/retry.tscn" + ), + "missing path rejects request", + failures + ) + + +func _verify_validated_scene_completion(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + var validated_scene := PackedScene.new() + cache_state.call("remember_request", "world/b.wmo", "res://cache/b.tscn") + _expect_false( + cache_state.call("complete_request_with_scene", "world/b.wmo", null), + "null completion rejected", + failures + ) + _expect_true( + cache_state.call("complete_request_with_scene", "world/b.wmo", validated_scene), + "validated PackedScene completes request", + failures + ) + _expect_false( + cache_state.call("has_request", "world/b.wmo"), + "successful completion removes request", + failures + ) + _expect_true( + cache_state.call("contains_scene", "world/b.wmo"), + "successful completion cached", + failures + ) + _expect_true( + cache_state.call("scene_for", "world/b.wmo") == validated_scene, + "cached PackedScene identity preserved", + failures + ) + _expect_false( + cache_state.call("mark_missing", "world/b.wmo"), + "cached scene rejects missing state", + failures + ) + + +func _verify_failed_request_completion(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + _expect_false( + cache_state.call("complete_request_as_missing", "world/unknown.wmo"), + "unknown request completion rejected", + failures + ) + cache_state.call("remember_request", "world/failed.wmo", "res://cache/failed.tscn") + _expect_true( + cache_state.call("complete_request_as_missing", "world/failed.wmo"), + "failed request completes as missing", + failures + ) + _expect_true( + cache_state.call("is_missing", "world/failed.wmo"), + "failed request negative-cached", + failures + ) + _expect_false( + cache_state.call("has_request", "world/failed.wmo"), + "failed completion removes request", + failures + ) + + +func _verify_transient_and_full_clear(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + var retained_scene := PackedScene.new() + cache_state.call("remember_request", "world/retained.wmo", "res://retained.tscn") + cache_state.call("complete_request_with_scene", "world/retained.wmo", retained_scene) + cache_state.call("remember_request", "world/pending.wmo", "res://pending.tscn") + cache_state.call("mark_missing", "world/missing.wmo") + cache_state.call("clear_transient_state") + _expect_true( + cache_state.call("scene_for", "world/retained.wmo") == retained_scene, + "transient clear retains PackedScene", + failures + ) + _expect_equal( + int(cache_state.call("pending_request_count")), + 0, + "transient clear removes requests", + failures + ) + _expect_false( + cache_state.call("is_missing", "world/missing.wmo"), + "transient clear removes missing state", + failures + ) + cache_state.call("clear_all") + cache_state.call("clear_all") + _expect_false( + cache_state.call("contains_scene", "world/retained.wmo"), + "full clear idempotently releases PackedScene", + failures + ) + + +func _verify_detached_sorted_diagnostics(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + cache_state.call("remember_request", "world/z.wmo", "res://z.tscn") + cache_state.call("remember_request", "world/a.wmo", "res://a.tscn") + cache_state.call("mark_missing", "world/m.wmo") + var snapshot: Dictionary = cache_state.call("diagnostic_snapshot") + _expect_string_array( + snapshot["request_paths"], + ["world/a.wmo", "world/z.wmo"], + "request diagnostics sorted", + failures + ) + _expect_string_array( + snapshot["missing_paths"], + ["world/m.wmo"], + "missing diagnostics", + failures + ) + (snapshot["request_paths"] as Array).clear() + var fresh_snapshot: Dictionary = cache_state.call("diagnostic_snapshot") + _expect_equal( + (fresh_snapshot["request_paths"] as Array).size(), + 2, + "diagnostics detached", + failures + ) + + +func _verify_ownership_boundaries(failures: Array[String]) -> void: + var loader_source := _read_text(LOADER_PATH, failures) + var cache_state_source := _read_text(CACHE_STATE_PATH, failures) + _expect_true( + loader_source.contains("WMO_SCENE_RESOURCE_CACHE_STATE_SCRIPT.new()"), + "loader composes scene cache state", + failures + ) + for legacy_field in [ + "var _wmo_scene_resource_cache:", + "var _wmo_scene_cache_missing:", + "var _wmo_scene_load_requests:", + ]: + _expect_false( + loader_source.contains(legacy_field), + "loader removes %s" % legacy_field, + failures + ) + for loader_owned_token in [ + "ResourceLoader.", + "FileAccess.", + "WMO_BUILDER_SCRIPT", + "instantiate()", + "Node3D", + "RID", + "Mutex", + "Semaphore", + ]: + _expect_false( + cache_state_source.contains(loader_owned_token), + "scene cache state omits %s dependency" % loader_owned_token, + failures + ) + for retained_loader_rule in [ + "wmo_max_runtime_scene_mb", + "_get_resource_file_size(cache_path)", + "_is_wmo_scene_cache_current(resource as PackedScene)", + "scene.instantiate() as Node3D", + "WMO_BUILDER_SCRIPT.is_scene_cache_current(node)", + ]: + _expect_true( + loader_source.contains(retained_loader_rule), + "loader retains %s" % retained_loader_rule, + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + var started_microseconds := Time.get_ticks_usec() + for iteration in range(100): + for scene_index in range(256): + var normalized_path := "world/object_%d.wmo" % scene_index + if scene_index % 3 == 0: + cache_state.call("mark_missing", normalized_path) + else: + cache_state.call( + "remember_request", + normalized_path, + "res://cache/object_%d.tscn" % scene_index + ) + if scene_index % 2 == 0: + cache_state.call( + "complete_request_with_scene", + normalized_path, + PackedScene.new() + ) + else: + cache_state.call("complete_request_as_missing", normalized_path) + cache_state.call("clear_all") + _expect_equal( + int(cache_state.call("pending_request_count")), + 0, + "timing cycle %d clears" % iteration, + failures + ) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true( + elapsed_milliseconds < 1000.0, + "100 scene/request cycles remain bounded", + failures + ) + return elapsed_milliseconds + + +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_equal( + actual_value: int, + expected_value: int, + label: String, + failures: Array[String] +) -> void: + if actual_value != expected_value: + failures.append("%s expected %d, got %d" % [label, expected_value, actual_value]) + + +func _expect_string_equal( + actual_value: String, + expected_value: String, + label: String, + failures: Array[String] +) -> void: + if actual_value != expected_value: + failures.append("%s expected %s, got %s" % [label, expected_value, actual_value]) + + +func _expect_string_array( + actual_values: Array, + expected_values: Array, + label: String, + failures: Array[String] +) -> void: + if actual_values != expected_values: + failures.append("%s expected %s, got %s" % [label, expected_values, actual_values]) + + +func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void: + if not actual_value: + failures.append("%s expected true" % label) + + +func _expect_false(actual_value: bool, label: String, failures: Array[String]) -> void: + if actual_value: + failures.append("%s expected false" % label) diff --git a/src/tools/verify_wmo_scene_resource_cache_state.gd.uid b/src/tools/verify_wmo_scene_resource_cache_state.gd.uid new file mode 100644 index 0000000..df65d73 --- /dev/null +++ b/src/tools/verify_wmo_scene_resource_cache_state.gd.uid @@ -0,0 +1 @@ +uid://ccuuoqli83f6f