Compare commits

..

5 Commits

11 changed files with 693 additions and 94 deletions
+13
View File
@@ -990,6 +990,19 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- M2/ADT cache formats, transforms, profiles and visible rules are unchanged.
Asset-backed cross-boundary p95/p99 and visual acceptance remain required.
## 2026-07-16 M2 Placement Transform Resolver Extraction
- `M2PlacementTransformResolver` now owns the pure ADT M2 basis and local
origin-compensation formulas used by worker grouping, placeholders and direct
instance creation.
- Regular models retain `Basis.from_euler`; `ElwynnCliffRock01/02` retain their
narrow shell correction; `NewWaterfall` and `ElwynnTallWaterfall01` retain
calibrated world yaw, local fall-axis twist and tall-sheet anchor compensation.
- Path separator/case/MDX handling, constants and caller-specific final scale
clamps are unchanged. The resolver owns no Node, task, cache or RID.
- Cache formats, placement positions and visible rules are unchanged by the
extraction. Asset-backed visual recheck and general placement parity remain gaps.
## 2026-07-16 World Environment Snapshot Facade
- `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value,
@@ -0,0 +1,107 @@
# M03-RND-M2-PLACEMENT-TRANSFORM-001 — M2 placement transform resolver
<!-- OPENWC_CLAIM:M03-RND-M2-PLACEMENT-TRANSFORM-001:sindo-main-codex:2026-07-19 -->
## Ownership
- Target: M03
- Program: RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m03-m2-placement-transform`
- Lease expires UTC: 2026-07-19
- Integrator: M03 milestone integrator
## Outcome
Extract pure ADT M2 placement basis/origin-offset calculation, including the
existing waterfall and Elwynn cliff-rock calibrations, into a scene-free service.
## Non-goals
- Change coordinate conversion, placement positions, rotations or scale clamps.
- Generalize model-specific corrections or add new allowlisted models.
- Move grouping/tasks, MultiMesh/Node/Mesh/material/RID creation or caches.
- Change M2 animation/material behavior, cache versions or renderer profiles.
- Claim build-12340 placement parity beyond existing fixtures.
## Paths
- Exclusive: `src/render/m2/m2_placement_transform_resolver.gd`,
`src/tools/verify_m2_placement_transform_resolver.gd`,
`docs/modules/m2-placement-transform-resolver.md`, this claim
- Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`,
`docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`,
`targets/03-renderer-facade.md`
- Generated/ignored: `.godot`, native DLL, generated ADT resources, caches and
proprietary renderer corpus
## Contracts and data
- Input: ADT-loader Godot Euler rotation, normalized/original M2 relative path,
and raw placement scale where origin compensation is requested
- Output: `Basis` and local origin-offset `Vector3`; no retained state
- Regular models use `Basis.from_euler`; two cliff rocks retain narrow YZX-style
shell correction; two waterfall sheets retain yaw/twist/anchor compensation
- Scale clamps remain caller-owned for final basis and resolver-owned at `0.0001`
only for historical waterfall anchor compensation
- Cache/schema/coordinate versions: unchanged
## Dependencies
- Requires: current master `1b6c468`
- Blocks: pure M2 grouping extraction and broader M2 service decomposition
- External state: none; tests use synthetic rotations/paths and pinned constants
## Verification
- Commands: dedicated regular/path-normalization/cliffrock/waterfall/twist-anchor/
scale and bounded timing verifier; three loader call-site/source boundaries;
M2 unique/dedupe plus facade/terrain/internal-access/manifest/shutdown gates
- Fidelity evidence: exact formulas/constants and model allowlists moved unchanged
- Performance budget: allocation-free scalar/Basis math per placement aside from
path normalization; no Node, Resource, RID, task, cache or file access
## Documentation deliverables
- Inline resolver API documentation
- New module spec with inputs/outputs, data-flow, lifecycle, ownership, failure,
performance, fidelity limitations and source map
- World renderer/module registry/`RENDER.md` updates
## Simplicity and naming
- Important name: `M2PlacementTransformResolver`
- Simplest solution: stateless service with basis and origin-offset queries
- Rejected complexity: transform strategy hierarchy, generic correction registry,
Resources, data-driven override format or callback injection
- Unavoidable complexity: four measured model-specific exceptions remain explicit
- Measured optimization evidence: bounded synthetic transform loop
## Status
- State: accepted
- Done: resolver extracted, three call sites migrated, contracts and docs verified
- Next: pure M2 grouping extraction and broader M2 service decomposition
- Blocked by:
<!-- OPENWC_HANDOFF:READY:M03-RND-M2-PLACEMENT-TRANSFORM-001:0a09d7b -->
<!-- OPENWC_INTEGRATION:ACCEPTED:M03-RND-M2-PLACEMENT-TRANSFORM-001:396be5e -->
## Handoff
- Commit: `0a09d7b` (`refactor(M03): extract M2 placement transform resolver`)
- Results: dedicated formula/source/timing contract PASS (`cases=9`,
`iterations=10000`, `elapsed_ms=28.732`); cold editor parse PASS;
documentation gate PASS (`module_specs=16`, `required_files=7`);
coordination gate PASS with 23 pre-existing expired M00/M01 warnings; related
M2 unique/dedupe, terrain cache/LOD/queue, facade, internal-access, manifest,
shutdown, scheduler, streaming focus/planner and coordinate-boundary suites
PASS; renderer checkpoint dry-run PASS `7/7`.
- Remaining risks: no private asset-backed visual recheck in this package;
general ADT M2 placement parity and asset-backed p95/p99 remain pending.
- Documentation updated: inline public API; new
`docs/modules/m2-placement-transform-resolver.md`; renderer module registry,
`docs/modules/world-renderer.md` and `RENDER.md`.
- Integration: accepted in master merge `396be5e`; post-merge resolver
(`cases=9`, `iterations=10000`, `elapsed_ms=27.409`), M2 registry,
internal-access, facade, manifest, documentation and coordination gates passed.
+1
View File
@@ -16,6 +16,7 @@
| Terrain chunk LOD planner | Implemented extraction | [`terrain-chunk-lod-planner.md`](terrain-chunk-lod-planner.md) |
| Terrain chunk geometry queue planner | Implemented extraction | [`terrain-chunk-geometry-queue-planner.md`](terrain-chunk-geometry-queue-planner.md) |
| 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) |
| 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) |
@@ -0,0 +1,184 @@
# M2 Placement Transform Resolver
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M03 / `M03-RND-M2-PLACEMENT-TRANSFORM-001` |
| Owners | Pure ADT M2 placement basis and model-specific origin compensation |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-placement-transform`, 2026-07-16 |
| Profiles/capabilities | Existing static M2 paths; four calibrated model exceptions |
## Purpose
Resolve the unscaled Godot-space orientation and optional local origin offset
used by ADT M2 worker grouping, placeholder rendering and instance creation.
## Non-goals
- Convert WoW/server/ADT coordinates or own placement positions.
- Apply final scale clamps, build transforms, group placements or create Nodes.
- Add, generalize or configure model-specific corrections.
- Change M2 materials, animation, caches, visibility or quality profiles.
- Claim general build-12340 placement parity.
## Context and boundaries
```mermaid
flowchart LR
ADT[ADT placement rotation/path/scale] --> Resolver[M2PlacementTransformResolver]
Resolver --> Basis[Unscaled Basis]
Resolver --> Offset[Local origin offset]
Basis --> Group[Worker grouping]
Basis --> Placeholder[Placeholder MultiMesh path]
Basis --> Instance[Instance path]
Offset --> Group
Offset --> Placeholder
Offset --> Instance
Group --> Loader[StreamingWorldLoader-owned queues/build]
```
Allowed dependencies are `Vector3`, `Basis`, scalar math and path-string
normalization. Node, SceneTree, RenderingServer, ResourceLoader, tasks, caches,
files, gameplay, network and editor UI are forbidden.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `resolve_basis(rotation_radians, relative_m2_path)` | Pure query | Return unscaled regular or calibrated placement orientation | Worker/main thread; call-local result | Unknown paths use `Basis.from_euler` |
| `resolve_origin_offset(rotation_radians, relative_m2_path, scale_value)` | Pure query | Compensate the measured tall-waterfall twist anchor | Worker/main thread; call-local result | Regular/unanchored paths return zero; scale clamps to `0.0001` for compensation |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Godot Euler rotation in radians | ADT loader placement record | Resolver | Scalar copy | One call |
| Input | Relative M2 path | ADT MMDX/name table | Resolver allowlist | Caller string | One call |
| Input | Raw placement scale | ADT placement record | Offset compensation | Scalar copy | One call |
| Output | Unscaled `Basis` | Resolver | Group/placeholder/instance adapter | Value copy | One call |
| Output | Local `Vector3` offset | Resolver | Transform origin adapter | Value copy | One call |
Callers preserve their historical basis scale minima: grouping/instance use
`0.0001`, while placeholders use `0.01`.
## Data flow
```mermaid
flowchart TD
BasisCall[resolve_basis] --> ModelName[Normalize separators/case/basename]
ModelName --> Waterfall{Waterfall sheet?}
Waterfall -->|yes| WaterfallBasis[+90 degree world yaw * Euler * local-axis twist]
Waterfall -->|no| Cliff{Elwynn cliff rock?}
Cliff -->|yes| CliffBasis[Corrected Y/BACK/RIGHT composition]
Cliff -->|no| Regular[Basis.from_euler]
OffsetCall[resolve_origin_offset] --> Anchored{Tall waterfall anchor?}
Anchored -->|no| Zero[Vector3.ZERO]
Anchored -->|yes| Compensation[(base anchor - twisted anchor) * clamped scale]
```
## Lifecycle/state
The resolver is stateless. It may be shared between the main thread and worker
calls because each result is computed solely from value inputs. Construction,
world reset and shutdown require no clear, cancellation or resource release.
## Main sequence
```mermaid
sequenceDiagram
participant Loader as StreamingWorldLoader
participant Resolver as M2PlacementTransformResolver
participant Consumer as Group/placeholder/instance path
Loader->>Resolver: resolve_basis(rotation, path)
Resolver-->>Loader: unscaled Basis
Loader->>Resolver: resolve_origin_offset(rotation, path, scale)
Resolver-->>Loader: local offset
Loader->>Consumer: compose historical scaled Transform3D
```
## Ownership, threading and resources
- The resolver retains no mutable state or engine resources.
- The loader owns placement records, final transforms, tasks, queues, caches,
MultiMesh/Node/Mesh/material/RID creation and cleanup.
- Pure methods are safe for current worker grouping and main-thread consumers.
- Returned `Basis`/`Vector3` values have no lifetime coupling to the resolver.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Empty/unknown path | Allowlist miss | Regular Euler basis and zero offset | Contract fixture | Expected fallback |
| Mixed case/backslashes/MDX | Normalization | Same calibrated model match | Contract fixture | None required |
| Zero/negative scale for anchored offset | Numeric clamp | Use `0.0001` compensation scale | Contract fixture | Caller may supply valid ADT scale later |
| World reset/shutdown | No retained state | No action | Existing shutdown verifier | New calls remain independent |
There is no asynchronous operation or cancellation inside the resolver.
## Configuration and capabilities
The resolver introduces no settings. The preserved constants and allowlists are:
- world yaw `PI` for `ElwynnCliffRock01/02`;
- world yaw `PI/2` for `NewWaterfall` and `ElwynnTallWaterfall01`;
- measured local axes/twist signs for both waterfall sheets;
- measured tall-waterfall anchor `(-2.667799, 89.62273, 0.00129)`.
## Persistence, cache and migration
No persistence, schema or cache format participates. Existing ADT/M2 cache
versions and rebuild instructions remain unchanged.
## Diagnostics and observability
The resolver emits no logs. The contract verifier compares formulas/constants,
checks all three loader consumers and records bounded synthetic timing. Visual
and asset-backed evidence remains in the renderer checkpoint workflow.
## Verification
- `verify_m2_placement_transform_resolver.gd`: regular fallback, path
normalization, both cliff rocks, both waterfalls, twist anchor, negative-scale
compensation, three loader adapters, source boundary and 10,000-pair timing.
- M2 unique/dedupe, terrain, facade, internal-access, manifest, shutdown,
scheduler, streaming and coordinate regressions remain required.
- Fidelity evidence is exact extraction of existing calibrated formulas. The
private asset/camera comparison that motivated them is not reproduced here.
## Extension points
- The next package may compose this resolver into a pure placement grouper.
- New model corrections require separate measured fidelity evidence; this module
is not a generic override registry.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Consistent transform rules across three M2 paths | Implemented | Formula/source/timing contract | Asset-backed visual recheck pending |
| General ADT M2 placement parity | Partial | Existing checkpoint notes | Broader fixture coverage required |
## Known gaps and risks
- Four model-specific exceptions are empirical compatibility rules.
- Most placements use raw Godot Euler behavior; independent client-wide
transform validation is incomplete.
- Callers intentionally retain different final scale clamps.
- Grouping, build, cache and render ownership remain in the streamer.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/m2/m2_placement_transform_resolver.gd` | Pure basis/offset rules and calibrated allowlists |
| `src/scenes/streaming/streaming_world_loader.gd` | Three adapters and final Transform3D/resource ownership |
| `src/tools/verify_m2_placement_transform_resolver.gd` | Formula, source and performance regression |
## Related decisions and references
- [`m2-unique-placement-registry.md`](m2-unique-placement-registry.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)
+12 -2
View File
@@ -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-RND-M2-UNIQUE-REGISTRY-001` |
| 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 packages |
| Owners | Renderer workstream / milestone integrator |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-unique-registry`, 2026-07-16 |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-placement-transform`, 2026-07-16 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose
@@ -48,6 +48,8 @@ flowchart LR
ChunkQueue --> Loader
Loader --> M2Registry[M2UniquePlacementRegistry]
M2Registry --> Loader
Loader --> M2Transform[M2PlacementTransformResolver]
M2Transform --> Loader
Native --> Parsed[Parsed tile/model data]
Parsed --> Loader
Loader --> Scene[SceneTree nodes]
@@ -115,6 +117,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state
| `TerrainChunkLodPlanner.plan` | Internal pure terrain query | Maps populated parsed chunk indices to desired LOD 0/1/2 | Synchronous per target refresh | Invalid/disabled input returns empty for existing tile-LOD fallback |
| `TerrainChunkGeometryQueuePlanner.plan` | Internal pure terrain query | Produces chunk create/remove requests from current and desired state | Synchronous per queue rebuild | Invalid focus returns empty arrays |
| `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 |
Публичным contract не считаются `StreamingWorldLoader` methods/properties,
внутренние dictionaries, queues, job records и generated node names. Scene-owned
@@ -140,6 +143,7 @@ loader configuration remains transitional composition data, not a caller API.
| Internal plan | Parsed chunks, typed focus and immutable chunk-LOD policy | Loader / `TerrainChunkLodPlanner` | Loader desired state | Fresh dictionary; no retained resources | One target refresh |
| Internal plan | Current/desired chunk state and typed focus | Loader / `TerrainChunkGeometryQueuePlanner` | Loader chunk queues | Fresh request arrays; no retained resources | One queue rebuild |
| 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 / `M2PlacementTransformResolver` | Group/placeholder/instance transforms | Value-only Basis/Vector3 | One placement |
| 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 |
@@ -185,6 +189,7 @@ flowchart TD
ChunkQueue --> B
R --> M2Registry[M2UniquePlacementRegistry reserve]
M2Registry --> M2
M2Transform[M2PlacementTransformResolver] --> M2
R --> B[RenderBudgetScheduler permits]
B --> Terrain[Terrain attach/upgrade]
B --> M2[M2 group/MultiMesh attach]
@@ -299,6 +304,8 @@ sequenceDiagram
- `M2UniquePlacementRegistry` owns only positive unique-key to tile-key strings.
The loader retains owned/skipped tile arrays, candidate retry, grouping/build
tasks, caches and all MultiMesh/Node/Mesh/material/RID side effects.
- `M2PlacementTransformResolver` is stateless and retains no resources. The
loader owns final transforms and every grouping/build/render side effect.
- 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
@@ -384,6 +391,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
- M2 unique placement contract: unkeyed pass-through, within-call/cross-tile
dedupe, owner release/retry, clear, detached diagnostics, loader boundary and
bounded reserve/release timing without a world scene.
- M2 placement transform contract: regular/cliffrock/waterfall formulas, path
normalization, scale compensation, all three consumers 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.
@@ -414,6 +423,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| ADT streaming/terrain | Partial | M00 checkpoints and current scenes | Fidelity/performance gaps remain |
| Static M2 placement | Partial | MultiMesh/dedupe probes | Full materials/animation/effects |
| 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 |
| 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 |
@@ -0,0 +1,110 @@
class_name M2PlacementTransformResolver
extends RefCounted
## Stateless ADT M2 placement orientation and origin-correction rules.
const CLIFFROCK_WORLD_YAW_OFFSET := PI
const WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
## Resolves the unscaled Godot-space basis for one ADT M2 placement.
func resolve_basis(rotation_radians: Vector3, relative_m2_path: String = "") -> Basis:
if not _is_waterfall_path(relative_m2_path):
return _resolve_regular_basis(rotation_radians, relative_m2_path)
var placement_basis := Basis.from_euler(rotation_radians)
return (
Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET)
* placement_basis
* Basis(
_resolve_waterfall_local_fall_axis(relative_m2_path),
_resolve_waterfall_sheet_twist(relative_m2_path)
)
)
## Resolves local origin compensation for a twisted waterfall sheet. Regular
## models and waterfall models without a measured anchor return zero.
func resolve_origin_offset(
rotation_radians: Vector3,
relative_m2_path: String,
scale_value: float
) -> Vector3:
if not _is_waterfall_path(relative_m2_path):
return Vector3.ZERO
var twist_radians := _resolve_waterfall_sheet_twist(relative_m2_path)
if is_zero_approx(twist_radians):
return Vector3.ZERO
var anchor_local_position := _resolve_waterfall_sheet_twist_anchor(relative_m2_path)
if anchor_local_position == Vector3.ZERO:
return Vector3.ZERO
var placement_basis := (
Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET)
* Basis.from_euler(rotation_radians)
)
var twisted_basis := placement_basis * Basis(
_resolve_waterfall_local_fall_axis(relative_m2_path),
twist_radians
)
return (
placement_basis * anchor_local_position
- twisted_basis * anchor_local_position
) * maxf(scale_value, 0.0001)
func _resolve_regular_basis(rotation_radians: Vector3, relative_m2_path: String) -> Basis:
if not _is_elwynn_cliffrock_path(relative_m2_path):
return Basis.from_euler(rotation_radians)
return (
Basis(Vector3.UP, rotation_radians.y + CLIFFROCK_WORLD_YAW_OFFSET)
* Basis(Vector3.BACK, rotation_radians.x)
* Basis(Vector3.RIGHT, -rotation_radians.z)
)
func _is_waterfall_path(relative_m2_path: String) -> bool:
var model_name := _normalized_model_name(relative_m2_path)
return model_name == "newwaterfall" or model_name == "elwynntallwaterfall01"
func _is_elwynn_cliffrock_path(relative_m2_path: String) -> bool:
var model_name := _normalized_model_name(relative_m2_path)
return model_name == "elwynncliffrock01" or model_name == "elwynncliffrock02"
func _normalized_model_name(relative_m2_path: String) -> String:
return relative_m2_path.replace("\\", "/").to_lower().get_file().get_basename()
func _resolve_waterfall_local_fall_axis(relative_m2_path: String) -> Vector3:
var normalized_path := relative_m2_path.replace("\\", "/").to_lower()
if normalized_path.ends_with("newwaterfall.m2") or normalized_path.ends_with("newwaterfall.mdx"):
return Vector3(-0.138742, 0.990329, 0.0).normalized()
if (
normalized_path.ends_with("elwynntallwaterfall01.m2")
or normalized_path.ends_with("elwynntallwaterfall01.mdx")
):
return Vector3(-0.068962, 0.997619, 0.0).normalized()
return Vector3.UP
func _resolve_waterfall_sheet_twist(relative_m2_path: String) -> float:
var normalized_path := relative_m2_path.replace("\\", "/").to_lower()
if normalized_path.ends_with("newwaterfall.m2") or normalized_path.ends_with("newwaterfall.mdx"):
return PI * 0.5
if (
normalized_path.ends_with("elwynntallwaterfall01.m2")
or normalized_path.ends_with("elwynntallwaterfall01.mdx")
):
return -PI * 0.5
return 0.0
func _resolve_waterfall_sheet_twist_anchor(relative_m2_path: String) -> Vector3:
var normalized_path := relative_m2_path.replace("\\", "/").to_lower()
if (
normalized_path.ends_with("elwynntallwaterfall01.m2")
or normalized_path.ends_with("elwynntallwaterfall01.mdx")
):
return Vector3(-2.667799, 89.62273, 0.00129)
return Vector3.ZERO
@@ -0,0 +1 @@
uid://bcnmp7y8ph3qa
+32 -88
View File
@@ -27,6 +27,9 @@ const TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT := preload(
const M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT := preload(
"res://src/render/m2/m2_unique_placement_registry.gd"
)
const M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT := preload(
"res://src/render/m2/m2_placement_transform_resolver.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")
@@ -39,8 +42,6 @@ const RENDER_GROUND_QUERY_RAY_HEIGHT_UNITS := 5000.0
const TILE_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI
const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
const QUALITY_CUSTOM := "Custom"
const QUALITY_PERFORMANCE := "Performance"
const QUALITY_BALANCED := "Balanced"
@@ -213,6 +214,7 @@ var _m2_build_queue: Array = []
var _m2_unique_placement_registry := (
M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()
)
var _m2_placement_transform_resolver := M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()
var _m2_mesh_cache: Dictionary = {}
var _m2_mesh_load_requests: Dictionary = {}
var _m2_mesh_finalize_queue: Array = []
@@ -3978,86 +3980,6 @@ func _rebuild_cached_wmo_material(material: Material) -> Material:
return WMO_BUILDER_SCRIPT._build_material(mat_def, textures, extracted_dir)
func _adt_m2_placement_basis(rot: Vector3, rel_path: String = "") -> Basis:
if not _is_waterfall_m2_path(rel_path):
return _adt_m2_regular_placement_basis(rot, rel_path)
# ADTLoader stores MDDF yaw as raw_yaw - 90 degrees for historical Godot
# facing alignment. In our tile-positive ADT world, waterfall models need a
# world-space +90 degree yaw to anchor their top to the source pool. Twist
# the sheet around its own local fall axis afterwards, so the anchor stays in
# place but the alpha plane is not rendered edge-on.
var basis := Basis.from_euler(rot)
return (
Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET)
* basis
* Basis(_waterfall_local_fall_axis(rel_path), _waterfall_sheet_twist(rel_path))
)
func _adt_m2_regular_placement_basis(rot: Vector3, rel_path: String = "") -> Basis:
if not _is_elwynn_cliffrock_m2_path(rel_path):
return Basis.from_euler(rot)
# Elwynn cliff rock M2s are open-backed shell meshes. Plain Godot Euler
# points their hollow side out of the cliff at waterfall placements. Keep the
# correction model-specific so normal props retain the native ADT rotation.
return (
Basis(Vector3.UP, rot.y + ADT_CLIFFROCK_WORLD_YAW_OFFSET)
* Basis(Vector3.BACK, rot.x)
* Basis(Vector3.RIGHT, -rot.z)
)
func _adt_m2_placement_origin_offset(rot: Vector3, rel_path: String, scale_value: float) -> Vector3:
if not _is_waterfall_m2_path(rel_path):
return Vector3.ZERO
var twist := _waterfall_sheet_twist(rel_path)
if is_zero_approx(twist):
return Vector3.ZERO
var anchor := _waterfall_sheet_twist_anchor(rel_path)
if anchor == Vector3.ZERO:
return Vector3.ZERO
var base := Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET) * Basis.from_euler(rot)
var twisted := base * Basis(_waterfall_local_fall_axis(rel_path), twist)
return (base * anchor - twisted * anchor) * maxf(scale_value, 0.0001)
func _is_waterfall_m2_path(rel_path: String) -> bool:
var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename()
return name == "newwaterfall" or name == "elwynntallwaterfall01"
func _is_elwynn_cliffrock_m2_path(rel_path: String) -> bool:
var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename()
return name == "elwynncliffrock01" or name == "elwynncliffrock02"
func _waterfall_local_fall_axis(rel_path: String) -> Vector3:
var normalized := rel_path.replace("\\", "/").to_lower()
if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"):
return Vector3(-0.138742, 0.990329, 0.0).normalized()
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
return Vector3(-0.068962, 0.997619, 0.0).normalized()
return Vector3.UP
func _waterfall_sheet_twist(rel_path: String) -> float:
var normalized := rel_path.replace("\\", "/").to_lower()
if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"):
return PI * 0.5
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
return -PI * 0.5
return 0.0
func _waterfall_sheet_twist_anchor(rel_path: String) -> Vector3:
var normalized := rel_path.replace("\\", "/").to_lower()
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
return Vector3(-2.667799, 89.62273, 0.00129)
return Vector3.ZERO
func _cancel_wmo_build_job(tile_key: String) -> void:
if _wmo_build_jobs.has(tile_key):
_wmo_build_jobs.erase(tile_key)
@@ -4216,8 +4138,12 @@ func _group_tile_m2_task(
var pos: Vector3 = world_pos - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
var basis := _adt_m2_placement_basis(rot, normalized)
var offset := _adt_m2_placement_origin_offset(rot, normalized, scale_value)
var basis: Basis = _m2_placement_transform_resolver.resolve_basis(rot, normalized)
var offset: Vector3 = _m2_placement_transform_resolver.resolve_origin_offset(
rot,
normalized,
scale_value
)
var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos + offset)
if not groups.has(normalized):
@@ -4905,8 +4831,18 @@ func _build_tile_m2_placeholders(tile_root: Node3D, tile_origin: Vector3, m2_nam
var local_pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
var xform_basis := _adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.01))
var xf := Transform3D(xform_basis, local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value))
var xform_basis := _m2_placement_transform_resolver.resolve_basis(
rot,
rel_path
).scaled(Vector3.ONE * maxf(scale_value, 0.01))
var xf := Transform3D(
xform_basis,
local_pos + _m2_placement_transform_resolver.resolve_origin_offset(
rot,
rel_path,
scale_value
)
)
if is_tree:
tree_transforms.append(xf)
else:
@@ -4956,8 +4892,16 @@ func _instantiate_m2(rel_path: String, placement: Dictionary, tile_origin: Vecto
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
node.transform = Transform3D(
_adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.0001)),
local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value))
_m2_placement_transform_resolver.resolve_basis(
rot,
rel_path
).scaled(Vector3.ONE * maxf(scale_value, 0.0001)),
local_pos + _m2_placement_transform_resolver.resolve_origin_offset(
rot,
rel_path,
scale_value
)
)
return node
@@ -0,0 +1,219 @@
extends SceneTree
## Asset-free formula, dependency and timing regression for ADT M2 transforms.
const RESOLVER_SCRIPT := preload("res://src/render/m2/m2_placement_transform_resolver.gd")
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
const WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
const CLIFFROCK_WORLD_YAW_OFFSET := PI
func _initialize() -> void:
var failures: Array[String] = []
_verify_regular_basis(failures)
_verify_cliffrock_basis(failures)
_verify_new_waterfall_basis(failures)
_verify_tall_waterfall_basis_and_offset(failures)
_verify_path_normalization_and_scale_clamp(failures)
_verify_loader_boundary(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("M2_PLACEMENT_TRANSFORM_RESOLVER: %s" % failure)
quit(1)
return
print(
"M2_PLACEMENT_TRANSFORM_RESOLVER PASS cases=9 iterations=10000 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_regular_basis(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(0.2, -0.7, 1.1)
_expect_basis_near(
resolver.call("resolve_basis", rotation, "World/Generic/Tree.m2"),
Basis.from_euler(rotation),
"regular basis",
failures
)
_expect_vector_near(
resolver.call("resolve_origin_offset", rotation, "World/Generic/Tree.m2", 3.0),
Vector3.ZERO,
"regular offset",
failures
)
func _verify_cliffrock_basis(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(0.3, 0.4, -0.2)
var expected_basis := (
Basis(Vector3.UP, rotation.y + CLIFFROCK_WORLD_YAW_OFFSET)
* Basis(Vector3.BACK, rotation.x)
* Basis(Vector3.RIGHT, -rotation.z)
)
_expect_basis_near(
resolver.call("resolve_basis", rotation, "WORLD\\AZEROTH\\ElwynnCliffRock01.MDX"),
expected_basis,
"cliffrock basis",
failures
)
func _verify_new_waterfall_basis(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(-0.1, 0.8, 0.2)
var fall_axis := Vector3(-0.138742, 0.990329, 0.0).normalized()
var expected_basis := (
Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET)
* Basis.from_euler(rotation)
* Basis(fall_axis, PI * 0.5)
)
_expect_basis_near(
resolver.call("resolve_basis", rotation, "world/x/NewWaterfall.m2"),
expected_basis,
"new waterfall basis",
failures
)
_expect_vector_near(
resolver.call("resolve_origin_offset", rotation, "world/x/NewWaterfall.m2", 1.0),
Vector3.ZERO,
"new waterfall offset",
failures
)
func _verify_tall_waterfall_basis_and_offset(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(0.05, -0.2, 0.15)
var path := "world/x/ElwynnTallWaterfall01.m2"
var fall_axis := Vector3(-0.068962, 0.997619, 0.0).normalized()
var placement_basis := (
Basis(Vector3.UP, WATERFALL_WORLD_YAW_OFFSET) * Basis.from_euler(rotation)
)
var expected_basis := placement_basis * Basis(fall_axis, -PI * 0.5)
var anchor := Vector3(-2.667799, 89.62273, 0.00129)
var expected_offset := (placement_basis * anchor - expected_basis * anchor) * 1.5
_expect_basis_near(
resolver.call("resolve_basis", rotation, path),
expected_basis,
"tall waterfall basis",
failures
)
_expect_vector_near(
resolver.call("resolve_origin_offset", rotation, path, 1.5),
expected_offset,
"tall waterfall offset",
failures
)
func _verify_path_normalization_and_scale_clamp(failures: Array[String]) -> void:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var path := "WORLD\\X\\ELWYNNTALLWATERFALL01.MDX"
var minimum_offset: Vector3 = resolver.call("resolve_origin_offset", Vector3.ZERO, path, 0.0001)
_expect_vector_near(
resolver.call("resolve_origin_offset", Vector3.ZERO, path, -9.0),
minimum_offset,
"negative scale clamp",
failures
)
_expect_basis_near(
resolver.call("resolve_basis", Vector3.ZERO, "ElwynnCliffRock02.m2"),
Basis(Vector3.UP, PI),
"second cliffrock",
failures
)
func _verify_loader_boundary(failures: Array[String]) -> void:
var source := _read_text(LOADER_PATH, failures)
_expect_true(
source.contains("M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()"),
"loader composes resolver",
failures
)
_expect_equal(
source.count("_m2_placement_transform_resolver.resolve_basis("),
3,
"three basis adapters",
failures
)
_expect_equal(
source.count("_m2_placement_transform_resolver.resolve_origin_offset("),
3,
"three offset adapters",
failures
)
_expect_true(
not source.contains("func _adt_m2_placement_basis("),
"loader omits basis formula",
failures
)
_expect_true(
not source.contains("func _waterfall_sheet_twist("),
"loader omits waterfall formula",
failures
)
func _verify_bounded_timing(failures: Array[String]) -> float:
var resolver: RefCounted = RESOLVER_SCRIPT.new()
var rotation := Vector3(0.1, 0.2, 0.3)
var started_microseconds := Time.get_ticks_usec()
for iteration in range(10000):
var path := (
"world/x/ElwynnTallWaterfall01.m2"
if iteration % 4 == 0
else "world/generic/Tree.m2"
)
resolver.call("resolve_basis", rotation, path)
resolver.call("resolve_origin_offset", rotation, path, 1.0)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
_expect_true(elapsed_milliseconds < 1000.0, "10000 transform pairs bounded", failures)
return elapsed_milliseconds
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_basis_near(
actual_basis: Basis,
expected_basis: Basis,
label: String,
failures: Array[String]
) -> void:
if not actual_basis.is_equal_approx(expected_basis):
failures.append("%s basis mismatch" % label)
func _expect_vector_near(
actual_value: Vector3,
expected_value: Vector3,
label: String,
failures: Array[String]
) -> void:
if not actual_value.is_equal_approx(expected_value):
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
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)
@@ -0,0 +1 @@
uid://b4a7gtg46jrdn
+13 -4
View File
@@ -27,12 +27,13 @@ Runtime и Editor используют facade; planner/scheduler тестиру
## Evidence
- Date: 2026-07-16
- Revision/worktree: master merges `7ece2ab`, `7e35de7`, `80cb084`, `d6e5b53`, `c69abd6`, `606770c`, `630a0c1`, `f36fabb`, `24aef13`, `2342430`;
- Revision/worktree: master merges `7ece2ab`, `7e35de7`, `80cb084`, `d6e5b53`, `c69abd6`, `606770c`, `630a0c1`, `f36fabb`, `24aef13`, `2342430`, `396be5e`;
packages `M03-RND-SCHEDULER-001`, `M03-RND-INTERNAL-ACCESS-GATE-001`,
`M03-RND-FACADE-GROUND-QUERY-001`, `M03-RND-FACADE-ENVIRONMENT-001`,
`M03-RND-FACADE-ENTITY-001`, `M03-RND-TERRAIN-CACHE-SERVICE-001`,
`M03-RND-TERRAIN-LOD-PLANNER-001`, `M03-RND-TERRAIN-CHUNK-QUEUE-001`,
`M03-RND-M2-UNIQUE-REGISTRY-001`
`M03-RND-M2-UNIQUE-REGISTRY-001`,
`M03-RND-M2-PLACEMENT-TRANSFORM-001`
- Commands: dedicated scheduler/planner/facade/focus/coordinate/manifest/shutdown
headless verifiers; Godot cold editor parse; coordination, documentation and diff gates.
- Results: scheduler `cases=6 iterations=20000 elapsed_ms=10.216`; planner
@@ -57,6 +58,9 @@ Runtime и Editor используют facade; planner/scheduler тестиру
M2 unique placement registry passed `cases=10 iterations=100 elapsed_ms=35.513`;
the historical `uid:11785` cross-tile smoke, internal-access `42`, facade and
`7/7` manifest gates remained green after merge.
M2 placement transform resolver passed `cases=9 iterations=10000
elapsed_ms=27.409`; post-merge M2 registry, internal-access `42`, facade,
`7/7` manifest, documentation and coordination gates remained green.
M02 terrain-query regression
remained green (13 pre-existing expired M00 claim warnings).
- Fidelity comparison: all 16 historical operation limits and drain sites were
@@ -77,6 +81,9 @@ Runtime и Editor используют facade; planner/scheduler тестиру
loader still drains removals first through the shared `chunk_geometry` budget.
M2 positive `MDDF.uniqueId` retains `uid:<decimal>` first-owner semantics,
within-call duplicate suppression, cross-tile skip/retry and unkeyed pass-through.
M2 regular Euler, Elwynn cliff-rock shell correction, waterfall yaw/local-axis
twist, tall-waterfall anchor compensation, path normalization and caller scale
clamps are unchanged across worker grouping, placeholders and direct instances.
No new build-12340 parity claim is made; asset-backed visual/p95/p99 comparison remains pending.
- Changed files: scene-free scheduler and verifier; streamer budget composition/
permit delegation; scheduler module spec; world renderer/module registry/RENDER notes;
@@ -87,7 +94,8 @@ Runtime и Editor используют facade; planner/scheduler тестиру
immutable terrain chunk LOD policy, pure planner and both loader plan adapters,
pure terrain chunk geometry queue planner and loader queue-adoption adapter,
stateful M2 unique placement registry, loader reserve/release/clear adapters and
migrated historical dedupe smoke,
migrated historical dedupe smoke, stateless M2 placement transform resolver and
three loader transform adapters,
expanded facade/internal-access/coordinate verifiers; work-package claims and this evidence.
- Remaining risks: queue storage length and worker concurrency/stale-result logic
remain streamer-owned; cancellation stops new permits but does not interrupt
@@ -99,6 +107,7 @@ Runtime и Editor используют facade; planner/scheduler тестиру
chunk queue planning adds a second tile-state pass whose asset-backed p95/p99
still needs acceptance, while queue drains and geometry finalization remain monolithic;
private Azeroth data was absent for the asset-backed ADT placement probe; M2
grouping/build/tasks/caches/finalization and boundary traversal p95/p99 remain pending;
asset-backed visual transform recheck, grouping/build/tasks/caches/finalization
and boundary traversal p95/p99 remain pending;
M03 still needs further terrain/M2/WMO/liquid service extraction
and dependency/performance acceptance.