diff --git a/docs/adr/0001-canonical-world-coordinates.md b/docs/adr/0001-canonical-world-coordinates.md new file mode 100644 index 0000000..1fbafaf --- /dev/null +++ b/docs/adr/0001-canonical-world-coordinates.md @@ -0,0 +1,151 @@ +# ADR 0001: Canonical world coordinate contract + +- Status: Accepted for M01 contract implementation +- Date: 2026-07-13 +- Work package: `M01-FND-COORDS-001` +- Decision owners: M01 foundation/integration owners + +## Context + +OpenWC currently performs coordinate conversion in native ADT/WDT loaders, +`StreamingWorldLoader`, sky, player and renderer diagnostic scripts. The same +numbers are frequently carried in `Vector3` or dictionaries, so the coordinate +space is implicit. Existing names also mix ADT filename indices, renderer X/Z +indices and MDDF/MODF placement fields. + +M00 recorded five positions observed in WoW 3.3.5a build 12340. They verify the +current client-position to Godot-position arithmetic within `0.002` yard, but +they do not define a cross-layer contract and do not prove camera composition +or visual parity. + +Independent references agree on a 64 by 64 ADT grid and an approximately +`533.3333` yard tile size. They do not all use the word “canonical” for the same +axis order. OpenWC therefore needs names tied to observable formats instead of +adopting an internal convention from one reference project. + +## Decision + +### Canonical position + +`CanonicalWowWorldPosition(X, Y, Z)` preserves the numeric X/Y/Z order used by +the WoW client, movement protocol and TrinityCore/AzerothCore world positions. +X and Y are horizontal; Z is height. Units are WoW yards. + +`ServerWorldPosition` is a distinct type but converts component-for-component +to canonical. The type boundary prevents a server record from being passed +directly to renderer code and leaves room for adapter validation without a +silent axis swap. + +### World extent and ADT indices + +The contract uses: + +```text +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) +``` + +`AdtTileCoordinate(tile_x, tile_y)` always means the indices in +`Map_tileX_tileY.adt`. Results are intentionally not clamped: the exact +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. + +### ADT placement fields + +MDDF/MODF position fields are represented in on-disk X/height/Z order: + +```text +adt.X = halfExtent - canonical.X +adt.height = canonical.Z +adt.Z = halfExtent - canonical.Y +``` + +Model-local MDDF/MODF Euler corrections are not world orientation. They remain +outside this contract until a placement-orientation package has format- and +model-specific fixtures. + +### Godot renderer basis + +The compatibility renderer keeps one Godot unit equal to one WoW yard and uses +Y-up coordinates from the north-west map extent: + +```text +godot.X = halfExtent - canonical.Y # east +godot.Y = canonical.Z # up +godot.Z = halfExtent - canonical.X # south +``` + +`GodotWorldPosition` contains scalars rather than `Vector3`. A scene/render +adapter may construct `Vector3` only at its boundary. + +### World yaw + +Server/canonical yaw zero faces increasing WoW X; positive yaw turns toward +increasing WoW Y. In the mapped Godot basis, Godot forward `-Z` and positive +Y-axis yaw produce the same numeric world angle. Mapper conversions therefore +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. + +## Alternatives considered + +### Make renderer coordinates canonical + +Rejected because protocol, database and ADT adapters would silently inherit a +presentation-specific offset and Y-up basis. + +### Adopt WoWee's internal canonical axis names unchanged + +Rejected because that reference swaps server X/Y into its chosen north/west +ordering. OpenWC preserves observable client/server field names and makes the +render/ADT transformations explicit instead. + +### Use `Vector3` plus comments + +Rejected because comments cannot prevent server, ADT and Godot values from +being passed across the wrong boundary. + +### Generic vector-space or matrix framework + +Rejected as unnecessary complexity for fixed affine transforms and integer +grid ownership rules. + +## Consequences + +- Network and server-data adapters can expose typed positions without knowing + Godot. +- 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. +- Persisted schemas are unchanged. Future serialized positions must include an + explicit coordinate-space/version discriminator. + +## Verification + +- Five M00 build-12340 client camera positions map to the recorded Godot + 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. +- World-yaw normalization and round-trip cases cover cardinal angles and wrap. + +## References + +- [TrinityCore GridDefines](https://github.com/TrinityCore/TrinityCore/blob/master/src/server/game/Grids/GridDefines.h) +- `reference/open-realm/games/world-of-warcraft/renderer/wow/r_wowmap_adt.c` +- `reference/open-realm/games/world-of-warcraft/renderer/wow/r_wowmap_objects.c` +- `reference/WoWee/include/core/coordinates.hpp` +- [`../M00_FINAL_PAIRED_EVIDENCE.md`](../M00_FINAL_PAIRED_EVIDENCE.md) diff --git a/docs/modules/README.md b/docs/modules/README.md index b1e5873..b6d2c46 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -7,6 +7,7 @@ | Module | Current status | Primary specification | |---|---|---| | Foundation/data | Planned/Partial | [`../../targets/roadmap/01-foundation-and-data.md`](../../targets/roadmap/01-foundation-and-data.md) | +| Coordinate mapping | Implemented | [`coordinate-mapping.md`](coordinate-mapping.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | | Network/session | Planned | [`../../targets/roadmap/03-network-and-session.md`](../../targets/roadmap/03-network-and-session.md) | | Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) | diff --git a/docs/modules/coordinate-mapping.md b/docs/modules/coordinate-mapping.md new file mode 100644 index 0000000..a4a17a1 --- /dev/null +++ b/docs/modules/coordinate-mapping.md @@ -0,0 +1,218 @@ +# Coordinate Mapping + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | `M01-FND-COORDS-001` | +| Owners | Foundation/domain | +| Last verified | Worktree `work/sindo-main-codex/m01-coordinate-mapper`, 2026-07-13 | +| Profiles/capabilities | All profiles; contract version 1 | + +## Purpose + +Provide scene-free value objects and one pure `CoordinateMapper` for positions, +ADT grid ownership and world-facing yaw. The module makes every source space +explicit before renderer, network, gameplay and authoring consumers migrate. + +## Non-goals + +- Reading ADT, packet, SQL or scene data. +- Constructing `Vector3`, `Transform3D`, Node or Resource values. +- Correcting M2/WMO local axes or MDDF/MODF Euler rotations. +- Selecting maps, validating terrain height or owning streaming focus. +- Persisting coordinate values in this work package. + +## Context and boundaries + +```mermaid +flowchart LR + Server[Server / movement X,Y,Z] --> ServerPosition[ServerWorldPosition] + ADT[MDDF/MODF X,height,Z] --> AdtPosition[AdtPlacementPosition] + ServerPosition --> Mapper[CoordinateMapper] + AdtPosition --> Mapper + Mapper --> Canonical[CanonicalWowWorldPosition] + Canonical --> Tile[AdtTileCoordinate + AdtTileLocalPosition] + Canonical --> GodotPosition[GodotWorldPosition] + GodotPosition --> Adapter[Future scene/render adapter] + Adapter --> Vector[Vector3 at boundary] + Scene[Node / Camera3D / scene lookup] -. forbidden .-> Mapper +``` + +Allowed dependencies: + +- GDScript scalar math and `RefCounted` lifetime; +- other value objects in `src/domain/coordinates`; +- callers in network, gameplay, render and server-data adapters. + +Forbidden dependencies: + +- `Node`, `Resource`, SceneTree, Camera3D and global scene lookup; +- packet buffers, SQL rows, asset parsers and renderer state; +- `Vector3` as a public mapper input or output; +- model-specific placement corrections. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `CanonicalWowWorldPosition` | Immutable value | Client/server X/Y/Z in yards | Any thread; caller-owned reference | No bounds validation | +| `ServerWorldPosition` | Immutable value | Server/wire X/Y/Z in yards | Any thread; caller-owned reference | No adapter validation | +| `AdtPlacementPosition` | Immutable value | MDDF/MODF X/height/Z fields | Any thread; caller-owned reference | No file validation | +| `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 | +| `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 | +| `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.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 | +| `CoordinateMapper.adt_tile_local_to_chunk` | Pure static method | Compute unclamped MCNK indices | Synchronous; new result value | Boundary/out-of-range remains diagnosable | +| World-yaw mapper methods | Pure static methods | Normalize and cross the world-basis boundary | Synchronous; new result value | Non-finite input is unsupported | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | `ServerWorldPosition`, `ServerWorldYaw` | Future protocol/server adapter | `CoordinateMapper` | Immutable caller value | Any thread, call duration | +| Input | `AdtPlacementPosition` | Future ADT adapter | `CoordinateMapper` | Immutable caller value | Any thread, call duration | +| Input | `CanonicalWowWorldPosition`, `CanonicalWowWorldYaw` | Gameplay/content/domain | `CoordinateMapper` | Immutable caller value | Any thread, call duration | +| Output | `CanonicalWowWorldPosition` | `CoordinateMapper` | Gameplay/content/adapters | New immutable value | Caller-owned reference | +| Output | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content adapters | New immutable values | Caller-owned references | +| Output | `GodotWorldPosition`, `GodotWorldYaw` | `CoordinateMapper` | Future render/scene adapter | New immutable values | Caller-owned references | + +Side effects: + +- none; +- no scene mutation, file/cache write, network send, database access or logging. + +## Data flow + +```mermaid +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 -->|north-west edge offsets| Local[AdtTileLocalPosition] + Local -->|floor offset / chunk size| Chunk[AdtChunkCoordinate] + Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition] + ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw] + CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw] +``` + +## Lifecycle/state + +The module is stateless. Each call reads immutable scalar inputs and returns a +new value object. There is no initialization, shutdown, cancellation or retry +state machine. + +## Main sequence + +No cross-layer asynchronous sequence occurs inside this module. Producers parse +or validate their external format first, call the mapper synchronously, and +then pass only typed results across the next boundary. + +## Ownership, threading and resources + +- Value objects own only scalar fields and, for chunks, one immutable tile reference. +- Callers own returned `RefCounted` references. +- Methods do not mutate inputs or shared state and are safe for worker use. +- No Node, RID, file, socket, database or cache resource is acquired. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Tile/chunk outside standard grid | `is_within_map_grid()` | Unclamped indices are preserved | Caller reports source and map context | Reject or apply explicit map policy | +| Tile-local value outside half-open tile | Consumer boundary validation | Value is preserved | Caller reports source coordinate | Recompute ownership or reject malformed input | +| Null value object | Static typing/call failure | Programming contract violation | GDScript error with call site | Fix producer; no fallback value | +| NaN/infinite scalar | Adapter validation | Arithmetic propagates invalid value | Adapter must report source field | Reject malformed external input | + +There is no cancellation because every conversion is constant-time scalar math. + +## Configuration and capabilities + +| Setting/capability | Default | Profile | Runtime mutable | Effect | +|---|---|---|---|---| +| Contract version | 1 | All | No | Axis, unit and boundary semantics 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 | + +## Persistence, cache and migration + +This package adds no persisted format, cache or migration. Existing dictionaries, +native `Vector3` payloads and renderer conversion helpers remain compatible. +Consumer migration is ordered after this contract and must remove old manual +conversions only after its producer/consumer tests pass. + +Any future serialized coordinate must store a schema version and explicit space; +raw three-number arrays are not an accepted new persistence contract. + +## Diagnostics and observability + +- Logs: none in production mapper. +- Metrics: none; conversion cost is constant and outside frame-budget concern. +- Debug views: headless contract probe prints one PASS summary. +- Correlation IDs: supplied by external adapters if an invalid source is reported. + +## Verification + +- Unit/contract tests: `src/tools/verify_coordinate_mapper.gd`. +- Integration/E2E: consumer migration and `StreamingFocus` packages remain pending. +- Fidelity evidence: five build-12340 camera positions from the M00 manifest map + within `0.002` yard; independent reference formulas are recorded in ADR 0001. +- Performance budgets: constant-time arithmetic; no scene or file access. +- Manual diagnostics: not required for the pure contract; renderer checkpoints + are required when consumers migrate. + +## Extension points + +- Add typed map IDs around positions without changing axis semantics. +- Add a format-specific placement-orientation adapter with its own fixtures. +- Add serialization only with explicit space/version fields and migrations. +- Add consumer adapters that construct engine-native vectors at their boundary. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| 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 | +| 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 | + +## 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. +- 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. +- Arithmetic agreement does not prove terrain, placement, camera direction or visual parity. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/domain/coordinates/coordinate_mapper.gd` | Constants and all coordinate-axis conversions | +| `src/domain/coordinates/*_position.gd` | Immutable typed position values | +| `src/domain/coordinates/adt_tile_coordinate.gd` | ADT filename tile indices and bounds | +| `src/domain/coordinates/adt_chunk_coordinate.gd` | MCNK indices and bounds | +| `src/domain/coordinates/*_yaw.gd` | Immutable typed world-facing yaw values | +| `src/tools/verify_coordinate_mapper.gd` | Headless fixtures and boundary regressions | +| `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision | + +## Related decisions and references + +- ADR: [`../adr/0001-canonical-world-coordinates.md`](../adr/0001-canonical-world-coordinates.md) +- M00 evidence: [`../M00_FINAL_PAIRED_EVIDENCE.md`](../M00_FINAL_PAIRED_EVIDENCE.md) +- Architecture: [`../ARCHITECTURE.md`](../ARCHITECTURE.md) +- Testing rules: [`../TESTING.md`](../TESTING.md) diff --git a/src/domain/coordinates/adt_chunk_coordinate.gd b/src/domain/coordinates/adt_chunk_coordinate.gd new file mode 100644 index 0000000..312f84a --- /dev/null +++ b/src/domain/coordinates/adt_chunk_coordinate.gd @@ -0,0 +1,44 @@ +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 +## tile conversion contract rather than Godot's X/Z member names. + +const MINIMUM_CHUNK_INDEX: int = 0 +const MAXIMUM_CHUNK_INDEX: int = 15 + +var tile_coordinate: AdtTileCoordinate: + get: + return _tile_coordinate + +var chunk_x: int: + get: + return _chunk_x + +var chunk_y: int: + get: + return _chunk_y + +var _tile_coordinate: AdtTileCoordinate +var _chunk_x: int +var _chunk_y: int + + +## Creates chunk indices without clamping so invalid parser input is visible. +func _init(tile_coordinate_value: AdtTileCoordinate, chunk_x_value: int, chunk_y_value: int) -> void: + _tile_coordinate = tile_coordinate_value + _chunk_x = chunk_x_value + _chunk_y = chunk_y_value + + +## Returns whether the tile and both chunk indices are inside standard bounds. +func is_within_map_grid() -> bool: + return ( + _tile_coordinate != null + and _tile_coordinate.is_within_map_grid() + and _chunk_x >= MINIMUM_CHUNK_INDEX + and _chunk_x <= MAXIMUM_CHUNK_INDEX + and _chunk_y >= MINIMUM_CHUNK_INDEX + and _chunk_y <= MAXIMUM_CHUNK_INDEX + ) diff --git a/src/domain/coordinates/adt_chunk_coordinate.gd.uid b/src/domain/coordinates/adt_chunk_coordinate.gd.uid new file mode 100644 index 0000000..4175f7a --- /dev/null +++ b/src/domain/coordinates/adt_chunk_coordinate.gd.uid @@ -0,0 +1 @@ +uid://b1lf5w5p3b6ye diff --git a/src/domain/coordinates/adt_placement_position.gd b/src/domain/coordinates/adt_placement_position.gd new file mode 100644 index 0000000..0712c56 --- /dev/null +++ b/src/domain/coordinates/adt_placement_position.gd @@ -0,0 +1,29 @@ +class_name AdtPlacementPosition +extends RefCounted + +## Immutable MDDF/MODF position in ADT file field order. +## `x_offset_yards` and `z_offset_yards` are measured from the north-west map +## extent; `height_yards` is the vertical field stored between them. + +var x_offset_yards: float: + get: + return _x_offset_yards + +var height_yards: float: + get: + return _height_yards + +var z_offset_yards: float: + get: + return _z_offset_yards + +var _x_offset_yards: float +var _height_yards: float +var _z_offset_yards: float + + +## Creates an ADT placement position in on-disk X/height/Z field order. +func _init(x_offset_yards_value: float, height_yards_value: float, z_offset_yards_value: float) -> void: + _x_offset_yards = x_offset_yards_value + _height_yards = height_yards_value + _z_offset_yards = z_offset_yards_value diff --git a/src/domain/coordinates/adt_placement_position.gd.uid b/src/domain/coordinates/adt_placement_position.gd.uid new file mode 100644 index 0000000..c4d48ca --- /dev/null +++ b/src/domain/coordinates/adt_placement_position.gd.uid @@ -0,0 +1 @@ +uid://cxxlbkjmdtg7m diff --git a/src/domain/coordinates/adt_tile_coordinate.gd b/src/domain/coordinates/adt_tile_coordinate.gd new file mode 100644 index 0000000..fcefdb5 --- /dev/null +++ b/src/domain/coordinates/adt_tile_coordinate.gd @@ -0,0 +1,35 @@ +class_name AdtTileCoordinate +extends RefCounted + +## Immutable indices from an ADT filename `Map_tileX_tileY.adt`. +## Both axes use the inclusive range 0..63 for a normal WotLK world map. + +const MINIMUM_TILE_INDEX: int = 0 +const MAXIMUM_TILE_INDEX: int = 63 + +var tile_x: int: + get: + return _tile_x + +var tile_y: int: + get: + return _tile_y + +var _tile_x: int +var _tile_y: int + + +## Creates file indices without clamping so out-of-map input remains diagnosable. +func _init(tile_x_value: int, tile_y_value: int) -> void: + _tile_x = tile_x_value + _tile_y = tile_y_value + + +## Returns whether both file indices address the standard 64 by 64 ADT grid. +func is_within_map_grid() -> bool: + return ( + _tile_x >= MINIMUM_TILE_INDEX + and _tile_x <= MAXIMUM_TILE_INDEX + and _tile_y >= MINIMUM_TILE_INDEX + and _tile_y <= MAXIMUM_TILE_INDEX + ) diff --git a/src/domain/coordinates/adt_tile_coordinate.gd.uid b/src/domain/coordinates/adt_tile_coordinate.gd.uid new file mode 100644 index 0000000..3d71750 --- /dev/null +++ b/src/domain/coordinates/adt_tile_coordinate.gd.uid @@ -0,0 +1 @@ +uid://bg0a286yty1jw diff --git a/src/domain/coordinates/adt_tile_local_position.gd b/src/domain/coordinates/adt_tile_local_position.gd new file mode 100644 index 0000000..2b2682d --- /dev/null +++ b/src/domain/coordinates/adt_tile_local_position.gd @@ -0,0 +1,29 @@ +class_name AdtTileLocalPosition +extends RefCounted + +## Immutable position relative to the north-west corner of one ADT tile. +## East and south offsets are normally in `[0, ADT_TILE_SIZE_YARDS)`; height +## remains in the canonical vertical space. + +var east_yards: float: + get: + return _east_yards + +var height_yards: float: + get: + return _height_yards + +var south_yards: float: + get: + return _south_yards + +var _east_yards: float +var _height_yards: float +var _south_yards: float + + +## Creates a tile-local position without clamping boundary values. +func _init(east_yards_value: float, height_yards_value: float, south_yards_value: float) -> void: + _east_yards = east_yards_value + _height_yards = height_yards_value + _south_yards = south_yards_value diff --git a/src/domain/coordinates/adt_tile_local_position.gd.uid b/src/domain/coordinates/adt_tile_local_position.gd.uid new file mode 100644 index 0000000..40f7c2e --- /dev/null +++ b/src/domain/coordinates/adt_tile_local_position.gd.uid @@ -0,0 +1 @@ +uid://cpit2uvrdud6h diff --git a/src/domain/coordinates/canonical_wow_world_position.gd b/src/domain/coordinates/canonical_wow_world_position.gd new file mode 100644 index 0000000..52ab16b --- /dev/null +++ b/src/domain/coordinates/canonical_wow_world_position.gd @@ -0,0 +1,29 @@ +class_name CanonicalWowWorldPosition +extends RefCounted + +## Immutable WoW 3.3.5a world position in client/server axis order and yards. +## X and Y are the horizontal coordinates reported by the client and world +## server; Z is height. This type deliberately does not expose a [Vector3]. + +var x_yards: float: + get: + return _x_yards + +var y_yards: float: + get: + return _y_yards + +var z_yards: float: + get: + return _z_yards + +var _x_yards: float +var _y_yards: float +var _z_yards: float + + +## Creates a canonical position without changing precision or validating map bounds. +func _init(x_yards_value: float, y_yards_value: float, z_yards_value: float) -> void: + _x_yards = x_yards_value + _y_yards = y_yards_value + _z_yards = z_yards_value diff --git a/src/domain/coordinates/canonical_wow_world_position.gd.uid b/src/domain/coordinates/canonical_wow_world_position.gd.uid new file mode 100644 index 0000000..c0ecb4a --- /dev/null +++ b/src/domain/coordinates/canonical_wow_world_position.gd.uid @@ -0,0 +1 @@ +uid://b75i20d4vnkyh diff --git a/src/domain/coordinates/canonical_wow_world_yaw.gd b/src/domain/coordinates/canonical_wow_world_yaw.gd new file mode 100644 index 0000000..554a336 --- /dev/null +++ b/src/domain/coordinates/canonical_wow_world_yaw.gd @@ -0,0 +1,16 @@ +class_name CanonicalWowWorldYaw +extends RefCounted + +## Immutable canonical world-facing yaw in radians. +## Zero faces increasing WoW X and positive rotation turns toward increasing Y. + +var radians: float: + get: + return _radians + +var _radians: float + + +## Creates a yaw without normalization; [CoordinateMapper] normalizes conversions. +func _init(radians_value: float) -> void: + _radians = radians_value diff --git a/src/domain/coordinates/canonical_wow_world_yaw.gd.uid b/src/domain/coordinates/canonical_wow_world_yaw.gd.uid new file mode 100644 index 0000000..1ce120a --- /dev/null +++ b/src/domain/coordinates/canonical_wow_world_yaw.gd.uid @@ -0,0 +1 @@ +uid://ddu5eiobntlrm diff --git a/src/domain/coordinates/coordinate_mapper.gd b/src/domain/coordinates/coordinate_mapper.gd new file mode 100644 index 0000000..52daf40 --- /dev/null +++ b/src/domain/coordinates/coordinate_mapper.gd @@ -0,0 +1,137 @@ +class_name CoordinateMapper +extends RefCounted + +## Pure, stateless conversions between OpenWC coordinate value objects. +## This is the only domain contract allowed to change coordinate axes. It does +## not depend on Node, Resource, scene state, map assets or renderer globals. + +const ADT_TILES_PER_MAP_AXIS: int = 64 +const ADT_CHUNKS_PER_TILE_AXIS: int = 16 +const ADT_TILE_SIZE_YARDS: float = 1600.0 / 3.0 +const ADT_CHUNK_SIZE_YARDS: float = ADT_TILE_SIZE_YARDS / float(ADT_CHUNKS_PER_TILE_AXIS) +const WOW_WORLD_HALF_EXTENT_YARDS: float = ADT_TILE_SIZE_YARDS * float(ADT_TILES_PER_MAP_AXIS) * 0.5 + + +## Converts server/wire X/Y/Z to canonical WoW X/Y/Z without an axis swap. +static func server_to_canonical(server_position: ServerWorldPosition) -> CanonicalWowWorldPosition: + return CanonicalWowWorldPosition.new( + server_position.x_yards, + server_position.y_yards, + server_position.z_yards + ) + + +## Converts canonical WoW X/Y/Z to server/wire X/Y/Z without an axis swap. +static func canonical_to_server(canonical_position: CanonicalWowWorldPosition) -> ServerWorldPosition: + return ServerWorldPosition.new( + canonical_position.x_yards, + canonical_position.y_yards, + canonical_position.z_yards + ) + + +## Converts canonical WoW world axes to the Godot Y-up compatibility basis. +static func canonical_to_godot(canonical_position: CanonicalWowWorldPosition) -> GodotWorldPosition: + return GodotWorldPosition.new( + WOW_WORLD_HALF_EXTENT_YARDS - canonical_position.y_yards, + canonical_position.z_yards, + WOW_WORLD_HALF_EXTENT_YARDS - canonical_position.x_yards + ) + + +## Converts the Godot Y-up compatibility basis to canonical WoW world axes. +static func godot_to_canonical(godot_position: GodotWorldPosition) -> CanonicalWowWorldPosition: + return CanonicalWowWorldPosition.new( + WOW_WORLD_HALF_EXTENT_YARDS - godot_position.z_units, + WOW_WORLD_HALF_EXTENT_YARDS - godot_position.x_units, + godot_position.y_units + ) + + +## 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( + WOW_WORLD_HALF_EXTENT_YARDS - canonical_position.x_yards, + canonical_position.z_yards, + WOW_WORLD_HALF_EXTENT_YARDS - canonical_position.y_yards + ) + + +## Converts MDDF/MODF X/height/Z file fields to canonical WoW coordinates. +static func adt_placement_to_canonical(adt_position: AdtPlacementPosition) -> CanonicalWowWorldPosition: + return CanonicalWowWorldPosition.new( + WOW_WORLD_HALF_EXTENT_YARDS - adt_position.x_offset_yards, + WOW_WORLD_HALF_EXTENT_YARDS - adt_position.z_offset_yards, + adt_position.height_yards + ) + + +## Returns unclamped ADT filename indices for a canonical position. +## 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)) + ) + + +## 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 + return AdtTileLocalPosition.new( + tile_west_edge_y_yards - canonical_position.y_yards, + canonical_position.z_yards, + tile_north_edge_x_yards - canonical_position.x_yards + ) + + +## Reconstructs canonical coordinates from an ADT tile and tile-local offsets. +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 + return CanonicalWowWorldPosition.new( + tile_north_edge_x_yards - local_position.south_yards, + tile_west_edge_y_yards - local_position.east_yards, + local_position.height_yards + ) + + +## Returns unclamped MCNK indices for a tile-local position. +static func adt_tile_local_to_chunk( + tile_coordinate: AdtTileCoordinate, + local_position: AdtTileLocalPosition +) -> 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)) + ) + + +## Converts server movement yaw to canonical yaw and normalizes it to [-PI, PI). +static func server_to_canonical_yaw(server_yaw: ServerWorldYaw) -> CanonicalWowWorldYaw: + return CanonicalWowWorldYaw.new(_normalize_yaw(server_yaw.radians)) + + +## Converts canonical yaw to server movement yaw and normalizes it to [-PI, PI). +static func canonical_to_server_yaw(canonical_yaw: CanonicalWowWorldYaw) -> ServerWorldYaw: + return ServerWorldYaw.new(_normalize_yaw(canonical_yaw.radians)) + + +## Converts canonical world yaw to Godot Y-axis yaw in the mapped world basis. +static func canonical_to_godot_yaw(canonical_yaw: CanonicalWowWorldYaw) -> GodotWorldYaw: + return GodotWorldYaw.new(_normalize_yaw(canonical_yaw.radians)) + + +## Converts Godot Y-axis world yaw to canonical yaw in the mapped world basis. +static func godot_to_canonical_yaw(godot_yaw: GodotWorldYaw) -> CanonicalWowWorldYaw: + return CanonicalWowWorldYaw.new(_normalize_yaw(godot_yaw.radians)) + + +static func _normalize_yaw(radians: float) -> float: + return fposmod(radians + PI, TAU) - PI diff --git a/src/domain/coordinates/coordinate_mapper.gd.uid b/src/domain/coordinates/coordinate_mapper.gd.uid new file mode 100644 index 0000000..62820ac --- /dev/null +++ b/src/domain/coordinates/coordinate_mapper.gd.uid @@ -0,0 +1 @@ +uid://bg8p8rrkmg84d diff --git a/src/domain/coordinates/godot_world_position.gd b/src/domain/coordinates/godot_world_position.gd new file mode 100644 index 0000000..838ea7d --- /dev/null +++ b/src/domain/coordinates/godot_world_position.gd @@ -0,0 +1,29 @@ +class_name GodotWorldPosition +extends RefCounted + +## Immutable Godot Y-up renderer position. +## One Godot unit equals one WoW yard in the compatibility renderer. X grows +## east, Y grows up and Z grows south from the north-west map extent. + +var x_units: float: + get: + return _x_units + +var y_units: float: + get: + return _y_units + +var z_units: float: + get: + return _z_units + +var _x_units: float +var _y_units: float +var _z_units: float + + +## Creates a renderer position without exposing it as a cross-layer [Vector3]. +func _init(x_units_value: float, y_units_value: float, z_units_value: float) -> void: + _x_units = x_units_value + _y_units = y_units_value + _z_units = z_units_value diff --git a/src/domain/coordinates/godot_world_position.gd.uid b/src/domain/coordinates/godot_world_position.gd.uid new file mode 100644 index 0000000..b94d48e --- /dev/null +++ b/src/domain/coordinates/godot_world_position.gd.uid @@ -0,0 +1 @@ +uid://tp7jo14w25nc diff --git a/src/domain/coordinates/godot_world_yaw.gd b/src/domain/coordinates/godot_world_yaw.gd new file mode 100644 index 0000000..1a85f50 --- /dev/null +++ b/src/domain/coordinates/godot_world_yaw.gd @@ -0,0 +1,17 @@ +class_name GodotWorldYaw +extends RefCounted + +## Immutable Godot Y-axis yaw in radians for the canonical world basis. +## With Godot forward `-Z`, the numeric angle equals WoW world yaw after the +## position basis conversion. Model-local MDDF/MODF corrections are separate. + +var radians: float: + get: + return _radians + +var _radians: float + + +## Creates a Godot world yaw without normalization. +func _init(radians_value: float) -> void: + _radians = radians_value diff --git a/src/domain/coordinates/godot_world_yaw.gd.uid b/src/domain/coordinates/godot_world_yaw.gd.uid new file mode 100644 index 0000000..0822465 --- /dev/null +++ b/src/domain/coordinates/godot_world_yaw.gd.uid @@ -0,0 +1 @@ +uid://cuy2skhdv3f6g diff --git a/src/domain/coordinates/server_world_position.gd b/src/domain/coordinates/server_world_position.gd new file mode 100644 index 0000000..6d187ab --- /dev/null +++ b/src/domain/coordinates/server_world_position.gd @@ -0,0 +1,29 @@ +class_name ServerWorldPosition +extends RefCounted + +## Immutable TrinityCore/AzerothCore world position in wire/database X/Y/Z order. +## Units are WoW yards. A distinct type prevents server positions from being +## passed directly to renderer APIs even though the scalar values are canonical. + +var x_yards: float: + get: + return _x_yards + +var y_yards: float: + get: + return _y_yards + +var z_yards: float: + get: + return _z_yards + +var _x_yards: float +var _y_yards: float +var _z_yards: float + + +## Creates a server position without changing precision or validating map bounds. +func _init(x_yards_value: float, y_yards_value: float, z_yards_value: float) -> void: + _x_yards = x_yards_value + _y_yards = y_yards_value + _z_yards = z_yards_value diff --git a/src/domain/coordinates/server_world_position.gd.uid b/src/domain/coordinates/server_world_position.gd.uid new file mode 100644 index 0000000..6630d69 --- /dev/null +++ b/src/domain/coordinates/server_world_position.gd.uid @@ -0,0 +1 @@ +uid://dilhr7ym3xr43 diff --git a/src/domain/coordinates/server_world_yaw.gd b/src/domain/coordinates/server_world_yaw.gd new file mode 100644 index 0000000..d6887ff --- /dev/null +++ b/src/domain/coordinates/server_world_yaw.gd @@ -0,0 +1,16 @@ +class_name ServerWorldYaw +extends RefCounted + +## Immutable TrinityCore/AzerothCore movement orientation in radians. +## It uses the WoW client convention: zero faces +X and positive turns toward +Y. + +var radians: float: + get: + return _radians + +var _radians: float + + +## Creates a server yaw without normalization. +func _init(radians_value: float) -> void: + _radians = radians_value diff --git a/src/domain/coordinates/server_world_yaw.gd.uid b/src/domain/coordinates/server_world_yaw.gd.uid new file mode 100644 index 0000000..d3b9b45 --- /dev/null +++ b/src/domain/coordinates/server_world_yaw.gd.uid @@ -0,0 +1 @@ +uid://dr8k82t2qgewd diff --git a/src/tools/verify_coordinate_mapper.gd b/src/tools/verify_coordinate_mapper.gd new file mode 100644 index 0000000..2ffdebf --- /dev/null +++ b/src/tools/verify_coordinate_mapper.gd @@ -0,0 +1,185 @@ +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. + +const CoordinateMapperScript = preload("res://src/domain/coordinates/coordinate_mapper.gd") +const CanonicalWowWorldPositionScript = preload("res://src/domain/coordinates/canonical_wow_world_position.gd") +const ServerWorldPositionScript = preload("res://src/domain/coordinates/server_world_position.gd") +const AdtPlacementPositionScript = preload("res://src/domain/coordinates/adt_placement_position.gd") +const AdtTileCoordinateScript = preload("res://src/domain/coordinates/adt_tile_coordinate.gd") +const AdtTileLocalPositionScript = preload("res://src/domain/coordinates/adt_tile_local_position.gd") +const CanonicalWowWorldYawScript = preload("res://src/domain/coordinates/canonical_wow_world_yaw.gd") +const ServerWorldYawScript = preload("res://src/domain/coordinates/server_world_yaw.gd") + +const RENDER_BASELINE_MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" +const GOLDEN_POSITION_TOLERANCE_YARDS := 0.002 +const ROUND_TRIP_TOLERANCE_YARDS := 0.000001 +const BOUNDARY_EPSILON_YARDS := 0.001 + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_build_12340_golden_positions(failures) + _verify_server_and_adt_round_trips(failures) + _verify_tile_and_chunk_boundaries(failures) + _verify_world_yaw_conversions(failures) + + if not failures.is_empty(): + for failure in failures: + push_error("COORDINATE_MAPPER: %s" % failure) + quit(1) + return + + print("COORDINATE_MAPPER PASS golden_points=5 tile_boundaries=8 yaw_cases=5") + quit(0) + + +func _verify_build_12340_golden_positions(failures: Array[String]) -> void: + var manifest := _load_json_object(RENDER_BASELINE_MANIFEST_PATH, failures) + if manifest.is_empty(): + return + + var golden_point_count := 0 + for checkpoint_variant in manifest.get("checkpoints", []): + if not (checkpoint_variant is Dictionary): + continue + var checkpoint: Dictionary = checkpoint_variant + var reference_wow_camera_variant = checkpoint.get("reference_wow_camera", null) + if not (reference_wow_camera_variant is Array) or reference_wow_camera_variant.size() != 3: + continue + + var expected_godot_camera_variant = checkpoint.get("camera", null) + if not (expected_godot_camera_variant is Array) or expected_godot_camera_variant.size() != 3: + failures.append("%s has no three-component renderer camera" % checkpoint.get("name", "checkpoint")) + continue + + var canonical_position = CanonicalWowWorldPositionScript.new( + float(reference_wow_camera_variant[0]), + float(reference_wow_camera_variant[1]), + float(reference_wow_camera_variant[2]) + ) + var godot_position = CoordinateMapperScript.canonical_to_godot(canonical_position) + var checkpoint_name := String(checkpoint.get("name", "checkpoint")) + _expect_near(godot_position.x_units, float(expected_godot_camera_variant[0]), GOLDEN_POSITION_TOLERANCE_YARDS, "%s Godot X" % checkpoint_name, failures) + _expect_near(godot_position.y_units, float(expected_godot_camera_variant[1]), GOLDEN_POSITION_TOLERANCE_YARDS, "%s Godot Y" % checkpoint_name, failures) + _expect_near(godot_position.z_units, float(expected_godot_camera_variant[2]), GOLDEN_POSITION_TOLERANCE_YARDS, "%s Godot Z" % checkpoint_name, failures) + + var round_trip_position = CoordinateMapperScript.godot_to_canonical(godot_position) + _expect_canonical_position(round_trip_position, canonical_position, ROUND_TRIP_TOLERANCE_YARDS, "%s Godot round trip" % checkpoint_name, failures) + golden_point_count += 1 + + _expect_equal(golden_point_count, 5, "build 12340 golden point count", failures) + + +func _verify_server_and_adt_round_trips(failures: Array[String]) -> void: + var server_position = ServerWorldPositionScript.new(-9153.334, 386.666, 180.0) + var canonical_position = CoordinateMapperScript.server_to_canonical(server_position) + _expect_near(canonical_position.x_yards, server_position.x_yards, ROUND_TRIP_TOLERANCE_YARDS, "server X remains canonical X", failures) + _expect_near(canonical_position.y_yards, server_position.y_yards, ROUND_TRIP_TOLERANCE_YARDS, "server Y remains canonical Y", failures) + _expect_near(canonical_position.z_yards, server_position.z_yards, ROUND_TRIP_TOLERANCE_YARDS, "server Z remains canonical Z", failures) + + var round_trip_server = CoordinateMapperScript.canonical_to_server(canonical_position) + _expect_near(round_trip_server.x_yards, server_position.x_yards, ROUND_TRIP_TOLERANCE_YARDS, "server X round trip", failures) + _expect_near(round_trip_server.y_yards, server_position.y_yards, ROUND_TRIP_TOLERANCE_YARDS, "server Y round trip", failures) + _expect_near(round_trip_server.z_yards, server_position.z_yards, ROUND_TRIP_TOLERANCE_YARDS, "server Z round trip", failures) + + var adt_position = CoordinateMapperScript.canonical_to_adt_placement(canonical_position) + _expect_near(adt_position.x_offset_yards, 26220.000666666667, GOLDEN_POSITION_TOLERANCE_YARDS, "ADT placement X offset", failures) + _expect_near(adt_position.height_yards, 180.0, ROUND_TRIP_TOLERANCE_YARDS, "ADT placement height", failures) + _expect_near(adt_position.z_offset_yards, 16680.000666666667, GOLDEN_POSITION_TOLERANCE_YARDS, "ADT placement Z offset", failures) + var adt_round_trip = CoordinateMapperScript.adt_placement_to_canonical(adt_position) + _expect_canonical_position(adt_round_trip, canonical_position, ROUND_TRIP_TOLERANCE_YARDS, "ADT placement round trip", failures) + + var direct_adt_position = AdtPlacementPositionScript.new(26220.000666666667, 180.0, 16680.000666666667) + var direct_canonical = CoordinateMapperScript.adt_placement_to_canonical(direct_adt_position) + _expect_canonical_position(direct_canonical, canonical_position, GOLDEN_POSITION_TOLERANCE_YARDS, "direct ADT placement conversion", failures) + + +func _verify_tile_and_chunk_boundaries(failures: Array[String]) -> void: + var half_extent := CoordinateMapperScript.WOW_WORLD_HALF_EXTENT_YARDS + var tile_size := CoordinateMapperScript.ADT_TILE_SIZE_YARDS + + _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(half_extent, half_extent, 0.0)), 0, 0, true, "north-west extent", failures) + _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(0.0, 0.0, 0.0)), 32, 32, true, "map center", failures) + _expect_tile(CoordinateMapperScript.canonical_to_adt_tile(CanonicalWowWorldPositionScript.new(BOUNDARY_EPSILON_YARDS, BOUNDARY_EPSILON_YARDS, 0.0)), 31, 31, true, "north-west of center boundary", failures) + _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) + + var tile_coordinate = AdtTileCoordinateScript.new(49, 31) + 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) + _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 chunk_local_position = AdtTileLocalPositionScript.new( + CoordinateMapperScript.ADT_CHUNK_SIZE_YARDS * 2.0, + 180.0, + 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_true(chunk_coordinate.is_within_map_grid(), "chunk coordinate is inside map grid", 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: + var server_yaw = ServerWorldYawScript.new(float(yaw_radians)) + var canonical_yaw = CoordinateMapperScript.server_to_canonical_yaw(server_yaw) + var godot_yaw = CoordinateMapperScript.canonical_to_godot_yaw(canonical_yaw) + var canonical_round_trip = CoordinateMapperScript.godot_to_canonical_yaw(godot_yaw) + var server_round_trip = CoordinateMapperScript.canonical_to_server_yaw(canonical_round_trip) + var expected_normalized_yaw := fposmod(float(yaw_radians) + PI, TAU) - PI + _expect_near(canonical_yaw.radians, expected_normalized_yaw, ROUND_TRIP_TOLERANCE_YARDS, "canonical yaw normalization", failures) + _expect_near(godot_yaw.radians, expected_normalized_yaw, ROUND_TRIP_TOLERANCE_YARDS, "Godot yaw basis", failures) + _expect_near(server_round_trip.radians, expected_normalized_yaw, ROUND_TRIP_TOLERANCE_YARDS, "server yaw round trip", failures) + + var canonical_pi_yaw = CanonicalWowWorldYawScript.new(PI) + var normalized_godot_yaw = CoordinateMapperScript.canonical_to_godot_yaw(canonical_pi_yaw) + _expect_near(normalized_godot_yaw.radians, -PI, ROUND_TRIP_TOLERANCE_YARDS, "positive PI normalizes to negative PI", failures) + + +func _expect_tile(tile_coordinate, expected_x: int, expected_y: int, expected_valid: bool, label: String, failures: Array[String]) -> void: + _expect_equal(tile_coordinate.tile_x, expected_x, "%s tile X" % label, failures) + _expect_equal(tile_coordinate.tile_y, expected_y, "%s tile Y" % label, failures) + _expect_equal(tile_coordinate.is_within_map_grid(), expected_valid, "%s validity" % label, failures) + + +func _expect_canonical_position(actual_position, expected_position, tolerance: float, label: String, failures: Array[String]) -> void: + _expect_near(actual_position.x_yards, expected_position.x_yards, tolerance, "%s X" % label, failures) + _expect_near(actual_position.y_yards, expected_position.y_yards, tolerance, "%s Y" % label, failures) + _expect_near(actual_position.z_yards, expected_position.z_yards, tolerance, "%s Z" % label, failures) + + +func _expect_near(actual_value: float, expected_value: float, tolerance: float, label: String, failures: Array[String]) -> void: + if absf(actual_value - expected_value) > tolerance: + failures.append("%s expected %.9f, got %.9f" % [label, expected_value, actual_value]) + + +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) + + +func _load_json_object(path: String, failures: Array[String]) -> Dictionary: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("cannot open JSON fixture: %s" % path) + return {} + var parsed = JSON.parse_string(file.get_as_text()) + if not (parsed is Dictionary): + failures.append("JSON fixture is not an object: %s" % path) + return {} + return parsed diff --git a/src/tools/verify_coordinate_mapper.gd.uid b/src/tools/verify_coordinate_mapper.gd.uid new file mode 100644 index 0000000..8f486ba --- /dev/null +++ b/src/tools/verify_coordinate_mapper.gd.uid @@ -0,0 +1 @@ +uid://bmxjtf1fsrl2x