From 19ca8f48e82953489c684c401c73ba0d88b12989 Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 17 Jul 2026 10:01:29 +0400 Subject: [PATCH 1/2] refactor(M03): extract M2 runtime rebuild classifier --- RENDER.md | 17 ++ docs/modules/README.md | 1 + .../m2-runtime-mesh-rebuild-classifier.md | 221 ++++++++++++++++ docs/modules/world-renderer.md | 8 +- .../m2/m2_runtime_mesh_rebuild_classifier.gd | 93 +++++++ .../m2_runtime_mesh_rebuild_classifier.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 62 +---- ...rify_m2_runtime_mesh_rebuild_classifier.gd | 248 ++++++++++++++++++ ..._m2_runtime_mesh_rebuild_classifier.gd.uid | 1 + 9 files changed, 600 insertions(+), 52 deletions(-) create mode 100644 docs/modules/m2-runtime-mesh-rebuild-classifier.md create mode 100644 src/render/m2/m2_runtime_mesh_rebuild_classifier.gd create mode 100644 src/render/m2/m2_runtime_mesh_rebuild_classifier.gd.uid create mode 100644 src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd create mode 100644 src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd.uid diff --git a/RENDER.md b/RENDER.md index 4ca734e..84279e9 100644 --- a/RENDER.md +++ b/RENDER.md @@ -35,6 +35,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `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/render/liquid/adt_water_load_pipeline_state.gd` - ADT water pending FIFO/dedupe, active task IDs and worker-safe parsed-result mailbox without parser or Node ownership. - `src/render/liquid/adt_water_scene_finalizer.gd` - stateless main-thread ADT water build/attach and optional persisted Editor ownership through the existing ADTBuilder. +- `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` - memoized billboard/UV-rotation decision for stale cached M2 runtime mesh refresh. - `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. @@ -1039,6 +1040,22 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Cache formats, quality profiles, batching output and visible rules are unchanged. Asset-backed p95/p99 and spatial-cell batching evidence remain pending. +## 2026-07-17 M2 Runtime Mesh Rebuild Classifier Extraction + +- `M2RuntimeMeshRebuildClassifier` now owns the memoized decision used when a + stale cached M2 mesh may need an in-memory rebuild from raw data. +- Existing triggers are unchanged: any billboard flag/positive billboard vertex + count, or a referenced non-identity UV rotation/absolute rotation speed above + `0.000001` across at most four texture stages. +- Invalid batch variants and combo/transform indices remain skipped. The first + decision for a normalized path remains cached until the same two historical + map/shutdown clear sites. +- `StreamingWorldLoader` still loads raw M2 data, checks + `M2_MATERIAL_REFRESH_VERSION`, calls `M2Builder`, adopts/falls back to Meshes + and owns ResourceLoader/finalization budgets. +- No cache version, shader, material, geometry or visible rule changed. Synthetic + predicate timing is not asset-backed descriptor-pressure/p95/p99 evidence. + ## 2026-07-17 WMO Placement Resolver Extraction - `WmoPlacementResolver` now owns lowercase/slash cache-key normalization, diff --git a/docs/modules/README.md b/docs/modules/README.md index ea90aee..39ada82 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -21,6 +21,7 @@ | M2 placement transform resolver | Implemented | [`m2-placement-transform-resolver.md`](m2-placement-transform-resolver.md) | | M2 placement grouper | Implemented extraction | [`m2-placement-grouper.md`](m2-placement-grouper.md) | | M2 build batch planner | Implemented extraction | [`m2-build-batch-planner.md`](m2-build-batch-planner.md) | +| M2 runtime mesh rebuild classifier | Implemented extraction | [`m2-runtime-mesh-rebuild-classifier.md`](m2-runtime-mesh-rebuild-classifier.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) | diff --git a/docs/modules/m2-runtime-mesh-rebuild-classifier.md b/docs/modules/m2-runtime-mesh-rebuild-classifier.md new file mode 100644 index 0000000..1c2ead7 --- /dev/null +++ b/docs/modules/m2-runtime-mesh-rebuild-classifier.md @@ -0,0 +1,221 @@ +# M2 Runtime Mesh Rebuild Classifier + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-M2-RUNTIME-REBUILD-CLASSIFIER-001` | +| Owners | Billboard/UV-rotation rebuild decision and per-path memoization | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-runtime-rebuild-classifier`, 2026-07-17 | +| Profiles/capabilities | Existing M2 material-refresh path; profile-independent | + +## Purpose + +Determine whether a stale cached M2 mesh must be rebuilt in memory from raw M2 +data to restore billboard custom attributes or UV-rotation material inputs. The +first decision is memoized by normalized relative path for the current map/ +runtime-cache lifetime. + +## Non-goals + +- Read M2 files or invoke `M2Loader`/`M2Builder`. +- Own cached Meshes, scenes, materials, Nodes or RIDs. +- Select cache paths, perform ResourceLoader transitions or consume permits. +- Change refresh/cache format versions or shader behavior. +- Classify animation, particles, ribbons or general visual fidelity. + +## Context and boundaries + +```mermaid +flowchart LR + Cache[Stale cached M2 Mesh] --> Loader[StreamingWorldLoader] + Loader --> Raw[M2Loader raw Dictionary] + Raw --> Classifier[M2RuntimeMeshRebuildClassifier] + Classifier --> Decision[Memoized rebuild boolean] + Decision --> Loader + Loader -->|true| Builder[M2Builder rebuild] + Loader -->|false| Refresh[Mark material refresh version] +``` + +Allowed dependencies are Dictionary/Array, `PackedInt32Array`, `Vector4` and +scalar math. Files, ResourceLoader, parser/builder classes, SceneTree, +WorkerThreadPool, Mesh/Node/RID ownership and other layers are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `needs_runtime_mesh_rebuild(normalized_relative_path, raw_m2_data)` | Memoized query | Detect billboard or referenced UV rotation requirements | Renderer main thread; cached until clear | Invalid variants/indices are skipped; first path decision wins | +| `clear()` | Command | Drop all memoized path decisions | Main thread; map reset/shutdown | Idempotent | +| `cached_path_count()` | Query | Return memoized path count | Main thread | None | +| `diagnostic_snapshot()` | Query | Return detached path/decision map | Main thread; caller-owned | None | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Normalized M2 relative path | Loader cache lookup | Classifier cache key | String value copied | Until clear | +| Input | Raw M2 batches/transforms/combos Dictionary | Native `M2Loader` adapter | Classifier | Borrowed; not mutated | First classification call per path | +| Output | Rebuild-required boolean | Classifier | Loader material-refresh adapter | Scalar | Memoized until clear | +| Output | Detached decision Dictionary | Classifier | Verifier/future diagnostics | Caller-owned copy | Snapshot lifetime | + +Side effects are limited to inserting and clearing boolean memoization entries. + +## Data flow + +```mermaid +flowchart TD + Query[path + raw M2 data] --> Cached{Path cached?} + Cached -->|yes| Return[Return first decision] + Cached -->|no| Billboard{Any billboard flag or positive vertex count?} + Billboard -->|yes| StoreTrue[Store true] + Billboard -->|no| Inputs{Transforms and combos present?} + Inputs -->|no| StoreFalse[Store false] + Inputs -->|yes| Batch[For each Dictionary batch] + Batch --> Stage[Inspect at most four referenced stages] + Stage --> Rotation{Non-identity rotation or speed above epsilon?} + Rotation -->|yes| StoreTrue + Rotation -->|no| StoreFalse + StoreTrue --> Return + StoreFalse --> Return +``` + +## Lifecycle/state + +Each path moves from unknown to one immutable cached boolean. Clear returns all +paths to unknown; no resource lifecycle participates. + +```mermaid +stateDiagram-v2 + [*] --> Unknown + Unknown --> RebuildRequired: billboard or referenced UV rotation + Unknown --> RebuildNotRequired: no relevant metadata + RebuildRequired --> RebuildRequired: repeated query + RebuildNotRequired --> RebuildNotRequired: repeated query + RebuildRequired --> Unknown: clear + RebuildNotRequired --> Unknown: clear +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant Native as M2Loader + participant Classifier as M2RuntimeMeshRebuildClassifier + participant Builder as M2Builder + Loader->>Native: load_m2(normalized path) + Native-->>Loader: raw Dictionary + Loader->>Classifier: needs_runtime_mesh_rebuild(path, data) + Classifier-->>Loader: memoized boolean + alt rebuild required + Loader->>Builder: rebuild mesh from raw data + else no rebuild required + Loader->>Loader: stamp material refresh version + end +``` + +## Dependency diagram + +```mermaid +flowchart TB + Loader[StreamingWorldLoader] --> Classifier[M2RuntimeMeshRebuildClassifier] + Loader --> Native[M2Loader] + Loader --> Builder[M2Builder] + Loader --> Cache[M2 Mesh/Scene caches] + Classifier -. no dependency .-> Native + Classifier -. no dependency .-> Builder + Classifier -. no dependency .-> Cache +``` + +## Ownership, threading and resources + +- The classifier owns only normalized-path boolean entries. +- The loader owns raw loading, cache/resource selection, Mesh replacement and + material refresh metadata. +- Raw Dictionaries are borrowed and never retained; only the computed boolean is cached. +- Current calls and clears are serialized on the renderer main thread. +- No Node, Resource, Mesh, material or RID reference crosses into the classifier. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Non-Dictionary batch/transform | Type guard | Skip entry or use identity defaults | Synthetic verifier | Correct parser data on next cleared lifetime | +| Missing transforms/combos | Empty guard | No UV-driven rebuild | Synthetic verifier | Billboard classification still applies | +| Invalid combo/transform index | Bounds guard | Skip stage | Synthetic verifier | Correct raw data and clear | +| Repeated path with changed data | Cache hit | Preserve first decision | Memoization fixture | Map/reset clear recomputes | +| Load/build failure | Outside classifier | Existing mesh fallback remains | Loader diagnostics | Existing reload/fallback path | + +## Configuration and capabilities + +| Setting/capability | Default | Profile | Runtime mutable | Effect | +|---|---|---|---|---| +| Maximum texture stages inspected | `4` | Existing M2 material path | No | Preserves historical four-stage shader boundary | +| UV rotation-speed epsilon | `0.000001` exclusive | Existing M2 material path | No | Larger absolute speed requires rebuild | +| `M2_MATERIAL_REFRESH_VERSION` | Loader constant `2` | All | Code version | Gates whether classifier is called | + +## Persistence, cache and migration + +Memoized booleans are runtime-only and are cleared at the two historical loader +reset/shutdown sites. No persisted cache version or rebuild instruction changes. + +## Diagnostics and observability + +- Logs: none. +- Metrics: `cached_path_count` is contract-level only and not added to runtime logs. +- Debug views: detached path-to-boolean snapshot. +- Correlation IDs: normalized M2 relative path. + +## Verification + +- `verify_m2_runtime_mesh_rebuild_classifier.gd`: billboard flag/count, invalid + variants, identity/non-identity rotation, positive/negative speed, exact + epsilon, four-stage cap, invalid indices, memoization, clear, source boundary + and 100-by-256 timing. +- Existing M2 builder/material verifiers cover downstream shader/cache behavior. +- Fidelity evidence is exact predicate/cache extraction. No new asset-backed or + original-client parity claim is made. +- Performance budget: 25,600 classifications and clears under one second. + +## Extension points + +- A typed raw M2 material descriptor may replace the Dictionary only with parser + fixtures and a compatibility adapter. +- Mesh request/cache/finalization state can be extracted separately without + changing this rebuild predicate. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Billboard rebuild classification | Implemented extraction | Flag/count fixtures | Asset-backed cache refresh timing pending | +| UV-rotation rebuild classification | Implemented extraction | Combo/stage/threshold fixtures | More build-12340 material fixtures pending | +| Runtime memoization lifetime | Implemented extraction | First-decision/clear/source fixtures | Byte/count runtime metric not exposed | +| M2 mesh loading/finalization | Existing loader-owned | Material/shutdown regressions | Separate state extraction pending | + +## Known gaps and risks + +- Raw parser data remains Dictionary-based. +- The first decision wins even if different raw data is passed for the same path; + this intentionally preserves prior cache behavior. +- Only four texture stages are inspected, matching current material support. +- No private asset traversal, descriptor-pressure, p95/p99 or paired visual run is included. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` | Predicate, thresholds and boolean memoization | +| `src/scenes/streaming/streaming_world_loader.gd` | Raw loading, Mesh rebuild/adoption, refresh version and clear calls | +| `addons/mpq_extractor/loaders/m2_builder.gd` | Mesh/material rebuild implementation | +| `src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd` | Synthetic predicate/cache/boundary/timing regression | + +## Related decisions and references + +- [`m2-build-batch-planner.md`](m2-build-batch-planner.md) +- [`m2-placement-grouper.md`](m2-placement-grouper.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 cb68134..6b09992 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-adt-water-scene-finalizer`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-runtime-rebuild-classifier`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -132,6 +132,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `M2PlacementTransformResolver.resolve_basis/resolve_origin_offset` | Internal pure M2 service | Resolves regular and calibrated model-specific ADT placement transforms | Worker/main thread; stateless | Unknown paths use regular basis and zero offset | | `M2PlacementGrouper.group_placements` | Internal pure M2 service | Validates and groups ordered tile-local placement transforms by normalized path | Worker/main thread; stateless | Invalid variants/name IDs/empty paths are skipped | | `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 | +| `M2RuntimeMeshRebuildClassifier` | Internal memoized M2 service | Detects billboard/UV-rotation metadata requiring stale cached-mesh rebuild | Renderer main thread; cached until reset | Invalid variants/indices skipped; first path decision wins | | `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 | @@ -365,6 +366,9 @@ sequenceDiagram stale tile/path/root checks and `water_loaded` timing. `AdtWaterSceneFinalizer` performs the existing ADTBuilder call, tile attach and optional recursive Editor ownership on the main thread without retaining the generated subtree. +- `M2RuntimeMeshRebuildClassifier` owns only normalized-path boolean decisions + for billboard/UV-rotation material refresh. The loader retains raw M2 loading, + refresh-version checks, Mesh rebuild/adoption and both historical clear sites. - 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 @@ -565,6 +569,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/render/terrain/terrain_quality_mesh_cache.gd` | Full-quality terrain revisit Mesh retention and LRU ownership | | `src/render/liquid/adt_water_load_pipeline_state.gd` | ADT water request/task/result bookkeeping and worker-safe mailbox | | `src/render/liquid/adt_water_scene_finalizer.gd` | Main-thread ADT water build, tile attachment and optional editor ownership | +| `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` | Billboard/UV-rotation stale cached-mesh rebuild decision and memoization | | `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation | | `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy | | `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets | @@ -579,6 +584,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_terrain_quality_mesh_cache.gd` | Terrain cache admission, LRU, ownership and loader-boundary regression | | `src/tools/verify_adt_water_load_pipeline_state.gd` | ADT water FIFO/task/thread/boundary/timing regression | | `src/tools/verify_adt_water_scene_finalizer.gd` | Synthetic ADT water scene/ownership/boundary/timing regression | +| `src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd` | M2 rebuild predicate/cache/boundary/timing regression | | `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression | | `src/tools/verify_render_budget_scheduler.gd` | Scheduler bounds, shared-lane priority, cancellation and timing regression | | `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin/registered renderer-tool boundary gate derived from private streamer fields | diff --git a/src/render/m2/m2_runtime_mesh_rebuild_classifier.gd b/src/render/m2/m2_runtime_mesh_rebuild_classifier.gd new file mode 100644 index 0000000..685186b --- /dev/null +++ b/src/render/m2/m2_runtime_mesh_rebuild_classifier.gd @@ -0,0 +1,93 @@ +class_name M2RuntimeMeshRebuildClassifier +extends RefCounted + +## Memoizes whether raw M2 billboard or UV-rotation metadata requires replacing +## a stale cached runtime mesh. Owns no parser, Mesh, material, Node or RID. + +const UV_ROTATION_SPEED_EPSILON := 0.000001 +const MAXIMUM_TEXTURE_STAGES := 4 + +var _rebuild_required_by_normalized_path: Dictionary = {} + + +## Returns the first memoized rebuild decision for [param normalized_relative_path]. +## [param raw_m2_data] is borrowed and not mutated. The decision remains cached +## until [method clear], matching the loader map/shutdown reset lifetime. +func needs_runtime_mesh_rebuild( + normalized_relative_path: String, + raw_m2_data: Dictionary) -> bool: + if _rebuild_required_by_normalized_path.has(normalized_relative_path): + return bool(_rebuild_required_by_normalized_path[normalized_relative_path]) + var rebuild_required := ( + _raw_data_has_billboards(raw_m2_data) + or _raw_data_has_uv_rotation(raw_m2_data) + ) + _rebuild_required_by_normalized_path[normalized_relative_path] = rebuild_required + return rebuild_required + + +## Clears every memoized path decision. No Mesh or resource is retained or freed. +func clear() -> void: + _rebuild_required_by_normalized_path.clear() + + +## Returns the number of memoized normalized paths. +func cached_path_count() -> int: + return _rebuild_required_by_normalized_path.size() + + +## Returns a detached normalized-path to boolean decision snapshot. +func diagnostic_snapshot() -> Dictionary: + return _rebuild_required_by_normalized_path.duplicate() + + +func _raw_data_has_billboards(raw_m2_data: Dictionary) -> bool: + for batch_variant in raw_m2_data.get("batches", []): + if not (batch_variant is Dictionary): + continue + var batch: Dictionary = batch_variant + if ( + bool(batch.get("has_billboard", false)) + or int(batch.get("billboard_vertex_count", 0)) > 0 + ): + return true + return false + + +func _raw_data_has_uv_rotation(raw_m2_data: Dictionary) -> bool: + var texture_transforms: Array = raw_m2_data.get("texture_transforms", []) + if texture_transforms.is_empty(): + return false + var texture_transform_combos: PackedInt32Array = raw_m2_data.get( + "texture_transform_combos", + PackedInt32Array() + ) + if texture_transform_combos.is_empty(): + return false + for batch_variant in raw_m2_data.get("batches", []): + if not (batch_variant is Dictionary): + continue + var batch: Dictionary = batch_variant + var texture_count := maxi(1, int(batch.get("texture_count", 1))) + var base_combo_index := int(batch.get("texture_transform_combo_index", -1)) + for texture_stage in mini(texture_count, MAXIMUM_TEXTURE_STAGES): + var combo_index := base_combo_index + texture_stage + if combo_index < 0 or combo_index >= texture_transform_combos.size(): + continue + var transform_index := int(texture_transform_combos[combo_index]) + if transform_index < 0 or transform_index >= texture_transforms.size(): + continue + var texture_transform: Dictionary = ( + texture_transforms[transform_index] + if texture_transforms[transform_index] is Dictionary + else {} + ) + var rotation: Vector4 = texture_transform.get( + "rotation", + Vector4(1.0, 0.0, 0.0, 1.0) + ) + if not rotation.is_equal_approx(Vector4(1.0, 0.0, 0.0, 1.0)): + return true + if absf(float(texture_transform.get("rotation_speed", 0.0))) > UV_ROTATION_SPEED_EPSILON: + return true + return false diff --git a/src/render/m2/m2_runtime_mesh_rebuild_classifier.gd.uid b/src/render/m2/m2_runtime_mesh_rebuild_classifier.gd.uid new file mode 100644 index 0000000..30f7d38 --- /dev/null +++ b/src/render/m2/m2_runtime_mesh_rebuild_classifier.gd.uid @@ -0,0 +1 @@ +uid://breu8vpmm88en diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 841f9f3..a4d06ea 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -60,6 +60,9 @@ const M2_PLACEMENT_GROUPER_SCRIPT := preload( const M2_BUILD_BATCH_PLANNER_SCRIPT := preload( "res://src/render/m2/m2_build_batch_planner.gd" ) +const M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT := preload( + "res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd" +) const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd") const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd") const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd") @@ -247,10 +250,12 @@ var _m2_unique_placement_registry := ( var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new() var _m2_placement_grouper := M2_PLACEMENT_GROUPER_SCRIPT.new() var _m2_build_batch_planner := M2_BUILD_BATCH_PLANNER_SCRIPT.new() +var _m2_runtime_mesh_rebuild_classifier := ( + M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new() +) var _m2_mesh_cache: Dictionary = {} var _m2_mesh_load_requests: Dictionary = {} var _m2_mesh_finalize_queue: Array = [] -var _m2_runtime_rebuild_required_cache: Dictionary = {} var _m2_animation_load_requests: Dictionary = {} var _m2_animation_finalize_queue: Array = [] var _wmo_build_jobs: Dictionary = {} @@ -2342,7 +2347,7 @@ func _wait_for_tile_tasks() -> void: ResourceLoader.load_threaded_get(path) _m2_mesh_load_requests.clear() _m2_mesh_finalize_queue.clear() - _m2_runtime_rebuild_required_cache.clear() + _m2_runtime_mesh_rebuild_classifier.clear() for pending in _m2_animation_load_requests.values(): var path: String = String(pending.get("path", "")) @@ -2993,7 +2998,7 @@ func _clear_streamed_world() -> void: _m2_unique_placement_registry.clear() _m2_mesh_load_requests.clear() _m2_mesh_finalize_queue.clear() - _m2_runtime_rebuild_required_cache.clear() + _m2_runtime_mesh_rebuild_classifier.clear() _m2_animation_load_requests.clear() _m2_animation_finalize_queue.clear() _wmo_build_jobs.clear() @@ -4645,7 +4650,9 @@ func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh: if int(mesh.get_meta("wow_m2_material_refresh_version", 0)) >= M2_MATERIAL_REFRESH_VERSION: return mesh var data := _load_m2_raw_data_for_refresh(normalized_rel) - if data.is_empty() or not _m2_raw_data_needs_runtime_mesh_rebuild(normalized_rel, data): + if data.is_empty() or not _m2_runtime_mesh_rebuild_classifier.needs_runtime_mesh_rebuild( + normalized_rel, + data): mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION) return mesh var rebuilt := _rebuild_m2_mesh_from_data(data) @@ -4679,53 +4686,6 @@ func _load_m2_raw_data_for_refresh(normalized_rel: String) -> Dictionary: return data if data is Dictionary else {} -func _m2_raw_data_needs_runtime_mesh_rebuild(normalized_rel: String, data: Dictionary) -> bool: - if _m2_runtime_rebuild_required_cache.has(normalized_rel): - return bool(_m2_runtime_rebuild_required_cache[normalized_rel]) - var needs_rebuild := _m2_raw_data_has_billboards(data) or _m2_raw_data_has_uv_rotation(data) - _m2_runtime_rebuild_required_cache[normalized_rel] = needs_rebuild - return needs_rebuild - - -func _m2_raw_data_has_billboards(data: Dictionary) -> bool: - for batch_variant in data.get("batches", []): - if not (batch_variant is Dictionary): - continue - var batch: Dictionary = batch_variant - if bool(batch.get("has_billboard", false)) or int(batch.get("billboard_vertex_count", 0)) > 0: - return true - return false - - -func _m2_raw_data_has_uv_rotation(data: Dictionary) -> bool: - var transforms: Array = data.get("texture_transforms", []) - if transforms.is_empty(): - return false - var combos: PackedInt32Array = data.get("texture_transform_combos", PackedInt32Array()) - if combos.is_empty(): - return false - for batch_variant in data.get("batches", []): - if not (batch_variant is Dictionary): - continue - var batch: Dictionary = batch_variant - var texture_count := maxi(1, int(batch.get("texture_count", 1))) - var base_combo := int(batch.get("texture_transform_combo_index", -1)) - for stage in mini(texture_count, 4): - var combo_index := base_combo + stage - if combo_index < 0 or combo_index >= combos.size(): - continue - var transform_index := int(combos[combo_index]) - if transform_index < 0 or transform_index >= transforms.size(): - continue - var transform: Dictionary = transforms[transform_index] if transforms[transform_index] is Dictionary else {} - var rotation: Vector4 = transform.get("rotation", Vector4(1.0, 0.0, 0.0, 1.0)) - if not rotation.is_equal_approx(Vector4(1.0, 0.0, 0.0, 1.0)): - return true - if absf(float(transform.get("rotation_speed", 0.0))) > 0.000001: - return true - return false - - func _rebuild_m2_mesh_from_data(data: Dictionary) -> Mesh: if data.is_empty(): return null diff --git a/src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd b/src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd new file mode 100644 index 0000000..979337d --- /dev/null +++ b/src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd @@ -0,0 +1,248 @@ +extends SceneTree + +## Synthetic predicate, memoization, boundary and timing regression for stale +## cached M2 runtime mesh rebuild decisions. + +const CLASSIFIER_SCRIPT := preload( + "res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd" +) +const CLASSIFIER_PATH := "res://src/render/m2/m2_runtime_mesh_rebuild_classifier.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_billboard_decisions(failures) + _verify_uv_rotation_decisions(failures) + _verify_stage_and_index_boundaries(failures) + _verify_memoization_clear_and_diagnostics(failures) + _verify_ownership_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_RUNTIME_MESH_REBUILD_CLASSIFIER: %s" % failure) + quit(1) + return + print( + "M2_RUNTIME_MESH_REBUILD_CLASSIFIER PASS cases=12 iterations=100 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_billboard_decisions(failures: Array[String]) -> void: + _expect_false(_classify("empty", {}), "empty data does not rebuild", failures) + _expect_false( + _classify("invalid", {"batches": ["invalid", 7, null]}), + "non-Dictionary batches skipped", + failures + ) + _expect_true( + _classify("flag", {"batches": [{"has_billboard": true}]}), + "billboard flag rebuilds", + failures + ) + _expect_true( + _classify("vertices", {"batches": [{"billboard_vertex_count": 1}]}), + "positive billboard vertex count rebuilds", + failures + ) + _expect_false( + _classify("zero_vertices", {"batches": [{"billboard_vertex_count": 0}]}), + "zero billboard vertex count does not rebuild", + failures + ) + + +func _verify_uv_rotation_decisions(failures: Array[String]) -> void: + _expect_false( + _classify("identity", _uv_data([_rotation_transform()])), + "identity UV rotation does not rebuild", + failures + ) + _expect_true( + _classify( + "rotation", + _uv_data([_rotation_transform(Vector4(0.0, 1.0, -1.0, 0.0))]) + ), + "non-identity UV rotation rebuilds", + failures + ) + _expect_true( + _classify("positive_speed", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), 0.000002)])), + "positive UV rotation speed rebuilds", + failures + ) + _expect_true( + _classify("negative_speed", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), -0.000002)])), + "negative UV rotation speed rebuilds", + failures + ) + _expect_false( + _classify("epsilon", _uv_data([_rotation_transform(Vector4(1, 0, 0, 1), 0.000001)])), + "exact UV rotation epsilon does not rebuild", + failures + ) + + +func _verify_stage_and_index_boundaries(failures: Array[String]) -> void: + var fifth_stage_transforms: Array = [ + _rotation_transform(), + _rotation_transform(), + _rotation_transform(), + _rotation_transform(), + _rotation_transform(Vector4(0.0, 1.0, -1.0, 0.0)), + ] + _expect_false( + _classify( + "fifth_stage", + _uv_data(fifth_stage_transforms, PackedInt32Array([0, 1, 2, 3, 4]), 5) + ), + "fifth texture stage remains ignored", + failures + ) + _expect_false( + _classify( + "invalid_indices", + _uv_data([_rotation_transform(Vector4(0, 1, -1, 0))], PackedInt32Array([-1, 9]), 2) + ), + "invalid transform indices skipped", + failures + ) + _expect_false( + _classify( + "invalid_transform", + _uv_data(["not-a-transform"], PackedInt32Array([0]), 1) + ), + "non-Dictionary transform uses identity defaults", + failures + ) + + +func _verify_memoization_clear_and_diagnostics(failures: Array[String]) -> void: + var classifier: RefCounted = CLASSIFIER_SCRIPT.new() + var billboard_data := {"batches": [{"has_billboard": true}]} + _expect_true( + classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", billboard_data), + "first true decision", + failures + ) + _expect_true( + classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}), + "first path decision memoized", + failures + ) + classifier.call("needs_runtime_mesh_rebuild", "world/rock.m2", {}) + _expect_equal(int(classifier.call("cached_path_count")), 2, "cached path count", failures) + var snapshot: Dictionary = classifier.call("diagnostic_snapshot") + snapshot["world/tree.m2"] = false + _expect_true( + classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}), + "diagnostics detached", + failures + ) + classifier.call("clear") + _expect_equal(int(classifier.call("cached_path_count")), 0, "clear count", failures) + _expect_false( + classifier.call("needs_runtime_mesh_rebuild", "world/tree.m2", {}), + "clear allows recomputation", + failures + ) + + +func _verify_ownership_boundaries(failures: Array[String]) -> void: + var classifier_source := FileAccess.get_file_as_string(CLASSIFIER_PATH) + var loader_source := FileAccess.get_file_as_string(LOADER_PATH) + _expect_true( + loader_source.contains("M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new()"), + "loader composes classifier", + failures + ) + _expect_equal( + loader_source.count("_m2_runtime_mesh_rebuild_classifier.clear()"), + 2, + "loader retains two classifier clear sites", + failures + ) + _expect_false( + loader_source.contains("_m2_runtime_rebuild_required_cache"), + "loader removes legacy rebuild cache", + failures + ) + for removed_function in [ + "func _m2_raw_data_needs_runtime_mesh_rebuild", + "func _m2_raw_data_has_billboards", + "func _m2_raw_data_has_uv_rotation", + ]: + _expect_false(loader_source.contains(removed_function), "loader removes %s" % removed_function, failures) + for forbidden_dependency in [ + "ResourceLoader.", + "WorkerThreadPool.", + "M2_BUILDER_SCRIPT", + "ClassDB.instantiate", + "FileAccess.", + ]: + _expect_false( + classifier_source.contains(forbidden_dependency), + "classifier omits %s dependency" % forbidden_dependency, + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var classifier: RefCounted = CLASSIFIER_SCRIPT.new() + var ordinary_data := {"batches": [{"has_billboard": false}]} + var started_microseconds := Time.get_ticks_usec() + for iteration in range(100): + for path_index in range(256): + classifier.call( + "needs_runtime_mesh_rebuild", + "world/model_%d.m2" % path_index, + ordinary_data + ) + classifier.call("clear") + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "100 by 256 classifications under 1 second", failures) + return elapsed_milliseconds + + +func _classify(normalized_path: String, raw_data: Dictionary) -> bool: + var classifier: RefCounted = CLASSIFIER_SCRIPT.new() + return bool(classifier.call("needs_runtime_mesh_rebuild", normalized_path, raw_data)) + + +func _uv_data( + texture_transforms: Array, + texture_transform_combos: PackedInt32Array = PackedInt32Array([0]), + texture_count: int = 1) -> Dictionary: + return { + "texture_transforms": texture_transforms, + "texture_transform_combos": texture_transform_combos, + "batches": [{ + "texture_count": texture_count, + "texture_transform_combo_index": 0, + }], + } + + +func _rotation_transform( + rotation: Vector4 = Vector4(1.0, 0.0, 0.0, 1.0), + rotation_speed: float = 0.0) -> Dictionary: + return { + "rotation": rotation, + "rotation_speed": rotation_speed, + } + + +func _expect_true(condition: bool, label: String, failures: Array[String]) -> void: + if not condition: + failures.append(label) + + +func _expect_false(condition: bool, label: String, failures: Array[String]) -> void: + _expect_true(not condition, label, failures) + + +func _expect_equal(actual: int, expected: int, label: String, failures: Array[String]) -> void: + if actual != expected: + failures.append("%s expected=%d actual=%d" % [label, expected, actual]) diff --git a/src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd.uid b/src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd.uid new file mode 100644 index 0000000..6f65442 --- /dev/null +++ b/src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd.uid @@ -0,0 +1 @@ +uid://cc62iwxjrkte6 From 0544cf22dfd817c01ed15d05947b51888a5420d0 Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 17 Jul 2026 10:01:47 +0400 Subject: [PATCH 2/2] docs(M03): hand off M2 runtime rebuild classifier --- ...3-RND-M2-RUNTIME-REBUILD-CLASSIFIER-001.md | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/coordination/claims/M03-RND-M2-RUNTIME-REBUILD-CLASSIFIER-001.md b/coordination/claims/M03-RND-M2-RUNTIME-REBUILD-CLASSIFIER-001.md index 6e6e6d3..fe5ac02 100644 --- a/coordination/claims/M03-RND-M2-RUNTIME-REBUILD-CLASSIFIER-001.md +++ b/coordination/claims/M03-RND-M2-RUNTIME-REBUILD-CLASSIFIER-001.md @@ -81,14 +81,29 @@ rotation transforms. ## Status -- State: claimed -- Done: rebuild predicate, one query site and two cache-clear sites identified -- Next: implement classifier, migrate loader adapter, verify and document +- State: ready for integration +- Done: classifier/cache implemented; loader query and both clear sites migrated; + synthetic predicate/cache verifier and required documentation added +- Next: integrate and run post-merge smoke on master - Blocked by: ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `19ca8f4` +- Results: classifier PASS (`cases=12`, `iterations=100`, + `elapsed_ms=64.585`; initial `35.461ms`); billboard flag/count, invalid + variants/indices, UV rotation/speed/epsilon, four-stage cap, memoization, + detached diagnostics and clear contracts PASS; 33 M2/WMO/liquid/terrain/ + facade/scheduler/streaming/coordinate/material/shutdown regressions PASS; + internal-access decreased to `35`; documentation and coordination gates PASS; + checkpoint dry-run PASS `7/7`. +- Remaining risks: raw parser data remains Dictionary-based; the first decision + for a path remains authoritative until clear; classification covers the current + four-stage material implementation only; private asset descriptor-pressure, + traversal, p95/p99 and paired original-client evidence remain absent. +- Documentation updated: inline public API; new + `docs/modules/m2-runtime-mesh-rebuild-classifier.md`; module registry, world + renderer and `RENDER.md`; data-flow/state/sequence/dependency diagrams, + ownership, failure/recovery, thresholds, cache lifetime and source map included. + +