feat(M01): enforce coordinate conversion boundaries
Work-Package: M01-FND-COORD-BOUNDARY-GATE-001 Agent: sindo-main-codex
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 фактическому коду?
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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", ".",
|
||||
|
||||
Reference in New Issue
Block a user