From a45d5215670a738972a2d9ead20a0d686cca5a78 Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 14 Jul 2026 23:10:55 +0400 Subject: [PATCH 1/2] 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 --- .../claims/M02-GMP-TERRAIN-QUERY-001.md | 84 ++++++ docs/modules/README.md | 1 + docs/modules/coordinate-mapping.md | 14 +- docs/modules/local-player-movement.md | 13 +- docs/modules/terrain-query.md | 251 ++++++++++++++++++ src/gameplay/terrain/adt_terrain_query.gd | 166 ++++++++++++ src/gameplay/terrain/adt_terrain_query.gd.uid | 1 + src/gameplay/terrain/terrain_ground_sample.gd | 49 ++++ .../terrain/terrain_ground_sample.gd.uid | 1 + src/gameplay/terrain/terrain_query.gd | 12 + src/gameplay/terrain/terrain_query.gd.uid | 1 + .../player/third_person_wow_controller.gd | 129 ++------- ...verify_coordinate_conversion_boundaries.gd | 5 +- src/tools/verify_terrain_query.gd | 198 ++++++++++++++ 14 files changed, 812 insertions(+), 113 deletions(-) create mode 100644 coordination/claims/M02-GMP-TERRAIN-QUERY-001.md create mode 100644 docs/modules/terrain-query.md create mode 100644 src/gameplay/terrain/adt_terrain_query.gd create mode 100644 src/gameplay/terrain/adt_terrain_query.gd.uid create mode 100644 src/gameplay/terrain/terrain_ground_sample.gd create mode 100644 src/gameplay/terrain/terrain_ground_sample.gd.uid create mode 100644 src/gameplay/terrain/terrain_query.gd create mode 100644 src/gameplay/terrain/terrain_query.gd.uid create mode 100644 src/tools/verify_terrain_query.gd diff --git a/coordination/claims/M02-GMP-TERRAIN-QUERY-001.md b/coordination/claims/M02-GMP-TERRAIN-QUERY-001.md new file mode 100644 index 0000000..81a77ba --- /dev/null +++ b/coordination/claims/M02-GMP-TERRAIN-QUERY-001.md @@ -0,0 +1,84 @@ +# M02-GMP-TERRAIN-QUERY-001 — Player terrain query boundary + + + +## 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: diff --git a/docs/modules/README.md b/docs/modules/README.md index c551e4d..d02b1ee 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -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) | diff --git a/docs/modules/coordinate-mapping.md b/docs/modules/coordinate-mapping.md index 0ab92f1..a430bf7 100644 --- a/docs/modules/coordinate-mapping.md +++ b/docs/modules/coordinate-mapping.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 | diff --git a/docs/modules/local-player-movement.md b/docs/modules/local-player-movement.md index 051351e..7a6eefc 100644 --- a/docs/modules/local-player-movement.md +++ b/docs/modules/local-player-movement.md @@ -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 | diff --git a/docs/modules/terrain-query.md b/docs/modules/terrain-query.md new file mode 100644 index 0000000..ff9b289 --- /dev/null +++ b/docs/modules/terrain-query.md @@ -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`. diff --git a/src/gameplay/terrain/adt_terrain_query.gd b/src/gameplay/terrain/adt_terrain_query.gd new file mode 100644 index 0000000..3d4723c --- /dev/null +++ b/src/gameplay/terrain/adt_terrain_query.gd @@ -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 diff --git a/src/gameplay/terrain/adt_terrain_query.gd.uid b/src/gameplay/terrain/adt_terrain_query.gd.uid new file mode 100644 index 0000000..69735eb --- /dev/null +++ b/src/gameplay/terrain/adt_terrain_query.gd.uid @@ -0,0 +1 @@ +uid://cq7scvxqw6ffm diff --git a/src/gameplay/terrain/terrain_ground_sample.gd b/src/gameplay/terrain/terrain_ground_sample.gd new file mode 100644 index 0000000..6c857c7 --- /dev/null +++ b/src/gameplay/terrain/terrain_ground_sample.gd @@ -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" diff --git a/src/gameplay/terrain/terrain_ground_sample.gd.uid b/src/gameplay/terrain/terrain_ground_sample.gd.uid new file mode 100644 index 0000000..272750c --- /dev/null +++ b/src/gameplay/terrain/terrain_ground_sample.gd.uid @@ -0,0 +1 @@ +uid://boe5qkip6ffku diff --git a/src/gameplay/terrain/terrain_query.gd b/src/gameplay/terrain/terrain_query.gd new file mode 100644 index 0000000..1f2106b --- /dev/null +++ b/src/gameplay/terrain/terrain_query.gd @@ -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") diff --git a/src/gameplay/terrain/terrain_query.gd.uid b/src/gameplay/terrain/terrain_query.gd.uid new file mode 100644 index 0000000..8a8cd5e --- /dev/null +++ b/src/gameplay/terrain/terrain_query.gd.uid @@ -0,0 +1 @@ +uid://d3f2hetuo342q diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd index 9100f4a..3b4591e 100644 --- a/src/scenes/player/third_person_wow_controller.gd +++ b/src/scenes/player/third_person_wow_controller.gd @@ -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 + ) + ) diff --git a/src/tools/verify_coordinate_conversion_boundaries.gd b/src/tools/verify_coordinate_conversion_boundaries.gd index f40d083..794ae77 100644 --- a/src/tools/verify_coordinate_conversion_boundaries.gd +++ b/src/tools/verify_coordinate_conversion_boundaries.gd @@ -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) diff --git a/src/tools/verify_terrain_query.gd b/src/tools/verify_terrain_query.gd new file mode 100644 index 0000000..da70efa --- /dev/null +++ b/src/tools/verify_terrain_query.gd @@ -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) -- 2.52.0 From 0ce3cae2088a00add81fe1d170296a145263c5c4 Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 14 Jul 2026 23:11:23 +0400 Subject: [PATCH 2/2] 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 --- .../claims/M02-GMP-TERRAIN-QUERY-001.md | 40 +++++++++++++++++-- docs/modules/coordinate-mapping.md | 2 +- docs/modules/local-player-movement.md | 2 +- docs/modules/terrain-query.md | 2 +- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/coordination/claims/M02-GMP-TERRAIN-QUERY-001.md b/coordination/claims/M02-GMP-TERRAIN-QUERY-001.md index 81a77ba..9284fac 100644 --- a/coordination/claims/M02-GMP-TERRAIN-QUERY-001.md +++ b/coordination/claims/M02-GMP-TERRAIN-QUERY-001.md @@ -1,6 +1,7 @@ # M02-GMP-TERRAIN-QUERY-001 — Player terrain query boundary + ## Ownership @@ -78,7 +79,40 @@ changing current spawn/ground-snap behavior. ## Status -- State: active -- Done: claim and isolated worktree from updated master -- Next: implement contract/adapter, migrate player, test and document +- 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. diff --git a/docs/modules/coordinate-mapping.md b/docs/modules/coordinate-mapping.md index a430bf7..0c24593 100644 --- a/docs/modules/coordinate-mapping.md +++ b/docs/modules/coordinate-mapping.md @@ -7,7 +7,7 @@ | 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`, `M02-GMP-TERRAIN-QUERY-001` consumer migration | | Owners | Foundation/domain | -| Last verified | `working tree`, 2026-07-14 | +| Last verified | `a45d521`, 2026-07-14 | | Profiles/capabilities | All profiles; contract version 2 | ## Purpose diff --git a/docs/modules/local-player-movement.md b/docs/modules/local-player-movement.md index 7a6eefc..e31eebe 100644 --- a/docs/modules/local-player-movement.md +++ b/docs/modules/local-player-movement.md @@ -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 | `working tree`, 2026-07-14 | +| Last verified | `a45d521`, 2026-07-14 | | Profiles/capabilities | Current render sandbox; production/debug profile gate pending | ## Purpose diff --git a/docs/modules/terrain-query.md b/docs/modules/terrain-query.md index ff9b289..e53485a 100644 --- a/docs/modules/terrain-query.md +++ b/docs/modules/terrain-query.md @@ -7,7 +7,7 @@ | 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 | +| Last verified | `a45d521`, 2026-07-14 | | Profiles/capabilities | Current sandbox ADT ground height; renderer/physics backend planned | ## Purpose -- 2.52.0