Compare commits

..

16 Commits

Author SHA1 Message Date
sindoring 03891edeb2 test(M00): record Goldshire pose evidence 2026-07-12 01:59:42 +04:00
sindoring 657a1d888a Merge pull request 'test(M00): add camera pose sweep' (#8) from work/sindo-main-codex/m00-camera-pose-sweep into master
Reviewed-on: #8
2026-07-12 00:47:17 +03:00
sindoring b48195d08d test(M00): add camera pose sweep 2026-07-12 01:46:02 +04:00
sindoring 8d4641a43b Merge pull request 'fix(renderer): drain threaded loads on shutdown' (#7) from work/sindo-main-codex/m00-capture-shutdown into master
Reviewed-on: #7
2026-07-12 00:39:21 +03:00
sindoring ad9ba7af4e fix(renderer): drain threaded loads on shutdown 2026-07-12 01:28:26 +04:00
sindoring f54850718a Merge pull request 'test(M00): add empirical checkpoint FOV sweep' (#6) from work/sindo-main-codex/m00-fov-sweep into master
Reviewed-on: #6
2026-07-11 23:57:40 +03:00
sindoring 5103eed014 test(M00): add empirical checkpoint FOV sweep
Work-Package: M00-QAR-FOV-SWEEP-001
Agent: sindo-main-codex
Tests: comparator filter self-test, FOV capture dry-run, bounded real sweep, repository gates
Fidelity: projection sweep remains inconclusive without reproducible reference yaw and pitch
2026-07-12 00:51:58 +04:00
sindoring 9d34a47765 Merge pull request 'test(M00): diagnose checkpoint camera occluders' (#5) from work/sindo-main-codex/m00-camera-occluders into master
Reviewed-on: #5
2026-07-11 23:04:13 +03:00
sindoring aea7787b9b test(M00): diagnose checkpoint camera occluders
Work-Package: M00-QAR-CAMERA-OCCLUDERS-001
Agent: sindo-main-codex
Tests: five-point camera occluder probe, coordination and documentation gates
Fidelity: no camera containment found; manual target and FOV mismatch recorded
2026-07-11 22:23:33 +04:00
sindoring 230620089f Merge pull request 'test(M00): diagnose waterfall tile ownership' (#4) from work/sindo-main-codex/m00-waterfall-tile-ownership into master
Reviewed-on: #4
2026-07-11 21:11:21 +03:00
sindoring 42fdf40282 test(M00): diagnose waterfall tile ownership
Work-Package: M00-QAR-TILE-OWNERSHIP-001
Agent: sindo-main-codex
Tests: strict five-point terrain probe, coordination and documentation gates
Fidelity: tile 30_49 ownership is healthy; exact ray miss classified as triangle seam
2026-07-11 22:06:22 +04:00
sindoring f8538ba2cf Merge pull request 'test(M00): probe renderer terrain clearance' (#3) from work/sindo-main-codex/m00-terrain-height into master
Reviewed-on: #3
2026-07-11 20:48:57 +03:00
sindoring d233a41ce8 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
2026-07-11 19:38:23 +04:00
sindoring dfc10312f8 Merge pull request 'test(M00): add renderer coordinate calibration' (#2) from work/sindo-main-codex/m00-coordinate-calibration into master
Reviewed-on: #2
2026-07-11 18:12:57 +03:00
sindoring 8e8ea32ba3 test(M00): add renderer coordinate calibration
Work-Package: M00-QAR-COORD-CALIBRATION-001
Agent: sindo-main-codex
Tests: coordinate probe, M00 dry-run, coordination and documentation gates
Fidelity: five build 12340 camera points round-trip within 0.000015 units
2026-07-11 19:09:46 +04:00
sindoring 19df5b7968 Merge pull request 'test(M00): add checkpoint perceptual comparison' (#1) from work/sindo-main-codex/m00-checkpoint-diff into master
Reviewed-on: #1
2026-07-11 17:57:04 +03:00
20 changed files with 1461 additions and 15 deletions
@@ -0,0 +1,78 @@
# M00-QAR-CAMERA-OCCLUDERS-001 — Camera occluder diagnostic
<!-- OPENWC_CLAIM:M00-QAR-CAMERA-OCCLUDERS-001:sindo-main-codex:2026-07-13 -->
## Ownership
- Target: M00
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m00-camera-occluders`
- Lease expires UTC: 2026-07-13
- Integrator: milestone integrator
## Outcome
Report scene-tree M2/WMO geometry that contains calibrated cameras or intersects each camera-to-target segment.
## Non-goals
- Changing placements, cameras, FOV, culling, or renderer behavior.
- Claiming full coverage of RenderingServer RID-only instances.
- Implementing a production visibility or collision service.
## Paths
- Exclusive: `src/tools/probe_render_camera_occluders.gd`
- Shared/hotspots: renderer baseline documentation
- 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 workflow
## Dependencies
- Requires: calibrated five-point manifest and streaming scene
- Blocks: placement versus camera-composition classification
- External state: local extracted/cache data
## Verification
- Commands: camera occluder probe, coordination and documentation gates
- Fixtures: five calibrated camera/target segments
- Fidelity evidence: scene-tree bounds at paired build 12340 viewpoints
- Performance budget: offline diagnostic
## Documentation deliverables
- Inline public API docs: probe header and output fields
- Module specification: verification/source map
- Data-flow diagram: occluder probe flow
- Sequence/state/dependency diagrams: synchronous diagnostic; not applicable
- Source map/status updates: baseline findings
## Simplicity and naming
- Important names introduced: `camera_containing_geometry`, `segment_intersecting_geometry`
- Simplest considered solution: transformed published AABBs
- Rejected complexity/abstractions: GPU visibility readback or new BVH
- Unavoidable complexity and justification: RID-only geometry cannot be named by this probe
- Measured optimization evidence: not applicable
## Status
- State: ready
- Done: five-point scene-tree AABB probe, containment/intersection classification and documentation
- Next: integrator review; calibrate reproducible reference camera direction/FOV separately
- Blocked by:
## Handoff
- Commit: branch HEAD
- Results: zero containing geometry at all five cameras; expected WMO/liquid target intersections; ADT/dense segments unobstructed
- Remaining risks: RID-only instances are excluded; manual reference direction and FOV were not recorded exactly
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
@@ -0,0 +1,78 @@
# M00-QAR-CAMERA-POSE-EVIDENCE-001 — Goldshire camera pose evidence
<!-- OPENWC_CLAIM:M00-QAR-CAMERA-POSE-EVIDENCE-001:sindo-main-codex:2026-07-14 -->
## Ownership
- Target: M00
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m00-camera-pose-evidence`
- Lease expires UTC: 2026-07-14
- Integrator: milestone integrator
## Outcome
Produce coarse-to-fine yaw/pitch evidence for the build 12340 Goldshire Inn reference and classify whether framing dominates the paired-image gap.
## Non-goals
- Changing manifest camera defaults before human approval.
- Treating minimum perceptual error as proof of exact camera parity.
- Committing proprietary reference images or generated candidates.
## Paths
- Exclusive: camera-pose evidence record
- Shared/hotspots: renderer baseline and module fidelity findings
- Generated/ignored: all sweep PNGs and JSON under `user://`
## Contracts and data
- Public API/events: none
- Schema/format version: unchanged
- Migration/compatibility: none
- Consumers: M00 fidelity review
## Dependencies
- Requires: merged camera-pose sweep and local build 12340 reference
- Blocks: classification of the Goldshire WMO paired gap
- External state: `sources/OpenWCReferenceCheckpoints/goldshire_inn_large_wmo.jpg`
## Verification
- Commands: coarse and fine real sweeps, ranking inspection, repository gates
- Fixtures: private Goldshire Inn build 12340 screenshot
- Fidelity evidence: ranked mean perceptual error and changed-pixel ratio
- Performance budget: offline diagnostic only
## Documentation deliverables
- Inline public API docs: unchanged
- Module specification: fidelity finding and remaining risk
- Data-flow diagram: unchanged
- Sequence/state/dependency diagrams: unchanged
- Source map/status updates: baseline evidence
## Simplicity and naming
- Important names introduced: none
- Simplest considered solution: use the merged bounded grid runner
- Rejected complexity/abstractions: optimizer or renderer changes
- Unavoidable complexity and justification: real images must be rendered for each candidate
- Measured optimization evidence: single pass per candidate
## Status
- State: ready-for-review
- Done: private reference dimensions verified; coarse, extended and limit sweeps completed; representative candidates inspected; runner failure handling hardened
- Next: investigate Goldshire Inn spatial/placement composition separately; do not change manifest yaw/pitch from this metric
- Blocked by:
## Handoff
- Commit: this work-package commit
- Results: zero offset mean error 0.099632; coarse best (-10,-20) 0.077575; metric decreased monotonically to (0,-60) 0.063574/changed ratio 0.549556, while visual inspection showed only grass and no inn
- Remaining risks: full-frame color error cannot register a missing landmark; WMO placement/streaming at this checkpoint remains unresolved
- Documentation updated: `docs/CAMERA_POSE_SWEEP.md`, `docs/modules/world-renderer.md`
@@ -0,0 +1,78 @@
# M00-QAR-CAMERA-POSE-SWEEP-001 — Empirical camera pose sweep
<!-- OPENWC_CLAIM:M00-QAR-CAMERA-POSE-SWEEP-001:sindo-main-codex:2026-07-14 -->
## Ownership
- Target: M00
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m00-camera-pose-sweep`
- Lease expires UTC: 2026-07-14
- Integrator: milestone integrator
## Outcome
Recover reproducible checkpoint framing by sweeping bounded yaw/pitch offsets and ranking paired-image error.
## Non-goals
- Changing runtime player camera defaults.
- Claiming an exact original-client camera pose from perceptual score alone.
- Adding computer-vision registration or image warping.
## Paths
- Exclusive: camera-pose CLI and sweep orchestration
- Shared/hotspots: renderer baseline documentation
- Generated/ignored: local candidate PNGs and JSON reports
## Contracts and data
- Public API/events: additive camera yaw/pitch offset and single-pass capture options; new PowerShell sweep command
- Schema/format version: capture report schema remains version 1 with additive camera-offset fields
- Migration/compatibility: existing capture commands retain zero offsets and two passes
- Consumers: M00 paired-fidelity workflow
## Dependencies
- Requires: build 12340 reference image, capture tool, paired-image comparator
- Blocks: reproducible manual-reference framing
- External state: original screenshots remain outside Git
## Verification
- Commands: capture dry-run offset regression, comparator self-test, bounded local sweep when a display/reference is available, repository gates
- Fixtures: one named M00 checkpoint
- Fidelity evidence: ranked yaw/pitch candidates with explicit human-approval requirement
- Performance budget: offline diagnostic; single-pass mode avoids redundant warm capture
## Documentation deliverables
- Inline public API docs: capture and sweep usage
- Module specification: camera-pose data flow, verification and source map
- Data-flow diagram: updated pose-sweep flow
- Sequence/state/dependency diagrams: unchanged
- Source map/status updates: baseline workflow and findings
## Simplicity and naming
- Important names introduced: `camera_yaw_offset_degrees`, `camera_pitch_offset_degrees`, `single_pass`
- Simplest considered solution: bounded grid over existing capture/comparator tools
- Rejected complexity/abstractions: feature matching, optimizer framework, image transforms
- Unavoidable complexity and justification: original-client camera angles are not exposed by tested APIs
- Measured optimization evidence: single-pass mode halves captures per candidate
## Status
- State: ready-for-review
- Done: additive capture offsets, single-pass calibration mode, bounded grid runner, ranked JSON contract, grid-plan regression and documentation
- Next: run a coarse-to-fine real sweep with the private build 12340 reference directory, then obtain human framing approval
- Blocked by:
## Handoff
- Commit: this work-package commit
- Results: dry-run reported yaw 12.50/pitch -7.50; comparator self-test passed; a 3x2 plan produced six unique candidates; full seven-checkpoint M00 dry-run passed with default zero offsets
- Remaining risks: no private reference directory was available in this worktree, so real perceptual ranking and human approval remain external evidence
- Documentation updated: `docs/CAMERA_POSE_SWEEP.md`; renderer module verification, risk and source map
@@ -0,0 +1,78 @@
# M00-QAR-CAPTURE-SHUTDOWN-001 — Capture shutdown drain
<!-- OPENWC_CLAIM:M00-QAR-CAPTURE-SHUTDOWN-001:sindo-main-codex:2026-07-14 -->
## Ownership
- Target: M00
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m00-capture-shutdown`
- Lease expires UTC: 2026-07-14
- Integrator: milestone integrator
## Outcome
Eliminate the reproducible capture-tool ObjectDB leak by completing owned threaded resource requests and draining deferred scene deletion before process exit.
## Non-goals
- Refactoring renderer resource ownership.
- Hiding genuine RID or worker-task leaks.
- Changing capture images, metrics, or streaming behavior.
## Paths
- Exclusive: capture tool shutdown sequence
- Shared/hotspots: renderer baseline documentation
- Generated/ignored: verbose Godot logs
## Contracts and data
- Public API/events: none
- Schema/format version: unchanged
- Migration/compatibility: none
- Consumers: M00 baseline runner
## Dependencies
- Requires: reproducible verbose leak diagnostic
- Blocks: clean M00 capture shutdown evidence
- External state: none
## Verification
- Commands: identical verbose dry-run before/after, M00 dry-run, repository gates
- Fixtures: ADT-boundary filtered capture
- Fidelity evidence: no visual behavior change
- Performance budget: blocking resource completion is restricted to shutdown; two SceneTree drain frames only
## Documentation deliverables
- Inline public API docs: shutdown rationale comment
- Module specification: recovery/known-risk update
- Data-flow diagram: unchanged
- Sequence/state/dependency diagrams: shutdown sequence documented in baseline
- Source map/status updates: baseline evidence
## Simplicity and naming
- Important names introduced: none
- Simplest considered solution: await two SceneTree frames after `queue_free` and finish registered in-flight ResourceLoader requests during loader shutdown
- Rejected complexity/abstractions: resource registry or manual child traversal
- Unavoidable complexity and justification: deferred deletion requires frame drain
- Measured optimization evidence: not applicable
## Status
- State: ready-for-review
- Done: one leaked RefCounted reproduced; isolation attributed it to `StreamingWorldLoader`; active threaded tile request was confirmed as the retained object; all owned threaded resource registries now finish in-progress requests before clearing; two identical verbose ADT-boundary runs and the full M00 dry-run completed without ObjectDB leak diagnostics
- Next: integrator review and merge
- Blocked by:
## Handoff
- Commit: this work-package commit
- Results: verbose filtered capture returned one report and zero `Leaked instance`/`ObjectDB instances leaked` lines on two consecutive runs; `run_render_baseline.ps1 -DryRun` passed all gates and seven checkpoints
- Remaining risks:
- Documentation updated: `docs/modules/world-renderer.md` shutdown sequence, ownership contract, recovery table and known-risk status
@@ -0,0 +1,78 @@
# M00-QAR-COORD-CALIBRATION-001 — Renderer coordinate calibration
<!-- OPENWC_CLAIM:M00-QAR-COORD-CALIBRATION-001:sindo-main-codex:2026-07-13 -->
## Ownership
- Target: M00
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m00-coordinate-calibration`
- Lease expires UTC: 2026-07-13
- Integrator: milestone integrator
## Outcome
Record the five observed build 12340 camera positions as non-proprietary golden coordinates and determine whether the existing WoW/Godot position formula round-trips them.
## Non-goals
- Introducing the M01 production `CoordinateMapper` contract.
- Changing terrain, placement, WMO, liquid, or streaming behavior.
- Claiming camera composition parity from coordinate round-trip alone.
## Paths
- Exclusive: `src/tools/verify_render_coordinate_calibration.gd`
- Shared/hotspots: `src/tools/render_baseline_manifest.json`, renderer baseline documentation
- Generated/ignored: original-client screenshots and local reports
## Contracts and data
- Public API/events: none; headless diagnostic only
- Schema/format version: additive checkpoint calibration metadata, manifest schema remains 1
- Migration/compatibility: existing capture consumers ignore additive fields
- Consumers: M00 fidelity workflow and future M01 golden fixtures
## Dependencies
- Requires: five accepted original-client build 12340 viewpoints
- Blocks: diagnosis of paired camera/placement mismatch
- External state: screenshots remain outside Git
## Verification
- Commands: coordinate calibration probe, baseline manifest, coordination and documentation gates
- Fixtures: five `reference_wow_camera` values in renderer manifest
- Fidelity evidence: positions were observed in build 12340 during the paired session
- Performance budget: negligible headless arithmetic
## Documentation deliverables
- Inline public API docs: diagnostic script header
- Module specification: renderer verification/source map update
- Data-flow diagram: baseline calibration flow update
- Sequence/state/dependency diagrams: not applicable; stateless synchronous probe
- Source map/status updates: renderer module and baseline document
## Simplicity and naming
- Important names introduced: `reference_wow_camera`, `maximum_round_trip_error`
- Simplest considered solution: direct formula probe over manifest fixtures
- Rejected complexity/abstractions: production domain mapper before M01
- Unavoidable complexity and justification: none
- Measured optimization evidence: not applicable
## Status
- State: ready
- Done: five golden camera points, headless round-trip probe, runner integration and documentation
- Next: integrator review; diagnose terrain height/placement/composition separately
- Blocked by:
## Handoff
- Commit: branch HEAD
- Results: five points passed with maximum mapping/round-trip error 0.000015
- Remaining risks: correct position arithmetic does not prove terrain height, placement or camera direction parity
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
@@ -0,0 +1,78 @@
# M00-QAR-FOV-SWEEP-001 — Empirical camera FOV sweep
<!-- OPENWC_CLAIM:M00-QAR-FOV-SWEEP-001:sindo-main-codex:2026-07-14 -->
## Ownership
- Target: M00
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m00-fov-sweep`
- Lease expires UTC: 2026-07-14
- Integrator: milestone integrator
## Outcome
Support a bounded empirical FOV sweep when build 12340 does not expose a readable camera FOV CVar.
## Non-goals
- Claiming exact WoW FOV from a single manually aimed screenshot.
- Changing runtime player camera defaults.
- Automating yaw/pitch registration or image warping.
## Paths
- Exclusive: FOV/filter CLI additions in checkpoint tools
- Shared/hotspots: renderer baseline documentation
- Generated/ignored: local sweep PNGs and JSON reports
## Contracts and data
- Public API/events: additive `--camera-fov` and comparator `--only` CLI options
- Schema/format version: unchanged
- Migration/compatibility: existing commands retain defaults
- Consumers: M00 fidelity workflow
## Dependencies
- Requires: build 12340 reference JPG and paired-image comparator
- Blocks: empirical projection ranking
- External state: original screenshots remain outside Git
## Verification
- Commands: synthetic comparator test, capture dry-run with override, bounded real sweep
- Fixtures: `elwynn_adt_boundary` reference
- Fidelity evidence: ranked perceptual metrics with manual-direction limitation
- Performance budget: offline diagnostic
## Documentation deliverables
- Inline public API docs: CLI headers
- Module specification: verification/source map if needed
- Data-flow diagram: FOV sweep flow
- Sequence/state/dependency diagrams: not applicable
- Source map/status updates: baseline findings
## Simplicity and naming
- Important names introduced: `camera_fov_override`, `only_filter`
- Simplest considered solution: reuse capture and comparator
- Rejected complexity/abstractions: computer vision registration framework
- Unavoidable complexity and justification: multiple captures are required because client FOV is inaccessible
- Measured optimization evidence: not applicable
## Status
- State: ready
- Done: client FOV access audit, capture override, comparator filter/regression, dedicated-camera ownership fix and bounded sweep
- Next: integrator review; obtain reproducible yaw/pitch/zoom metadata before changing normative FOV
- Blocked by:
## Handoff
- Commit: branch HEAD
- Results: corrected ranking 26=0.079588, 38=0.079633, 50=0.084353, 62=0.088360, 86=0.097993; plateau is inconclusive
- Remaining risks: build 12340 FOV is inaccessible through tested APIs; manual direction/framing dominates metrics
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
@@ -0,0 +1,78 @@
# M00-QAR-TERRAIN-HEIGHT-001 — Terrain height diagnostic
<!-- OPENWC_CLAIM:M00-QAR-TERRAIN-HEIGHT-001:sindo-main-codex:2026-07-13 -->
## 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`
@@ -0,0 +1,78 @@
# M00-QAR-TILE-OWNERSHIP-001 — Waterfall terrain tile ownership
<!-- OPENWC_CLAIM:M00-QAR-TILE-OWNERSHIP-001:sindo-main-codex:2026-07-13 -->
## Ownership
- Target: M00
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m00-waterfall-tile-ownership`
- Lease expires UTC: 2026-07-13
- Integrator: milestone integrator
## Outcome
Identify the runtime transition that prevents waterfall tile `30_49` from exposing a terrain mesh to the height probe.
## Non-goals
- Changing streaming budgets or terrain behavior.
- Rebuilding caches or modifying extracted assets.
- Adding a production terrain query service.
## Paths
- Exclusive: terrain probe runtime ownership diagnostics
- Shared/hotspots: renderer baseline documentation
- Generated/ignored: local probe reports and caches
## Contracts and data
- Public API/events: additive diagnostic JSON fields
- Schema/format version: terrain report remains schema 1
- Migration/compatibility: additive fields only
- Consumers: M00 fidelity diagnosis
## Dependencies
- Requires: merged terrain-height probe and local cache inventory
- Blocks: waterfall terrain ownership classification
- External state: local extracted/cache data
## Verification
- Commands: isolated waterfall probe, coordination and documentation gates
- Fixtures: checkpoint tile `30_49`
- Fidelity evidence: runtime state correlated with build 12340 waterfall viewpoint
- Performance budget: offline diagnostic
## Documentation deliverables
- Inline public API docs: diagnostic output fields
- Module specification: verification/source map if behavior changes
- Data-flow diagram: update ownership transition if needed
- Sequence/state/dependency diagrams: document observed tile transition
- Source map/status updates: baseline findings
## Simplicity and naming
- Important names introduced: `available`, `queued_index`, `loading`, `state_present`, `mesh_source`
- Simplest considered solution: inspect existing loader registries read-only
- Rejected complexity/abstractions: new tracing framework
- Unavoidable complexity and justification: none
- Measured optimization evidence: not applicable
## Status
- State: ready
- Done: raw/cache inventory, isolated runtime state, mesh AABB/local probe and nearby sampling diagnosis
- Next: integrator review; placement/composition remains the actual paired-camera gap
- Blocked by:
## Handoff
- Commit: branch HEAD
- Results: tile 30_49 ownership and meshes are healthy; exact ray misses a triangle seam/edge, while a 2-unit offset samples terrain at 113.872
- Remaining risks: nearby estimate is diagnostic and must not become a gameplay terrain-query contract
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`
+48
View File
@@ -0,0 +1,48 @@
# Renderer Camera Pose Sweep
This offline M00 diagnostic recovers reproducible checkpoint framing when an original-client screenshot has no recorded camera yaw or pitch. It ranks a bounded grid; it does not change renderer or player-camera defaults and does not prove the exact build 12340 camera pose without human approval.
## Camera contract
`capture_render_checkpoints.gd` accepts additive `--camera-yaw-offset` and `--camera-pitch-offset` values in degrees. Yaw rotates the no-roll checkpoint basis around Godot world `Vector3.UP`; pitch then rotates around the camera-local right axis. Zero offsets preserve the manifest target exactly. `--single-pass` captures only `cold_process` and is intended for pose calibration, not performance baselines.
## Workflow
```powershell
.\tools\sweep_render_checkpoint_camera_pose.ps1 `
-ReferenceDirectory 'D:\private-fixtures\wow-3.3.5a-checkpoints' `
-Checkpoint elwynn_adt_boundary `
-YawOffsets -15,-10,-5,0,5,10,15 `
-PitchOffsets -10,-5,0,5,10 `
-ViewportWidth 1280 -ViewportHeight 960 `
-WaitSeconds 2
```
Each candidate receives its own output directory and comparison report. The viewport must exactly match the reference image dimensions; the runner fails on `size_mismatch` instead of inventing a score. `ranking.json` sorts candidates by mean perceptual error and then changed-pixel ratio. Use `-PlanOnly` to validate the Cartesian grid and output paths without rendering. Run a coarse grid first, then a finer grid around the best candidate.
```mermaid
flowchart LR
Y[Yaw offsets in degrees] --> G[Bounded Cartesian grid]
P[Pitch offsets in degrees] --> G
G --> C[Single-pass checkpoint captures]
R[Original-client reference] --> D[Perceptual comparator]
C --> D
D --> J[ranking.json]
J --> H[Human framing approval]
```
## Interpretation and recovery
The smallest error is a candidate, not automatic fidelity approval. Geometry, materials, lighting and FOV can move the perceptual optimum away from the true original-client pose. Inspect the best images manually and retain the original reference outside Git. A missing matching reference or candidate is a hard error; correct the checkpoint filter or filenames and rerun. Generated PNG and JSON outputs are disposable local evidence.
## Verification
The headless capture dry-run must report requested yaw and pitch offsets. Comparator `--self-test` covers paired metrics. Sweep `-PlanOnly` covers deterministic grid expansion and output naming without requiring a display. A real ranked sweep requires a display and a private build 12340 reference image.
## Goldshire Inn evidence — 2026-07-12
The private `2560x1440` build 12340 `goldshire_inn_large_wmo` reference was evaluated at FOV 62 degrees. A coarse 5x5 grid over yaw/pitch `[-20, -10, 0, 10, 20]` reduced mean error from `0.099632` at `(0, 0)` to `0.077575` at `(-10, -20)`. Extending pitch produced `0.070901` at `(0, -40)` and `0.063574` at `(0, -60)` with changed-pixel ratio `0.549556`.
This is not a camera-pose solution. Human inspection shows the original-client reference centered on the Goldshire Inn, while the zero-offset OpenWC candidate contains forest/roads and no inn; negative pitch increasingly fills the frame with grass. The monotonically improving full-frame metric rewards similar green color coverage rather than landmark alignment. Therefore the manifest pose is unchanged and this checkpoint remains spatial/placement-composition evidence. A future registration metric must use approved landmark or region masks before it can select camera pose automatically.
During this run the sweep orchestration was hardened in two ways: expected comparator exit code `1` is now collected through an explicit child process, and viewport dimensions are explicit. A reference/candidate `size_mismatch` now fails before ranking instead of producing an empty metric.
+74
View File
@@ -120,3 +120,77 @@ Baseline пока не имеет approved парных кадров ориги
Первый paired run после калибровки создал десять сравнений (пять reference × cold/warm), без missing pairs. Все пары ожидаемо превысили строгий tolerance: mean perceptual error `0.0707..0.1746`, changed-pixel ratio `0.5504..0.8187`. Human inspection показал, что это пока не чистая material/lighting ошибка: при тех же WoW-derived координатах OpenWC terrain-overview camera находится под terrain/placements, WMO camera — внутри таверны, liquid camera — внутри скал; ADT и dense-M2 композиции также существенно смещены. До исправления coordinate/placement mismatch эти значения являются gap evidence, а не основанием расширять tolerance.
Capture tool строит camera basis явно из target и world-up. Это исключает неоднозначный roll `look_at` при автоматической съёмке. `ViewportTexture.get_image()` сохраняется без дополнительного vertical flip для Godot 4.6.1.
## Coordinate calibration
Пять принятых build 12340 viewpoints записаны в manifest как `reference_wow_camera`. Они содержат только числовые world coordinates и не включают proprietary данные. Headless probe:
```powershell
godot --headless --path . --script res://src/tools/verify_render_coordinate_calibration.gd
```
```mermaid
flowchart LR
O[Observed build 12340 WoW XYZ] --> W[WoW to Godot formula]
W --> G[Manifest Godot camera XYZ]
G --> R[Godot to WoW round-trip]
R --> E[Maximum numeric error]
```
На пяти точках 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`, waterfall примерно `76.128` Godot units. Следовательно, все пять камер находятся над rendered terrain; visual obstruction принадлежит placements/WMO/composition, а не terrain height.
Waterfall XZ сначала давал `no_intersection`, хотя tile `30_49` был available, полностью загружен, имел `control_splat_cache` quality mesh и LOD0 mesh, а probe находился внутри mesh AABB. Ray со смещением `2.0` units пересёк тот же mesh на высоте `113.872`; точная XZ попала на triangle seam/edge numerical miss. Probe теперь сообщает `sampled_nearby`, distance и source tile вместо ложного streaming ownership gap. `--require-all` остаётся строгим режимом для действительно неснятых точек.
## Camera occluder diagnostic
Scene-tree placement composition проверяется transformed AABB без изменения renderer:
```powershell
godot --headless --path . --script res://src/tools/probe_render_camera_occluders.gd -- --wait 3
```
```mermaid
flowchart LR
C[Calibrated camera] --> A[Published Mesh/MultiMesh AABBs]
T[Manifest target] --> S[Camera-to-target segment]
A --> P[Camera containment test]
A --> I[Segment intersection test]
S --> I
P --> J[JSON occluder report]
I --> J
```
Ни одна из пяти камер не находится внутри опубликованной scene-tree geometry. Terrain-overview segment пересекает четыре Stormwind WMO groups, large-WMO segment — три Goldshire Inn groups, waterfall segment — liquid surface; ADT boundary и dense-M2 segments не пересекают placement AABB. Поэтому прежнее визуальное впечатление «камера внутри WMO/placements» не подтверждается. Основной paired gap сейчас — неточно воспроизведённые manual look direction/target/FOV reference-кадров: например, автоматический Goldshire target направляет луч через фасад внутрь WMO, тогда как reference был вручную скадрирован на весь фасад. Probe охватывает только scene-tree MeshInstance/MultiMesh; RID-only instances не имеют доступного semantic path и явно исключены из coverage.
## Empirical FOV sweep
В build 12340 `GetCVar("cameraFoV")` возвращает `nil`, а `/console cameraFoV` и `ConsoleExec("cameraFoV")` не выдают значения. Поэтому capture tool поддерживает additive `--camera-fov`, а comparator — `--only`, позволяющий ограничить sweep одним reference checkpoint.
```mermaid
flowchart LR
F[Candidate vertical FOV] --> C[Dedicated checkpoint camera]
C --> P[Filtered checkpoint PNGs]
R[One original-client reference] --> D[Perceptual comparator --only]
P --> D
D --> M[Ranked metrics]
```
Первый sweep обнаружил capture defect: разные FOV иногда создавали одинаковые hashes, потому что scene camera могла перехватить viewport после входа в tree. Capture теперь вызывает `camera.make_current()` после добавления world и перед каждым checkpoint. После исправления ADT-boundary ranking стал: `26° → 0.079588`, `38° → 0.079633`, `50° → 0.084353`, `62° → 0.088360`, `86° → 0.097993` mean error. Plateau `2638°` и несовпавший changed-pixel optimum показывают доминирование manual look direction/framing; эти данные не обосновывают изменение нормативного manifest FOV `62°`. Для настоящей калибровки reference capture должен сохранять воспроизводимые yaw/pitch/zoom или независимый projection fixture.
+17 -4
View File
@@ -141,6 +141,9 @@ sequenceDiagram
Stream->>Budget: enqueue finalize operations
Budget->>Render: attach bounded terrain/M2/WMO/liquid work
Stream->>Render: evict outside retention range
Stream->>Worker: shutdown: wait for WorkerThreadPool tasks
Stream->>Stream: shutdown: finish registered ResourceLoader requests
Stream->>Render: clear queues, nodes, caches and RIDs
```
## Ownership, threading and resources
@@ -149,7 +152,7 @@ sequenceDiagram
- Worker tasks не должны менять SceneTree и shared Resource concurrently.
- Parsed/grouped results передаются обратно через guarded result queues.
- Mesh/material/node/RID finalization выполняется main thread и ограничивается exported budgets.
- Shutdown/map switch обязан отменить/дождаться jobs и освободить RIDs; M00 всё ещё фиксирует leaked-resource risk.
- Shutdown/map switch обязан дождаться WorkerThreadPool jobs и зарегистрированных threaded ResourceLoader requests до очистки очередей, nodes, caches и RIDs.
- Cache resources считаются immutable после публикации.
## Errors, cancellation and recovery
@@ -162,7 +165,7 @@ sequenceDiagram
| Main-thread hitch | Named section timing | Frame spike, work remains queued | `HITCH` log | Lower budget/fix finalize path |
| D3D12 descriptor exhaustion | Rendering backend error | Render failure/fallback backend | Godot error + baseline report | Dedup resources/fix settings |
| Teleport/map change | Focus/session transition | Old jobs become stale | Target/session generation | Cancel/drop stale results |
| Shutdown leak | Godot leak/RID diagnostics | Resource retained | Shutdown report | Ownership cleanup before DONE |
| Shutdown leak | Godot leak/RID diagnostics | Resource retained | Verbose shutdown report | Drain owned worker and resource requests before clearing registries |
## Configuration and capabilities
@@ -192,7 +195,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Verification
- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest, synthetic perceptual checkpoint diff.
- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff, camera-pose grid plan.
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints.
- Fidelity evidence: пять локальных build 12340 reference JPG откалибровали terrain/ADT/M2/WMO/liquid viewpoints; automated paired-image metrics exist, но synthetic animation/dusk и полный human approval ещё не закрыты.
- Performance budgets: M00 report records cold/warm p95 and max hitch; no final acceptance threshold yet.
@@ -223,7 +226,12 @@ 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.
- D3D12 descriptor and shutdown RID/resource issues remain.
- Terrain-height probe исключил under-terrain состояние для всех пяти точек; waterfall exact-XZ miss классифицирован как TriangleMesh seam/edge и подтверждён nearby sample в 2 units.
- Camera-occluder probe не нашёл camera containment в пяти точках; paired mismatch локализован прежде всего в manual look direction/target/FOV calibration, с явным ограничением по RID-only geometry.
- Empirical FOV sweep выявил, что checkpoint camera должна явно вызывать `make_current()`; после исправления projection ranking остаётся inconclusive из-за неизвестного manual yaw/pitch/framing reference.
- Camera-pose sweep can now rank bounded yaw/pitch grids without changing manifest defaults; perceptual ranking remains diagnostic and requires human framing approval.
- Goldshire Inn pose evidence has no landmark-aligned optimum: full-frame error decreases as negative pitch replaces the missing inn composition with grass. The checkpoint remains a spatial/placement gap, not a validated camera offset.
- D3D12 descriptor issues remain; the capture-path anonymous `RefCounted` shutdown leak is regression-covered by a clean verbose dry-run, while other RID/resource diagnostics still require independent evidence.
- M2/WMO/material/particle/ribbon/portal parity incomplete.
- Public API is mostly exported configuration rather than stable contracts.
@@ -242,10 +250,15 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| `tools/run_render_baseline.ps1` | Unified M00 baseline runner |
| `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 |
| `tools/sweep_render_checkpoint_camera_pose.ps1` | Offline yaw/pitch capture grid and paired-error ranking |
| `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 |
| `src/tools/probe_render_camera_occluders.gd` | Scene-tree placement containment and camera-to-target AABB intersection report |
## Related decisions and references
- [`../../RENDER.md`](../../RENDER.md)
- [`../RENDER_BASELINE.md`](../RENDER_BASELINE.md)
- [`../CAMERA_POSE_SWEEP.md`](../CAMERA_POSE_SWEEP.md)
- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md)
- [`../GODOT_BEST_PRACTICES.md`](../GODOT_BEST_PRACTICES.md)
@@ -2073,7 +2073,7 @@ func _wait_for_tile_tasks() -> void:
var path: String = String(pending.get("path", ""))
if not path.is_empty():
var status := ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
ResourceLoader.load_threaded_get(path)
_tile_loading_tasks.clear()
@@ -2094,7 +2094,7 @@ func _wait_for_tile_tasks() -> void:
if path.is_empty():
continue
var status := ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
ResourceLoader.load_threaded_get(path)
_m2_mesh_load_requests.clear()
_m2_mesh_finalize_queue.clear()
@@ -2105,7 +2105,7 @@ func _wait_for_tile_tasks() -> void:
if path.is_empty():
continue
var status := ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
ResourceLoader.load_threaded_get(path)
_m2_animation_load_requests.clear()
_m2_animation_finalize_queue.clear()
@@ -2115,7 +2115,7 @@ func _wait_for_tile_tasks() -> void:
if path.is_empty():
continue
var status := ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
ResourceLoader.load_threaded_get(path)
_wmo_scene_load_requests.clear()
@@ -2124,7 +2124,7 @@ func _wait_for_tile_tasks() -> void:
if path.is_empty():
continue
var status := ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
ResourceLoader.load_threaded_get(path)
_wmo_render_load_requests.clear()
_wmo_render_missing_cache.clear()
@@ -2137,7 +2137,7 @@ func _wait_for_tile_tasks() -> void:
if path.is_empty():
continue
var status := ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
ResourceLoader.load_threaded_get(path)
_terrain_upgrade_tasks.clear()
@@ -2148,7 +2148,7 @@ func _wait_for_tile_tasks() -> void:
if path.is_empty():
continue
var status := ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
ResourceLoader.load_threaded_get(path)
_terrain_control_splat_cache_tasks.clear()
@@ -2162,7 +2162,7 @@ func _wait_for_tile_tasks() -> void:
if path.is_empty():
continue
var status := ResourceLoader.load_threaded_get_status(path)
if status == ResourceLoader.THREAD_LOAD_LOADED:
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
ResourceLoader.load_threaded_get(path)
_terrain_splat_cache_tasks.clear()
_terrain_splat_result_mutex.lock()
+26 -2
View File
@@ -2,6 +2,7 @@ extends SceneTree
const DEFAULT_MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
const M2_NATIVE_ANIMATED_BUILDER := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
const SHUTDOWN_DRAIN_FRAMES := 2
func _initialize() -> void:
@@ -22,6 +23,10 @@ func _capture_async() -> void:
var measure_seconds := float(_arg(args, "--measure", str(manifest.get("default_measure_seconds", 3.0))))
var revision := _arg(args, "--revision", "worktree")
var cache_state := _arg(args, "--cache-state", "existing")
var camera_fov_override := float(_arg(args, "--camera-fov", str(manifest.get("camera_fov", 62.0))))
var camera_yaw_offset_degrees := float(_arg(args, "--camera-yaw-offset", "0.0"))
var camera_pitch_offset_degrees := float(_arg(args, "--camera-pitch-offset", "0.0"))
var single_pass := args.has("--single-pass")
var headless := DisplayServer.get_name().to_lower() == "headless"
var dry_run := args.has("--dry-run") or headless
var viewport: Array = manifest.get("viewport", [1280, 900])
@@ -55,7 +60,7 @@ func _capture_async() -> void:
var camera := Camera3D.new()
camera.name = "CheckpointCamera"
camera.current = true
camera.fov = float(manifest.get("camera_fov", 62.0))
camera.fov = camera_fov_override
camera.far = 50000.0
camera.position = _vector3(first.get("camera", [0.0, 0.0, 0.0]))
(world as Node3D).add_child(camera)
@@ -65,6 +70,7 @@ func _capture_async() -> void:
get_root().add_child(world)
await process_frame
await process_frame
camera.make_current()
var player := world.get_node_or_null("ThirdPersonPlayer") as Node3D
if player != null:
@@ -82,6 +88,9 @@ func _capture_async() -> void:
"created_utc": Time.get_datetime_string_from_system(true, true),
"dry_run": dry_run,
"cache_state": cache_state,
"camera_fov": camera_fov_override,
"camera_yaw_offset_degrees": camera_yaw_offset_degrees,
"camera_pitch_offset_degrees": camera_pitch_offset_degrees,
"environment": _environment_metadata(),
"comparison_budgets": manifest.get("comparison_budgets", {}),
"cache_contract": manifest.get("cache_contract", {}),
@@ -90,6 +99,8 @@ func _capture_async() -> void:
}
var captured := 0
var passes := ["cold_process", "warm_revisit"]
if single_pass:
passes = ["cold_process"]
if dry_run:
passes = ["dry_run"]
@@ -110,6 +121,8 @@ func _capture_async() -> void:
camera.global_position = _vector3(checkpoint.get("camera", [0.0, 0.0, 0.0]))
_orient_camera_without_roll(camera, _vector3(checkpoint.get("target", [0.0, 0.0, 0.0])))
_apply_camera_pose_offsets(camera, camera_yaw_offset_degrees, camera_pitch_offset_degrees)
camera.make_current()
if player != null:
player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0])))
_set_sky_time(world, float(checkpoint.get("time_hours", 13.0)))
@@ -117,11 +130,13 @@ func _capture_async() -> void:
world.call("_refresh_streaming_targets_at", camera.global_position, true)
if dry_run:
print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s time=%.2f" % [
print("RENDER_CHECKPOINT dry_run name=%s coverage=%s camera=%s target=%s yaw_offset=%.2f pitch_offset=%.2f time=%.2f" % [
checkpoint_name,
str(checkpoint.get("coverage", [])),
camera.global_position,
_vector3(checkpoint.get("target", [0.0, 0.0, 0.0])),
camera_yaw_offset_degrees,
camera_pitch_offset_degrees,
float(checkpoint.get("time_hours", 13.0)),
])
(report["results"] as Array).append(_result_record(checkpoint, pass_name, 0.0, {}, ""))
@@ -163,6 +178,10 @@ func _capture_async() -> void:
print("RENDER_BASELINE report=%s results=%d" % [report_path, captured])
world.queue_free()
# SceneTree deletion is deferred. Allow the world and its queued children
# to run their shutdown paths before engine teardown.
for unused_frame in SHUTDOWN_DRAIN_FRAMES:
await process_frame
if captured <= 0:
push_error("No checkpoints selected. --only filter was: %s" % only)
quit(1)
@@ -206,6 +225,11 @@ func _orient_camera_without_roll(camera: Camera3D, target_position: Vector3) ->
camera.global_basis = Basis(right, corrected_up, -forward)
func _apply_camera_pose_offsets(camera: Camera3D, yaw_offset_degrees: float, pitch_offset_degrees: float) -> void:
camera.global_rotate(Vector3.UP, deg_to_rad(yaw_offset_degrees))
camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(pitch_offset_degrees))
func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: float, metrics: Dictionary, sha256: String) -> Dictionary:
return {
"name": checkpoint.get("name", "checkpoint"),
+21 -1
View File
@@ -56,7 +56,7 @@ func _parse_arguments(raw_arguments: PackedStringArray) -> Dictionary:
parsed.self_test = true
index += 1
continue
if argument not in ["--reference", "--candidate", "--output", "--pixel-threshold", "--mean-threshold", "--changed-ratio-threshold"]:
if argument not in ["--reference", "--candidate", "--output", "--only", "--pixel-threshold", "--mean-threshold", "--changed-ratio-threshold"]:
return {"error": "unknown argument: %s" % argument}
if index + 1 >= raw_arguments.size():
return {"error": "missing value for %s" % argument}
@@ -76,10 +76,13 @@ func _compare_directories(reference_directory: String, candidate_directory: Stri
reference_directory = ProjectSettings.globalize_path(reference_directory)
candidate_directory = ProjectSettings.globalize_path(candidate_directory)
var reference_files := _reference_image_file_names(reference_directory)
var only_filter := String(options.get("only", "")).to_lower()
var results: Array[Dictionary] = []
var failed_count := 0
var missing_count := 0
for file_name in reference_files:
if not only_filter.is_empty() and not file_name.get_basename().to_lower().contains(only_filter):
continue
var reference_path := reference_directory.path_join(file_name)
var checkpoint_name := file_name.get_basename()
var candidate_file_names := _candidate_file_names(candidate_directory, checkpoint_name)
@@ -203,6 +206,7 @@ func _run_self_test() -> int:
var changed_directory := root.path_join("changed")
for directory_path in [reference_directory, identical_directory, changed_directory]:
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(directory_path))
_clear_directory_files(directory_path)
var reference_image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
reference_image.fill(Color(0.25, 0.5, 0.75, 1.0))
var changed_image := reference_image.duplicate()
@@ -225,5 +229,21 @@ func _run_self_test() -> int:
if not identical_report.passed or changed_report.passed:
push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: expected identical pass and changed failure")
return 1
if reference_image.save_jpg(reference_directory.path_join("ignored.jpg"), 1.0) != OK:
return 1
var filtered_options := options.duplicate()
filtered_options["only"] = "synthetic"
var filtered_report := _compare_directories(reference_directory, identical_directory, filtered_options)
if not filtered_report.passed or filtered_report.compared_count != 2:
push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: --only must exclude unrelated references")
return 1
print("RENDER_CHECKPOINT_DIFF SELF_TEST PASS")
return 0
func _clear_directory_files(directory_path: String) -> void:
var directory := DirAccess.open(directory_path)
if directory == null:
return
for file_name in directory.get_files():
directory.remove(file_name)
+166
View File
@@ -0,0 +1,166 @@
extends SceneTree
## Reports published scene-tree geometry around calibrated renderer cameras.
## Usage: godot --headless --path . --script res://src/tools/probe_render_camera_occluders.gd --
## [--wait 3.0] [--output user://render_camera_occluders/report.json]
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
const MAX_REPORTED_INTERSECTIONS := 20
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_camera_occluders/report.json")
var only_filter := _argument(arguments, "--only", "").to_lower()
var manifest := _load_json(MANIFEST_PATH)
var packed_scene: PackedScene = load(String(manifest.get("scene", "")))
if packed_scene == null:
push_error("CAMERA_OCCLUDER_PROBE: cannot load streaming scene")
quit(1)
return
var world := packed_scene.instantiate() as Node3D
var camera := Camera3D.new()
camera.name = "OccluderProbeCamera"
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
var checkpoint_name := String(checkpoint.get("name", "checkpoint"))
if not only_filter.is_empty() and not checkpoint_name.to_lower().contains(only_filter):
continue
var camera_position := _vector3(checkpoint.get("camera", []))
var target_position := _vector3(checkpoint.get("target", []))
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 geometry_nodes: Array[Node3D] = []
_collect_geometry_nodes(world, geometry_nodes)
var containing: Array[Dictionary] = []
var intersecting: Array[Dictionary] = []
for geometry_node in geometry_nodes:
var world_aabb := _geometry_world_aabb(geometry_node)
if world_aabb.size.is_zero_approx():
continue
var record := _geometry_record(geometry_node, world_aabb, camera_position)
if world_aabb.has_point(camera_position):
containing.append(record)
var intersection = world_aabb.intersects_segment(camera_position, target_position)
if intersection != null:
record["intersection_distance"] = camera_position.distance_to(intersection as Vector3)
intersecting.append(record)
intersecting.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return float(a.intersection_distance) < float(b.intersection_distance))
if intersecting.size() > MAX_REPORTED_INTERSECTIONS:
intersecting.resize(MAX_REPORTED_INTERSECTIONS)
var result := {
"name": checkpoint_name,
"geometry_node_count": geometry_nodes.size(),
"camera_containing_geometry": containing,
"segment_intersecting_geometry": intersecting,
}
results.append(result)
print("CAMERA_OCCLUDERS name=%s geometry=%d containing=%d segment=%d" % [
checkpoint_name, geometry_nodes.size(), containing.size(), intersecting.size()])
var report := {
"schema_version": 1,
"created_utc": Time.get_datetime_string_from_system(true, true),
"wait_seconds": wait_seconds,
"coverage": "scene_tree_only; RenderingServer RID-only instances are excluded",
"results": results,
}
if not _write_json(output_path, report):
quit(1)
return
world.queue_free()
quit(0 if not results.is_empty() else 1)
func _collect_geometry_nodes(node: Node, output: Array[Node3D]) -> void:
if node is MeshInstance3D:
var mesh_instance := node as MeshInstance3D
if mesh_instance.mesh != null and not mesh_instance.name.begins_with("TileLOD"):
output.append(mesh_instance)
elif node is MultiMeshInstance3D:
var multimesh_instance := node as MultiMeshInstance3D
if multimesh_instance.multimesh != null:
output.append(multimesh_instance)
for child in node.get_children():
_collect_geometry_nodes(child, output)
func _geometry_world_aabb(node: Node3D) -> AABB:
if node is MeshInstance3D:
return node.global_transform * (node as MeshInstance3D).mesh.get_aabb()
if node is MultiMeshInstance3D:
return node.global_transform * (node as MultiMeshInstance3D).multimesh.get_aabb()
return AABB()
func _geometry_record(node: Node3D, world_aabb: AABB, camera_position: Vector3) -> Dictionary:
var node_path := String(node.get_path())
var category := "geometry"
if node_path.contains("/M2s/") or node is MultiMeshInstance3D:
category = "m2"
elif node_path.contains("/WMOs/"):
category = "wmo"
return {
"category": category,
"node_path": node_path,
"distance_to_center": camera_position.distance_to(world_aabb.get_center()),
"aabb_position": _vector3_array(world_aabb.position),
"aabb_size": _vector3_array(world_aabb.size),
}
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 _vector3_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z]
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
+250
View File
@@ -0,0 +1,250 @@
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):
var missing_result := {
"status": "no_intersection" if ready_mesh_count > 0 else "mesh_not_ready",
"tile": "%d_%d" % [tile_coordinate.x, tile_coordinate.y],
}
missing_result.merge(_tile_runtime_diagnostic(world, String(missing_result.tile), world_position), true)
if bool(missing_result.get("quality_mesh_present", false)) or bool(missing_result.get("tile_lod_mesh_present", false)):
missing_result["status"] = "no_intersection"
missing_result.merge(_nearest_terrain_sample(world, world_position), true)
if missing_result.get("nearest_sample_distance", null) != null:
missing_result["status"] = "sampled_nearby"
missing_result["terrain_height"] = float(missing_result.nearest_sample_height)
return missing_result
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 _tile_runtime_diagnostic(world: Node3D, tile_key: String, world_position: Vector3) -> Dictionary:
var available_tiles: Dictionary = world.get("_available_tiles")
var loading_tasks: Dictionary = world.get("_tile_loading_tasks")
var tile_states: Dictionary = world.get("_tile_states")
var load_queue: Array = world.get("_tile_load_queue")
var queued_index := -1
for index in load_queue.size():
var request: Dictionary = load_queue[index]
if String(request.get("key", "")) == tile_key:
queued_index = index
break
var diagnostic := {
"available": available_tiles.has(tile_key),
"queued_index": queued_index,
"loading": loading_tasks.has(tile_key),
"state_present": tile_states.has(tile_key),
"load_queue_size": load_queue.size(),
}
if tile_states.has(tile_key):
var state: Dictionary = tile_states[tile_key]
var quality_mesh: Mesh = state.get("quality_terrain_mesh", null)
var tile_lod_mesh: Mesh = state.get("tile_lod_mesh", null)
diagnostic["quality_mesh_present"] = quality_mesh != null
diagnostic["tile_lod_mesh_present"] = tile_lod_mesh != null
diagnostic["quality_source"] = String(state.get("quality_terrain_source", ""))
diagnostic["tile_lod"] = int(state.get("tile_lod", -1))
var tile_root := state.get("root", null) as Node3D
if tile_root != null:
var local_position := tile_root.global_transform.affine_inverse() * world_position
diagnostic["tile_root_position"] = _vector3_array(tile_root.global_position)
diagnostic["probe_local_position"] = _vector3_array(local_position)
var diagnostic_mesh := quality_mesh if quality_mesh != null else tile_lod_mesh
if diagnostic_mesh != null:
var mesh_aabb := diagnostic_mesh.get_aabb()
diagnostic["mesh_aabb_position"] = _vector3_array(mesh_aabb.position)
diagnostic["mesh_aabb_size"] = _vector3_array(mesh_aabb.size)
return diagnostic
func _nearest_terrain_sample(world: Node3D, world_position: Vector3) -> Dictionary:
var tile_states: Dictionary = world.get("_tile_states")
for radius in [2.0, 5.0, 10.0, 20.0, 40.0]:
for offset in [Vector2(radius, 0.0), Vector2(-radius, 0.0), Vector2(0.0, radius), Vector2(0.0, -radius)]:
var sample_position := world_position + Vector3(offset.x, 0.0, offset.y)
var sample_tile := Vector2i(
int(floor(sample_position.x / TILE_SIZE)),
int(floor(sample_position.z / TILE_SIZE)))
var sample_key := "%d_%d" % [sample_tile.x, sample_tile.y]
if not tile_states.has(sample_key):
continue
var height_variant = _intersect_terrain_state(tile_states[sample_key], sample_position)
if height_variant != null:
return {
"nearest_sample_distance": radius,
"nearest_sample_height": float(height_variant),
"nearest_sample_tile": sample_key,
}
return {"nearest_sample_distance": null}
func _vector3_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z]
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
+5
View File
@@ -43,6 +43,7 @@
"name": "elwynn_terrain_overview",
"coverage": ["terrain"],
"camera": [16680.0, 180.0, 26220.0],
"reference_wow_camera": [-9153.334, 386.666, 180.0],
"target": [16800.0, 62.0, 26400.0],
"player": [16800.0, 58.0, 26400.0],
"time_hours": 13.0
@@ -51,6 +52,7 @@
"name": "elwynn_adt_boundary",
"coverage": ["adt_boundary"],
"camera": [17020.0, 105.0, 26590.0],
"reference_wow_camera": [-9523.334, 46.666, 105.0],
"target": [17066.666, 62.0, 26666.666],
"player": [17060.0, 58.0, 26660.0],
"time_hours": 13.0
@@ -59,6 +61,7 @@
"name": "goldshire_dense_m2",
"coverage": ["dense_m2"],
"camera": [16956.666, 150.0, 26466.666],
"reference_wow_camera": [-9400.0, 110.0, 150.0],
"target": [17015.0, 62.0, 26525.0],
"player": [17015.0, 58.0, 26525.0],
"time_hours": 13.0
@@ -67,6 +70,7 @@
"name": "goldshire_inn_large_wmo",
"coverage": ["large_wmo"],
"camera": [17013.666, 72.0, 26451.666],
"reference_wow_camera": [-9385.0, 53.0, 72.0],
"target": [17042.27, 66.0, 26530.91],
"player": [17042.27, 58.0, 26530.91],
"time_hours": 13.0
@@ -75,6 +79,7 @@
"name": "elwynn_waterfall_liquid",
"coverage": ["liquid"],
"camera": [16481.666, 190.0, 26366.666],
"reference_wow_camera": [-9300.0, 585.0, 190.0],
"target": [16518.84, 68.0, 26427.27],
"player": [16518.84, 55.0, 26427.27],
"time_hours": 13.0
@@ -0,0 +1,88 @@
extends SceneTree
## Verifies observed build 12340 camera coordinates against the current renderer
## WoW X/Y/Z to Godot X/Y/Z convention. This diagnostic does not define the
## future M01 production CoordinateMapper contract.
const MANIFEST_PATH := "res://src/tools/render_baseline_manifest.json"
const WOW_WORLD_CENTER := 17066.666
const MAXIMUM_POSITION_ERROR := 0.002
func _initialize() -> void:
var manifest := _load_manifest()
if manifest.is_empty():
quit(1)
return
var failures: Array[String] = []
var calibrated_count := 0
var maximum_round_trip_error := 0.0
for checkpoint_variant in manifest.get("checkpoints", []):
if not (checkpoint_variant is Dictionary):
continue
var checkpoint: Dictionary = checkpoint_variant
var reference_wow_camera_variant = checkpoint.get("reference_wow_camera", null)
if not (reference_wow_camera_variant is Array):
continue
var reference_wow_camera: Array = reference_wow_camera_variant
var checkpoint_name := String(checkpoint.get("name", "checkpoint"))
if reference_wow_camera.size() != 3:
failures.append("%s reference_wow_camera must contain three coordinates" % checkpoint_name)
continue
var expected_godot_camera := _array_to_vector3(checkpoint.get("camera", []))
var reference_wow_position := _array_to_vector3(reference_wow_camera)
var mapped_godot_position := _wow_to_godot(reference_wow_position)
var round_trip_wow_position := _godot_to_wow(mapped_godot_position)
var mapping_error := mapped_godot_position.distance_to(expected_godot_camera)
var round_trip_error := round_trip_wow_position.distance_to(reference_wow_position)
maximum_round_trip_error = maxf(maximum_round_trip_error, maxf(mapping_error, round_trip_error))
if mapping_error > MAXIMUM_POSITION_ERROR:
failures.append("%s maps %.6f units away from manifest camera" % [checkpoint_name, mapping_error])
if round_trip_error > MAXIMUM_POSITION_ERROR:
failures.append("%s round-trip error is %.6f" % [checkpoint_name, round_trip_error])
calibrated_count += 1
if calibrated_count < 5:
failures.append("expected at least five build 12340 camera calibrations, found %d" % calibrated_count)
if not failures.is_empty():
for failure in failures:
push_error("RENDER_COORDINATE_CALIBRATION: %s" % failure)
quit(1)
return
print("RENDER_COORDINATE_CALIBRATION PASS points=%d maximum_error=%.6f" % [calibrated_count, maximum_round_trip_error])
quit(0)
func _wow_to_godot(wow_position: Vector3) -> Vector3:
return Vector3(
WOW_WORLD_CENTER - wow_position.y,
wow_position.z,
WOW_WORLD_CENTER - wow_position.x
)
func _godot_to_wow(godot_position: Vector3) -> Vector3:
return Vector3(
WOW_WORLD_CENTER - godot_position.z,
WOW_WORLD_CENTER - godot_position.x,
godot_position.y
)
func _array_to_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 _load_manifest() -> Dictionary:
var manifest_file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
if manifest_file == null:
push_error("Cannot open renderer baseline manifest: %s" % MANIFEST_PATH)
return {}
var parsed = JSON.parse_string(manifest_file.get_as_text())
if not (parsed is Dictionary):
push_error("Renderer baseline manifest is not a JSON object")
return {}
return parsed
+1
View File
@@ -46,6 +46,7 @@ try {
Invoke-GodotStep "render-materials" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_materials.gd")
Invoke-GodotStep "m2-unique-dedupe" @("--headless", "--path", ".", "--script", "res://src/tools/verify_m2_unique_dedupe.gd")
Invoke-GodotStep "baseline-manifest" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_baseline_manifest.gd")
Invoke-GodotStep "coordinate-calibration" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_coordinate_calibration.gd")
$captureArgs = @(
"--path", ".",
@@ -0,0 +1,133 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ReferenceDirectory,
[Parameter(Mandatory = $true)]
[string]$Checkpoint,
[string]$GodotPath,
[string]$Output = "user://render_camera_pose_sweep",
[double[]]$YawOffsets = @(-10.0, 0.0, 10.0),
[double[]]$PitchOffsets = @(-10.0, 0.0, 10.0),
[double]$CameraFov = 62.0,
[int]$ViewportWidth = 1280,
[int]$ViewportHeight = 900,
[double]$WaitSeconds = 2.0,
[double]$MeasureSeconds = 0.1,
[switch]$PlanOnly
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
if (-not $GodotPath) {
$godotCommand = Get-Command godot -ErrorAction SilentlyContinue
if ($godotCommand) {
$GodotPath = $godotCommand.Source
} else {
$GodotPath = Join-Path $env:TEMP "godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe"
}
}
if (-not (Test-Path -LiteralPath $GodotPath)) {
throw "Godot executable not found: $GodotPath"
}
if (-not (Test-Path -LiteralPath $ReferenceDirectory)) {
throw "Reference directory not found: $ReferenceDirectory"
}
$invariantCulture = [Globalization.CultureInfo]::InvariantCulture
$cameraFovText = $CameraFov.ToString($invariantCulture)
$waitSecondsText = $WaitSeconds.ToString($invariantCulture)
$measureSecondsText = $MeasureSeconds.ToString($invariantCulture)
$absoluteOutput = if ($Output.StartsWith("user://")) {
Join-Path $env:APPDATA "Godot\app_userdata\OpenWC\$($Output.Substring(7))"
} else {
[IO.Path]::GetFullPath((Join-Path $repoRoot $Output))
}
$ranking = [Collections.Generic.List[object]]::new()
Push-Location $repoRoot
try {
foreach ($yawOffset in $YawOffsets) {
foreach ($pitchOffset in $PitchOffsets) {
$yawText = $yawOffset.ToString("0.###", $invariantCulture)
$pitchText = $pitchOffset.ToString("0.###", $invariantCulture)
$candidateName = "yaw_$($yawText.Replace('-', 'n').Replace('.', '_'))__pitch_$($pitchText.Replace('-', 'n').Replace('.', '_'))"
$candidateOutput = "$Output/$candidateName"
$comparisonReport = Join-Path $absoluteOutput "$candidateName.json"
if ($PlanOnly) {
$ranking.Add([pscustomobject]@{
yaw_offset_degrees = $yawOffset
pitch_offset_degrees = $pitchOffset
camera_fov_degrees = $CameraFov
candidate_directory = $candidateOutput
})
continue
}
$captureArguments = @(
"--path", ".", "--script", "res://src/tools/capture_render_checkpoints.gd", "--",
"--output", $candidateOutput, "--only", $Checkpoint, "--single-pass",
"--camera-fov", $cameraFovText,
"--camera-yaw-offset", $yawText, "--camera-pitch-offset", $pitchText,
"--viewport-width", $ViewportWidth, "--viewport-height", $ViewportHeight,
"--wait", $waitSecondsText, "--measure", $measureSecondsText
)
$captureProcess = Start-Process -FilePath $GodotPath -ArgumentList $captureArguments -Wait -PassThru
if ($captureProcess.ExitCode -ne 0) {
throw "Capture failed for yaw=$yawText pitch=$pitchText"
}
$comparisonArguments = @(
"--headless", "--path", ".", "--script", "res://src/tools/compare_render_checkpoints.gd", "--",
"--reference", $ReferenceDirectory, "--candidate", $candidateOutput,
"--only", $Checkpoint, "--output", $comparisonReport
)
$comparisonProcess = Start-Process -FilePath $GodotPath -ArgumentList $comparisonArguments -Wait -PassThru -NoNewWindow
if ($comparisonProcess.ExitCode -notin @(0, 1)) {
throw "Comparison failed for yaw=$yawText pitch=$pitchText"
}
$comparison = Get-Content -Raw -LiteralPath $comparisonReport | ConvertFrom-Json
if ($comparison.compared_count -lt 1) {
throw "No paired image was compared for yaw=$yawText pitch=$pitchText"
}
$metricResults = @($comparison.results | Where-Object { $null -ne $_.mean_perceptual_error })
if ($metricResults.Count -ne $comparison.compared_count) {
$statuses = ($comparison.results.status | Sort-Object -Unique) -join ","
throw "Comparison produced no numeric metric for yaw=$yawText pitch=$pitchText statuses=$statuses"
}
$meanError = ($metricResults | Measure-Object -Property mean_perceptual_error -Average).Average
$changedRatio = ($metricResults | Measure-Object -Property changed_pixel_ratio -Average).Average
$ranking.Add([pscustomobject]@{
yaw_offset_degrees = $yawOffset
pitch_offset_degrees = $pitchOffset
camera_fov_degrees = $CameraFov
mean_perceptual_error = $meanError
changed_pixel_ratio = $changedRatio
candidate_directory = $candidateOutput
comparison_report = $comparisonReport
})
}
}
$rankedCandidates = if ($PlanOnly) {
@($ranking)
} else {
@($ranking | Sort-Object mean_perceptual_error, changed_pixel_ratio)
}
$rankingPath = Join-Path $absoluteOutput "ranking.json"
$rankingDocument = [ordered]@{
schema_version = 1
checkpoint = $Checkpoint
reference_directory = [IO.Path]::GetFullPath($ReferenceDirectory)
viewport = @($ViewportWidth, $ViewportHeight)
candidates = $rankedCandidates
}
New-Item -ItemType Directory -Force -Path $absoluteOutput | Out-Null
$rankingDocument | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 -LiteralPath $rankingPath
$rankedCandidates | Format-Table yaw_offset_degrees, pitch_offset_degrees, mean_perceptual_error, changed_pixel_ratio
$completionKind = if ($PlanOnly) { "plan" } else { "ranking" }
Write-Output "Camera pose sweep $completionKind completed: $rankingPath"
} finally {
Pop-Location
}