feat(M01): enforce coordinate conversion boundaries

Work-Package: M01-FND-COORD-BOUNDARY-GATE-001

Agent: sindo-main-codex
This commit is contained in:
2026-07-13 16:37:29 +04:00
parent c8e62acf4c
commit 47dec6c32c
13 changed files with 335 additions and 66 deletions
+14
View File
@@ -843,6 +843,20 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- Streaming radii, LOD decisions, cache formats and renderer quality profiles
are unchanged by this migration.
## 2026-07-13 Coordinate Conversion Boundary Gate
- Sky/area lookup, streaming tile ownership, player spawn/terrain sampling and
terrain diagnostics now cross world-coordinate spaces through typed
`CoordinateMapper` methods.
- Renderer distance, LOD, ray and model-local M2/WMO calculations remain in
their existing Godot/local spaces; they are not axis conversions.
- The repository gate rejects new manual world-center, WoW/Godot and
Godot-position-to-ADT formulas. The unified renderer baseline runs this gate.
- Native ADT/WDT parsers keep one shared world conversion in
`wow_chunk_reader.h`; the independent M00 calibration formula remains a test oracle.
- Coordinate contract version 2, renderer radii, cache formats and visual
quality profiles are unchanged.
## Practical Rule For Future Work
If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it.
+19
View File
@@ -237,6 +237,23 @@ TODO/FIXME обязан содержать target/work-package ID или diagnos
- Медленный понятный reference implementation полезен как oracle для optimized implementation.
- Сложная оптимизация удаляется или упрощается, если она не даёт измеримого результата.
## Coordinate conversion boundary
- Cross-space world conversions выполняются только через typed API
`CoordinateMapper`; ручные world-center offsets, swaps осей и вычисление ADT
filename indices из Godot position запрещены.
- Scene/render adapter МОЖЕТ создать `Vector3` только после получения
`GodotWorldPosition` или перевести engine vector в typed scalar value на своей границе.
- Единственная native world-position формула находится в
`src/native/src/wow_chunk_reader.h`, поскольку parser workers не вызывают
GDScript. Новая native копия требует отдельного boundary decision и parity fixture.
- `verify_render_coordinate_calibration.gd` остаётся независимым test oracle;
production code не копирует его формулы.
- Model-local M2/WMO vertex basis и placement orientation не являются world
coordinate conversion. Они документируются и тестируются своим format adapter.
- `verify_coordinate_conversion_boundaries.gd` обязателен при изменении
coordinates, native parsers, sky, player, streaming или renderer diagnostics.
## Review checklist
- Можно ли понять назначение сущности по имени?
@@ -245,6 +262,8 @@ TODO/FIXME обязан содержать target/work-package ID или diagnos
- Можно ли убрать abstraction без потери correctness/boundary?
- Есть ли скрытый mutable state или side effect?
- Не появился ли второй источник истины?
- Все ли cross-space world coordinates проходят через `CoordinateMapper` или
документированную native boundary?
- Измерена ли сложная optimization?
- Изолирована и документирована ли неизбежная сложность?
- Соответствуют ли API docs и diagrams фактическому коду?
+23 -4
View File
@@ -108,6 +108,21 @@ show `indexX` decreasing world Y (east) and `indexY` decreasing world X (south).
OpenRealm independently calculates filename X from world Y and filename Y from
world X. Contract version 2 corrects the formulas before any consumer migration.
### Enforced conversion boundaries
GDScript production consumers use `CoordinateMapper` for canonical/Godot and
Godot/ADT tile-local conversion. `StreamingWorldLoader`, player spawn/terrain
sampling, sky/area lookup and terrain diagnostics may construct `Vector3` only
at their scene/tool boundary.
The native ADT/WDT parser path retains one equivalent formula definition in
`src/native/src/wow_chunk_reader.h`; worker parsers cannot call GDScript. The
M00 renderer calibration keeps one independent formula as a test oracle so a
shared mapper defect cannot make both implementation and evidence agree.
Repository verification rejects additional world-center, world/tile or named
WoW/Godot formulas outside those explicit exceptions. Model-local M2/WMO basis
transforms are separate format contracts and are not classified as world-space mapping.
## Alternatives considered
### Make renderer coordinates canonical
@@ -138,10 +153,12 @@ grid ownership rules.
- Renderer consumers must explicitly map and then create Godot vectors at their
own boundary.
- ADT filename indices can no longer be confused with renderer X/Z tile names.
- Existing native/parser and renderer conversions remain unchanged until
separate consumer migration packages merge after this contract.
- Manual axis conversions become review/test violations once migration is
complete; this ADR alone does not delete compatibility paths.
- GDScript world/tile consumers use mapper APIs; renderer distance and LOD math
remains ordinary Godot-space geometry.
- Native ADT/WDT loaders share one boundary formula and the M00 calibration
keeps one independent non-production oracle.
- New manual axis conversions are review/test violations enforced by the
repository-wide source gate.
- Persisted schemas are unchanged. Future serialized positions must include an
explicit coordinate-space/version discriminator.
@@ -154,6 +171,8 @@ grid ownership rules.
- The observed Elwynn position resolves to the real `Azeroth_31_49.adt` file;
MCNK `(0,0)` to `(15,15)` origins confirm X-east and Y-south chunk growth.
- World-yaw normalization and round-trip cases cover cardinal angles and wrap.
- Direct Godot/ADT tile-local conversions round-trip and production consumers
are checked by `verify_coordinate_conversion_boundaries.gd`.
## References
+17 -7
View File
@@ -5,9 +5,9 @@
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001` |
| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001`, `M01-FND-COORD-BOUNDARY-GATE-001` |
| Owners | Foundation/domain |
| Last verified | Worktree `work/sindo-main-codex/m01-coordinate-fixtures`, 2026-07-13 |
| Last verified | Worktree `work/sindo-main-codex/m01-coordinate-boundary-gate`, 2026-07-13 |
| Profiles/capabilities | All profiles; contract version 2 |
## Purpose
@@ -69,6 +69,9 @@ Forbidden dependencies:
| `GodotWorldYaw` | Immutable value | Godot Y-axis world yaw in radians | Any thread; caller-owned reference | Constructor does not normalize |
| `CoordinateMapper.server_to_canonical` / inverse | Pure static methods | Preserve server X/Y/Z as canonical | Synchronous; new result value | Null input is a caller contract violation |
| `CoordinateMapper.canonical_to_godot` / inverse | Pure static methods | Apply Y-up renderer basis and map offset | Synchronous; new result value | Non-finite scalars propagate |
| `CoordinateMapper.godot_to_adt_tile` | Pure static method | Resolve filename indices from typed renderer position | Synchronous; new result value | Result remains unclamped |
| `CoordinateMapper.godot_to_adt_tile_local` | Pure static method | Resolve east/height/south offsets from typed renderer position | Synchronous; new result value | Caller validates ownership/range |
| `CoordinateMapper.adt_tile_local_to_godot` | Pure static method | Compose typed renderer position from tile/local values | Synchronous; new result value | Non-finite scalars propagate |
| `CoordinateMapper.canonical_to_adt_placement` / inverse | Pure static methods | Convert ADT placement field basis | Synchronous; new result value | Non-finite scalars propagate |
| `CoordinateMapper.canonical_to_adt_tile` | Pure static method | Compute unclamped filename indices | Synchronous; new result value | Out-of-map result remains invalid |
| `CoordinateMapper.canonical_to_adt_tile_local` / inverse | Pure static methods | Split/reconstruct tile-local position | Synchronous; new result value | Caller validates tile ownership |
@@ -106,6 +109,9 @@ flowchart LR
Canonical -->|north-west edge offsets| Local[AdtTileLocalPosition]
Local -->|floor offset / chunk size| Chunk[AdtChunkCoordinate]
Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition]
Godot -->|typed direct adapter| Tile
Godot -->|typed direct adapter| Local
Tile -->|tile plus local| Godot
ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw]
```
@@ -172,7 +178,9 @@ raw three-number arrays are not an accepted new persistence contract.
- Unit/contract tests: `src/tools/verify_coordinate_mapper.gd`.
- Cross-source golden test: `src/tools/verify_coordinate_golden_fixtures.gd`.
- Integration/E2E: consumer migration and `StreamingFocus` packages remain pending.
- Boundary enforcement: `src/tools/verify_coordinate_conversion_boundaries.gd`.
- Integration/E2E: sky, player, streamer and terrain probe use mapper boundaries;
`StreamingFocus` provides typed renderer focus.
- Fidelity evidence: five build-12340 camera positions from the M00 manifest map
within `0.002` yard; a pinned AzerothCore human-warrior spawn and one raw MCNK
header observation independently confirm tile `(32,48)` and chunk `(3,12)`;
@@ -192,18 +200,19 @@ raw three-number arrays are not an accepted new persistence contract.
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Canonical/server/Godot position mapping | Implemented | Headless round-trip and five M00 golden points | Migrate producers/consumers |
| Canonical/server/Godot position mapping | Implemented | Headless round-trip, five M00 golden points and migrated GDScript consumers | Network/server adapters remain future consumers |
| ADT placement mapping | Implemented | Synthetic/direct round-trip fixtures | Compare native parsed placements during migration |
| ADT tile/local/chunk ownership | Implemented | Center/extent boundaries plus raw `Azeroth_31_49.adt` filename and MCNK direction evidence | Migrate streaming consumers |
| ADT tile/local/chunk ownership | Implemented | Center/extent boundaries, raw `Azeroth_31_49.adt` evidence and direct Godot adapter round-trip | Native parser remains explicit boundary |
| World-facing yaw mapping | Implemented | Cardinal/wrap round-trip fixtures | Placement Euler remains separate |
| Scene-free production contract | Implemented | Source boundary and headless test | Add dependency search gate later in M01 |
| Scene-free production contract | Implemented | Source boundary, headless tests and repository-wide conversion gate | Maintain gate with new consumers |
| Cross-source coordinate fixture | Implemented | Pinned AzerothCore spawn, raw ADT MCNK metadata and five build-12340 points pass one headless verifier | Add live adapter/renderer integration |
| TrinityCore populated spawn equivalence | Not verified | The base development schema has no pinned populated row used by this fixture | Pin and verify a TDB snapshot before claiming parity |
| Visual/server-spawn integration | Planned | M01 target criteria | Route the fixture through migrated adapters and renderer |
## Known gaps and risks
- Existing native loaders and renderer scripts still contain manual conversions.
- Native ADT/WDT parsing retains one shared boundary formula because worker
parsers cannot call GDScript; the gate prevents additional definitions.
- Contract version 1 briefly transposed ADT filename/chunk axes; version 2 fixes
the defect before any consumer migration.
- MDDF/MODF rotation comments and implementations disagree across existing paths;
@@ -225,6 +234,7 @@ raw three-number arrays are not an accepted new persistence contract.
| `src/tools/verify_coordinate_mapper.gd` | Headless fixtures and boundary regressions |
| `src/tests/fixtures/coordinate_golden_points.json` | Versioned cross-source values, provenance and tolerances |
| `src/tools/verify_coordinate_golden_fixtures.gd` | Cross-source fixture schema and mapper validation |
| `src/tools/verify_coordinate_conversion_boundaries.gd` | Repository-wide manual conversion and required-consumer gate |
| `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision |
## Related decisions and references
@@ -48,6 +48,36 @@ static func godot_to_canonical(godot_position: GodotWorldPosition) -> CanonicalW
)
## Returns ADT filename indices directly from a Godot renderer position.
static func godot_to_adt_tile(godot_position: GodotWorldPosition) -> AdtTileCoordinate:
return AdtTileCoordinate.new(
int(floor(godot_position.x_units / ADT_TILE_SIZE_YARDS)),
int(floor(godot_position.z_units / ADT_TILE_SIZE_YARDS))
)
## Returns east/height/south offsets inside the owning ADT tile.
static func godot_to_adt_tile_local(godot_position: GodotWorldPosition) -> AdtTileLocalPosition:
var tile_coordinate := godot_to_adt_tile(godot_position)
return AdtTileLocalPosition.new(
godot_position.x_units - float(tile_coordinate.tile_x) * ADT_TILE_SIZE_YARDS,
godot_position.y_units,
godot_position.z_units - float(tile_coordinate.tile_y) * ADT_TILE_SIZE_YARDS
)
## Converts an ADT tile/local pair directly to the Godot renderer basis.
static func adt_tile_local_to_godot(
tile_coordinate: AdtTileCoordinate,
local_position: AdtTileLocalPosition
) -> GodotWorldPosition:
return GodotWorldPosition.new(
float(tile_coordinate.tile_x) * ADT_TILE_SIZE_YARDS + local_position.east_yards,
local_position.height_yards,
float(tile_coordinate.tile_y) * ADT_TILE_SIZE_YARDS + local_position.south_yards
)
## Converts canonical WoW coordinates to MDDF/MODF X/height/Z file fields.
static func canonical_to_adt_placement(canonical_position: CanonicalWowWorldPosition) -> AdtPlacementPosition:
return AdtPlacementPosition.new(
@@ -3,9 +3,12 @@ extends CharacterBody3D
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const TILE_SIZE := 533.33333
const CHUNK_SIZE := TILE_SIZE / 16.0
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const UNIT_SIZE := CHUNK_SIZE / 8.0
@export var extracted_dir: String = "res://data/extracted"
@@ -62,8 +65,11 @@ func _ready() -> void:
_camera.current = true
_camera.far = 50000.0
if spawn_at_tile_center:
global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE
global_position.z = (float(spawn_tile_y) + 0.5) * TILE_SIZE
var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y)
var half_tile_size := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS * 0.5
var spawn_local = ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, global_position.y, half_tile_size)
var spawn_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(spawn_tile, spawn_local)
global_position = Vector3(spawn_position.x_units, spawn_position.y_units, spawn_position.z_units)
_load_character_visual()
var ground := _sample_ground_height(global_position)
if is_finite(ground):
@@ -324,16 +330,17 @@ func _apply_camera_transform() -> void:
func _sample_ground_height(world_pos: Vector3) -> float:
var tx := int(floor(world_pos.x / TILE_SIZE))
var ty := int(floor(world_pos.z / TILE_SIZE))
var data := _load_adt(tx, ty)
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
var data := _load_adt(tile_coordinate.tile_x, tile_coordinate.tile_y)
if data.is_empty():
return NAN
var tile_origin := _get_tile_origin(data)
var local := world_pos - tile_origin
var chunk_x := clampi(int(floor(local.x / CHUNK_SIZE)), 0, 15)
var chunk_y := clampi(int(floor(local.z / CHUNK_SIZE)), 0, 15)
var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin)
if chunk.is_empty():
return NAN
+15 -13
View File
@@ -2,9 +2,9 @@
extends Node
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const TILE_SIZE := 533.33333
const WOW_WORLD_CENTER := 17066.666
const LIGHT_COORD_SCALE := 36.0
const HALF_MINUTES_PER_DAY := 2880
@@ -701,28 +701,30 @@ func _get_target_wow_position() -> Vector3:
var world_pos := Vector3.ZERO
if _target:
world_pos = _target.global_position
return Vector3(
WOW_WORLD_CENTER - world_pos.z,
WOW_WORLD_CENTER - world_pos.x,
world_pos.y)
var canonical_position = COORDINATE_MAPPER_SCRIPT.godot_to_canonical(_typed_godot_position(world_pos))
return Vector3(canonical_position.x_yards, canonical_position.y_yards, canonical_position.z_yards)
func _get_target_area_id() -> int:
if not _target or map_name.is_empty():
return 0
var world_pos := _target.global_position
var tile_x := int(floor(world_pos.x / TILE_SIZE))
var tile_y := int(floor(world_pos.z / TILE_SIZE))
var local_x := world_pos.x - float(tile_x) * TILE_SIZE
var local_y := world_pos.z - float(tile_y) * TILE_SIZE
var chunk_x := clampi(int(floor(local_x / (TILE_SIZE / 16.0))), 0, 15)
var chunk_y := clampi(int(floor(local_y / (TILE_SIZE / 16.0))), 0, 15)
var areas := _load_adt_area_grid(tile_x, tile_y)
var typed_world_position = _typed_godot_position(world_pos)
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
var local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, local_position)
var areas := _load_adt_area_grid(tile_coordinate.tile_x, tile_coordinate.tile_y)
if areas.is_empty():
return 0
var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
return int(areas[chunk_y * 16 + chunk_x])
func _typed_godot_position(world_position: Vector3):
return GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z)
func _load_adt_area_grid(tile_x: int, tile_y: int) -> PackedInt32Array:
var key := "%d_%d" % [tile_x, tile_y]
if _adt_area_cache.has(key):
+36 -25
View File
@@ -14,14 +14,17 @@ const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/l
const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd")
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
const M2_MATERIAL_REFRESH_VERSION := 2
const WMO_MATERIAL_REFRESH_VERSION := 10
const TILE_SIZE := 533.33333
const CHUNK_SIZE := TILE_SIZE / 16.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"
@@ -786,10 +789,9 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar
var parts: PackedStringArray = key.split("_")
var tx: int = int(parts[0])
var ty: int = int(parts[1])
var tcx: float = (tx + 0.5) * TILE_SIZE
var tcz: float = (ty + 0.5) * TILE_SIZE
var dx: float = tcx - focus_pos.x
var dz: float = tcz - focus_pos.z
var tile_center := _tile_center_to_world(tx, ty)
var dx: float = tile_center.x - focus_pos.x
var dz: float = tile_center.z - focus_pos.z
_tile_load_queue.append({
"key": key,
"tx": tx,
@@ -2909,8 +2911,10 @@ func _predictive_focus_tiles(focus_pos: Vector3) -> Array[Vector2i]:
if threshold <= 0.0:
return result
var local_x := fposmod(focus_pos.x, TILE_SIZE) / TILE_SIZE
var local_z := fposmod(focus_pos.z, TILE_SIZE) / TILE_SIZE
var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_focus_position)
var local_x: float = tile_local_position.east_yards / TILE_SIZE
var local_z: float = tile_local_position.south_yards / TILE_SIZE
var x_offsets: Array[int] = [0]
var z_offsets: Array[int] = [0]
@@ -3033,10 +3037,17 @@ func _is_tile_chunk_set_ready(state: Dictionary) -> bool:
func _tile_dist_sq(state: Dictionary, cam_pos: Vector3) -> float:
var min_x: float = float(state["tx"]) * TILE_SIZE
var max_x: float = min_x + TILE_SIZE
var min_z: float = float(state["ty"]) * TILE_SIZE
var max_z: float = min_z + TILE_SIZE
var tile_coordinate = ADT_TILE_COORDINATE_SCRIPT.new(int(state["tx"]), int(state["ty"]))
var north_west_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(
tile_coordinate,
ADT_TILE_LOCAL_POSITION_SCRIPT.new(0.0, 0.0, 0.0))
var south_east_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(
tile_coordinate,
ADT_TILE_LOCAL_POSITION_SCRIPT.new(TILE_SIZE, 0.0, TILE_SIZE))
var min_x: float = north_west_position.x_units
var max_x: float = south_east_position.x_units
var min_z: float = north_west_position.z_units
var max_z: float = south_east_position.z_units
var dx := 0.0
if cam_pos.x < min_x:
@@ -5397,9 +5408,9 @@ func _position_camera_over_world() -> void:
if camera == null:
return
var center_tx: float = (_tile_min.x + _tile_max.x) * 0.5
var center_ty: float = (_tile_min.y + _tile_max.y) * 0.5
var center := _tile_center_to_world(center_tx, center_ty)
var minimum_tile_center := _tile_center_to_world(_tile_min.x, _tile_min.y)
var maximum_tile_center := _tile_center_to_world(_tile_max.x, _tile_max.y)
var center := (minimum_tile_center + maximum_tile_center) * 0.5
var tile_span: int = int(max(_tile_max.x - _tile_min.x + 1, _tile_max.y - _tile_min.y + 1))
var height: float = max(2000.0, float(tile_span) * TILE_SIZE * 0.18)
@@ -5408,19 +5419,19 @@ func _position_camera_over_world() -> void:
_camera_initialized = true
## Tile (tx, ty) NW corner sits at Godot (tx*TILE_SIZE, 0, ty*TILE_SIZE).
## This matches wow_to_godot output: chunk origins ≈ (TX*TILE_SIZE, h, TY*TILE_SIZE).
func _tile_center_to_world(tile_x: float, tile_y: float) -> Vector3:
return Vector3(
(tile_x + 0.5) * TILE_SIZE,
0.0,
(tile_y + 0.5) * TILE_SIZE)
func _tile_center_to_world(tile_x: int, tile_y: int) -> Vector3:
var tile_coordinate = ADT_TILE_COORDINATE_SCRIPT.new(tile_x, tile_y)
var half_tile_size := TILE_SIZE * 0.5
var tile_center_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(
tile_coordinate,
ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, 0.0, half_tile_size))
return Vector3(tile_center_position.x_units, tile_center_position.y_units, tile_center_position.z_units)
func _world_to_tile(world_pos: Vector3) -> Vector2i:
return Vector2i(
int(floor(world_pos.x / TILE_SIZE)),
int(floor(world_pos.z / TILE_SIZE)))
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
return Vector2i(tile_coordinate.tile_x, tile_coordinate.tile_y)
func _tile_key(tx: int, ty: int) -> String:
+10 -7
View File
@@ -5,7 +5,8 @@ extends SceneTree
## [--wait 3.0] [--output user://render_terrain_height/report.json]
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
const TILE_SIZE := 533.33333
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const RAY_HEIGHT := 5000.0
@@ -90,9 +91,7 @@ func _run_async() -> void:
func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary:
var tile_coordinate := Vector2i(
int(floor(world_position.x / TILE_SIZE)),
int(floor(world_position.z / TILE_SIZE)))
var tile_coordinate := _adt_tile_vector2i(world_position)
var tile_states: Dictionary = world.get("_tile_states")
var highest_terrain_height := -INF
var intersected_tile_key := ""
@@ -198,9 +197,7 @@ func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictiona
for radius in [2.0, 5.0, 10.0, 20.0, 40.0]:
for offset in [Vector2(radius, 0.0), Vector2(-radius, 0.0), Vector2(0.0, radius), Vector2(0.0, -radius)]:
var sample_position := world_position + Vector3(offset.x, 0.0, offset.y)
var sample_tile := Vector2i(
int(floor(sample_position.x / TILE_SIZE)),
int(floor(sample_position.z / TILE_SIZE)))
var sample_tile := _adt_tile_vector2i(sample_position)
var sample_key := "%d_%d" % [sample_tile.x, sample_tile.y]
if not tile_states.has(sample_key):
continue
@@ -214,6 +211,12 @@ func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictiona
return {"nearest_sample_distance": null}
func _adt_tile_vector2i(world_position: Vector3) -> Vector2i:
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z)
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
return Vector2i(tile_coordinate.tile_x, tile_coordinate.tile_y)
func _vector3_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z]
@@ -0,0 +1,140 @@
extends SceneTree
## Repository-wide M01 gate against manual cross-space world coordinate formulas.
## Model-local M2/WMO basis transforms and ordinary Godot-space geometry are not
## world-space conversions and are intentionally outside these signatures.
const SCAN_ROOTS: Array[String] = ["res://src", "res://addons"]
const SOURCE_EXTENSIONS: Array[String] = ["gd", "cpp", "h", "hpp"]
const EXCLUDED_DIRECTORY_PREFIXES: Array[String] = ["res://src/native/build/"]
const GATE_PATH := "res://src/tools/verify_coordinate_conversion_boundaries.gd"
const COORDINATE_MAPPER_PATH := "res://src/domain/coordinates/coordinate_mapper.gd"
const CALIBRATION_ORACLE_PATH := "res://src/tools/verify_render_coordinate_calibration.gd"
const NATIVE_BOUNDARY_PATH := "res://src/native/src/wow_chunk_reader.h"
const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
NATIVE_BOUNDARY_PATH,
"res://src/native/src/adt_loader.cpp",
"res://src/native/src/wdt_loader.cpp",
CALIBRATION_ORACLE_PATH,
]
const REQUIRED_MAPPER_CALLS := {
"res://src/scenes/sky/wow_sky_controller.gd": ["godot_to_canonical", "godot_to_adt_tile", "godot_to_adt_tile_local"],
"res://src/scenes/streaming/streaming_world_loader.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_godot"],
"res://src/scenes/player/third_person_wow_controller.gd": ["godot_to_adt_tile", "adt_tile_local_to_godot"],
"res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"],
}
func _initialize() -> void:
var failures: Array[String] = []
var source_paths: Array[String] = []
for scan_root in SCAN_ROOTS:
_collect_source_paths(scan_root, source_paths, failures)
var native_boundary_definition_count := 0
var independent_oracle_definition_count := 0
for source_path in source_paths:
if source_path == GATE_PATH:
continue
var source := _read_text(source_path, failures)
var source_lines := source.split("\n")
for line_index in range(source_lines.size()):
var source_line := String(source_lines[line_index])
var violation := _coordinate_violation(source_path, source_line)
if not violation.is_empty():
failures.append("%s:%d %s" % [source_path, line_index + 1, violation])
if source_path == NATIVE_BOUNDARY_PATH and source_line.contains("inline void wow_to_godot"):
native_boundary_definition_count += 1
if source_path == CALIBRATION_ORACLE_PATH and source_line.contains("func _wow_to_godot"):
independent_oracle_definition_count += 1
_expect_equal(native_boundary_definition_count, 1, "native world conversion definition count", failures)
_expect_equal(independent_oracle_definition_count, 1, "independent calibration oracle count", failures)
_verify_required_mapper_calls(failures)
_verify_classifier_guards(failures)
if not failures.is_empty():
for failure in failures:
push_error("COORDINATE_CONVERSION_BOUNDARIES: %s" % failure)
quit(1)
return
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=4 classifier_cases=6" % source_paths.size())
quit(0)
func _coordinate_violation(source_path: String, source_line: String) -> String:
var stripped_line := source_line.strip_edges()
if stripped_line.is_empty():
return ""
if (source_line.contains("wow_to_godot") or source_line.contains("godot_to_wow")) and source_path not in ALLOWED_LEGACY_NAME_PATHS:
return "manual WoW/Godot conversion name outside mapper boundary"
if (source_line.contains("17066.666") or source_line.contains("WOW_WORLD_CENTER")) and source_path not in [NATIVE_BOUNDARY_PATH, CALIBRATION_ORACLE_PATH]:
return "manual WoW world-center formula outside native boundary/oracle"
if source_line.contains("1600.0 / 3.0") and source_path != COORDINATE_MAPPER_PATH:
return "duplicated ADT tile-size formula outside CoordinateMapper"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("floor(") and source_line.contains("TILE_SIZE") and (source_line.contains(".x") or source_line.contains(".z")):
return "manual Godot world-position to ADT tile formula"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("fposmod(") and source_line.contains("TILE_SIZE"):
return "manual Godot world-position to ADT tile-local formula"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("+ 0.5) * TILE_SIZE"):
return "manual ADT tile-center to Godot world-position formula"
return ""
func _verify_required_mapper_calls(failures: Array[String]) -> void:
for source_path in REQUIRED_MAPPER_CALLS:
var source := _read_text(source_path, failures)
for required_call in REQUIRED_MAPPER_CALLS[source_path]:
if not source.contains(String(required_call)):
failures.append("%s must call CoordinateMapper.%s" % [source_path, required_call])
func _verify_classifier_guards(failures: Array[String]) -> void:
_expect_true(not _coordinate_violation("res://src/example.gd", "const CENTER := 17066.666").is_empty(), "world-center classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "func wow_to_godot():").is_empty(), "conversion-name classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "floor(world_position.x / TILE_SIZE)").is_empty(), "world-to-tile classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "fposmod(world_position.z, TILE_SIZE)").is_empty(), "world-to-tile-local classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "(tile_x + 0.5) * TILE_SIZE").is_empty(), "tile-center classifier", failures)
_expect_true(_coordinate_violation(NATIVE_BOUNDARY_PATH, "inline void wow_to_godot(float wx) { gx = -(wx - 17066.666f); }").is_empty(), "native boundary exception", failures)
func _collect_source_paths(directory_path: String, source_paths: Array[String], failures: Array[String]) -> void:
var directory := DirAccess.open(directory_path)
if directory == null:
failures.append("cannot scan directory %s" % directory_path)
return
for entry_name in directory.get_files():
var source_path := directory_path.path_join(entry_name)
if entry_name.get_extension().to_lower() in SOURCE_EXTENSIONS:
source_paths.append(source_path)
for child_directory_name in directory.get_directories():
var child_directory_path := directory_path.path_join(child_directory_name) + "/"
if _has_excluded_prefix(child_directory_path):
continue
_collect_source_paths(child_directory_path.trim_suffix("/"), source_paths, failures)
func _has_excluded_prefix(path: String) -> bool:
for excluded_prefix in EXCLUDED_DIRECTORY_PREFIXES:
if path.begins_with(excluded_prefix):
return true
return false
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_equal(actual_value, expected_value, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://bnj844sd2n6ln
+12
View File
@@ -119,6 +119,18 @@ func _verify_tile_and_chunk_boundaries(failures: Array[String]) -> void:
_expect_true(local_position.east_yards >= 0.0 and local_position.east_yards < tile_size, "tile-local east offset is half-open", failures)
_expect_true(local_position.south_yards >= 0.0 and local_position.south_yards < tile_size, "tile-local south offset is half-open", failures)
var godot_position = CoordinateMapperScript.canonical_to_godot(CanonicalWowWorldPositionScript.new(-9153.334, 386.666, 180.0))
var direct_godot_tile = CoordinateMapperScript.godot_to_adt_tile(godot_position)
var direct_godot_local = CoordinateMapperScript.godot_to_adt_tile_local(godot_position)
var direct_godot_round_trip = CoordinateMapperScript.adt_tile_local_to_godot(direct_godot_tile, direct_godot_local)
_expect_equal(direct_godot_tile.tile_x, 31, "direct Godot ADT tile X", failures)
_expect_equal(direct_godot_tile.tile_y, 49, "direct Godot ADT tile Y", failures)
_expect_near(direct_godot_local.east_yards, 146.66677, GOLDEN_POSITION_TOLERANCE_YARDS, "direct Godot tile-local east", failures)
_expect_near(direct_godot_local.south_yards, 86.66677, GOLDEN_POSITION_TOLERANCE_YARDS, "direct Godot tile-local south", failures)
_expect_near(direct_godot_round_trip.x_units, godot_position.x_units, ROUND_TRIP_TOLERANCE_YARDS, "direct Godot tile-local X round trip", failures)
_expect_near(direct_godot_round_trip.y_units, godot_position.y_units, ROUND_TRIP_TOLERANCE_YARDS, "direct Godot tile-local Y round trip", failures)
_expect_near(direct_godot_round_trip.z_units, godot_position.z_units, ROUND_TRIP_TOLERANCE_YARDS, "direct Godot tile-local Z round trip", failures)
var chunk_local_position = AdtTileLocalPositionScript.new(
CoordinateMapperScript.ADT_CHUNK_SIZE_YARDS * 2.0,
180.0,
+1
View File
@@ -57,6 +57,7 @@ try {
Invoke-GodotStep "runtime-cache-shutdown" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_runtime_cache_shutdown.gd")
Invoke-GodotStep "baseline-manifest" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_baseline_manifest.gd")
Invoke-GodotStep "coordinate-calibration" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_coordinate_calibration.gd")
Invoke-GodotStep "coordinate-boundaries" @("--headless", "--path", ".", "--script", "res://src/tools/verify_coordinate_conversion_boundaries.gd")
$captureArgs = @(
"--path", ".",