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
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# M02-GMP-TERRAIN-QUERY-001 — Player terrain query boundary
|
||||
|
||||
<!-- OPENWC_CLAIM:M02-GMP-TERRAIN-QUERY-001:sindo-main-codex:2026-07-16 -->
|
||||
|
||||
## 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: active
|
||||
- Done: claim and isolated worktree from updated master
|
||||
- Next: implement contract/adapter, migrate player, test and document
|
||||
- Blocked by:
|
||||
@@ -11,6 +11,7 @@
|
||||
| Domain identities | Implemented contract | [`domain-identities.md`](domain-identities.md) |
|
||||
| Player input | Partial | [`player-input.md`](player-input.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) |
|
||||
| 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) |
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Status | Implemented |
|
||||
| Target/work package | `M01-FND-COORDS-001`, `M01-FND-COORD-TILE-AXIS-002`, `M01-QAR-COORD-FIXTURES-001`, `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 |
|
||||
| Last verified | Worktree `work/sindo-main-codex/m01-server-spawn-renderer`, 2026-07-13 |
|
||||
| Last verified | `working tree`, 2026-07-14 |
|
||||
| Profiles/capabilities | All profiles; contract version 2 |
|
||||
|
||||
## Purpose
|
||||
@@ -86,7 +86,7 @@ Forbidden dependencies:
|
||||
| Input | `AdtPlacementPosition` | Future ADT adapter | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
|
||||
| Input | `CanonicalWowWorldPosition`, `CanonicalWowWorldYaw` | Gameplay/content/domain | `CoordinateMapper` | Immutable caller value | Any thread, call duration |
|
||||
| Output | `CanonicalWowWorldPosition` | `CoordinateMapper` | Gameplay/content/adapters | New immutable value | Caller-owned reference |
|
||||
| Output | `AdtTileCoordinate`, `AdtTileLocalPosition`, `AdtChunkCoordinate` | `CoordinateMapper` | Streaming/content adapters | New immutable values | Caller-owned references |
|
||||
| Output | `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 |
|
||||
| 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]
|
||||
Godot -->|typed direct adapter| Tile
|
||||
Godot -->|typed direct adapter| Local
|
||||
Godot --> TerrainQuery[AdtTerrainQuery consumer]
|
||||
TerrainQuery --> Tile
|
||||
TerrainQuery --> Local
|
||||
TerrainQuery --> Chunk
|
||||
Tile -->|tile plus local| Godot
|
||||
ServerYaw[ServerWorldYaw] -->|normalize only| CanonicalYaw[CanonicalWowWorldYaw]
|
||||
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`.
|
||||
- Server-spawn renderer contract: `src/tools/verify_server_spawn_renderer.gd`
|
||||
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
|
||||
Human Warrior spawn is captured in the Eastern Kingdoms renderer at ADT
|
||||
`(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/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/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/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 |
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Status | Implemented |
|
||||
| Target/work package | M02 / M02-GMP-MOVEMENT-001 |
|
||||
| Owners | Gameplay local movement state; scene composition owns the instance |
|
||||
| Last verified | `435e1c9`, 2026-07-14 |
|
||||
| Last verified | `working tree`, 2026-07-14 |
|
||||
| Profiles/capabilities | Current render sandbox; production/debug profile gate pending |
|
||||
|
||||
## Purpose
|
||||
@@ -37,7 +37,7 @@ flowchart LR
|
||||
Scene[ThirdPersonWowController] --> Basis
|
||||
Displacement --> Scene
|
||||
Scene --> Transform[Player world transform]
|
||||
Scene --> Terrain[Ground snap adapter]
|
||||
Scene --> Terrain[TerrainQuery and ground snap 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
- Configuration is captured at scene `_ready`; runtime mutation of exported speed
|
||||
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.
|
||||
- The module uses Godot math value types; a future engine-independent gameplay
|
||||
predictor may require scalar/domain movement contracts.
|
||||
@@ -230,6 +232,7 @@ separate versioned movement snapshot contract.
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `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/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 |
|
||||
|
||||
@@ -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 | `working tree`, 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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_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 CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS
|
||||
const UNIT_SIZE := CHUNK_SIZE / 8.0
|
||||
const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrain_query.gd")
|
||||
|
||||
@export var extracted_dir: String = "res://data/extracted"
|
||||
@export var map_name: String = "Azeroth"
|
||||
@@ -55,13 +53,24 @@ var _active_animation := ""
|
||||
var _captured := false
|
||||
var _yaw := 0.0
|
||||
var _pitch := deg_to_rad(-18.0)
|
||||
var _adt_cache: Dictionary = {}
|
||||
var _player_input_source: PlayerInputSource
|
||||
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:
|
||||
_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(
|
||||
run_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)
|
||||
global_position = Vector3(spawn_position.x_units, spawn_position.y_units, spawn_position.z_units)
|
||||
_load_character_visual()
|
||||
var ground := _sample_ground_height(global_position)
|
||||
if is_finite(ground):
|
||||
global_position.y = ground + ground_offset
|
||||
var spawn_ground_sample := _sample_ground_height(global_position)
|
||||
if spawn_ground_sample.is_available:
|
||||
global_position.y = spawn_ground_sample.height_units + ground_offset
|
||||
_yaw = rotation.y
|
||||
_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)
|
||||
|
||||
if not _local_movement_controller.is_flight_enabled:
|
||||
var ground := _sample_ground_height(global_position)
|
||||
if is_finite(ground):
|
||||
var target_y := ground + ground_offset
|
||||
var ground_sample := _sample_ground_height(global_position)
|
||||
if ground_sample.is_available:
|
||||
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))
|
||||
|
||||
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
|
||||
|
||||
|
||||
func _sample_ground_height(world_pos: Vector3) -> float:
|
||||
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z)
|
||||
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
|
||||
var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
|
||||
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, tile_local_position)
|
||||
var data := _load_adt(tile_coordinate.tile_x, tile_coordinate.tile_y)
|
||||
if data.is_empty():
|
||||
return NAN
|
||||
|
||||
var tile_origin := _get_tile_origin(data)
|
||||
var 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
|
||||
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
|
||||
return _terrain_query.sample_ground_height(
|
||||
GODOT_WORLD_POSITION_SCRIPT.new(
|
||||
godot_world_position.x,
|
||||
godot_world_position.y,
|
||||
godot_world_position.z
|
||||
)
|
||||
)
|
||||
|
||||
@@ -20,7 +20,8 @@ const ALLOWED_LEGACY_NAME_PATHS: Array[String] = [
|
||||
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/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"],
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ func _initialize() -> void:
|
||||
quit(1)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user