From 3a6b1d306d2a3d14b7b07b550e22c203b65797a0 Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 17 Jul 2026 00:47:54 +0400 Subject: [PATCH 1/2] refactor(M03): extract WMO render build planner --- RENDER.md | 3 + docs/modules/README.md | 1 + docs/modules/wmo-render-build-step-planner.md | 185 +++++++++++++++ docs/modules/world-renderer.md | 17 +- .../wmo/wmo_render_build_step_planner.gd | 40 ++++ .../wmo/wmo_render_build_step_planner.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 51 +++-- .../verify_wmo_render_build_step_planner.gd | 213 ++++++++++++++++++ ...erify_wmo_render_build_step_planner.gd.uid | 1 + 9 files changed, 490 insertions(+), 22 deletions(-) create mode 100644 docs/modules/wmo-render-build-step-planner.md create mode 100644 src/render/wmo/wmo_render_build_step_planner.gd create mode 100644 src/render/wmo/wmo_render_build_step_planner.gd.uid create mode 100644 src/tools/verify_wmo_render_build_step_planner.gd create mode 100644 src/tools/verify_wmo_render_build_step_planner.gd.uid diff --git a/RENDER.md b/RENDER.md index a806078..bc48cd3 100644 --- a/RENDER.md +++ b/RENDER.md @@ -29,6 +29,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `src/scenes/streaming/streaming_world_loader.gd` - внутренний runtime streamer мира. - `src/render/wmo/wmo_placement_resolver.gd` - value-only WMO path, identity and transform rules. - `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/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. @@ -398,6 +399,8 @@ Known limits: - WMO на границе ADT не дублируются; - `WmoPlacementRegistry` owns only placement reference sets; the streamer keeps the parallel key-to-Node map and performs final-release cancellation/free; +- `WmoRenderBuildStepPlanner` selects one mesh or MultiMesh group and its next + cursors; the streamer still materializes Nodes and consumes the frame permit; - большие WMO не должны инстанцироваться как тяжелые `.tscn` во время движения; - добавлен lightweight render-cache path для WMO groups; - `wmo_render_group_ops_per_tick` ограничивает подключение WMO groups по кадрам; diff --git a/docs/modules/README.md b/docs/modules/README.md index 7d19f99..b56a0a2 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -21,6 +21,7 @@ | M2 build batch planner | Implemented extraction | [`m2-build-batch-planner.md`](m2-build-batch-planner.md) | | WMO placement resolver | Implemented extraction | [`wmo-placement-resolver.md`](wmo-placement-resolver.md) | | 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) | | 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-step-planner.md b/docs/modules/wmo-render-build-step-planner.md new file mode 100644 index 0000000..fdae2c9 --- /dev/null +++ b/docs/modules/wmo-render-build-step-planner.md @@ -0,0 +1,185 @@ +# WMO Render Build Step Planner + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | M03 / `M03-RND-WMO-RENDER-BUILD-PLANNER-001` | +| Owners | Pure mesh-first WMO render-group cursor decision | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-render-build-planner`, 2026-07-17 | +| Profiles/capabilities | Lightweight WMO render-cache path; profile-independent | + +## Purpose + +Select exactly one next lightweight WMO render build operation from mesh and +MultiMesh counts/cursors. The planner makes the ordering and cursor transition +testable without loading a WMO, creating a Node or consuming a frame budget. + +## Non-goals + +- Own render Resources, Nodes, materials, jobs, queues or permits. +- Read WMO arrays, create group instances or adopt cursors in a job. +- Change group ordering, names, transforms, shadows or visibility. +- Load, validate, version or persist a WMO cache. +- Implement portals, rooms, doodad semantics or WMO visual parity. + +## Context and boundaries + +```mermaid +flowchart LR + Cache[WMOStreamingResource arrays] --> Loader[StreamingWorldLoader] + Job[Loader mesh/MultiMesh cursors] --> Loader + Loader --> Planner[WmoRenderBuildStepPlanner] + Planner --> Step[Operation + selected index + next cursors] + Step --> Loader + Loader --> Material[Material refresh] + Loader --> Node[MeshInstance3D or MultiMeshInstance3D] + Loader --> Budget[RenderBudgetScheduler permit] +``` + +The planner depends only on `RefCounted`, integers, `StringName` and a fresh +Dictionary result. Node, Resource, Mesh, MultiMesh, RID, SceneTree, files, +threads, locks, caches, gameplay, network and editor UI are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `OPERATION_MESH` | Constant | Identifies one mesh-group operation | Immutable | None | +| `OPERATION_MULTIMESH` | Constant | Identifies one doodad MultiMesh-group operation | Immutable | None | +| `OPERATION_COMPLETE` | Constant | Identifies exhausted mesh and MultiMesh ranges | Immutable | None | +| `plan_step(mesh_count, mesh_index, multimesh_count, multimesh_index)` | Pure query | Select one operation, selected index and next cursors | Any thread; caller-owned result | No clamp/error branch; raw integer comparisons are preserved | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Mesh array count and current mesh cursor | Loader resource/job adapter | Planner | Integer values | One call | +| Input | MultiMesh array count and current MultiMesh cursor | Loader resource/job adapter | Planner | Integer values | One call | +| Output | `mesh`, `multimesh` or `complete` operation | Planner | Loader materializer/cancellation branch | Immutable `StringName` | One call | +| Output | Selected index | Planner | Loader Resource array access | Integer value | One operation | +| Output | Next mesh and MultiMesh cursors | Planner | Loader job adoption | Fresh Dictionary values | Until adopted/discarded | + +The planner retains no input or output. Scene-tree mutation, material refresh, +job writes, permit consumption and cancellation are loader side effects. + +## Data flow + +```mermaid +flowchart TD + Input[Counts and cursors] --> MeshRemaining{mesh index < mesh count?} + MeshRemaining -->|yes| MeshStep[mesh at mesh index; increment mesh cursor] + MeshRemaining -->|no| MultiRemaining{MultiMesh index < MultiMesh count?} + MultiRemaining -->|yes| MultiStep[MultiMesh at its index; increment MultiMesh cursor] + MultiRemaining -->|no| Complete[complete; keep both cursors] +``` + +## Lifecycle/state + +The planner is stateless. State belongs to the loader job. Repeated loader calls +advance through `mesh` steps, then `multimesh` steps, then `complete`. Planner +construction, cancellation, map reset and shutdown require no cleanup. + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant Planner as WmoRenderBuildStepPlanner + participant Scene as SceneTree + participant Budget as RenderBudgetScheduler + Loader->>Planner: plan_step(counts, cursors) + Planner-->>Loader: operation, selected index, next cursors + alt mesh or MultiMesh + Loader->>Loader: refresh material and create group Node + Loader->>Scene: attach Node and editor owner + Loader->>Loader: adopt returned cursor + Loader->>Budget: consume one WMO group permit + else complete + Loader->>Loader: cancel/remove job and queue key + end +``` + +## Ownership, threading and resources + +- The planner owns only call-local scalar comparisons and the returned value map. +- The loader owns Resources, mesh arrays, build jobs/queue and cursor adoption. +- The loader creates and owns every MeshInstance3D/MultiMeshInstance3D and + performs material refresh and editor-owner assignment on the main thread. +- `RenderBudgetScheduler` owns permits; the planner neither reads nor consumes one. +- Pure calls are thread-safe because the planner has no mutable state. + +## Errors, cancellation and recovery + +| Failure/state | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Empty arrays | Both `0 < 0` comparisons false | Return `complete` | Contract verifier | Loader removes job | +| Cursor at/beyond count | Raw integer comparison | Skip that range | Contract verifier | Continue next range or complete | +| Negative cursor/count | Raw historical comparison | No clamp; select exactly as prior loader condition | Edge fixture | Caller should maintain valid job cursors | +| Missing/invalid Resource | Loader validation before planning | Loader cancels job | Existing shutdown/runtime diagnostics | Reload/requeue placement | +| Root freed | Loader validity check before planning | Loader cancels job | Runtime-cache shutdown verifier | Placement may rebuild later | +| Placement final release | Loader/registry lifecycle | Loader cancels queued build | Registry regression | Later placement starts at cursor zero | + +## Configuration and capabilities + +The planner has no setting or profile branch. `wmo_render_group_ops_per_tick`, +quality presets, shadow/visibility settings and cache limits remain loader-owned. + +## Persistence, cache and migration + +No data is persisted. WMO render resource format/version, cache path and job +Dictionary fields are unchanged. No migration or cache rebuild is required. + +## Diagnostics and observability + +The planner emits no logs. Its verifier reports operation/cursor/source contracts +and bounded timing. Queue lengths, active WMO count and hitch sections remain in +existing loader/facade diagnostics. + +## Verification + +- `verify_wmo_render_build_step_planner.gd`: mesh-first order, cursor increments, + completion, empty and raw negative integer behavior, fresh results, loader + delegation, dependency boundary and 20,000-plan timing. +- WMO registry/resolver/material/shutdown plus renderer regressions verify the + unchanged ownership and surrounding runtime path. +- Fidelity evidence is exact extraction of existing mesh-before-MultiMesh and + one-index-per-permit behavior. No asset-backed build-12340 visual claim is made. + +## Extension points + +- WMO render build job/queue ownership may be extracted separately after a typed + cancellation and Resource lifetime contract exists. +- Materialization can later receive typed commands without changing this planner. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| WMO render build step planning | Implemented | Pure contract/source/timing verifier | Asset-backed traversal p95/p99 pending | +| WMO build job/resource ownership | Partial | Existing loader/runtime regressions | Separate state/cancellation package | +| WMO visual/portal/material fidelity | Partial | Renderer baseline/material checks | Original-client paired evidence pending | + +## Known gaps and risks + +- Loader build jobs remain untyped Dictionaries. +- Resource array shape/length consistency remains a cache-builder invariant. +- No proprietary WMO corpus, traversal profile or paired client capture is part + of this extraction. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/wmo/wmo_render_build_step_planner.gd` | Pure operation and cursor selection | +| `src/scenes/streaming/streaming_world_loader.gd` | Resource validation, jobs/queues, materialization, permits and cancellation | +| `src/tools/verify_wmo_render_build_step_planner.gd` | Contract, boundary and timing regression | + +## Related decisions and references + +- [`wmo-placement-registry.md`](wmo-placement-registry.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 03e1a53..a77d9c5 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-placement-registry`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-render-build-planner`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -58,6 +58,8 @@ flowchart LR WmoPlacement --> Loader Loader --> WmoRegistry[WmoPlacementRegistry] WmoRegistry --> Loader + Loader --> WmoBuildStep[WmoRenderBuildStepPlanner] + WmoBuildStep --> Loader Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -130,6 +132,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `M2BuildBatchPlanner.plan_batch` | Internal pure M2 service | Selects static/animated batch count and next group cursor | Main/any thread; stateless | Non-positive selected limit clamps to one; empty range completes | | `WmoPlacementResolver.normalize_relative_path/resolve_unique_key/resolve_world_transform` | Internal pure WMO service | Resolves cache key, registry identity and world transform | Main/any thread; stateless | Missing UID uses tile/index fallback; transform fields use historical defaults | | `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 | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -160,6 +163,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal batch plan | Transform count/offset, path kind and limits | Loader / `M2BuildBatchPlanner` | Loader materialization/cursor adapter | Fresh scalar Dictionary | One build operation | | Internal WMO placement | Path, MODF placement, tile/index | Loader / `WmoPlacementResolver` | WMO caches, registry and three instance adapters | Value-only String/Transform3D | Lookup/placement lifetime | | 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 | | 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 | @@ -210,7 +214,8 @@ flowchart TD M2Batch --> M2 R --> WmoPlacement[WmoPlacementResolver] WmoPlacement --> WmoRegistry[WmoPlacementRegistry] - WmoRegistry --> WMO + WmoRegistry --> WmoBuildStep[WmoRenderBuildStepPlanner] + WmoBuildStep --> WMO R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] @@ -334,8 +339,9 @@ sequenceDiagram loader retains queue/resource transitions, cursor adoption and materialization. - `WmoPlacementResolver` is stateless and owns only call-local cache-key, identity and transform values. `WmoPlacementRegistry` owns only placement-key - reference sets. The loader retains the key-to-Node map, caches, jobs, queues, - resources, cancellation and every Node lifecycle action. + reference sets. `WmoRenderBuildStepPlanner` owns only a call-local operation + and cursor plan. The loader retains the key-to-Node map, caches, jobs, queues, + resources, cursor adoption, permits, cancellation 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 @@ -431,6 +437,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m default/rotated/unclamped transforms, three consumers and bounded timing. - WMO placement registry contract: first/idempotent/shared/non-owner/final/global lifecycle, detached sorted diagnostics, source ownership and bounded timing. +- WMO render build step contract: mesh-before-MultiMesh order, one-index cursor + transition, completion/raw integer behavior, source ownership 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. @@ -466,6 +474,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | M2 build batch planner | Implemented extraction | Scene-free limit/count/cursor/source/timing contract | Queue/resource state, spatial cells and asset-backed p95/p99 remain pending | | 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 | Job/resource ownership and asset-backed traversal p95/p99 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_build_step_planner.gd b/src/render/wmo/wmo_render_build_step_planner.gd new file mode 100644 index 0000000..ae6166e --- /dev/null +++ b/src/render/wmo/wmo_render_build_step_planner.gd @@ -0,0 +1,40 @@ +class_name WmoRenderBuildStepPlanner +extends RefCounted + +## Selects one value-only WMO render-group build operation without engine objects. + +const OPERATION_MESH: StringName = &"mesh" +const OPERATION_MULTIMESH: StringName = &"multimesh" +const OPERATION_COMPLETE: StringName = &"complete" + + +## Selects the next mesh-first build operation and returns its selected index +## plus the cursor values to adopt after that one operation. Counts and cursors +## are intentionally not clamped so the loader's historical comparisons remain +## exact for every integer input. +func plan_step( + mesh_count: int, + mesh_index: int, + multimesh_count: int, + multimesh_index: int +) -> Dictionary: + if mesh_index < mesh_count: + return { + "operation": OPERATION_MESH, + "selected_index": mesh_index, + "next_mesh_index": mesh_index + 1, + "next_multimesh_index": multimesh_index, + } + if multimesh_index < multimesh_count: + return { + "operation": OPERATION_MULTIMESH, + "selected_index": multimesh_index, + "next_mesh_index": mesh_index, + "next_multimesh_index": multimesh_index + 1, + } + return { + "operation": OPERATION_COMPLETE, + "selected_index": -1, + "next_mesh_index": mesh_index, + "next_multimesh_index": multimesh_index, + } diff --git a/src/render/wmo/wmo_render_build_step_planner.gd.uid b/src/render/wmo/wmo_render_build_step_planner.gd.uid new file mode 100644 index 0000000..30d1cae --- /dev/null +++ b/src/render/wmo/wmo_render_build_step_planner.gd.uid @@ -0,0 +1 @@ +uid://dqogt0el0x3kx diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 10c842d..00a2173 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -15,6 +15,9 @@ const WMO_PLACEMENT_RESOLVER_SCRIPT := preload( const WMO_PLACEMENT_REGISTRY_SCRIPT := preload( "res://src/render/wmo/wmo_placement_registry.gd" ) +const WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT := preload( + "res://src/render/wmo/wmo_render_build_step_planner.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") @@ -290,6 +293,7 @@ var _m2_static_animation_cache: Dictionary = {} var _m2_missing_cache: Dictionary = {} var _world_wmo_root: Node3D var _wmo_placement_registry := WMO_PLACEMENT_REGISTRY_SCRIPT.new() +var _wmo_render_build_step_planner := WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.new() var _wmo_instance_nodes_by_unique_key: Dictionary = {} @@ -3791,15 +3795,30 @@ func _process_wmo_render_build_jobs() -> void: var mesh_transforms: Array = render_resource.get("mesh_transforms") var mesh_names: PackedStringArray = render_resource.get("mesh_names") var mesh_index: int = int(job.get("mesh_index", 0)) - if mesh_index < meshes.size(): - var mesh := meshes[mesh_index] as Mesh + var multimeshes: Array = render_resource.get("multimeshes") + var multimesh_transforms: Array = render_resource.get("multimesh_transforms") + var multimesh_names: PackedStringArray = render_resource.get("multimesh_names") + var multimesh_index: int = int(job.get("multimesh_index", 0)) + var build_step: Dictionary = _wmo_render_build_step_planner.plan_step( + meshes.size(), + mesh_index, + multimeshes.size(), + multimesh_index + ) + var selected_index := int(build_step["selected_index"]) + if build_step["operation"] == WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.OPERATION_MESH: + var mesh := meshes[selected_index] as Mesh if mesh != null: _refresh_cached_wmo_mesh_materials(mesh) var mesh_instance := MeshInstance3D.new() - mesh_instance.name = mesh_names[mesh_index] if mesh_index < mesh_names.size() else "Group_%d" % mesh_index + mesh_instance.name = ( + mesh_names[selected_index] + if selected_index < mesh_names.size() + else "Group_%d" % selected_index + ) mesh_instance.mesh = mesh - if mesh_index < mesh_transforms.size(): - mesh_instance.transform = mesh_transforms[mesh_index] + if selected_index < mesh_transforms.size(): + mesh_instance.transform = mesh_transforms[selected_index] mesh_instance.cast_shadow = ( GeometryInstance3D.SHADOW_CASTING_SETTING_ON if wmo_cast_shadows @@ -3810,28 +3829,24 @@ func _process_wmo_render_build_jobs() -> void: mesh_instance.visibility_range_end_margin = CHUNK_SIZE (root as Node3D).add_child(mesh_instance) _set_editor_owner_recursive(mesh_instance) - job["mesh_index"] = mesh_index + 1 + job["mesh_index"] = int(build_step["next_mesh_index"]) _wmo_render_build_jobs[unique_key] = job _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD) continue - var multimeshes: Array = render_resource.get("multimeshes") - var multimesh_transforms: Array = render_resource.get("multimesh_transforms") - var multimesh_names: PackedStringArray = render_resource.get("multimesh_names") - var multimesh_index: int = int(job.get("multimesh_index", 0)) - if multimesh_index < multimeshes.size(): - var multimesh := multimeshes[multimesh_index] as MultiMesh + if build_step["operation"] == WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.OPERATION_MULTIMESH: + var multimesh := multimeshes[selected_index] as MultiMesh if multimesh != null: _refresh_cached_wmo_mesh_materials(multimesh.mesh) var multimesh_instance := MultiMeshInstance3D.new() multimesh_instance.name = ( - multimesh_names[multimesh_index] - if multimesh_index < multimesh_names.size() - else "DoodadGroup_%d" % multimesh_index + multimesh_names[selected_index] + if selected_index < multimesh_names.size() + else "DoodadGroup_%d" % selected_index ) multimesh_instance.multimesh = multimesh - if multimesh_index < multimesh_transforms.size(): - multimesh_instance.transform = multimesh_transforms[multimesh_index] + if selected_index < multimesh_transforms.size(): + multimesh_instance.transform = multimesh_transforms[selected_index] multimesh_instance.cast_shadow = ( GeometryInstance3D.SHADOW_CASTING_SETTING_ON if wmo_cast_shadows @@ -3842,7 +3857,7 @@ func _process_wmo_render_build_jobs() -> void: multimesh_instance.visibility_range_end_margin = CHUNK_SIZE (root as Node3D).add_child(multimesh_instance) _set_editor_owner_recursive(multimesh_instance) - job["multimesh_index"] = multimesh_index + 1 + job["multimesh_index"] = int(build_step["next_multimesh_index"]) _wmo_render_build_jobs[unique_key] = job _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.WMO_RENDER_GROUP_BUILD) continue diff --git a/src/tools/verify_wmo_render_build_step_planner.gd b/src/tools/verify_wmo_render_build_step_planner.gd new file mode 100644 index 0000000..bb1834f --- /dev/null +++ b/src/tools/verify_wmo_render_build_step_planner.gd @@ -0,0 +1,213 @@ +extends SceneTree + +## Asset-free ordering, cursor, dependency and timing regression for WMO builds. + +const PLANNER_SCRIPT := preload("res://src/render/wmo/wmo_render_build_step_planner.gd") +const PLANNER_PATH := "res://src/render/wmo/wmo_render_build_step_planner.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_mesh_steps_precede_multimeshes(failures) + _verify_multimesh_cursor_and_completion(failures) + _verify_empty_and_raw_integer_behavior(failures) + _verify_fresh_results(failures) + _verify_loader_and_dependency_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("WMO_RENDER_BUILD_STEP_PLANNER: %s" % failure) + quit(1) + return + print( + "WMO_RENDER_BUILD_STEP_PLANNER PASS cases=9 iterations=20000 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_mesh_steps_precede_multimeshes(failures: Array[String]) -> void: + var planner: RefCounted = PLANNER_SCRIPT.new() + var first: Dictionary = planner.call("plan_step", 2, 0, 3, 0) + _expect_operation(first, PLANNER_SCRIPT.OPERATION_MESH, 0, 1, 0, "first mesh", failures) + var second: Dictionary = planner.call("plan_step", 2, 1, 3, 0) + _expect_operation(second, PLANNER_SCRIPT.OPERATION_MESH, 1, 2, 0, "second mesh", failures) + var after_meshes: Dictionary = planner.call("plan_step", 2, 2, 3, 0) + _expect_operation( + after_meshes, + PLANNER_SCRIPT.OPERATION_MULTIMESH, + 0, + 2, + 1, + "multimesh follows meshes", + failures + ) + + +func _verify_multimesh_cursor_and_completion(failures: Array[String]) -> void: + var planner: RefCounted = PLANNER_SCRIPT.new() + var last_multimesh: Dictionary = planner.call("plan_step", 1, 1, 2, 1) + _expect_operation( + last_multimesh, + PLANNER_SCRIPT.OPERATION_MULTIMESH, + 1, + 1, + 2, + "last multimesh", + failures + ) + var complete: Dictionary = planner.call("plan_step", 1, 1, 2, 2) + _expect_operation( + complete, + PLANNER_SCRIPT.OPERATION_COMPLETE, + -1, + 1, + 2, + "both ranges complete", + failures + ) + + +func _verify_empty_and_raw_integer_behavior(failures: Array[String]) -> void: + var planner: RefCounted = PLANNER_SCRIPT.new() + var empty: Dictionary = planner.call("plan_step", 0, 0, 0, 0) + _expect_operation(empty, PLANNER_SCRIPT.OPERATION_COMPLETE, -1, 0, 0, "empty", failures) + var negative_mesh_cursor: Dictionary = planner.call("plan_step", 1, -1, 0, 0) + _expect_operation( + negative_mesh_cursor, + PLANNER_SCRIPT.OPERATION_MESH, + -1, + 0, + 0, + "negative mesh cursor is not clamped", + failures + ) + var negative_counts: Dictionary = planner.call("plan_step", -1, 0, -1, 0) + _expect_operation( + negative_counts, + PLANNER_SCRIPT.OPERATION_COMPLETE, + -1, + 0, + 0, + "negative counts preserve comparisons", + failures + ) + + +func _verify_fresh_results(failures: Array[String]) -> void: + var planner: RefCounted = PLANNER_SCRIPT.new() + var first: Dictionary = planner.call("plan_step", 1, 0, 0, 0) + first["next_mesh_index"] = 99 + var second: Dictionary = planner.call("plan_step", 1, 0, 0, 0) + _expect_equal(int(second["next_mesh_index"]), 1, "result is fresh", failures) + + +func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void: + var loader_source := _read_text(LOADER_PATH, failures) + var planner_source := _read_text(PLANNER_PATH, failures) + _expect_true( + loader_source.contains("WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.new()"), + "loader composes planner", + failures + ) + _expect_true( + loader_source.contains("_wmo_render_build_step_planner.plan_step("), + "loader delegates step decision", + failures + ) + _expect_true( + loader_source.contains("MeshInstance3D.new()") + and loader_source.contains("MultiMeshInstance3D.new()"), + "loader retains node materialization", + failures + ) + _expect_false( + loader_source.contains("if mesh_index < meshes.size():"), + "loader omits mesh cursor decision", + failures + ) + for forbidden_token in [ + "extends Node", "Node3D", "Resource", "Mesh", "RID", "Thread", "Mutex", "FileAccess" + ]: + _expect_false( + planner_source.contains(forbidden_token), + "planner omits %s dependency" % forbidden_token, + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var planner: RefCounted = PLANNER_SCRIPT.new() + var started_microseconds := Time.get_ticks_usec() + var checksum := 0 + for iteration in range(20000): + var step: Dictionary = planner.call( + "plan_step", + 48, + iteration % 49, + 24, + iteration % 25 + ) + checksum += int(step["selected_index"]) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(checksum > 0, "timing checksum", failures) + _expect_true(elapsed_milliseconds < 1000.0, "20,000 plans remain bounded", failures) + return elapsed_milliseconds + + +func _expect_operation( + actual_step: Dictionary, + expected_operation: StringName, + expected_selected_index: int, + expected_next_mesh_index: int, + expected_next_multimesh_index: int, + label: String, + failures: Array[String] +) -> void: + if actual_step["operation"] != expected_operation: + failures.append( + "%s operation expected %s, got %s" + % [label, expected_operation, actual_step["operation"]] + ) + _expect_equal( + int(actual_step["selected_index"]), + expected_selected_index, + "%s selected" % label, + failures + ) + _expect_equal( + int(actual_step["next_mesh_index"]), + expected_next_mesh_index, + "%s next mesh" % label, + failures + ) + _expect_equal( + int(actual_step["next_multimesh_index"]), + expected_next_multimesh_index, + "%s next multimesh" % label, + failures + ) + + +func _read_text(path: String, failures: Array[String]) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("cannot open %s" % path) + return "" + return file.get_as_text() + + +func _expect_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_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_build_step_planner.gd.uid b/src/tools/verify_wmo_render_build_step_planner.gd.uid new file mode 100644 index 0000000..480ce61 --- /dev/null +++ b/src/tools/verify_wmo_render_build_step_planner.gd.uid @@ -0,0 +1 @@ +uid://w68w77v5sps4 From 01bad4ca266f87b57b63b960bf037e8d35bd35ac Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 17 Jul 2026 00:48:13 +0400 Subject: [PATCH 2/2] docs(M03): hand off WMO render build planner --- .../M03-RND-WMO-RENDER-BUILD-PLANNER-001.md | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/coordination/claims/M03-RND-WMO-RENDER-BUILD-PLANNER-001.md b/coordination/claims/M03-RND-WMO-RENDER-BUILD-PLANNER-001.md index ba49b21..3c18dac 100644 --- a/coordination/claims/M03-RND-WMO-RENDER-BUILD-PLANNER-001.md +++ b/coordination/claims/M03-RND-WMO-RENDER-BUILD-PLANNER-001.md @@ -79,14 +79,25 @@ while preserving mesh-before-MultiMesh ordering and one budget permit per group. ## Status -- State: claimed -- Done: cursor and ownership boundary published -- Next: implement planner, migrate loader decision, tests and docs +- State: ready for integration +- Done: planner and loader adapter implemented; Node/Resource/material/job/queue/ + permit ownership retained in loader; regressions, dry-run and docs passed +- Next: integrator merge and post-merge verification - Blocked by: ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `3a6b1d3` +- Results: dedicated planner PASS (`cases=9`, `iterations=20000`, + `elapsed_ms=11.914`); WMO registry/resolver/material/shutdown and 18 adjacent + M2/terrain/facade/scheduler/streaming/coordinate regressions PASS; + documentation and coordination gates PASS; checkpoint dry-run PASS `7/7`. +- Remaining risks: no proprietary WMO corpus, original-client paired capture or + traversal p95/p99; dry-run reports expected missing ADT/DBC/character assets; + WMO Resource arrays and untyped build jobs/queues remain loader-owned. +- Documentation updated: inline planner API; + `docs/modules/wmo-render-build-step-planner.md`, module registry, + `docs/modules/world-renderer.md`, `RENDER.md` with data-flow and sequence + diagrams, ownership, failure/recovery and source map. + +