From 47dec6c32cba0941d976f6101827c1f6d173aac8 Mon Sep 17 00:00:00 2001 From: sindoring Date: Mon, 13 Jul 2026 16:37:29 +0400 Subject: [PATCH] feat(M01): enforce coordinate conversion boundaries Work-Package: M01-FND-COORD-BOUNDARY-GATE-001 Agent: sindo-main-codex --- RENDER.md | 14 ++ docs/CODING_STANDARD.md | 19 +++ docs/adr/0001-canonical-world-coordinates.md | 27 +++- docs/modules/coordinate-mapping.md | 24 ++- src/domain/coordinates/coordinate_mapper.gd | 30 ++++ .../player/third_person_wow_controller.gd | 27 ++-- src/scenes/sky/wow_sky_controller.gd | 28 ++-- .../streaming/streaming_world_loader.gd | 61 ++++---- src/tools/probe_render_terrain_height.gd | 17 ++- ...verify_coordinate_conversion_boundaries.gd | 140 ++++++++++++++++++ ...fy_coordinate_conversion_boundaries.gd.uid | 1 + src/tools/verify_coordinate_mapper.gd | 12 ++ tools/run_render_baseline.ps1 | 1 + 13 files changed, 335 insertions(+), 66 deletions(-) create mode 100644 src/tools/verify_coordinate_conversion_boundaries.gd create mode 100644 src/tools/verify_coordinate_conversion_boundaries.gd.uid diff --git a/RENDER.md b/RENDER.md index a4e0cdc..88afe21 100644 --- a/RENDER.md +++ b/RENDER.md @@ -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. diff --git a/docs/CODING_STANDARD.md b/docs/CODING_STANDARD.md index 3070592..ee6e788 100644 --- a/docs/CODING_STANDARD.md +++ b/docs/CODING_STANDARD.md @@ -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 фактическому коду? diff --git a/docs/adr/0001-canonical-world-coordinates.md b/docs/adr/0001-canonical-world-coordinates.md index 077130e..79625c5 100644 --- a/docs/adr/0001-canonical-world-coordinates.md +++ b/docs/adr/0001-canonical-world-coordinates.md @@ -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 diff --git a/docs/modules/coordinate-mapping.md b/docs/modules/coordinate-mapping.md index e1ad379..b040e13 100644 --- a/docs/modules/coordinate-mapping.md +++ b/docs/modules/coordinate-mapping.md @@ -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 diff --git a/src/domain/coordinates/coordinate_mapper.gd b/src/domain/coordinates/coordinate_mapper.gd index dc558c4..6d2d414 100644 --- a/src/domain/coordinates/coordinate_mapper.gd +++ b/src/domain/coordinates/coordinate_mapper.gd @@ -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( diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd index a2fff6a..c6b63bb 100644 --- a/src/scenes/player/third_person_wow_controller.gd +++ b/src/scenes/player/third_person_wow_controller.gd @@ -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 diff --git a/src/scenes/sky/wow_sky_controller.gd b/src/scenes/sky/wow_sky_controller.gd index f21de8f..d844117 100644 --- a/src/scenes/sky/wow_sky_controller.gd +++ b/src/scenes/sky/wow_sky_controller.gd @@ -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): diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 2dbe1c6..53fdbd5 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -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: diff --git a/src/tools/probe_render_terrain_height.gd b/src/tools/probe_render_terrain_height.gd index 7b39599..d596a74 100644 --- a/src/tools/probe_render_terrain_height.gd +++ b/src/tools/probe_render_terrain_height.gd @@ -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] diff --git a/src/tools/verify_coordinate_conversion_boundaries.gd b/src/tools/verify_coordinate_conversion_boundaries.gd new file mode 100644 index 0000000..f40d083 --- /dev/null +++ b/src/tools/verify_coordinate_conversion_boundaries.gd @@ -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) diff --git a/src/tools/verify_coordinate_conversion_boundaries.gd.uid b/src/tools/verify_coordinate_conversion_boundaries.gd.uid new file mode 100644 index 0000000..ed2c423 --- /dev/null +++ b/src/tools/verify_coordinate_conversion_boundaries.gd.uid @@ -0,0 +1 @@ +uid://bnj844sd2n6ln diff --git a/src/tools/verify_coordinate_mapper.gd b/src/tools/verify_coordinate_mapper.gd index 8e372bb..75927a0 100644 --- a/src/tools/verify_coordinate_mapper.gd +++ b/src/tools/verify_coordinate_mapper.gd @@ -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, diff --git a/tools/run_render_baseline.ps1 b/tools/run_render_baseline.ps1 index 64b2f7e..957d6fb 100644 --- a/tools/run_render_baseline.ps1 +++ b/tools/run_render_baseline.ps1 @@ -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", ".",