diff --git a/docs/adr/0001-canonical-world-coordinates.md b/docs/adr/0001-canonical-world-coordinates.md index 1fbafaf..077130e 100644 --- a/docs/adr/0001-canonical-world-coordinates.md +++ b/docs/adr/0001-canonical-world-coordinates.md @@ -2,7 +2,7 @@ - Status: Accepted for M01 contract implementation - Date: 2026-07-13 -- Work package: `M01-FND-COORDS-001` +- Work packages: `M01-FND-COORDS-001`, corrected by `M01-FND-COORD-TILE-AXIS-002` - Decision owners: M01 foundation/integration owners ## Context @@ -45,8 +45,8 @@ ADT_TILE_SIZE_YARDS = 1600 / 3 ADT_TILES_PER_MAP_AXIS = 64 WOW_WORLD_HALF_EXTENT_YARDS = 32 * ADT_TILE_SIZE_YARDS -tileX = floor(32 - canonical.X / ADT_TILE_SIZE_YARDS) -tileY = floor(32 - canonical.Y / ADT_TILE_SIZE_YARDS) +tileX = floor(32 - canonical.Y / ADT_TILE_SIZE_YARDS) +tileY = floor(32 - canonical.X / ADT_TILE_SIZE_YARDS) ``` `AdtTileCoordinate(tile_x, tile_y)` always means the indices in @@ -55,9 +55,9 @@ south-east extent produces index 64 and remains diagnosable as out of bounds. Normal valid indices are 0 through 63. An `AdtTileLocalPosition` is measured east and south from the tile's -north-west corner. An `AdtChunkCoordinate` uses `chunk_x` south and `chunk_y` -east, with valid indices 0 through 15. These direction names avoid pretending -that ADT file axes are Godot X/Z axes. +north-west corner. Filename `tile_x` and MCNK `chunk_x` grow east; filename +`tile_y` and MCNK `chunk_y` grow south, with valid indices 0 through 15. These +direction names avoid pretending that ADT file axes are Godot X/Z axes. ### ADT placement fields @@ -97,6 +97,17 @@ normalize the value to `[-PI, PI)` without adding an offset. This identity applies to world-facing yaw only. It does not apply to M2/WMO asset axes or placement Euler triples. +### Contract version 2 correction evidence + +Contract version 1 incorrectly calculated filename `tile_x` from canonical X +and `tile_y` from canonical Y. The private WotLK corpus contains the observed +Elwynn point in `Azeroth_31_49.adt`, SHA-256 +`18185B5FAC9566D9C57A9C19ABD75F108370D1795639B6077167C0EFD3672FAE`; the +transposed `Azeroth_49_31.adt` does not exist. Raw MCNK headers in that file +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. + ## Alternatives considered ### Make renderer coordinates canonical @@ -140,6 +151,8 @@ grid ownership rules. checkpoints within `0.002` yard. - Server, ADT placement, Godot and tile-local conversions round-trip. - Exact center, tile, chunk and world-extent boundaries have explicit tests. +- 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. ## References diff --git a/docs/modules/coordinate-mapping.md b/docs/modules/coordinate-mapping.md index a4a17a1..3e69a70 100644 --- a/docs/modules/coordinate-mapping.md +++ b/docs/modules/coordinate-mapping.md @@ -5,10 +5,10 @@ | Field | Value | |---|---| | Status | Implemented | -| Target/work package | `M01-FND-COORDS-001` | +| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002` | | Owners | Foundation/domain | -| Last verified | Worktree `work/sindo-main-codex/m01-coordinate-mapper`, 2026-07-13 | -| Profiles/capabilities | All profiles; contract version 1 | +| Last verified | Worktree `work/sindo-main-codex/m01-coordinate-tile-axis-fix`, 2026-07-13 | +| Profiles/capabilities | All profiles; contract version 2 | ## Purpose @@ -63,7 +63,7 @@ Forbidden dependencies: | `GodotWorldPosition` | Immutable value | Godot east/up/south scalars | Any thread; caller-owned reference | Does not create `Vector3` | | `AdtTileCoordinate` | Immutable value | `Map_tileX_tileY.adt` indices | Any thread; caller-owned reference | `is_within_map_grid()` reports invalid indices | | `AdtTileLocalPosition` | Immutable value | East/height/south offsets from tile north-west corner | Any thread; caller-owned reference | Values are intentionally unclamped | -| `AdtChunkCoordinate` | Immutable value | MCNK chunk indices inside one tile | Any thread; caller-owned reference | `is_within_map_grid()` reports invalid indices | +| `AdtChunkCoordinate` | Immutable value | MCNK X-east/Y-south indices inside one tile | Any thread; caller-owned reference | `is_within_map_grid()` reports invalid indices | | `CanonicalWowWorldYaw` | Immutable value | Canonical world yaw in radians | Any thread; caller-owned reference | Constructor does not normalize | | `ServerWorldYaw` | Immutable value | Movement orientation in radians | Any thread; caller-owned reference | Constructor does not normalize | | `GodotWorldYaw` | Immutable value | Godot Y-axis world yaw in radians | Any thread; caller-owned reference | Constructor does not normalize | @@ -97,7 +97,7 @@ Side effects: flowchart LR Server[ServerWorldPosition X,Y,Z] -->|identity components| Canonical[CanonicalWowWorldPosition] ADT[AdtPlacementPosition X,height,Z] -->|half extent - horizontal| Canonical - Canonical -->|floor 32 - axis / tile size| Tile[AdtTileCoordinate] + Canonical -->|tileX from Y; tileY from X| Tile[AdtTileCoordinate] Canonical -->|north-west edge offsets| Local[AdtTileLocalPosition] Local -->|floor offset / chunk size| Chunk[AdtChunkCoordinate] Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition] @@ -139,7 +139,7 @@ There is no cancellation because every conversion is constant-time scalar math. | Setting/capability | Default | Profile | Runtime mutable | Effect | |---|---|---|---|---| -| Contract version | 1 | All | No | Axis, unit and boundary semantics in ADR 0001 | +| Contract version | 2 | All | No | Corrected filename/chunk axes in ADR 0001 | | Godot units per WoW yard | 1 | `Blizzlike335` and current tools | No | Preserves current renderer scale | | ADT grid | 64 tiles/axis, 16 chunks/tile | WotLK 3.3.5a | No | Defines index and boundary math | @@ -183,7 +183,7 @@ raw three-number arrays are not an accepted new persistence contract. |---|---|---|---| | Canonical/server/Godot position mapping | Implemented | Headless round-trip and five M00 golden points | Migrate producers/consumers | | ADT placement mapping | Implemented | Synthetic/direct round-trip fixtures | Compare native parsed placements during migration | -| ADT tile/local/chunk ownership | Implemented | Center, extent, tile and chunk boundary fixtures | Migrate streaming names/ownership | +| ADT tile/local/chunk ownership | Implemented | Center/extent boundaries plus raw `Azeroth_31_49.adt` filename and MCNK direction evidence | Migrate streaming consumers | | 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 | | Visual/server-spawn integration | Planned | M01 target criteria | Add golden server spawn after adapter migration | @@ -191,8 +191,8 @@ raw three-number arrays are not an accepted new persistence contract. ## Known gaps and risks - Existing native loaders and renderer scripts still contain manual conversions. -- Current `StreamingWorldLoader` tile names may represent Godot X/Z ordering rather - than ADT filename X/Y; migration must preserve visible baseline behavior. +- 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; this package deliberately does not generalize them into world yaw. - Finite-number validation belongs to external format adapters and is not yet implemented. diff --git a/src/domain/coordinates/adt_chunk_coordinate.gd b/src/domain/coordinates/adt_chunk_coordinate.gd index 312f84a..ce8323e 100644 --- a/src/domain/coordinates/adt_chunk_coordinate.gd +++ b/src/domain/coordinates/adt_chunk_coordinate.gd @@ -2,7 +2,7 @@ class_name AdtChunkCoordinate extends RefCounted ## Immutable MCNK chunk indices inside one ADT tile. -## `chunk_x` grows south and `chunk_y` grows east, matching the canonical +## `chunk_x` grows east and `chunk_y` grows south, matching the ADT header and ## tile conversion contract rather than Godot's X/Z member names. const MINIMUM_CHUNK_INDEX: int = 0 diff --git a/src/domain/coordinates/coordinate_mapper.gd b/src/domain/coordinates/coordinate_mapper.gd index 52daf40..dc558c4 100644 --- a/src/domain/coordinates/coordinate_mapper.gd +++ b/src/domain/coordinates/coordinate_mapper.gd @@ -70,16 +70,16 @@ static func adt_placement_to_canonical(adt_position: AdtPlacementPosition) -> Ca ## Exact south/east boundaries belong to the next tile by floor semantics. static func canonical_to_adt_tile(canonical_position: CanonicalWowWorldPosition) -> AdtTileCoordinate: return AdtTileCoordinate.new( - int(floor(float(ADT_TILES_PER_MAP_AXIS) * 0.5 - canonical_position.x_yards / ADT_TILE_SIZE_YARDS)), - int(floor(float(ADT_TILES_PER_MAP_AXIS) * 0.5 - canonical_position.y_yards / ADT_TILE_SIZE_YARDS)) + int(floor(float(ADT_TILES_PER_MAP_AXIS) * 0.5 - canonical_position.y_yards / ADT_TILE_SIZE_YARDS)), + int(floor(float(ADT_TILES_PER_MAP_AXIS) * 0.5 - canonical_position.x_yards / ADT_TILE_SIZE_YARDS)) ) ## Converts a canonical position to east/south offsets inside its ADT tile. static func canonical_to_adt_tile_local(canonical_position: CanonicalWowWorldPosition) -> AdtTileLocalPosition: var tile_coordinate := canonical_to_adt_tile(canonical_position) - var tile_north_edge_x_yards := (float(ADT_TILES_PER_MAP_AXIS) * 0.5 - float(tile_coordinate.tile_x)) * ADT_TILE_SIZE_YARDS - var tile_west_edge_y_yards := (float(ADT_TILES_PER_MAP_AXIS) * 0.5 - float(tile_coordinate.tile_y)) * ADT_TILE_SIZE_YARDS + var tile_west_edge_y_yards := (float(ADT_TILES_PER_MAP_AXIS) * 0.5 - float(tile_coordinate.tile_x)) * ADT_TILE_SIZE_YARDS + var tile_north_edge_x_yards := (float(ADT_TILES_PER_MAP_AXIS) * 0.5 - float(tile_coordinate.tile_y)) * ADT_TILE_SIZE_YARDS return AdtTileLocalPosition.new( tile_west_edge_y_yards - canonical_position.y_yards, canonical_position.z_yards, @@ -92,8 +92,8 @@ static func adt_tile_local_to_canonical( tile_coordinate: AdtTileCoordinate, local_position: AdtTileLocalPosition ) -> CanonicalWowWorldPosition: - var tile_north_edge_x_yards := (float(ADT_TILES_PER_MAP_AXIS) * 0.5 - float(tile_coordinate.tile_x)) * ADT_TILE_SIZE_YARDS - var tile_west_edge_y_yards := (float(ADT_TILES_PER_MAP_AXIS) * 0.5 - float(tile_coordinate.tile_y)) * ADT_TILE_SIZE_YARDS + var tile_west_edge_y_yards := (float(ADT_TILES_PER_MAP_AXIS) * 0.5 - float(tile_coordinate.tile_x)) * ADT_TILE_SIZE_YARDS + var tile_north_edge_x_yards := (float(ADT_TILES_PER_MAP_AXIS) * 0.5 - float(tile_coordinate.tile_y)) * ADT_TILE_SIZE_YARDS return CanonicalWowWorldPosition.new( tile_north_edge_x_yards - local_position.south_yards, tile_west_edge_y_yards - local_position.east_yards, @@ -108,8 +108,8 @@ static func adt_tile_local_to_chunk( ) -> AdtChunkCoordinate: return AdtChunkCoordinate.new( tile_coordinate, - int(floor(local_position.south_yards / ADT_CHUNK_SIZE_YARDS)), - int(floor(local_position.east_yards / ADT_CHUNK_SIZE_YARDS)) + int(floor(local_position.east_yards / ADT_CHUNK_SIZE_YARDS)), + int(floor(local_position.south_yards / ADT_CHUNK_SIZE_YARDS)) ) diff --git a/src/tools/verify_coordinate_mapper.gd b/src/tools/verify_coordinate_mapper.gd index 2ffdebf..8e372bb 100644 --- a/src/tools/verify_coordinate_mapper.gd +++ b/src/tools/verify_coordinate_mapper.gd @@ -3,6 +3,7 @@ extends SceneTree ## Headless contract verification for M01 canonical coordinate value objects and ## [CoordinateMapper]. Uses synthetic boundaries plus five positions observed in ## the original WoW 3.3.5a build 12340 during the M00 paired renderer session. +## The Elwynn filename case regression-checks a raw `Azeroth_31_49.adt` corpus. const CoordinateMapperScript = preload("res://src/domain/coordinates/coordinate_mapper.gd") const CanonicalWowWorldPositionScript = preload("res://src/domain/coordinates/canonical_wow_world_position.gd") @@ -24,6 +25,7 @@ func _initialize() -> void: _verify_build_12340_golden_positions(failures) _verify_server_and_adt_round_trips(failures) _verify_tile_and_chunk_boundaries(failures) + _verify_recorded_raw_adt_chunk_axes(failures) _verify_world_yaw_conversions(failures) if not failures.is_empty(): @@ -32,7 +34,7 @@ func _initialize() -> void: quit(1) return - print("COORDINATE_MAPPER PASS golden_points=5 tile_boundaries=8 yaw_cases=5") + print("COORDINATE_MAPPER PASS golden_points=5 tile_boundaries=8 raw_mcnk=2 yaw_cases=5") quit(0) @@ -107,10 +109,10 @@ func _verify_tile_and_chunk_boundaries(failures: Array[String]) -> void: _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(-BOUNDARY_EPSILON_YARDS, -BOUNDARY_EPSILON_YARDS, 0.0)), 32, 32, true, "south-east of center boundary", failures) _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(-half_extent + BOUNDARY_EPSILON_YARDS, -half_extent + BOUNDARY_EPSILON_YARDS, 0.0)), 63, 63, true, "inside south-east extent", failures) _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(-half_extent, -half_extent, 0.0)), 64, 64, false, "exclusive south-east extent", failures) - _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(tile_size, -tile_size, 0.0)), 31, 33, true, "whole-tile offsets", failures) - _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(-9153.334, 386.666, 180.0)), 49, 31, true, "Elwynn golden tile", failures) + _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(tile_size, -tile_size, 0.0)), 33, 31, true, "whole-tile offsets", failures) + _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(-9153.334, 386.666, 180.0)), 31, 49, true, "raw Azeroth_31_49 filename", failures) - var tile_coordinate = AdtTileCoordinateScript.new(49, 31) + var tile_coordinate = AdtTileCoordinateScript.new(31, 49) var local_position = CoordinateMapperScript.canonical_to_adt_tile_local(CanonicalWowWorldPositionScript.new(-9153.334, 386.666, 180.0)) var local_round_trip = CoordinateMapperScript.adt_tile_local_to_canonical(tile_coordinate, local_position) _expect_canonical_position(local_round_trip, CanonicalWowWorldPositionScript.new(-9153.334, 386.666, 180.0), ROUND_TRIP_TOLERANCE_YARDS, "ADT tile-local round trip", failures) @@ -123,11 +125,43 @@ func _verify_tile_and_chunk_boundaries(failures: Array[String]) -> void: CoordinateMapperScript.ADT_CHUNK_SIZE_YARDS ) var chunk_coordinate = CoordinateMapperScript.adt_tile_local_to_chunk(tile_coordinate, chunk_local_position) - _expect_equal(chunk_coordinate.chunk_x, 1, "exact south chunk boundary", failures) - _expect_equal(chunk_coordinate.chunk_y, 2, "exact east chunk boundary", failures) + _expect_equal(chunk_coordinate.chunk_x, 2, "exact east chunk boundary", failures) + _expect_equal(chunk_coordinate.chunk_y, 1, "exact south chunk boundary", failures) _expect_true(chunk_coordinate.is_within_map_grid(), "chunk coordinate is inside map grid", failures) +func _verify_recorded_raw_adt_chunk_axes(failures: Array[String]) -> void: + var half_chunk_size_yards := CoordinateMapperScript.ADT_CHUNK_SIZE_YARDS * 0.5 + var recorded_chunk_origins := [ + { + "label": "raw MCNK 0,0", + "chunk_x": 0, + "chunk_y": 0, + "canonical_x_origin": -9066.666015625, + "canonical_y_origin": 533.333984375, + }, + { + "label": "raw MCNK 15,15", + "chunk_x": 15, + "chunk_y": 15, + "canonical_x_origin": -9566.666015625, + "canonical_y_origin": 33.333984375, + }, + ] + for recorded_chunk_origin in recorded_chunk_origins: + var chunk_center = CanonicalWowWorldPositionScript.new( + float(recorded_chunk_origin.canonical_x_origin) - half_chunk_size_yards, + float(recorded_chunk_origin.canonical_y_origin) - half_chunk_size_yards, + 0.0 + ) + var tile_coordinate = CoordinateMapperScript.canonical_to_adt_tile(chunk_center) + _expect_tile(tile_coordinate, 31, 49, true, String(recorded_chunk_origin.label), failures) + var local_position = CoordinateMapperScript.canonical_to_adt_tile_local(chunk_center) + var chunk_coordinate = CoordinateMapperScript.adt_tile_local_to_chunk(tile_coordinate, local_position) + _expect_equal(chunk_coordinate.chunk_x, int(recorded_chunk_origin.chunk_x), "%s chunk X" % recorded_chunk_origin.label, failures) + _expect_equal(chunk_coordinate.chunk_y, int(recorded_chunk_origin.chunk_y), "%s chunk Y" % recorded_chunk_origin.label, failures) + + func _verify_world_yaw_conversions(failures: Array[String]) -> void: var yaw_cases := [0.0, PI * 0.5, -PI * 0.5, PI, TAU + 0.25] for yaw_radians in yaw_cases: