feat(M01): enforce coordinate conversion boundaries

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

Agent: sindo-main-codex
This commit is contained in:
2026-07-13 16:37:29 +04:00
parent c8e62acf4c
commit 47dec6c32c
13 changed files with 335 additions and 66 deletions
+14
View File
@@ -843,6 +843,20 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- Streaming radii, LOD decisions, cache formats and renderer quality profiles - Streaming radii, LOD decisions, cache formats and renderer quality profiles
are unchanged by this migration. 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 ## 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. If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it.
+19
View File
@@ -237,6 +237,23 @@ TODO/FIXME обязан содержать target/work-package ID или diagnos
- Медленный понятный reference implementation полезен как oracle для optimized implementation. - Медленный понятный 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 ## Review checklist
- Можно ли понять назначение сущности по имени? - Можно ли понять назначение сущности по имени?
@@ -245,6 +262,8 @@ TODO/FIXME обязан содержать target/work-package ID или diagnos
- Можно ли убрать abstraction без потери correctness/boundary? - Можно ли убрать abstraction без потери correctness/boundary?
- Есть ли скрытый mutable state или side effect? - Есть ли скрытый mutable state или side effect?
- Не появился ли второй источник истины? - Не появился ли второй источник истины?
- Все ли cross-space world coordinates проходят через `CoordinateMapper` или
документированную native boundary?
- Измерена ли сложная optimization? - Измерена ли сложная optimization?
- Изолирована и документирована ли неизбежная сложность? - Изолирована и документирована ли неизбежная сложность?
- Соответствуют ли API docs и diagrams фактическому коду? - Соответствуют ли API docs и diagrams фактическому коду?
+23 -4
View File
@@ -108,6 +108,21 @@ show `indexX` decreasing world Y (east) and `indexY` decreasing world X (south).
OpenRealm independently calculates filename X from world Y and filename Y from OpenRealm independently calculates filename X from world Y and filename Y from
world X. Contract version 2 corrects the formulas before any consumer migration. 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 ## Alternatives considered
### Make renderer coordinates canonical ### Make renderer coordinates canonical
@@ -138,10 +153,12 @@ grid ownership rules.
- Renderer consumers must explicitly map and then create Godot vectors at their - Renderer consumers must explicitly map and then create Godot vectors at their
own boundary. own boundary.
- ADT filename indices can no longer be confused with renderer X/Z tile names. - ADT filename indices can no longer be confused with renderer X/Z tile names.
- Existing native/parser and renderer conversions remain unchanged until - GDScript world/tile consumers use mapper APIs; renderer distance and LOD math
separate consumer migration packages merge after this contract. remains ordinary Godot-space geometry.
- Manual axis conversions become review/test violations once migration is - Native ADT/WDT loaders share one boundary formula and the M00 calibration
complete; this ADR alone does not delete compatibility paths. 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 - Persisted schemas are unchanged. Future serialized positions must include an
explicit coordinate-space/version discriminator. 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; - 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. 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. - 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 ## References
+17 -7
View File
@@ -5,9 +5,9 @@
| Field | Value | | Field | Value |
|---|---| |---|---|
| Status | Implemented | | 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 | | 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 | | Profiles/capabilities | All profiles; contract version 2 |
## Purpose ## 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 | | `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.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_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_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` | 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.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] Canonical -->|north-west edge offsets| Local[AdtTileLocalPosition]
Local -->|floor offset / chunk size| Chunk[AdtChunkCoordinate] Local -->|floor offset / chunk size| Chunk[AdtChunkCoordinate]
Canonical -->|half extent - Y; Z; half extent - X| Godot[GodotWorldPosition] 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] ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
CanonicalYaw -->|same numeric world angle| GodotYaw[GodotWorldYaw] 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`. - Unit/contract tests: `src/tools/verify_coordinate_mapper.gd`.
- Cross-source golden test: `src/tools/verify_coordinate_golden_fixtures.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 - 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 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)`; 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 | | 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 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 | | 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 | | 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 | | 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 | | Visual/server-spawn integration | Planned | M01 target criteria | Route the fixture through migrated adapters and renderer |
## Known gaps and risks ## 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 - Contract version 1 briefly transposed ADT filename/chunk axes; version 2 fixes
the defect before any consumer migration. the defect before any consumer migration.
- MDDF/MODF rotation comments and implementations disagree across existing paths; - 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/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/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_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 | | `docs/adr/0001-canonical-world-coordinates.md` | Normative axes, units, boundaries and rollout decision |
## Related decisions and references ## Related decisions and references
@@ -48,6 +48,36 @@ static func godot_to_canonical(godot_position: GodotWorldPosition) -> CanonicalW
) )
## Returns ADT filename indices directly from a Godot renderer position.
static func godot_to_adt_tile(godot_position: GodotWorldPosition) -> AdtTileCoordinate:
return AdtTileCoordinate.new(
int(floor(godot_position.x_units / ADT_TILE_SIZE_YARDS)),
int(floor(godot_position.z_units / ADT_TILE_SIZE_YARDS))
)
## Returns east/height/south offsets inside the owning ADT tile.
static func godot_to_adt_tile_local(godot_position: GodotWorldPosition) -> AdtTileLocalPosition:
var tile_coordinate := godot_to_adt_tile(godot_position)
return AdtTileLocalPosition.new(
godot_position.x_units - float(tile_coordinate.tile_x) * ADT_TILE_SIZE_YARDS,
godot_position.y_units,
godot_position.z_units - float(tile_coordinate.tile_y) * ADT_TILE_SIZE_YARDS
)
## Converts an ADT tile/local pair directly to the Godot renderer basis.
static func adt_tile_local_to_godot(
tile_coordinate: AdtTileCoordinate,
local_position: AdtTileLocalPosition
) -> GodotWorldPosition:
return GodotWorldPosition.new(
float(tile_coordinate.tile_x) * ADT_TILE_SIZE_YARDS + local_position.east_yards,
local_position.height_yards,
float(tile_coordinate.tile_y) * ADT_TILE_SIZE_YARDS + local_position.south_yards
)
## Converts canonical WoW coordinates to MDDF/MODF X/height/Z file fields. ## Converts canonical WoW coordinates to MDDF/MODF X/height/Z file fields.
static func canonical_to_adt_placement(canonical_position: CanonicalWowWorldPosition) -> AdtPlacementPosition: static func canonical_to_adt_placement(canonical_position: CanonicalWowWorldPosition) -> AdtPlacementPosition:
return AdtPlacementPosition.new( return AdtPlacementPosition.new(
@@ -3,9 +3,12 @@ extends CharacterBody3D
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd") 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 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 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 := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const CHUNK_SIZE := TILE_SIZE / 16.0
const UNIT_SIZE := CHUNK_SIZE / 8.0 const UNIT_SIZE := CHUNK_SIZE / 8.0
@export var extracted_dir: String = "res://data/extracted" @export var extracted_dir: String = "res://data/extracted"
@@ -62,8 +65,11 @@ func _ready() -> void:
_camera.current = true _camera.current = true
_camera.far = 50000.0 _camera.far = 50000.0
if spawn_at_tile_center: if spawn_at_tile_center:
global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y)
global_position.z = (float(spawn_tile_y) + 0.5) * TILE_SIZE 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() _load_character_visual()
var ground := _sample_ground_height(global_position) var ground := _sample_ground_height(global_position)
if is_finite(ground): if is_finite(ground):
@@ -324,16 +330,17 @@ func _apply_camera_transform() -> void:
func _sample_ground_height(world_pos: Vector3) -> float: func _sample_ground_height(world_pos: Vector3) -> float:
var tx := int(floor(world_pos.x / TILE_SIZE)) var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
var ty := int(floor(world_pos.z / TILE_SIZE)) var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
var data := _load_adt(tx, ty) 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(): if data.is_empty():
return NAN return NAN
var tile_origin := _get_tile_origin(data) var tile_origin := _get_tile_origin(data)
var local := world_pos - tile_origin var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
var chunk_x := clampi(int(floor(local.x / CHUNK_SIZE)), 0, 15) var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
var chunk_y := clampi(int(floor(local.z / CHUNK_SIZE)), 0, 15)
var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin) var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin)
if chunk.is_empty(): if chunk.is_empty():
return NAN return NAN
+15 -13
View File
@@ -2,9 +2,9 @@
extends Node extends Node
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") 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 LIGHT_COORD_SCALE := 36.0
const HALF_MINUTES_PER_DAY := 2880 const HALF_MINUTES_PER_DAY := 2880
@@ -701,28 +701,30 @@ func _get_target_wow_position() -> Vector3:
var world_pos := Vector3.ZERO var world_pos := Vector3.ZERO
if _target: if _target:
world_pos = _target.global_position world_pos = _target.global_position
return Vector3( var canonical_position = COORDINATE_MAPPER_SCRIPT.godot_to_canonical(_typed_godot_position(world_pos))
WOW_WORLD_CENTER - world_pos.z, return Vector3(canonical_position.x_yards, canonical_position.y_yards, canonical_position.z_yards)
WOW_WORLD_CENTER - world_pos.x,
world_pos.y)
func _get_target_area_id() -> int: func _get_target_area_id() -> int:
if not _target or map_name.is_empty(): if not _target or map_name.is_empty():
return 0 return 0
var world_pos := _target.global_position var world_pos := _target.global_position
var tile_x := int(floor(world_pos.x / TILE_SIZE)) var typed_world_position = _typed_godot_position(world_pos)
var tile_y := int(floor(world_pos.z / TILE_SIZE)) var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
var local_x := world_pos.x - float(tile_x) * TILE_SIZE var local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
var local_y := world_pos.z - float(tile_y) * TILE_SIZE var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, local_position)
var chunk_x := clampi(int(floor(local_x / (TILE_SIZE / 16.0))), 0, 15) var areas := _load_adt_area_grid(tile_coordinate.tile_x, tile_coordinate.tile_y)
var chunk_y := clampi(int(floor(local_y / (TILE_SIZE / 16.0))), 0, 15)
var areas := _load_adt_area_grid(tile_x, tile_y)
if areas.is_empty(): if areas.is_empty():
return 0 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]) 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: func _load_adt_area_grid(tile_x: int, tile_y: int) -> PackedInt32Array:
var key := "%d_%d" % [tile_x, tile_y] var key := "%d_%d" % [tile_x, tile_y]
if _adt_area_cache.has(key): if _adt_area_cache.has(key):
+36 -25
View File
@@ -14,14 +14,17 @@ const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/l
const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") const 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 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 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_BAKED_TILE_FORMAT_VERSION := 5
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
const M2_MATERIAL_REFRESH_VERSION := 2 const M2_MATERIAL_REFRESH_VERSION := 2
const WMO_MATERIAL_REFRESH_VERSION := 10 const WMO_MATERIAL_REFRESH_VERSION := 10
const TILE_SIZE := 533.33333 const TILE_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS
const CHUNK_SIZE := TILE_SIZE / 16.0 const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI
const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5 const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
const QUALITY_CUSTOM := "Custom" const QUALITY_CUSTOM := "Custom"
@@ -786,10 +789,9 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar
var parts: PackedStringArray = key.split("_") var parts: PackedStringArray = key.split("_")
var tx: int = int(parts[0]) var tx: int = int(parts[0])
var ty: int = int(parts[1]) var ty: int = int(parts[1])
var tcx: float = (tx + 0.5) * TILE_SIZE var tile_center := _tile_center_to_world(tx, ty)
var tcz: float = (ty + 0.5) * TILE_SIZE var dx: float = tile_center.x - focus_pos.x
var dx: float = tcx - focus_pos.x var dz: float = tile_center.z - focus_pos.z
var dz: float = tcz - focus_pos.z
_tile_load_queue.append({ _tile_load_queue.append({
"key": key, "key": key,
"tx": tx, "tx": tx,
@@ -2909,8 +2911,10 @@ func _predictive_focus_tiles(focus_pos: Vector3) -> Array[Vector2i]:
if threshold <= 0.0: if threshold <= 0.0:
return result return result
var local_x := fposmod(focus_pos.x, TILE_SIZE) / TILE_SIZE var typed_focus_position = GODOT_WORLD_POSITION_SCRIPT.new(focus_pos.x, focus_pos.y, focus_pos.z)
var local_z := fposmod(focus_pos.z, TILE_SIZE) / TILE_SIZE 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 x_offsets: Array[int] = [0]
var z_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: func _tile_dist_sq(state: Dictionary, cam_pos: Vector3) -> float:
var min_x: float = float(state["tx"]) * TILE_SIZE var tile_coordinate = ADT_TILE_COORDINATE_SCRIPT.new(int(state["tx"]), int(state["ty"]))
var max_x: float = min_x + TILE_SIZE var north_west_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(
var min_z: float = float(state["ty"]) * TILE_SIZE tile_coordinate,
var max_z: float = min_z + TILE_SIZE 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 var dx := 0.0
if cam_pos.x < min_x: if cam_pos.x < min_x:
@@ -5397,9 +5408,9 @@ func _position_camera_over_world() -> void:
if camera == null: if camera == null:
return return
var center_tx: float = (_tile_min.x + _tile_max.x) * 0.5 var minimum_tile_center := _tile_center_to_world(_tile_min.x, _tile_min.y)
var center_ty: float = (_tile_min.y + _tile_max.y) * 0.5 var maximum_tile_center := _tile_center_to_world(_tile_max.x, _tile_max.y)
var center := _tile_center_to_world(center_tx, center_ty) 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 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) 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 _camera_initialized = true
## Tile (tx, ty) NW corner sits at Godot (tx*TILE_SIZE, 0, ty*TILE_SIZE). func _tile_center_to_world(tile_x: int, tile_y: int) -> Vector3:
## This matches wow_to_godot output: chunk origins ≈ (TX*TILE_SIZE, h, TY*TILE_SIZE). var tile_coordinate = ADT_TILE_COORDINATE_SCRIPT.new(tile_x, tile_y)
func _tile_center_to_world(tile_x: float, tile_y: float) -> Vector3: var half_tile_size := TILE_SIZE * 0.5
return Vector3( var tile_center_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(
(tile_x + 0.5) * TILE_SIZE, tile_coordinate,
0.0, ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, 0.0, half_tile_size))
(tile_y + 0.5) * 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: func _world_to_tile(world_pos: Vector3) -> Vector2i:
return Vector2i( var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
int(floor(world_pos.x / TILE_SIZE)), var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
int(floor(world_pos.z / TILE_SIZE))) return Vector2i(tile_coordinate.tile_x, tile_coordinate.tile_y)
func _tile_key(tx: int, ty: int) -> String: func _tile_key(tx: int, ty: int) -> String:
+10 -7
View File
@@ -5,7 +5,8 @@ extends SceneTree
## [--wait 3.0] [--output user://render_terrain_height/report.json] ## [--wait 3.0] [--output user://render_terrain_height/report.json]
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.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 const RAY_HEIGHT := 5000.0
@@ -90,9 +91,7 @@ func _run_async() -> void:
func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary: func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary:
var tile_coordinate := Vector2i( var tile_coordinate := _adt_tile_vector2i(world_position)
int(floor(world_position.x / TILE_SIZE)),
int(floor(world_position.z / TILE_SIZE)))
var tile_states: Dictionary = world.get("_tile_states") var tile_states: Dictionary = world.get("_tile_states")
var highest_terrain_height := -INF var highest_terrain_height := -INF
var intersected_tile_key := "" 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 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)]: 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_position := world_position + Vector3(offset.x, 0.0, offset.y)
var sample_tile := Vector2i( var sample_tile := _adt_tile_vector2i(sample_position)
int(floor(sample_position.x / TILE_SIZE)),
int(floor(sample_position.z / TILE_SIZE)))
var sample_key := "%d_%d" % [sample_tile.x, sample_tile.y] var sample_key := "%d_%d" % [sample_tile.x, sample_tile.y]
if not tile_states.has(sample_key): if not tile_states.has(sample_key):
continue continue
@@ -214,6 +211,12 @@ func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictiona
return {"nearest_sample_distance": null} 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]: func _vector3_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z] return [value.x, value.y, value.z]
@@ -0,0 +1,140 @@
extends SceneTree
## Repository-wide M01 gate against manual cross-space world coordinate formulas.
## Model-local M2/WMO basis transforms and ordinary Godot-space geometry are not
## world-space conversions and are intentionally outside these signatures.
const SCAN_ROOTS: Array[String] = ["res://src", "res://addons"]
const SOURCE_EXTENSIONS: Array[String] = ["gd", "cpp", "h", "hpp"]
const EXCLUDED_DIRECTORY_PREFIXES: Array[String] = ["res://src/native/build/"]
const GATE_PATH := "res://src/tools/verify_coordinate_conversion_boundaries.gd"
const COORDINATE_MAPPER_PATH := "res://src/domain/coordinates/coordinate_mapper.gd"
const CALIBRATION_ORACLE_PATH := "res://src/tools/verify_render_coordinate_calibration.gd"
const NATIVE_BOUNDARY_PATH := "res://src/native/src/wow_chunk_reader.h"
const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
NATIVE_BOUNDARY_PATH,
"res://src/native/src/adt_loader.cpp",
"res://src/native/src/wdt_loader.cpp",
CALIBRATION_ORACLE_PATH,
]
const REQUIRED_MAPPER_CALLS := {
"res://src/scenes/sky/wow_sky_controller.gd": ["godot_to_canonical", "godot_to_adt_tile", "godot_to_adt_tile_local"],
"res://src/scenes/streaming/streaming_world_loader.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_godot"],
"res://src/scenes/player/third_person_wow_controller.gd": ["godot_to_adt_tile", "adt_tile_local_to_godot"],
"res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"],
}
func _initialize() -> void:
var failures: Array[String] = []
var source_paths: Array[String] = []
for scan_root in SCAN_ROOTS:
_collect_source_paths(scan_root, source_paths, failures)
var native_boundary_definition_count := 0
var independent_oracle_definition_count := 0
for source_path in source_paths:
if source_path == GATE_PATH:
continue
var source := _read_text(source_path, failures)
var source_lines := source.split("\n")
for line_index in range(source_lines.size()):
var source_line := String(source_lines[line_index])
var violation := _coordinate_violation(source_path, source_line)
if not violation.is_empty():
failures.append("%s:%d %s" % [source_path, line_index + 1, violation])
if source_path == NATIVE_BOUNDARY_PATH and source_line.contains("inline void wow_to_godot"):
native_boundary_definition_count += 1
if source_path == CALIBRATION_ORACLE_PATH and source_line.contains("func _wow_to_godot"):
independent_oracle_definition_count += 1
_expect_equal(native_boundary_definition_count, 1, "native world conversion definition count", failures)
_expect_equal(independent_oracle_definition_count, 1, "independent calibration oracle count", failures)
_verify_required_mapper_calls(failures)
_verify_classifier_guards(failures)
if not failures.is_empty():
for failure in failures:
push_error("COORDINATE_CONVERSION_BOUNDARIES: %s" % failure)
quit(1)
return
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=4 classifier_cases=6" % source_paths.size())
quit(0)
func _coordinate_violation(source_path: String, source_line: String) -> String:
var stripped_line := source_line.strip_edges()
if stripped_line.is_empty():
return ""
if (source_line.contains("wow_to_godot") or source_line.contains("godot_to_wow")) and source_path not in ALLOWED_LEGACY_NAME_PATHS:
return "manual WoW/Godot conversion name outside mapper boundary"
if (source_line.contains("17066.666") or source_line.contains("WOW_WORLD_CENTER")) and source_path not in [NATIVE_BOUNDARY_PATH, CALIBRATION_ORACLE_PATH]:
return "manual WoW world-center formula outside native boundary/oracle"
if source_line.contains("1600.0 / 3.0") and source_path != COORDINATE_MAPPER_PATH:
return "duplicated ADT tile-size formula outside CoordinateMapper"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("floor(") and source_line.contains("TILE_SIZE") and (source_line.contains(".x") or source_line.contains(".z")):
return "manual Godot world-position to ADT tile formula"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("fposmod(") and source_line.contains("TILE_SIZE"):
return "manual Godot world-position to ADT tile-local formula"
if source_path != COORDINATE_MAPPER_PATH and source_line.contains("+ 0.5) * TILE_SIZE"):
return "manual ADT tile-center to Godot world-position formula"
return ""
func _verify_required_mapper_calls(failures: Array[String]) -> void:
for source_path in REQUIRED_MAPPER_CALLS:
var source := _read_text(source_path, failures)
for required_call in REQUIRED_MAPPER_CALLS[source_path]:
if not source.contains(String(required_call)):
failures.append("%s must call CoordinateMapper.%s" % [source_path, required_call])
func _verify_classifier_guards(failures: Array[String]) -> void:
_expect_true(not _coordinate_violation("res://src/example.gd", "const CENTER := 17066.666").is_empty(), "world-center classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "func wow_to_godot():").is_empty(), "conversion-name classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "floor(world_position.x / TILE_SIZE)").is_empty(), "world-to-tile classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "fposmod(world_position.z, TILE_SIZE)").is_empty(), "world-to-tile-local classifier", failures)
_expect_true(not _coordinate_violation("res://src/example.gd", "(tile_x + 0.5) * TILE_SIZE").is_empty(), "tile-center classifier", failures)
_expect_true(_coordinate_violation(NATIVE_BOUNDARY_PATH, "inline void wow_to_godot(float wx) { gx = -(wx - 17066.666f); }").is_empty(), "native boundary exception", failures)
func _collect_source_paths(directory_path: String, source_paths: Array[String], failures: Array[String]) -> void:
var directory := DirAccess.open(directory_path)
if directory == null:
failures.append("cannot scan directory %s" % directory_path)
return
for entry_name in directory.get_files():
var source_path := directory_path.path_join(entry_name)
if entry_name.get_extension().to_lower() in SOURCE_EXTENSIONS:
source_paths.append(source_path)
for child_directory_name in directory.get_directories():
var child_directory_path := directory_path.path_join(child_directory_name) + "/"
if _has_excluded_prefix(child_directory_path):
continue
_collect_source_paths(child_directory_path.trim_suffix("/"), source_paths, failures)
func _has_excluded_prefix(path: String) -> bool:
for excluded_prefix in EXCLUDED_DIRECTORY_PREFIXES:
if path.begins_with(excluded_prefix):
return true
return false
func _read_text(path: String, failures: Array[String]) -> String:
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
failures.append("cannot open %s" % path)
return ""
return file.get_as_text()
func _expect_equal(actual_value, expected_value, label: String, failures: Array[String]) -> void:
if actual_value != expected_value:
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
if not actual_value:
failures.append("%s expected true" % label)
@@ -0,0 +1 @@
uid://bnj844sd2n6ln
+12
View File
@@ -119,6 +119,18 @@ func _verify_tile_and_chunk_boundaries(failures: Array[String]) -> void:
_expect_true(local_position.east_yards >= 0.0 and local_position.east_yards < tile_size, "tile-local east offset is half-open", failures) _expect_true(local_position.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) _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( var chunk_local_position = AdtTileLocalPositionScript.new(
CoordinateMapperScript.ADT_CHUNK_SIZE_YARDS * 2.0, CoordinateMapperScript.ADT_CHUNK_SIZE_YARDS * 2.0,
180.0, 180.0,
+1
View File
@@ -57,6 +57,7 @@ try {
Invoke-GodotStep "runtime-cache-shutdown" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_runtime_cache_shutdown.gd") Invoke-GodotStep "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 "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-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 = @( $captureArgs = @(
"--path", ".", "--path", ".",