diff --git a/RENDER.md b/RENDER.md index 6aa1b46..655cd9c 100644 --- a/RENDER.md +++ b/RENDER.md @@ -1014,6 +1014,17 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Cache formats, batching, profiles and visible rules are unchanged. Asset-backed traversal p95/p99, visual recheck and spatial-cell grouping remain pending. +## 2026-07-17 M2 Build Batch Planner Extraction + +- `M2BuildBatchPlanner` now owns pure static/animated batch-limit selection, + remaining-count capping and next-offset/group-completion calculation. +- Existing animated/static limits still clamp to at least one; resource readiness, + queue rotation, serial progression and `M2_BUILD` permit consumption are unchanged. +- The loader retains build jobs/queues, tile cancellation, animation/mesh caches, + retries and all Node/MultiMesh/Mesh/material/RID finalization. +- Cache formats, quality profiles, batching output and visible rules are unchanged. + Asset-backed p95/p99 and spatial-cell batching evidence remain pending. + ## 2026-07-16 World Environment Snapshot Facade - `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value, diff --git a/coordination/claims/M03-RND-M2-BUILD-BATCH-PLANNER-001.md b/coordination/claims/M03-RND-M2-BUILD-BATCH-PLANNER-001.md index 4f75127..8495dc5 100644 --- a/coordination/claims/M03-RND-M2-BUILD-BATCH-PLANNER-001.md +++ b/coordination/claims/M03-RND-M2-BUILD-BATCH-PLANNER-001.md @@ -80,14 +80,26 @@ the loader build-job loop into a scene-free planner. ## Status -- State: claimed -- Done: formula, ownership and non-goal boundary published -- Next: implement planner, migrate cursor calculation, tests and docs +- State: handoff +- Done: planner, loader adapter, regressions and documentation verified +- Next: M03 integrator acceptance and merge to master - Blocked by: + + ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `be6db7f` (`refactor(M03): extract M2 build batch planner`) +- Results: dedicated planner PASS (`cases=8`, `iterations=20000`, + `elapsed_ms=13.464`); grouper, transform resolver, M2 registry/dedupe, terrain + cache/LOD/queue, facade, internal-access, manifest, shutdown, scheduler, + streaming planner/focus, terrain-query and coordinate-boundary suites PASS; + editor import parsed with only expected missing private corpus diagnostics; + documentation PASS (`module_specs=18`, `required_files=7`); coordination PASS + with 23 pre-existing expired M00/M01 warnings; checkpoint dry-run planned `7/7`. +- Remaining risks: no private asset-backed p95/p99 or visual comparison; result + and loader job remain Dictionaries; resource/queue transitions and spatial-cell + batching are still loader-owned; negative offset behavior remains permissive. +- Documentation updated: inline public API; new + `docs/modules/m2-build-batch-planner.md`; placement grouper module spec, + renderer module registry, `docs/modules/world-renderer.md` and `RENDER.md`. diff --git a/docs/modules/README.md b/docs/modules/README.md index 41340d0..a4696dd 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -18,6 +18,7 @@ | M2 unique placement registry | Implemented extraction | [`m2-unique-placement-registry.md`](m2-unique-placement-registry.md) | | 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) | | 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/m2-build-batch-planner.md b/docs/modules/m2-build-batch-planner.md new file mode 100644 index 0000000..1f710aa --- /dev/null +++ b/docs/modules/m2-build-batch-planner.md @@ -0,0 +1,187 @@ +# M2 Build Batch Planner + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | M03 / `M03-RND-M2-BUILD-BATCH-PLANNER-001` | +| Owners | Pure static/animated M2 batch sizing and group cursor progression | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-build-batch-planner`, 2026-07-17 | +| Profiles/capabilities | Existing static MultiMesh and animated-instance build paths | + +## Purpose + +Select the effective per-tick M2 batch limit, cap it to remaining transforms and +plan the next group offset without accessing loader state or renderer resources. + +## Non-goals + +- Own M2 build jobs, queues, tile state, retries or cancellation. +- Decide whether an animated prototype or static mesh is ready. +- Rotate queues or consume `RenderBudgetScheduler` permits. +- Create or modify Nodes, MultiMesh, Mesh, materials or RIDs. +- Introduce spatial cells or change profile batch limits. + +## Context and boundaries + +```mermaid +flowchart LR + Groups[M2PlacementGrouper transform arrays] --> Loader[Loader build loop] + Limits[Animated/static batch limits] --> Planner[M2BuildBatchPlanner] + Loader --> Planner + Planner --> Plan[Detached batch plan] + Plan --> Loader + Loader --> Ready{Resource ready?} + Ready --> Materialize[Animated or MultiMesh materialization] + Loader --> Budget[RenderBudgetScheduler permit] +``` + +Allowed dependencies are integer/boolean scalar math and a fresh Dictionary. +Node, SceneTree, RenderingServer, ResourceLoader, WorkerThreadPool, mutexes, +renderer caches, gameplay, network, editor UI and files are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `plan_batch(transform_count, current_offset, has_animated_prototype, animated_batch_limit, static_batch_limit)` | Pure query | Return the selected batch size/count and next group cursor | Any thread; call-local result | Non-positive selected limit clamps to one; empty/past-end range completes | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Transform count | Grouped transform array | Planner remaining-count formula | Scalar copy | One plan | +| Input | Current group offset | Loader build job | Planner cursor formula | Scalar copy | One plan | +| Input | Animated prototype availability | Loader resource selection | Planner limit selector | Scalar copy | One plan | +| Input | Animated/static raw limits | Renderer profile/exports | Planner clamp | Scalar copy | One plan | +| Output | `effective_batch_size` | Planner | Tests/diagnostics | Detached integer | One plan | +| Output | `batch_count` | Planner | Loader materialization adapter | Detached integer | One plan | +| Output | `next_offset` | Planner | Loader build-job cursor | Detached integer | Until next batch | +| Output | `group_complete` | Planner | Loader group-index progression | Detached boolean | One plan | + +The output Dictionary is newly allocated and caller-owned. The planner retains +no reference to it or any input. + +## Data flow + +```mermaid +flowchart TD + Select{Animated prototype?} -->|yes| Animated[animated_batch_limit] + Select -->|no| Static[static_batch_limit] + Animated --> Clamp[max 1] + Static --> Clamp + Clamp --> Remaining[max 0 of transform_count - current_offset] + Remaining --> Count[min effective limit, remaining] + Count --> Complete{count <= 0 or offset + count >= total?} + Complete -->|yes| Done[group_complete true; next_offset 0] + Complete -->|no| Continue[group_complete false; next_offset offset + count] +``` + +## Lifecycle/state + +The planner is stateless. It has no initialization, reset, cancellation, +recovery or shutdown work. Each call returns a complete detached value plan. + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader build loop + participant Planner as M2BuildBatchPlanner + participant Materializer as Animated/MultiMesh adapter + participant Scheduler as RenderBudgetScheduler + Loader->>Planner: plan_batch(count, offset, path kind, limits) + Planner-->>Loader: batch_count, next_offset, group_complete + Loader->>Loader: resolve/request prototype or mesh + Loader->>Materializer: materialize selected transform slice + Loader->>Loader: adopt cursor/group completion + Loader->>Scheduler: consume M2_BUILD permit +``` + +## Ownership, threading and resources + +- The planner owns only call-local scalar values and the returned Dictionary. +- The loader owns build-job Dictionaries, queue ordering, tile checks, resource + readiness/retry, serial numbers and group-index mutation. +- Materializers own main-thread Node/MultiMesh construction under loader roots. +- The scheduler owns the frame-local `M2_BUILD` counter. +- Pure planning is thread-safe, though the current adapter calls it on main thread. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Zero/negative selected limit | Scalar clamp | Effective size becomes one | Contract fixture | Correct profile value later | +| Empty transform group | Remaining count zero | Count zero; group completes | Contract fixture | Loader advances group | +| Offset at/past end | Remaining count clamp | Count zero; offset resets | Contract fixture | Loader advances group | +| Negative offset | No new validation | Historical arithmetic is preserved | Contract fixture | Loader inputs should remain valid | +| Resource pending/missing | Outside planner | Loader rotates or advances as before | Existing cache/build diagnostics | Resource loader retry/fallback | +| Tile cancelled | Outside planner | Loader cancels job before planning/materialization | Existing lifecycle gates | Eligible tile may requeue | + +## Configuration and capabilities + +The planner introduces no setting. It consumes existing +`m2_animated_instances_per_tick` and `m2_multimesh_batch_size` values after the +loader determines whether an animated prototype is available. + +## Persistence, cache and migration + +No persistence, schema or cache format participates. ADT/M2 cache versions, +quality presets and rebuild instructions are unchanged. + +## Diagnostics and observability + +The planner emits no logs. Its verifier covers deterministic formula/source +boundaries and records bounded 20,000-plan timing. Existing loader metrics retain +queue depth, build activity and hitch observability. + +## Verification + +- `verify_m2_build_batch_planner.gd`: static/animated selection, invalid-limit + clamp, remaining cap, exact completion, empty/past-end and negative-offset + behavior, loader/dependency source boundaries and bounded timing. +- Grouper, transform resolver, unique registry/dedupe, facade, internal-access, + manifest, shutdown, scheduler, streaming, coordinate, documentation and + coordination regressions remain required. +- Fidelity evidence is formula extraction only. Asset-backed frame pacing and + visual output require the renderer checkpoint/performance workflow. + +## Extension points + +- A later package may define typed build-job state once queue/resource transitions + are extracted together with explicit cancellation and recovery. +- Spatial-cell batching must use measured culling/performance evidence and must + not silently change this model-path batch cursor. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Static/animated batch cursor planning | Implemented extraction | Contract/source/timing verifier | Asset-backed p95/p99 pending | +| Build queue/resource state machine | Remains in loader | Existing lifecycle regressions | Stateful extraction pending | +| Spatial-cell batching | Planned | Renderer roadmap | Culling evidence/design pending | + +## Known gaps and risks + +- The result remains a Dictionary while loader build jobs are untyped Dictionaries. +- Negative offsets are preserved rather than rejected to avoid a hidden behavior change. +- Planning timing does not measure resource loading or materialization. +- Private asset-backed p95/p99 and visual comparison remain unavailable. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_build_batch_planner.gd` | Pure limit/count/cursor planning | +| `src/scenes/streaming/streaming_world_loader.gd` | Build job, queue, resources, materialization and budgets | +| `src/render/m2/m2_placement_grouper.gd` | Produces grouped transform arrays | +| `src/tools/verify_m2_build_batch_planner.gd` | Formula, source and timing regression | + +## Related decisions and references + +- [`m2-placement-grouper.md`](m2-placement-grouper.md) +- [`m2-placement-transform-resolver.md`](m2-placement-transform-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/m2-placement-grouper.md b/docs/modules/m2-placement-grouper.md index f91d4ac..061377a 100644 --- a/docs/modules/m2-placement-grouper.md +++ b/docs/modules/m2-placement-grouper.md @@ -153,8 +153,8 @@ continue to own task/build/cache observability. ## Extension points -- A later package may extract the M2 build-job state machine behind explicit - budgets while retaining this pure grouping input. +- `M2BuildBatchPlanner` now owns pure batch sizing/cursor progression; the + remaining build-job state machine stays behind explicit loader budgets. - Spatial cells require measured culling evidence and are outside this contract. ## Capability status @@ -179,11 +179,13 @@ continue to own task/build/cache observability. |---|---| | `src/render/m2/m2_placement_grouper.gd` | Validation, normalization and grouping | | `src/render/m2/m2_placement_transform_resolver.gd` | Basis and origin rules | +| `src/render/m2/m2_build_batch_planner.gd` | Downstream batch sizing and cursor planning | | `src/scenes/streaming/streaming_world_loader.gd` | Worker/result/build lifecycle and resources | | `src/tools/verify_m2_placement_grouper.gd` | Contract, dependency and timing regression | ## Related decisions and references +- [`m2-build-batch-planner.md`](m2-build-batch-planner.md) - [`m2-placement-transform-resolver.md`](m2-placement-transform-resolver.md) - [`m2-unique-placement-registry.md`](m2-unique-placement-registry.md) - [`world-renderer.md`](world-renderer.md) diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 9bd6a28..5d4e529 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -5,9 +5,9 @@ | Field | Value | |---|---| | 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 unique/transform/grouping packages | +| 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 unique/transform/grouping/batch packages | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-m2-placement-grouper`, 2026-07-16 | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-build-batch-planner`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -52,6 +52,8 @@ flowchart LR M2Grouper --> M2Transform[M2PlacementTransformResolver] M2Transform --> M2Grouper M2Transform --> Loader + Loader --> M2Batch[M2BuildBatchPlanner] + M2Batch --> Loader Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -121,6 +123,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `M2UniquePlacementRegistry.reserve/release/clear` | Internal M2 service | Owns positive cross-tile ADT placement-ID reservations | Renderer thread/map session | Invalid/unkeyed/non-owner inputs preserve documented fallback | | `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 | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -148,6 +151,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal registry | Tile key and M2 placement unique IDs | Loader / `M2UniquePlacementRegistry` | Filtered grouping input and tile retry state | Registry-owned strings; fresh result arrays | Map session | | Internal transform | Rotation/path/scale | Loader or grouper / `M2PlacementTransformResolver` | Group/placeholder/instance transforms | Value-only Basis/Vector3 | One placement | | Internal grouping | Tile origin, M2 names and placements | Loader / `M2PlacementGrouper` | Loader worker result/build job | Fresh Dictionary/Transform3D arrays | One grouping task | +| Internal batch plan | Transform count/offset, path kind and limits | Loader / `M2BuildBatchPlanner` | Loader materialization/cursor adapter | Fresh scalar Dictionary | One build 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 | @@ -194,7 +198,8 @@ flowchart TD R --> M2Registry[M2UniquePlacementRegistry reserve] M2Registry --> M2Grouper[M2PlacementGrouper] M2Transform[M2PlacementTransformResolver] --> M2Grouper - M2Grouper --> M2 + M2Grouper --> M2Batch[M2BuildBatchPlanner] + M2Batch --> M2 R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] @@ -314,6 +319,8 @@ sequenceDiagram transforms and every build/render side effect remain loader-owned. - `M2PlacementGrouper` is stateless and owns only call-local grouped transforms. The loader retains tasks, mutex/result queues, stale checks and build state. +- `M2BuildBatchPlanner` is stateless and owns only call-local scalar plans. The + loader retains queue/resource transitions, cursor adoption and materialization. - 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 @@ -403,6 +410,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m normalization, scale compensation, all three consumers and bounded timing. - M2 placement grouper contract: invalid/default records, historical path rules, group order, local transforms, fresh output, source boundary and bounded timing. +- M2 build batch contract: static/animated limit selection, clamp, remaining + count, completion/cursor rules, source boundary 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. @@ -435,6 +444,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | M2 unique placement registry | Implemented extraction | Scene-free ownership/lifecycle/timing contract and historical `uid:11785` smoke | Group/build/tasks/finalization and asset-backed p95/p99 remain pending | | M2 placement transform resolver | Implemented extraction | Scene-free formula/source/timing contract across three consumers | Asset-backed visual recheck and general placement parity pending | | M2 placement grouper | Implemented extraction | Scene-free validation/order/transform/source/timing contract | Worker/build state, spatial cells and asset-backed p95/p99 remain pending | +| 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 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/m2/m2_build_batch_planner.gd b/src/render/m2/m2_build_batch_planner.gd new file mode 100644 index 0000000..59efa9f --- /dev/null +++ b/src/render/m2/m2_build_batch_planner.gd @@ -0,0 +1,30 @@ +class_name M2BuildBatchPlanner +extends RefCounted + +## Stateless M2 build batch sizing and group-cursor progression. + + +## Plans one static or animated build batch. The returned Dictionary is detached +## and contains effective_batch_size, batch_count, next_offset and group_complete. +func plan_batch( + transform_count: int, + current_offset: int, + has_animated_prototype: bool, + animated_batch_limit: int, + static_batch_limit: int +) -> Dictionary: + var selected_batch_limit := ( + animated_batch_limit if has_animated_prototype else static_batch_limit + ) + var effective_batch_size := maxi(1, selected_batch_limit) + var remaining_transform_count := maxi(0, transform_count - current_offset) + var batch_count := mini(effective_batch_size, remaining_transform_count) + var is_group_complete := ( + batch_count <= 0 or current_offset + batch_count >= transform_count + ) + return { + "effective_batch_size": effective_batch_size, + "batch_count": batch_count, + "next_offset": 0 if is_group_complete else current_offset + batch_count, + "group_complete": is_group_complete, + } diff --git a/src/render/m2/m2_build_batch_planner.gd.uid b/src/render/m2/m2_build_batch_planner.gd.uid new file mode 100644 index 0000000..6148b79 --- /dev/null +++ b/src/render/m2/m2_build_batch_planner.gd.uid @@ -0,0 +1 @@ +uid://d0my7yntfsvuy diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 3ff1971..3593a86 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -33,6 +33,9 @@ const M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT := preload( const M2_PLACEMENT_GROUPER_SCRIPT := preload( "res://src/render/m2/m2_placement_grouper.gd" ) +const M2_BUILD_BATCH_PLANNER_SCRIPT := preload( + "res://src/render/m2/m2_build_batch_planner.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") @@ -219,6 +222,7 @@ 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_mesh_cache: Dictionary = {} var _m2_mesh_load_requests: Dictionary = {} var _m2_mesh_finalize_queue: Array = [] @@ -4315,14 +4319,15 @@ func _process_m2_build_jobs() -> void: _m2_build_queue.append(key) _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) continue - var batch_size: int = ( - maxi(1, m2_animated_instances_per_tick) - if animated_prototype != null - else maxi(1, m2_multimesh_batch_size) + var batch_plan: Dictionary = _m2_build_batch_planner.plan_batch( + transforms.size(), + offset, + animated_prototype != null, + m2_animated_instances_per_tick, + m2_multimesh_batch_size ) - var batch_count: int = mini(batch_size, maxi(0, transforms.size() - offset)) + var batch_count: int = int(batch_plan["batch_count"]) var serial: int = int(job.get("serial", 0)) - var consumed := batch_count if batch_count > 0: if animated_prototype != null: _materialize_m2_animated_batch(root as Node3D, rel_path, animated_prototype, transforms, offset, batch_count, serial) @@ -4337,11 +4342,11 @@ func _process_m2_build_jobs() -> void: else: _materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial) job["serial"] = serial + 1 - if consumed <= 0 or offset + consumed >= transforms.size(): + if bool(batch_plan["group_complete"]): job["index"] = index + 1 job["offset"] = 0 else: - job["offset"] = offset + consumed + job["offset"] = int(batch_plan["next_offset"]) _m2_build_jobs[key] = job _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) diff --git a/src/tools/verify_m2_build_batch_planner.gd b/src/tools/verify_m2_build_batch_planner.gd new file mode 100644 index 0000000..7a130d0 --- /dev/null +++ b/src/tools/verify_m2_build_batch_planner.gd @@ -0,0 +1,217 @@ +extends SceneTree + +## Asset-free contract, dependency and timing regression for M2 batch planning. + +const PLANNER_SCRIPT := preload("res://src/render/m2/m2_build_batch_planner.gd") +const PLANNER_PATH := "res://src/render/m2/m2_build_batch_planner.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_static_batch_limit(failures) + _verify_animated_batch_limit(failures) + _verify_non_positive_limit_clamp(failures) + _verify_remaining_count_cap(failures) + _verify_exact_group_completion(failures) + _verify_empty_and_past_end(failures) + _verify_historical_negative_offset(failures) + _verify_source_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_BUILD_BATCH_PLANNER: %s" % failure) + quit(1) + return + print( + "M2_BUILD_BATCH_PLANNER PASS cases=8 iterations=20000 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_static_batch_limit(failures: Array[String]) -> void: + var plan := _plan(300, 0, false, 8, 128) + _expect_plan(plan, 128, 128, 128, false, "static batch", failures) + + +func _verify_animated_batch_limit(failures: Array[String]) -> void: + var plan := _plan(300, 0, true, 8, 128) + _expect_plan(plan, 8, 8, 8, false, "animated batch", failures) + + +func _verify_non_positive_limit_clamp(failures: Array[String]) -> void: + var animated_plan := _plan(4, 0, true, 0, 128) + _expect_plan(animated_plan, 1, 1, 1, false, "zero animated limit", failures) + var static_plan := _plan(4, 0, false, 8, -10) + _expect_plan(static_plan, 1, 1, 1, false, "negative static limit", failures) + + +func _verify_remaining_count_cap(failures: Array[String]) -> void: + var plan := _plan(140, 128, false, 8, 128) + _expect_plan(plan, 128, 12, 0, true, "remaining cap", failures) + + +func _verify_exact_group_completion(failures: Array[String]) -> void: + var plan := _plan(16, 8, true, 8, 128) + _expect_plan(plan, 8, 8, 0, true, "exact completion", failures) + + +func _verify_empty_and_past_end(failures: Array[String]) -> void: + var empty_plan := _plan(0, 0, false, 8, 128) + _expect_plan(empty_plan, 128, 0, 0, true, "empty group", failures) + var past_end_plan := _plan(10, 11, false, 8, 128) + _expect_plan(past_end_plan, 128, 0, 0, true, "past end", failures) + + +func _verify_historical_negative_offset(failures: Array[String]) -> void: + var plan := _plan(10, -2, false, 8, 4) + _expect_plan(plan, 4, 4, 2, false, "negative offset", failures) + + +func _verify_source_boundaries(failures: Array[String]) -> void: + var loader_source := _read_text(LOADER_PATH, failures) + var planner_source := _read_text(PLANNER_PATH, failures) + var build_loop_source := _source_between( + loader_source, + "func _process_m2_build_jobs()", + "func _materialize_m2_group_batch(", + failures + ) + _expect_equal( + build_loop_source.count("_m2_build_batch_planner.plan_batch("), + 1, + "one loader planner adapter", + failures + ) + _expect_true( + not build_loop_source.contains("var batch_size:"), + "loader omits batch-size formula", + failures + ) + _expect_true( + not build_loop_source.contains("mini(batch_size"), + "loader omits batch-count formula", + failures + ) + for forbidden_dependency in [ + "WorkerThreadPool", + "RenderingServer", + "ResourceLoader", + "MultiMesh", + "Node", + "Mutex", + ]: + _expect_true( + not planner_source.contains(forbidden_dependency), + "planner omits %s" % forbidden_dependency, + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var planner := PLANNER_SCRIPT.new() + var started_microseconds := Time.get_ticks_usec() + for iteration in range(20000): + var plan: Dictionary = planner.plan_batch( + 512, + iteration % 512, + iteration % 3 == 0, + 8, + 128 + ) + if int(plan.get("batch_count", -1)) < 0: + failures.append("timing iteration returned negative batch count") + break + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "20000 batch plans bounded", failures) + return elapsed_milliseconds + + +func _plan( + transform_count: int, + current_offset: int, + has_animated_prototype: bool, + animated_batch_limit: int, + static_batch_limit: int +) -> Dictionary: + var planner := PLANNER_SCRIPT.new() + return planner.plan_batch( + transform_count, + current_offset, + has_animated_prototype, + animated_batch_limit, + static_batch_limit + ) + + +func _expect_plan( + plan: Dictionary, + expected_batch_size: int, + expected_batch_count: int, + expected_next_offset: int, + expected_group_complete: bool, + label: String, + failures: Array[String] +) -> void: + _expect_equal( + int(plan.get("effective_batch_size", -1)), + expected_batch_size, + "%s effective batch size" % label, + failures + ) + _expect_equal( + int(plan.get("batch_count", -1)), + expected_batch_count, + "%s batch count" % label, + failures + ) + _expect_equal( + int(plan.get("next_offset", -1)), + expected_next_offset, + "%s next offset" % label, + failures + ) + _expect_true( + bool(plan.get("group_complete", not expected_group_complete)) + == expected_group_complete, + "%s group complete" % 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 _source_between( + source: String, + start_marker: String, + end_marker: String, + failures: Array[String] +) -> String: + var start_offset := source.find(start_marker) + var end_offset := source.find(end_marker, start_offset + start_marker.length()) + if start_offset < 0 or end_offset <= start_offset: + failures.append("cannot isolate source between %s and %s" % [start_marker, end_marker]) + return "" + return source.substr(start_offset, end_offset - start_offset) + + +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) diff --git a/src/tools/verify_m2_build_batch_planner.gd.uid b/src/tools/verify_m2_build_batch_planner.gd.uid new file mode 100644 index 0000000..a22b858 --- /dev/null +++ b/src/tools/verify_m2_build_batch_planner.gd.uid @@ -0,0 +1 @@ +uid://bw088yviu4kk3