From d233a41ce83963a148289b3c89fae1668f3fff9a Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 11 Jul 2026 19:38:23 +0400 Subject: [PATCH] test(M00): probe renderer terrain clearance Work-Package: M00-QAR-TERRAIN-HEIGHT-001 Agent: sindo-main-codex Tests: five-point terrain probe, coordination and documentation gates Fidelity: four cameras confirmed above terrain; waterfall tile missing terrain mesh recorded --- .../claims/M00-QAR-TERRAIN-HEIGHT-001.md | 78 ++++++++ docs/RENDER_BASELINE.md | 18 ++ docs/modules/world-renderer.md | 2 + src/tools/probe_render_terrain_height.gd | 178 ++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 coordination/claims/M00-QAR-TERRAIN-HEIGHT-001.md create mode 100644 src/tools/probe_render_terrain_height.gd diff --git a/coordination/claims/M00-QAR-TERRAIN-HEIGHT-001.md b/coordination/claims/M00-QAR-TERRAIN-HEIGHT-001.md new file mode 100644 index 0000000..80e5574 --- /dev/null +++ b/coordination/claims/M00-QAR-TERRAIN-HEIGHT-001.md @@ -0,0 +1,78 @@ +# M00-QAR-TERRAIN-HEIGHT-001 — Terrain height diagnostic + + + +## Ownership + +- Target: M00 +- Program: QAR +- Owner/Agent ID: sindo-main-codex +- Branch: `work/sindo-main-codex/m00-terrain-height` +- Lease expires UTC: 2026-07-13 +- Integrator: milestone integrator + +## Outcome + +Measure rendered terrain height and camera clearance at the five build 12340 golden checkpoints without changing renderer behavior. + +## Non-goals + +- Adding a runtime terrain-query API or collision system. +- Changing terrain geometry, coordinate mapping, placements, or cameras. +- Implementing the M01 CoordinateMapper. + +## Paths + +- Exclusive: `src/tools/probe_render_terrain_height.gd` +- Shared/hotspots: renderer baseline documentation and runner +- Generated/ignored: local JSON probe reports + +## Contracts and data + +- Public API/events: headless diagnostic CLI only +- Schema/format version: report schema 1 +- Migration/compatibility: none +- Consumers: M00 fidelity diagnosis + +## Dependencies + +- Requires: calibrated renderer manifest and active terrain meshes +- Blocks: classification of under-terrain camera gaps +- External state: local extracted/cache data + +## Verification + +- Commands: terrain probe, M00 dry-run, coordination and documentation gates +- Fixtures: five calibrated manifest checkpoints +- Fidelity evidence: camera clearance against rendered OpenWC terrain +- Performance budget: offline diagnostic only + +## Documentation deliverables + +- Inline public API docs: script CLI header +- Module specification: verification/source map +- Data-flow diagram: terrain probe flow +- Sequence/state/dependency diagrams: synchronous diagnostic; not applicable +- Source map/status updates: baseline and renderer module + +## Simplicity and naming + +- Important names introduced: `terrain_height`, `camera_clearance` +- Simplest considered solution: CPU ray against already loaded mesh +- Rejected complexity/abstractions: new parser, physics collision, runtime query service +- Unavoidable complexity and justification: tile-local ray transform is required by mesh ownership +- Measured optimization evidence: not applicable + +## Status + +- State: ready +- Done: active-mesh terrain probe, four clearance measurements, isolated waterfall missing-mesh confirmation and documentation +- Next: integrator review; placement/composition diagnosis for four points and tile 30_49 ownership diagnosis remain separate packages +- Blocked by: + +## Handoff + +- Commit: branch HEAD +- Results: four cameras are 12.034..90.178 units above terrain; waterfall tile has no accessible terrain mesh after 10-second settle +- Remaining risks: mesh ray does not measure WMO/M2 occlusion; waterfall missing mesh requires streaming ownership investigation +- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md` diff --git a/docs/RENDER_BASELINE.md b/docs/RENDER_BASELINE.md index b1fbbb2..4ef6a09 100644 --- a/docs/RENDER_BASELINE.md +++ b/docs/RENDER_BASELINE.md @@ -138,3 +138,21 @@ flowchart LR ``` На пяти точках maximum mapping/round-trip error равен `0.000015`. Это исключает текущую формулу `gx = center - wy`, `gy = wz`, `gz = center - wx` как источник крупного paired mismatch. Результат не доказывает renderer parity: следующая диагностика должна отдельно проверить terrain height, placement transforms и фактическое camera direction/FOV. Production `CoordinateMapper` остаётся задачей M01; M00 probe не создаёт второй публичный coordinate contract. + +## Terrain height diagnostic + +Rendered terrain проверяется без нового runtime API: offline probe использует уже загруженный tile mesh, строит `TriangleMesh` и выполняет вертикальный ray в tile-local space. + +```powershell +godot --headless --path . --script res://src/tools/probe_render_terrain_height.gd -- --wait 2 +``` + +```mermaid +flowchart LR + C[Calibrated camera XZ] --> S[Streaming tile state] + S --> M[Active terrain mesh] + M --> T[Tile-local TriangleMesh ray] + T --> H[Terrain height and camera clearance] +``` + +Измеренный clearance: terrain overview `89.044`, ADT boundary `44.788`, dense M2 `90.178`, large WMO `12.034` Godot units. Следовательно, эти четыре камеры находятся над rendered terrain; visual obstruction принадлежит placements/WMO/composition, а не terrain height. Waterfall tile `30_49` не публикует доступный terrain mesh даже после isolated 10-second settle; это отдельный streaming/terrain ownership gap. Default probe сохраняет partial report, а `--require-all` включает строгий ненулевой exit при любой неснятой точке. diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 0b56fa9..d038545 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -223,6 +223,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - Direct camera path remains until M01/M03. - Original-client paired fidelity evidence incomplete. - Первый paired run выявил coordinate/placement mismatch: несколько совпадающих server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC. +- Terrain-height probe исключил under-terrain состояние для четырёх точек; waterfall tile `30_49` не предоставил terrain mesh после isolated settle. - D3D12 descriptor and shutdown RID/resource issues remain. - M2/WMO/material/particle/ribbon/portal parity incomplete. - Public API is mostly exported configuration rather than stable contracts. @@ -243,6 +244,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/compare_render_checkpoints.gd` | Offline JPG/PNG paired-image perceptual metrics and JSON pass/fail report | | `src/tools/capture_render_checkpoints.gd` | Deterministic no-roll checkpoint camera, performance and visual capture | | `src/tools/verify_render_coordinate_calibration.gd` | Build 12340 camera-coordinate golden point round-trip diagnostic | +| `src/tools/probe_render_terrain_height.gd` | Offline active-mesh terrain height and camera-clearance report | ## Related decisions and references diff --git a/src/tools/probe_render_terrain_height.gd b/src/tools/probe_render_terrain_height.gd new file mode 100644 index 0000000..12955a0 --- /dev/null +++ b/src/tools/probe_render_terrain_height.gd @@ -0,0 +1,178 @@ +extends SceneTree + +## Measures camera clearance against the active rendered terrain mesh. +## Usage: godot --path . --script res://src/tools/probe_render_terrain_height.gd -- +## [--wait 3.0] [--output user://render_terrain_height/report.json] + +const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json" +const TILE_SIZE := 533.33333 +const RAY_HEIGHT := 5000.0 + + +func _initialize() -> void: + _run_async.call_deferred() + + +func _run_async() -> void: + var arguments := OS.get_cmdline_user_args() + var wait_seconds := float(_argument(arguments, "--wait", "3.0")) + var output_path := _argument(arguments, "--output", "user://render_terrain_height/report.json") + var only_filter := _argument(arguments, "--only", "").to_lower() + var require_all := arguments.has("--require-all") + var manifest := _load_json(MANIFEST_PATH) + if manifest.is_empty(): + quit(1) + return + var packed_scene: PackedScene = load(String(manifest.get("scene", ""))) + if packed_scene == null: + push_error("TERRAIN_HEIGHT_PROBE: cannot load streaming scene") + quit(1) + return + var world := packed_scene.instantiate() as Node3D + if world == null: + push_error("TERRAIN_HEIGHT_PROBE: streaming scene root is not Node3D") + quit(1) + return + var camera := Camera3D.new() + camera.current = true + world.add_child(camera) + world.set("camera_path", NodePath(camera.name)) + world.set("debug_streaming", false) + get_root().add_child(world) + await process_frame + await process_frame + + var results: Array[Dictionary] = [] + for checkpoint_variant in manifest.get("checkpoints", []): + if not (checkpoint_variant is Dictionary): + continue + var checkpoint: Dictionary = checkpoint_variant + if not checkpoint.has("reference_wow_camera"): + continue + if not only_filter.is_empty() and not String(checkpoint.get("name", "")).to_lower().contains(only_filter): + continue + var camera_position := _vector3(checkpoint.get("camera", [])) + camera.global_position = camera_position + if world.has_method("_refresh_streaming_targets_at"): + world.call("_refresh_streaming_targets_at", camera_position, true) + await create_timer(maxf(0.1, wait_seconds)).timeout + var terrain_sample := _sample_terrain(world, camera_position) + terrain_sample["name"] = checkpoint.get("name", "checkpoint") + terrain_sample["camera_y"] = camera_position.y + if terrain_sample.has("terrain_height"): + terrain_sample["camera_clearance"] = camera_position.y - float(terrain_sample.terrain_height) + results.append(terrain_sample) + + var report := { + "schema_version": 1, + "created_utc": Time.get_datetime_string_from_system(true, true), + "wait_seconds": wait_seconds, + "results": results, + } + if not _write_json(output_path, report): + quit(1) + return + var sampled_count := 0 + for result in results: + if result.has("terrain_height"): + sampled_count += 1 + print("TERRAIN_HEIGHT name=%s status=%s camera_y=%.3f terrain=%s clearance=%s" % [ + result.get("name", "checkpoint"), + result.get("status", "unknown"), + float(result.get("camera_y", 0.0)), + str(result.get("terrain_height", "n/a")), + str(result.get("camera_clearance", "n/a")), + ]) + print("TERRAIN_HEIGHT_PROBE sampled=%d total=%d report=%s" % [sampled_count, results.size(), output_path]) + world.queue_free() + var failed := sampled_count == 0 or (require_all and sampled_count != results.size()) + quit(1 if failed else 0) + + +func _sample_terrain(world: Node3D, world_position: Vector3) -> Dictionary: + var tile_coordinate := Vector2i( + int(floor(world_position.x / TILE_SIZE)), + int(floor(world_position.z / TILE_SIZE))) + var tile_states: Dictionary = world.get("_tile_states") + var highest_terrain_height := -INF + var intersected_tile_key := "" + var ready_mesh_count := 0 + for tile_y in range(tile_coordinate.y - 1, tile_coordinate.y + 2): + for tile_x in range(tile_coordinate.x - 1, tile_coordinate.x + 2): + var tile_key := "%d_%d" % [tile_x, tile_y] + if not tile_states.has(tile_key): + continue + var state: Dictionary = tile_states[tile_key] + var terrain_height_variant = _intersect_terrain_state(state, world_position) + if terrain_height_variant == null: + continue + ready_mesh_count += 1 + var terrain_height := float(terrain_height_variant) + if terrain_height > highest_terrain_height: + highest_terrain_height = terrain_height + intersected_tile_key = tile_key + if not is_finite(highest_terrain_height): + return { + "status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready", + "tile": "%d_%d" % [tile_coordinate.x, tile_coordinate.y], + } + return { + "status": "sampled", + "tile": intersected_tile_key, + "terrain_height": highest_terrain_height, + } + + +func _intersect_terrain_state(state: Dictionary, world_position: Vector3): + var terrain_mesh: Mesh = state.get("quality_terrain_mesh", null) + if terrain_mesh == null: + terrain_mesh = state.get("tile_lod_mesh", null) + if terrain_mesh == null: + return null + var triangle_mesh := terrain_mesh.generate_triangle_mesh() + if triangle_mesh == null: + return null + var tile_root := state.get("root", null) as Node3D + if tile_root == null: + return null + var inverse_transform := tile_root.global_transform.affine_inverse() + var local_ray_origin := inverse_transform * Vector3(world_position.x, RAY_HEIGHT, world_position.z) + var local_ray_direction := inverse_transform.basis * Vector3.DOWN + var intersection: Dictionary = triangle_mesh.intersect_ray(local_ray_origin, local_ray_direction) + if intersection.is_empty(): + return null + var local_hit: Vector3 = intersection.get("position", Vector3.ZERO) + var world_hit := tile_root.global_transform * local_hit + return world_hit.y + + +func _vector3(value_variant) -> Vector3: + if not (value_variant is Array) or value_variant.size() != 3: + return Vector3.ZERO + return Vector3(float(value_variant[0]), float(value_variant[1]), float(value_variant[2])) + + +func _argument(arguments: PackedStringArray, name: String, default_value: String) -> String: + var index := arguments.find(name) + if index >= 0 and index + 1 < arguments.size(): + return arguments[index + 1] + return default_value + + +func _load_json(path: String) -> Dictionary: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return {} + var parsed = JSON.parse_string(file.get_as_text()) + return parsed if parsed is Dictionary else {} + + +func _write_json(path: String, value: Dictionary) -> bool: + var absolute_path := ProjectSettings.globalize_path(path) + if DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir()) != OK: + return false + var file := FileAccess.open(absolute_path, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify(value, " ")) + return true