Compare commits

..

10 Commits

Author SHA1 Message Date
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
sindoring d467ffee7f test(M00): calibrate paired renderer checkpoints
Work-Package: M00-QAR-VISUAL-DIFF-001
Agent: sindo-main-codex
Tests: synthetic JPG/PNG diff, manifest, coordination and documentation gates
Fidelity: five build 12340 references compared; coordinate and placement gaps recorded
2026-07-11 18:23:07 +04:00
sindoring a4f60dcb06 test(M00): add checkpoint perceptual comparison
Work-Package: M00-QAR-VISUAL-DIFF-001
Agent: sindo-main-codex
Tests: synthetic visual diff, M00 dry-run, coordination and documentation gates
Fidelity: automates paired metrics; approved 3.3.5a captures and human approval remain required
2026-07-11 09:38:40 +04:00
16 changed files with 1309 additions and 11 deletions
+4
View File
@@ -2,6 +2,10 @@
Воспроизводимый renderer baseline для текущей цели M00, regression manifest, метрики и правила сравнения описаны в [`docs/RENDER_BASELINE.md`](docs/RENDER_BASELINE.md). Baseline фиксирует текущее поведение и известные расхождения, но не заявляет parity с WoW 3.3.5a. Воспроизводимый renderer baseline для текущей цели M00, regression manifest, метрики и правила сравнения описаны в [`docs/RENDER_BASELINE.md`](docs/RENDER_BASELINE.md). Baseline фиксирует текущее поведение и известные расхождения, но не заявляет parity с WoW 3.3.5a.
Парные checkpoint PNG можно автоматически проверить через `src/tools/compare_render_checkpoints.gd`; reference-снимки оригинального клиента остаются вне Git, а автоматический tolerance не заменяет human fidelity approval.
Paired run 2026-07-11 подтвердил крупный coordinate/placement gap: некоторые server-derived camera positions оказываются под terrain или внутри WMO/rocks OpenWC. До исправления этого расхождения perceptual metrics измеряют также несовпадение композиции, а не только материалы и свет.
Цель renderer-работы в этом проекте: добиться ощущения производительности оригинального клиента WoW 3.3.5a в Godot, без видимых фризов при переходе ADT -> ADT и без постоянного отката видимых участков к низкому качеству. Цель renderer-работы в этом проекте: добиться ощущения производительности оригинального клиента WoW 3.3.5a в Godot, без видимых фризов при переходе ADT -> ADT и без постоянного отката видимых участков к низкому качеству.
Этот документ фиксирует текущее состояние рендера, сделанные оптимизации и практические правила дальнейшей работы. Этот документ фиксирует текущее состояние рендера, сделанные оптимизации и практические правила дальнейшей работы.
@@ -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-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-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`
@@ -0,0 +1,78 @@
# M00-QAR-VISUAL-DIFF-001 — Renderer checkpoint visual diff
<!-- OPENWC_CLAIM:M00-QAR-VISUAL-DIFF-001:sindo-main-codex:2026-07-13 -->
## Ownership
- Target: M00
- Program: QAR
- Owner/Agent ID: sindo-main-codex
- Branch: `work/sindo-main-codex/m00-checkpoint-diff`
- Lease expires UTC: 2026-07-13
- Integrator: milestone integrator
## Outcome
Provide a deterministic, headless perceptual comparison for paired renderer checkpoint PNGs, with machine-readable results and synthetic regression coverage.
## Non-goals
- Supplying or committing original-client screenshots.
- Claiming visual parity or replacing human approval.
- Image registration, camera correction, or temporal animation matching.
## Paths
- Exclusive: `src/tools/compare_render_checkpoints.gd`
- Shared/hotspots: `src/tools/render_baseline_manifest.json`, `tools/run_render_baseline.ps1`, `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`, `RENDER.md`
- Generated/ignored: comparison reports, diff PNGs, original-client captures
## Contracts and data
- Public API/events: headless CLI arguments and JSON report documented in renderer baseline docs
- Schema/format version: comparison report schema 1; baseline manifest schema remains 1
- Migration/compatibility: additive manifest budget fields
- Consumers: developers and CI
## Dependencies
- Requires: Godot `Image` API and M00 checkpoint naming contract
- Blocks: automated portion of original-client paired comparison
- External state: approved build 12340 screenshots remain unavailable
## Verification
- Commands: headless self-test; manifest verification; documentation and coordination gates
- Fixtures: tool-generated identical and changed 2x2 PNGs
- Fidelity evidence: algorithm is deterministic; real-client evidence remains a manual/external input
- Performance budget: offline checkpoint operation, linear in pixel count
## Documentation deliverables
- Inline public API docs: CLI usage in script header
- Module specification: update renderer verification and source map
- Data-flow diagram: update baseline documentation
- Sequence/state/dependency diagrams: not stateful or asynchronous; not applicable
- Source map/status updates: renderer module and RENDER notes
## Simplicity and naming
- Important names introduced: `mean_perceptual_error`, `changed_pixel_ratio`
- Simplest considered solution: direct Godot Image iteration with no dependency
- Rejected complexity/abstractions: external image library, SSIM window pipeline, image registration
- Unavoidable complexity and justification: sRGB linearization avoids comparing encoded channel values directly
- Measured optimization evidence: not required for offline seven-checkpoint comparison
## Status
- State: ready
- Done: comparator, runner integration, manifest tolerance contract, synthetic regression, five local build 12340 references, three viewpoint calibrations, corrected visual capture and first paired report
- Next: integrator review; coordinate/placement gap should be resolved before tolerance calibration
- Blocked by: real paired screenshots only for final human fidelity approval
## Handoff
- Commit: branch HEAD
- Results: ten pairs compared with no missing candidates; mean error 0.0707..0.1746 and changed ratio 0.5504..0.8187; human review identified under-terrain/inside-geometry camera mismatches
- Remaining risks: coordinate/placement mismatch blocks meaningful tolerance calibration; synthetic animation, dusk and full human approval remain incomplete
- Documentation updated: `docs/RENDER_BASELINE.md`, `docs/modules/world-renderer.md`, `RENDER.md`
+106 -1
View File
@@ -24,6 +24,46 @@
- `<checkpoint>__cold_process.png` — первый визит в текущем процессе; - `<checkpoint>__cold_process.png` — первый визит в текущем процессе;
- `<checkpoint>__warm_revisit.png` — повторный визит в том же процессе после обхода контрольных точек. - `<checkpoint>__warm_revisit.png` — повторный визит в том же процессе после обхода контрольных точек.
## Парное визуальное сравнение
Одобренные снимки оригинального клиента хранятся вне Git. Если каталог с ними доступен локально, единый запуск может сразу сравнить PNG с одинаковыми именами:
```powershell
.\tools\run_render_baseline.ps1 `
-ReferenceCheckpointDirectory 'D:\private-fixtures\wow-3.3.5a-checkpoints'
```
Результат записывается в `user://render_baseline/visual_comparison.json`. Другой путь задаётся через `-VisualComparisonReport`. `-DryRun` несовместим с визуальным сравнением, потому что не создаёт PNG. Runner сохраняет обычный performance capture в нормативном `1280×900`, затем отдельно снимает visual candidates в `2560×1440`; разрешение можно переопределить через `-VisualComparisonWidth` и `-VisualComparisonHeight`, не меняя performance baseline.
Отдельный запуск сравнения:
```powershell
godot --headless --path . `
--script res://src/tools/compare_render_checkpoints.gd -- `
--reference 'D:\private-fixtures\wow-3.3.5a-checkpoints' `
--candidate "$env:APPDATA\Godot\app_userdata\OpenWC\render_baseline" `
--output user://render_baseline/visual_comparison.json
```
Reference-каталог принимает JPG/JPEG/PNG оригинального клиента с именем checkpoint, например `goldshire_dense_m2.jpg`. Диагностические кадры с префиксом `diagnostic_` игнорируются. Один reference автоматически сопоставляется с `<checkpoint>__cold_process.png` и `<checkpoint>__warm_revisit.png` OpenWC.
Сравнение переводит sRGB в linear color space, вычисляет взвешенную ошибку яркости/цвета для каждого пикселя, среднюю ошибку кадра и долю пикселей выше локального порога. Нормативные defaults находятся в `comparison_budgets` manifest: mean error `0.015`, changed-pixel ratio `0.01`, pixel error threshold `0.04`. Любое превышение, несовпадение размеров, отсутствие пары или пустой reference-каталог возвращает ненулевой exit code.
Автоматический pass не является доказательством parity: animation phase, weather, camera alignment и intentional gaps требуют human approval. Для проверки алгоритма без proprietary данных используется:
```powershell
godot --headless --path . --script res://src/tools/compare_render_checkpoints.gd -- --self-test
```
```mermaid
flowchart LR
R[Private approved reference PNGs] --> C[Checkpoint comparator]
N[New baseline PNGs] --> C
M[Manifest tolerance defaults] --> C
C --> J[JSON metrics and pass/fail]
J --> H[Human fidelity approval]
```
`cold_process` не означает очищенный Windows filesystem cache. Поле `cache_state` и полный inventory обязаны интерпретироваться вместе с результатом. Удаление или принудительная пересборка cache не входит в baseline-команду. Для отдельного запуска после контролируемой очистки безопасного локального cache следует передать осмысленную метку, например `-CacheState rebuilt-clean`; proprietary source assets команда не изменяет. `cold_process` не означает очищенный Windows filesystem cache. Поле `cache_state` и полный inventory обязаны интерпретироваться вместе с результатом. Удаление или принудительная пересборка cache не входит в baseline-команду. Для отдельного запуска после контролируемой очистки безопасного локального cache следует передать осмысленную метку, например `-CacheState rebuilt-clean`; proprietary source assets команда не изменяет.
## Контрольные точки и детерминизм ## Контрольные точки и детерминизм
@@ -38,7 +78,7 @@
- синтетический native-animation probe `GryphonRoost01.m2`; - синтетический native-animation probe `GryphonRoost01.m2`;
- тот же Elwynn overview в 19:00 для sky transition. - тот же Elwynn overview в 19:00 для sky transition.
Зафиксированы camera/target/player position, viewport, FOV, quality preset и время мира. Для temporal shader/animation кадров SHA-256 служит идентификатором артефакта, но не pass-критерием: будущая автоматическая визуальная проверка должна использовать perceptual diff и согласованный tolerance. Синтетический animation probe существует только внутри capture tool и не меняет runtime streaming scene. Зафиксированы camera/target/player position, viewport, FOV, quality preset и время мира. Для temporal shader/animation кадров SHA-256 служит идентификатором артефакта, но не самостоятельным pass-критерием: автоматическая визуальная проверка использует perceptual diff и согласованный tolerance. Синтетический animation probe существует только внутри capture tool и не меняет runtime streaming scene.
## Метрики и budget ## Метрики и budget
@@ -74,3 +114,68 @@ Baseline пока не имеет approved парных кадров ориги
- возможная смена D3D12 на Vulkan при ошибке descriptor heap — фактический backend всегда берётся из `report.json`. - возможная смена D3D12 на Vulkan при ошибке descriptor heap — фактический backend всегда берётся из `report.json`.
Парное сравнение с клиентом 3.3.5a должно использовать те же map position, local time и weather. Каждый gap регистрируется как terrain/material, placement, animation, lighting, liquid или culling; baseline сам по себе gap не закрывает. Парное сравнение с клиентом 3.3.5a должно использовать те же map position, local time и weather. Каждый gap регистрируется как terrain/material, placement, animation, lighting, liquid или culling; baseline сам по себе gap не закрывает.
Локальная сессия build 12340 от 2026-07-11 откалибровала три непригодные исходные позиции: dense-M2 camera была полностью закрыта кронами, large-WMO camera находилась у дымохода кузни, liquid camera попадала под terrain оригинального клиента. Manifest теперь содержит проверенные replacement camera positions. Пять approved локальных reference JPG хранятся вне Git; synthetic animated probe и dusk checkpoint всё ещё требуют отдельной процедуры.
Первый 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.
+11 -3
View File
@@ -7,7 +7,7 @@
| Status | Partial | | Status | Partial |
| Target/work package | M00 active; декомпозиция M01–M03 | | Target/work package | M00 active; декомпозиция M01–M03 |
| Owners | Renderer workstream / milestone integrator | | Owners | Renderer workstream / milestone integrator |
| Last verified | `93bfe11` + M00 worktree baseline, 2026-07-10 | | Last verified | M00 visual-diff worktree, 2026-07-11 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose ## Purpose
@@ -192,9 +192,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
## Verification ## Verification
- Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest. - Unit/contract tests: material mapping, unique-ID dedupe, placement probes, baseline manifest, five-point coordinate calibration, synthetic perceptual checkpoint diff.
- Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints. - Integration/E2E: Eastern Kingdoms/Kalimdor streaming scenes and seven cold/warm checkpoints.
- Fidelity evidence: current fixed-position captures exist; approved original-client paired screenshots are still missing. - 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. - Performance budgets: M00 report records cold/warm p95 and max hitch; no final acceptance threshold yet.
- Manual diagnostics: [`../RENDER_BASELINE.md`](../RENDER_BASELINE.md) and [`../../RENDER.md`](../../RENDER.md). - Manual diagnostics: [`../RENDER_BASELINE.md`](../RENDER_BASELINE.md) and [`../../RENDER.md`](../../RENDER.md).
@@ -222,6 +222,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
- Monolithic streamer mixes planning, jobs, caches and presentation. - Monolithic streamer mixes planning, jobs, caches and presentation.
- Direct camera path remains until M01/M03. - Direct camera path remains until M01/M03.
- Original-client paired fidelity evidence incomplete. - 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 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.
- D3D12 descriptor and shutdown RID/resource issues remain. - D3D12 descriptor and shutdown RID/resource issues remain.
- M2/WMO/material/particle/ribbon/portal parity incomplete. - M2/WMO/material/particle/ribbon/portal parity incomplete.
- Public API is mostly exported configuration rather than stable contracts. - Public API is mostly exported configuration rather than stable contracts.
@@ -239,6 +242,11 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| `src/native/src/*_loader.cpp` | Native binary parsing | | `src/native/src/*_loader.cpp` | Native binary parsing |
| `src/tools/build_*cache.gd`, `src/tools/bake_*cache.gd` | Offline cache generation | | `src/tools/build_*cache.gd`, `src/tools/bake_*cache.gd` | Offline cache generation |
| `tools/run_render_baseline.ps1` | Unified M00 baseline runner | | `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 |
| `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 ## Related decisions and references
+13 -3
View File
@@ -25,7 +25,9 @@ func _capture_async() -> void:
var headless := DisplayServer.get_name().to_lower() == "headless" var headless := DisplayServer.get_name().to_lower() == "headless"
var dry_run := args.has("--dry-run") or headless var dry_run := args.has("--dry-run") or headless
var viewport: Array = manifest.get("viewport", [1280, 900]) var viewport: Array = manifest.get("viewport", [1280, 900])
get_root().size = Vector2i(maxi(16, int(viewport[0])), maxi(16, int(viewport[1]))) var viewport_width := int(_arg(args, "--viewport-width", str(viewport[0])))
var viewport_height := int(_arg(args, "--viewport-height", str(viewport[1])))
get_root().size = Vector2i(maxi(16, viewport_width), maxi(16, viewport_height))
var abs_output_dir := ProjectSettings.globalize_path(output_dir) var abs_output_dir := ProjectSettings.globalize_path(output_dir)
DirAccess.make_dir_recursive_absolute(abs_output_dir) DirAccess.make_dir_recursive_absolute(abs_output_dir)
@@ -107,7 +109,7 @@ func _capture_async() -> void:
probe.scale = Vector3.ONE * float(checkpoint.get("probe_scale", 1.0)) probe.scale = Vector3.ONE * float(checkpoint.get("probe_scale", 1.0))
camera.global_position = _vector3(checkpoint.get("camera", [0.0, 0.0, 0.0])) camera.global_position = _vector3(checkpoint.get("camera", [0.0, 0.0, 0.0]))
camera.look_at(_vector3(checkpoint.get("target", [0.0, 0.0, 0.0])), Vector3.UP) _orient_camera_without_roll(camera, _vector3(checkpoint.get("target", [0.0, 0.0, 0.0])))
if player != null: if player != null:
player.global_position = _vector3(checkpoint.get("player", checkpoint.get("target", [0.0, 0.0, 0.0]))) 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))) _set_sky_time(world, float(checkpoint.get("time_hours", 13.0)))
@@ -135,7 +137,6 @@ func _capture_async() -> void:
await RenderingServer.frame_post_draw await RenderingServer.frame_post_draw
var image := get_root().get_texture().get_image() var image := get_root().get_texture().get_image()
image.flip_y()
var file_name := "%s__%s.png" % [checkpoint_name, pass_name] var file_name := "%s__%s.png" % [checkpoint_name, pass_name]
var abs_path := abs_output_dir.path_join(file_name) var abs_path := abs_output_dir.path_join(file_name)
var err := image.save_png(abs_path) var err := image.save_png(abs_path)
@@ -196,6 +197,15 @@ func _measure_frames(seconds: float, world: Node) -> Dictionary:
} }
func _orient_camera_without_roll(camera: Camera3D, target_position: Vector3) -> void:
var forward := (target_position - camera.global_position).normalized()
var right := forward.cross(Vector3.UP).normalized()
if right.is_zero_approx():
right = Vector3.RIGHT
var corrected_up := right.cross(forward).normalized()
camera.global_basis = Basis(right, corrected_up, -forward)
func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: float, metrics: Dictionary, sha256: String) -> Dictionary: func _result_record(checkpoint: Dictionary, pass_name: String, load_time_ms: float, metrics: Dictionary, sha256: String) -> Dictionary:
return { return {
"name": checkpoint.get("name", "checkpoint"), "name": checkpoint.get("name", "checkpoint"),
+229
View File
@@ -0,0 +1,229 @@
extends SceneTree
## Compares original-client JPG/PNG references with OpenWC checkpoint PNGs.
## Usage: godot --headless --path . --script res://src/tools/compare_render_checkpoints.gd --
## --reference <directory> --candidate <directory> [--output <report.json>]
## [--pixel-threshold 0.04] [--mean-threshold 0.015] [--changed-ratio-threshold 0.01]
## Use --self-test for a synthetic regression check that writes only under user://.
const REPORT_SCHEMA_VERSION := 1
const DEFAULT_PIXEL_THRESHOLD := 0.04
const DEFAULT_MEAN_THRESHOLD := 0.015
const DEFAULT_CHANGED_RATIO_THRESHOLD := 0.01
func _initialize() -> void:
var arguments := _parse_arguments(OS.get_cmdline_user_args())
if arguments.has("error"):
push_error("RENDER_CHECKPOINT_DIFF: %s" % arguments.error)
quit(2)
return
if arguments.get("self_test", false):
quit(_run_self_test())
return
var reference_directory := String(arguments.get("reference", ""))
var candidate_directory := String(arguments.get("candidate", ""))
if reference_directory.is_empty() or candidate_directory.is_empty():
push_error("RENDER_CHECKPOINT_DIFF: --reference and --candidate are required")
quit(2)
return
var report := _compare_directories(reference_directory, candidate_directory, arguments)
var output_path := String(arguments.get("output", ""))
if not output_path.is_empty() and not _write_report(output_path, report):
quit(2)
return
print("RENDER_CHECKPOINT_DIFF %s compared=%d failed=%d missing=%d" % [
"PASS" if report.passed else "FAIL",
report.compared_count,
report.failed_count,
report.missing_count,
])
quit(0 if report.passed else 1)
func _parse_arguments(raw_arguments: PackedStringArray) -> Dictionary:
var parsed := {
"pixel_threshold": DEFAULT_PIXEL_THRESHOLD,
"mean_threshold": DEFAULT_MEAN_THRESHOLD,
"changed_ratio_threshold": DEFAULT_CHANGED_RATIO_THRESHOLD,
}
var index := 0
while index < raw_arguments.size():
var argument := raw_arguments[index]
if argument == "--self-test":
parsed.self_test = true
index += 1
continue
if argument not in ["--reference", "--candidate", "--output", "--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}
var key := argument.trim_prefix("--").replace("-", "_")
var value := raw_arguments[index + 1]
if key.ends_with("threshold"):
if not value.is_valid_float():
return {"error": "%s requires a number" % argument}
parsed[key] = float(value)
else:
parsed[key] = value
index += 2
return parsed
func _compare_directories(reference_directory: String, candidate_directory: String, options: Dictionary) -> Dictionary:
reference_directory = ProjectSettings.globalize_path(reference_directory)
candidate_directory = ProjectSettings.globalize_path(candidate_directory)
var reference_files := _reference_image_file_names(reference_directory)
var results: Array[Dictionary] = []
var failed_count := 0
var missing_count := 0
for file_name in reference_files:
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)
if candidate_file_names.is_empty():
results.append({"checkpoint": checkpoint_name, "reference": file_name, "status": "missing_candidate"})
missing_count += 1
continue
for candidate_file_name in candidate_file_names:
var candidate_path := candidate_directory.path_join(candidate_file_name)
var comparison := _compare_images(reference_path, candidate_path, options)
comparison.checkpoint = checkpoint_name
comparison.reference = file_name
comparison.candidate = candidate_file_name
results.append(comparison)
if comparison.status != "passed":
failed_count += 1
var no_reference_images := reference_files.is_empty()
return {
"schema_version": REPORT_SCHEMA_VERSION,
"passed": not no_reference_images and failed_count == 0 and missing_count == 0,
"reference_directory": reference_directory,
"candidate_directory": candidate_directory,
"pixel_threshold": options.pixel_threshold,
"mean_threshold": options.mean_threshold,
"changed_ratio_threshold": options.changed_ratio_threshold,
"compared_count": results.size() - missing_count,
"failed_count": failed_count,
"missing_count": missing_count,
"no_reference_images": no_reference_images,
"results": results,
}
func _compare_images(reference_path: String, candidate_path: String, options: Dictionary) -> Dictionary:
var reference_image := Image.load_from_file(reference_path)
var candidate_image := Image.load_from_file(candidate_path)
if reference_image == null or candidate_image == null or reference_image.is_empty() or candidate_image.is_empty():
return {"status": "load_error"}
if reference_image.get_size() != candidate_image.get_size():
return {
"status": "size_mismatch",
"reference_size": [reference_image.get_width(), reference_image.get_height()],
"candidate_size": [candidate_image.get_width(), candidate_image.get_height()],
}
var error_sum := 0.0
var changed_pixel_count := 0
var pixel_count := reference_image.get_width() * reference_image.get_height()
for y in reference_image.get_height():
for x in reference_image.get_width():
var perceptual_error := _perceptual_color_error(reference_image.get_pixel(x, y), candidate_image.get_pixel(x, y))
error_sum += perceptual_error
if perceptual_error > float(options.pixel_threshold):
changed_pixel_count += 1
var mean_perceptual_error := error_sum / float(pixel_count)
var changed_pixel_ratio := float(changed_pixel_count) / float(pixel_count)
var passed := mean_perceptual_error <= float(options.mean_threshold) and changed_pixel_ratio <= float(options.changed_ratio_threshold)
return {
"status": "passed" if passed else "threshold_exceeded",
"width": reference_image.get_width(),
"height": reference_image.get_height(),
"mean_perceptual_error": mean_perceptual_error,
"changed_pixel_ratio": changed_pixel_ratio,
}
func _perceptual_color_error(reference_color: Color, candidate_color: Color) -> float:
var reference_linear := reference_color.srgb_to_linear()
var candidate_linear := candidate_color.srgb_to_linear()
var red_delta := absf(reference_linear.r - candidate_linear.r)
var green_delta := absf(reference_linear.g - candidate_linear.g)
var blue_delta := absf(reference_linear.b - candidate_linear.b)
var alpha_delta := absf(reference_linear.a - candidate_linear.a)
return 0.2126 * red_delta + 0.7152 * green_delta + 0.0722 * blue_delta + 0.25 * alpha_delta
func _reference_image_file_names(directory_path: String) -> Array[String]:
var directory := DirAccess.open(directory_path)
if directory == null:
return []
var file_names: Array[String] = []
for file_name in directory.get_files():
var lower_name := file_name.to_lower()
if not lower_name.begins_with("diagnostic_") and (lower_name.ends_with(".png") or lower_name.ends_with(".jpg") or lower_name.ends_with(".jpeg")):
file_names.append(file_name)
file_names.sort()
return file_names
func _candidate_file_names(candidate_directory: String, checkpoint_name: String) -> Array[String]:
var candidates: Array[String] = []
for pass_name in ["cold_process", "warm_revisit"]:
var file_name := "%s__%s.png" % [checkpoint_name, pass_name]
if FileAccess.file_exists(candidate_directory.path_join(file_name)):
candidates.append(file_name)
if candidates.is_empty():
var exact_file_name := "%s.png" % checkpoint_name
if FileAccess.file_exists(candidate_directory.path_join(exact_file_name)):
candidates.append(exact_file_name)
return candidates
func _write_report(output_path: String, report: Dictionary) -> bool:
var absolute_path := ProjectSettings.globalize_path(output_path)
var error := DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir())
if error != OK:
push_error("RENDER_CHECKPOINT_DIFF: cannot create report directory: %s" % error_string(error))
return false
var report_file := FileAccess.open(absolute_path, FileAccess.WRITE)
if report_file == null:
push_error("RENDER_CHECKPOINT_DIFF: cannot write report: %s" % output_path)
return false
report_file.store_string(JSON.stringify(report, " "))
return true
func _run_self_test() -> int:
var root := "user://render_checkpoint_diff_self_test"
var reference_directory := root.path_join("reference")
var identical_directory := root.path_join("identical")
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))
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()
changed_image.set_pixel(0, 0, Color.WHITE)
if reference_image.save_jpg(reference_directory.path_join("synthetic.jpg"), 1.0) != OK:
push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: cannot save JPG reference")
return 1
for pass_name in ["cold_process", "warm_revisit"]:
if reference_image.save_png(identical_directory.path_join("synthetic__%s.png" % pass_name)) != OK:
return 1
if changed_image.save_png(changed_directory.path_join("synthetic__%s.png" % pass_name)) != OK:
return 1
var options := {
"pixel_threshold": DEFAULT_PIXEL_THRESHOLD,
"mean_threshold": DEFAULT_MEAN_THRESHOLD,
"changed_ratio_threshold": DEFAULT_CHANGED_RATIO_THRESHOLD,
}
var identical_report := _compare_directories(reference_directory, identical_directory, options)
var changed_report := _compare_directories(reference_directory, changed_directory, options)
if not identical_report.passed or changed_report.passed:
push_error("RENDER_CHECKPOINT_DIFF SELF_TEST: expected identical pass and changed failure")
return 1
print("RENDER_CHECKPOINT_DIFF SELF_TEST PASS")
return 0
+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
+13 -4
View File
@@ -5,6 +5,7 @@
"map": "Azeroth", "map": "Azeroth",
"quality_preset": "High", "quality_preset": "High",
"viewport": [1280, 900], "viewport": [1280, 900],
"fidelity_comparison_viewport": [2560, 1440],
"camera_fov": 62.0, "camera_fov": 62.0,
"default_wait_seconds": 8.0, "default_wait_seconds": 8.0,
"default_measure_seconds": 3.0, "default_measure_seconds": 3.0,
@@ -14,7 +15,10 @@
"max_hitch_ms_max_regression_percent": 10.0, "max_hitch_ms_max_regression_percent": 10.0,
"load_time_ms_max_regression_percent": 10.0, "load_time_ms_max_regression_percent": 10.0,
"memory_bytes_max_regression_percent": 10.0, "memory_bytes_max_regression_percent": 10.0,
"visual_diff": "perceptual comparison required; exact PNG hashes are evidence, not an animated-scene pass criterion" "visual_mean_perceptual_error_max": 0.015,
"visual_changed_pixel_ratio_max": 0.01,
"visual_pixel_error_threshold": 0.04,
"visual_diff": "automated thresholds require human approval; exact PNG hashes are evidence, not an animated-scene pass criterion"
}, },
"required_coverage": [ "required_coverage": [
"terrain", "terrain",
@@ -39,6 +43,7 @@
"name": "elwynn_terrain_overview", "name": "elwynn_terrain_overview",
"coverage": ["terrain"], "coverage": ["terrain"],
"camera": [16680.0, 180.0, 26220.0], "camera": [16680.0, 180.0, 26220.0],
"reference_wow_camera": [-9153.334, 386.666, 180.0],
"target": [16800.0, 62.0, 26400.0], "target": [16800.0, 62.0, 26400.0],
"player": [16800.0, 58.0, 26400.0], "player": [16800.0, 58.0, 26400.0],
"time_hours": 13.0 "time_hours": 13.0
@@ -47,6 +52,7 @@
"name": "elwynn_adt_boundary", "name": "elwynn_adt_boundary",
"coverage": ["adt_boundary"], "coverage": ["adt_boundary"],
"camera": [17020.0, 105.0, 26590.0], "camera": [17020.0, 105.0, 26590.0],
"reference_wow_camera": [-9523.334, 46.666, 105.0],
"target": [17066.666, 62.0, 26666.666], "target": [17066.666, 62.0, 26666.666],
"player": [17060.0, 58.0, 26660.0], "player": [17060.0, 58.0, 26660.0],
"time_hours": 13.0 "time_hours": 13.0
@@ -54,7 +60,8 @@
{ {
"name": "goldshire_dense_m2", "name": "goldshire_dense_m2",
"coverage": ["dense_m2"], "coverage": ["dense_m2"],
"camera": [16835.0, 135.0, 26435.0], "camera": [16956.666, 150.0, 26466.666],
"reference_wow_camera": [-9400.0, 110.0, 150.0],
"target": [17015.0, 62.0, 26525.0], "target": [17015.0, 62.0, 26525.0],
"player": [17015.0, 58.0, 26525.0], "player": [17015.0, 58.0, 26525.0],
"time_hours": 13.0 "time_hours": 13.0
@@ -62,7 +69,8 @@
{ {
"name": "goldshire_inn_large_wmo", "name": "goldshire_inn_large_wmo",
"coverage": ["large_wmo"], "coverage": ["large_wmo"],
"camera": [16942.0, 82.0, 26503.0], "camera": [17013.666, 72.0, 26451.666],
"reference_wow_camera": [-9385.0, 53.0, 72.0],
"target": [17042.27, 66.0, 26530.91], "target": [17042.27, 66.0, 26530.91],
"player": [17042.27, 58.0, 26530.91], "player": [17042.27, 58.0, 26530.91],
"time_hours": 13.0 "time_hours": 13.0
@@ -70,7 +78,8 @@
{ {
"name": "elwynn_waterfall_liquid", "name": "elwynn_waterfall_liquid",
"coverage": ["liquid"], "coverage": ["liquid"],
"camera": [16445.0, 125.0, 26295.0], "camera": [16481.666, 190.0, 26366.666],
"reference_wow_camera": [-9300.0, 585.0, 190.0],
"target": [16518.84, 68.0, 26427.27], "target": [16518.84, 68.0, 26427.27],
"player": [16518.84, 55.0, 26427.27], "player": [16518.84, 55.0, 26427.27],
"time_hours": 13.0 "time_hours": 13.0
@@ -20,6 +20,8 @@ func _initialize() -> void:
_expect(int(manifest.get("schema_version", 0)) == 1, "manifest schema must be version 1", failures) _expect(int(manifest.get("schema_version", 0)) == 1, "manifest schema must be version 1", failures)
_expect(String(manifest.get("profile", "")) == "Blizzlike335", "manifest profile must be Blizzlike335", failures) _expect(String(manifest.get("profile", "")) == "Blizzlike335", "manifest profile must be Blizzlike335", failures)
_expect(ResourceLoader.exists(String(manifest.get("scene", ""))), "streaming scene must exist", failures) _expect(ResourceLoader.exists(String(manifest.get("scene", ""))), "streaming scene must exist", failures)
var fidelity_viewport: Array = manifest.get("fidelity_comparison_viewport", [])
_expect(fidelity_viewport.size() == 2 and int(fidelity_viewport[0]) > 0 and int(fidelity_viewport[1]) > 0, "fidelity comparison viewport must contain positive width and height", failures)
var covered := {} var covered := {}
var names := {} var names := {}
@@ -42,6 +44,11 @@ func _initialize() -> void:
var required := String(required_variant) var required := String(required_variant)
_expect(covered.has(required), "missing checkpoint coverage: %s" % required, failures) _expect(covered.has(required), "missing checkpoint coverage: %s" % required, failures)
var comparison_budgets: Dictionary = manifest.get("comparison_budgets", {})
for threshold_name in ["visual_mean_perceptual_error_max", "visual_changed_pixel_ratio_max", "visual_pixel_error_threshold"]:
var threshold := float(comparison_budgets.get(threshold_name, -1.0))
_expect(threshold >= 0.0 and threshold <= 1.0, "%s must be between 0 and 1" % threshold_name, failures)
var contract: Dictionary = manifest.get("cache_contract", {}) var contract: Dictionary = manifest.get("cache_contract", {})
var actual_versions := { var actual_versions := {
"baked_terrain": STREAMING_LOADER.REQUIRED_BAKED_TILE_FORMAT_VERSION, "baked_terrain": STREAMING_LOADER.REQUIRED_BAKED_TILE_FORMAT_VERSION,
@@ -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
+32
View File
@@ -5,6 +5,10 @@ param(
[string]$CacheState = "existing", [string]$CacheState = "existing",
[double]$WaitSeconds = 8.0, [double]$WaitSeconds = 8.0,
[double]$MeasureSeconds = 3.0, [double]$MeasureSeconds = 3.0,
[string]$ReferenceCheckpointDirectory,
[string]$VisualComparisonReport = "user://render_baseline/visual_comparison.json",
[int]$VisualComparisonWidth = 2560,
[int]$VisualComparisonHeight = 1440,
[switch]$DryRun [switch]$DryRun
) )
@@ -42,6 +46,7 @@ try {
Invoke-GodotStep "render-materials" @("--headless", "--path", ".", "--script", "res://src/tools/verify_render_materials.gd") 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 "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 "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 = @( $captureArgs = @(
"--path", ".", "--path", ".",
@@ -57,6 +62,33 @@ try {
$captureArgs = @("--headless") + $captureArgs + @("--dry-run") $captureArgs = @("--headless") + $captureArgs + @("--dry-run")
} }
Invoke-GodotStep "capture" $captureArgs Invoke-GodotStep "capture" $captureArgs
if ($ReferenceCheckpointDirectory) {
if ($DryRun) {
throw "Reference checkpoint comparison requires PNG capture; remove -DryRun"
}
$visualCandidateOutput = "$Output/visual_comparison_candidates"
$visualCaptureArgs = @(
"--path", ".",
"--script", "res://src/tools/capture_render_checkpoints.gd",
"--",
"--output", $visualCandidateOutput,
"--cache-state", $CacheState,
"--revision", $revision,
"--wait", $WaitSeconds.ToString([Globalization.CultureInfo]::InvariantCulture),
"--measure", $MeasureSeconds.ToString([Globalization.CultureInfo]::InvariantCulture),
"--viewport-width", $VisualComparisonWidth.ToString(),
"--viewport-height", $VisualComparisonHeight.ToString()
)
Invoke-GodotStep "visual-capture" $visualCaptureArgs
Invoke-GodotStep "visual-comparison" @(
"--headless", "--path", ".",
"--script", "res://src/tools/compare_render_checkpoints.gd",
"--",
"--reference", $ReferenceCheckpointDirectory,
"--candidate", $visualCandidateOutput,
"--output", $VisualComparisonReport
)
}
Write-Host "Renderer baseline completed. Report: $Output/report.json" Write-Host "Renderer baseline completed. Report: $Output/report.json"
} finally { } finally {
Pop-Location Pop-Location