Compare commits

...

3 Commits

Author SHA1 Message Date
sindoring f5bb64e6c7 Merge pull request 'gmp(M02): extract player terrain query' (#23) from work/sindo-main-codex/m02-terrain-query into master
Reviewed-on: #23
2026-07-14 22:17:12 +03:00
sindoring 0ce3cae208 docs(M02): publish terrain query handoff
Work-Package: M02-GMP-TERRAIN-QUERY-001
Agent: sindo-main-codex
Tests: coordination and documentation gates; diff check
Fidelity: documents inherited height/snap regression and remaining collision evidence gap
2026-07-14 23:11:23 +04:00
sindoring a45d521567 gmp(M02): extract player terrain query
Work-Package: M02-GMP-TERRAIN-QUERY-001
Agent: sindo-main-codex
Tests: terrain query, player input and movement regressions; asset-free scene; renderer dry-run; coordinate, streaming, documentation and coordination gates
Fidelity: preserves existing ADT outer-grid interpolation and player ground snap; build-12340 collision parity remains unverified
2026-07-14 23:10:55 +04:00
14 changed files with 846 additions and 113 deletions
@@ -0,0 +1,118 @@
# M02-GMP-TERRAIN-QUERY-001 — Player terrain query boundary
<!-- OPENWC_CLAIM:M02-GMP-TERRAIN-QUERY-001:sindo-main-codex:2026-07-16 -->
<!-- OPENWC_HANDOFF:READY:M02-GMP-TERRAIN-QUERY-001:a45d521 -->
## Ownership
- Target: M02
- Program: GMP/RND
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m02-terrain-query`
- Lease expires UTC: 2026-07-16
- Integrator: M02 milestone integrator
## Outcome
Introduce a typed `TerrainQuery` boundary and move ADT loading, caching, chunk
selection and height interpolation out of `ThirdPersonWowController` without
changing current spawn/ground-snap behavior.
## Non-goals
- Change ADT topology, coordinate formulas, terrain mesh or renderer caches.
- Add physics collision, slopes, water, jump/fall/swim or server movement.
- Reuse the streaming renderer cache before its public terrain-query contract exists.
- Extract camera or character presentation.
- Mark M02 complete or edit its checklist/Evidence.
## Paths
- Exclusive: `src/gameplay/terrain/`, `src/tools/verify_terrain_query.gd`,
`docs/modules/terrain-query.md`, this claim
- Shared/hotspots: `src/scenes/player/third_person_wow_controller.gd`,
`src/tools/verify_player_input.gd`,
`src/tools/verify_coordinate_conversion_boundaries.gd`,
`docs/modules/local-player-movement.md`,
`docs/modules/README.md`
- Generated/ignored: local `.godot`, native DLL, generated ADT resources and renderer corpus
## Contracts and data
- Public API: `TerrainQuery.sample_ground_height(GodotWorldPosition)` returning
immutable `TerrainGroundSample`
- Production adapter: `AdtTerrainQuery` with owned per-tile parse cache
- Test/data-source seam: optional ADT tile loader callable at construction
- Coordinate contract version, ADT format, renderer cache and persistence: unchanged
- Consumers: current player scene; future movement/collision policies
## Dependencies
- Requires: merged movement controller on master `5206c42`
- Blocks: camera/presentation extraction from the player hotspot
- External state: production uses existing native `ADTLoader`; synthetic tests need no proprietary data
## Verification
- Commands: synthetic height/cache/failure verifier, player input/movement
regressions, asset-free scene, coordinate/StreamingFocus gates, renderer dry-run
and repository gates
- Fixtures: synthetic 145-height chunk payload and injected flat terrain query
- Fidelity evidence: preserves existing outer-grid bilinear interpolation,
spawn ground offset and per-tick snap formula only
- Performance budget: one cached tile lookup and local interpolation per ground query;
no new I/O after first tile load
## Documentation deliverables
- Inline public API docs for query, sample and ADT adapter
- Terrain-query module spec with inputs/outputs, data-flow, cache lifecycle and sequence diagrams
- Updated movement/coordinate consumer boundaries, source maps and module registry
## Simplicity and naming
- Important names: `TerrainQuery`, `TerrainGroundSample`, `AdtTerrainQuery`
- Simplest approach: one narrow query contract, one typed result and one existing-format adapter
- Rejected complexity: general collision framework, terrain service locator and renderer-cache coupling
- Unavoidable complexity: ADT 145-height layout and chunk lookup stay isolated in adapter
- Measured optimization evidence: existing per-tile cache retained; no new optimization
## Status
- State: ready-for-review
- Done: typed query/sample contract, cached ADT adapter, player migration,
synthetic interpolation/cache/failure tests, injected scene regression and documentation
- Next: M02 integrator reviews and merges before camera-rig extraction
- Blocked by:
## Handoff
- Commit: `a45d521`
- Branch: `work/sindo-main-codex/m02-terrain-query`
- Outcome: `ThirdPersonWowController` no longer loads ADTs, caches tile
dictionaries, selects chunks or interpolates height grids. It consumes a
replaceable typed `TerrainQuery` and retains only existing offset/snap policy.
- Public contracts: additive immutable `TerrainGroundSample`, replaceable
`TerrainQuery`, cached `AdtTerrainQuery` and player `set_terrain_query`.
Coordinate contract version, ADT/native format and renderer caches are unchanged.
- Verification: `TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1
failures=2 player_injection=1`; player-input and local-movement regressions
passed; asset-free scene exited `0`; coordinate boundary passed with five
required consumers and StreamingFocus passed; renderer dry-run passed eight
pre-capture gates and seven checkpoint plans; coordination/documentation/diff gates passed.
- Fidelity: synthetic fractional-grid fixture locks the inherited 145-height
outer-grid bilinear result within `0.002` Godot units; injected scene locks
spawn offset `0.05` and physics snap. This is sandbox-regression evidence,
not build-12340 triangle/hole/collision parity.
- Local verification inputs: isolated worktree reused the existing ignored
native DLL and generated ADT resource scripts. Proprietary extracted data and
character GLB were absent, so renderer dry-run logged expected missing-corpus
diagnostics while all contract steps returned success.
- Rebuild/migration: none; caches are transient and existing formats remain unchanged.
- Remaining risks: first ADT parse remains synchronous as before; unavailable
tiles cache until query replacement; player still owns snap policy; exact
ADT triangle choice, holes, slopes, liquids and collision remain open.
- Documentation: inline public APIs plus `docs/modules/terrain-query.md` with
inputs/outputs, data-flow, cache state and cross-boundary sequence diagrams,
ownership, failure/recovery, capability status and source map. Coordinate and
movement consumer specifications were updated.
+1
View File
@@ -11,6 +11,7 @@
| Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) | | Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) |
| Player input | Partial | [`player-input.md`](player-input.md) | | Player input | Partial | [`player-input.md`](player-input.md) |
| Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) | | Local player movement | Implemented | [`local-player-movement.md`](local-player-movement.md) |
| Terrain query | Implemented | [`terrain-query.md`](terrain-query.md) |
| Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.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) | | 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) | | Gameplay | Prototype/Planned | [`../../targets/roadmap/04-gameplay-systems.md`](../../targets/roadmap/04-gameplay-systems.md) |
+10 -4
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`, `M01-FND-COORD-BOUNDARY-GATE-001`, `M01-QAR-SERVER-SPAWN-RENDERER-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`, `M01-QAR-SERVER-SPAWN-RENDERER-001`, `M02-GMP-TERRAIN-QUERY-001` consumer migration |
| Owners | Foundation/domain | | Owners | Foundation/domain |
| Last verified | Worktree `work/sindo-main-codex/m01-server-spawn-renderer`, 2026-07-13 | | Last verified | `a45d521`, 2026-07-14 |
| Profiles/capabilities | All profiles; contract version 2 | | Profiles/capabilities | All profiles; contract version 2 |
## Purpose ## Purpose
@@ -86,7 +86,7 @@ Forbidden dependencies:
| Input | `AdtPlacementPosition` | Future ADT 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 | | 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 | `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 | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content/terrain-query adapters | New immutable values | Caller-owned references |
| Output | `GodotWorldPosition`, `GodotWorldYaw` | `CoordinateMapper` | Future render/scene adapter | New immutable values | Caller-owned references | | Output | `GodotWorldPosition`, `GodotWorldYaw` | `CoordinateMapper` | Future render/scene adapter | New immutable values | Caller-owned references |
| Test input | `coordinate_golden_points.json` schema 1 | Pinned public server row plus private numeric observations | Headless fixture verifier | Versioned repository fixture | Test-process lifetime | | Test input | `coordinate_golden_points.json` schema 1 | Pinned public server row plus private numeric observations | Headless fixture verifier | Versioned repository fixture | Test-process lifetime |
@@ -115,6 +115,10 @@ flowchart LR
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| Tile
Godot -->|typed direct adapter| Local Godot -->|typed direct adapter| Local
Godot --> TerrainQuery[AdtTerrainQuery consumer]
TerrainQuery --> Tile
TerrainQuery --> Local
TerrainQuery --> Chunk
Tile -->|tile plus local| Godot 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]
@@ -185,7 +189,8 @@ raw three-number arrays are not an accepted new persistence contract.
- Boundary enforcement: `src/tools/verify_coordinate_conversion_boundaries.gd`. - Boundary enforcement: `src/tools/verify_coordinate_conversion_boundaries.gd`.
- Server-spawn renderer contract: `src/tools/verify_server_spawn_renderer.gd` - Server-spawn renderer contract: `src/tools/verify_server_spawn_renderer.gd`
validates the dedicated manifest against the same pinned fixture before GUI capture. validates the dedicated manifest against the same pinned fixture before GUI capture.
- Integration/E2E: sky, player, streamer and terrain probe use mapper boundaries; - Integration/E2E: sky, player spawn, streamer, terrain probe and the extracted
`AdtTerrainQuery` use mapper boundaries;
`StreamingFocus` provides typed renderer focus, and the pinned AzerothCore `StreamingFocus` provides typed renderer focus, and the pinned AzerothCore
Human Warrior spawn is captured in the Eastern Kingdoms renderer at ADT Human Warrior spawn is captured in the Eastern Kingdoms renderer at ADT
`(32,48)`, chunk `(3,12)`. `(32,48)`, chunk `(3,12)`.
@@ -243,6 +248,7 @@ raw three-number arrays are not an accepted new persistence contract.
| `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 | | `src/tools/verify_coordinate_conversion_boundaries.gd` | Repository-wide manual conversion and required-consumer gate |
| `src/gameplay/terrain/adt_terrain_query.gd` | Typed Godot-position consumer for ADT tile/local/chunk lookup |
| `src/tools/server_spawn_render_manifest.json` | Pinned server-spawn camera, player, marker and expected ADT ownership | | `src/tools/server_spawn_render_manifest.json` | Pinned server-spawn camera, player, marker and expected ADT ownership |
| `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer manifest contract verifier | | `src/tools/verify_server_spawn_renderer.gd` | Fixture-to-renderer manifest contract verifier |
| `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 |
+8 -5
View File
@@ -7,7 +7,7 @@
| Status | Implemented | | Status | Implemented |
| Target/work package | M02 / M02-GMP-MOVEMENT-001 | | Target/work package | M02 / M02-GMP-MOVEMENT-001 |
| Owners | Gameplay local movement state; scene composition owns the instance | | Owners | Gameplay local movement state; scene composition owns the instance |
| Last verified | `435e1c9`, 2026-07-14 | | Last verified | `a45d521`, 2026-07-14 |
| Profiles/capabilities | Current render sandbox; production/debug profile gate pending | | Profiles/capabilities | Current render sandbox; production/debug profile gate pending |
## Purpose ## Purpose
@@ -37,7 +37,7 @@ flowchart LR
Scene[ThirdPersonWowController] --> Basis Scene[ThirdPersonWowController] --> Basis
Displacement --> Scene Displacement --> Scene
Scene --> Transform[Player world transform] Scene --> Transform[Player world transform]
Scene --> Terrain[Ground snap adapter] Scene --> Terrain[TerrainQuery and ground snap adapter]
Velocity --> Presentation[Visual facing and animation adapter] Velocity --> Presentation[Visual facing and animation adapter]
``` ```
@@ -153,7 +153,7 @@ sequenceDiagram
| Negative physics delta | `advance` guard | Returns zero displacement; velocity unchanged | Unit regression | Resume with valid non-negative delta | | Negative physics delta | `advance` guard | Returns zero displacement; velocity unchanged | Unit regression | Resume with valid non-negative delta |
| Missing scene basis owner | Scene composition defect | Controller cannot be called correctly | Scene/parser error or boundary test | Restore explicit scene dependency | | Missing scene basis owner | Scene composition defect | Controller cannot be called correctly | Scene/parser error or boundary test | Restore explicit scene dependency |
| Flight toggle during motion | Command path | Velocity clears before next step | State-transition regression | Continue from zero velocity | | Flight toggle during motion | Command path | Velocity clears before next step | State-transition regression | Continue from zero velocity |
| Missing terrain data | Outside module | Scene skips existing ground snap | Existing terrain diagnostics | TerrainQuery extraction will own policy | | Missing terrain data | `TerrainGroundSample.is_available == false` | Scene skips existing ground snap | Stable terrain failure code | Replace/recover query backend |
There is no asynchronous cancellation, retry or rollback. Recreating the There is no asynchronous cancellation, retry or rollback. Recreating the
controller resets velocity and flight state deterministically. controller resets velocity and flight state deterministically.
@@ -211,7 +211,8 @@ separate versioned movement snapshot contract.
| Scene-free local velocity state | Implemented | Pure controller and source boundary regression | Integrate target checklist after review | | Scene-free local velocity state | Implemented | Pure controller and source boundary regression | Integrate target checklist after review |
| Existing planar acceleration/speeds | Implemented | Exact numeric unit cases and real scene regression | Compare with build 12340 before fidelity claim | | Existing planar acceleration/speeds | Implemented | Exact numeric unit cases and real scene regression | Compare with build 12340 before fidelity claim |
| Existing camera-relative free flight | Implemented | Pitched-basis and toggle/reset cases | Restrict to sandbox profile | | Existing camera-relative free flight | Implemented | Pitched-basis and toggle/reset cases | Restrict to sandbox profile |
| Terrain/collision movement policy | Planned | Outside module by design | Extract `TerrainQuery` next | | Terrain height query | Implemented | Typed `TerrainQuery` and injected player regression | Ground-snap policy remains scene-owned |
| Terrain collision movement policy | Planned | Height-only query does not model collision | Add slopes/holes/collision later |
| Jump/fall/swim | Planned | M02/M09 roadmap | Requires terrain/liquid and server contracts | | Jump/fall/swim | Planned | M02/M09 roadmap | Requires terrain/liquid and server contracts |
| Prediction/reconciliation | Planned | M08/M09 roadmap | Requires movement snapshot/network contract | | Prediction/reconciliation | Planned | M08/M09 roadmap | Requires movement snapshot/network contract |
@@ -220,7 +221,8 @@ separate versioned movement snapshot contract.
- Numeric defaults preserve the existing sandbox but are not original-client evidence. - Numeric defaults preserve the existing sandbox but are not original-client evidence.
- Configuration is captured at scene `_ready`; runtime mutation of exported speed - Configuration is captured at scene `_ready`; runtime mutation of exported speed
properties does not reconfigure an existing controller. properties does not reconfigure an existing controller.
- Ground snap remains in the player scene until `TerrainQuery` extraction. - Ground snap remains in the player scene, but its ADT data access is now behind
a replaceable `TerrainQuery`.
- Sprint and free flight still lack a typed sandbox capability gate. - Sprint and free flight still lack a typed sandbox capability gate.
- The module uses Godot math value types; a future engine-independent gameplay - The module uses Godot math value types; a future engine-independent gameplay
predictor may require scalar/domain movement contracts. predictor may require scalar/domain movement contracts.
@@ -230,6 +232,7 @@ separate versioned movement snapshot contract.
| Path | Responsibility | | Path | Responsibility |
|---|---| |---|---|
| `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration | | `src/gameplay/movement/local_player_movement_controller.gd` | Owned velocity/flight state and deterministic integration |
| `src/gameplay/terrain/terrain_query.gd` | Replaceable ground-height input boundary |
| `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain/presentation adapters | | `src/scenes/player/third_person_wow_controller.gd` | Composition, basis selection, displacement/terrain/presentation adapters |
| `src/tools/verify_local_player_movement.gd` | Pure numeric, state-transition and dependency regression | | `src/tools/verify_local_player_movement.gd` | Pure numeric, state-transition and dependency regression |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture | | `src/tests/scenes/player_input_regression.tscn` | Asset-free integrated player fixture |
+251
View File
@@ -0,0 +1,251 @@
# Terrain Query
## Metadata
| Field | Value |
|---|---|
| Status | Implemented |
| Target/work package | M02 / M02-GMP-TERRAIN-QUERY-001 |
| Owners | Gameplay terrain-query contract; ADT adapter owns parsed tile cache |
| Last verified | `a45d521`, 2026-07-14 |
| Profiles/capabilities | Current sandbox ADT ground height; renderer/physics backend planned |
## Purpose
Provide a replaceable, scene-free ground-height query for player movement.
Isolate native ADT loading, per-tile caching, chunk selection and legacy outer
height-grid interpolation from the player scene behind a typed
`GodotWorldPosition → TerrainGroundSample` contract.
## Non-goals
- Own player transforms, movement velocity or ground-snap policy.
- Provide collision normals, slopes, holes, liquids, WMO/M2 collision or ray queries.
- Change ADT topology, coordinate conversion, renderer caches or native parser format.
- Claim original-client terrain-contact parity from height regression alone.
- Couple gameplay to the monolithic streaming renderer before a public backend exists.
## Context and boundaries
```mermaid
flowchart LR
Scene[ThirdPersonWowController adapter] --> Position[GodotWorldPosition]
Position --> Contract[TerrainQuery]
Contract --> Adapter[AdtTerrainQuery]
Adapter --> Mapper[CoordinateMapper]
Adapter --> Native[ADTLoader]
Native --> Tile[ADT tile Dictionary]
Tile --> Cache[Owned tile cache]
Cache --> Sample[TerrainGroundSample]
Sample --> Scene
Scene --> Snap[Existing ground-snap policy]
```
Allowed dependencies:
- Typed `GodotWorldPosition` and `CoordinateMapper` contracts.
- Existing native `ADTLoader` at the ADT adapter boundary.
- Dynamic dictionaries only inside the native-format adapter.
- Player scene composition may inject any `TerrainQuery` implementation.
Forbidden dependencies:
- `TerrainQuery` contract depending on Nodes, cameras, input or renderer state.
- Player scene loading/parsing ADT data or owning a parsed tile cache.
- Manual WoW/Godot/ADT world-coordinate conversion outside `CoordinateMapper`.
- Terrain query mutating transforms, gameplay movement state or visual meshes.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `TerrainGroundSample.available(height_units)` | Immutable value factory | Creates a finite Godot-world ground height | Any thread; caller-held value | Non-finite height becomes unavailable |
| `TerrainGroundSample.unavailable(failure_code)` | Immutable value factory | Creates explicit unavailable result | Any thread; caller-held value | Empty code normalizes to `terrain_unavailable` |
| `TerrainQuery.sample_ground_height(position)` | Replaceable boundary | Samples ground at typed Godot world position | Implementation-defined; current adapter main-thread owned | Base reports `terrain_query_not_implemented` |
| `AdtTerrainQuery.new(extracted_directory, map_directory_name, loader_override)` | Adapter constructor | Configures one map and optional test/data-source loader | Query lifetime | Production validates native loader and file presence lazily |
| `AdtTerrainQuery.sample_ground_height(position)` | Adapter method | Loads/caches tile and interpolates ADT outer heights | Main thread; cache lives with query | Returns stable failure code |
| `ThirdPersonWowController.set_terrain_query(query)` | Composition method | Replaces terrain backend independently of movement/presentation | Call before scene ready or on main thread | Null is rejected and current query retained |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | `GodotWorldPosition` | Player scene adapter | `TerrainQuery` | Immutable caller-held value | One query |
| Input | Extracted directory/map name | Scene composition | `AdtTerrainQuery` | Copied strings | Query lifetime |
| Input | ADT file/dictionary | Filesystem/native loader or injected loader | `AdtTerrainQuery` | Adapter-owned cached dictionary | Query lifetime |
| Output | `TerrainGroundSample` | Terrain query | Player ground-snap adapter | Immutable caller-held value | One query |
| Output | Failure code | Terrain query | Diagnostics/tests | Immutable `StringName` | Sample lifetime |
Side effects:
- Production adapter performs lazy file existence check and native ADT parse on first tile query.
- Adapter mutates only its per-query tile/failure caches.
- No scene tree, transform, renderer RID, network, database or persisted setting mutation occurs.
## Data flow
```mermaid
flowchart LR
Position[GodotWorldPosition] --> TileCoord[CoordinateMapper tile/local/chunk]
TileCoord --> Lookup{Tile cached?}
Lookup -->|no| Load[ADTLoader or injected loader]
Load --> Cache[Cache Dictionary or unavailable result]
Lookup -->|yes| Cache
Cache --> Chunk[Select MCNK by origin]
Chunk --> Heights[Validate 145 heights]
Heights --> Bilinear[Interpolate 9x9 outer grid]
Bilinear --> Available[TerrainGroundSample available]
Cache -->|missing/invalid| Unavailable[TerrainGroundSample unavailable]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> TileUnknown
TileUnknown --> TileAvailable: first query / successful load
TileUnknown --> TileUnavailable: first query / missing or failed
TileAvailable --> TileAvailable: cached queries
TileUnavailable --> TileUnavailable: cached queries
TileAvailable --> [*]: query released
TileUnavailable --> [*]: query released
```
Unavailable tiles are cached intentionally to avoid repeated filesystem/native
work each physics tick. Recovery after files appear requires replacing the query
instance; hot-reload invalidation is outside this M02 package.
## Main sequence
```mermaid
sequenceDiagram
participant Player as ThirdPersonWowController
participant Query as TerrainQuery
participant ADT as AdtTerrainQuery
participant Mapper as CoordinateMapper
participant Native as ADTLoader
Player->>Query: sample_ground_height(GodotWorldPosition)
Query->>ADT: implementation dispatch
ADT->>Mapper: tile/local/chunk coordinates
alt tile not cached
ADT->>Native: load_adt(absolute path)
Native-->>ADT: ADT Dictionary
end
ADT->>ADT: select chunk and interpolate heights
ADT-->>Player: TerrainGroundSample
Player->>Player: apply existing offset/snap if available
```
## Ownership, threading and resources
- Player scene owns the active `TerrainQuery` reference.
- `AdtTerrainQuery` exclusively owns tile/failure dictionaries until released.
- Current production calls and cache mutations occur on the main physics thread.
- Native loader result dictionaries do not cross the adapter boundary.
- Samples and typed positions are immutable caller-held values.
- The module creates no Nodes, Resources, RIDs or background jobs.
- Future worker-backed queries require an explicit async/result contract rather
than concurrent mutation of the current dictionaries.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Base contract used directly | Base method | Unavailable sample | `terrain_query_not_implemented` | Inject implementation |
| Native loader absent | ClassDB check | Cache unavailable tile | `adt_loader_unavailable` | Load extension and replace query |
| ADT file absent | File check | Cache unavailable tile | `adt_source_missing` | Restore corpus and replace query |
| Native parse empty/fails | Result validation | Cache unavailable tile | `adt_load_failed` | Inspect source/parser; replace query |
| Injected loader returns wrong type | Type check | Cache unavailable tile | `adt_loader_result_invalid` | Fix adapter |
| Chunk absent | Chunk lookup | Unavailable sample | `adt_chunk_missing` | Validate ADT dictionary |
| Height array shorter than 145 | Size check | Unavailable sample | `adt_chunk_heights_invalid` | Fix parser/source |
| Non-finite interpolated height | Sample invariant | Unavailable sample | `terrain_height_not_finite` | Inspect source values |
| Null query injection | Scene guard | Existing query retained | Player composition error | Pass valid query |
There are no asynchronous operations to cancel. Replacing/releasing the query
is the deterministic cache-reset and recovery path.
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Extracted directory | Player `extracted_dir` | Current sandbox | Captured at creation | ADT source root |
| Map directory name | Player `map_name` | Current sandbox | Captured at creation | ADT filename/path |
| Loader override | Invalid callable | Test/custom data source | Constructor only | Bypasses production ClassDB/file loading |
| Tile cache | Enabled | All current uses | Cleared by replacing query | Avoids repeated parse/failure work |
| Height policy | 9x9 outer-grid bilinear | Current sandbox | Fixed | Preserves pre-extraction behavior |
## Persistence, cache and migration
The cache is in-memory and unversioned because it holds native loader output for
one query lifetime. No disk cache, schema or migration changes are introduced.
Existing ADT/native/cache format versions remain unchanged.
## Diagnostics and observability
- Logs: dedicated verifier emits `TERRAIN_QUERY PASS` or named failures.
- Structured status: every unavailable sample has a stable `failure_code`.
- Metrics: synthetic verifier counts loader calls to prove one load per tile.
- Debug views: none.
- Correlation IDs: not applicable to synchronous local query.
## Verification
- Contract: available/unavailable/base/non-finite result invariants.
- Pure synthetic adapter: typed coordinate mapping, exact chunk selection,
145-height layout and bilinear interpolation within `0.002` units (Vector3
source precision), plus cached second query.
- Failures: empty tile and invalid height array produce stable codes.
- Integration: asset-free player scene accepts injected flat query and preserves
spawn offset `0.05` plus physics ground snap.
- Boundary: source regression rejects ADT loader/cache/chunk helpers in player.
- Fidelity evidence: existing interpolation and snap behavior only; no
original-client or collision parity claim.
- Performance budget: one dictionary cache lookup and scalar interpolation per
cached query; native parse only on first tile access.
## Extension points
- Streaming renderer or physics backend may implement `TerrainQuery` without changing player/movement.
- Future result may grow separate slope, surface, liquid or collision contracts;
incompatible additions require a versioned contract rather than Dictionary fields.
- Test/authoring data sources may use constructor loader override.
- Async streaming requires a separate availability/update model.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| Typed replaceable ground-height query | Implemented | Contract and injected player regression | Integrate target checklist after review |
| Existing ADT height interpolation | Implemented | Synthetic fractional-grid fixture | Compare terrain contact with build 12340 |
| Per-tile success/failure cache | Implemented | Loader call-count regression | Add explicit invalidation only with authoring/hot reload |
| Renderer/streaming cache backend | Planned | Boundary permits replacement | M03 renderer facade/public terrain backend |
| Holes/slopes/collision | Planned | Outside height-only contract | Later movement/physics package |
| Liquid/swim query | Planned | Outside contract | M09/M12 world gameplay |
## Known gaps and risks
- Bilinear outer-grid height is the inherited sandbox approximation; exact ADT
triangle choice, holes and collision are not represented.
- Query is synchronous; first access may parse an ADT on the main thread exactly
as before extraction. A streaming/backend replacement is future work.
- Unavailable results remain cached until query replacement.
- Player still owns ground offset/snap policy; only data access is extracted.
- Existing native dictionaries remain dynamic inside the adapter.
## Source map
| Path | Responsibility |
|---|---|
| `src/gameplay/terrain/terrain_ground_sample.gd` | Immutable available/unavailable result |
| `src/gameplay/terrain/terrain_query.gd` | Replaceable typed query boundary |
| `src/gameplay/terrain/adt_terrain_query.gd` | Native ADT adapter, cache, chunk selection and interpolation |
| `src/scenes/player/third_person_wow_controller.gd` | Query composition and existing ground-snap consumer |
| `src/tools/verify_terrain_query.gd` | Synthetic contract/cache/failure/injection regression |
| `src/tests/scenes/player_input_regression.tscn` | Asset-free player composition fixture |
## Related decisions and references
- ADR: none; no coordinate, format or authority decision changed.
- Upstream/reference: `targets/02-player-decomposition.md`,
`targets/roadmap/02-rendering-and-graphics.md`,
`targets/roadmap/04-gameplay-systems.md`, `docs/ARCHITECTURE.md`.
+166
View File
@@ -0,0 +1,166 @@
class_name AdtTerrainQuery
extends TerrainQuery
## TerrainQuery adapter for existing native ADT dictionaries.
## Parsed tiles, including unavailable results, are cached for this query's
## lifetime. The adapter is main-thread owned and creates no scene resources.
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const CHUNK_SIZE_UNITS := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const OUTER_GRID_UNIT_SIZE := CHUNK_SIZE_UNITS / 8.0
var _extracted_directory: String
var _map_directory_name: String
var _adt_tile_loader_override: Callable
var _adt_tile_cache: Dictionary = {}
var _failure_code_by_tile_key: Dictionary = {}
## Creates an ADT query for one map. `adt_tile_loader_override` is an optional
## test/data-source seam receiving the absolute ADT path and returning a Dictionary.
func _init(
extracted_directory: String,
map_directory_name: String,
adt_tile_loader_override: Callable = Callable()
) -> void:
_extracted_directory = extracted_directory
_map_directory_name = map_directory_name
_adt_tile_loader_override = adt_tile_loader_override
## Samples the existing 9x9 outer-height grid with bilinear interpolation.
func sample_ground_height(godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(godot_world_position)
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(godot_world_position)
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(
tile_coordinate,
tile_local_position
)
var tile_key := _tile_key(tile_coordinate.tile_x, tile_coordinate.tile_y)
var adt_tile_data := _load_adt_tile(tile_coordinate.tile_x, tile_coordinate.tile_y)
if adt_tile_data.is_empty():
return TerrainGroundSample.unavailable(
_failure_code_by_tile_key.get(tile_key, &"adt_tile_unavailable")
)
var tile_origin := _find_tile_origin(adt_tile_data)
var chunk := _find_chunk(
adt_tile_data,
clampi(chunk_coordinate.chunk_x, 0, 15),
clampi(chunk_coordinate.chunk_y, 0, 15),
tile_origin
)
if chunk.is_empty():
return TerrainGroundSample.unavailable(&"adt_chunk_missing")
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
if heights.size() < 145:
return TerrainGroundSample.unavailable(&"adt_chunk_heights_invalid")
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var chunk_local_x := godot_world_position.x_units - chunk_origin.x
var chunk_local_z := godot_world_position.z_units - chunk_origin.z
var grid_x := clampf(chunk_local_x / OUTER_GRID_UNIT_SIZE, 0.0, 8.0)
var grid_z := clampf(chunk_local_z / OUTER_GRID_UNIT_SIZE, 0.0, 8.0)
var x0 := clampi(int(floor(grid_x)), 0, 8)
var z0 := clampi(int(floor(grid_z)), 0, 8)
var x1 := mini(x0 + 1, 8)
var z1 := mini(z0 + 1, 8)
var fraction_x := grid_x - float(x0)
var fraction_z := grid_z - float(z0)
var height_x0 := lerpf(
heights[_outer_height_index(z0, x0)],
heights[_outer_height_index(z0, x1)],
fraction_x
)
var height_x1 := lerpf(
heights[_outer_height_index(z1, x0)],
heights[_outer_height_index(z1, x1)],
fraction_x
)
return TerrainGroundSample.available(
chunk_origin.y + lerpf(height_x0, height_x1, fraction_z)
)
func _load_adt_tile(tile_x: int, tile_y: int) -> Dictionary:
var tile_key := _tile_key(tile_x, tile_y)
if _adt_tile_cache.has(tile_key):
return _adt_tile_cache[tile_key]
var resource_path := _extracted_directory.path_join(
"World/Maps/%s/%s_%d_%d.adt" % [_map_directory_name, _map_directory_name, tile_x, tile_y]
)
var absolute_path := ProjectSettings.globalize_path(resource_path)
var adt_tile_data: Dictionary = {}
var failure_code: StringName = &""
if _adt_tile_loader_override.is_valid():
var override_result = _adt_tile_loader_override.call(absolute_path)
if override_result is Dictionary:
adt_tile_data = override_result
else:
failure_code = &"adt_loader_result_invalid"
elif not ClassDB.class_exists("ADTLoader"):
failure_code = &"adt_loader_unavailable"
elif not FileAccess.file_exists(absolute_path):
failure_code = &"adt_source_missing"
else:
var loader = ClassDB.instantiate("ADTLoader")
if loader != null:
var load_result = loader.call("load_adt", absolute_path)
if load_result is Dictionary:
adt_tile_data = load_result
if adt_tile_data.is_empty():
failure_code = &"adt_load_failed"
if adt_tile_data.is_empty() and failure_code.is_empty():
failure_code = &"adt_tile_unavailable"
_adt_tile_cache[tile_key] = adt_tile_data
_failure_code_by_tile_key[tile_key] = failure_code
return adt_tile_data
func _find_chunk(
adt_tile_data: Dictionary,
chunk_x: int,
chunk_y: int,
tile_origin: Vector3
) -> Dictionary:
for chunk in adt_tile_data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var local_origin := chunk_origin - tile_origin
var candidate_chunk_x := clampi(int(round(local_origin.x / CHUNK_SIZE_UNITS)), 0, 15)
var candidate_chunk_y := clampi(int(round(local_origin.z / CHUNK_SIZE_UNITS)), 0, 15)
if candidate_chunk_x == chunk_x and candidate_chunk_y == chunk_y:
return chunk
return {}
func _find_tile_origin(adt_tile_data: Dictionary) -> Vector3:
var has_origin := false
var minimum_x := 0.0
var minimum_z := 0.0
for chunk in adt_tile_data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
if not has_origin:
minimum_x = chunk_origin.x
minimum_z = chunk_origin.z
has_origin = true
else:
minimum_x = minf(minimum_x, chunk_origin.x)
minimum_z = minf(minimum_z, chunk_origin.z)
return Vector3(minimum_x, 0.0, minimum_z) if has_origin else Vector3.ZERO
func _tile_key(tile_x: int, tile_y: int) -> String:
return "%d_%d" % [tile_x, tile_y]
func _outer_height_index(row: int, column: int) -> int:
return row * 17 + column
@@ -0,0 +1 @@
uid://cq7scvxqw6ffm
@@ -0,0 +1,49 @@
class_name TerrainGroundSample
extends RefCounted
## Immutable result of a terrain ground-height query in Godot world units.
## An unavailable sample carries a stable failure code instead of overloading a
## numeric height with `NAN` at the public boundary.
var is_available: bool:
get:
return _is_available
var height_units: float:
get:
return _height_units
var failure_code: StringName:
get:
return _failure_code
var _is_available: bool
var _height_units: float
var _failure_code: StringName
## Creates an available ground sample in Godot world units.
static func available(height_units_value: float) -> TerrainGroundSample:
return TerrainGroundSample.new(true, height_units_value, &"")
## Creates an unavailable sample with a stable diagnostic code.
static func unavailable(failure_code_value: StringName) -> TerrainGroundSample:
return TerrainGroundSample.new(false, 0.0, failure_code_value)
## Normalizes factory inputs so available samples are finite and unavailable
## samples always carry a non-empty failure code.
func _init(
is_available_value: bool,
height_units_value: float,
failure_code_value: StringName
) -> void:
_is_available = is_available_value and is_finite(height_units_value)
_height_units = height_units_value if _is_available else 0.0
if _is_available:
_failure_code = &""
elif not is_finite(height_units_value):
_failure_code = &"terrain_height_not_finite"
else:
_failure_code = failure_code_value if not failure_code_value.is_empty() else &"terrain_unavailable"
@@ -0,0 +1 @@
uid://boe5qkip6ffku
+12
View File
@@ -0,0 +1,12 @@
class_name TerrainQuery
extends RefCounted
## Replaceable scene-free boundary for gameplay terrain height sampling.
## Implementations receive a typed Godot world position and must return an
## explicit available/unavailable result without mutating the caller.
## Samples terrain ground height at a typed Godot world position.
## The base implementation reports `terrain_query_not_implemented`.
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
return TerrainGroundSample.unavailable(&"terrain_query_not_implemented")
@@ -0,0 +1 @@
uid://d3f2hetuo342q
+27 -102
View File
@@ -10,9 +10,7 @@ const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/ad
const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd") const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd") const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd") const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrain_query.gd")
const CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
const UNIT_SIZE := CHUNK_SIZE / 8.0
@export var extracted_dir: String = "res://data/extracted" @export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth" @export var map_name: String = "Azeroth"
@@ -55,13 +53,24 @@ var _active_animation := ""
var _captured := false var _captured := false
var _yaw := 0.0 var _yaw := 0.0
var _pitch := deg_to_rad(-18.0) var _pitch := deg_to_rad(-18.0)
var _adt_cache: Dictionary = {}
var _player_input_source: PlayerInputSource var _player_input_source: PlayerInputSource
var _local_movement_controller: LocalPlayerMovementController var _local_movement_controller: LocalPlayerMovementController
var _terrain_query: TerrainQuery
## Replaces the ground-height backend without changing player movement or presentation.
## Passing null is a composition error and leaves the current query unchanged.
func set_terrain_query(terrain_query: TerrainQuery) -> void:
if terrain_query == null:
push_error("ThirdPersonWowController: terrain query cannot be null")
return
_terrain_query = terrain_query
func _ready() -> void: func _ready() -> void:
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new() _player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
if _terrain_query == null:
_terrain_query = ADT_TERRAIN_QUERY_SCRIPT.new(extracted_dir, map_name)
_local_movement_controller = LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT.new( _local_movement_controller = LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT.new(
run_speed, run_speed,
backward_speed, backward_speed,
@@ -83,9 +92,9 @@ func _ready() -> void:
var spawn_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(spawn_tile, spawn_local) 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) 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 spawn_ground_sample := _sample_ground_height(global_position)
if is_finite(ground): if spawn_ground_sample.is_available:
global_position.y = ground + ground_offset global_position.y = spawn_ground_sample.height_units + ground_offset
_yaw = rotation.y _yaw = rotation.y
_apply_camera_transform() _apply_camera_transform()
@@ -124,9 +133,9 @@ func _physics_process(delta: float) -> void:
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta) global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
if not _local_movement_controller.is_flight_enabled: if not _local_movement_controller.is_flight_enabled:
var ground := _sample_ground_height(global_position) var ground_sample := _sample_ground_height(global_position)
if is_finite(ground): if ground_sample.is_available:
var target_y := ground + ground_offset var target_y := ground_sample.height_units + ground_offset
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0)) global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
var movement_velocity := _local_movement_controller.godot_world_velocity_units_per_second var movement_velocity := _local_movement_controller.godot_world_velocity_units_per_second
@@ -298,95 +307,11 @@ func _apply_camera_transform() -> void:
_camera.rotation = Vector3.ZERO _camera.rotation = Vector3.ZERO
func _sample_ground_height(world_pos: Vector3) -> float: func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z) return _terrain_query.sample_ground_height(
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position) GODOT_WORLD_POSITION_SCRIPT.new(
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position) godot_world_position.x,
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, tile_local_position) godot_world_position.y,
var data := _load_adt(tile_coordinate.tile_x, tile_coordinate.tile_y) godot_world_position.z
if data.is_empty(): )
return NAN )
var tile_origin := _get_tile_origin(data)
var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin)
if chunk.is_empty():
return NAN
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var chunk_local := world_pos - origin
var gx := clampf(chunk_local.x / UNIT_SIZE, 0.0, 8.0)
var gz := clampf(chunk_local.z / UNIT_SIZE, 0.0, 8.0)
var x0 := clampi(int(floor(gx)), 0, 8)
var z0 := clampi(int(floor(gz)), 0, 8)
var x1 := mini(x0 + 1, 8)
var z1 := mini(z0 + 1, 8)
var fx := gx - float(x0)
var fz := gz - float(z0)
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
if heights.size() < 145:
return NAN
var h00 := heights[_outer(z0, x0)]
var h10 := heights[_outer(z0, x1)]
var h01 := heights[_outer(z1, x0)]
var h11 := heights[_outer(z1, x1)]
var hx0 := lerpf(h00, h10, fx)
var hx1 := lerpf(h01, h11, fx)
return origin.y + lerpf(hx0, hx1, fz)
func _load_adt(tx: int, ty: int) -> Dictionary:
var key := "%d_%d" % [tx, ty]
if _adt_cache.has(key):
return _adt_cache[key]
if not ClassDB.class_exists("ADTLoader"):
_adt_cache[key] = {}
return {}
var res_path := extracted_dir.path_join("World/Maps/%s/%s_%d_%d.adt" % [map_name, map_name, tx, ty])
var abs_path := ProjectSettings.globalize_path(res_path)
if not FileAccess.file_exists(abs_path):
_adt_cache[key] = {}
return {}
var loader = ClassDB.instantiate("ADTLoader")
var data: Dictionary = loader.call("load_adt", abs_path) if loader else {}
_adt_cache[key] = data
return data
func _find_chunk(data: Dictionary, chunk_x: int, chunk_y: int, tile_origin: Vector3) -> Dictionary:
for chunk in data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var local := origin - tile_origin
var cx := clampi(int(round(local.x / CHUNK_SIZE)), 0, 15)
var cy := clampi(int(round(local.z / CHUNK_SIZE)), 0, 15)
if cx == chunk_x and cy == chunk_y:
return chunk
return {}
func _get_tile_origin(data: Dictionary) -> Vector3:
var found := false
var min_x := 0.0
var min_z := 0.0
for chunk in data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
if not found:
min_x = origin.x
min_z = origin.z
found = true
else:
min_x = minf(min_x, origin.x)
min_z = minf(min_z, origin.z)
return Vector3(min_x, 0.0, min_z) if found else Vector3.ZERO
func _outer(row: int, col: int) -> int:
return row * 17 + col
@@ -20,7 +20,8 @@ const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
const REQUIRED_MAPPER_CALLS := { 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/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/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/scenes/player/third_person_wow_controller.gd": ["adt_tile_local_to_godot"],
"res://src/gameplay/terrain/adt_terrain_query.gd": ["godot_to_adt_tile", "godot_to_adt_tile_local", "adt_tile_local_to_chunk"],
"res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"], "res://src/tools/probe_render_terrain_height.gd": ["godot_to_adt_tile"],
} }
@@ -59,7 +60,7 @@ func _initialize() -> void:
quit(1) quit(1)
return return
print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=4 classifier_cases=6" % source_paths.size()) print("COORDINATE_CONVERSION_BOUNDARIES PASS files=%d native_boundary=1 oracle=1 consumers=5 classifier_cases=6" % source_paths.size())
quit(0) quit(0)
+198
View File
@@ -0,0 +1,198 @@
extends SceneTree
## Headless M02 contract, interpolation, cache and player-injection regression.
const TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/terrain_query.gd"
const ADT_TERRAIN_QUERY_PATH := "res://src/gameplay/terrain/adt_terrain_query.gd"
const PLAYER_CONTROLLER_PATH := "res://src/scenes/player/third_person_wow_controller.gd"
const REGRESSION_SCENE_PATH := "res://src/tests/scenes/player_input_regression.tscn"
var _synthetic_adt_tile_data: Dictionary = {}
var _synthetic_loader_call_count := 0
class FlatTerrainQuery extends TerrainQuery:
var _height_units: float
func _init(height_units: float) -> void:
_height_units = height_units
func sample_ground_height(_godot_world_position: GodotWorldPosition) -> TerrainGroundSample:
return TerrainGroundSample.available(_height_units)
func _initialize() -> void:
_run_verification.call_deferred()
func _run_verification() -> void:
var failures: Array[String] = []
_verify_typed_result_contract(failures)
_verify_synthetic_adt_interpolation_and_cache(failures)
_verify_unavailable_tile(failures)
_verify_player_injection(failures)
_verify_source_boundaries(failures)
if not failures.is_empty():
for failure in failures:
push_error("TERRAIN_QUERY: %s" % failure)
quit(1)
return
print("TERRAIN_QUERY PASS contract=4 interpolation=1 cache=1 failures=2 player_injection=1")
quit(0)
func _verify_typed_result_contract(failures: Array[String]) -> void:
var available_sample := TerrainGroundSample.available(123.5)
_expect_true(available_sample.is_available, "available sample state", failures)
_expect_near(available_sample.height_units, 123.5, "available sample height", failures)
_expect_true(available_sample.failure_code.is_empty(), "available sample has no failure", failures)
var unavailable_sample := TerrainGroundSample.unavailable(&"synthetic_failure")
_expect_true(not unavailable_sample.is_available, "unavailable sample state", failures)
_expect_equal(unavailable_sample.failure_code, &"synthetic_failure", "unavailable failure code", failures)
var base_sample := TerrainQuery.new().sample_ground_height(GodotWorldPosition.new(0.0, 0.0, 0.0))
_expect_equal(base_sample.failure_code, &"terrain_query_not_implemented", "base query failure", failures)
var non_finite_sample := TerrainGroundSample.available(NAN)
_expect_true(not non_finite_sample.is_available, "non-finite sample unavailable", failures)
_expect_equal(non_finite_sample.failure_code, &"terrain_height_not_finite", "non-finite failure code", failures)
func _verify_synthetic_adt_interpolation_and_cache(failures: Array[String]) -> void:
var query_position := GodotWorldPosition.new(17000.0, 999.0, 26525.0)
_synthetic_adt_tile_data = _build_synthetic_adt_tile(query_position)
_synthetic_loader_call_count = 0
var terrain_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", _load_synthetic_adt_tile)
var first_sample := terrain_query.sample_ground_height(query_position)
_expect_true(first_sample.is_available, "synthetic ADT sample available", failures)
_expect_near_with_tolerance(first_sample.height_units, 137.0, 0.002, "synthetic bilinear height", failures)
var second_sample := terrain_query.sample_ground_height(query_position)
_expect_near_with_tolerance(second_sample.height_units, 137.0, 0.002, "cached synthetic height", failures)
_expect_equal(_synthetic_loader_call_count, 1, "ADT tile loaded once", failures)
func _verify_unavailable_tile(failures: Array[String]) -> void:
var query_position := GodotWorldPosition.new(17000.0, 0.0, 26525.0)
var empty_query := AdtTerrainQuery.new("res://synthetic", "Azeroth", func(_path: String) -> Dictionary: return {})
var empty_sample := empty_query.sample_ground_height(query_position)
_expect_true(not empty_sample.is_available, "empty ADT unavailable", failures)
_expect_equal(empty_sample.failure_code, &"adt_tile_unavailable", "empty ADT failure", failures)
var invalid_heights_data := _build_synthetic_adt_tile(query_position)
var invalid_chunks: Array = invalid_heights_data.get("chunks", [])
(invalid_chunks[invalid_chunks.size() - 1] as Dictionary)["heights"] = PackedFloat32Array()
var invalid_query := AdtTerrainQuery.new(
"res://synthetic",
"Azeroth",
func(_path: String) -> Dictionary: return invalid_heights_data
)
var invalid_sample := invalid_query.sample_ground_height(query_position)
_expect_equal(invalid_sample.failure_code, &"adt_chunk_heights_invalid", "invalid heights failure", failures)
func _verify_player_injection(failures: Array[String]) -> void:
var packed_scene := load(REGRESSION_SCENE_PATH) as PackedScene
_expect_true(packed_scene != null, "regression scene loads", failures)
if packed_scene == null:
return
var player := packed_scene.instantiate() as CharacterBody3D
player.set_terrain_query(FlatTerrainQuery.new(20.0))
root.add_child(player)
player.set_physics_process(false)
_expect_near(player.position.y, 20.05, "injected spawn ground height", failures)
player.position.y = 30.0
player.call("_physics_process", 1.0)
_expect_near(player.position.y, 20.05, "injected physics ground snap", failures)
player.free()
func _verify_source_boundaries(failures: Array[String]) -> void:
var terrain_contract_source := _read_text(TERRAIN_QUERY_PATH, failures)
_expect_true(not terrain_contract_source.contains("extends Node"), "TerrainQuery omits Node", failures)
_expect_true(not terrain_contract_source.contains("Vector3"), "TerrainQuery omits untyped Vector3 position", failures)
var adt_query_source := _read_text(ADT_TERRAIN_QUERY_PATH, failures)
_expect_true(adt_query_source.contains("ADTLoader"), "ADT adapter owns native loader", failures)
_expect_true(adt_query_source.contains("_adt_tile_cache"), "ADT adapter owns tile cache", failures)
var player_source := _read_text(PLAYER_CONTROLLER_PATH, failures)
for forbidden_text in ["ADTLoader", "load_adt", "_adt_cache", "_find_chunk", "_outer_height_index"]:
_expect_true(not player_source.contains(forbidden_text), "player omits %s" % forbidden_text, failures)
_expect_true(player_source.contains("set_terrain_query"), "player exposes terrain query injection", failures)
_expect_true(player_source.contains("_terrain_query.sample_ground_height"), "player delegates ground sampling", failures)
func _build_synthetic_adt_tile(query_position: GodotWorldPosition) -> Dictionary:
var tile_coordinate := CoordinateMapper.godot_to_adt_tile(query_position)
var tile_local_position := CoordinateMapper.godot_to_adt_tile_local(query_position)
var chunk_coordinate := CoordinateMapper.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
var chunk_size := CoordinateMapper.ADT_CHUNK_SIZE_YARDS
var outer_grid_unit_size := chunk_size / 8.0
var target_chunk_origin := Vector3(
query_position.x_units - outer_grid_unit_size * 4.5,
100.0,
query_position.z_units - outer_grid_unit_size * 3.25
)
var tile_origin := target_chunk_origin - Vector3(
chunk_coordinate.chunk_x * chunk_size,
0.0,
chunk_coordinate.chunk_y * chunk_size
)
var heights := PackedFloat32Array()
heights.resize(145)
for row in range(9):
for column in range(9):
heights[row * 17 + column] = float(row * 10 + column)
var target_chunk := {"origin": target_chunk_origin, "heights": heights}
if chunk_coordinate.chunk_x == 0 and chunk_coordinate.chunk_y == 0:
return {"chunks": [target_chunk]}
return {
"chunks": [
{"origin": tile_origin, "heights": PackedFloat32Array()},
target_chunk,
]
}
func _load_synthetic_adt_tile(_absolute_path: String) -> Dictionary:
_synthetic_loader_call_count += 1
return _synthetic_adt_tile_data
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_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
if absf(actual_value - expected_value) > 0.000001:
failures.append("%s expected %.6f, got %.6f" % [label, expected_value, actual_value])
func _expect_near_with_tolerance(
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 %.6f ± %.6f, got %.6f" % [label, expected_value, tolerance, actual_value])
func _expect_equal(actual_value: Variant, expected_value: Variant, 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)