From d39f591e5054455f8930bfbaa1c794d07648efcb Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 17 Jul 2026 01:11:11 +0400 Subject: [PATCH] refactor(M03): extract WMO render resource cache state --- RENDER.md | 15 + docs/modules/README.md | 1 + docs/modules/wmo-render-build-queue.md | 6 +- .../wmo-render-resource-cache-state.md | 219 ++++++++++++ docs/modules/world-renderer.md | 18 +- .../wmo/wmo_render_resource_cache_state.gd | 126 +++++++ .../wmo_render_resource_cache_state.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 66 ++-- .../verify_render_runtime_cache_shutdown.gd | 22 +- .../verify_wmo_render_resource_cache_state.gd | 335 ++++++++++++++++++ ...ify_wmo_render_resource_cache_state.gd.uid | 1 + 11 files changed, 774 insertions(+), 36 deletions(-) create mode 100644 docs/modules/wmo-render-resource-cache-state.md create mode 100644 src/render/wmo/wmo_render_resource_cache_state.gd create mode 100644 src/render/wmo/wmo_render_resource_cache_state.gd.uid create mode 100644 src/tools/verify_wmo_render_resource_cache_state.gd create mode 100644 src/tools/verify_wmo_render_resource_cache_state.gd.uid diff --git a/RENDER.md b/RENDER.md index 9388981..25da9a7 100644 --- a/RENDER.md +++ b/RENDER.md @@ -31,6 +31,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `src/render/wmo/wmo_placement_registry.gd` - WMO placement-key to tile/global reference sets; Nodes and render jobs remain in the streamer. - `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/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. @@ -1047,6 +1048,20 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Cache formats, placement values, profiles and visible rules are unchanged. Asset-backed placement/p95/p99 and general WMO parity remain pending. +## 2026-07-17 WMO Render Resource Cache State Extraction + +- `WmoRenderResourceCacheState` now owns validated lightweight-WMO render + Resources, negative entries and normalized-path to pending-cache-path records. +- `StreamingWorldLoader` still constructs cache paths, calls `ResourceLoader`, + polls requests and validates `WMOStreamingResource` script identity plus + `FORMAT_VERSION` before completing cache state. +- Map reset and orderly request draining clear pending/negative state while + retaining accepted Resources; final runtime cache release clears all state. +- Missing render-cache files still are not negatively cached, preserving retry + and cached-scene/live fallback behavior. Formats, profiles and visuals are unchanged. +- Asset-backed corrupt-cache, 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 6c57989..87d9c2e 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -23,6 +23,7 @@ | WMO placement registry | Implemented extraction | [`wmo-placement-registry.md`](wmo-placement-registry.md) | | 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) | | 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-build-queue.md b/docs/modules/wmo-render-build-queue.md index 6c4a018..74cd499 100644 --- a/docs/modules/wmo-render-build-queue.md +++ b/docs/modules/wmo-render-build-queue.md @@ -21,7 +21,7 @@ all validation, planning, materialization, permit consumption and destruction. - Free or queue-free Nodes, Resources or RIDs. - Create MeshInstance3D/MultiMeshInstance3D or refresh materials. -- Own frame permits, placement references, caches or load requests. +- Own frame permits, placement references, render cache state or load I/O. - Change duplicate enqueue, stale-key cleanup, cancellation or ordering. - Change cache versions, WMO transforms, shadows, visibility or fidelity rules. @@ -213,7 +213,8 @@ without exposing mutable jobs or engine objects. No runtime logs are emitted. |---|---|---|---| | WMO render build pending-state ownership | Implemented | Lifecycle/source/timing verifier | Asset-backed traversal/leak evidence pending | | WMO group planning | Implemented | Build-step planner verifier | Asset-backed p95/p99 pending | -| WMO materialization/cache loading | Partial | Existing runtime regressions | Separate loader seams remain | +| WMO render Resource state | Implemented extraction | Cache-state lifecycle/source/timing verifier | Asset-backed traversal/leak evidence pending | +| WMO materialization/cache I/O | Partial | Existing runtime regressions | Cached-scene and I/O seams remain | ## Known gaps and risks @@ -236,6 +237,7 @@ without exposing mutable jobs or engine objects. No runtime logs are emitted. ## Related decisions and references - [`wmo-render-build-step-planner.md`](wmo-render-build-step-planner.md) +- [`wmo-render-resource-cache-state.md`](wmo-render-resource-cache-state.md) - [`wmo-placement-registry.md`](wmo-placement-registry.md) - [`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 new file mode 100644 index 0000000..801828c --- /dev/null +++ b/docs/modules/wmo-render-resource-cache-state.md @@ -0,0 +1,219 @@ +# WMO Render Resource Cache State + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | M03 / `M03-RND-WMO-RENDER-RESOURCE-CACHE-001` | +| Owners | Validated lightweight-WMO render Resources, negative cache and pending request paths | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-render-resource-cache`, 2026-07-17 | +| Profiles/capabilities | Lightweight WMO render-cache path; profile-independent | + +## Purpose + +Own the mutually exclusive cached, missing and pending states for normalized +lightweight-WMO render paths outside the monolithic streamer. The state holder +accepts only caller-validated `Resource` references and records cache paths for +threaded requests whose I/O lifecycle remains in `StreamingWorldLoader`. + +## Non-goals + +- Normalize WMO paths or construct `.res` cache paths. +- Call `ResourceLoader`, inspect files or validate script/format versions. +- Own WMO cached scenes, live prototypes, placements, build jobs or engine Nodes. +- Change fallback order, retry timing, quality profiles or visible output. +- Persist, migrate or build WMO caches. + +## Context and boundaries + +```mermaid +flowchart LR + Placement[Normalized WMO render lookup] --> Loader[StreamingWorldLoader] + Loader --> State[WmoRenderResourceCacheState] + State -->|cached Resource| Loader + Loader -->|cache path| ResourceLoader[Godot ResourceLoader] + ResourceLoader -->|status and loaded Resource| Loader + Loader --> Validate[Script and FORMAT_VERSION validation] + Validate -->|accepted Resource or missing| State + Loader --> Fallback[Cached scene or live-prototype fallback] +``` + +The module may retain validated `Resource` references and copied path Strings. +It has no FileAccess, ResourceLoader, Node, RID, worker, mutex, gameplay, network +or editor dependency. Cache validation stays at the I/O boundary in the loader. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `resource_for(path)` | Query | Borrow validated cached Resource | Renderer main thread; until full clear | Empty/unknown returns null | +| `contains_resource(path)` | Query | Test accepted cache state | Renderer main thread | Empty returns false | +| `is_missing(path)` | Query | Test negative cache state | Renderer main thread | Empty returns false | +| `has_request(path)` | Query | Test pending threaded-request state | Renderer main thread | Empty returns false | +| `remember_request(path, cache_path)` | Command/query | Record one loader-started request | Renderer main thread; until terminal/reset | Invalid or occupied state returns false | +| `request_paths_snapshot()` | Query | Copy pending normalized/cache-path mapping | Poll or shutdown drain | Detached Dictionary | +| `complete_request_with_resource(path, resource)` | Command/query | Remove pending request and adopt caller-validated Resource | Terminal loader poll | Unknown/null returns false | +| `complete_request_as_missing(path)` | Command/query | Remove pending request and adopt negative state | Terminal loader poll | Unknown returns false | +| `clear_transient_state()` | Command | Clear pending and missing while retaining accepted Resources | Map reset/request drain | Idempotent | +| `clear_all()` | Command | Release Resources, pending and missing | Final runtime cache release | Idempotent | +| `pending_request_count()` | Query | Preserve renderer queue metric contribution | Renderer diagnostics | None | +| `diagnostic_snapshot()` | Query | Return sorted detached scalar keys | Tests/diagnostics | Never exposes Resources | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Normalized relative WMO path | Placement resolver through loader | Cache state | Copied String | Map/cache lifetime | +| Input | `.res` cache path after accepted threaded-request result | Loader | Pending state | Copied String | Until terminal/reset | +| Input | Script/version-validated WMO Resource | Loader validation adapter | Resource cache | Strong reference | Until full clear | +| Output | Borrowed validated Resource | Cache state | Loader render-root/build adapters | State retains reference | Lookup/build start | +| Output | Detached pending-path snapshot | Cache state | Loader ResourceLoader polling/shutdown | Caller-owned Dictionary | One drain pass | +| Output | Sorted scalar diagnostic keys | Cache state | Verifier/future metrics | Caller-owned values | Snapshot lifetime | + +## Data flow + +```mermaid +flowchart TD + Lookup[Lookup normalized path] --> Cached{Cached?} + Cached -->|yes| Return[Return validated Resource] + Cached -->|no| Blocked{Missing or pending?} + Blocked -->|yes| Null[Return null; loader waits/falls back] + Blocked -->|no| Start[Loader starts threaded request] + Start --> Remember[remember_request] + Remember --> Poll[Loader polls detached request snapshot] + Poll --> Terminal{Loaded or failed?} + Terminal -->|no| Poll + Terminal -->|failed| Missing[complete as missing] + Terminal -->|loaded| Validate[Loader validates script/version] + Validate -->|valid| Adopt[complete with Resource] + Validate -->|invalid| Missing +``` + +## Lifecycle/state + +Each normalized path is `Absent`, `Pending`, `Cached` or `Missing`. Valid request +completion leaves exactly one terminal state. Transient clear moves `Pending` and +`Missing` to `Absent` but retains `Cached`; full clear moves every state to +`Absent` and releases retained Resources. + +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Pending: remember valid request + Pending --> Cached: validated Resource completion + Pending --> Missing: load/validation failure + Pending --> Absent: clear transient/full + Missing --> Absent: clear transient/full + Cached --> Absent: full clear +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant State as WmoRenderResourceCacheState + participant RL as ResourceLoader + Loader->>State: cached/missing/pending queries + Loader->>RL: exists + load_threaded_request(cache path) + Loader->>State: remember_request(normalized, cache path) + loop renderer tick + Loader->>State: request_paths_snapshot() + Loader->>RL: load_threaded_get_status(cache path) + end + alt load failed + Loader->>State: complete_request_as_missing(normalized) + else loaded + Loader->>RL: load_threaded_get(cache path) + Loader->>Loader: validate script and FORMAT_VERSION + Loader->>State: complete with Resource or as missing + end +``` + +## Ownership, threading and resources + +- The state owns three Dictionaries and strong references to accepted Resources. +- The loader owns normalization, cache paths, ResourceLoader calls and validation. +- All mutation is serialized by the renderer main-thread lookup/drain lifecycle. +- No mutex or callback is needed; detached request snapshots permit safe removal. +- The loader/build queue borrow Resources without transferring ownership. + +## Errors, cancellation and recovery + +| Failure/state | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Empty path/cache path | Request guard | Reject unchanged | Contract verifier | Repair caller input | +| Duplicate or occupied path | State membership | Reject request unchanged | Contract/source verifier | Await terminal/reset | +| Threaded load failed | Loader status | Complete as missing | Existing renderer metrics | Transient reset permits retry | +| Wrong script/old format | Loader validation | Complete as missing | Cache-version source gate | Rebuild cache, then reset | +| Shutdown while pending | Loader drains ResourceLoader snapshot | Clear transient afterward | Shutdown verifier | Next runtime starts absent | +| Final cache release | Loader shutdown lifecycle | Full clear releases Resources | Shutdown verifier | Recreated with loader | + +## Configuration and capabilities + +The module adds no setting or profile branch. `wmo_render_cache_dir`, resource +format version, request scheduling and WMO build budgets remain loader-owned. + +## Persistence, cache and migration + +The state is runtime-only and serializes nothing. `WMOStreamingResource` script +identity and `FORMAT_VERSION` validation remain unchanged in the loader; no cache +migration or rebuild is introduced by this extraction. + +## Diagnostics and observability + +`pending_request_count` preserves the existing `wmobuild` metric contribution. +Diagnostics expose only sorted normalized-path keys for cached/missing/pending +states; Resource references and cache file paths are not exposed. No logs emit. + +## Verification + +- `verify_wmo_render_resource_cache_state.gd`: invalid input, detached requests, + successful/missing terminal transitions, exclusivity, partial/full reset, + sorted diagnostics, ownership/source boundary and bounded timing. +- `verify_render_runtime_cache_shutdown.gd`: full release of accepted Resources + and negative state through the loader lifecycle. +- WMO queue/planner/registry/resolver and surrounding renderer regressions verify + unchanged fallback/build behavior. This package makes no visual parity claim. + +## Extension points + +- Cached WMO scene request state can use 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. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| 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 | +| WMO materialization | Partial/loader-owned | Queue/planner regressions | Further safe extraction | + +## Known gaps and risks + +- Strong Resource references live until final cache release, matching prior state. +- Missing cache files remain unrecorded for render Resources, so lookup can retry; + this intentionally differs from the cached-scene path. +- No proprietary WMO corpus, corrupt-version fixture, leak run, traversal p95/p99 + or paired original-client capture is included. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/wmo/wmo_render_resource_cache_state.gd` | Resource/missing/request state and resets | +| `src/scenes/streaming/streaming_world_loader.gd` | Normalize, request, poll, validate, fallback and shutdown I/O | +| `src/tools/verify_wmo_render_resource_cache_state.gd` | State, boundary and timing regression | +| `src/tools/verify_render_runtime_cache_shutdown.gd` | Loader final Resource ownership regression | + +## Related decisions and references + +- [`wmo-render-build-queue.md`](wmo-render-build-queue.md) +- [`wmo-placement-resolver.md`](wmo-placement-resolver.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 664bbae..e055ac2 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-build-queue`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-render-resource-cache`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -136,6 +136,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `WmoPlacementRegistry.add_reference/release_reference/contains/active_count/diagnostic_snapshot/clear` | Internal WMO service | Owns placement-key to tile/global reference sets | Renderer main thread; map session | Empty/unknown/non-owner input is rejected without mutation | | `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 | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -168,6 +169,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal WMO ownership | Resolved placement key and tile/global reference key | Loader / `WmoPlacementRegistry` | Loader create/retain/final-free decisions | Registry-owned String sets; detached diagnostics | Map session or final release | | 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 | | 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 | @@ -346,9 +348,11 @@ sequenceDiagram identity and transform values. `WmoPlacementRegistry` owns only placement-key reference sets. `WmoRenderBuildStepPlanner` owns only a call-local operation and cursor plan. `WmoRenderBuildQueue` owns typed pending jobs, FIFO keys and - strong root/resource references without freeing engine objects. The loader - retains the placement key-to-Node map, caches/loads, materialization, permits, - validity reactions and every Node lifecycle action. + 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. - 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 @@ -448,6 +452,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m transition, completion/raw integer behavior, source ownership and bounded timing. - WMO render build queue contract: typed references/cursors, FIFO, duplicate replacement, stale-front cleanup, cancel/clear engine lifetime and bounded 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. - 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. @@ -484,7 +491,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | WMO placement resolver | Implemented extraction | Scene-free path/identity/transform/source/timing contract | Asset-backed comparison pending | | 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 | Cache/load state and asset-backed traversal/leak evidence 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 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_render_resource_cache_state.gd b/src/render/wmo/wmo_render_resource_cache_state.gd new file mode 100644 index 0000000..299602d --- /dev/null +++ b/src/render/wmo/wmo_render_resource_cache_state.gd @@ -0,0 +1,126 @@ +class_name WmoRenderResourceCacheState +extends RefCounted + +## Owns validated lightweight-WMO render resources and their load state. +## ResourceLoader I/O and cache-format validation remain caller responsibilities. + +var _resources_by_normalized_path: Dictionary = {} +var _missing_normalized_paths: Dictionary = {} +var _request_cache_paths_by_normalized_path: Dictionary = {} + + +## Returns the validated cached Resource for a normalized WMO path, or null. +func resource_for(normalized_relative_path: String) -> Resource: + if normalized_relative_path.is_empty(): + return null + return _resources_by_normalized_path.get(normalized_relative_path) as Resource + + +## Returns whether a validated Resource is cached for the normalized WMO path. +func contains_resource(normalized_relative_path: String) -> bool: + return ( + not normalized_relative_path.is_empty() + and _resources_by_normalized_path.has(normalized_relative_path) + ) + + +## Returns whether the normalized WMO 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 WMO 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-owned threaded request. Cached, missing, duplicate or empty +## paths are rejected so each normalized WMO path has exactly one state. +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 ( + _resources_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) + ): + return false + _request_cache_paths_by_normalized_path[normalized_relative_path] = cache_path + return true + + +## Returns a detached normalized-path to cache-path snapshot for loader-owned +## ResourceLoader polling or shutdown draining. +func request_paths_snapshot() -> Dictionary: + return _request_cache_paths_by_normalized_path.duplicate() + + +## Completes a pending request with a caller-validated Resource. Returns false +## for unknown requests or null Resources and otherwise adopts the reference. +func complete_request_with_resource( + normalized_relative_path: String, + validated_resource: Resource +) -> bool: + if ( + normalized_relative_path.is_empty() + or validated_resource == 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) + _resources_by_normalized_path[normalized_relative_path] = validated_resource + return true + + +## Completes a pending request as missing after load failure or validation +## rejection. Returns false for an unknown request. +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) + _resources_by_normalized_path.erase(normalized_relative_path) + _missing_normalized_paths[normalized_relative_path] = true + return true + + +## Clears pending requests and negative entries while retaining validated +## Resources for map reset or orderly shutdown request draining. +func clear_transient_state() -> void: + _request_cache_paths_by_normalized_path.clear() + _missing_normalized_paths.clear() + + +## Releases every cached Resource and transient entry. Safe to call repeatedly. +func clear_all() -> void: + _resources_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 Resource references. +func diagnostic_snapshot() -> Dictionary: + var resource_paths: Array = _resources_by_normalized_path.keys() + var missing_paths: Array = _missing_normalized_paths.keys() + var request_paths: Array = _request_cache_paths_by_normalized_path.keys() + resource_paths.sort() + missing_paths.sort() + request_paths.sort() + return { + "resource_paths": resource_paths.duplicate(), + "missing_paths": missing_paths.duplicate(), + "request_paths": request_paths.duplicate(), + } diff --git a/src/render/wmo/wmo_render_resource_cache_state.gd.uid b/src/render/wmo/wmo_render_resource_cache_state.gd.uid new file mode 100644 index 0000000..72378c7 --- /dev/null +++ b/src/render/wmo/wmo_render_resource_cache_state.gd.uid @@ -0,0 +1 @@ +uid://b1g4s1rqrptsw diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 824fe2d..eff46b4 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -21,6 +21,9 @@ const WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT := preload( const WMO_RENDER_BUILD_QUEUE_SCRIPT := preload( "res://src/render/wmo/wmo_render_build_queue.gd" ) +const WMO_RENDER_RESOURCE_CACHE_STATE_SCRIPT := preload( + "res://src/render/wmo/wmo_render_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") @@ -280,9 +283,7 @@ var _tile_mesh_cache: Dictionary = {} var _tile_mesh_cache_order: Array = [] var _terrain_quality_mesh_cache: RefCounted = TERRAIN_QUALITY_MESH_CACHE_SCRIPT.new() var _wmo_prototype_cache: Dictionary = {} -var _wmo_render_cache: Dictionary = {} -var _wmo_render_missing_cache: Dictionary = {} -var _wmo_render_load_requests: 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 = {} @@ -467,9 +468,8 @@ func _release_runtime_caches_for_shutdown() -> void: _m2_mesh_cache.clear() _m2_static_animation_cache.clear() _m2_missing_cache.clear() - _wmo_render_cache.clear() + _wmo_render_resource_cache_state.clear_all() _wmo_scene_resource_cache.clear() - _wmo_render_missing_cache.clear() _wmo_scene_cache_missing.clear() _wmo_missing_cache.clear() _shared_tex_cache.clear() @@ -1005,7 +1005,11 @@ func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bo _m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(), _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), _m2_build_queue.size(), - _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(), + ( + _wmo_build_queue.size() + + _wmo_scene_load_requests.size() + + _wmo_render_resource_cache_state.pending_request_count() + ), _wmo_render_build_queue.pending_count(), _tile_lod_create_queue.size(), _tile_lod_remove_queue.size(), @@ -1032,7 +1036,11 @@ func render_baseline_snapshot() -> Dictionary: "m2_animation": _m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(), "m2_mesh": _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), "m2_build": _m2_build_queue.size(), - "wmo_build": _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(), + "wmo_build": ( + _wmo_build_queue.size() + + _wmo_scene_load_requests.size() + + _wmo_render_resource_cache_state.pending_request_count() + ), "wmo_groups": _wmo_render_build_queue.pending_count(), "lod_create": _tile_lod_create_queue.size(), "lod_remove": _tile_lod_remove_queue.size(), @@ -1314,7 +1322,11 @@ func _tick_runtime_stats(delta: float) -> void: _m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(), _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), _m2_build_queue.size(), - _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(), + ( + _wmo_build_queue.size() + + _wmo_scene_load_requests.size() + + _wmo_render_resource_cache_state.pending_request_count() + ), _wmo_render_build_queue.pending_count(), _tile_lod_create_queue.size(), _tile_lod_remove_queue.size(), @@ -2356,15 +2368,17 @@ func _wait_for_tile_tasks() -> void: ResourceLoader.load_threaded_get(path) _wmo_scene_load_requests.clear() - for path_variant in _wmo_render_load_requests.values(): + var wmo_render_request_paths := ( + _wmo_render_resource_cache_state.request_paths_snapshot() + ) + for path_variant in wmo_render_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_render_load_requests.clear() - _wmo_render_missing_cache.clear() + _wmo_render_resource_cache_state.clear_transient_state() _wmo_scene_cache_missing.clear() _wmo_render_build_queue.clear() @@ -3002,8 +3016,7 @@ func _clear_streamed_world() -> void: _wmo_build_jobs.clear() _wmo_build_queue.clear() _wmo_scene_load_requests.clear() - _wmo_render_load_requests.clear() - _wmo_render_missing_cache.clear() + _wmo_render_resource_cache_state.clear_transient_state() _wmo_scene_cache_missing.clear() _wmo_render_build_queue.clear() @@ -3589,7 +3602,7 @@ func _process_wmo_build_jobs() -> void: var normalized_rel := _wmo_placement_resolver.normalize_relative_path( rel_path ) - if _wmo_render_load_requests.has(normalized_rel): + if _wmo_render_resource_cache_state.has_request(normalized_rel): advance_job = false else: var scene := _get_wmo_scene_or_request(rel_path) @@ -3667,15 +3680,15 @@ func _drain_wmo_scene_loads() -> void: func _drain_wmo_render_loads() -> void: - for normalized_rel_variant in _wmo_render_load_requests.keys(): + var request_paths := _wmo_render_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_render_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_render_load_requests.erase(normalized_rel) if status != ResourceLoader.THREAD_LOAD_LOADED: - _wmo_render_missing_cache[normalized_rel] = true + _wmo_render_resource_cache_state.complete_request_as_missing(normalized_rel) continue var resource: Resource = ResourceLoader.load_threaded_get(path) if ( @@ -3683,20 +3696,23 @@ func _drain_wmo_render_loads() -> void: and resource.get_script() == WMO_STREAMING_SCRIPT and int(resource.get("format_version")) >= WMO_STREAMING_SCRIPT.FORMAT_VERSION ): - _wmo_render_cache[normalized_rel] = resource + _wmo_render_resource_cache_state.complete_request_with_resource( + normalized_rel, + resource + ) else: - _wmo_render_missing_cache[normalized_rel] = true + _wmo_render_resource_cache_state.complete_request_as_missing(normalized_rel) func _get_wmo_render_or_request(rel_path: String) -> Resource: var normalized_rel := _wmo_placement_resolver.normalize_relative_path(rel_path) if normalized_rel.is_empty(): return null - if _wmo_render_cache.has(normalized_rel): - return _wmo_render_cache[normalized_rel] - if _wmo_render_missing_cache.has(normalized_rel): + if _wmo_render_resource_cache_state.contains_resource(normalized_rel): + return _wmo_render_resource_cache_state.resource_for(normalized_rel) + if _wmo_render_resource_cache_state.is_missing(normalized_rel): return null - if _wmo_render_load_requests.has(normalized_rel): + if _wmo_render_resource_cache_state.has_request(normalized_rel): return null var cache_path := wmo_render_cache_dir.path_join(normalized_rel.get_basename() + ".res") @@ -3709,7 +3725,7 @@ func _get_wmo_render_or_request(rel_path: String) -> Resource: false, ResourceLoader.CACHE_MODE_REUSE) if err == OK or err == ERR_BUSY: - _wmo_render_load_requests[normalized_rel] = cache_path + _wmo_render_resource_cache_state.remember_request(normalized_rel, cache_path) return null diff --git a/src/tools/verify_render_runtime_cache_shutdown.gd b/src/tools/verify_render_runtime_cache_shutdown.gd index 735e954..e76259b 100644 --- a/src/tools/verify_render_runtime_cache_shutdown.gd +++ b/src/tools/verify_render_runtime_cache_shutdown.gd @@ -38,9 +38,12 @@ func _verify_runtime_cache_ownership() -> void: loader.set("_m2_mesh_cache", {"mesh": ArrayMesh.new()}) loader.set("_m2_static_animation_cache", {"static": true}) loader.set("_m2_missing_cache", {"missing": true}) - loader.set("_wmo_render_cache", {"render": Resource.new()}) + var wmo_render_cache_state: RefCounted = loader.get("_wmo_render_resource_cache_state") + wmo_render_cache_state.call("remember_request", "render", "res://render.res") + 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_render_missing_cache", {"missing": true}) loader.set("_wmo_scene_cache_missing", {"missing": true}) loader.set("_wmo_missing_cache", {"missing": true}) loader.set("_shared_tex_cache", {"texture": ImageTexture.new()}) @@ -57,15 +60,26 @@ func _verify_runtime_cache_ownership() -> void: "_m2_mesh_cache", "_m2_static_animation_cache", "_m2_missing_cache", - "_wmo_render_cache", "_wmo_scene_resource_cache", - "_wmo_render_missing_cache", "_wmo_scene_cache_missing", "_wmo_missing_cache", "_shared_tex_cache", ]: var cache: Dictionary = loader.get(cache_name) _expect(cache.is_empty(), "%s should be empty after shutdown" % cache_name) + var wmo_render_snapshot: Dictionary = wmo_render_cache_state.call("diagnostic_snapshot") + _expect( + (wmo_render_snapshot["resource_paths"] as Array).is_empty(), + "WMO render Resources should be empty after shutdown" + ) + _expect( + (wmo_render_snapshot["missing_paths"] as Array).is_empty(), + "WMO render negative cache should be empty after shutdown" + ) + _expect( + (wmo_render_snapshot["request_paths"] as Array).is_empty(), + "WMO render requests should be empty after shutdown" + ) loader.free() diff --git a/src/tools/verify_wmo_render_resource_cache_state.gd b/src/tools/verify_wmo_render_resource_cache_state.gd new file mode 100644 index 0000000..efadc27 --- /dev/null +++ b/src/tools/verify_wmo_render_resource_cache_state.gd @@ -0,0 +1,335 @@ +extends SceneTree + +## Asset-free lifecycle, ownership, dependency and timing regression for the +## lightweight-WMO render Resource cache state. + +const CACHE_STATE_SCRIPT := preload( + "res://src/render/wmo/wmo_render_resource_cache_state.gd" +) +const CACHE_STATE_PATH := "res://src/render/wmo/wmo_render_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_validated_resource_completion(failures) + _verify_missing_completion_and_exclusivity(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_RENDER_RESOURCE_CACHE_STATE: %s" % failure) + quit(1) + return + print( + "WMO_RENDER_RESOURCE_CACHE_STATE PASS cases=10 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.res"), + "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.res"), + "valid request accepted", + failures + ) + _expect_true( + cache_state.call("has_request", "world/a.wmo"), + "pending request observable", + failures + ) + _expect_false( + cache_state.call("remember_request", "world/a.wmo", "res://cache/other.res"), + "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.res", + "request cache path preserved", + failures + ) + request_snapshot.clear() + _expect_true( + cache_state.call("has_request", "world/a.wmo"), + "request snapshot detached", + failures + ) + + +func _verify_validated_resource_completion(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + var validated_resource := Resource.new() + cache_state.call("remember_request", "world/b.wmo", "res://cache/b.res") + _expect_false( + cache_state.call("complete_request_with_resource", "world/b.wmo", null), + "null completion rejected", + failures + ) + _expect_true( + cache_state.call( + "complete_request_with_resource", + "world/b.wmo", + validated_resource + ), + "validated Resource completes request", + failures + ) + _expect_false( + cache_state.call("has_request", "world/b.wmo"), + "successful completion removes request", + failures + ) + _expect_true( + cache_state.call("contains_resource", "world/b.wmo"), + "successful completion cached", + failures + ) + _expect_true( + cache_state.call("resource_for", "world/b.wmo") == validated_resource, + "cached Resource identity preserved", + failures + ) + _expect_false( + cache_state.call("remember_request", "world/b.wmo", "res://cache/b2.res"), + "cached path rejects request", + failures + ) + + +func _verify_missing_completion_and_exclusivity(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 completion rejected", + failures + ) + cache_state.call("remember_request", "world/missing.wmo", "res://cache/missing.res") + _expect_true( + cache_state.call("complete_request_as_missing", "world/missing.wmo"), + "failed request completes as missing", + failures + ) + _expect_true( + cache_state.call("is_missing", "world/missing.wmo"), + "negative cache observable", + failures + ) + _expect_false( + cache_state.call("has_request", "world/missing.wmo"), + "missing completion removes request", + failures + ) + _expect_false( + cache_state.call( + "remember_request", + "world/missing.wmo", + "res://cache/retry.res" + ), + "negative cache rejects request", + failures + ) + + +func _verify_transient_and_full_clear(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + var retained_resource := Resource.new() + cache_state.call("remember_request", "world/retained.wmo", "res://cache/retained.res") + cache_state.call( + "complete_request_with_resource", + "world/retained.wmo", + retained_resource + ) + cache_state.call("remember_request", "world/pending.wmo", "res://cache/pending.res") + cache_state.call("remember_request", "world/missing.wmo", "res://cache/missing.res") + cache_state.call("complete_request_as_missing", "world/missing.wmo") + cache_state.call("clear_transient_state") + _expect_true( + cache_state.call("resource_for", "world/retained.wmo") == retained_resource, + "transient clear retains validated Resource", + 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 negative cache", + failures + ) + cache_state.call("clear_all") + cache_state.call("clear_all") + _expect_false( + cache_state.call("contains_resource", "world/retained.wmo"), + "full clear idempotently releases Resource", + failures + ) + + +func _verify_detached_sorted_diagnostics(failures: Array[String]) -> void: + var cache_state: RefCounted = CACHE_STATE_SCRIPT.new() + for normalized_path in ["world/z.wmo", "world/a.wmo"]: + cache_state.call("remember_request", normalized_path, "res://cache/%s.res" % normalized_path) + var snapshot: Dictionary = cache_state.call("diagnostic_snapshot") + _expect_string_array( + snapshot["request_paths"], + ["world/a.wmo", "world/z.wmo"], + "request diagnostics sorted", + 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_RENDER_RESOURCE_CACHE_STATE_SCRIPT.new()"), + "loader composes cache state", + failures + ) + for legacy_field in [ + "var _wmo_render_cache:", + "var _wmo_render_missing_cache:", + "var _wmo_render_load_requests:", + ]: + _expect_false( + loader_source.contains(legacy_field), + "loader removes %s" % legacy_field, + failures + ) + for loader_owned_token in [ + "ResourceLoader.", + "FileAccess.", + "WMO_STREAMING_SCRIPT", + "format_version", + "Node3D", + "RID", + "Thread", + "Mutex", + "Semaphore", + ]: + _expect_false( + cache_state_source.contains(loader_owned_token), + "cache state omits %s dependency" % loader_owned_token, + failures + ) + _expect_true( + loader_source.contains("resource.get_script() == WMO_STREAMING_SCRIPT"), + "loader retains script validation", + failures + ) + _expect_true( + loader_source.contains("WMO_STREAMING_SCRIPT.FORMAT_VERSION"), + "loader retains cache format validation", + 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 resource_index in range(256): + var normalized_path := "world/object_%d.wmo" % resource_index + cache_state.call( + "remember_request", + normalized_path, + "res://cache/object_%d.res" % resource_index + ) + if resource_index % 2 == 0: + cache_state.call( + "complete_request_with_resource", + normalized_path, + Resource.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 resource/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_render_resource_cache_state.gd.uid b/src/tools/verify_wmo_render_resource_cache_state.gd.uid new file mode 100644 index 0000000..336952a --- /dev/null +++ b/src/tools/verify_wmo_render_resource_cache_state.gd.uid @@ -0,0 +1 @@ +uid://b5kuqdm8au7o1